Electron开发超详细教程(从入门到放弃)

适用版本: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. 开发体验类坑)
  • 附录

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 必须记住的原则

  1. 渲染进程默认不能直接访问 Node.js 。要用 preload + IPC 暴露最小化 API。
  2. 主进程是单线程的(Node 事件循环)。主进程里做同步大循环、同步读大文件都会导致整应用无响应。
  3. 每个渲染进程独立。一个渲染进程崩溃一般不会直接拖垮其他窗口。
  4. 一切来自渲染进程的参数都不可信。主进程必须校验类型、范围、路径等。
  5. 安全优先 :永远保持 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。

验证步骤:

  1. 新建空文件夹
  2. 按上方任一版创建文件
  3. npm install
  4. npm 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。
  • 解决方案:
    1. 一刀切安全开关 :contextIsolation: true, nodeIntegration: false, sandbox: true, webSecurity: true
    2. 远程内容最小化 :能用 loadFile 就别用 loadURL 加载不受信任站点
    3. 外链全交系统浏览器 :严格用 setWindowOpenHandler 拒绝应用内打开外链
    4. 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"能过。
  • 原因: 关闭同源策略,任何注入脚本都可跨域读响应。
  • 解决方案(安全顺序推荐):
    1. 后端配 CORS(最省事也最常规)
    2. Vite 代理 :electron-vite 开发期用 vite.config 的 proxy
    3. 主进程转发 :渲染发 IPC -> 主进程用 net.fetch/axios 请求后端 -> 再回传渲染(主进程发请求不受浏览器 CORS 限制)
    4. 实在无法改后端:极少数场景才考虑其他方式,且要严格评估风险

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 下载。
  • 解决方案:
    1. 配置 .npmrc 的 electron_builder_binaries_mirror(见2.2)
    2. 手动从镜像站下载对应压缩包,放到 %LOCALAPPDATA%\electron-builder\Cache\ 对应目录(按版本匹配)
    3. 换网络(公司代理需配 npm 代理)

12.3 坑:图标无效

  • 现象: 安装后图标还是默认,或打包报"icon must be..."。
  • 解决方案:
    • Windows 用真正多尺寸 的 .ico,别直接把 .png 重命名
    • macOS 用 .icns
    • 路径写对:build/icon.ico,且文件确实存在
    • buildResources: build 配置配合 icon: build/icon.ico 最省事

12.4 坑:体积过大

  • 现象: 安装包 200MB+。
  • 合理预期: Electron 底座本身就约 130~150MB,剩下的是你的业务代码+资源。
  • 优化思路:
    1. files 只留 out/** 和 package.json,别放测试、README、设计稿
    2. 保持 asar: true(electron-builder 默认开启)
    3. 前端做 tree-shaking、懒加载、图片压缩(webp/avif)
    4. 大文件(离线模型、视频、字体包)放 extraResources 而不是直接打进 renderer 产物,按需加载
    5. 用 npx depcheck 找无用依赖清理
    6. 避免一次性打全平台+全架构(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 等版本会提示"已损坏,无法打开"
  • 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;资源管理器预览;杀软实时扫描。
  • 解决方案:
    1. 用完流务必关闭 :stream.on('end', ()=>stream.close()) 或 try/finally { stream.destroy() }
    2. "先写临时,再替换"原子性写法 :写 settings.json.tmp 成功后再 fs.rename 覆盖(rename 在 Windows 上也常比直接 unlink+write 稳定)
    3. 轻微重试(可选):带 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 = true
  • close 事件:若 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() 永远卡住,渲染进程也没报错。
  • 原因:
    1. 通道名与 ipcMain.handle 注册的不一致(大小写、前缀错)
    2. ipcMain.handle 根本没执行到(模块没 import)
    3. preload 没注入成功(路径错)导致 window.api 根本不存在
    4. 开发热重载时重复注册同一通道(旧 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。
  • 解决方案:
    1. preload 暴露时返回取消函数(已在4.3示例做了)
    2. 渲染端务必在副作用清理里调用

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 跟 Node child_process.fork 类似,但更贴合 Electron 沙箱/上下文。适合"纯计算""文件解析""压缩解压"等不直接操作 GUI 的重活。

16.3 坑:启动慢

  • 优化清单:
    1. 延迟初始化 :托盘、自动更新、全局快捷键等非首屏必须 的能力推迟到 did-finish-load 或首屏渲染后再初始化
    2. 首屏资源精简:路由懒加载、代码分割、压缩图片
    3. 减少主进程同步 I/O:启动时别同步读十几个大 JSON
    4. 启用缓存:渲染用 session 默认缓存即可,别刻意禁用
    5. show:false + ready-to-show:感知上快很多(用户先看到窗口再等内容)

16.4 坑:GPU 黑屏/花屏

  • 现象: 部分老显卡、虚拟机、远程桌面(RDP)环境打开应用直接黑屏。
  • 排查思路:
    1. 临时加 app.disableHardwareAcceleration() 重启看是否恢复(最省事)
    2. 若恢复,说明是 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。改名等于换目录。
  • 解决方案:
    1. 尽量别改 productName。若已上线再改是破坏性操作。
    2. 做迁移逻辑 :应用启动时检测旧目录是否存在且新目录为空,自动 copy 配置文件、数据库过去。
    3. 配置结构加 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 或查 PID lsof -i :5173
  • 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 ."
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讲过)、部分 shell API 行为变更、protocol 注册方式微调等。
  • 升级建议:
    1. 小步升级:一次升 1 个大版本(30->31->32...)比跨 3 个风险小
    2. 查 Breaking Changes :官方文档 electronjs.org/docs/latest/breaking-changes 必看
    3. 锁定版本 :package.json 用精确版本 "electron": "30.5.1" 而不是 "^30.5.1",避免 CI 拉到最新小版本产生不一致
    4. 本地全量冒烟 :升级后跑 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

附录

附录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、类型、排障顺序这几个最关键的点。照着跑通最小项目,再逐步往业务加功能是最不踩坑的路线。

相关推荐
最强小杰1 小时前
DeepSeek V4 Flash + Codex CLI 接入教程:原生 Responses API 适配,从配置到跑通收藏这篇就够了(2026)
java·前端·python·ai
静水leo1 小时前
使用 Whistle 拦截接口并模拟后端响应
前端
掘金者阿豪2 小时前
SQL Server数据迁移完成后,凌晨的数据库告警该怎么查?
前端·后端
JudithHuang2 小时前
React 嵌套路由与布局
前端·react.js·arcgis·react·react router
htzyl2062 小时前
前端阶梯——第四十三章、实战二:待办清单To-Do
前端·javascript·html
程序员大阳2 小时前
HTML+CSS+JS对话式实战(1)-开篇:认识我们要做的社团活动发布平台
javascript·css·html·实战·系统·平台·社团活动发布
速易达网络2 小时前
Three.js 动画入门教学:从场景搭建到流畅动效
开发语言·javascript·ecmascript
摹客3 小时前
【3D设计】界面设计中的3D元素:应用场景+5款工具横评(含Three.js)
前端