Electron Menu 类深度解析:应用菜单与上下文菜单的完整实现指南
【免费下载链接】electron:electron: Build cross-platform desktop apps with JavaScript, HTML, and CSS项目地址: https://gitcode.com/GitHub_Trending/el/electron
本文围绕 Electron 的 Menu API 展开,系统讲解Menu类的创建、应用菜单设置、上下文菜单弹出、菜单事件与实例属性等全部核心能力,并结合 Electron 仓库中的 C++ 原生实现(electron_api_menu.cc)、TypeScript 绑定层(menu.ts)与测试用例(api-menu-spec.ts),剖析从 JS 模板到原生菜单项的完整调用链,帮助开发者在跨平台桌面应用中正确构建并动态管理菜单。
一、Menu 类概览与跨平台表现差异
Menu类用于创建应用菜单(application menu)和上下文菜单(context menu),仅在主进程可用(Process: Main)。不同操作系统下菜单的呈现方式存在本质差异:
- Windows / Linux:菜单在视觉上与 Chromium 一致,由 Views 框架渲染,
Menu.setApplicationMenu()设置的菜单会作为每个窗口的顶部菜单栏(menu bar); - macOS:菜单是原生 NSMenu,应用菜单显示在系统级菜单栏中。
注意:Electron 的内置类不允许在用户代码中继承(子类化)。更多背景可参考 FAQ。
new Menu()创建一个空菜单实例。更完整的菜单编写指南(如各 role 的用法、平台差异建议)见 menus 教程,本文聚焦于Menu类本身的 API 与实现原理。
二、静态方法:setApplicationMenu / getApplicationMenu
Menu.setApplicationMenu(menu)
menuMenu | null
在 macOS 上,该调用把menu设为系统应用菜单;在 Windows 和 Linux 上,menu会被设置为每个窗口的顶部菜单栏。
&助记符(Windows / Linux 特有):在顶级菜单项名称中使用&指定哪个字母应生成快捷键。例如文件菜单命名为&File会生成Alt-F快捷键用于打开对应菜单,该字母在按钮标签上带下划线,&本身不显示。若要转义&字符,需写两个&&,例如&&File会在按钮标签上显示&File。
传入null的效果:抑制默认菜单。在 Windows 和 Linux 上还有额外效果——移除窗口上的菜单栏。
若应用从未设置过菜单,Electron 会自动创建包含
File、Edit、View、Window等标准项的默认菜单。
源码实现细节:JS 层的 Menu.setApplicationMenu 做了类型校验后区分平台处理:
- macOS 分支:先调用
menu._callMenuWillShow()预激活菜单(触发所有子菜单项的初始化钩子),再通过原生绑定bindings.setApplicationMenu(menu)安装到 NSMenu; - Windows / Linux 分支:遍历
BaseWindow.getAllWindows()并逐个调用w.setMenu(menu),即"应用菜单"实际被展开为每个窗口的菜单栏——这也解释了为什么在这两个平台上菜单是"每窗口一份"的。
同时该函数会调用 default-menu.ts 中的setApplicationMenuWasSet()打标,一旦打过标,应用启动时setDefaultApplicationMenu()就不再自动构建默认菜单——这就是"手动设置后默认菜单消失"的机制。默认菜单的模板本身非常简单,仅为若干 role 的组合(setDefaultApplicationMenu):
const template: Electron.MenuItemConstructorOptions[] = [ ...(isMac ? [{ role: 'appMenu' }] : []), { role: 'fileMenu' }, { role: 'editMenu' }, { role: 'viewMenu' }, { role: 'windowMenu' } ];Menu.getApplicationMenu()
返回Menu | null:已设置的应用菜单,或null。
注意:返回的
Menu实例不支持动态增删菜单项(append/insert仅对新构建的菜单有意义),但实例属性仍可动态修改。从 JS 实现看,getApplicationMenu只是返回模块级变量applicationMenu(menu.ts#L204),而原生模型层对已安装为应用菜单的 model 不再接受结构性变更。
Menu.sendActionToFirstResponder(action)macOS
actionstring
向应用的第一响应者(first responder)发送action,用于模拟 macOS 默认菜单行为。通常更推荐做法是给MenuItem设置role属性,由 role 自动映射到正确的原生动作。
源码细节:该静态方法仅在 macOS 编译分支中注册到模块导出(electron_api_menu.cc#L367-L371 中#if BUILDFLAG(IS_MAC)守卫setApplicationMenu与sendActionToFirstResponder两个原生绑定),JS 层 menu.ts#L206 直接将其挂到Menu构造函数上。
三、Menu.buildFromTemplate(template):从模板构建菜单
Menu.buildFromTemplate(template) - template ([MenuItemConstructorOptions](https://link.gitcode.com/i/eb4c4d2d97c67e49423a80d3331d8e23#new-menuitemoptions) | [MenuItem](https://link.gitcode.com/i/eb4c4d2d97c67e49423a80d3331d8e23))[] Returns: Menutemplate通常是构造 MenuItem 的options数组,元素既可以是选项对象,也可以是已构建好的MenuItem实例。你还可以在模板元素上附加任意额外字段,这些字段会成为所构建菜单项的属性——这在模板中携带自定义数据(如状态标识、回调标记)时非常实用。
模板验证与预处理流程
JS 实现(Menu.buildFromTemplate)并非直接逐项 append,而是经过三步处理:
- 验证(areValidTemplateItems):模板必须是数组,且每个元素必须至少拥有
label、role之一,或type === 'separator',否则抛出TypeError; - 排序(sortTemplate):调用 menu-utils.ts 中的
sortMenuItems,按菜单项的id/before/after/beforeGroupContaining/afterGroupContaining属性做拓扑排序,让多个上下文菜单来源可以声明"我的项应该插在某 id 项之前/之后"或"我的整组应该放在某组之前",排序对子菜单递归生效; - 清理分隔线(removeExtraSeparators):折叠相邻的 separator,并移除首尾的 separator(
visible === false的项跳过检查)。
测试用例(spec/api-menu-spec.ts)覆盖了这些行为,例如空模板元素、null项、非数组模板均会抛错,before/after排序有专门的 describe 块验证。
一个典型的模板示例(结合 MenuItemConstructorOptions 的常用字段):
const { Menu, app } = require('electron'); const template = [ ...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []), { role: 'fileMenu' }, { label: '编辑', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { type: 'separator' }, { role: 'selectAll' } ] }, { label: '视图', submenu: [ { role: 'reload' }, { role: 'toggleDevTools' }, { type: 'separator' }, { role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' }, { type: 'separator' }, { role: 'togglefullscreen' } ] }, { role: 'windowMenu' }, { label: '帮助', submenu: [ { label: '关于本应用', click: async () => { const { dialog } = require('electron'); await dialog.showMessageBox({ type: 'info' }); } } ] } ]; const menu = Menu.buildFromTemplate(template); Menu.setApplicationMenu(menu);菜单项类型的分发逻辑
menu.append(item)内部通过 insertItemByType 按item.type分发到不同原生插入方法(对应 C++ 层 FillObjectTemplate 注册的方法):
| type | 原生方法 | 说明 |
|---|---|---|
normal/header | insertItem(pos, commandId, label) | 普通项 |
checkbox | insertCheckItem(pos, commandId, label) | 勾选框,点击自动翻转checked |
radio | insertRadioItem(pos, commandId, label, groupId) | 单选项,同组互斥 |
separator | insertSeparator(pos) | 分隔线 |
submenu/palette | insertSubMenu(pos, commandId, label, submenu) | 子菜单,Menu可嵌套在MenuItem.submenu上 |
其中radio类型有一段值得注意的实现:generateGroupId(menu.ts#L274-L288)会在分隔线范围内查找相邻的 radio 项,复用其groupId,从而把同一菜单中分隔线隔开的多段 radio 项自动归入同一互斥组;同时通过Object.defineProperty重定义checkedsetter,保证设置某项为选中时自动取消同组其他项的选中状态。
四、实例方法:popup 与 closePopup
menu.popup([options])
optionsObject (optional)windowBaseWindow (optional) - 默认为当前聚焦窗口。frameWebFrameMain (optional) - 如果希望 Writing Tools(macOS)等 OS 级功能正确工作,应提供相关 frame。通常应取WebContents的context-menu事件中的params.frame,或focusedFrame属性。xnumber (optional) - 默认为当前鼠标光标位置。声明了y时声明x为必填。ynumber (optional) - 默认为当前鼠标光标位置。声明了x时声明y为必填。positioningItemnumber (optional)macOS- 指定坐标处应位于鼠标光标下方的菜单项索引,默认 -1。sourceTypestring (optional)WindowsLinux- 应映射为context-menu事件提供的menuSourceType。不建议手动设置该值,只提供从其他 API 收到的值或保持undefined。可取none、mouse、keyboard、touch、touchMenu、longPress、longTap、touchHandle、stylus、adjustSelection、adjustSelectionReset。callbackFunction (optional) - 菜单关闭时调用。
在BaseWindow中将该菜单弹为上下文菜单。更多细节见 Context Menu 指南。
JS 层默认值与窗口选择逻辑(Menu.prototype.popup):x/y缺省为 -1(表示跟随鼠标),positioningItem缺省 -1,sourceType缺省'mouse';若window参数不在BaseWindow.getAllWindows()中,则回退到聚焦窗口、再回退到第一个窗口;一个窗口都不存在时抛出Error: Cannot open Menu without a BaseWindow present。
原生层定位逻辑(MenuViews::PopupAt,Windows / Linux):x == -1 || y == -1时取display::Screen::Get()->GetCursorScreenPoint(),否则以窗口内容区原点为基准换算为屏幕坐标;随后用views::MenuRunner(带CONTEXT_MENU | HAS_MNEMONICS标志)运行菜单,并把sourceType透传给RunMenuAt——这正是sourceType选项影响系统级行为(如键盘可访问性标注)的落点。
menu.closePopup([window])
windowBaseWindow (optional) - 默认为聚焦窗口。
关闭window中的上下文菜单。实现上,若传入BaseWindow实例则调用closePopupAt(window.id);否则传 -1,使原生层 ClosePopupAt 关闭该菜单打开的所有menu runner——因为一个Menu可能同时在多个窗口弹出。
五、项管理:append / insert / getMenuItemById 与 items 属性
menu.append(menuItem)-menuItemMenuItem:把menuItem追加到菜单末尾。实现即insert(this.getItemCount(), item)(menu.ts#L158-L160)。menu.insert(pos, menuItem)-posInteger、menuItemMenuItem:插入到pos位置。JS 层会校验项类型必须是MenuItem(否则TypeError: Invalid item),且pos不能小于 0 或大于当前项总数(否则RangeError);插入后同步设置toolTip、icon、role、自定义type(palette/header)、macOSbadge,并把menu反向挂到该项上。menu.getMenuItemById(id)-idstring:返回MenuItem | null,即指定id的项。实现(menu.ts#L145-L156)是递归搜索:先查当前层items,找不到则逐个进入子菜单继续查找,因此可以定位任意深度子菜单中的项。
menu.items
menu对象还具有实例属性menu.items:一个MenuItem[]数组,包含该菜单的所有项。每个Menu由多个MenuItem组成,每个MenuItem又可以通过其submenu属性嵌套一个Menu——这构成递归的树形结构。JS 侧的this.items在_init中初始化(menu.ts#L15-L19),与 C++ 模型层的commandId一一对应,保证两端结构同步。
六、事件:menu-will-show / menu-will-close
由new Menu创建或Menu.buildFromTemplate返回的对象会发出以下事件(部分事件仅限特定操作系统,文档中会标注):
Event: 'menu-will-show'
返回:eventEvent。当menu.popup()被调用时发出(更准确地说,在菜单即将展示的原生钩子处)。
Event: 'menu-will-close'
返回:eventEvent。当弹出菜单被手动关闭或被menu.closePopup()关闭时发出。
实现链路:C++ 的 ElectronMenuModel 是ui::SimpleMenuModel的派生类,原生菜单展示/关闭时回调到 Menu::OnMenuWillShow / OnMenuWillClose,其中OnMenuWillShow先把自身压入keep_alive_(SelfKeepAlive)防止弹出中的菜单被 GC,再Emit("menu-will-show")。OnMenuWillShow还经由 ui::SimpleMenuModel::Delegate 回调 触发 JS 侧_menuWillShow,负责确保每个 radio 组至少有一项被选中(menu.ts#L96-L102)——若组内无选中项,则默认选中第一项。
测试中通过 spec/api-menu-spec.ts#L855-L863 的once(menu, 'menu-will-show')/once(menu, 'menu-will-close')验证了两个事件的触发。
七、架构剖析:JS Menu 与原生 ElectronMenuModel 的双层模型
从源码结构看,Electron 菜单采用"JS 侧持有状态 + C++ 侧持有原生模型"的双层架构,理解它有助于解释文档中的种种限制:
JS 层 (lib/browser/api/menu.ts) C++ 层 (shell/browser) ────────────────────────────── ───────────────────────────── Menu 实例 electron::api::Menu ├─ items: MenuItem[] └─ model_: ElectronMenuModel ├─ commandsMap: { commandId -> item } (继承 ui::SimpleMenuModel) └─ groupsMap: { groupId -> radio[] } └─ 平台实现: │ commandId 为 JS 为每项 ├─ macOS: NSMenu (electron_menu_controller.mm) │ 分配的数字 ID └─ Win/Linux: views::MenuRunner ▼ (electron_api_menu_views.cc) C++ 通过 Delegate 接口反查 JS: IsCommandIdChecked / GetLabelForCommandId / GetAcceleratorForCommandIdWithParams / ExecuteCommand ... │ (通过 gin_helper::CallMethod 调用 │ JS 的 _isCommandIdChecked 等下划线方法) ▼ 菜单项的 label、icon、accelerator、enabled 等实际都保存在 JS MenuItem 上, 原生模型每次展示时按需查询。关键机制(electron_api_menu.cc#L122-L199):
- 属性按需拉取:
ui::SimpleMenuModel::Delegate的每个查询方法(IsCommandIdChecked、IsCommandIdEnabled、GetLabelForCommandId、GetIconForCommandId、GetAcceleratorForCommandIdWithParams等)都通过gin_helper::CallMethod回调 JS 中对应_xxx下划线方法,再由 JS 从commandsMap[id]上读取MenuItem的真实属性。因此MenuItem的label/enabled/accelerator等属性可以在运行时动态修改并立即生效,这正是文档提示"实例属性可动态修改"的实现基础。 - 窗口感知的 enabled:JS 侧 _isCommandIdEnabled 对特殊 role 做了焦点窗口联动——
minimize取决于聚焦窗口是否isMinimizable(),togglefullscreen取决于isFullScreenable(),close取决于isClosable()。 - 命令执行:用户点击菜单项时,C++ ExecuteCommand 回调 JS
_executeCommand,最终执行command.click(event, focusedWindow, focusedWebContents)(menu.ts#L89-L94)——这解释了click回调中window可能是undefined的原因:以聚焦窗口为准,无窗口时为 undefined。 - 内存管理:弹出中的菜单由 keep_alive_(SelfKeepAlive) 保持存活,
popup的callback则通过 BindSelfToClosure 持有 JS 引用直至回调执行,防止回调触发前菜单被 GC。
ElectronMenuModel额外维护了原生侧才有的元数据映射:tooltip、role、自定义类型(palette/header)、macOS badge 与 SharingItem(electron_menu_model.cc、electron_api_menu.cc#L26-L64),这些在insert时由 JS 层按平台条件调用setToolTip/setRole/setCustomType/setBadge写入(menu.ts#L177-L185)。
八、实战:结合 context-menu 事件构建动态上下文菜单
综合上述 API,一个标准的上下文菜单实现如下(webContents的context-menu事件签名见 web-contents.md):
const { Menu } = require('electron'); function onContextMenu(e, params) { const menu = Menu.buildFromTemplate([ { label: '复制', role: 'copy' }, { label: '粘贴', role: 'paste', enabled: !!params.misspelledWord.length === false }, ...(params.misspelledWord ? [{ type: 'separator' }, ...params.misspelledWord.slice(0, 3).map(word => ({ label: word, click: () => params.replaceMisspelling(word) }))] : []), { type: 'separator' }, { label: '刷新页面', accelerator: 'CmdOrCtrl+R', click: () => e.reload() } ]); // frame 与 sourceType 直接透传 context-menu 事件参数, // 保证 macOS Writing Tools 等 OS 级功能正常工作 menu.popup({ window: e.getOwnerWindow(), frame: params.frame, sourceType: params.menuSourceType }); }要点复述:
template元素上可附加自定义字段,构建后成为 MenuItem 属性,便于携带逻辑标记;- 相邻与首尾的
separator会被自动折叠/移除,模板里无需精确控制分隔线数量; - 通过
id+before/after可让多个菜单来源(如插件)声明相对位置,构建时自动拓扑排序; getMenuItemById支持跨子菜单递归查找,适合在菜单弹出后按 id 动态更新项状态(menu-will-show事件是更新时机)。
九、参考路径汇总
| 内容 | 路径 |
|---|---|
| Menu API 文档(本文主体) | docs/api/menu.md |
| MenuItem API 文档 | docs/api/menu-item.md |
| menus 教程(roles 详解) | docs/tutorial/menus.md |
| 上下文菜单指南 | docs/tutorial/context-menu.md |
| 默认菜单构建 | lib/browser/default-menu.ts |
| JS 绑定层 | lib/browser/api/menu.ts |
| 模板排序工具 | lib/browser/api/menu-utils.ts |
| C++ 核心实现 | shell/browser/api/electron_api_menu.cc |
| 菜单模型 | shell/browser/ui/electron_menu_model.cc |
| Views 平台弹出实现 | shell/browser/api/electron_api_menu_views.cc |
| 单元测试 | spec/api-menu-spec.ts |
【免费下载链接】electron:electron: Build cross-platform desktop apps with JavaScript, HTML, and CSS项目地址: https://gitcode.com/GitHub_Trending/el/electron
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考