04 · 原生能力实战:菜单、托盘、通知、快捷键、对话框

目标:用实例演练 Electron 最常用的桌面原生能力。官方 添加功能 章节是能力索引,本章挑选高频项给出完整代码。 官方对应:API 文档 各模块

综合实例:迷你”系统信息面板”

一个托盘驻留的小工具:托盘图标 + 菜单,点击菜单项通过 IPC 把系统信息推到页面;支持原生通知和全局快捷键。涉及模块:Menu / Tray / Notification / globalShortcut / dialog / shell / clipboard / os。

main.js(完整示例)

const {
  app, BrowserWindow, Tray, Menu, Notification,
  globalShortcut, dialog, ipcMain, shell, clipboard, nativeImage
} = require('electron')
const path = require('node:path')
const os = require('node:os')
 
// 内联 16x16 托盘图标(base64 PNG),零外部依赖;实际项目换成打包资源里的图标文件
const TRAY_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAO0lEQVR4nGNgoAXwmnDzPzZMkWaiDCGkGa8hxGrGagipmjEMGTWACgZQHI1USUjEGoJXMyFDiNJMKgAAjhukiGPUSaIAAAAASUVORK5CYII='
 
let win
let tray
 
const createWindow = () => {
  win = new BrowserWindow({
    width: 600,
    height: 400,
    webPreferences: { preload: path.join(__dirname, 'preload.js') }
  })
  win.loadFile('index.html')
}
 
// ---------- 托盘 ----------
const createTray = () => {
  tray = new Tray(nativeImage.createFromDataURL(TRAY_ICON))
  tray.setContextMenu(Menu.buildFromTemplate([
    { label: '显示窗口', click: () => win.show() },
    { label: '复制系统信息', click: () => clipboard.writeText(getSystemInfo()) },
    { label: '发送通知', click: () => sendNotification() },
    { type: 'separator' },
    { label: '退出', click: () => app.quit() }
  ]))
  tray.setToolTip('系统信息面板')
  // 注意:macOS 左击托盘默认直接弹上下文菜单,此回调主要对 Windows/Linux 生效
  tray.on('click', () => win.isVisible() ? win.hide() : win.show())
}
 
// ---------- 通知 ----------
const sendNotification = () => {
  if (Notification.isSupported()) {
    new Notification({ title: '系统信息', body: getSystemInfo() }).show()
  }
}
 
const getSystemInfo = () =>
  `${os.type()} ${os.release()} | ${os.cpus().length} 核 | 内存 ${(os.totalmem() / 1024 ** 3).toFixed(1)} GB`
 
// ---------- IPC:渲染器请求系统信息 ----------
ipcMain.handle('system:info', () => ({
  platform: os.type(),
  cpu: os.cpus()[0].model,
  cores: os.cpus().length,
  totalmem: os.totalmem(),
  freemem: os.freemem(),
  uptime: os.uptime()
}))
 
// ---------- IPC:保存文件对话框 ----------
ipcMain.handle('dialog:save', async (_event, defaultName) => {
  const { canceled, filePath } = await dialog.showSaveDialog(win, {
    defaultPath: defaultName
  })
  return canceled ? null : filePath
})
 
app.whenReady().then(() => {
  createWindow()
  createTray()
 
  // 全局快捷键:即使窗口失焦也生效(需退出时注销)
  globalShortcut.register('CommandOrControl+Shift+I', () => sendNotification())
 
  // 应用菜单:Cmd+O 打开 GitHub
  Menu.setApplicationMenu(Menu.buildFromTemplate([
    {
      label: '文件',
      submenu: [
        {
          label: '打开 GitHub',
          accelerator: 'CommandOrControl+O',
          click: () => shell.openExternal('https://github.com')
        },
        { role: 'quit' }  // 内置角色,自动处理多平台差异
      ]
    },
    { role: 'editMenu' }  // macOS 必备:复制/粘贴等
  ]))
})
 
app.on('will-quit', () => {
  globalShortcut.unregisterAll()   // 忘记注销会报错
})
 
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

preload.js

const { contextBridge, ipcRenderer } = require('electron')
 
contextBridge.exposeInMainWorld('systemAPI', {
  getInfo: () => ipcRenderer.invoke('system:info'),
  saveFile: (name) => ipcRenderer.invoke('dialog:save', name)
})

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy"
          content="default-src 'self'; script-src 'self'" />
    <title>系统信息面板</title>
    <style>
      body { font-family: -apple-system, sans-serif; padding: 24px; }
      button { margin-right: 8px; }
      #info { margin-top: 16px; color: #333; }
      #saved { margin-top: 8px; color: #0a7d32; font-size: 13px; }
    </style>
  </head>
  <body>
    <h3>系统信息面板</h3>
    <button id="btn" type="button">获取系统信息</button>
    <button id="saveBtn" type="button">保存为文件</button>
    <p id="info">点击按钮获取…</p>
    <p id="saved"></p>
    <script src="./renderer.js"></script>
  </body>
</html>

renderer.js

let lastInfoText = ''
 
document.getElementById('btn').addEventListener('click', async () => {
  const info = await systemAPI.getInfo()
  lastInfoText =
    `${info.platform} | ${info.cores} 核 | 内存 ${(info.totalmem / 1024 ** 3).toFixed(1)} GB`
  document.getElementById('info').textContent = lastInfoText
})
 
document.getElementById('saveBtn').addEventListener('click', async () => {
  const filePath = await systemAPI.saveFile('system-info.txt')
  document.getElementById('saved').textContent = filePath ? `已保存: ${filePath}` : '已取消'
})

注:dialog:save handler 只弹对话框并返回路径,真正写文件可加 fs.promises.writeFile(filePath, lastInfoText)(主进程里经 IPC 接收文本)。

能力速查表

模块用途关键点
Menu / MenuItem应用菜单、右键菜单role 内置角色省心;accelerator 配快捷键;CommandOrControl 跨平台写法
Tray托盘图标 + 菜单图标文件要随包分发,用 path.join(__dirname);nativeImage.createFromDataURL 可用内联 base64
Notification系统通知先 Notification.isSupported();Linux 部分发行版需 libnotify
globalShortcut全局热键will-quit 时 unregisterAll();与页面内快捷键(keydown)区分
dialog打开/保存/消息框showOpenDialog / showSaveDialog / showMessageBox 均为异步
shell打开外部链接/文件openExternal 不要传不可信内容(安全,见 06-security-performance)
clipboard读写剪贴板主进程渲染进程均可用(common 模块)
os(Node)系统信息主进程直接用,经 IPC 暴露给页面

常见场景片段

窗口内快捷键(非全局)

渲染进程里直接监听键盘即可,无需 Electron API:

window.addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key === 's') {
    e.preventDefault()
    // 触发保存逻辑
  }
})

无边框窗口(自定义标题栏)

const win = new BrowserWindow({ frame: false })
// CSS 里给自定义标题栏区域加:
// -webkit-app-region: drag;    可拖动
// 按钮区域加 -webkit-app-region: no-drag;

加载远程页面时的安全姿势

// 远程内容一律用 <webview> 或 WebContentsView,
// 且 nodeIntegration: false、contextIsolation: true
win.loadURL('https://example.com')

练习

  1. 给托盘菜单加”定时通知”开关,每 30 秒发一次通知(setInterval + tray.destroy() 时清理)
  2. 用 dialog.showMessageBox 在退出前弹确认框(win.on('close') 里 event.preventDefault())
  3. 做一个无边框窗口 + 自定义标题栏(拖拽 + 最小化/关闭按钮,走 IPC)

小结

  • 原生能力全部在主进程,页面经 03-ipc 调用
  • 菜单优先用 role;快捷键区分”全局”(globalShortcut)与”窗口内”(DOM keydown)
  • 下一章:05-packaging-distribution — 打包、签名与自动更新