跳到主要内容

菜单与托盘

菜单和系统托盘是桌面应用的重要组成部分。本节介绍如何创建应用菜单、上下文菜单和系统托盘图标。

应用菜单

应用菜单是桌面应用的标准组成部分,通常包含文件、编辑、视图等菜单项。

创建菜单

使用 Menu 模块创建应用菜单:

const { app, BrowserWindow, Menu } = require('electron')

const template = [
{
label: '文件',
submenu: [
{
label: '新建',
accelerator: 'CmdOrCtrl+N',
click: () => createNewDocument()
},
{
label: '打开',
accelerator: 'CmdOrCtrl+O',
click: () => openFile()
},
{ type: 'separator' },
{
label: '退出',
accelerator: 'CmdOrCtrl+Q',
role: 'quit'
}
]
},
{
label: '编辑',
submenu: [
{ role: 'undo', label: '撤销' },
{ role: 'redo', label: '重做' },
{ type: 'separator' },
{ role: 'cut', label: '剪切' },
{ role: 'copy', label: '复制' },
{ role: 'paste', label: '粘贴' },
{ role: 'selectAll', label: '全选' }
]
},
{
label: '视图',
submenu: [
{ role: 'reload', label: '重新加载' },
{ role: 'forceReload', label: '强制重新加载' },
{ role: 'toggleDevTools', label: '开发者工具' },
{ type: 'separator' },
{ role: 'resetZoom', label: '重置缩放' },
{ role: 'zoomIn', label: '放大' },
{ role: 'zoomOut', label: '缩小' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: '全屏' }
]
},
{
label: '窗口',
submenu: [
{ role: 'minimize', label: '最小化' },
{ role: 'zoom', label: '缩放' },
{ type: 'separator' },
{ role: 'front', label: '前置全部窗口' }
]
},
{
label: '帮助',
submenu: [
{
label: '关于',
click: () => showAbout()
},
{
label: '文档',
click: async () => {
const { shell } = require('electron')
await shell.openExternal('https://example.com/docs')
}
}
]
}
]

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

内置角色

Electron 提供了内置角色,可以快速添加常用菜单项:

角色说明
undo撤销
redo重做
cut剪切
copy复制
paste粘贴
selectAll全选
delete删除
reload重新加载
forceReload强制重新加载
toggleDevTools切换开发者工具
resetZoom重置缩放
zoomIn放大
zoomOut缩小
togglefullscreen切换全屏
minimize最小化
close关闭
quit退出
front前置窗口

快捷键

使用 accelerator 属性设置快捷键:

{
label: '新建',
accelerator: 'CmdOrCtrl+N',
click: () => createNewDocument()
}

平台相关快捷键使用 CmdOrCtrl

accelerator: 'CmdOrCtrl+S'

这会在 macOS 上映射为 Cmd+S,在 Windows/Linux 上映射为 Ctrl+S

动态菜单

根据应用状态动态更新菜单:

const { Menu } = require('electron')

let isEditing = false

function updateMenu() {
const template = [
{
label: '编辑',
submenu: [
{ role: 'undo', enabled: isEditing },
{ role: 'redo', enabled: isEditing },
{ type: 'separator' },
{ role: 'cut', enabled: isEditing },
{ role: 'copy', enabled: isEditing },
{ role: 'paste', enabled: isEditing }
]
}
]

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

function setEditingState(state) {
isEditing = state
updateMenu()
}

macOS 特有菜单

macOS 有一些特殊的菜单约定:

应用菜单

第一个菜单应该是应用名称菜单:

const template = [
{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
}
]

上下文菜单

上下文菜单(右键菜单)在用户右键点击时显示。

创建上下文菜单

const { Menu } = require('electron')

const contextMenu = Menu.buildFromTemplate([
{ label: '复制', role: 'copy' },
{ label: '粘贴', role: 'paste' },
{ type: 'separator' },
{
label: '自定义操作',
click: () => console.log('自定义操作被点击')
}
])

// 在窗口中显示
contextMenu.popup()

在渲染进程中触发

主进程

const { ipcMain, Menu } = require('electron')

ipcMain.on('show-context-menu', (event) => {
const contextMenu = Menu.buildFromTemplate([
{ label: '复制', role: 'copy' },
{ label: '粘贴', role: 'paste' }
])

const win = BrowserWindow.fromWebContents(event.sender)
contextMenu.popup(win)
})

预加载脚本

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
showContextMenu: () => ipcRenderer.send('show-context-menu')
})

渲染进程

document.addEventListener('contextmenu', (e) => {
e.preventDefault()
window.electronAPI.showContextMenu()
})

使用 webContents 事件

更好的方式是监听 context-menu 事件:

const { BrowserWindow, Menu } = require('electron')

mainWindow.webContents.on('context-menu', (event, params) => {
const menu = Menu.buildFromTemplate([
{ label: '复制', role: 'copy', enabled: params.editFlags.canCopy },
{ label: '粘贴', role: 'paste', enabled: params.editFlags.canPaste },
{ type: 'separator' },
{ label: '检查元素', click: () => mainWindow.webContents.inspectElement(params.x, params.y) }
])

menu.popup()
})

params 对象包含丰富的上下文信息:

{
x: 100,
y: 200,
linkURL: 'https://example.com',
linkText: '链接文本',
pageURL: 'file:///path/to/page.html',
selectionText: '选中的文本',
editFlags: {
canUndo: true,
canRedo: false,
canCut: true,
canCopy: true,
canPaste: true,
canDelete: true,
canSelectAll: true
}
}

系统托盘

系统托盘图标可以让应用在后台运行,并提供快速访问入口。

创建托盘图标

const { app, BrowserWindow, Tray, Menu } = require('electron')
const path = require('node:path')

let tray

function createTray() {
const iconPath = path.join(__dirname, 'icon.png')
tray = new Tray(iconPath)

const contextMenu = Menu.buildFromTemplate([
{ label: '显示窗口', click: () => mainWindow.show() },
{ label: '隐藏窗口', click: () => mainWindow.hide() },
{ type: 'separator' },
{ label: '退出', click: () => app.quit() }
])

tray.setToolTip('我的应用')
tray.setContextMenu(contextMenu)

tray.on('click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
})
}

app.whenReady().then(() => {
createWindow()
createTray()
})

托盘事件

tray.on('click', () => {
console.log('托盘被点击')
})

tray.on('double-click', () => {
console.log('托盘被双击')
})

tray.on('right-click', () => {
console.log('托盘被右键点击')
})

tray.on('mouse-enter', () => {
console.log('鼠标进入托盘')
})

tray.on('mouse-leave', () => {
console.log('鼠标离开托盘')
})

更新托盘图标

tray.setImage(path.join(__dirname, 'new-icon.png'))
tray.setToolTip('新提示文本')

托盘气泡通知

tray.displayBalloon({
title: '通知标题',
content: '通知内容',
iconType: 'info'
})

macOS 托盘特性

macOS 上托盘图标有一些特殊行为:

tray.setTitle('标题文字')
tray.setIgnoreDoubleClickEvents(true)

菜单项类型

普通菜单项

{
label: '普通菜单项',
click: () => console.log('被点击')
}

分隔符

{ type: 'separator' }

复选框

{
label: '自动保存',
type: 'checkbox',
checked: true,
click: (menuItem) => {
console.log('自动保存:', menuItem.checked)
}
}

单选按钮

{
label: '主题',
submenu: [
{ label: '浅色', type: 'radio', checked: true },
{ label: '深色', type: 'radio' },
{ label: '自动', type: 'radio' }
]
}

子菜单

{
label: '最近文件',
submenu: [
{ label: '文件1.txt' },
{ label: '文件2.txt' },
{ label: '文件3.txt' }
]
}

图标菜单项

{
label: '带图标的菜单项',
icon: nativeImage.createFromPath('icon.png')
}

完整示例

下面是一个完整的菜单和托盘示例:

const { app, BrowserWindow, Menu, Tray, shell, dialog } = require('electron')
const path = require('node:path')

let mainWindow
let tray
let recentFiles = []

function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})

mainWindow.loadFile('index.html')

mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault()
mainWindow.hide()
}
})
}

function buildMenu() {
const template = [
{
label: '文件',
submenu: [
{
label: '新建',
accelerator: 'CmdOrCtrl+N',
click: () => mainWindow.webContents.send('new-file')
},
{
label: '打开',
accelerator: 'CmdOrCtrl+O',
click: async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ name: '文本文件', extensions: ['txt', 'md'] }]
})
if (!canceled && filePaths.length > 0) {
const filePath = filePaths[0]
addRecentFile(filePath)
mainWindow.webContents.send('open-file', filePath)
}
}
},
{
label: '最近文件',
submenu: recentFiles.map(file => ({
label: file,
click: () => mainWindow.webContents.send('open-file', file)
}))
},
{ type: 'separator' },
{
label: '退出',
accelerator: 'CmdOrCtrl+Q',
click: () => {
app.isQuitting = true
app.quit()
}
}
]
},
{
label: '编辑',
submenu: [
{ role: 'undo', label: '撤销' },
{ role: 'redo', label: '重做' },
{ type: 'separator' },
{ role: 'cut', label: '剪切' },
{ role: 'copy', label: '复制' },
{ role: 'paste', label: '粘贴' },
{ role: 'selectAll', label: '全选' }
]
},
{
label: '视图',
submenu: [
{ role: 'reload', label: '重新加载' },
{ role: 'toggleDevTools', label: '开发者工具' },
{ type: 'separator' },
{ role: 'togglefullscreen', label: '全屏' }
]
},
{
label: '帮助',
submenu: [
{
label: '文档',
click: async () => {
await shell.openExternal('https://example.com/docs')
}
},
{
label: '关于',
click: () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: '关于',
message: '我的应用',
detail: '版本 1.0.0'
})
}
}
]
}
]

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

function createTray() {
const iconPath = path.join(__dirname, 'icon.png')
tray = new Tray(iconPath)

const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
click: () => {
mainWindow.show()
mainWindow.focus()
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
app.isQuitting = true
app.quit()
}
}
])

tray.setToolTip('我的应用')
tray.setContextMenu(contextMenu)

tray.on('click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show()
})
}

function addRecentFile(filePath) {
recentFiles = recentFiles.filter(f => f !== filePath)
recentFiles.unshift(filePath)
if (recentFiles.length > 10) {
recentFiles = recentFiles.slice(0, 10)
}
buildMenu()
}

app.whenReady().then(() => {
createWindow()
buildMenu()
createTray()
})

app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})

app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})

最佳实践

菜单设计原则

  1. 遵循平台惯例:macOS 和 Windows 的菜单习惯不同
  2. 合理分组:使用分隔符分隔不同功能的菜单项
  3. 提供快捷键:常用操作应该有快捷键
  4. 禁用不可用项:根据应用状态禁用不合适的菜单项

托盘设计原则

  1. 提供基本操作:显示/隐藏窗口、退出应用
  2. 双击行为:双击托盘图标通常显示主窗口
  3. 右键菜单:提供更多操作选项
  4. 状态指示:通过图标变化显示应用状态

总结

菜单和托盘是桌面应用的重要交互方式:

  1. 使用 Menu.buildFromTemplate 创建菜单
  2. 内置角色简化常用菜单项的创建
  3. 上下文菜单提供右键操作
  4. 系统托盘让应用在后台运行