适用版本:Electron 30+
建议 Node.js:20+(LTS)
面向对象:零基础也能看懂、能跑起来、能避坑
本教程分为两大部分:
- 第一部分:基础教程篇(从环境搭建到打包分发,手把手带你跑通最小可用项目)
- 第二部分:避坑指南篇(30+ 高频坑点 + 可直接复用的解决方案)
- 附录(报错速查、安全清单、类型模板)
目录
- [0. 前置知识与环境自检](#0. 前置知识与环境自检)
- 第一部分:基础教程篇
- [1. Electron 是什么 & 架构模型](#1. Electron 是什么 & 架构模型)
- [2. 环境搭建与项目初始化](#2. 环境搭建与项目初始化)
- [3. 主进程与渲染进程](#3. 主进程与渲染进程)
- [4. IPC 通信详解](#4. IPC 通信详解)
- [5. Preload 脚本与安全模型](#5. Preload 脚本与安全模型)
- [6. 窗口管理与生命周期](#6. 窗口管理与生命周期)
- [7. 常用系统能力(托盘、菜单、对话框、通知、文件拖拽)](#7. 常用系统能力(托盘、菜单、对话框、通知、文件拖拽))
- [8. 打包与分发(electron-builder)](#8. 打包与分发(electron-builder))
- [9. 自动更新(electron-updater)](#9. 自动更新(electron-updater))
- [10. 调试技巧(主进程、渲染进程、Preload)](#10. 调试技巧(主进程、渲染进程、Preload))
- 第二部分:避坑指南篇
- [11. 安全类坑(P0)](#11. 安全类坑(P0))
- [12. 打包类坑(P0)](#12. 打包类坑(P0))
- [13. 路径与文件类坑](#13. 路径与文件类坑)
- [14. 窗口与生命周期类坑](#14. 窗口与生命周期类坑)
- [15. IPC 与通信类坑](#15. IPC 与通信类坑)
- [16. 性能类坑](#16. 性能类坑)
- [17. 平台兼容类坑(Windows/macOS/Linux)](#17. 平台兼容类坑(Windows/macOS/Linux))
- [18. 更新与分发类坑](#18. 更新与分发类坑)
- [19. 开发体验类坑](#19. 开发体验类坑)
- 附录
- 附录A:常见报错速查表
- [附录B:发布前安全与自查 Checklist](#附录B:发布前安全与自查 Checklist)
- 附录C:类型安全模板(推荐直接复用)
0. 前置知识与环境自检
0.1 最小前置知识
在开始前,建议你先具备以下最基础的概念:
- JavaScript 基础:变量、函数、对象、数组、Promise、async/await
- 模块基础 :了解
CommonJS(require/module.exports)和ESM(import/export)的区别 - Node.js 基础 :知道
fs、path、process常用用法 - 浏览器基础 :DOM、事件监听、
fetch思路 - 异步思路:回调、Promise、事件循环基本概念
不是要精通全套,而是遇到报错时能大概看懂堆栈信息。
0.2 环境自检
安装好 Node.js(LTS)后,在终端检查:
bash
node -v
npm -v
检查 npm 镜像是否已配置(国内强烈建议):
bash
npm config get electron_mirror
npm config get electron_builder_binaries_mirror
如果为空,按第2章配置 .npmrc。
第一部分:基础教程篇
1. Electron 是什么 & 架构模型
Electron = Chromium(渲染) + Node.js(系统能力) + 原生胶水层,用 Web 技术栈开发跨平台桌面应用。
1.1 核心概念
| 概念 | 说明 | 类比 |
|---|---|---|
| 主进程(Main Process) | 应用唯一入口。负责窗口创建、生命周期、系统资源、原生能力(文件、托盘、菜单等) | 浏览器外壳 |
| 渲染进程(Renderer Process) | 每个 BrowserWindow 对应一个独立渲染进程,运行 Web 页面(HTML/CSS/JS) |
浏览器 Tab |
| Preload 脚本 | 在页面加载前注入,运行在渲染进程上下文但与页面隔离。是"主进程 <-> 渲染进程"的受控桥梁 | 安全通道(桥) |
| 实用进程(Utility Process) | Electron 22+ 引入,用于跑 CPU 密集、长耗时后台任务,避免主进程阻塞 | Web Worker 思路(更贴近 Node 环境) |
| GPU 进程 | 多窗口共享,用于硬件加速渲染 | --- |
1.2 必须记住的原则
- 渲染进程默认不能直接访问 Node.js 。要用
preload + IPC暴露最小化 API。 - 主进程是单线程的(Node 事件循环)。主进程里做同步大循环、同步读大文件都会导致整应用无响应。
- 每个渲染进程独立。一个渲染进程崩溃一般不会直接拖垮其他窗口。
- 一切来自渲染进程的参数都不可信。主进程必须校验类型、范围、路径等。
- 安全优先 :永远保持
contextIsolation: true、nodeIntegration: false。
2. 环境搭建与项目初始化
2.1 环境要求
- Node.js:≥ 20.x(推荐 LTS)
- 包管理器 :npm、pnpm、yarn 任一。国内推荐 pnpm(下载 electron 二进制更快)
- 跨平台打包提示 :
- 只能在 Windows 机器打包 Windows 安装包
- 必须在 macOS 机器打包 macOS(.dmg/.app) 且做签名+公证
- Linux 多数在 Linux 或通用 CI 可打包 AppImage/deb
2.2 解决国内下载 Electron 二进制慢/失败
Electron 二进制托管在 GitHub。国内直连常失败。推荐在项目根目录新建 .npmrc:
ini
# .npmrc(建议随仓库提交,统一团队环境)
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
也可临时用环境变量(仅当前 shell 生效):
bash
# Windows PowerShell
$env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
# Linux/macOS
export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
提示: 如果之前
npm install已半途失败,建议先删node_modules/electron和node_modules/.cache再重新装。
2.3 起步方式对比
| 方式 | 适用 | 说明 |
|---|---|---|
electron-vite (npm create @quick-start/electron@latest) |
★★★★★(强烈推荐新手) | Vite 驱动,主/预加载/渲染分层清晰,HMR 快,类型友好,社区资料也多。 |
| Electron Forge | ★★★★☆ | 官方推荐,集成较全但初学上手配置感更重。 |
| 纯手写(main.js + index.html) | ★★★☆☆(仅用于理解原理) | 有助于吃透主进程入口、preload、IPC 边界,后续还是建议用脚手架。 |
2.4 推荐起步:electron-vite 脚手架
bash
npm create @quick-start/electron@latest my-app
cd my-app
npm install
npm run dev
交互时可选 TypeScript + Vue/React/Svelte/Vanilla。默认结构对初学者最省事。
脚手架关键结构:
text
my-app/
├── electron.vite.config.ts # Vite 构建主/预加载/渲染
├── src/
│ ├── main/ # 主进程(入口 index.ts)
│ ├── preload/ # Preload(暴露 window.api)
│ └── renderer/ # 渲染进程(前端页面)
├── package.json
└── electron-builder.yml # 打包配置(electron-builder)
2.5 手写最小项目(用来彻底理解原理)
下面给出 CJS 版 和 ESM 版 两个最小示例。日常推荐用脚手架,但这两个示例能帮你厘清"入口、preload、安全开关、生命周期"。
(A)CJS 版(最常见老教程写法)
package.json
json
{
"name": "electron-mini-cjs",
"version": "1.0.0",
"main": "main.js",
"type": "commonjs",
"scripts": {
"dev": "electron ."
},
"devDependencies": {
"electron": "^30.5.1"
}
}
main.js
js
const { app, BrowserWindow } = require('electron')
const path = require('path')
function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 680,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
win.once('ready-to-show', () => {
win.show()
})
win.loadFile('index.html')
// 开发调试时可临时打开(可按需注释)
// win.webContents.openDevTools({ mode: 'detach' })
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
preload.js
js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
ping: () => ipcRenderer.invoke('ping'),
hello: (name) => `Hello, ${name || 'Electron'}`
})
index.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Electron 最小项目(CJS)</title>
</head>
<body>
<h1>Electron 跑起来了</h1>
<p id="msg"></p>
<script>
window.addEventListener('DOMContentLoaded', async () => {
const msg = document.getElementById('msg')
msg.textContent = window.api.hello('新手')
})
</script>
</body>
</html>
(B)ESM 版(electron-vite 常用)
package.json
json
{
"name": "electron-mini-esm",
"version": "1.0.0",
"main": "main.mjs",
"type": "module",
"scripts": {
"dev": "electron ."
},
"devDependencies": {
"electron": "^30.5.1"
}
}
main.mjs
js
import { app, BrowserWindow, ipcMain } from 'electron'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 680,
show: false,
backgroundColor: '#ffffff',
webPreferences: {
preload: join(__dirname, 'preload.mjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
win.once('ready-to-show', () => win.show())
win.loadFile('index.html')
}
// 简单 IPC 演示
ipcMain.handle('ping', async () => {
return { ok: true, time: Date.now() }
})
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
preload.mjs
js
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('api', {
ping: () => ipcRenderer.invoke('ping'),
hello: (name) => `Hello, ${name || 'Electron'}`
})
要点: ESM 中没有
__dirname/__filename,必须用import.meta.url + fileURLToPath/dirname获取。Preload 同理可用 ESM。
验证步骤:
- 新建空文件夹
- 按上方任一版创建文件
npm installnpm run dev,能看到窗口即成功
3. 主进程与渲染进程
3.1 职责划分(铁律)
| 角色 | 该做的 | 不该做的 |
|---|---|---|
| 主进程 | 窗口、托盘、菜单、全局快捷键、原生对话框、文件系统、子进程、应用协议、单实例、自动更新、系统权限相关 | 直接渲染大量 DOM、处理纯 UI 交互细节 |
| 渲染进程 | UI 布局、路由、表单校验、动画、本地状态管理 | 直接读写任意磁盘、执行 shell、直接用 Node API、发起无校验的系统操作 |
核心思想: "渲染要什么,主进程就暴露什么函数"。函数名、参数都要最小且受控。
3.2 为什么不能在渲染直接用 Node?
历史上确实有 nodeIntegration: true 的写法,但一旦页面被 XSS 注入(哪怕是第三方富文本、嵌入远程页面),攻击者就能直接 require('fs')、require('child_process') 执行本地命令。
现代 Electron 的安全基石就是"上下文隔离(contextIsolation)+ 禁用 nodeIntegration"。
4. IPC 通信详解
IPC(Inter-Process Communication)是主进程和渲染进程之间的通信桥梁。下面介绍最常用的 4 种模式。
4.1 通道名统一管理(强烈推荐)
拼写错误是最隐蔽的 IPC 坑:通道名不一致时 invoke 会一直 pending,既无报错也无响应。
建议新建 src/shared/ipc-channels.ts(electron-vite 项目常用):
ts
// src/shared/ipc-channels.ts
export const IPC = {
FS_READ: 'fs:read',
FS_WRITE: 'fs:write',
DIALOG_OPEN: 'dialog:openFile',
DIALOG_SAVE: 'dialog:saveFile',
PING: 'app:ping'
} as const
主进程、preload、渲染进程都统一 import 这个常量。
4.2 模式一:渲染 -> 主(invoke/handle,需返回值)
最常用,适合"读文件、打开对话框、查询配置"等需要结果的操作。
主进程(src/main/index.ts)
ts
import { ipcMain, app } from 'electron'
import { IPC } from '../shared/ipc-channels'
import { readFile } from 'fs/promises'
ipcMain.handle(IPC.FS_READ, async (_event, filePath: string) => {
// 入参校验(后面安全章会细讲,这里先示意)
if (typeof filePath !== 'string') throw new Error('参数类型错误')
return await readFile(filePath, 'utf-8')
})
ipcMain.handle(IPC.PING, async () => ({ ok: true, pid: process.pid }))
Preload(src/preload/index.ts)
ts
import { contextBridge, ipcRenderer } from 'electron'
import { IPC } from '../shared/ipc-channels'
contextBridge.exposeInMainWorld('api', {
readFile: (filePath: string) => ipcRenderer.invoke(IPC.FS_READ, filePath),
ping: () => ipcRenderer.invoke(IPC.PING)
})
渲染进程(src/renderer/src/App.tsx 等)
ts
async function test() {
const res = await window.api.ping()
console.log(res)
}
4.3 模式二:主 -> 渲染(webContents.send,推送事件)
适合"下载进度、日志推送、自动更新进度"等单向推送。
主进程
ts
// 假设有 mainWindow 实例
mainWindow.webContents.send('download:progress', { percent: 42, file: 'a.zip' })
Preload(务必返回取消函数,防止重复监听泄漏)
ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('api', {
onDownloadProgress: (cb: (data: any) => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: any) => cb(data)
ipcRenderer.on('download:progress', listener)
// 返回清理函数
return () => {
ipcRenderer.removeListener('download:progress', listener)
}
}
})
渲染进程
ts
const unlisten = window.api.onDownloadProgress((data) => {
console.log(data.percent)
})
// 组件卸载时一定要调用
unlisten()
4.4 模式三:渲染 -> 主(send/on,单向无需返回)
适合"埋点日志""仅通知主进程"的轻量场景。
ts
// 主进程
ipcMain.on('log:info', (_e, msg: string) => {
console.log('[renderer]', msg)
})
// preload
contextBridge.exposeInMainWorld('api', {
logInfo: (msg: string) => ipcRenderer.send('log:info', msg)
})
// 渲染
window.api.logInfo('页面已加载')
4.5 模式四:渲染 <-> 渲染(MessagePort 中转)
当两个窗口之间要高频互发数据(如图表实时数据、串口数据转发)时,走主进程中转每次都序列化会有开销。可用 MessageChannelMain 建立点对点直连。
主进程
ts
import { BrowserWindow, MessageChannelMain } from 'electron'
function bridgeTwoWindows(winA: BrowserWindow, winB: BrowserWindow) {
const { port1, port2 } = new MessageChannelMain()
// 发给 A
winA.webContents.postMessage('port:connect', null, [port1])
// 发给 B
winB.webContents.postMessage('port:connect', null, [port2])
}
渲染进程 A/B
ts
window.addEventListener('message', (ev) => {
if (ev.data === 'port:connect') {
const port = ev.ports[0]
// 建立后直接用 port.onmessage / port.postMessage
port.onmessage = (e) => console.log('收到', e.data)
port.postMessage('你好,我是渲染A')
}
})
说明:
MessagePort在渲染进程里用原生MessagePort接口。配合contextIsolation: true也能正常工作。
4.6 IPC 类型安全(强烈推荐初学用 TS)
用 TypeScript 时,建议把主渲染暴露的 API 类型统一定义。
src/renderer/src/global.d.ts
ts
export interface IElectronAPI {
readFile: (filePath: string) => Promise<string>
ping: () => Promise<{ ok: boolean; pid: number }>
onDownloadProgress: (cb: (data: any) => void) => () => void
logInfo: (msg: string) => void
hello: (name?: string) => string
}
declare global {
interface Window {
api: IElectronAPI
}
}
export {}
preload 暴露的对象结构务必与此接口保持一致,就能获得完整的 TS 智能提示。
5. Preload 脚本与安全模型
5.1 Preload 的定位
Preload 脚本在渲染进程中、页面 DOM 创建前 执行。它的唯一合法用途是:用 contextBridge 向 window 暴露一个"受控白名单"API,由渲染进程通过这套 API 去请求主进程能力。
5.2 安全开关一览(强烈推荐全程保持默认安全值)
| 配置项 | 推荐值 | 说明 |
|---|---|---|
contextIsolation |
true |
最关键。开启后,preload 脚本的上下文与页面 JavaScript 上下文完全隔离。页面无法直接访问 preload 里的变量、也无法覆写你暴露的 API。 |
nodeIntegration |
false |
禁止渲染进程直接访问 Node.js API。历史上 true 几乎等于放弃安全边界。 |
sandbox |
true |
启用 Chromium 沙箱。preload 也会受沙箱约束:能用 electron 模块暴露的有限子集,直接用 require 某些 Node 内置模块时要格外留意。electron-vite 下用 Vite 打包 preload 基本能规避这类冲突。 |
webSecurity |
true |
永远不要设为 false 。关闭会破坏同源策略,极大增加跨站脚本利用面。跨域请走代理、主进程 net.fetch 等方案(见11.4)。 |
allowRunningInsecureContent |
false |
禁止混合内容(HTTPS 页面加载 HTTP 资源)。生产环境尤其要守住。 |
5.3 正确用法:contextBridge 白名单暴露
原则:暴露"函数",而不是"模块对象"。越具体越安全。
ts
// src/preload/index.ts(TS 示例)
import { contextBridge, ipcRenderer } from 'electron'
import { IPC } from '../shared/ipc-channels'
const api = {
// 只暴露 invoke 封装
openFile: () => ipcRenderer.invoke(IPC.DIALOG_OPEN),
readFile: (path: string) => ipcRenderer.invoke(IPC.FS_READ, path),
// 事件监听要带取消
onThemeChange: (cb: (dark: boolean) => void) => {
const l = (_: Electron.IpcRendererEvent, dark: boolean) => cb(dark)
ipcRenderer.on('theme:changed', l)
return () => ipcRenderer.removeListener('theme:changed', l)
}
}
contextBridge.exposeInMainWorld('api', api)
5.4 preload 常见误区(必须避开)
| 误区 | 风险 | 正确做法 |
|---|---|---|
暴露 ipcRenderer 本身 :exposeInMainWorld('ipc', ipcRenderer) |
等于把 IPC 全通道权限直接交给页面。若页面被注入 JS,攻击者可随意 invoke/send 主进程任何已注册通道。 |
只暴露你事先定义好的函数。 |
暴露 require、fs、child_process |
直接把 Node 能力暴露给渲染。XSS 即 RCE(远程代码执行)。 | 严格禁止。全部走主进程处理。 |
| 把渲染传来的原始对象直接透传给主进程 | 主进程极易被误用。 | 主进程侧强制校验参数类型、范围、白名单。 |
| 用动态字符串组装通道名暴露 | 容易产生"通道越权"隐患。 | 固定常量 + 白名单函数。 |
5.5 关于 sandbox: true 的实用说明
Electron 开启 sandbox: true 后,preload 的运行环境更接近 Chromium 沙箱。大多数情况下配合 Vite 打包(把 preload 编译成单文件)都能正常工作。
如果你手写 preload 用 require('electron') 且报 require is not defined 之类异常,先检查 webPreferences.sandbox。electron-vite 脚手架默认配置是符合现代安全推荐的,初学者直接用脚手架最省事。
6. 窗口管理与生命周期
6.1 创建窗口标准模板(安全 + 体验最佳实践)
下面是能直接复用的主进程窗口创建模板,涵盖"避免白屏闪烁""外链拦截""开发/生产加载区分"。
ts
// src/main/window.ts(示意)
import { app, BrowserWindow, shell } from 'electron'
import { join } from 'path'
let mainWindow: BrowserWindow | null = null
export function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 860,
minHeight: 640,
show: false, // 先隐藏,等 ready-to-show 再显示
backgroundColor: '#ffffff',
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
// 避免启动白屏
mainWindow.once('ready-to-show', () => {
mainWindow?.show()
// 开发期按需打开 DevTools
// if (!app.isPackaged) mainWindow?.webContents.openDevTools({ mode: 'detach' })
})
// 加载页面:开发用 Vite dev server,生产用打包后的文件
if (app.isPackaged) {
// electron-vite 默认产出 out/renderer/index.html
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
} else {
// electron-vite 注入 ELECTRON_RENDERER_URL
const url = process.env.ELECTRON_RENDERER_URL
if (url) {
mainWindow.loadURL(url)
} else {
// 兜底(极少用到)
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// 拦截新窗口(target="_blank")
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
// 只允许用系统浏览器打开 http/https
if (url.startsWith('https://') || url.startsWith('http://')) {
shell.openExternal(url)
}
// 拒绝在应用内新开窗口
return { action: 'deny' }
})
// 拦截页面内导航(will-navigate)
mainWindow.webContents.on('will-navigate', (event, url) => {
// 开发期允许跳转到本地 Vite dev server
const isDevAllowed = !app.isPackaged && url.startsWith('http://localhost')
// 生产期只允许 file:// 协议加载自己的页面
const isSelf = url.startsWith('file://')
if (isSelf || isDevAllowed) {
return // 允许
}
// 其余一律阻止并用系统浏览器打开
event.preventDefault()
if (url.startsWith('https://') || url.startsWith('http://')) {
shell.openExternal(url)
}
})
// 渲染进程崩溃监听(排障用)
mainWindow.webContents.on('render-process-gone', (_event, details) => {
console.error('[render-process-gone]', details.reason, details.exitCode)
})
return mainWindow
}
export function getMainWindow() {
return mainWindow
}
改进点: 原版对
will-navigate的"放行 localhost"写法更通用。但加上!app.isPackaged判断更贴合"开发/生产"两种场景,减少生产误放行风险。
6.2 应用生命周期与单实例锁
ts
import { app, BrowserWindow } from 'electron'
import { createMainWindow } from './window'
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
// 已有实例在运行,直接退出当前实例
app.quit()
} else {
app.on('second-instance', (_event, argv, _cwd) => {
const win = BrowserWindow.getAllWindows()[0]
if (win) {
if (win.isMinimized()) win.restore()
win.focus()
// 若需要处理"用本应用打开文件"(argv 中可能含文件路径)可在此扩展
}
})
app.whenReady().then(() => {
createMainWindow()
app.on('activate', () => {
// macOS:Dock 图标被点击且无窗口时重建
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow()
}
})
})
app.on('window-all-closed', () => {
// macOS 习惯:窗口全关不退出,留在 Dock
if (process.platform !== 'darwin') {
app.quit()
}
})
}
6.3 窗口关闭时"最小化到托盘"(常用需求)
见第7.2与14.3。关键是用 isQuitting 标志位区分"用户点关闭"还是"真正退出应用"。
7. 常用系统能力(托盘、菜单、对话框、通知、文件拖拽)
7.1 原生菜单(Menu)
ts
import { Menu, app } from 'electron'
const isMac = process.platform === 'darwin'
const template: Electron.MenuItemConstructorOptions[] = [
...(isMac
? [{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
} as Electron.MenuItemConstructorOptions]
: []),
{
label: '文件',
submenu: [
{ label: '打开...', accelerator: 'CmdOrCtrl+O', click: () => {/* TODO */} },
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' }
]
},
{
label: '视图',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
}
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
提示: macOS 应用几乎都要保留"appMenu"(含 quit/hide),否则快捷键体验会不符合原生预期。
7.2 系统托盘(Tray)+"最小化到托盘"
最易踩的点:Tray 实例必须用模块级变量持有,否则 GC 会导致托盘图标消失。
ts
// src/main/tray.ts
import { app, Tray, Menu, nativeImage, BrowserWindow } from 'electron'
import { join } from 'path'
let tray: Tray | null = null
let isQuitting = false
export function setQuitting(v: boolean) {
isQuitting = v
}
export function getQuitting() {
return isQuitting
}
export function createTray(win: BrowserWindow) {
// 建议用 16x16/32x32 的托盘图标(build/icon.png 或 .ico)
const iconPath = app.isPackaged
? join(process.resourcesPath, 'resources', 'tray.png')
: join(__dirname, '../../build', 'tray.png')
const icon = nativeImage.createFromPath(iconPath)
tray = new Tray(icon)
tray.setToolTip(app.name)
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: '显示主窗口',
click: () => {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
}
},
{ type: 'separator' },
{
label: '退出',
click: () => {
setQuitting(true)
app.quit() // 会触发 before-quit 清理
}
}
])
)
// 双击托盘也可恢复窗口(Windows 常用)
tray.on('double-click', () => {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
})
}
配合窗口 close 事件:
ts
// 在 createMainWindow 内
win.on('close', (e) => {
if (process.platform === 'darwin') return // macOS 交由系统惯例处理也可自定义
if (!getQuitting()) {
e.preventDefault()
win.hide() // 最小化到托盘
}
// 若真要退出,会走 app.quit() -> before-quit
})
app.on('before-quit', () => {
setQuitting(true)
})
说明: 托盘"退出"用
app.quit()比app.exit(0)更推荐。app.quit()会正常触发before-quit、窗口close(此时isQuitting=true可放行)等钩子,便于做退出前保存、日志刷盘等清理。
7.3 原生对话框(dialog)------ 必须在主进程调用
渲染进程不能 直接 require('electron').dialog。统一走 IPC。
主进程
ts
import { dialog, ipcMain, BrowserWindow } from 'electron'
import { IPC } from '../shared/ipc-channels'
ipcMain.handle(IPC.DIALOG_OPEN, async (_event) => {
const win = BrowserWindow.getFocusedWindow()
const result = await dialog.showOpenDialog(win || undefined, {
properties: ['openFile', 'multiSelections'],
filters: [
{ name: '文本文件', extensions: ['txt', 'md', 'json'] },
{ name: '所有文件', extensions: ['*'] }
]
})
return result // { canceled, filePaths }
})
ipcMain.handle(IPC.DIALOG_SAVE, async (_event, defaultName: string = '未命名.txt') => {
const win = BrowserWindow.getFocusedWindow()
const result = await dialog.showSaveDialog(win || undefined, {
defaultPath: defaultName,
filters: [{ name: '文本文件', extensions: ['txt'] }]
})
return result.canceled ? null : result.filePath
})
Preload
ts
contextBridge.exposeInMainWorld('api', {
openFile: () => ipcRenderer.invoke(IPC.DIALOG_OPEN),
saveFile: (name?: string) => ipcRenderer.invoke(IPC.DIALOG_SAVE, name)
})
7.4 系统通知(Notification)
ts
// 主进程或渲染进程都可用(渲染进程用 HTML5 Notification 在 Electron 里也会走系统通知)
new Notification({
title: '下载完成',
body: '文件已保存到桌面',
silent: false
}).show()
Windows 开发提示: 直接用
electron .跑开发时,Windows 有时"开始菜单快捷方式"不存在会导致通知不显示。这是开发环境常见现象,打包成安装版后正常。
7.5 文件拖拽(获取原始文件路径)
Electron 32+ 中,渲染进程的 File 对象不再 有 path 属性。要获取磁盘真实路径,必须用 webUtils.getPathForFile。
Preload
ts
import { contextBridge, webUtils } from 'electron'
contextBridge.exposeInMainWorld('api', {
getPathForFile: (file: File) => webUtils.getPathForFile(file)
})
渲染进程
html
<div id="dropzone" style="border:2px dashed #ccc;padding:24px;text-align:center">
拖拽文件到这里
</div>
<script>
const zone = document.getElementById('dropzone')
zone.addEventListener('dragover', (e) => {
e.preventDefault()
e.stopPropagation()
})
zone.addEventListener('drop', (e) => {
e.preventDefault()
e.stopPropagation()
const files = Array.from(e.dataTransfer.files)
files.forEach(f => {
const realPath = window.api.getPathForFile(f)
console.log('真实路径:', realPath, '文件名:', f.name)
})
})
</script>
8. 打包与分发(electron-builder)
8.1 工具选型
首推 electron-builder:配置相对直观、NSIS/dmg/AppImage/deb 一应俱全、社区最活跃。
8.2 最小 electron-builder.yml(推荐直接改参数)
假设用 electron-vite,构建产物默认输出到 out/ 目录。
在项目根目录新建 electron-builder.yml:
yaml
appId: com.example.myapp
productName: 我的桌面应用
copyright: Copyright © 2026 Example
directories:
output: release
buildResources: build
# 打包时只包含必要文件
files:
- out/** # electron-vite 构建产物:out/main、out/preload、out/renderer
- package.json
# 将静态资源一并打包到 resources 目录(打包后用 process.resourcesPath 访问)
extraResources:
- from: resources
to: resources
filter:
- "**/*"
# Windows 配置
win:
target:
- target: nsis
arch: [x64]
icon: build/icon.ico
# NSIS 安装器
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: 我的桌面应用
uninstallDisplayName: 我的桌面应用
# macOS(仅在 macOS 机器上打包生效)
mac:
target:
- dmg
category: public.app-category.productivity
icon: build/icon.icns
# 签名+公证详见官方文档:identity + notarize
# hardenedRuntime: true
# gatekeeperAssess: false
# Linux
linux:
target:
- AppImage
- deb
icon: build/icons
category: Utility
# 发布配置(配合 electron-updater 用 generic 静态服务器最简单)
publish:
provider: generic
url: https://your-update-server.com/updates/
channel: latest
8.3 package.json 相关字段
确保 main 指向构建后的主进程入口:
json
{
"name": "my-app",
"version": "1.0.0",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"dist:win": "npm run build && electron-builder --win",
"dist:mac": "npm run build && electron-builder --mac",
"dist:linux": "npm run build && electron-builder --linux",
"dist:dir": "npm run build && electron-builder --dir"
},
"dependencies": {
// 运行时真正用到的包都放这里(打包会带上)
},
"devDependencies": {
// 构建工具、类型、lint 等仅开发用的放这里
"electron": "^30.5.1",
"electron-builder": "^24.13.3",
"electron-vite": "^2.3.0",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}
极重要: 凡是打包后运行时要 require/import 的第三方库 ,一律放
dependencies。放devDependencies会导致"打包后 Cannot find module xxx"。
8.4 图标准备
- Windows :用
.ico,建议包含 16、32、48、64、128、256 尺寸(直接改 png 后缀是无效的) - macOS :用
.icns,源图建议 ≥512×512 - Linux :常用一组 png 放
build/icons/(electron-builder 会按需处理)
推荐用在线工具或 png2ico、iconutil(macOS)生成。
8.5 本地打包验证建议
bash
# 先用 --dir 快速出免安装文件夹,省去每次装卸载时间
npm run dist:dir
产物在 release/win-unpacked/(Windows)里找到 .exe 直接双击跑。用这种方式排查"打包后白屏/模块找不到"最省事。
9. 自动更新(electron-updater)
推荐用 electron-updater,与 electron-builder 天然配套。
9.1 安装
bash
npm i electron-updater
9.2 主进程配置示例
ts
// src/main/updater.ts
import { app, dialog } from 'electron'
import { autoUpdater } from 'electron-updater'
import log from 'electron-log'
log.transports.file.level = 'info'
autoUpdater.logger = log
// 基本配置
autoUpdater.autoDownload = true
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('checking-for-update', () => {
log.info('正在检查更新...')
})
autoUpdater.on('update-available', (info) => {
log.info('发现新版本:', info.version)
})
autoUpdater.on('update-not-available', (info) => {
log.info('当前已是最新版本:', info.version)
})
autoUpdater.on('download-progress', (progressObj) => {
const msg = `下载速度:${progressObj.bytesPerSecond} 已下载:${progressObj.percent.toFixed(2)}%`
log.info(msg)
// 也可发到渲染进程显示进度条
})
autoUpdater.on('update-downloaded', (info) => {
dialog
.showMessageBox({
type: 'info',
title: '更新就绪',
message: `新版本 v${info.version} 已下载完成,是否立即重启应用以安装更新?`,
buttons: ['立即重启', '稍后安装']
})
.then((res) => {
if (res.response === 0) {
autoUpdater.quitAndInstall()
}
})
})
autoUpdater.on('error', (err) => {
log.error('自动更新出错:', err)
})
export function checkForUpdates() {
if (app.isPackaged) {
autoUpdater.checkForUpdates()
} else {
// 开发模式下 electron-updater 不工作
log.info('开发模式跳过自动更新检查')
}
}
在 app.whenReady() 后调用 checkForUpdates()。
9.3 发布服务器要求
electron-builder 打包后会生成:
- Windows:
latest.yml、*.exe、*.exe.blockmap - macOS:
latest-mac.yml、*.dmg、*.dmg.blockmap - Linux:
latest-linux.yml(视配置而定)
发布时必须把这几个文件放到同一个静态目录 ,并确保能通过 https://your-update-server.com/updates/latest.yml 访问。
常用选项:
- 对象存储:阿里云 OSS、腾讯云 COS、AWS S3(配合静态托管)
- 静态服务:Nginx、Caddy
- 现成平台:GitHub Releases(也支持 generic 思路但需按路径放置)
强烈建议: feed URL 一定用 https。自签名证书在部分环境会导致校验证书失败。
9.4 版本号注意
package.json的version必须符合 semver (如1.0.0、1.2.3-beta.1)- 新版本号必须 严格大于 旧版本号:
1.0.10 > 1.0.9正确,1.0.9.1这种非标准四段极易导致 updater 解析失败 - 建议用 git tag 驱动版本(CI 中从 tag 写入 package.json version),避免手动改漏
10. 调试技巧(主进程、渲染进程、Preload)
| 调试对象 | 方法 | 说明 |
|---|---|---|
| 渲染进程 | Ctrl+Shift+I(Windows/Linux)或 Cmd+Option+I(macOS) |
等同 Chrome DevTools。Elements/Console/Network/Performance/Memory 全都有。 |
| 渲染进程(代码里强制开) | mainWindow.webContents.openDevTools({ mode: 'detach' }) |
适合打包后临时排查白屏。排查完记得注释。 |
| 主进程(附加调试) | 用 --inspect 启动 |
electron --inspect=9229 .,然后 Chrome chrome://inspect 或 VSCode "Attach to Node Process" 连上端口 9229。 |
| 主进程(直接启动调试) | VSCode launch.json |
常用做法:配置"runtimeExecutable"为本地 electron,配合 --remote-debugging-port 也可。 |
| Preload | DevTools → Sources | 有 sourcemap 的前提下能看到 preload 源码。也可直接 console.log 打印。 |
| 日志落盘 | electron-log | 主进程+渲染进程都能用。自动写文件到 app.getPath('logs'),排查用户环境报错极有用。 |
VSCode launch.json 最简示例(附加主进程)
json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach Electron Main",
"port": 9229,
"skipFiles": ["<node_internals>/**"]
}
]
}
先用 npx electron --inspect=9229 . 启动,再点 VSCode 调试启动配置。
第二部分:避坑指南篇
每个坑点都按 【现象】→【原因】→【解决方案】 排列。P0 表示极易引发安全或核心功能故障,务必处理。
11. 安全类坑(P0)
11.1 坑:nodeIntegration: true + 加载远程内容
- 现象: 应用内嵌第三方网页,安全扫描报"远程代码可执行本地命令"。
- 原因: 渲染进程一旦能直接用 Node,任何 XSS 都等于本地 RCE。
- 解决方案:
- 一刀切安全开关 :
contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true - 远程内容最小化 :能用
loadFile就别用loadURL加载不受信任站点 - 外链全交系统浏览器 :严格用
setWindowOpenHandler拒绝应用内打开外链 - CSP 加固 :在
index.html加最基础 CSP(生产环境再按实际需求收紧)
- 一刀切安全开关 :
html
<!-- 渲染进程 index.html 建议加 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self';" />
style-src 'unsafe-inline'常被 UI 库需要。若你的样式全走 CSS 文件可去掉。
11.2 坑:IPC 入参不校验导致路径穿越/命令注入
- 现象: 渲染传入
../../etc/passwd居然能读文件;或把用户输入拼接exec执行。 - 原因: 主进程直接信任渲染参数。
- 解决方案:主进程侧必须"防御性校验"
用白名单根目录 + path.relative 判断越界(比单纯 startsWith 更健壮):
ts
import { ipcMain, app } from 'electron'
import { resolve, relative } from 'path'
import { readFile } from 'fs/promises'
const ALLOWED_ROOT = resolve(app.getPath('userData'), 'notes')
ipcMain.handle('fs:read', async (_e, relPath: string) => {
// 1. 类型校验
if (typeof relPath !== 'string' || relPath.trim() === '') {
throw new Error('非法参数:路径必须是非空字符串')
}
// 2. 禁止绝对路径直接传入(强制用相对路径)
if (resolve(relPath) === relPath) {
// 简单判断:在 Windows 也可用 path.isAbsolute
throw new Error('非法参数:请传入相对路径')
}
// 3. 拼接并规范化
const fullPath = resolve(ALLOWED_ROOT, relPath)
// 4. 用 relative 判断是否越界
const rel = relative(ALLOWED_ROOT, fullPath)
if (rel.startsWith('..') || relative(ALLOWED_ROOT, resolve(fullPath)) !== rel) {
// 双重保险也可简化:if (rel.startsWith('..') || rel === '')? 按需
throw new Error('路径越界:禁止访问允许目录之外的文件')
}
// 5. 扩展名白名单(按业务收紧)
if (!/\.(md|txt|json)$/i.test(fullPath)) {
throw new Error('文件类型不允许')
}
return await readFile(fullPath, 'utf-8')
})
补充命令执行原则:
- 永远不用
exec拼字符串 。要用execFile或spawn,且用数组传参。 shell.openPath、shell.openExternal前校验协议(仅 https/http)和目标是否预期。- 涉及敏感路径一律走白名单。
11.3 坑:外链导航未拦截(窗口"变浏览器")
- 现象: 点页面内外部链接,整个应用窗口跳转到目标站点,用户再也回不来。
- 解决方案: 见6.1模板。核心是
setWindowOpenHandler+will-navigate双重拦截。
11.4 坑:为跨域关 webSecurity: false
- 现象: 开发时接口跨域报错,搜到"设 webSecurity:false"能过。
- 原因: 关闭同源策略,任何注入脚本都可跨域读响应。
- 解决方案(安全顺序推荐):
- 后端配 CORS(最省事也最常规)
- Vite 代理 :
electron-vite开发期用vite.config的proxy - 主进程转发 :渲染发 IPC -> 主进程用
net.fetch/axios请求后端 -> 再回传渲染(主进程发请求不受浏览器 CORS 限制) - 实在无法改后端:极少数场景才考虑其他方式,且要严格评估风险
11.5 坑:自定义协议权限不严
- 现象: 注册
app://后,外部页面也能用app://../../类似形式尝试访问本地。 - 解决方案: 用
protocol.registerSchemesAsPrivileged明确权限,并在 handler 内校验路径范围。
ts
import { protocol } from 'electron'
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: {
standard: true,
secure: true,
stream: true,
supportFetchAPI: true
}
}
])
app.whenReady().then(() => {
protocol.handle('app', async (request) => {
const url = new URL(request.url)
// 只允许访问 assets 目录下资源
const safePath = resolve(app.getAppPath(), 'assets', url.hostname + url.pathname)
const rel = relative(resolve(app.getAppPath(), 'assets'), safePath)
if (rel.startsWith('..')) {
return new Response('Forbidden', { status: 403 })
}
// 返回文件流等
return net.fetch('file://' + safePath.replace(/\\/g, '/'))
})
})
注意:用
file://时 Windows 路径要转正斜杠。
12. 打包类坑(P0)
12.1 坑:开发正常,打包后白屏 / Cannot find module 'xxx'
- 现象:
npm run dev一切正常,打包后点开 .exe 只显示空白窗口,控制台看不到渲染也没反应;或报模块找不到。 - 原因TOP3:依赖放错、路径用错、main 字段指错
- 排查思路(按顺序)
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1. 验证免安装包 | npm run dist:dir 后跑 release/win-unpacked/*.exe |
比装 NSIS 快太多。能立刻复现打包后环境。 |
| 2. 开启日志 | 用 --enable-logging 启动(命令行)或接入 electron-log |
看主进程控制台输出。渲染进程异常也可通过 render-process-gone 捕获。 |
| 3. 检查依赖归属 | 全局搜"运行时用到的 import/require" | 只要打包后真正运行 时要加载的包,必须在 dependencies。类型包、vite、electron-builder 等放 devDependencies。 |
| 4. 检查主进程入口 | package.json.main 是否指向构建产物 |
electron-vite 建议 out/main/index.js。手写项目要指向打包后能找到的文件。 |
| 5. 检查 preload 路径 | BrowserWindow.webPreferences.preload 用 __dirname 拼接构建后路径 |
打包后 __dirname 对应 out/main/,preload 一般是 ../preload/index.js。 |
| 6. 检查静态资源 | 资源文件是否进 files/extraResources |
只有列进的文件才会打进安装包。 |
路径在打包后的正确取法(极常用):
ts
import { app } from 'electron'
import { join } from 'path'
function getResourcePath(...parts: string[]) {
// 打包后 extraResources 放到 process.resourcesPath/resources
// 开发时直接用源码相对
if (app.isPackaged) {
return join(process.resourcesPath, 'resources', ...parts)
} else {
return join(__dirname, '../../resources', ...parts)
}
}
渲染进程白屏排查技巧:
临时在主进程 ready-to-show 后加:
ts
mainWindow.webContents.openDevTools({ mode: 'detach' })
打包版也能开 DevTools,立马就能看到渲染控制台报错(最关键)。
12.2 坑:electron-builder 下载依赖工具卡住
- 现象: 第一次打包时停在
downloading winCodeSign/nsis很久。 - 原因: 工具包从 GitHub 下载。
- 解决方案:
- 配置
.npmrc的electron_builder_binaries_mirror(见2.2) - 手动从镜像站下载对应压缩包,放到
%LOCALAPPDATA%\electron-builder\Cache\对应目录(按版本匹配) - 换网络(公司代理需配 npm 代理)
- 配置
12.3 坑:图标无效
- 现象: 安装后图标还是默认,或打包报"icon must be..."。
- 解决方案:
- Windows 用真正多尺寸 的
.ico,别直接把.png重命名 - macOS 用
.icns - 路径写对:
build/icon.ico,且文件确实存在 buildResources: build配置配合icon: build/icon.ico最省事
- Windows 用真正多尺寸 的
12.4 坑:体积过大
- 现象: 安装包 200MB+。
- 合理预期: Electron 底座本身就约 130~150MB,剩下的是你的业务代码+资源。
- 优化思路:
files只留out/**和package.json,别放测试、README、设计稿- 保持
asar: true(electron-builder 默认开启) - 前端做 tree-shaking、懒加载、图片压缩(webp/avif)
- 大文件(离线模型、视频、字体包)放
extraResources而不是直接打进renderer产物,按需加载 - 用
npx depcheck找无用依赖清理 - 避免一次性打全平台+全架构(x64+arm64 双架构体积翻倍)
提醒: 为"省几 MB"而折腾 asar 解包、手动剔除 Chromium 模块几乎没必要且易出兼容问题。优先从业务资源下手。
12.5 坑:杀毒/SmartScreen 报毒(未签名)
- 现象: Windows 用户下载后被 Windows Defender/360 报"可疑应用"、SmartScreen 拦截。
- 原因: 未代码签名的 NSIS 安装包缺乏信任链。
- 解决方案:
- 正式分发必做: 购买代码签名证书(OV 证书)并配置 electron-builder
win.certificateSubjectName/win.signingHashAlgorithms - macOS 必做: Apple Developer ID 签名 + 公证(notarization)。未公证的 .dmg 在 macOS Sequoia 等版本会提示"已损坏,无法打开"
- 正式分发必做: 购买代码签名证书(OV 证书)并配置 electron-builder
- electron-builder mac 公证示例(环境变量方式):
yaml
mac:
hardenedRuntime: true
gatekeeperAssess: false
notarize:
teamId: YOUR_TEAM_ID
需在 CI 设置 APPLE_ID、APPLE_APP_SPECIFIC_PASSWORD、TEAM_ID。
开发测试可接受未签名,但发给真实用户前务必走签名+公证流程。
13. 路径与文件类坑
13.1 坑:打包后 ./config.json 找不到
- 现象:
fs.readFile('./config.json')开发能读,打包后ENOENT。 - 原因:
process.cwd()在打包后是启动 exe 的目录(用户双击时可能是桌面、开始菜单等)- 代码一旦被打进
app.asar,用相对"源码思路"的路径就失效
- 解决方案:用 Electron 提供的路径 API
| 场景 | 用哪个 | 说明 |
|---|---|---|
| 只读的内置资源(随安装包分发) | app.getAppPath() |
对应 app.asar 内根(或解包后真实路径)。常用于读取内置 JSON、模板。 |
| 打包时额外拷贝的资源(extraResources) | process.resourcesPath |
打包后是 C:\...\resources\。适合放"可替换但默认随包"的静态文件。 |
| 运行时可读可写(用户数据、配置、缓存、日志) | app.getPath('userData') |
绝对不能 放安装目录。Windows 是 %APPDATA%/<productName>。 |
| 临时文件 | app.getPath('temp') |
注意 Windows 用 %TEMP%,macOS 用系统临时目录(不是 /tmp)。 |
| 下载目录 | app.getPath('downloads') |
跟随系统设置。 |
实用封装:
ts
import { app } from 'electron'
import { join } from 'path'
import { mkdirSync } from 'fs'
export const paths = {
userData: app.getPath('userData'),
logs: app.getPath('logs'),
temp: app.getPath('temp'),
resources: app.isPackaged ? process.resourcesPath : join(__dirname, '../../')
}
export function ensureDir(p: string) {
mkdirSync(p, { recursive: true })
}
13.2 坑:写安装目录报 EPERM/EACCES(Windows Program Files)
- 现象: 首次启动想在应用根目录建
config.json,Windows 上装在C:\Program Files\...时直接写失败。 - 原因: Program Files 需要管理员权限。现代 Windows 普通用户无写权限。
- 解决方案: 一律把所有"运行时产生"的文件写到
userData。
配置文件示例:
ts
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
const cfgDir = app.getPath('userData')
const cfgPath = join(cfgDir, 'settings.json')
function loadConfig() {
try {
if (!existsSync(cfgDir)) mkdirSync(cfgDir, { recursive: true })
if (!existsSync(cfgPath)) return { theme: 'light' }
return JSON.parse(readFileSync(cfgPath, 'utf-8'))
} catch (e) {
console.error('加载配置失败', e)
return { theme: 'light' }
}
}
function saveConfig(cfg: any) {
if (!existsSync(cfgDir)) mkdirSync(cfgDir, { recursive: true })
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2), 'utf-8')
}
13.3 坑:Windows 路径反斜杠导致拼接/比较出错
- 现象:
file://拼接后变成file:///C:\...(浏览器识别异常)或路径比较失败。 - 解决方案:
- 文件系统操作 :一律用
path.join、path.resolve、path.normalize - 转 file:// URL :用
pathToFileURL(Node 内置)最省事 - URL 中用正斜杠 :
fileURL.href或手动替换\为/
- 文件系统操作 :一律用
ts
import { pathToFileURL } from 'url'
import { resolve } from 'path'
const filePath = resolve('out/renderer/index.html')
const fileUrl = pathToFileURL(filePath).href // file:///D:/.../index.html
13.4 坑:文件被占用(EBUSY)导致删除/覆盖失败
- 现象: Windows 上更新配置、替换文件时报
EBUSY: resource busy or locked。 - 原因: 上次
fs.createReadStream、fs.open没有close;资源管理器预览;杀软实时扫描。 - 解决方案:
- 用完流务必关闭 :
stream.on('end', ()=>stream.close())或try/finally { stream.destroy() } - "先写临时,再替换"原子性写法 :写
settings.json.tmp成功后再fs.rename覆盖(rename 在 Windows 上也常比直接 unlink+write 稳定) - 轻微重试(可选):带 100~300ms 延时重试 2~3 次,适合自动更新替换场景
- 用完流务必关闭 :
ts
import { writeFile, rename, unlink } from 'fs/promises'
import { join } from 'path'
async function safeWrite(target: string, data: string) {
const tmp = target + '.tmp'
await writeFile(tmp, data, 'utf-8')
try {
await unlink(target)
} catch (e) {
// 目标可能不存在,忽略
}
await rename(tmp, target)
}
14. 窗口与生命周期类坑
14.1 坑:启动白屏闪烁
- 现象: 窗口一闪而过出现白底,再切到页面内容。
- 原因:
BrowserWindow默认创建后立即显示,但页面 HTML/CSS/JS 尚未首帧渲染。 - 解决方案(见6.1):
show: false- 监听
ready-to-show再win.show() - 设置
backgroundColor: '#ffffff'(或跟主题色)填充空白期
14.2 坑:macOS 关闭窗口后应用"假死"
- 现象: 在 macOS 点窗口红叉关闭后,托盘还在但再点"显示主窗口"报错(
mainWindow变null)。 - 原因: macOS 关闭窗口不等于 退出应用。
closed事件触发后要清空引用。 - 解决方案:
ts
let mainWindow: BrowserWindow | null = null
function createMainWindow() {
mainWindow = new BrowserWindow({ /* ... */ })
mainWindow.on('closed', () => {
mainWindow = null
})
return mainWindow
}
// 使用时都要判空
function showMain() {
if (!mainWindow || mainWindow.isDestroyed()) {
mainWindow = createMainWindow()
}
mainWindow.show()
mainWindow.focus()
}
配合 activate 事件(见6.2)在无窗口时重建。
14.3 坑:"最小化到托盘"与"点退出"逻辑冲突
- 现象: 想点关闭隐藏到托盘,结果托盘点"退出"也被
close事件拦住导致关不掉。 - 原因:
event.preventDefault()无差别拦截了所有关闭请求。 - 解决方案:用标志位(见7.2)
要点:
before-quit时设isQuitting = trueclose事件:若isQuitting为 true 则放行 (走正常退出);否则preventDefault()+hide()- 托盘"退出"菜单直接
app.quit()(会触发before-quit)
14.4 坑:托盘图标莫名消失
- 现象: 运行一段时间后托盘图标突然没了。
- 原因:
new Tray(...)赋值给局部变量,JavaScript GC 回收后托盘消失。 - 解决方案: 全局/模块顶层持有引用。
ts
// 顶部模块作用域
let tray: Tray | null = null
export function createTray(win: BrowserWindow) {
tray = new Tray(icon) // 别用 const 局部
// ...
}
14.5 坑:无边框窗口(frameless)拖拽/双击失效
- 现象: 自定义标题栏后整个窗口点哪都没反应、拖不动、双击最大化无效。
- 原因:
-webkit-app-region: drag覆盖了所有可点击区域。 - 解决方案:
CSS:
css
.titlebar {
-webkit-app-region: drag;
height: 32px;
display: flex;
align-items: center;
user-select: none;
}
.titlebar button,
.titlebar input,
.titlebar .no-drag {
-webkit-app-region: no-drag;
}
双击最大化建议: 只把纯标题栏空白条 设 drag,按钮、搜索框一律 no-drag。
Windows 圆角/阴影: Electron 14+ 支持 titleBarStyle: 'hidden' 配合 titleBarOverlay: { color: '#2f3241', symbolColor: '#74b1be' },能保留系统原生窗口控制按钮的交互体验。
14.6 坑:多显示器/DPI 下窗口位置错乱
- 现象: 重启应用后窗口跑到屏幕外、或跨屏时模糊。
- 解决方案: 保存
bounds(x,y,width,height)到userData,恢复时先校验是否落在任意可见显示器内。
ts
import { screen } from 'electron'
function isBoundsVisible(bounds: Electron.Rectangle) {
const displays = screen.getAllDisplays()
return displays.some((d) => {
const { x, y, width, height } = d.workArea
// 简单相交判断:窗口大部分在屏幕内即可
return (
bounds.x + bounds.width > x &&
bounds.x < x + width &&
bounds.y + bounds.height > y &&
bounds.y < y + height
)
})
}
function restoreSafeBounds(saved: Electron.Rectangle | null) {
if (!saved) return null
return isBoundsVisible(saved) ? saved : null
}
若超出任何显示器(拔显示器场景最常见),就放弃恢复改用默认居中尺寸。
15. IPC 与通信类坑
15.1 坑:invoke 一直 pending,无报错
- 现象:
await window.api.xxx()永远卡住,渲染进程也没报错。 - 原因:
- 通道名与
ipcMain.handle注册的不一致(大小写、前缀错) ipcMain.handle根本没执行到(模块没 import)- preload 没注入成功(路径错)导致
window.api根本不存在 - 开发热重载时重复注册同一通道(旧 handler 可能被顶掉但逻辑混乱)
- 通道名与
- 解决方案:
排查清单:
- 控制台检查
window.api是否有目标函数:console.log(Object.keys(window.api)) - 用通道名常量(4.1)杜绝拼写
- 主进程启动时临时
console.log打印"已注册 handle:xxx" - 给
invoke包超时调试(临时用):
ts
function withTimeout<T>(p: Promise<T>, ms=3000, tag='ipc') {
return Promise.race([
p,
new Promise<never>((_,rej)=>setTimeout(()=>rej(new Error(`${tag} 超时(${ms}ms)`)), ms))
])
}
// 用法:await withTimeout(ipcRenderer.invoke('xxx'), 2000, 'fs:read')
防止重复注册 handle(热重载场景):
ts
import { ipcMain } from 'electron'
import { IPC } from '../shared/ipc-channels'
function registerHandlers() {
// 若之前注册过,先移除(electron-vite 热重载时 main.ts 可能重新执行)
ipcMain.removeHandler(IPC.FS_READ)
ipcMain.handle(IPC.FS_READ, async (_e, p) => { /* ... */ })
}
// app.whenReady() 前后都可调用,但只在"真正需要注册"时调用
registerHandlers()
removeHandler需 Electron 14+ 支持(30+ 完全没问题)。
15.2 坑:IPC 传递函数/类实例变成 {}
- 现象:
send('event', { cb: ()=>{} })主进程收到cb: {}。 - 原因: 结构化克隆算法(Structured Clone) 不支持函数、Symbol、DOM 节点、Error 实例的部分字段。
- 解决方案:
- 只传纯数据(Plain Object/Array/Number/String/Boolean/null)
- 主进程向渲染抛错时传可序列化对象:
{ message: err.message, code: err.code } - 需要"回调"时不要直接传函数 ,改用
invoke(请求-响应)或MessagePort
15.3 坑:事件监听重复注册导致多次触发
- 现象: 路由切换回上一页后,"下载进度"回调执行 2 次、3 次...
- 原因: 每次组件挂载都
ipcRenderer.on,卸载时没removeListener。 - 解决方案:
- preload 暴露时返回取消函数(已在4.3示例做了)
- 渲染端务必在副作用清理里调用
React 示例:
tsx
useEffect(() => {
const off = window.api.onDownloadProgress((d) => setPercent(d.percent))
return () => off()
}, [])
Vue 3 示例:
ts
onMounted(() => {
off = window.api.onDownloadProgress(update)
})
onUnmounted(() => {
off?.()
})
主进程侧也要注意: ipcMain.on 放在 app.whenReady() 只注册一次 ,别放在 createWindow() 里反复执行。
15.4 坑:大对象走 IPC 卡顿
- 现象: 传 10~50MB JSON 对象时,窗口短暂卡住。
- 原因: 结构化克隆需序列化/反序列化全量数据,且会阻塞事件循环一小段。
- 解决方案(按数据特性选):
- 大文件内容 :直接在主进程读,渲染用
file://加载(图片、文本)或主进程返回文件路径由渲染按需读取 - 高频小包 :考虑
MessagePort(直连更省序列化开销) - 超大二进制 :用
SharedArrayBuffer(需注意 COOP/COEP,Electron 渲染进程一般较宽松但仍要理解上下文) - 分页 :分批
send,每批几千条记录而不是全量
- 大文件内容 :直接在主进程读,渲染用
15.5 坑:窗口刷新(reload)后主进程发消息报"Object has been destroyed"
- 现象: 渲染进程按
Ctrl+R刷新后,主进程再win.webContents.send报Error: Object has been destroyed: webContents。 - 原因: 刷新会销毁旧的
webContents,新建渲染上下文。主进程若还持有旧引用发消息就会报错。 - 解决方案:
发消息前先判活:
ts
function safeSend(win: BrowserWindow | null, channel: string, ...args: any[]) {
if (!win || win.isDestroyed()) return
if (win.webContents.isDestroyed()) return
win.webContents.send(channel, ...args)
}
也可监听 did-finish-load 重新初始化主进程向该窗口推送的状态。
16. 性能类坑
16.1 坑:内存泄漏(渲染进程最常见)
- 现象: 长时间开关页面、频繁操作后内存只升不降。
- 排查工具: Chrome DevTools → Memory,用"Heap Snapshot"前后对比找多出来的对象。
- 常见原因及修复:
| 原因 | 修复 |
|---|---|
定时器未清理 :setInterval/setTimeout |
用 clearInterval/clearTimeout,放在 useEffect cleanup(React)或 onUnmounted(Vue) |
事件监听未移除 :addEventListener |
removeEventListener,且回调用具名函数而不是匿名函数更易对应 |
IPC 监听未取消 :ipcRenderer.on |
调用 preload 返回的取消函数(15.3) |
| 大对象闭包持有:组件卸载仍被回调引用 | 及时置空引用(obj = null)或用弱引用思路 |
| 三方库副作用:地图、图表、富文本编辑器 | 按库文档销毁实例(如 chart.destroy()、编辑器 destroy()) |
16.2 坑:主进程阻塞导致整应用卡死
-
现象: 点某个按钮后,所有窗口都失去响应(鼠标转圈、IPC 全部 pending)。
-
原因: 主进程里用了同步 API:
fs.readFileSync、fs.existsSync(大文件)、crypto.pbkdf2Sync、密集for循环。 -
解决方案:
-
首选异步 :全部换成
fs.promises、回调版异步 -
重活转子进程 :用 Utility Process (Electron 推荐)或
child_process.fork
Utility Process 最简示例:
主进程(src/main/worker.ts 调用方)
ts
import { utilityProcess, app } from 'electron'
import { join } from 'path'
export function runHeavyTask(payload: any): Promise<any> {
return new Promise((resolve, reject) => {
const script = join(__dirname, 'workers/heavy.mjs') // 或 .js
const proc = utilityProcess.fork(script, [], {
env: { ...process.env }
})
proc.once('message', (msg) => {
resolve(msg)
proc.kill()
})
proc.once('exit', (code) => {
if (code !== 0) reject(new Error(`子进程异常退出:${code}`))
})
// 发任务
proc.postMessage(payload)
})
}
子进程(src/main/workers/heavy.mjs)
js
import { parentPort } from 'electron'
parentPort.on('message', async (payload) => {
// 模拟耗时计算(别用同步阻塞思路写死循环也可拆 chunk)
let sum = 0
for (let i = 0; i < 10_000_000; i++) {
sum += i
}
parentPort.postMessage({ ok: true, result: sum })
})
utilityProcess.fork跟 Nodechild_process.fork类似,但更贴合 Electron 沙箱/上下文。适合"纯计算""文件解析""压缩解压"等不直接操作 GUI 的重活。
16.3 坑:启动慢
- 优化清单:
- 延迟初始化 :托盘、自动更新、全局快捷键等非首屏必须 的能力推迟到
did-finish-load或首屏渲染后再初始化 - 首屏资源精简:路由懒加载、代码分割、压缩图片
- 减少主进程同步 I/O:启动时别同步读十几个大 JSON
- 启用缓存:渲染用 session 默认缓存即可,别刻意禁用
- show:false + ready-to-show:感知上快很多(用户先看到窗口再等内容)
- 延迟初始化 :托盘、自动更新、全局快捷键等非首屏必须 的能力推迟到
16.4 坑:GPU 黑屏/花屏
- 现象: 部分老显卡、虚拟机、远程桌面(RDP)环境打开应用直接黑屏。
- 排查思路:
- 临时加
app.disableHardwareAcceleration()重启看是否恢复(最省事) - 若恢复,说明是 GPU 驱动/显卡兼容问题
- 临时加
- 建议做法:
- 默认保留硬件加速(性能更好,动画流畅)
- 按环境降级 :检测远程桌面(如
process.env.SESSIONNAME含RDP)或显卡异常时才禁用 - 提供"设置→高级→禁用硬件加速"可选项,交给用户决定
ts
// 示例:RDP 环境自动降级
if (process.platform === 'win32' && process.env.SESSIONNAME?.toLowerCase().includes('rdp')) {
app.disableHardwareAcceleration()
}
17. 平台兼容类坑(Windows/macOS/Linux)
17.1 坑:spawn 在 Windows 找不到 .cmd 可执行文件
- 现象:
spawn('npm', ['run', 'build'])在 Windows 报ENOENT。 - 原因: Windows 上
npm、npx是npm.cmd/npx.cmd(批处理),直接传npm给spawn有时找不到。 - 解决方案:
ts
import { spawn } from 'child_process'
function runNpm(args: string[]) {
const cmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const child = spawn(cmd, args, { stdio: 'inherit' })
return child
}
也可用 shell: true 简化(需注意参数转义):
ts
spawn('npm', args, { shell: true, stdio: 'inherit' })
两种方式都可用。生产里执行用户输入极少的场景 用 shell: true 较省事;若参数来自用户输入绝对不要 用 shell: true。
17.2 坑:路径硬编码
- 现象: 用
C:\\Users\\...或~/字符串拼接路径。 - 解决方案: 全部用
app.getPath()+path.join。
常用对照:
| 用途 | Windows | macOS | Linux |
|---|---|---|---|
| 用户数据 | %APPDATA%\<productName>(app.getPath('userData')) |
~/Library/Application Support/<productName> |
~/.config/<productName> |
| 日志 | 同上 logs 子目录(app.getPath('logs')) |
~/Library/Logs/<productName> |
~/.config/<productName>/logs |
| 临时文件 | %TEMP% |
系统临时目录(NSTemporaryDirectory) | /tmp |
| 下载 | %USERPROFILE%\Downloads |
~/Downloads |
~/Downloads |
17.3 坑:macOS 权限描述缺失(Info.plist)
- 现象: 首次用摄像头、麦克风、屏幕录制时应用直接崩溃或静默失败。
- 原因: macOS 要求在
Info.plist声明权限用途说明(用户看到的弹窗文案)。 - 解决方案: 在
electron-builder.yml用extendInfo注入。
yaml
mac:
extendInfo:
NSCameraUsageDescription: "该应用需要访问摄像头用于视频通话"
NSMicrophoneUsageDescription: "该应用需要访问麦克风用于语音输入"
NSPhotoLibraryUsageDescription: "该应用需要访问相册用于导入图片"
NSScreenCaptureDescription: "该应用需要屏幕录制权限用于演示分享"
也可用 systemPreferences.getMediaAccessStatus('microphone') 等 API 检测授权状态,再引导用户去"系统设置→隐私与安全性"。
17.4 坑:Linux AppImage 沙箱报错(Ubuntu 24+ 常见)
- 现象:
./YourApp.AppImage启动报The SUID sandbox helper binary was found, but is not configured correctly。 - 原因: Chromium 沙箱需要
chrome-sandbox拥有root所有权且setuid位(4755)。 - 解决方案:
- 临时(仅本地调试): 加启动参数
--no-sandbox:./YourApp.AppImage --no-sandbox(安全性下降,仅限排查用) - 正解(分发给用户): deb 包常在
postinst脚本里自动修复。AppImage 分发时建议在 README 写明修复命令:
- 临时(仅本地调试): 加启动参数
bash
chmod +x YourApp.AppImage
./YourApp.AppImage --appimage-extract
sudo chown root:root squashfs-root/chrome-sandbox
sudo chmod 4755 squashfs-root/chrome-sandbox
./squashfs-root/AppRun
不同发行版处理略有差异。也有用户直接用
--no-sandbox跑,但不推荐作为正式方案。
17.5 坑:中文输入法、快捷键冲突
- 中文输入法在无边框窗口漏字/光标错位: 确保输入框区域没有 被
-webkit-app-region: drag覆盖。输入类元素加no-drag。 - 全局快捷键被占用:
globalShortcut.register返回boolean。注册失败时一定要提示用户"快捷键已被其他应用占用"。 - Windows 控制台乱码: 日志里中文显示方块时可用
chcp 65001(临时)或统一用 UTF-8 读写文件。 - Alt 键显隐菜单(Windows): 若自定义菜单栏且想保留 Alt 显示原生菜单行为要注意。大多数自绘 UI 会禁用默认菜单改自绘。
17.6 坑:深色/浅色模式跟随系统
ts
import { nativeTheme } from 'electron'
// 设置跟随系统
nativeTheme.themeSource = 'system' // 'system' | 'light' | 'dark'
// 监听变化并发给渲染
nativeTheme.on('updated', () => {
const isDark = nativeTheme.shouldUseDarkColors
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send('theme:changed', isDark)
}
})
})
渲染端配合 prefers-color-scheme:
css
:root { color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
body { background: #1e1e1e; color: #ddd; }
}
Windows 上"应用自定义模式"和"系统颜色模式"都要考虑。
nativeTheme.shouldUseDarkColors是最终生效的系统判断值。
18. 更新与分发类坑
18.1 坑:自动更新检测不到/静默失败
- 现象: 正式打包后
checkForUpdates()没反应,也没走error事件。 - 排查清单:
| 检查项 | 原因 | 处理 |
|---|---|---|
| 开发模式 | app.isPackaged 为 false |
electron-updater 不工作。必须用打包后的 exe/dmg 测试。 |
| feed URL 可访问 | latest.yml 404/403 |
浏览器直接打开 https://.../latest.yml 看能否下载。注意 CDN 缓存。 |
| latest.yml 与安装包同目录 | 路径错 | 必须同一目录。generic provider 就是按 url + latest.yml 去找。 |
| 版本号不符合 semver | 解析失败 | 改成 1.0.1 而不是 1.0.1.0。beta 用 1.1.0-beta.1。 |
| 新版本 <= 旧版本 | 判定"已是最新" | 检查 package.json.version 是否真大于服务器上的。 |
| HTTPS 证书 | 自签名证书校验失败 | 内网可用受信 CA 证书。极不推荐绕过 TLS 校验。 |
| macOS 未签名/未公证 | 更新校验失败 | 上架前走完整签名+公证流程。 |
务必监听所有关键事件并打日志:
ts
autoUpdater.on('error', (err) => log.error('update error', err))
autoUpdater.on('update-available', (info) => log.info('update-available', info))
autoUpdater.on('update-not-available', (info) => log.info('no-update', info))
用 electron-log 把日志发给你便于远程排障。
18.2 坑:更新后 userData 目录变动导致配置丢失
- 现象: 改了
productName后,更新安装新包,旧配置"消失"了(其实跑到新目录)。 - 原因:
app.getPath('userData')路径包含productName。改名等于换目录。 - 解决方案:
- 尽量别改
productName。若已上线再改是破坏性操作。 - 做迁移逻辑 :应用启动时检测旧目录是否存在且新目录为空,自动
copy配置文件、数据库过去。 - 配置结构加
version字段:做"配置升级(migrate)"合并默认值。
- 尽量别改
ts
import { app } from 'electron'
import { join } from 'path'
import { existsSync, cpSync, mkdirSync } from 'fs'
function migrateUserDataIfNeeded() {
// 假设旧 productName 是 "OldApp",新的是 "NewApp"
const old = join(app.getPath('appData'), 'OldApp')
const now = app.getPath('userData')
if (existsSync(old) && !existsSync(join(now, 'migrated.flag'))) {
mkdirSync(now, { recursive: true })
cpSync(old, now, { recursive: true })
// 打标记避免重复迁移
require('fs').writeFileSync(join(now, 'migrated.flag'), '1')
}
}
更通用做法是维护"known-legacy-dirs"列表逐个尝试。
18.3 坑:每次都全量下载(blockmap 没生效)
- 现象: 新版本很小(改几行 JS)却还是下载完整 150MB 安装包。
- 原因: 服务器没上传
.blockmap文件,或latest.yml引用的sha512对不上。 - 解决方案:
- 发布时一并上传 :
*.exe.blockmap、*.dmg.blockmap、latest*.yml全部上传到更新目录 - 别手动改版本号重用旧产物:CI 必须从干净构建产出 yml+blockmap
- 检查 yml 内容 :
size、sha512、url字段是否与实际文件匹配
- 发布时一并上传 :
- electron-updater 会自动用 blockmap 做**增量(delta)**更新。缺少 blockmap 就回退全量。
18.4 坑:多渠道(beta/stable)混淆
- 建议: 用
channel区分。比如latest.yml对应 stable,latest-beta.yml对应 beta。 - 在代码里切换:
ts
import { autoUpdater } from 'electron-updater'
autoUpdater.channel = 'beta' // 或从设置读取
autoUpdater.checkForUpdates()
但前提是服务器也放了对应的 latest-beta.yml。
19. 开发体验类坑
19.1 坑:npm run dev 报 EADDRINUSE(端口占用)
- 现象: 上次
dev没正常退出,再跑时报"端口 5173(或 Vite 端口)已被占用"。 - 原因: Windows 上
Ctrl+C有时没杀死electron.exe子进程。 - 解决方案:
- Windows:
taskkill /F /IM electron.exe清理残留 - Linux/macOS:
pkill -f electron或查 PIDlsof -i :5173
- Windows:
- electron-vite 一般会自动选择空闲端口,但极少数场景仍会撞上。
19.2 坑:改主进程代码要手动重启
- 现象: 改
src/main/index.ts后页面没变化。 - 原因: 渲染进程走 Vite HMR,但主进程和 preload 是 Node 进程,不走浏览器 HMR。
- 解决方案:
- 用 electron-vite:
electron-vite dev内置主进程热重启(改动主进程会自动重启 Electron) - 纯手写: 用
nodemon监听:nodemon --watch src/main --watch src/preload --exec "electron ."
- 用 electron-vite:
json
"scripts": {
"dev:watch": "nodemon --ext ts,js,mjs --watch src/main --watch src/preload --exec electron ."
}
提示: preload 改动后通常也要重启 Electron 才能生效(因为 preload 注入到已存在的 webContents 上逻辑已跑过)。
19.3 坑:DevTools 报 require is not defined
- 现象: 在渲染进程 Console 里敲
require('fs')报错。 - 原因: 这不是 bug,是故意的安全设计。 开启
nodeIntegration: false后渲染进程压根就没有require。 - 解决方案: 忘掉在渲染里直接用 Node API 。统一用
window.api.xxx走 preload+IPC。
19.4 坑:Electron 大版本升级踩 API 变动
- 现象: 升级
electron从 30.x 到 33+/34+ 后编译报错、运行异常。 - 常见变动:
File.path移除(已在7.5讲过)、部分shellAPI 行为变更、protocol注册方式微调等。 - 升级建议:
- 小步升级:一次升 1 个大版本(30->31->32...)比跨 3 个风险小
- 查 Breaking Changes :官方文档 electronjs.org/docs/latest/breaking-changes 必看
- 锁定版本 :
package.json用精确版本"electron": "30.5.1"而不是"^30.5.1",避免 CI 拉到最新小版本产生不一致 - 本地全量冒烟 :升级后跑
dev+dist:dir打包版都测一遍核心流程
19.5 坑:CI 打包与本地不一致
- 现象: GitHub Actions 能打包成功,本地
npm run dist:win却失败(或反过来)。 - 常见原因:
- Node 版本不同:本地 Node 22,CI 用 Node 20
- npm 缓存 :CI 缓存了旧的
node_modules/.cache/electron - 镜像差异 :本地配了
.npmrc镜像,CI 没配 - 平台专属依赖 :
optionalDependencies里 macOS 专用包在 Windows 装不全(属正常但别强行)
- 建议:
- CI 用 Node LTS 且与本地尽量一致(
.nvmrc) - 打包前
npm ci(比npm install更确定) - 必要时在 CI 步骤里清理
~/.cache/electron、~/.cache/electron-builder
- CI 用 Node LTS 且与本地尽量一致(
附录
附录A:常见报错速查表
| 报错信息 | 出现场景 | 原因 | 解决方案 |
|---|---|---|---|
require is not defined(渲染控制台) |
渲染进程里写 require('fs') |
nodeIntegration: false 禁用了渲染进程 Node 能力 |
改用 window.api + IPC(5.3) |
Cannot find module 'xxx'(打包后) |
打包版启动报错 | 依赖放错到 devDependencies 或没进打包清单 |
放到 dependencies;检查 files/extraResources(12.1) |
| 白屏但无报错(打包后) | 窗口空白 | 渲染资源路径错、preload 路径错、Vite base 配置不当 | 用 dist:dir + DevTools 排查(12.1) |
invoke 永远 pending |
渲染 await window.api.xxx() 卡住 |
通道名不一致、handle 未注册、preload 未注入 | 检查通道常量、window.api 存在性、主进程 handle 注册(15.1) |
Object has been destroyed: webContents |
主进程发消息给已刷新窗口 | 窗口刷新后旧 webContents 已销毁 | 发消息前 isDestroyed() 判断(15.5) |
ENOENT: no such file or directory |
读写文件失败 | 用了相对路径或安装目录路径 | 改用 app.getPath('userData') 等(13.1) |
EBUSY: resource busy or locked(Windows) |
替换/删除文件 | 文件句柄未关闭被占用 | 先写临时文件再 rename;确保流关闭(13.4) |
EADDRINUSE |
npm run dev 启动失败 |
端口被残留 electron 占用 | 清理进程(19.1) |
Attempted to register a second handler for 'xxx' |
主进程热重载时报错 | 重复 ipcMain.handle |
用 removeHandler 或确保只注册一次(15.1) |
附录B:发布前安全与自查 Checklist
安全(P0,全部打勾)
-
contextIsolation: true -
nodeIntegration: false -
sandbox: true -
webSecurity: true -
allowRunningInsecureContent: false - 全部
ipcMain.handle都做入参类型+范围校验(尤其路径、ID) -
setWindowOpenHandler+will-navigate已拦截外部导航 -
shell.openExternal仅用于https://、http:// -
exec已替换为execFile(如确实要用 shell,评估风险) -
index.html已配 CSP(至少script-src 'self') - 自定义协议 handler 已做路径越界校验
功能与体验
-
dev模式核心流程全走通 -
dist:dir免安装包全流程走通(最关键的打包后自测) - Windows/macOS 双平台最少各测一次:开关窗口、托盘、对话框、通知
- macOS 点关闭→激活重建窗口正常
- 单实例锁生效(第二个实例聚焦而不是新开)
- 拖拽文件能拿到真实路径(Electron 32+ 用
webUtils) - "最小化到托盘"+"退出"逻辑无冲突
数据与路径
- 所有可写文件都在
app.getPath('userData') - 完全没 向
app.getAppPath()、安装根目录写文件 - 配置文件做"缺省值合并 + 版本迁移"
- 日志用
electron-log写到logs目录 - 读取静态资源用
app.isPackaged分支正确
打包与分发
- 图标为真正
.ico(Windows)/.icns(macOS) -
files只含out/**、package.json(再按需加) -
dependencies无多余、devDependencies未混入运行时包 - 版本号
package.json.version符合 semver 且与 git tag 一致 - Windows 已代码签名(正式发版)
- macOS 已签名+公证(正式发版)
- 更新服务器已上传
latest*.yml、.blockmap、安装包
附录C:类型安全模板(推荐直接复用)
src/shared/ipc-channels.ts
ts
export const IPC = {
PING: 'app:ping',
FS_READ: 'fs:read',
FS_WRITE: 'fs:write',
DIALOG_OPEN: 'dialog:openFile',
DIALOG_SAVE: 'dialog:saveFile',
THEME_CHANGED: 'theme:changed'
} as const
export type IpcChannel = typeof IPC[keyof typeof IPC]
src/renderer/src/global.d.ts
ts
export interface IElectronAPI {
ping: () => Promise<{ ok: boolean; time: number }>
readFile: (path: string) => Promise<string>
writeFile: (path: string, content: string) => Promise<void>
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>
saveFile: (name?: string) => Promise<string | null>
getPathForFile: (file: File) => string
onThemeChange: (cb: (dark: boolean) => void) => () => void
logInfo: (msg: string) => void
}
declare global {
interface Window {
api: IElectronAPI
}
}
export {}
preload 基座(极简型)
ts
import { contextBridge, ipcRenderer } from 'electron'
import { IPC } from '../shared/ipc-channels'
const api: Window['api'] = {
ping: () => ipcRenderer.invoke(IPC.PING),
readFile: (p) => ipcRenderer.invoke(IPC.FS_READ, p),
writeFile: (p, c) => ipcRenderer.invoke(IPC.FS_WRITE, p, c),
openFile: () => ipcRenderer.invoke(IPC.DIALOG_OPEN),
saveFile: (n) => ipcRenderer.invoke(IPC.DIALOG_SAVE, n),
getPathForFile: (f) => (require('electron') as any).webUtils.getPathForFile(f), // 若用 ESM preload 直接 import webUtils
onThemeChange: (cb) => {
const l = (_: Electron.IpcRendererEvent, d: boolean) => cb(d)
ipcRenderer.on(IPC.THEME_CHANGED, l)
return () => ipcRenderer.removeListener(IPC.THEME_CHANGED, l)
},
logInfo: (m) => ipcRenderer.send('log:info', m)
}
contextBridge.exposeInMainWorld('api', api)
若 preload 用 ESM (electron-vite 默认),把
require('electron').webUtils改成import { webUtils } from 'electron'再webUtils.getPathForFile即可。
写在最后:
Electron 的坑 90% 都绕不开这句话:"开发环境能跑 ≠ 打包环境能跑"。 每次改动后,养成用 dist:dir 跑打包后免安装包的习惯,比等到发版再排查省一堆时间。
本修订版比原版更强调安全边界、路径分支、CJS/ESM、类型、排障顺序这几个最关键的点。照着跑通最小项目,再逐步往业务加功能是最不踩坑的路线。