一、核心概念

在 Electron 中,菜单系统分为三个层次:

类型 作用域 触发方式 典型场景
应用菜单 整个应用 macOS 顶部栏 / Windows 窗口标题栏下 文件、编辑、视图、帮助等标准菜单
右键菜单 特定元素/区域 鼠标右键点击 复制粘贴、刷新、自定义操作
全局快捷键 系统级 键盘组合键(即使应用失焦) 截图、录音、快速唤出窗口

二、应用菜单 (Application Menu)

2.1 基础结构

// main.js - 主进程
const { Menu, app, BrowserWindow } = require('electron')

const template = [
  {
    label: '文件',
    submenu: [
      {
        label: '新建窗口',
        click: () => {
          new BrowserWindow({ width: 800, height: 600 })
        },
        accelerator: 'CmdOrCtrl+N'  // 快捷键
      },
      {
        label: '打开文件',
        click: async () => {
          const { dialog } = require('electron')
          const result = await dialog.showOpenDialog({ properties: ['openFile'] })
          console.log(result.filePaths)
        },
        accelerator: 'CmdOrCtrl+O'
      },
      { type: 'separator' },  // 分隔线
      {
        label: '退出',
        click: () => app.quit(),
        accelerator: 'CmdOrCtrl+Q'
      }
    ]
  },
  {
    label: '编辑',
    submenu: [
      { label: '撤销', role: 'undo' },
      { label: '重做', role: 'redo' },
      { type: 'separator' },
      { label: '剪切', role: 'cut' },
      { label: '复制', role: 'copy' },
      { label: '粘贴', role: 'paste' }
    ]
  },
  {
    label: '视图',
    submenu: [
      { label: '重载', role: 'reload' },
      { label: '强制重载', role: 'forceReload' },
      { label: '开发者工具', role: 'toggleDevTools' },
      { type: 'separator' },
      {
        label: '实际大小',
        role: 'resetZoom'
      },
      {
        label: '放大',
        role: 'zoomIn'
      },
      {
        label: '缩小',
        role: 'zoomOut'
      }
    ]
  },
  {
    label: '帮助',
    submenu: [
      {
        label: '关于',
        click: () => {
          const { dialog } = require('electron')
          dialog.showMessageBox({
            title: '关于',
            message: '我的 Electron 应用 v1.0.0'
          })
        }
      }
    ]
  }
]

// macOS 特殊处理:第一个菜单会自动变成应用名菜单
if (process.platform === 'darwin') {
  template.unshift({
    label: app.getName(),
    submenu: [
      { label: '关于', role: 'about' },
      { type: 'separator' },
      { label: '偏好设置', accelerator: 'CmdOrCtrl+,', click: () => {} },
      { type: 'separator' },
      { label: '服务', role: 'services' },
      { type: 'separator' },
      { label: '隐藏', role: 'hide' },
      { label: '隐藏其他', role: 'hideOthers' },
      { label: '显示全部', role: 'unhide' },
      { type: 'separator' },
      { label: '退出', role: 'quit' }
    ]
  })
}

const menu = Menu.buildFromTemplate(template)
Menu.setApplicationMenu(menu)

2.2 常用 Role(内置行为)

Electron 提供了大量内置 role,无需手动实现:

// 标准 role 列表
const roles = {
  // 窗口控制
  'minimize': '最小化',
  'maximize': '最大化',
  'close': '关闭',
  
  // 编辑操作
  'undo': '撤销',
  'redo': '重做',
  'cut': '剪切',
  'copy': '复制',
  'paste': '粘贴',
  'selectAll': '全选',
  
  // 视图操作
  'reload': '重载页面',
  'forceReload': '强制重载',
  'toggleDevTools': '打开/关闭开发者工具',
  'resetZoom': '重置缩放',
  'zoomIn': '放大',
  'zoomOut': '缩小',
  
  // macOS 专属
  'about': '关于',
  'hide': '隐藏应用',
  'hideOthers': '隐藏其他应用',
  'unhide': '显示所有应用',
  'quit': '退出'
}

2.3 动态菜单更新

// 根据窗口状态动态修改菜单
function updateMenu(windowCount) {
  const menu = Menu.getApplicationMenu()
  const fileMenu = menu.items.find(item => item.label === '文件')
  
  if (fileMenu && fileMenu.submenu) {
    const closeItem = fileMenu.submenu.items.find(item => item.label === '关闭窗口')
    if (closeItem) {
      closeItem.enabled = windowCount > 0
      closeItem.visible = windowCount > 0
    }
  }
}

// 监听窗口事件
app.on('browser-window-created', () => updateMenu(BrowserWindow.getAllWindows().length))
app.on('browser-window-destroyed', () => updateMenu(BrowserWindow.getAllWindows().length))

三、右键菜单 (Context Menu)

3.1 基础右键菜单

// preload.js - 暴露 API 给渲染进程
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  showContextMenu: (options) => ipcRenderer.invoke('show-context-menu', options)
})
// main.js - 主进程处理
ipcMain.handle('show-context-menu', async (event, options) => {
  const template = [
    {
      label: '复制',
      role: 'copy',
      accelerator: 'CmdOrCtrl+C'
    },
    {
      label: '粘贴',
      role: 'paste',
      accelerator: 'CmdOrCtrl+V'
    },
    { type: 'separator' },
    {
      label: '自定义选项',
      click: () => {
        event.sender.send('context-menu-click', '自定义操作')
      }
    }
  ]
  
  // 添加动态选项
  if (options.hasSelection) {
    template.unshift({
      label: `搜索 "${options.selection}"`,
      click: () => {
        // 执行搜索
      }
    })
  }
  
  const menu = Menu.buildFromTemplate(template)
  menu.popup()
})
<!-- renderer.vue - 渲染进程 -->
<template>
  <div @contextmenu.prevent="handleContextMenu">
    <p>右键点击这个区域</p>
    <input v-model="text" @contextmenu.prevent="handleContextMenu" />
  </div>
</template>

<script setup>
import { ref } from 'vue'

const text = ref('')

const handleContextMenu = (event) => {
  const selection = window.getSelection().toString()
  
  window.electronAPI.showContextMenu({
    hasSelection: selection.length > 0,
    selection: selection,
    x: event.clientX,
    y: event.clientY
  })
}
</script>

3.2 编辑器右键菜单(增强版)

// main.js - 完整编辑器右键菜单
function createEditorContextMenu(selectionText, isEditable) {
  const template = []
  
  // 根据是否有选中文本显示不同选项
  if (selectionText) {
    template.push(
      { label: `复制 "${selectionText.substring(0, 20)}${selectionText.length > 20 ? '...' : ''}"`, role: 'copy' },
      { label: '剪切', role: 'cut', enabled: isEditable },
      { type: 'separator' }
    )
  }
  
  template.push(
    { label: '粘贴', role: 'paste', enabled: isEditable },
    { label: '删除', role: 'delete', enabled: isEditable },
    { type: 'separator' },
    { label: '全选', role: 'selectAll' }
  )
  
  // 额外功能
  template.push(
    { type: 'separator' },
    {
      label: '格式化',
      submenu: [
        { label: '转为大写', click: () => {} },
        { label: '转为小写', click: () => {} }
      ]
    }
  )
  
  return Menu.buildFromTemplate(template)
}

四、全局快捷键 (Global Shortcut)

4.1 基础使用

// main.js
const { globalShortcut, app } = require('electron')

app.whenReady().then(() => {
  // 注册全局快捷键
  const ret = globalShortcut.register('CommandOrControl+Shift+S', () => {
    console.log('截图快捷键被按下')
    // 触发截图功能
    captureScreen()
  })
  
  if (!ret) {
    console.log('快捷键注册失败,可能被其他应用占用')
  }
  
  // 注册多个快捷键
  globalShortcut.register('CommandOrControl+Alt+R', () => {
    // 录音功能
    startRecording()
  })
  
  globalShortcut.register('MediaPlayPause', () => {
    // 媒体控制键
    togglePlayback()
  })
})

// 应用退出前注销所有快捷键
app.on('will-quit', () => {
  globalShortcut.unregisterAll()
})

4.2 动态管理快捷键

class ShortcutManager {
  constructor() {
    this.shortcuts = new Map()
  }
  
  // 注册快捷键
  register(name, accelerator, callback) {
    if (this.shortcuts.has(name)) {
      this.unregister(name)
    }
    
    const success = globalShortcut.register(accelerator, callback)
    if (success) {
      this.shortcuts.set(name, { accelerator, callback })
      console.log(`快捷键 ${name} (${accelerator}) 注册成功`)
    } else {
      console.error(`快捷键 ${name} (${accelerator}) 注册失败`)
    }
    return success
  }
  
  // 注销单个快捷键
  unregister(name) {
    const shortcut = this.shortcuts.get(name)
    if (shortcut) {
      globalShortcut.unregister(shortcut.accelerator)
      this.shortcuts.delete(name)
    }
  }
  
  // 切换快捷键启用状态
  toggle(name, enabled) {
    const shortcut = this.shortcuts.get(name)
    if (shortcut) {
      if (enabled) {
        globalShortcut.register(shortcut.accelerator, shortcut.callback)
      } else {
        globalShortcut.unregister(shortcut.accelerator)
      }
    }
  }
  
  // 检查快捷键是否被注册
  isRegistered(accelerator) {
    return globalShortcut.isRegistered(accelerator)
  }
}

// 使用示例
const shortcutManager = new ShortcutManager()

// 注册截图快捷键
shortcutManager.register('screenshot', 'CommandOrControl+Shift+S', () => {
  captureScreen()
})

// 根据用户设置动态启用/禁用
shortcutManager.toggle('screenshot', userSettings.enableShortcut)

4.3 快捷键冲突处理

// 检测快捷键冲突
async function registerWithConflictCheck(accelerator, callback) {
  // 检查是否已被其他应用占用
  if (globalShortcut.isRegistered(accelerator)) {
    const { dialog } = require('electron')
    const result = await dialog.showMessageBox({
      type: 'warning',
      title: '快捷键冲突',
      message: `快捷键 ${accelerator} 已被其他应用占用`,
      detail: '是否仍然要注册该快捷键?',
      buttons: ['仍然注册', '取消']
    })
    
    if (result.response === 1) return false
  }
  
  return globalShortcut.register(accelerator, callback)
}

五、完整实战示例

5.1 带配置的完整菜单系统

// main.js - 完整应用
const { app, BrowserWindow, Menu, globalShortcut, ipcMain } = require('electron')
const Store = require('electron-store')

const store = new Store({ name: 'shortcuts-config' })

class MenuManager {
  constructor() {
    this.window = null
    this.shortcuts = {}
  }
  
  init(mainWindow) {
    this.window = mainWindow
    this.loadUserShortcuts()
    this.setupApplicationMenu()
    this.setupGlobalShortcuts()
    this.setupIPCHandlers()
  }
  
  loadUserShortcuts() {
    // 从存储加载用户自定义快捷键
    const saved = store.get('shortcuts', {})
    this.shortcuts = {
      'screenshot': saved.screenshot || 'CommandOrControl+Shift+S',
      'recording': saved.recording || 'CommandOrControl+Shift+R',
      'showWindow': saved.showWindow || 'CommandOrControl+Shift+W',
      ...saved
    }
  }
  
  setupApplicationMenu() {
    const template = [
      {
        label: '应用',
        submenu: [
          {
            label: '偏好设置',
            accelerator: 'CommandOrControl+,',
            click: () => {
              this.window.webContents.send('open-settings')
            }
          },
          { type: 'separator' },
          { label: '退出', role: 'quit' }
        ]
      },
      {
        label: '工具',
        submenu: [
          {
            label: '截图',
            accelerator: this.shortcuts.screenshot,
            click: () => this.captureScreen()
          },
          {
            label: '录音',
            accelerator: this.shortcuts.recording,
            click: () => this.startRecording()
          },
          {
            label: '显示主窗口',
            accelerator: this.shortcuts.showWindow,
            click: () => this.showMainWindow()
          }
        ]
      },
      {
        label: '帮助',
        submenu: [
          {
            label: '快捷键设置',
            click: () => {
              this.window.webContents.send('open-shortcut-settings')
            }
          }
        ]
      }
    ]
    
    const menu = Menu.buildFromTemplate(template)
    Menu.setApplicationMenu(menu)
  }
  
  setupGlobalShortcuts() {
    // 截图快捷键
    globalShortcut.register(this.shortcuts.screenshot, () => {
      this.captureScreen()
    })
    
    // 录音快捷键
    globalShortcut.register(this.shortcuts.recording, () => {
      this.startRecording()
    })
    
    // 显示窗口快捷键
    globalShortcut.register(this.shortcuts.showWindow, () => {
      this.showMainWindow()
    })
  }
  
  updateShortcut(name, newAccelerator) {
    // 更新存储
    this.shortcuts[name] = newAccelerator
    store.set(`shortcuts.${name}`, newAccelerator)
    
    // 重新注册快捷键
    globalShortcut.unregisterAll()
    this.setupGlobalShortcuts()
    
    // 更新菜单显示
    this.setupApplicationMenu()
  }
  
  captureScreen() {
    console.log('执行截图...')
    this.window.webContents.send('trigger-screenshot')
  }
  
  startRecording() {
    console.log('开始录音...')
    this.window.webContents.send('start-recording')
  }
  
  showMainWindow() {
    if (this.window.isMinimized()) this.window.restore()
    this.window.show()
    this.window.focus()
  }
  
  setupIPCHandlers() {
    ipcMain.handle('update-shortcut', (event, { name, accelerator }) => {
      this.updateShortcut(name, accelerator)
      return { success: true }
    })
    
    ipcMain.handle('get-shortcuts', () => {
      return this.shortcuts
    })
  }
}

5.2 渲染进程配置界面

<!-- ShortcutSettings.vue -->
<template>
  <div class="shortcut-settings">
    <h2>快捷键设置</h2>
    
    <div v-for="shortcut in shortcuts" :key="shortcut.name" class="shortcut-item">
      <label>{{ shortcut.label }}</label>
      <div class="shortcut-input">
        <input 
          v-model="shortcut.accelerator" 
          @keydown="handleKeydown($event, shortcut)"
          :disabled="shortcut.isRecording"
          placeholder="点击录制"
        />
        <button @click="startRecording(shortcut)">录制</button>
        <button @click="resetShortcut(shortcut)">重置</button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const shortcuts = ref([
  { name: 'screenshot', label: '截图', accelerator: 'CommandOrControl+Shift+S', isRecording: false },
  { name: 'recording', label: '开始录音', accelerator: 'CommandOrControl+Shift+R', isRecording: false },
  { name: 'showWindow', label: '显示主窗口', accelerator: 'CommandOrControl+Shift+W', isRecording: false }
])

const handleKeydown = (event, shortcut) => {
  if (!shortcut.isRecording) return
  
  event.preventDefault()
  
  const keys = []
  if (event.ctrlKey) keys.push('Ctrl')
  if (event.shiftKey) keys.push('Shift')
  if (event.altKey) keys.push('Alt')
  if (event.metaKey) keys.push('Command')
  
  const key = event.key.length === 1 ? event.key.toUpperCase() : event.key
  if (key !== 'Control' && key !== 'Shift' && key !== 'Alt' && key !== 'Meta') {
    keys.push(key)
    const accelerator = keys.join('+')
    
    // 更新本地显示
    shortcut.accelerator = accelerator
    shortcut.isRecording = false
    
    // 保存到主进程
    window.electronAPI.updateShortcut({
      name: shortcut.name,
      accelerator: accelerator
    })
  }
}

const startRecording = (shortcut) => {
  // 停止其他正在录制的
  shortcuts.value.forEach(s => s.isRecording = false)
  shortcut.isRecording = true
  shortcut.accelerator = '按下快捷键...'
}

const resetShortcut = (shortcut) => {
  const defaults = {
    screenshot: 'CommandOrControl+Shift+S',
    recording: 'CommandOrControl+Shift+R',
    showWindow: 'CommandOrControl+Shift+W'
  }
  shortcut.accelerator = defaults[shortcut.name]
  window.electronAPI.updateShortcut({
    name: shortcut.name,
    accelerator: defaults[shortcut.name]
  })
}

onMounted(async () => {
  const saved = await window.electronAPI.getShortcuts()
  shortcuts.value.forEach(shortcut => {
    if (saved[shortcut.name]) {
      shortcut.accelerator = saved[shortcut.name]
    }
  })
})
</script>

六、常见坑点与最佳实践

坑点总结

问题 原因 解决方案
macOS 应用菜单不显示 未处理 macOS 特殊逻辑 在 template 开头添加应用名菜单
快捷键注册失败 被其他应用占用 检查 globalShortcut.isRegistered() 并提示用户
右键菜单不显示 未调用 preventDefault() 在 @contextmenu 事件中添加 .prevent
无边框窗口无法拖拽 缺少 CSS 属性 设置 -webkit-app-region: drag
快捷键在开发工具中失效 DevTools 抢占了焦点 区分开发/生产环境处理

七、调试技巧

// 开发环境显示菜单调试信息
if (process.env.NODE_ENV === 'development') {
  const menu = Menu.getApplicationMenu()
  console.log('当前菜单结构:', JSON.stringify(menu, null, 2))
  
  // 添加开发菜单
  const devMenu = {
    label: '开发',
    submenu: [
      { label: '重新加载菜单', click: () => Menu.setApplicationMenu(Menu.buildFromTemplate(template)) },
      { label: '列出所有快捷键', click: () => console.log(globalShortcut.getAll()) }
    ]
  }
  
  const existingMenu = Menu.getApplicationMenu()
  const newTemplate = [...existingMenu.items, devMenu]
  Menu.setApplicationMenu(Menu.buildFromTemplate(newTemplate))
}

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐