Elementor 菜单注册机制解析:从 @elementor/menus 的版本演进到 React 插槽式菜单架构
2026/9/17 22:19:56 网站建设 项目流程

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/locations@0.7.6修复包的导出字段,保证 ESM/CJS/类型声明的正确解析
0.1.2Update and lock dependencies versions统一并锁定依赖版本,保证构建与运行时行为可复现
0.1.3Fix menu keyboard navigation修复菜单项的键盘导航问题,是唯一一次针对交互行为的修复
0.1.4update 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返回的对象上会自动出现类型安全的registerButtonregisterLink两个方法——注册函数名与组件名一一对应,且在编译期即可校验参数类型。

四、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 二选一

类型定义通过联合类型严格约束propsuseProps只能二选一:

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(); }; }

三条核心语义:

  1. 重复 ID 保护:同一Location内 ID 必须唯一。重复注册且未指定overwrite: true时,新注册会被忽略并输出console.warn提示(这正是 CHANGELOG 中"版本迭代依赖注入机制"的体现之一);
  2. overwrite 覆盖:指定overwrite: true后,同名项会被新组件替换,这是 Elementor 允许第三方"替换默认菜单项"的机制;
  3. 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-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.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/uiIconButtonTooltip构建,visiblefalse时直接返回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 反推的工程实践要点

结合版本记录与源码,可以提炼出几条对该包演进过程的工程观察:

  1. 依赖治理优先(0.1.2、0.1.4、0.1.5):6 个版本中有 4 个涉及依赖更新或锁定。@elementor/menus@elementor/locations的强依赖意味着,注入机制的每次升级都会以 Patch 形式传递到本包,锁版本保证了 monorepo 构建的可复现性;
  2. 导出字段是发布生命线(0.1.1)exports字段(types/import/require三通道)决定了包能否被 Vite、Webpack、Node 等不同解析器正确消费,修复它通常发生在包首次被外部消费之前;
  3. 键盘导航是菜单的及格线(0.1.3):菜单不同于普通列表,必须支持纯键盘操作。该修复与Action组件使用语义化IconButton的事实相互印证;
  4. 内聚与复用(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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询