窗口、桌面 API 与笔记应用
完整连接窗口、菜单、托盘、对话框、剪贴板和文件保存。
本页整合 Electron 归档分稿中的机制、接口和接线。归档使用 Electron 44 系列作为其版本讨论背景;本文接口依据官方文档及 breaking changes 核查,实际采用时仍需固定项目版本。包版本、维护状态和原稿实测均是对应日期的历史信息。本次未启动 Electron、构建安装包、签名、公证或验证更新。代码块按各自标注区分片段与组合示例。
0. 准备:本课所有示例的公共骨架
是什么/为什么:Electron 应用就是一个 npm 包,package.json 的 main 字段指向主进程入口脚本;Electron 本身安装为 devDependency(打包工具链会把它捆绑进产物,所以不算生产依赖)。以下骨架来自官方 "Building your First App" 教程,本课后续每个示例都在它之上替换文件即可运行。
mkdir my-app && cd my-app
npm init # entry point 填 main.js
npm install --save-dev electron{
"name": "my-app",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron ."
},
"devDependencies": {
"electron": "^44.5.1"
}
}<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- CSP(Content-Security-Policy,内容安全策略)限制页面只能加载自身资源 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>第二课示例</title>
</head>
<body>
<h1>Hello from Electron renderer!</h1>
<script src="./renderer.js"></script>
</body>
</html>之后每个示例给出完整的 main.js(需要渲染进程参与时再单独给出 preload.js / renderer.js),运行方式始终是 npm start。先记住四个文件各自的职责,后面所有内容都在这张表里展开:
| 文件 | 进程 | 职责 | 能用什么 |
|---|---|---|---|
main.js | 主进程(Node.js 环境) | 生命周期、窗口、菜单、托盘、文件读写 | Electron 全部 API + Node 内置模块 + npm 包 |
preload.js | 渲染进程的隔离上下文 | 给页面暴露最小白名单 API | contextBridge、ipcRenderer 等少数模块 |
renderer.js | 渲染进程(沙箱) | 界面逻辑 | 标准 Web API + preload 暴露的 window.xxx |
index.html | 渲染进程 | 界面结构 | 普通 HTML/CSS |
本课统一使用 CommonJS(require)——这也是官方教程的写法;Electron 自 28 起同样支持 ESM(import),初学阶段建议先用 CommonJS。另外,判断平台用 Node 的 process.platform,它只有三个可能值:win32(Windows)、linux、darwin(macOS)。
1. app 生命周期:先把"进程的生老病死"讲透
是什么:app 模块(Electron 对应用本身的控制器)是一个 Node.js EventEmitter(事件发射器,用 .on() 订阅事件)。为什么重要:主进程退出即应用退出;窗口何时创建、应用何时退出,都由这些事件决定。
核心事件(按一次「启动 → 用完 → Cmd/Ctrl+Q 退出」的时间线):
| 事件 | 触发时机 | 典型用法 |
|---|---|---|
ready | Electron 初始化完成,"Emitted once"。 | 之后才能创建窗口/使用大部分模块 |
window-all-closed | 所有窗口关闭后 | Windows/Linux 调 app.quit();macOS 留空以保活 |
before-quit | 应用开始关闭窗口之前 | event.preventDefault() 可阻止退出,常做「还有未保存内容?」确认 |
will-quit | 所有窗口已关、应用即将退出 | 注销全局快捷键等清理 |
quit | 应用退出时 | 参数带 exitCode |
activate | macOS 专有:点击 Dock 图标重新激活应用 | 无窗口时重建窗口 |
两个容易踩坑的细节(均来自官方 app 文档):① 若用户按了 Cmd+Q 或代码调用了 app.quit(),Electron 会先尝试关闭所有窗口再发出 will-quit,这条路径下 window-all-closed 不会被触发;② before-quit / will-quit / quit 在 Windows 系统关机/注销时不发出。
为什么平台惯例不同?macOS 应用没有"主窗口"的概念:窗口全关后应用仍留在 Dock(程序坞)里随时可再激活,所以在 macOS 上不要在 window-all-closed 里退出,而是靠 activate 事件按需重建窗口;Windows 与 Linux 用户则预期关掉所有窗口就是退出应用。Electron 不替你强制任何一种,只提供事件让你按平台实现惯例——这就是下面代码里 process.platform !== 'darwin' 判断的由来。
app.whenReady() 返回 Promise<void>,是官方推荐的等待 ready 的方式(比直接 app.on('ready') 少一些微妙陷阱)。下面这份演示代码把完整时间线打印出来,建议亲手跑一遍观察终端输出顺序:
const { app, BrowserWindow } = require('electron')
const createWindow = () => {
const win = new BrowserWindow({ width: 800, height: 600 }) // 尺寸默认值就是 800×600
win.loadFile('index.html')
win.on('closed', () => console.log('[win] closed'))
}
// before-quit 在窗口开始被关闭之前发出,这里可以拦截退出
app.on('before-quit', (e) => console.log('[app] before-quit'))
app.whenReady().then(() => {
console.log('[app] ready')
createWindow()
// macOS:点 Dock 图标时若无窗口则重建(activate 必须在 ready 之后订阅)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
// Windows/Linux 惯例:窗口全关即退出;macOS 惯例:应用继续驻留
app.on('window-all-closed', () => {
console.log('[app] window-all-closed')
if (process.platform !== 'darwin') app.quit()
})
app.on('will-quit', () => console.log('[app] will-quit'))
app.on('quit', (_e, code) => console.log('[app] quit, exitCode =', code))单实例锁:requestSingleInstanceLock
为什么需要:双击图标两次,默认会启动两个独立进程、写同一份配置文件。app.requestSingleInstanceLock()(请求单实例锁)让第二个进程立刻退出,并把启动参数转交给第一实例。
规则(官方 app 文档):返回 true 表示本进程是主实例,应继续运行;返回 false 表示锁已被占,应立即 app.quit()。第二实例的参数通过第一实例的 second-instance 事件送达,回调参数为 (event, argv, workingDirectory, additionalData);该事件「保证在 ready 之后发出」,典型响应是聚焦已有窗口。锁必须在应用顶层尽早请求(在创建任何窗口之前),这是官方示例的标准位置:
const { app, BrowserWindow } = require('electron')
const gotTheLock = app.requestSingleInstanceLock() // 尽早调用,返回是否拿到主实例身份
if (!gotTheLock) {
app.quit() // 第二实例:参数已转交,直接退出
} else {
// 第一实例:收到第二实例的启动参数时,聚焦并还原自己的主窗口
app.on('second-instance', (_event, argv, workingDirectory) => {
console.log('第二实例参数:', argv, '工作目录:', workingDirectory)
const win = BrowserWindow.getAllWindows()[0]
if (win) {
if (win.isMinimized()) win.restore()
win.focus()
}
})
app.whenReady().then(() => { /* createWindow() ... */ })
}版本差异备注:macOS 从 Finder 启动时系统本身会保证单实例(走 open-file / open-url 事件),但从命令行启动仍可多开,所以官方建议三平台统一使用此锁。macOS/Linux 上参数与 additionalData 合并为一条消息,上限 32MB(超出会被丢弃,second-instance 不触发)。
2. BrowserWindow:窗口选项与 webPreferences 逐项讲
是什么:BrowserWindow(浏览器窗口类)创建并管理一个显示 Web 内容的原生窗口,同时也是一个 EventEmitter。
先辨析两个最容易混的窗口事件,第 5 节的笔记应用全靠它们:close 在窗口将要关闭时发出,此时还能 event.preventDefault() 反悔(把"关闭"改成"隐藏到托盘"就靠它);closed 在窗口已经关闭后发出,此时窗口实例已被销毁,任何对它的访问都会抛 "Object has been destroyed"——所以全局保存窗口引用的地方必须在 closed 里把引用清掉。相应地,win.close() 会走完 close → closed 的礼貌流程(可拦截),而 win.destroy() 直接销毁、不给任何回调机会,只该用于异常清理。
2.1 顶层常用选项(默认值来自官方 BaseWindowOptions 结构文档)
| 选项 | 默认值 | 说明 |
|---|---|---|
width / height | 800 / 600 | 窗口像素尺寸 |
minWidth / minHeight | 0 | 最小尺寸约束 |
resizable | true | 是否可调整大小 |
title | "Electron" | 被 HTML <title> 覆盖 |
show | true | 创建时是否立即显示;配 ready-to-show 可做"无白屏启动" |
frame | true | 是否带系统边框/标题栏;false 即无边框窗口 |
parent / modal | null / false | 父窗口 / 模态(子窗口未关时阻塞父窗口) |
show: false + ready-to-show 是官方推荐的启动姿势——页面渲染出第一帧后才显示窗口,避免用户看到空白:
const win = new BrowserWindow({ show: false })
win.once('ready-to-show', () => win.show()) // 首帧渲染完成,现在再亮出窗口2.2 webPreferences:安全三件套与常用开关
webPreferences(Web 偏好,控制渲染进程环境的配置)是初学者最容易抄错的地方。当前默认值(很重要,网上旧教程常与它相反):
| 配置 | 默认值 | 该不该动 |
|---|---|---|
contextIsolation(上下文隔离,preload 与页面运行在不同 V8 上下文) | true(自 Electron 12) | 保持开启。关掉它,页面脚本就能摸到 preload 的特权对象,等于自拆防火墙 |
sandbox(渲染进程 Chromium 原生沙箱) | true(自 Electron 20) | 保持开启。沙箱渲染进程不初始化 Node,特权操作只能走 IPC |
nodeIntegration(页面直接用 Node API) | false | 保持关闭。需要 Node 能力时由主进程或 preload 代办 |
preload(预加载脚本,页面加载前运行) | 无 | 按需设置,必须是绝对路径 |
webSecurity(同源策略等 Web 安全机制) | true | 保持开启。为"跨域加载本地文件"关它是最常见的自杀式操作 |
devTools | true | 开发期保留 |
spellcheck | true | 输入框拼写检查,按需关 |
webviewTag(嵌入 <webview> 标签) | false | 不用就别开 |
常见误区:很多旧教程为了让"页面里直接 require('fs')"而写 nodeIntegration: true + contextIsolation: false,这套组合等于把整个 Node 交给任意网页代码——一旦加载了不可信内容(一个第三方脚本、一条被注入的广告),攻击者就能读写你的文件系统。正确姿势永远是:页面要什么能力,就在主进程实现什么 handler,再经 preload 暴露一个只做这一件事的函数。
结论一句话:默认值即安全值,你唯一常写的是 preload。推荐写法:
const path = require('node:path')
const win = new BrowserWindow({
width: 900,
height: 650,
webPreferences: {
preload: path.join(__dirname, 'preload.js') // __dirname 是当前文件所在目录的绝对路径
// 其余项一律不写,吃默认:contextIsolation:true、sandbox:true、nodeIntegration:false
}
})3. IPC 一条龙:按钮 → preload → 主进程 → dialog → 回显
是什么/为什么:渲染进程默认在沙箱里,没有 Node、没有原生 API。IPC(Inter-Process Communication,进程间通信)是渲染进程调用主进程能力的唯一正道。官方 IPC 教程把常用通信归纳为四种模式,先建立全景再动手:
| 方向 | API 组合 | 语义 | 本课示例 |
|---|---|---|---|
| 渲染 → 主(单向) | ipcRenderer.send + ipcMain.on | 发完不管(fire-and-forget) | — |
| 渲染 → 主(双向) | ipcRenderer.invoke + ipcMain.handle | 请求/响应,返回 Promise | §3、§5 笔记应用 |
| 主 → 渲染(单向) | webContents.send + ipcRenderer.on | 推送到特定窗口 | — |
| 渲染 ↔ 渲染 | 无原生直连 | 经主进程转发或 MessagePort 直连 | — |
本节用第二种(也是首选)的 invoke/handle 模式:官方推荐的调用形态是双向的 ipcRenderer.invoke + ipcMain.handle(invoke 发起调用,handle 注册处理器,返回值自动以 Promise 回传)。
下面这个完整示例(与官方 IPC 教程 Pattern 2 同构)串起全链路:页面点按钮 → preload 暴露的 electronAPI.openFile() → 主进程 ipcMain.handle('dialog:openFile') → 原生文件选择对话框 dialog.showOpenDialog → 选中的路径回传渲染进程显示。共四个文件:
const { app, BrowserWindow, dialog, ipcMain } = require('electron')
const path = require('node:path')
// 主进程处理器:调用原生对话框,把结果返回给 invoke 调用方
async function handleFileOpen() {
const { canceled, filePaths } = await dialog.showOpenDialog({}) // canceled 为 true 表示用户取消
if (!canceled) {
return filePaths[0] // 返回值会自动变成渲染进程 invoke 的 Promise 结果
}
}
function createWindow() {
const mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js') // 注入 preload 脚本
}
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
// ready 之后注册一次即可:ipcMain 是全局单例,所有窗口共享
ipcMain.handle('dialog:openFile', handleFileOpen)
createWindow()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})const { contextBridge, ipcRenderer } = require('electron')
// contextBridge 只允许暴露白名单函数;切勿把整个 ipcRenderer 透传给页面
// (自 Electron 29 起,ipcRenderer 本体经 contextBridge 传输只会得到空对象)
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile')
})<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Dialog</title>
</head>
<body>
<button type="button" id="btn">打开一个文件</button>
File path: <strong id="filePath"></strong>
<script src='./renderer.js'></script>
</body>
</html>const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')
btn.addEventListener('click', async () => {
const filePath = await window.electronAPI.openFile() // 像调普通 async 函数一样调主进程
filePathElement.innerText = filePath
})三个要点:① IPC 频道(channel)名任意,dialog: 这样的前缀只是命名空间习惯;② handle 里抛出的错误会被序列化,渲染进程只能拿到 message 属性(官方 issue #24427),所以主进程侧要自己做好错误处理;③ 反方向(主进程 → 渲染进程)用 webContents.send + ipcRenderer.on,没有 invoke 等价物——本课笔记应用会用到。
4. 常用桌面能力:每个一个最小可运行示例
以下示例除特别说明外,都是"骨架上替换 main.js、npm start 即可运行"的完整代码。
4.1 Menu:应用菜单
是什么:Menu(菜单)模块构建原生菜单栏/上下文菜单。MenuItem 可用 role(预定义角色)一键获得系统标准行为——能用 role 就别手写 click,label 与快捷键都会按平台自动配好(role 名不区分大小写)。若不设置任何菜单,Electron 会自动生成含 File/Edit/View/Window 的默认菜单;Menu.setApplicationMenu(null) 可将其隐藏。
const { app, BrowserWindow, Menu } = require('electron')
function createWindow() {
const win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
}
app.whenReady().then(() => {
const template = [
// macOS 第一个菜单必须是应用菜单,role: 'appMenu' 自动生成 About/Quit 等
...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
{
label: '文件',
submenu: [
{
label: '新建窗口',
accelerator: 'CmdOrCtrl+N', // accelerator 即快捷键写法
click: () => createWindow() // 设了 role 的项会忽略 click
},
{ type: 'separator' }, // 分隔线
{ role: 'quit', label: '退出' } // role 自带 Cmd/Ctrl+Q
]
},
{ role: 'editMenu', label: '编辑' }, // 撤销/复制/粘贴整套,免费
{
label: '视图',
submenu: [
{ role: 'reload', label: '重新加载' },
{ role: 'toggleDevTools', label: '开发者工具' }, // DevTools
{ type: 'separator' },
{ role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },
{ role: 'togglefullscreen' }
]
}
]
// macOS 设为应用菜单;Windows/Linux 设为窗口顶部菜单
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
createWindow()
})Menu.buildFromTemplate(由模板数组构建菜单)之外还有 menu.popup() 可在鼠标处弹出上下文菜单;托盘菜单则由 Tray 自动弹出,见下。
4.2 Tray:系统托盘
是什么:Tray(托盘)在系统通知区(macOS 菜单栏右侧 / Windows 任务栏通知区 / 视桌面环境而定的 Linux)放一个常驻图标。官方要点:① 只能在 ready 之后实例化;② 必须保存全局引用,否则被垃圾回收后图标消失;③ 菜单用 setContextMenu 挂上即可,不需要手动 popup。macOS 图标建议用 Template Image(文件名以 Template 结尾的模板图,系统自动适配深浅色);Windows 推荐 ICO。下面直接使用官方托盘教程提供的 16×16 图标 data URL,零素材依赖:
const { app, BrowserWindow, Tray, Menu } = require('electron')
const { nativeImage } = require('electron/common')
let tray = null // 全局引用,防止被 GC(垃圾回收)后托盘图标消失
let win = null
function createWindow() {
win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
}
app.whenReady().then(() => {
// 官方教程的 16x16 红色圆点图标(内联 data URL,无需图片文件)
const icon = nativeImage.createFromDataURL(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACTSURBVHgBpZKBCYAgEEV/TeAIjuIIbdQIuUGt0CS1gW1iZ2jIVaTnhw+Cvs8/OYDJA4Y8kR3ZR2/kmazxJbpUEfQ/Dm/UG7wVwHkjlQdMFfDdJMFaACebnjJGyDWgcnZu1/lrCrl6NCoEHJBrDwEr5NrT6ko/UV8xdLAC2N49mlc5CylpYh8wCwqrvbBGLoKGvz8Bfq0QPWEUo/EAAAAASUVORK5CYII='
)
tray = new Tray(icon)
tray.setToolTip('我的托盘应用') // 悬停提示文字
tray.setContextMenu(Menu.buildFromTemplate([{ role: 'quit', label: '退出' }]))
tray.on('click', () => {
// 窗口可能已被用户关闭销毁,先守卫再访问,否则会抛 "Object has been destroyed"
if (!win || win.isDestroyed()) { createWindow(); return }
win.isVisible() ? win.hide() : win.show() // 左键单击:显示/隐藏窗口
})
createWindow()
})
// 留空监听以阻止退出:窗口全关后应用(和托盘)继续存活——官方托盘教程的做法
app.on('window-all-closed', () => {})macOS 上 setTitle 还能在图标旁显示文字;right-click、double-click 等事件按需订阅。
4.3 dialog:文件选择 / 保存 / 消息框
是什么:dialog(对话框)模块调用原生文件选择、保存与消息框。每个对话框都有异步与同步两个版本(如 showOpenDialog / showOpenDialogSync),同步版返回裸值(string[] | undefined)、会阻塞主进程,异步版返回 Promise——优先用异步版,主进程卡住会连累所有窗口的交互。注意三个返回值约定:showOpenDialog 异步版返回 { canceled, filePaths }(取消时 filePaths 为空数组);showSaveDialog 返回 { canceled, filePath }(取消时 filePath 为空字符串);showMessageBox 返回 { response, checkboxChecked },response 是被点按钮的下标。showErrorBox(title, content) 可在 ready 之前使用,适合报告启动早期错误。给第一个参数传窗口可让对话框成为该窗口的模态。
const { app, BrowserWindow, Menu, dialog } = require('electron')
let win
app.whenReady().then(() => {
win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
Menu.setApplicationMenu(Menu.buildFromTemplate([
{
label: '对话框演示',
submenu: [
{
label: '选择文件(可多选)',
click: async () => {
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
title: '挑一个文件',
filters: [ // 文件类型过滤,扩展名不带点
{ name: '文本', extensions: ['txt', 'md'] },
{ name: '所有文件', extensions: ['*'] }
],
properties: ['openFile', 'multiSelections'] // openFile/multiSelections/openDirectory
})
if (!canceled) console.log('选中:', filePaths)
}
},
{
label: '保存文件',
click: async () => {
const { canceled, filePath } = await dialog.showSaveDialog(win, {
defaultPath: '未命名.txt', // 预填文件名
filters: [{ name: '文本', extensions: ['txt'] }]
})
if (!canceled && filePath) console.log('将保存到:', filePath)
}
},
{
label: '消息框',
click: async () => {
const { response } = await dialog.showMessageBox(win, {
type: 'question', // none/info/error/question/warning
buttons: ['保存', '放弃', '取消'],
defaultId: 0, // 默认高亮第 0 个按钮
message: '要保存修改吗?',
detail: '不保存的修改将丢失。'
})
console.log('用户点了第', response, '个按钮')
}
}
]
}
]))
})平台差异:Windows/Linux 上对话框不能同时作为文件与目录选择器(同时传 openFile + openDirectory 会变成目录选择器);macOS 官方建议保存对话框用异步版以避免展开/收起动画问题。
4.4 clipboard:剪贴板(⚠ Electron 44 重大变化)
是什么:clipboard(剪贴板)模块读写系统剪贴板。版本差异(必读):Electron 44 将该模块整体重构以对齐 W3C Clipboard API——readText() 现在返回 Promise<string>、writeText() 返回 Promise<void>、has() 返回 Promise<boolean>;新增 ClipboardItem 类,read()/write() 以 MIME 类型读写多格式数据;readImage / writeHTML 等窄接口已删除(图片用 image/png,HTML 用 text/html);同时模块不再暴露给渲染进程(40 起已弃用)——渲染进程要么用 Web 标准 navigator.clipboard,要么经 preload/IPC 由主进程代办。网上大量 clipboard.readText() 同步用法的旧教程在 44 上直接报错。
const { app, BrowserWindow, clipboard, ClipboardItem, nativeImage } = require('electron')
app.whenReady().then(async () => {
const win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
await clipboard.writeText('你好,剪贴板') // 注意:44 起全部是异步 API,要 await
const text = await clipboard.readText()
console.log('读回:', text) // 你好,剪贴板
// 旧 writeImage(image) 的等价写法:ClipboardItem + image/png + Blob(这里复用官方教程的 16x16 图标)
const img = nativeImage.createFromDataURL(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACTSURBVHgBpZKBCYAgEEV/TeAIjuIIbdQIuUGt0CS1gW1iZ2jIVaTnhw+Cvs8/OYDJA4Y8kR3ZR2/kmazxJbpUEfQ/Dm/UG7wVwHkjlQdMFfDdJMFaACebnjJGyDWgcnZu1/lrCrl6NCoEHJBrDwEr5NrT6ko/UV8xdLAC2N49mlc5CylpYh8wCwqrvbBGLoKGvz8Bfq0QPWEUo/EAAAAASUVORK5CYII='
)
await clipboard.write([new ClipboardItem({ 'image/png': new Blob([img.toPNG()]) })])
console.log('剪贴板里有文本吗?', await clipboard.has('text/plain')) // has 也返回 Promise
})4.5 Notification:系统通知
是什么:Notification(系统通知)在主进程创建 OS 桌面通知。要点:构造后必须显式调用 show() 才显示;Notification.isSupported() 先探测支持;click 事件里通常把主窗口拉回前台。平台差异:macOS 要求应用代码签名后通知才会出现(开发态未签名可能收到 failed 事件);Windows 上 urgency: 'critical' 仍会被系统收纳,须再配 timeoutType: 'never' 才不自动消失。渲染进程发通知请用 Web Notifications API。
const { app, BrowserWindow, Notification } = require('electron')
let win
app.whenReady().then(() => {
win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
if (Notification.isSupported()) {
const n = new Notification({
title: '应用已启动',
body: '点击这条通知返回应用窗口',
silent: false // 是否静音
})
n.on('click', () => { // 用户点通知:显示并聚焦主窗口
win.show()
win.focus()
})
n.show() // 不调用 show() 就不会显示
}
})4.6 globalShortcut:全局快捷键
是什么:globalShortcut(全局快捷键)注册系统级快捷键——应用不在前台也生效(菜单 accelerator 只在应用激活时生效,两者别混)。规则:只能在 ready 之后使用;register 返回 boolean 表示是否注册成功;若快捷键已被其他应用占用会静默失败(isRegistered() 仍返回 false);退出前应在 will-quit 里 unregisterAll()。
const { app, BrowserWindow, globalShortcut } = require('electron')
let win
app.whenReady().then(() => {
win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
// CommandOrControl:macOS 映射 Cmd,Windows/Linux 映射 Ctrl
const ok = globalShortcut.register('CommandOrControl+Alt+K', () => {
win.isVisible() ? win.focus() : win.show() // 全局唤起/聚焦窗口
})
if (!ok) console.log('注册失败:快捷键可能被其他应用占用')
})
app.on('will-quit', () => globalShortcut.unregisterAll()) // 退出前注销,否则快捷键会残留4.7 shell.openExternal:打开外部链接
是什么:shell(外壳)模块把事情交还给操作系统:openExternal(url) 用桌面默认方式打开协议 URL(https 链接即默认浏览器),openPath(path) 打开文件/目录(失败时 resolve 出错误信息字符串,成功为 ""),showItemInFolder(fullPath) 在文件管理器中定位文件,beep() 播放系统提示音。注意 openExternal 的 URL 在 Windows 上最长 2081 字符。
安全姿势(官方安全教程第 14/15 条):页面里的 target="_blank" / window.open 默认会在 Electron 里新开一个Electron 窗口,而不是浏览器——既割裂体验,也给不可信内容开了新的攻击面。而把任意 URL 直接喂给 openExternal 同样危险:file:// 或其他协议可能触发系统上注册的任意处理程序。正确做法是用 setWindowOpenHandler 拦截并转交系统,同时只放行可信协议:
const { app, BrowserWindow, shell } = require('electron')
app.whenReady().then(() => {
const win = new BrowserWindow({ width: 800, height: 600 })
win.loadFile('index.html')
win.webContents.setWindowOpenHandler(({ url }) => {
// 只放行 http/https;file:// 等其他协议一律拒绝,防止 openExternal 打开意外协议
if (url.startsWith('https://') || url.startsWith('http://')) {
setImmediate(() => shell.openExternal(url)) // 交给默认浏览器
}
return { action: 'deny' } // 拒绝 Electron 自己开新窗口
})
})<a href="https://electronjs.org" target="_blank">Electron 官网(会开系统浏览器)</a>4.8 screen:屏幕信息
是什么:screen(屏幕)模块查询显示器几何信息。它不能在 ready 之前使用。常用:getPrimaryDisplay() / getAllDisplays() 返回 Display 对象(含 bounds、workArea(去掉任务栏/Dock 的工作区)、size、workAreaSize、scaleFactor(缩放比)、rotation 等字段,单位为 DIP);getCursorScreenPoint() 拿鼠标位置(Wayland 不支持)。经典用途——把窗口放到鼠标所在屏幕的工作区中央:
const { app, BrowserWindow, screen } = require('electron')
function createWindow() {
// screen 不能在 ready 前使用,所以放进 whenReady 之后调用
const cursor = screen.getCursorScreenPoint() // 鼠标当前坐标(DIP)
const display = screen
.getAllDisplays()
.find((d) => { // 找到鼠标所在的显示器
const { x, y, width, height } = d.bounds
return cursor.x >= x && cursor.x <= x + width && cursor.y >= y && cursor.y <= y + height
}) ?? screen.getPrimaryDisplay()
const { x, y, width, height } = display.workArea // 工作区:已扣除任务栏/Dock
const win = new BrowserWindow({
x: x + Math.floor((width - 800) / 2), // 在该屏幕工作区内居中
y: y + Math.floor((height - 600) / 2),
width: 800, height: 600
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
// 显示器热插拔监听:display-added / display-removed / display-metrics-changed
screen.on('display-metrics-changed', () => console.log('显示器参数变化(分辨率/缩放/旋转)'))
})4.9 主进程用 fs 读写文件
是什么/为什么:渲染进程在沙箱里没有 fs(Node 文件系统模块),文件读写属于特权操作,放主进程。存用户数据用 app.getPath('userData')(用户数据目录,默认是 appData 加应用名;官方建议大文件别直接堆在这里,且最好用其子目录避开 Chromium 的 Cache 等内部目录)。
const { app, BrowserWindow, dialog } = require('electron')
const fs = require('node:fs/promises') // 用 promise 版 fs,配 async/await
const path = require('node:path')
const settingsPath = () => path.join(app.getPath('userData'), 'settings.json')
async function loadSettings() {
try {
return JSON.parse(await fs.readFile(settingsPath(), 'utf8')) // 文件不存在会 throw
} catch {
return { theme: 'light', launches: 0 } // 首次启动给默认值
}
}
let win
app.whenReady().then(async () => {
const settings = await loadSettings()
console.log('历史启动次数:', settings.launches)
// 写文件演示:立即写回更新后的配置(别把异步写留到 quit 钩子里——进程可能等不到完成)
await fs.writeFile(settingsPath(), JSON.stringify({ ...settings, launches: settings.launches + 1 }), 'utf8')
win = new BrowserWindow({ width: 800, height: 600 })
await win.loadFile('index.html')
// 保存对话框 + fs 写文件的完整组合
const { canceled, filePath } = await dialog.showSaveDialog(win, {
defaultPath: 'note.txt'
})
if (!canceled && filePath) {
await fs.writeFile(filePath, '由主进程 fs 写入的内容\n', 'utf8')
await dialog.showMessageBox(win, { message: '已保存到 ' + filePath })
}
})4.10 多窗口管理
是什么:一个应用可以同时开任意多个 BrowserWindow。要点:用集合跟踪所有窗口、在 closed 事件里移除引用;子窗口用 parent 建立父子关系(子窗口始终浮在父窗口之上,modal: true 进一步阻塞父窗口);BrowserWindow.getAllWindows() / fromWebContents(webContents) / fromId(id) 可反查窗口;ipcMain.handle 只注册一次即可服务所有窗口,处理器里用 event.sender 拿到消息来源。
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('node:path')
const windows = new Set() // 跟踪所有打开的窗口,防止被 GC
function createMainWindow() {
const win = new BrowserWindow({
width: 900, height: 650,
webPreferences: { preload: path.join(__dirname, 'preload.js') }
})
win.loadFile('index.html')
windows.add(win)
win.on('closed', () => windows.delete(win)) // closed 后实例已销毁,必须移除引用
}
function createAboutWindow(parent) {
const about = new BrowserWindow({
width: 360, height: 220,
parent, // 父子关系:子窗口始终在父窗口之上
modal: true, // 模态:不关掉它,父窗口无法交互
autoHideMenuBar: process.platform !== 'darwin',
webPreferences: { preload: path.join(__dirname, 'preload.js') }
})
about.loadFile('about.html')
windows.add(about)
about.on('closed', () => windows.delete(about))
}
app.whenReady().then(() => {
createMainWindow()
// 只注册一次,所有窗口共用;用 event.sender 区分消息来自哪个窗口
ipcMain.handle('app:open-about', (event) => {
const sender = BrowserWindow.fromWebContents(event.sender) // 由 WebContents 反查窗口
if (sender) createAboutWindow(sender)
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})配套 preload.js 暴露 openAbout: () => ipcRenderer.invoke('app:open-about'),index.html 里放一个按钮调用 window.electronAPI.openAbout() 即可(写法与第 3 节完全一致)。
5. 贯穿小项目:迷你笔记应用
把本课全部知识拼成一个能用的应用:多窗口笔记编辑器 + Ctrl/Cmd+S 保存为文件 + 托盘常驻 + 全局快捷键唤起 + 单实例锁。功能清单:
- 「文件 → 新建笔记」(菜单 accelerator)可开任意多个编辑窗口,每个窗口各自记住当前文件路径;
- 首次保存弹
showSaveDialog选位置,之后 Ctrl/Cmd+S 直接写回原文件,窗口标题随之更新; - 「打开…」经
showOpenDialog选文件,主进程fs读出内容回填编辑器; - 「复制全文」走主进程的异步
clipboard.writeText; - 点窗口关闭按钮只是隐藏到托盘(拦截
close),从托盘或全局快捷键CommandOrControl+Alt+N随时唤回; - 再次启动应用时单实例锁让新进程退出,并把已有窗口拉到前台。
四个文件:
{
"name": "mini-notes",
"version": "1.0.0",
"main": "main.js",
"scripts": { "start": "electron ." },
"devDependencies": { "electron": "^44.5.1" }
}const {
app, BrowserWindow, Menu, Tray, dialog,
ipcMain, globalShortcut, clipboard
} = require('electron')
const { nativeImage } = require('electron/common')
const fs = require('node:fs/promises')
const path = require('node:path')
// ---------- 单实例锁(第 1 节)----------
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', () => showAnyWindow())
// ---------- 窗口管理(第 4.10 节)----------
const windows = new Set() // 当前打开的笔记窗口
const filePaths = new Map() // 每个窗口的"当前文件路径",key 为窗口 id
let isQuitting = false // 区分"关闭到托盘"与"真正退出"
function createNoteWindow() {
const win = new BrowserWindow({
width: 720, height: 560,
webPreferences: {
preload: path.join(__dirname, 'preload.js') // 只写 preload,其余吃安全默认值(第 2 节)
}
})
win.loadFile('index.html')
windows.add(win)
win.on('closed', () => { windows.delete(win); filePaths.delete(win.id) })
// 关闭到托盘:拦截 close 只是隐藏;真正退出时放行(见 before-quit 与托盘"退出")
win.on('close', (e) => {
if (!isQuitting) { e.preventDefault(); win.hide() }
})
return win
}
function showAnyWindow() {
const win = [...windows][0] ?? createNoteWindow() // 没有窗口就新建,有就聚焦
if (win.isMinimized()) win.restore()
win.show() // 窗口可能只是隐藏到托盘,显式显示
win.focus()
}
// ---------- 应用菜单(第 4.1 节)----------
function buildAppMenu() {
return Menu.buildFromTemplate([
...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
{
label: '文件',
submenu: [
{ label: '新建笔记', accelerator: 'CmdOrCtrl+N', click: () => createNoteWindow() },
{ type: 'separator' },
{ role: 'quit', label: '退出' }
]
},
{ role: 'editMenu', label: '编辑' },
{
label: '视图',
submenu: [{ role: 'reload' }, { role: 'toggleDevTools' }, { role: 'togglefullscreen' }]
}
])
}
// ---------- IPC:保存/打开/复制(第 3、4.4、4.9 节)----------
ipcMain.handle('note:save', async (event, content) => {
const win = BrowserWindow.fromWebContents(event.sender) // 消息来自哪个窗口
if (!win) return { ok: false, error: '窗口不存在' }
let filePath = filePaths.get(win.id)
if (!filePath) {
// 首次保存:弹保存对话框问用户存哪(取消时 filePath 为空字符串)
const result = await dialog.showSaveDialog(win, {
defaultPath: path.join(app.getPath('documents'), '未命名笔记.md'),
filters: [{ name: 'Markdown', extensions: ['md'] }, { name: '所有文件', extensions: ['*'] }]
})
if (result.canceled || !result.filePath) return { ok: false, canceled: true }
filePath = result.filePath
}
await fs.writeFile(filePath, content, 'utf8') // 主进程 fs 写文件
filePaths.set(win.id, filePath)
win.setTitle(path.basename(filePath)) // 用文件名刷新窗口标题
return { ok: true, filePath }
})
ipcMain.handle('note:open', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return { ok: false, error: '窗口不存在' }
const { canceled, filePaths: files } = await dialog.showOpenDialog(win, {
filters: [{ name: 'Markdown/文本', extensions: ['md', 'txt'] }],
properties: ['openFile']
})
if (canceled || files.length === 0) return { ok: false, canceled: true }
const content = await fs.readFile(files[0], 'utf8') // 主进程 fs 读文件
filePaths.set(win.id, files[0])
return { ok: true, content, filePath: files[0] }
})
ipcMain.handle('note:copy', async (_event, text) => {
await clipboard.writeText(text) // Electron 44:异步剪贴板 API
return { ok: true }
})
// ---------- 托盘 + 全局快捷键 + 生命周期(第 1、4.2、4.6 节)----------
let tray = null
app.whenReady().then(() => {
Menu.setApplicationMenu(buildAppMenu())
createNoteWindow()
// 托盘:官方教程的 16x16 图标;菜单提供显示/新建/退出
const icon = nativeImage.createFromDataURL(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACTSURBVHgBpZKBCYAgEEV/TeAIjuIIbdQIuUGt0CS1gW1iZ2jIVaTnhw+Cvs8/OYDJA4Y8kR3ZR2/kmazxJbpUEfQ/Dm/UG7wVwHkjlQdMFfDdJMFaACebnjJGyDWgcnZu1/lrCrl6NCoEHJBrDwEr5NrT6ko/UV8xdLAC2N49mlc5CylpYh8wCwqrvbBGLoKGvz8Bfq0QPWEUo/EAAAAASUVORK5CYII='
)
tray = new Tray(icon) // 全局引用,防 GC
tray.setToolTip('迷你笔记')
tray.setContextMenu(Menu.buildFromTemplate([
{ label: '显示', click: showAnyWindow },
{ label: '新建笔记', click: () => createNoteWindow() },
{ type: 'separator' },
{
label: '退出',
click: () => { isQuitting = true; app.quit() } // 真正退出:先置标志再 quit
}
]))
tray.on('click', showAnyWindow)
// 全局快捷键:应用在后台也能一键唤起
const ok = globalShortcut.register('CommandOrControl+Alt+N', showAnyWindow)
if (!ok) console.log('全局快捷键注册失败(可能被占用)')
})
app.on('activate', () => { // macOS Dock 点击
if (BrowserWindow.getAllWindows().length === 0) createNoteWindow()
})
app.on('before-quit', () => { isQuitting = true }) // Cmd+Q/菜单退出:放行 close
// 窗口全关不退出(托盘保活);真正退出时注销全局快捷键
app.on('window-all-closed', () => { /* 留空:保持托盘存活 */ })
app.on('will-quit', () => globalShortcut.unregisterAll())
}const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('notesAPI', {
save: (content) => ipcRenderer.invoke('note:save', content),
open: () => ipcRenderer.invoke('note:open'),
copy: (text) => ipcRenderer.invoke('note:copy', text)
})<!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 { display: flex; flex-direction: column; height: 100vh; margin: 0; }
#bar { padding: 6px; display: flex; gap: 8px; }
#editor { flex: 1; padding: 12px; font-size: 15px; border: none; outline: none; resize: none; }
#status { padding: 4px 10px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div id="bar">
<button id="open">打开…</button>
<button id="save">保存</button>
<button id="copy">复制全文</button>
</div>
<textarea id="editor" placeholder="在这里写笔记…(Ctrl/Cmd+S 保存)"></textarea>
<div id="status"></div>
<script src="./renderer.js"></script>
</body>
</html>const editor = document.getElementById('editor')
const status = document.getElementById('status')
const setStatus = (msg) => { status.textContent = msg }
document.getElementById('open').addEventListener('click', async () => {
const r = await window.notesAPI.open()
if (r.ok) { editor.value = r.content; setStatus('已打开:' + r.filePath) }
})
document.getElementById('save').addEventListener('click', saveNote)
document.getElementById('copy').addEventListener('click', async () => {
await window.notesAPI.copy(editor.value)
setStatus('已复制到剪贴板')
})
async function saveNote() {
const r = await window.notesAPI.save(editor.value)
if (r.ok) setStatus('已保存:' + r.filePath)
else if (!r.canceled) setStatus('保存失败:' + r.error)
}
// Ctrl/Cmd+S 拦截为"保存"(否则浏览器默认行为可能触发页面另存)
window.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault()
saveNote()
}
})阅读提示:整个
main.js包在一个else { ... }块里——这是单实例锁的官方写法(拿不到锁的进程直接app.quit(),不该再执行任何初始化)。退出、首次显示和多屏定位还需要各自的实现条件:before-quit可阻止退出并进入未保存确认流程,异步确认后再次退出要避免重复拦截;show: false与ready-to-show协调首次显示;screen可取得鼠标所在显示器及其工作区域来确定窗口位置。完整行为需在目标平台验证。
参考来源
- Electron 官方发布页(版本基线 44.5.1 / Chromium 152 / Node 24.21):https://releases.electronjs.org
- Building your First App(项目骨架、whenReady、activate、window-all-closed):https://electronjs.org/docs/latest/tutorial/tutorial-2-first-app
- app API(生命周期事件、requestSingleInstanceLock、getPath):https://electronjs.org/docs/latest/api/app
- BrowserWindow API 与 BaseWindowOptions 结构(窗口选项与 webPreferences 默认值):https://electronjs.org/docs/latest/api/browser-window 、https://electronjs.org/docs/latest/api/structures/base-window-options
- IPC 教程(invoke/handle、contextBridge、dialog:openFile 范例):https://electronjs.org/docs/latest/tutorial/ipc
- Menus 教程与 Menu API(role 列表、buildFromTemplate、setApplicationMenu):https://electronjs.org/docs/latest/tutorial/menus 、https://electronjs.org/docs/latest/api/menu
- Tray 教程(托盘保活、官方 16x16 图标示例)与 Tray API:https://electronjs.org/docs/latest/tutorial/tray 、https://electronjs.org/docs/latest/api/tray
- dialog API(三种对话框、filters、properties、返回值):https://electronjs.org/docs/latest/api/dialog
- clipboard API 与 Breaking Changes(44 重构迁移表、40 起渲染进程弃用):https://electronjs.org/docs/latest/api/clipboard 、https://electronjs.org/docs/latest/breaking-changes
- Notification API 与 Notifications 教程:https://electronjs.org/docs/latest/api/notification 、https://electronjs.org/docs/latest/tutorial/notifications
- globalShortcut API(register 返回值、ready 限制、冲突静默失败):https://electronjs.org/docs/latest/api/global-shortcut
- shell API 与安全教程第 14/15 条(setWindowOpenHandler + openExternal):https://electronjs.org/docs/latest/api/shell 、https://electronjs.org/docs/latest/tutorial/security
- screen API 与 Display 结构:https://electronjs.org/docs/latest/api/screen 、https://electronjs.org/docs/latest/api/structures/display
最后更新于