- 前端
- UI组件
【免费下载链接】primitives
Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.
本文以@radix-ui/react-context-menu的 CHANGELOG 为核心脉络,结合仓库内 组件实现、单元测试、Storybook 示例、SSR 测试页面 与 Playwright 端到端测试,系统梳理该组件从 2.2.8 到 2.3.7 的版本演进、核心 API(尤其是受控open)的正确用法与底层原理。读完你将掌握:如何安全使用受控open、如何理解并规避树摇优化/条件导出带来的行为差异、以及这些变更背后对应的源码级证据。
版本路线图:从 2.2.8 到 2.3.7 我们得到了什么
CHANGELOG 记录了两个版本区间:2.2.8至2.2.16(小版本迭代,主要聚焦依赖升级与基础修复)与2.3.0至2.3.7(功能增强与工程化改进)。当前仓库中 package.json 声明版本号为2.3.7,依赖了五个工作区包:
| 依赖 | 2.3.7 中的版本 |
|---|---|
@radix-ui/primitive | 1.1.7 |
@radix-ui/react-context | 1.2.2 |
@radix-ui/react-menu | 2.1.24 |
@radix-ui/react-primitive | 2.1.10 |
@radix-ui/react-use-controllable-state | 1.2.6 |
2.3.x 的三个关键节点
2.3.0:引入受控open(最重要的功能变更)
- 为
ContextMenu.Root新增受控openprop,官方用途是读取打开状态与编程式关闭菜单; - 明确不建议用
open编程式打开菜单,因为菜单定位依赖用户交互(右键/长按)产生的位置; - 同步修复长按触屏场景下子菜单在重新打开后仍保持展开的问题,并为所有 package.json 增加
repository.directory。
2.3.3:两个行为修复
- 修复菜单已打开时再次触发(例如在另一位置右键),
ContextMenu未重新锚定到最新指针位置的问题; - 修复菜单项、Tab 触发器、工具栏链接、Select 项拦截来自可聚焦后代的
Space/Enter键的问题。
2.3.4:树摇(tree-shaking)优化
- 组件部分标记
/* @__PURE__ */并使用命名渲染函数替代Component.displayName = ...赋值,使打包器能够删除未使用的组件; - 通过条件导出(conditional exports)将开发期警告从生产构建中剔除。
2.3.5 / 2.3.6 / 2.3.7:发布链路与兼容性收尾
- 2.3.5 重新经 CI 发布以附加 provenance(软件来源证明)签名;
- 2.3.7 回退了一些会与 React Server Components 产生兼容性问题的破坏性变更(reverted breaking changes that caused compatibility issues with RSC),属于一次"安全网"补丁。
受控 open:读取与关闭的正确姿势
源码中的定位逻辑
在 context-menu.tsx 中,ContextMenu组件通过useControllableState管理开关状态:
const [open, setOpen] = useControllableState({ prop: openProp, defaultProp: false, onChange: onOpenChange, caller: CONTEXT_MENU_NAME, });注意ContextMenu没有defaultOpen(组件接口只声明open/onOpenChange/dir/modal),因此open={false}是唯一合法的受控默认值。触发器的打开逻辑在 ContextMenuTrigger:右键或长按时记录指针坐标并调用context.onOpenChange(true),同时用React.useMemo构建一个"虚拟锚点"(getBoundingClientRect返回以指针位置为中心的零尺寸矩形)供 popper 定位。
const handleOpen = (event: React.MouseEvent | React.PointerEvent) => { context.hasInteractedRef.current = true; setPoint({ x: event.clientX, y: event.clientY }); context.onOpenChange(true); };开发期警告:为什么不能"编程式打开"
源码在开发环境下监听openProp:
if (openProp === true && !hasInteractedRef.current && !hasWarnedRef.current) { console.warn( 'ContextMenu: The `open` prop has been set to `true` before the user has interacted with the trigger, so its position is indeterminate. This is likely unintended and will result in the menu being anchored to the top-left corner of the viewport.', ); }含义很明确:受控状态只保证你能读取状态与关闭菜单;若在用户交互之前把open设为true,菜单会锚定到视口左上角(坐标 0,0),因为从未记录过指针位置。对应测试见 context-menu.test.tsx,其中open={true}的用例会触发一次警告,而未交互的用户触发打开则不会警告。
正确用法示例
读取状态并编程式关闭(对应 Storybook Controlled 故事):
function ControlledMenu() { const [open, setOpen] = React.useState(false); return ( <> <p>The menu is currently {open ? 'open' : 'closed'}.</p> <ContextMenu.Root open={open} onOpenChange={setOpen}> <ContextMenu.Trigger>Right click here</ContextMenu.Trigger> <ContextMenu.Portal> <ContextMenu.Content> <button type="button" onClick={() => setOpen(false)}>Close</button> <ContextMenu.Item onSelect={() => console.log('undo')}>Undo</ContextMenu.Item> </ContextMenu.Content> </ContextMenu.Portal> </ContextMenu.Root> </> ); }注意:在 Storybook 的Controlled故事中,菜单内容内嵌了一个"Close"按钮,点击后通过setOpen(false)编程式关闭——这正是官方推荐的受控用途。
受控状态下右键仍可打开:单元测试 opens on right click and reflects the open state 验证了:即使传入open={false}与onOpenChange,右键依然会调用onOpenChange(true);而 respects a controlled open={false} 则验证:若父组件不回写open,菜单保持关闭(trigger 的data-state仍为closed)。换句话说,受控打开是"单向否决"——你可以阻止打开,但不能脱离交互凭空打开。
触发机制:右键、长按与虚拟锚点重定位
事件绑定全貌
ContextMenuTrigger在 源码 中绑定了四类指针事件,全部通过composeEventHandlers与用户传入的事件处理合并:
onContextMenu:非禁用时调用handleOpen并event.preventDefault()屏蔽浏览器原生菜单;onPointerDown:仅对pointerType !== 'mouse'(触摸/笔)的指针生效(whenTouchOrPen辅助函数过滤),若菜单已打开则先关闭,再启动 700ms 长按定时器触发handleOpen;onPointerMove/onPointerCancel/onPointerUp:长按期间移动指针、取消或抬起都会清除长按定时器。
此外,disabled时所有事件处理原样透传给用户(且保留原生右键菜单),并渲染data-disabled属性;触发元素同时设置WebkitTouchCallout: 'none'以阻止 iOS 长按弹出系统菜单。700ms 长按阈值是源码中可查证的实现细节(longPressTimerRef.current = window.setTimeout(() => handleOpen(event), 700))。
2.3.3 的"重锚定"修复
2.3.3 修复了"已打开时再次触发不更新指针位置"。源码 virtualRef 的 useMemo 依赖point说明:每当指针位置更新,虚拟锚点会被重建。对应回归测试 re-anchors the content when re-triggered in a new location while open 验证:在 (10,10) 右键打开菜单后,再到 (200,150) 右键,[data-radix-popper-content-wrapper]的transform会发生变化。Playwright 端到端测试 context-menu.spec.ts 也从真实浏览器层面验证了"重新右键时关闭已展开的子菜单并重开根菜单"。
定位与外观:默认参数与 CSS 自定义属性
ContextMenuContent直接基于MenuPrimitive.Content,源码 固定了三个默认值:
| 参数 | 默认值 | 说明 |
|---|---|---|
side | right | 菜单出现在指针右侧 |
sideOffset | 2 | 距锚点 2px |
align | start | 沿对齐轴起点对齐 |
alignOffset(示例中常用-5)可从MenuPrimitive.Content透传。Content会屏蔽从 Menu 层继承的side/sideOffset/align/onEntryFocus(通过Omit类型声明,见 ContextMenuContentProps),防止用户改变这三个定位语义。
此外,源码 将 popper 的内部 CSS 变量重命名 为--radix-context-menu-*命名空间,供样式中使用:
--radix-context-menu-content-transform-origin; --radix-context-menu-content-available-width; --radix-context-menu-content-available-height; --radix-context-menu-trigger-width; --radix-context-menu-trigger-height;ContextMenuSubContent同样应用了这套变量重命名(源码)。Storybook 的动画样式见 context-menu.stories.module.css。
组件 API 全览
入口文件 标记'use client'并以短别名(Root、Trigger…)导出全部 18 个部件。基于 context-menu.tsx 的 export 区块 整理如下:
| 部件 | 职责 |
|---|---|
ContextMenu.Root | 根容器:open/onOpenChange/dir(ltr、rtl)/modal(默认true) |
ContextMenu.Trigger | 右键/长按触发,内部渲染<span>与虚拟锚点 |
ContextMenu.Portal | 将内容传送到 body 末尾 |
ContextMenu.Content | 菜单内容,默认side="right" |
ContextMenu.Group/ContextMenu.Label | 分组与组标签(role="group") |
ContextMenu.Item | 普通菜单项(role="menuitem"),onSelect后默认关闭 |
ContextMenu.CheckboxItem | 复选菜单项(role="menuitemcheckbox") |
ContextMenu.RadioGroup/ContextMenu.RadioItem | 单选组(role="menuitemradio") |
ContextMenu.ItemIndicator | 选中态指示符(配合checked/value显隐) |
ContextMenu.Separator | 分隔线 |
ContextMenu.Arrow | 指向触发点的箭头 |
ContextMenu.Sub/SubTrigger/SubContent | 子菜单(键盘可打开) |
关于modal:默认true时,菜单打开期间document.body被设置为pointer-events: none(测试 disables pointer events 验证),保证焦点被限制在菜单内;modal={false}时则不设置(对应测试)。如需在打开菜单的同时允许用户操作页面其他元素,可显式关闭模态。
子菜单:键盘交互与 RTL
子菜单由Sub/SubTrigger/SubContent组成。单元测试验证了以下键盘行为:
- 焦点在
SubTrigger上按ArrowRight(LTR)/ArrowLeft(RTL)打开子菜单并聚焦第一项,测试用例; - 打开时
SubTrigger通过aria-controls关联子菜单内容(仅在打开期间存在),测试用例; - 禁用的
SubTrigger不会被键盘打开,测试用例。
e2e 测试 context-menu.spec.ts 进一步确认:Enter、Space、ArrowRight均可打开子菜单并聚焦第一项;ArrowLeft只关闭当前聚焦的子菜单(根菜单保持打开,见 L182-L191);typeahead(输入首字母跳转)行为被限定在活动菜单内(L217-L230)。RTL 场景下方向键语义反转(L274-L295)。
2.3.3 的另一修复"菜单项拦截来自可聚焦后代的Space/Enter"也与子菜单键盘路径相关:即菜单项内部若包含可聚焦元素,其按键不应被菜单项的选中逻辑抢走。
无头、可组合与工程细节
部件组合与 asChild
所有部件在 context-menu.tsx 中都是对@radix-ui/react-menu对应部件的薄封装(例如ContextMenuItem仅透传MenuPrimitive.Item),因此继承了 Menu 的全部能力(焦点管理、类型匹配、方向键导航、Escape 关闭、交互外关闭等)。每个部件都支持asChild——测试 prop spreading 系统验证了各部件会将未消费的 props 透传到渲染元素,并在asChild时把role/data-state/aria-*等语义属性合并到子元素上(例如Trigger asChild会把data-state和WebkitTouchCallout样式合入自定义元素,见 L575-L604)。
RSC 兼容与 SSR
- 组件源文件入口标注
'use client'(index.ts 第一行),保证在 Server Components 边界下安全使用; - SSR 测试应用 提供了一个最小可运行的 Server 组件示例,仅引入
ContextMenu与基础菜单项; - 2.3.7 明确"回退破坏性变更"以保持 RSC 兼容(见 CHANGELOG 首条)。
2.3.4 的树摇优化如何生效
在源码中可见:ContextMenuTrigger、ContextMenuContent等部件以/* @__PURE__ */ React.forwardRef(...)包裹(例如 L112、L234),且渲染函数均为命名函数而非匿名箭头函数加displayName赋值。这让 Rollup/Vite/Webpack 等打包器能识别纯函数调用并删除未引用部件——即使index.ts导出了全部部件,只要应用只 import 需要的部件,未使用的部件代码即可被树摇掉。配合 2.3.4 的条件导出,开发期console.warn(如受控 open 警告)只保留在 development 产物中。
升级注意事项与可验证事实清单
open只读不写:不要用open编程式打开菜单(会导致定位失效并触发开发警告);只用于读取/关闭。modal默认true:modal 菜单会屏蔽 body 指针事件并限制焦点,false时保留页面交互。- 触发方式:鼠标右键、触摸/笔长按 700ms 触发;
disabled时透传原生右键菜单。 - 定位默认值:
side="right"、sideOffset={2}、align="start",不可从Content覆盖;alignOffset可用。 - 2.3.4 之后注意打包差异:
/* @__PURE__ */与条件导出是构建时行为,升级后若自定义构建请重新验证产物大小与 dev 警告。 - 2.3.7 的 RSC 回退:如果升级 2.3.7 时遇到与 Server Components 相关的问题,请确认是否停留在更早版本的特性上——本次补丁正是为消除这类兼容性问题而发布。
以上所有结论均可在 CHANGELOG、组件实现、单元测试 与 e2e 测试 中直接复核。
- 前端
- UI组件
【免费下载链接】primitives
Radix Primitives is an open-source UI component library for building high-quality, accessible design systems and web apps. Maintained by @workos.
相关推荐
深入解析 @radix-ui/react-context:从版本演进看 Radix Primitives 的上下文创建体系
深入解析 @radix ui/react context:从版本演进看 Radix Primitives 的上下文创建体系 @radix ui/react co
前端UI组件Radix Primitives HoverCard 组件演进实录:从 1.1.8 到 1.1.23 的变更解读与源码剖析
Radix Primitives HoverCard 组件演进实录:从 1.1.8 到 1.1.23 的变更解读与源码剖析 HoverCard(悬停卡片)是 R
前端UI组件@radix-ui/react-compose-refs 深入解析:Radix Primitives 中 ref 组合工具的实现原理与版本演进
@radix ui/react compose refs 深入解析:Radix Primitives 中 ref 组合工具的实现原理与版本演进 导读 @radi
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考