首页
/ Electron Menu 类深度解析:应用菜单与上下文菜单的完整实现指南

Electron Menu 类深度解析:应用菜单与上下文菜单的完整实现指南

2026-09-06 19:17:01作者:胡易黎Nicole

本文围绕 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)

在 macOS 上,该调用把 menu 设为系统应用菜单;在 Windows 和 Linux 上,menu 会被设置为每个窗口的顶部菜单栏

& 助记符(Windows / Linux 特有):在顶级菜单项名称中使用 & 指定哪个字母应生成快捷键。例如文件菜单命名为 &File 会生成 Alt-F 快捷键用于打开对应菜单,该字母在按钮标签上带下划线,& 本身不显示。若要转义 & 字符,需写两个 &&,例如 &&File 会在按钮标签上显示 &File

传入 null 的效果:抑制默认菜单。在 Windows 和 Linux 上还有额外效果——移除窗口上的菜单栏。

若应用从未设置过菜单,Electron 会自动创建包含 FileEditViewWindow 等标准项的默认菜单。

源码实现细节: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 只是返回模块级变量 applicationMenumenu.ts#L204),而原生模型层对已安装为应用菜单的 model 不再接受结构性变更。

Menu.sendActionToFirstResponder(action) macOS

  • action string

向应用的第一响应者(first responder)发送 action,用于模拟 macOS 默认菜单行为。通常更推荐做法是给 MenuItem 设置 role 属性,由 role 自动映射到正确的原生动作。

源码细节:该静态方法仅在 macOS 编译分支中注册到模块导出(electron_api_menu.cc#L367-L371#if BUILDFLAG(IS_MAC) 守卫 setApplicationMenusendActionToFirstResponder 两个原生绑定),JS 层 menu.ts#L206 直接将其挂到 Menu 构造函数上。

三、Menu.buildFromTemplate(template):从模板构建菜单

Menu.buildFromTemplate(template)
- template ([MenuItemConstructorOptions](https://gitcode.com/GitHub_Trending/el/electron/blob/65c2667607ae9d510901e0aa304cf9e570e58aef/docs/api/menu-item.md?utm_source=gitcode_repo_files#new-menuitemoptions) | [MenuItem](https://gitcode.com/GitHub_Trending/el/electron/blob/65c2667607ae9d510901e0aa304cf9e570e58aef/docs/api/menu-item.md?utm_source=gitcode_repo_files))[]
Returns: Menu

template 通常是构造 MenuItemoptions 数组,元素既可以是选项对象,也可以是已构建好的 MenuItem 实例。你还可以在模板元素上附加任意额外字段,这些字段会成为所构建菜单项的属性——这在模板中携带自定义数据(如状态标识、回调标记)时非常实用。

模板验证与预处理流程

JS 实现(Menu.buildFromTemplate)并非直接逐项 append,而是经过三步处理:

  1. 验证areValidTemplateItems):模板必须是数组,且每个元素必须至少拥有 labelrole 之一,或 type === 'separator',否则抛出 TypeError
  2. 排序sortTemplate):调用 menu-utils.ts 中的 sortMenuItems,按菜单项的 id / before / after / beforeGroupContaining / afterGroupContaining 属性做拓扑排序,让多个上下文菜单来源可以声明"我的项应该插在某 id 项之前/之后"或"我的整组应该放在某组之前",排序对子菜单递归生效;
  3. 清理分隔线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) 内部通过 insertItemByTypeitem.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 类型有一段值得注意的实现:generateGroupIdmenu.ts#L274-L288)会在分隔线范围内查找相邻的 radio 项,复用其 groupId,从而把同一菜单中分隔线隔开的多段 radio 项自动归入同一互斥组;同时通过 Object.defineProperty 重定义 checked setter,保证设置某项为选中时自动取消同组其他项的选中状态。

四、实例方法:popup 与 closePopup

menu.popup([options])

  • options Object (optional)
    • window BaseWindow (optional) - 默认为当前聚焦窗口。
    • frame WebFrameMain (optional) - 如果希望 Writing Tools(macOS)等 OS 级功能正确工作,应提供相关 frame。通常应取 WebContentscontext-menu 事件中的 params.frame,或 focusedFrame 属性
    • x number (optional) - 默认为当前鼠标光标位置。声明了 y 时声明 x 为必填。
    • y number (optional) - 默认为当前鼠标光标位置。声明了 x 时声明 y 为必填。
    • positioningItem number (optional) macOS - 指定坐标处应位于鼠标光标下方的菜单项索引,默认 -1。
    • sourceType string (optional) Windows Linux - 应映射为 context-menu 事件提供的 menuSourceType。不建议手动设置该值,只提供从其他 API 收到的值或保持 undefined。可取 nonemousekeyboardtouchtouchMenulongPresslongTaptouchHandlestylusadjustSelectionadjustSelectionReset
    • callback Function (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])

  • window BaseWindow (optional) - 默认为聚焦窗口。

关闭 window 中的上下文菜单。实现上,若传入 BaseWindow 实例则调用 closePopupAt(window.id);否则传 -1,使原生层 ClosePopupAt 关闭该菜单打开的所有 menu runner——因为一个 Menu 可能同时在多个窗口弹出。

五、项管理:append / insert / getMenuItemById 与 items 属性

  • menu.append(menuItem) - menuItem MenuItem:把 menuItem 追加到菜单末尾。实现即 insert(this.getItemCount(), item)menu.ts#L158-L160)。
  • menu.insert(pos, menuItem) - pos Integer、menuItem MenuItem:插入到 pos 位置。JS 层会校验项类型必须是 MenuItem(否则 TypeError: Invalid item),且 pos 不能小于 0 或大于当前项总数(否则 RangeError);插入后同步设置 toolTipiconrole、自定义 type(palette/header)、macOS badge,并把 menu 反向挂到该项上。
  • menu.getMenuItemById(id) - id string:返回 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'

返回:event Event。当 menu.popup() 被调用时发出(更准确地说,在菜单即将展示的原生钩子处)。

Event: 'menu-will-close'

返回:event Event。当弹出菜单被手动关闭或被 menu.closePopup() 关闭时发出。

实现链路:C++ 的 ElectronMenuModelui::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-L863once(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 的每个查询方法(IsCommandIdCheckedIsCommandIdEnabledGetLabelForCommandIdGetIconForCommandIdGetAcceleratorForCommandIdWithParams 等)都通过 gin_helper::CallMethod 回调 JS 中对应 _xxx 下划线方法,再由 JS 从 commandsMap[id] 上读取 MenuItem 的真实属性。因此 MenuItemlabel/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) 保持存活,popupcallback 则通过 BindSelfToClosure 持有 JS 引用直至回调执行,防止回调触发前菜单被 GC。

ElectronMenuModel 额外维护了原生侧才有的元数据映射:tooltip、role、自定义类型(palette/header)、macOS badge 与 SharingItem(electron_menu_model.ccelectron_api_menu.cc#L26-L64),这些在 insert 时由 JS 层按平台条件调用 setToolTip/setRole/setCustomType/setBadge 写入(menu.ts#L177-L185)。

八、实战:结合 context-menu 事件构建动态上下文菜单

综合上述 API,一个标准的上下文菜单实现如下(webContentscontext-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 });
}

要点复述:

  1. template 元素上可附加自定义字段,构建后成为 MenuItem 属性,便于携带逻辑标记;
  2. 相邻与首尾的 separator 会被自动折叠/移除,模板里无需精确控制分隔线数量;
  3. 通过 id + before/after 可让多个菜单来源(如插件)声明相对位置,构建时自动拓扑排序;
  4. 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
登录后查看全文
热门项目推荐
相关项目推荐