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:savehandler 只弹对话框并返回路径,真正写文件可加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')练习
- 给托盘菜单加”定时通知”开关,每 30 秒发一次通知(
setInterval+tray.destroy()时清理) - 用
dialog.showMessageBox在退出前弹确认框(win.on('close')里event.preventDefault()) - 做一个无边框窗口 + 自定义标题栏(拖拽 + 最小化/关闭按钮,走 IPC)
小结
- 原生能力全部在主进程,页面经 03-ipc 调用
- 菜单优先用
role;快捷键区分”全局”(globalShortcut)与”窗口内”(DOM keydown) - 下一章:05-packaging-distribution — 打包、签名与自动更新