Elementor 菜单注册机制解析:从 @elementor/menus 的版本演进到 React 插槽式菜单架构
【免费下载链接】elementorThe most advanced frontend drag & drop page builder. Create high-end, pixel perfect websites at record speeds. Any theme, any page, any design.项目地址: https://gitcode.com/GitHub_Trending/el/elementor
本篇文章以 Elementor 开源仓库中@elementor/menus包的 CHANGELOG.md 为线索,结合该包在packages/packages/libs/menus目录下的完整 TypeScript 源码、单元测试与编辑器实际调用场景,系统讲解 Elementor 如何为 React 应用提供一套「可注册、可分组、可排序、可覆盖」的菜单注入机制。读完本文,你将理解createMenu的底层原理、registerXxx注册函数的命名约定、基于useSyncExternalStore的响应式菜单读取,以及这套机制在 Elementor 编辑面板控制动作(Control Actions)中的真实落地方式。
一、CHANGELOG 总览:一个菜单包的六个版本
@elementor/menus的变更记录非常简短,却完整勾勒出这个包从诞生到稳定的演进路径。逐条解读如下:
| 版本 | 变更内容 | 核心影响 |
|---|---|---|
0.1.0 | Extract menus logic into a dedicated package | 将菜单逻辑从单体代码中抽离为独立包,是本包诞生的初始版本 |
0.1.1 | Fix package.json exports field;依赖更新至@elementor/locations@0.7.6 | 修复包的导出字段,保证 ESM/CJS/类型声明的正确解析 |
0.1.2 | Update and lock dependencies versions | 统一并锁定依赖版本,保证构建与运行时行为可复现 |
0.1.3 | Fix menu keyboard navigation | 修复菜单项的键盘导航问题,是唯一一次针对交互行为的修复 |
0.1.4 | update elementor/ui;依赖更新至@elementor/locations@0.7.7 | 升级 UI 基础组件库,间接改善菜单项渲染样式 |
0.1.5 | 依赖更新至@elementor/locations@0.8.0 | 跟随底层注入机制包的版本升级 |
值得说明的是,当前仓库中 package.json 的版本号已对齐至4.4.0(与 monorepo 内其他包保持一致),而 CHANGELOG 记录的最后一个独立发布版本为0.1.5,两者差异可以推断是 Elementor 在 monorepo 化过程中对包版本做了整体对齐所致。
这份 CHANGELOG 虽然只有版本号与一行说明,但它指向了三个值得深挖的技术点:包化拆分(0.1.0)、exports 导出治理(0.1.1)、键盘可访问性(0.1.3)。下文将逐一结合源码展开。
二、包定位与项目结构
从 package.json 的description可以看出该包的职责:"Add a menus registration mechanism for your React application"——为 React 应用提供菜单注册机制。它对外只暴露三个能力:
createMenu:创建一套菜单的工厂函数;controlActionsMenu:Elementor 编辑面板控制动作的预置菜单实例;Components类型:组件映射类型。
包的依赖关系也透露了架构分层:@elementor/locations(注入机制基础)、@elementor/utils(工具函数)、@elementor/editor-ui与@elementor/ui(UI 组件),并以react@^18.3.1作为 peer dependency。构建由tsup完成,产物同时提供dist/index.js(CJS)、dist/index.mjs(ESM)与dist/index.d.ts(类型声明)。
src目录下共 8 个源码文件,职责划分清晰:
src/ ├── index.ts # 对外出口:createMenu、controlActionsMenu、Components ├── create-menu.ts # 核心工厂:组装 locations、注册函数与 Hook ├── create-register-item.tsx # 生成 registerXxx 注册函数 ├── create-use-menu-items.ts # 生成 useMenuItems 响应式读取 Hook ├── controls-actions.ts # 编辑面板控制动作的预置菜单实例 ├── action.tsx # 默认的 Action 图标按钮组件 ├── types.ts # 类型定义 └── __tests__/index.test.tsx # 覆盖全行为的单元测试这正是 CHANGELOG0.1.0"Extract menus logic into a dedicated package" 的落地形态:菜单注册逻辑被抽成独立、可复用、可独立发布 npm 包。
三、createMenu 工厂:菜单注册机制的核心
create-menu.ts 是整个包的心脏。它接受两个配置:groups(可选,自定义菜单分组名数组)与components(组件映射),返回一个同时具备注册函数与读取 Hook 的Menu对象。
3.1 分组与默认组
export function createMenu< TComponents extends Components, TGroups extends string = 'default' >( { groups = [], components, }: { groups?: TGroups[]; components: TComponents; } ): Menu< TComponents, TGroups > { const locations = createLocations< MenuGroups< TGroups > >( [ ...groups, 'default' ] ); // ... }关键点:无论传入多少个自定义分组,'default'组都会被自动追加到组列表末尾。MenuGroups类型(见 types.ts)定义为TGroups | 'default',因此注册和读取时,分组名可以是自定义组,也可以是内置的'default'。
每个分组在底层对应一个由@elementor/locations提供的Location实例(createLocation()),形成一个以分组名为键的LocationsMap。
3.2 内部订阅机制
createMenu内部用Set实现了一个极简的发布订阅器:
function createSubscription(): Subscription { const listeners = new Set< () => void >(); return { subscribe: ( listener ) => { listeners.add( listener ); return () => listeners.delete( listener ); }, notify: () => listeners.forEach( ( listener ) => listener() ), }; }subscribe返回取消订阅函数,notify遍历唤醒所有监听者。这套订阅器被同时传递给注册函数(每次注册后notify())和useMenuItemsHook,构成了「注册 → 通知 → 重渲染」的响应链路,是后面useSyncExternalStore的数据基础。
3.3 注册函数的自动生成
createMenu遍历components对象,为每个组件生成一个形如registerXxx的注册函数:
return Object.entries( components ).reduce( ( acc, [ key, component ] ) => { const name = `register${ capitalize( key ) }`; return { ...acc, [ name ]: createRegisterItem( locations, component, notify ), }; }, {} as RegisterFns< TGroups, TComponents > );对应的类型定义通过 TypeScript 模板字面量类型实现强类型约束:
type RegisterFns< TGroups extends string, TComponents extends Components > = { [ K in keyof TComponents as `register${ Capitalize< K & string > }` ]: RegisterItem< TGroups, TComponents[ K ] >; };这意味着:如果传入components: { Button, Link },createMenu返回的对象上会自动出现类型安全的registerButton与registerLink两个方法——注册函数名与组件名一一对应,且在编译期即可校验参数类型。
四、registerXxx 注册函数:参数、默认值与运行期校验
create-register-item.tsx 实现了注册函数的真正行为。注册函数接受的参数结构如下:
{ id: string; // 菜单项唯一 ID(同一分组内) group?: MenuGroups; // 目标分组,默认 'default' priority?: number; // 排序权重,默认 10,数值越小越靠前 overwrite?: boolean; // 是否允许覆盖同名项,默认 false props?: TProps; // 静态 props,与 useProps 二选一 useProps?: () => TProps; // 响应式 props(Hook),与 props 二选一 }4.1 分组合法性检查
注册时首先检查分组是否存在:
if ( ! ( group in locations ) ) { return; }传入不存在的分组会被静默忽略,而不是抛错。这一点在测试中有明确覆盖(见下文第七节),适合作为"分组名拼写错误"时的兜底保护。
4.2 props 与 useProps 二选一
类型定义通过联合类型严格约束props与useProps只能二选一:
type PropsOrUseProps< TProps extends object > = | { props: TProps; useProps?: never } | { useProps: () => TProps; props?: never };运行时则统一归一化为一个useProps函数:
const useProps = _useProps || ( () => _props );随后生成一个InjectedComponent包装组件,把 Hook 返回值与外部传入的 props 合并后渲染目标组件:
const InjectedComponent = ( props: object ) => { const componentProps = useProps(); return <Component { ...props } { ...componentProps } />; };useProps的价值在于让菜单项具备响应式能力:它本质是一个 React Hook,可以在内部使用useState等,使菜单项随状态变化自动更新——这在 Elementor 编辑器中用于实现"跟随当前选中元素变化"的动态菜单项。
4.3 委托给 locations 注入
最终,注册函数把包装后的组件注入到对应分组的Location:
locations[ group ].inject( { id, component: InjectedComponent, options: { priority, overwrite }, } ); notify();inject完成后调用notify()通知订阅者,触发菜单 UI 刷新。
五、底层注入机制:@elementor/locations 的优先级与覆盖语义
@elementor/menus之所以如此轻量,是因为排序、去重、覆盖等底层逻辑全部委托给了 @elementor/locations 包。理解inject的实现(见 create-location.tsx)有助于理解菜单排序与覆盖行为:
function createInject( injections, notify ) { return ( { component, id, options = {} } ) => { if ( injections.has( id ) && ! options?.overwrite ) { console.warn( `An injection with the id "${ id }" already exists. Did you mean to use "options.overwrite"?` ); return; } injections.set( id, { id, component: wrapInjectedComponent( component ), priority: options.priority ?? DEFAULT_PRIORITY, } ); notify(); }; }三条核心语义:
- 重复 ID 保护:同一
Location内 ID 必须唯一。重复注册且未指定overwrite: true时,新注册会被忽略并输出console.warn提示(这正是 CHANGELOG 中"版本迭代依赖注入机制"的体现之一); - overwrite 覆盖:指定
overwrite: true后,同名项会被新组件替换,这是 Elementor 允许第三方"替换默认菜单项"的机制; - priority 排序:默认优先级
DEFAULT_PRIORITY = 10(见 injections.tsx),读取时按a.priority - b.priority升序排列,数值越小越靠前。
读取侧,createGetInjections每次返回一份按优先级排序的数组副本;Slot组件则负责把注入项渲染为 React 元素。此外,注入的组件还会被 injected-component-wrapper.tsx 包裹(wrapInjectedComponent),为每个注入项提供错误隔离边界。
六、useMenuItems:基于 useSyncExternalStore 的响应式读取
create-use-menu-items.ts 生成了菜单的读取 Hook。它采用 React 18 的useSyncExternalStore接入外部订阅器,并实现了一个快照缓存:
let snapshot: GroupedMenuItems< TGroups > | null = null; subscribe( () => { snapshot = null; // 任何注册/变化都会使缓存失效 } ); const getMenuItems = () => { if ( snapshot ) { return snapshot; } snapshot = Object.entries( locations ).reduce( ( carry, [ groupName, location ] ) => { const items = location.getInjections().map( ( injection ) => ( { id: injection.id, MenuItem: injection.component, } ) ); return { ...carry, [ groupName ]: items }; }, {} as GroupedMenuItems< TGroups > ); return snapshot; }; return () => useSyncExternalStore( subscribe, getMenuItems );设计要点:
- 按分组聚合:返回值为
Record<MenuGroups, Array<{ id, MenuItem }>>,消费方既可以用useMenuItems().default拿默认组,也可以用useMenuItems().customGroup拿自定义组; - 快照缓存:只有订阅器被触发(即发生注册)时才重建快照,避免每次渲染都重新遍历
locations; - 挂载后注册也能感知:由于
subscribe在模块级注册监听,即使菜单组件已经挂载、之后才注册新项(例如第三方插件脚本在编辑器渲染完成后加载),UI 也会自动刷新。这正是 injections.tsx 注释中所描述的"注入晚于 Slot 挂载也必须反映到 UI"的场景。
七、单元测试:8 个用例覆盖的完整行为契约
tests/index.test.tsx 是理解这个包行为契约最直接的文档,8 个测试用例与上文机制一一对应:
| 测试用例 | 验证的行为 | 对应实现 |
|---|---|---|
| 创建带分组的菜单并自动追加 default 组 | 自定义组 + 内置 default 组共存 | create-menu.ts 的[ ...groups, 'default' ] |
| 注册带 props 的菜单项 | props被注入到组件 | create-register-item.tsx的 props 合并 |
| 注册带 useProps 的响应式菜单项 | 点击后文本从initial-value变为new-value | usePropsHook 归一化 + React 状态驱动重渲染 |
| 传入不存在的分组 | 菜单项被静默忽略 | group in locations校验 |
| 菜单项优先级排序 | priority: 1排在priority: 10前面 | getInjections的升序排序 |
| 覆盖已存在的菜单项 | overwrite: true后渲染新组件 | inject的 overwrite 分支 |
| 重复 ID 注册产生警告 | 未指定 overwrite 时触发console.warn | create-location.tsx的重复 ID 保护 |
| 挂载后注册动态渲染 | 组件挂载后registerLink调用act包裹后菜单项出现 | useSyncExternalStore订阅链路 |
其中"优先级"与"覆盖"两个用例,恰好为开发者在registerXxx中调整菜单顺序、替换默认项提供了最直接的验证依据。
八、真实落地:controlActionsMenu 与编辑面板
@elementor/menus不是抽象的玩具代码,它在 Elementor 编辑器中承担着"控制动作菜单"的实际职责。包内预置了一个实例(见 controls-actions.ts):
import { PopoverAction } from '@elementor/editor-ui'; import Action from './action'; import { createMenu } from './create-menu'; export const controlActionsMenu = createMenu( { components: { Action, PopoverAction, }, } );8.1 组件定义
Action(见 action.tsx)是默认的控制动作组件,基于@elementor/ui的IconButton与Tooltip构建,visible为false时直接返回null实现隐藏:
export default function Action( { title, visible = true, icon: Icon, onClick }: ActionProps ) { if ( ! visible ) { return null; } return ( <Tooltip placement="top" title={ title } arrow={ true }> <IconButton aria-label={ title } size={ SIZE } onClick={ onClick }> <Icon fontSize={ SIZE } /> </IconButton> </Tooltip> ); }这里可以回扣 CHANGELOG0.1.3的 "Fix menu keyboard navigation":Action使用标准语义化的IconButton而非裸<div>或<span>,天然继承按钮的原生键盘交互(Tab 聚焦、Enter/Space 激活),配合aria-label提供无障碍名称——这正是菜单键盘导航修复得以成立的基础,也说明菜单项的可访问性从一开始就内建在组件选型之中。
8.2 消费端:编辑面板
编辑面板(editing-panel.tsx)解构出useMenuItems,并把默认组的菜单项交给ControlActionsProvider渲染:
const { useMenuItems } = controlActionsMenu; // ... const menuItems = useMenuItems().default; // ... <ControlActionsProvider items={ menuItems }>8.3 生产端:动态标签动作的注册
编辑面板的动态标签模块(dynamics/init.ts)解构出registerPopoverAction并注册具体菜单项:
const { registerPopoverAction } = controlActionsMenu; export const init = () => { // ... registerPopoverAction( { id: 'dynamic-tags', priority: 20, useProps: usePropDynamicAction, } ); // ... };这段代码是useProps响应式能力的教科书式应用:usePropDynamicAction依据当前选中元素的属性值动态计算菜单项的图标、可见性与点击行为,从而实现"动态标签"按钮在不同上下文中的差异化表现。可以推断,priority: 20使该动作排在默认优先级(10)之后,位于控制动作列表较后的位置。
由此可以看到完整的调用链:init 注册(registerPopoverAction)→ locations 注入 + notify → useMenuItems 快照失效 → 编辑面板重渲染 → ControlActionsProvider 消费。一条简洁的插件式扩展链路,让编辑器核心与功能模块解耦。
九、从 CHANGELOG 反推的工程实践要点
结合版本记录与源码,可以提炼出几条对该包演进过程的工程观察:
- 依赖治理优先(0.1.2、0.1.4、0.1.5):6 个版本中有 4 个涉及依赖更新或锁定。
@elementor/menus对@elementor/locations的强依赖意味着,注入机制的每次升级都会以 Patch 形式传递到本包,锁版本保证了 monorepo 构建的可复现性; - 导出字段是发布生命线(0.1.1):
exports字段(types/import/require三通道)决定了包能否被 Vite、Webpack、Node 等不同解析器正确消费,修复它通常发生在包首次被外部消费之前; - 键盘导航是菜单的及格线(0.1.3):菜单不同于普通列表,必须支持纯键盘操作。该修复与
Action组件使用语义化IconButton的事实相互印证; - 内聚与复用(0.1.0):将菜单逻辑抽包后,编辑面板、默认样式、变量面板等多个模块(仓库内搜索
controlActionsMenu可见于 editor-variables、editor-default-styles 等)都复用了同一套注册机制,避免了各自实现造成的重复与漂移。
十、总结
@elementor/menus是一个典型的小而美的 React 扩展点设计:CHANGELOG 记录了它"抽包 → 修导出 → 锁依赖 → 修键盘导航 → 跟随依赖升级"的完整生命周期,而源码则揭示了它背后的三层架构——createMenu提供声明式入口,registerXxx提供类型安全的注册 API,@elementor/locations提供优先级排序、ID 去重与覆盖替换的底层能力,最后通过useSyncExternalStore把注册结果实时同步到 React 树中。对于希望在 Elementor 插件或类似 React 应用中实现"可被第三方扩展的菜单"的开发者,这套机制从 API 设计、类型约束到测试覆盖都提供了可以直接借鉴的范本。
延伸阅读:可在仓库中继续研读 locations 包源码、菜单单元测试 以及编辑面板的完整消费端实现,以完整理解注入机制在真实编辑器中的运作全貌。
【免费下载链接】elementorThe most advanced frontend drag & drop page builder. Create high-end, pixel perfect websites at record speeds. Any theme, any page, any design.项目地址: https://gitcode.com/GitHub_Trending/el/elementor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考