知行札记
专题技术实践桌面应用实践Electron 桌面应用

窗口、桌面 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
package.json
{
  "name": "my-app",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^44.5.1"
  }
}
index.html(渲染进程页面,CSP 照抄官方教程)
<!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渲染进程的隔离上下文给页面暴露最小白名单 APIcontextBridge、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 退出」的时间线):

事件触发时机典型用法
readyElectron 初始化完成,"Emitted once"。之后才能创建窗口/使用大部分模块
window-all-closed所有窗口关闭后Windows/Linux 调 app.quit();macOS 留空以保活
before-quit应用开始关闭窗口之前event.preventDefault() 可阻止退出,常做「还有未保存内容?」确认
will-quit所有窗口已关、应用即将退出注销全局快捷键等清理
quit应用退出时参数带 exitCode
activatemacOS 专有:点击 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') 少一些微妙陷阱)。下面这份演示代码把完整时间线打印出来,建议亲手跑一遍观察终端输出顺序:

main.js —— 生命周期观察器(骨架上直接替换运行)
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 之后发出」,典型响应是聚焦已有窗口。锁必须在应用顶层尽早请求(在创建任何窗口之前),这是官方示例的标准位置:

main.js 顶部追加 —— 单实例锁
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 / height800 / 600窗口像素尺寸
minWidth / minHeight0最小尺寸约束
resizabletrue是否可调整大小
title"Electron"被 HTML <title> 覆盖
showtrue创建时是否立即显示;配 ready-to-show 可做"无白屏启动"
frametrue是否带系统边框/标题栏;false 即无边框窗口
parent / modalnull / 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保持开启。为"跨域加载本地文件"关它是最常见的自杀式操作
devToolstrue开发期保留
spellchecktrue输入框拼写检查,按需关
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 → 选中的路径回传渲染进程显示。共四个文件:

main.js
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()
})
preload.js —— 唯一合法的"特权出口"
const { contextBridge, ipcRenderer } = require('electron')

// contextBridge 只允许暴露白名单函数;切勿把整个 ipcRenderer 透传给页面
// (自 Electron 29 起,ipcRenderer 本体经 contextBridge 传输只会得到空对象)
contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile')
})
index.html
<!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>
renderer.js —— 页面脚本,只见 electronAPI,不见 Electron
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) 可将其隐藏。

main.js —— 应用菜单
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,零素材依赖:

main.js —— 托盘 + 点击显示/隐藏窗口
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 之前使用,适合报告启动早期错误。给第一个参数传窗口可让对话框成为该窗口的模态。

main.js —— 用菜单触发三种对话框
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 上直接报错。

main.js —— 写入/读出文本 + 写入图片(新 API)
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。

main.js —— 启动后发一条通知,点通知回到窗口
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()。

main.js —— CommandOrControl+Alt+K 唤起窗口
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 拦截并转交系统,同时只放行可信协议:

main.js —— 页面外链安全地交给系统浏览器
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 自己开新窗口
  })
})
index.html 的 body 里加一行测试
<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 不支持)。经典用途——把窗口放到鼠标所在屏幕的工作区中央:

main.js —— 在鼠标所在的屏幕中央开窗
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 等内部目录)。

main.js —— 启动时读取配置并写回,再用保存对话框存一个文件
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 拿到消息来源。

main.js —— 主窗口 + 关于窗口(模态子窗口)
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 随时唤回;
  • 再次启动应用时单实例锁让新进程退出,并把已有窗口拉到前台。

四个文件:

package.json
{
  "name": "mini-notes",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": { "start": "electron ." },
  "devDependencies": { "electron": "^44.5.1" }
}
main.js
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())
}
preload.js —— 最小特权出口
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)
})
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 { 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>
renderer.js
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 可取得鼠标所在显示器及其工作区域来确定窗口位置。完整行为需在目标平台验证。

参考来源

最后更新于

本页目录