
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.0Extract menus logic into a dedicated package将菜单逻辑从单体代码中抽离为独立包是本包诞生的初始版本0.1.1Fix package.json exports field依赖更新至elementor/locations0.7.6修复包的导出字段保证 ESM/CJS/类型声明的正确解析0.1.2Update and lock dependencies versions统一并锁定依赖版本保证构建与运行时行为可复现0.1.3Fix menu keyboard navigation修复菜单项的键盘导航问题是唯一一次针对交互行为的修复0.1.4update elementor/ui依赖更新至elementor/locations0.7.7升级 UI 基础组件库间接改善菜单项渲染样式0.1.5依赖更新至elementor/locations0.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创建一套菜单的工厂函数controlActionsMenuElementor 编辑面板控制动作的预置菜单实例Components类型组件映射类型。包的依赖关系也透露了架构分层elementor/locations注入机制基础、elementor/utils工具函数、elementor/editor-ui与elementor/uiUI 组件并以react^18.3.1作为 peer dependency。构建由tsup完成产物同时提供dist/index.jsCJS、dist/index.mjsESM与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.0Extract 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; // 响应式 propsHook与 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 注入最终注册函数把包装后的组件注入到对应分组的Locationlocations[ 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 );设计要点按分组聚合返回值为RecordMenuGroups, 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-valueusePropsHook 归一化 React 状态驱动重渲染传入不存在的分组菜单项被静默忽略group in locations校验菜单项优先级排序priority: 1排在priority: 10前面getInjections的升序排序覆盖已存在的菜单项overwrite: true后渲染新组件inject的 overwrite 分支重复 ID 注册产生警告未指定 overwrite 时触发console.warncreate-location.tsx的重复 ID 保护挂载后注册动态渲染组件挂载后registerLink调用act包裹后菜单项出现useSyncExternalStore订阅链路其中优先级与覆盖两个用例恰好为开发者在registerXxx中调整菜单顺序、替换默认项提供了最直接的验证依据。八、真实落地controlActionsMenu 与编辑面板elementor/menus不是抽象的玩具代码它在 Elementor 编辑器中承担着控制动作菜单的实际职责。包内预置了一个实例见 controls-actions.tsimport { 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 placementtop title{ title } arrow{ true } IconButton aria-label{ title } size{ SIZE } onClick{ onClick } Icon fontSize{ SIZE } / /IconButton /Tooltip ); }这里可以回扣 CHANGELOG0.1.3的 Fix menu keyboard navigationAction使用标准语义化的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.56 个版本中有 4 个涉及依赖更新或锁定。elementor/menus对elementor/locations的强依赖意味着注入机制的每次升级都会以 Patch 形式传递到本包锁版本保证了 monorepo 构建的可复现性导出字段是发布生命线0.1.1exports字段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提供类型安全的注册 APIelementor/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),仅供参考