技术栈:TypeScript + React + electron-vite
目标平台:Windows / macOS / Linux
深度:从装环境到打包出可分发的安装包
1. Electron 是什么
Electron 把 Chromium(渲染) 和 Node.js(后端) 打包进同一个可执行文件,让你用 Web 技术写桌面应用。
scss
┌─────────────────────────────────────┐
│ 你的 Electron App │
│ ┌────────────┐ ┌────────────────┐ │
│ │ 主进程 │ │ 渲染进程 │ │
│ │ (Node.js) │◄─┤ (Chromium) │ │
│ │ 文件/系统 │ │ React UI │ │
│ └────────────┘ └────────────────┘ │
│ ↑ Preload Script (桥接) │
└─────────────────────────────────────┘
优势 :上手最低门槛,前端工程师零学习曲线,生态最成熟(VS Code、Slack、Discord 都用它)。
劣势:安装包大(100 MB 起),内存占用高,每个窗口是一个完整的 Chrome 实例。
2. 环境准备
2.1 所有平台通用
| 依赖 | 版本要求 | 安装 |
|---|---|---|
| Node.js | ≥ 20 LTS | nodejs.org 或用 fnm/nvm 管理 |
| pnpm | ≥ 8 | npm install -g pnpm |
| Git | 任意 | git-scm.com |
验证:
bash
node -v # v20.x
pnpm -v # 8.x
git --version
2.2 Windows
安装 Visual Studio Build Tools 2022(原生 Node 模块编译用):
- 下载 vs_BuildTools.exe
- 勾选 "使用 C++ 的桌面开发"
- 安装完重启终端
设置 Electron 下载镜像(国内必做,否则 pnpm install 卡死):
bash
# 项目根目录创建 .npmrc,或全局 ~/.npmrc
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
2.3 macOS
bash
xcode-select --install # 安装命令行工具,会弹 GUI 确认框
如果要打公证的 .dmg(用户安装不会报"已损坏"),需要 Apple Developer 账号($99/年),见第 8 章。
2.4 Linux (Ubuntu / Debian)
bash
sudo apt update
sudo apt install -y \
build-essential \
libnss3 \
libatk-bridge2.0-0 \
libgtk-3-0 \
libgbm1 \
libasound2
3. 创建项目
使用 electron-vite 脚手架(Vite + React + TS 集成最好的选择):
bash
pnpm create @quick-start/electron my-app
交互选项:
sql
✔ Select a framework › React
✔ Add TypeScript? › Yes
✔ Add ESLint? › Yes
进入项目:
bash
cd my-app
pnpm install
pnpm dev
第一次运行会下载 Electron 二进制(约 80 MB),下载完后会弹出一个应用窗口,支持热更新。
3.1 目录结构解读
perl
my-app/
├── src/
│ ├── main/ # 主进程(Node 环境)
│ │ └── index.ts ← 应用入口,创建窗口、注册 IPC
│ ├── preload/ # 预加载脚本(桥接层)
│ │ └── index.ts ← 唯一可以同时访问 Node + DOM 的地方
│ └── renderer/ # 渲染进程(浏览器环境)
│ └── src/
│ ├── App.tsx ← React UI 入口
│ └── main.tsx
├── electron.vite.config.ts # Vite 构建配置(三进程各一份)
├── electron-builder.yml # 打包配置
└── package.json
4. 三进程模型(核心概念)
Electron 的安全模型是理解一切的基础,搞错这里会埋下严重漏洞。
4.1 主进程(Main Process)
- 环境:Node.js,有完整文件系统/网络/子进程权限
- 职责:创建/管理窗口,响应系统事件,处理菜单、托盘
- 数量:整个应用唯一一个
ts
// src/main/index.ts
import { app, BrowserWindow } from 'electron'
import path from 'node:path'
function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 600,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true, // 必须 true!
nodeIntegration: false, // 必须 false!
},
})
if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173')
win.webContents.openDevTools()
} else {
win.loadFile(path.join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(createWindow)
4.2 渲染进程(Renderer Process)
- 环境 :Chromium(浏览器),默认不能访问 Node.js
- 职责:跑 React UI
- 数量:每个窗口一个
渲染进程就是标准的 React 应用,和网页开发完全一样,唯一不同是可以通过 preload 暴露的 API 调用系统能力。
4.3 预加载脚本(Preload Script)
- 环境 :同时能访问 Node.js API 和
window对象 - 职责:把主进程能力安全地暴露给渲染进程(白名单机制)
- 关键 API :
contextBridge.exposeInMainWorld
ts
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
// 渲染进程只能调用你在这里列出的东西
readFile: (p: string) => ipcRenderer.invoke('fs:readFile', p),
writeFile: (p: string, data: string) => ipcRenderer.invoke('fs:writeFile', p, data),
onThemeChange: (cb: (theme: string) => void) => {
ipcRenderer.on('theme:changed', (_e, theme) => cb(theme))
},
})
4.4 IPC 通信
IPC(进程间通信)是主进程和渲染进程交换数据的机制。
scss
渲染进程 主进程
│ │
│ ipcRenderer.invoke() │
│ ─────────────────────────► │ ipcMain.handle()
│ │ 执行 Node 操作
│ ◄───────────────────────── │
│ 返回结果 (Promise) │
主进程注册处理器:
ts
// src/main/index.ts
import { ipcMain, app } from 'electron'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
ipcMain.handle('fs:readFile', async (_event, filename: string) => {
// ⚠ 安全:永远校验路径,防止路径穿越攻击
const userData = app.getPath('userData')
const safePath = path.resolve(userData, filename)
if (!safePath.startsWith(userData)) {
throw new Error('Access denied: path traversal detected')
}
return await readFile(safePath, 'utf-8')
})
ipcMain.handle('fs:writeFile', async (_event, filename: string, data: string) => {
const userData = app.getPath('userData')
const safePath = path.resolve(userData, filename)
if (!safePath.startsWith(userData)) {
throw new Error('Access denied')
}
await writeFile(safePath, data, 'utf-8')
})
5. 完整实战示例:本地 Note 应用
5.1 目标功能
- 输入框写笔记
- 保存到本地文件(userData 目录)
- 下次启动自动加载
5.2 主进程(完整)
ts
// src/main/index.ts
import { app, BrowserWindow, ipcMain } from 'electron'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
const NOTES_DIR = () => path.join(app.getPath('userData'), 'notes')
async function ensureNotesDir() {
await mkdir(NOTES_DIR(), { recursive: true })
}
ipcMain.handle('notes:load', async (_e, name: string) => {
await ensureNotesDir()
const file = path.join(NOTES_DIR(), `${name}.txt`)
try {
return await readFile(file, 'utf-8')
} catch {
return '' // 文件不存在时返回空字符串
}
})
ipcMain.handle('notes:save', async (_e, name: string, content: string) => {
await ensureNotesDir()
const file = path.join(NOTES_DIR(), `${name}.txt`)
await writeFile(file, content, 'utf-8')
})
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
},
})
if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173')
} else {
win.loadFile(path.join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() })
5.3 预加载脚本(完整)
ts
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('notes', {
load: (name: string): Promise<string> => ipcRenderer.invoke('notes:load', name),
save: (name: string, content: string): Promise<void> =>
ipcRenderer.invoke('notes:save', name, content),
})
5.4 React UI(完整)
tsx
// src/renderer/src/App.tsx
import { useState, useEffect } from 'react'
// 告诉 TS window.notes 的类型
declare global {
interface Window {
notes: {
load: (name: string) => Promise<string>
save: (name: string, content: string) => Promise<void>
}
}
}
const NOTE_NAME = 'default'
export default function App() {
const [text, setText] = useState('')
const [saved, setSaved] = useState(true)
useEffect(() => {
window.notes.load(NOTE_NAME).then(setText)
}, [])
const handleSave = async () => {
await window.notes.save(NOTE_NAME, text)
setSaved(true)
}
return (
<div style={{ padding: 20, fontFamily: 'sans-serif' }}>
<h2>My Notes {!saved && '●'}</h2>
<textarea
value={text}
onChange={(e) => { setText(e.target.value); setSaved(false) }}
style={{ width: '100%', height: 300, fontSize: 14 }}
/>
<br />
<button onClick={handleSave} disabled={saved}>
{saved ? 'Saved' : 'Save (Ctrl+S)'}
</button>
</div>
)
}
运行 pnpm dev,改内容保存后关窗口重开,数据会保留。
6. 系统集成
6.1 系统托盘
ts
// src/main/index.ts 里加
import { Tray, Menu, nativeImage } from 'electron'
import path from 'node:path'
let tray: Tray | null = null
function createTray() {
const icon = nativeImage.createFromPath(
path.join(__dirname, '../../resources/tray-icon.png')
)
tray = new Tray(icon.resize({ width: 16, height: 16 }))
tray.setContextMenu(Menu.buildFromTemplate([
{ label: '显示窗口', click: () => win?.show() },
{ type: 'separator' },
{ label: '退出', click: () => app.quit() },
]))
tray.setToolTip('My App')
}
6.2 原生菜单
ts
import { Menu } from 'electron'
const menuTemplate: Electron.MenuItemConstructorOptions[] = [
{
label: '文件',
submenu: [
{ label: '保存', accelerator: 'CmdOrCtrl+S', click: () => win?.webContents.send('menu:save') },
{ type: 'separator' },
{ label: '退出', role: 'quit' },
],
},
{ label: '编辑', role: 'editMenu' },
{ label: '视图', role: 'viewMenu' },
]
Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate))
6.3 系统对话框(打开/保存文件)
ts
import { dialog } from 'electron'
ipcMain.handle('dialog:openFile', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('dialog:saveFile', async (_e, content: string) => {
const result = await dialog.showSaveDialog({
filters: [{ name: 'Text', extensions: ['txt'] }],
})
if (!result.canceled && result.filePath) {
await writeFile(result.filePath, content, 'utf-8')
return result.filePath
}
return null
})
6.4 Shell 操作
ts
import { shell } from 'electron'
// 用系统默认浏览器打开链接(不要在 webview 里直接导航到外部链接)
ipcMain.handle('shell:openExternal', (_e, url: string) => {
shell.openExternal(url)
})
// 在文件管理器里显示文件
ipcMain.handle('shell:showInFolder', (_e, filePath: string) => {
shell.showItemInFolder(filePath)
})
7. 调试
7.1 渲染进程调试
开发模式下主进程会自动打开 DevTools(见 win.webContents.openDevTools()),和 Chrome DevTools 完全一样。
7.2 主进程调试
VS Code 配置 .vscode/launch.json:
json
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron",
"args": [".", "--inspect=5858"],
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron.cmd"
}
}
]
}
或者直接在终端加 --inspect:
bash
ELECTRON_INSPECT=5858 pnpm dev
# 然后在 Chrome 开 chrome://inspect
8. 打包
8.1 electron-builder 配置
electron-builder.yml(脚手架已生成,按需修改):
yaml
appId: com.yourcompany.myapp # 唯一标识符,反向域名格式
productName: My App
copyright: Copyright © 2025 YourName
directories:
output: dist # 产物目录
files:
- out/**/* # electron-vite 编译产物
- resources/**/* # 额外资源(图标等)
# ───────────── Windows ─────────────
win:
icon: resources/icon.ico # 256×256 .ico
target:
- target: nsis # 安装向导(推荐)
arch: [x64, arm64]
- target: portable # 免安装绿色版(可选)
nsis:
oneClick: false # false = 有安装向导
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
# ───────────── macOS ─────────────
mac:
icon: resources/icon.icns # .icns 格式
category: public.app-category.productivity
target:
- target: dmg
arch: [x64, arm64] # 分别打,或用 universal(更大)
dmg:
contents:
- x: 130
y: 220
- x: 410
y: 220
type: link
path: /Applications
# ───────────── Linux ─────────────
linux:
icon: resources/icon.png # 512×512 PNG
category: Utility
target:
- target: AppImage # 通用,推荐
- target: deb # Debian/Ubuntu
- target: rpm # Fedora/CentOS(可选)
8.2 图标准备
你需要三种格式的图标:
| 平台 | 格式 | 尺寸 |
|---|---|---|
| Windows | .ico | 至少包含 16/32/48/64/128/256 px |
| macOS | .icns | 包含从 16 到 1024 的多尺寸 |
| Linux | .png | 512×512 |
一键生成工具:把 1024×1024 PNG 放进去,用 electron-icon-builder:
bash
pnpm add -D electron-icon-builder
npx electron-icon-builder --input=icon.png --output=resources/
8.3 打包命令
bash
# 编译(不打包,检查构建是否通过)
pnpm build
# 打当前平台的包
pnpm build:win # 出 .exe(NSIS 安装包)
pnpm build:mac # 出 .dmg
pnpm build:linux # 出 .AppImage + .deb
跨平台打包说明:electron-builder 尽力支持,但不是所有情况都靠谱:
- Windows 包:建议在 Windows 机器打
- macOS 包:必须在 macOS 打(需要真正的 macOS 工具链和证书)
- Linux 包:Linux 或 macOS 都能打,Windows 可能有问题
8.4 产物位置
bash
dist/
├── win-unpacked/ # Windows 解压版(调试用)
├── My App-1.0.0-setup.exe # Windows NSIS 安装包
├── My App-1.0.0.dmg # macOS 磁盘镜像
├── My App-1.0.0.AppImage # Linux 通用包
└── My App_1.0.0_amd64.deb # Linux Debian 包
8.5 代码签名(正式发布必做)
Windows:
未签名的 exe 在用户电脑上会触发 SmartScreen 警告("Windows 已保护你的电脑")。
-
购买代码签名证书(DigiCert / Sectigo,EV 证书约 $200/年)
-
在
electron-builder.yml中配置:yamlwin: certificateFile: my-cert.pfx certificatePassword: ${env.CERT_PASSWORD} -
或用 Azure Trusted Signing(微软新方案,更便宜)。
macOS:
未签名/未公证的 app 在 macOS 上会被 Gatekeeper 拦截("已损坏,无法打开")。
yaml
# electron-builder.yml
mac:
identity: "Developer ID Application: Your Name (TEAM_ID)"
hardenedRuntime: true
entitlements: build/entitlements.mac.plist
entitlementsInherit: build/entitlements.mac.plist
gatekeeperAssess: false
notarize:
teamId: YOUR_TEAM_ID
需要 APPLE_ID、APPLE_APP_SPECIFIC_PASSWORD、APPLE_TEAM_ID 环境变量。
开发阶段临时绕过(仅自用):
bash
# macOS 让用户跑这个
xattr -cr /Applications/MyApp.app
9. package.json 脚本参考
json
{
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"build:win": "electron-vite build && electron-builder --win",
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit"
}
}
10. 常见问题排查
打包后白屏
根本原因:生产环境路径不对。
检查主进程里加载文件的代码:
ts
// ❌ 错误(相对路径在打包后失效)
win.loadFile('dist/renderer/index.html')
// ✅ 正确
win.loadFile(path.join(__dirname, '../renderer/index.html'))
打开 DevTools 看 Console 报错:
ts
win.webContents.openDevTools()
pnpm install 卡在下载 Electron
国内需要镜像,在项目根目录的 .npmrc 加:
ini
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
或者:
bash
export ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
pnpm install
原生模块编译失败
症状:Error: The module was compiled against a different version of Node。
bash
pnpm add -D @electron/rebuild
npx electron-rebuild
electron-builder 打包时会自动重新编译,开发时手动跑一遍就好。
ipcMain.handle 没收到调用
检查顺序:
- preload 里是否
exposeInMainWorld了这个方法 - 主进程的
ipcMain.handle是否在app.whenReady()后注册 - 渲染进程调的名字是否和主进程注册的名字完全一致(字符串)
macOS 打包报 "code sign" 错误
开发期不想处理签名:
yaml
# electron-builder.yml
mac:
identity: null
或设置环境变量跳过:
bash
CSC_IDENTITY_AUTO_DISCOVERY=false pnpm build:mac
菜单在 macOS 显示不对
macOS 的第一个菜单永远是应用名(系统行为),你设的 File 会变成第二个。macOS 需要特殊处理:
ts
if (process.platform === 'darwin') {
menuTemplate.unshift({ label: app.name, role: 'appMenu' })
}
11. 安全检查清单
在提交代码前过一遍:
-
nodeIntegration: false(默认已是,不要改) -
contextIsolation: true(默认已是,不要改) - preload 只暴露最小必要 API
- ipcMain.handle 内校验所有来自渲染进程的路径参数
- 外部链接用
shell.openExternal()而不是loadURL() - 不在渲染进程里
require('electron')
12. 自动更新
生产环境必须有自动更新,否则 bug 修复无法推到用户。Electron 用 electron-updater(electron-builder 附带)。
12.1 安装
bash
pnpm add electron-updater
12.2 更新服务端
electron-updater 支持多种更新源:
| 方案 | 适合场景 |
|---|---|
| GitHub Releases | 开源或免费项目,零成本 |
| S3 / OSS / MinIO | 自建,完全控制 |
| electron-release-server | 自建服务器,支持渠道管理 |
以 GitHub Releases 为例(最简单),electron-builder.yml 加:
yaml
publish:
provider: github
owner: your-github-username
repo: your-repo-name
12.3 主进程更新代码
ts
// src/main/updater.ts
import { autoUpdater } from 'electron-updater'
import { BrowserWindow, ipcMain } from 'electron'
import log from 'electron-log'
export function setupAutoUpdater(win: BrowserWindow) {
// 生产环境才启用
if (process.env.NODE_ENV === 'development') return
autoUpdater.logger = log
autoUpdater.autoDownload = false // 不自动下载,让用户决定
autoUpdater.on('update-available', (info) => {
win.webContents.send('update:available', info)
})
autoUpdater.on('update-not-available', () => {
win.webContents.send('update:not-available')
})
autoUpdater.on('download-progress', (progress) => {
win.webContents.send('update:progress', progress)
})
autoUpdater.on('update-downloaded', () => {
win.webContents.send('update:downloaded')
})
autoUpdater.on('error', (err) => {
win.webContents.send('update:error', err.message)
log.error('Updater error:', err)
})
// IPC:渲染进程触发检查 / 下载 / 安装
ipcMain.handle('updater:check', () => autoUpdater.checkForUpdates())
ipcMain.handle('updater:download', () => autoUpdater.downloadUpdate())
ipcMain.handle('updater:install', () => autoUpdater.quitAndInstall())
// 启动后 10 秒检查更新(不要立刻检查,影响启动体验)
setTimeout(() => autoUpdater.checkForUpdates(), 10_000)
}
在 createWindow 完成后调用:
ts
app.whenReady().then(() => {
const win = createWindow()
setupAutoUpdater(win)
})
12.4 Preload 暴露
ts
// src/preload/index.ts 追加
contextBridge.exposeInMainWorld('updater', {
check: () => ipcRenderer.invoke('updater:check'),
download: () => ipcRenderer.invoke('updater:download'),
install: () => ipcRenderer.invoke('updater:install'),
onAvailable: (cb: (info: unknown) => void) =>
ipcRenderer.on('update:available', (_e, info) => cb(info)),
onProgress: (cb: (p: unknown) => void) =>
ipcRenderer.on('update:progress', (_e, p) => cb(p)),
onDownloaded: (cb: () => void) =>
ipcRenderer.on('update:downloaded', cb),
})
12.5 发布流程
bash
# 打包并发布到 GitHub Releases(需要 GH_TOKEN 环境变量)
GH_TOKEN=your_token pnpm build:win
electron-builder 会上传安装包 + latest.yml(更新元数据文件)到 GitHub Releases,客户端通过读这个文件判断是否有新版本。
13. 多窗口管理
生产应用常有多种窗口:主窗口、设置窗口、关于窗口、全屏遮罩等。
13.1 窗口管理器
ts
// src/main/window-manager.ts
import { BrowserWindow, screen } from 'electron'
import path from 'node:path'
type WindowName = 'main' | 'settings' | 'about'
class WindowManager {
private windows = new Map<WindowName, BrowserWindow>()
private baseOpts = {
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
},
}
create(name: WindowName, opts: Electron.BrowserWindowConstructorOptions = {}) {
// 已存在则聚焦,不重复创建
const existing = this.windows.get(name)
if (existing && !existing.isDestroyed()) {
existing.focus()
return existing
}
const win = new BrowserWindow({ ...this.baseOpts, ...opts })
win.on('closed', () => this.windows.delete(name))
this.windows.set(name, win)
const url = process.env.NODE_ENV === 'development'
? `http://localhost:5173/#${name}`
: `file://${path.join(__dirname, '../renderer/index.html')}#${name}`
win.loadURL(url)
return win
}
get(name: WindowName) {
return this.windows.get(name)
}
closeAll() {
this.windows.forEach((win) => { if (!win.isDestroyed()) win.close() })
}
}
export const wm = new WindowManager()
使用:
ts
// 主进程
import { wm } from './window-manager'
app.whenReady().then(() => {
wm.create('main', { width: 1200, height: 800 })
})
ipcMain.handle('window:openSettings', () => {
wm.create('settings', { width: 600, height: 500, parent: wm.get('main') })
})
13.2 窗口状态持久化
关闭后记住窗口位置和大小,下次打开还原:
bash
pnpm add electron-window-state
ts
import windowStateKeeper from 'electron-window-state'
function createMainWindow() {
const state = windowStateKeeper({
defaultWidth: 1200,
defaultHeight: 800,
})
const win = new BrowserWindow({
x: state.x,
y: state.y,
width: state.width,
height: state.height,
webPreferences: { /* ... */ },
})
state.manage(win) // 自动保存 resize/move 事件
return win
}
14. 环境变量与配置管理
14.1 开发 vs 生产区分
electron-vite 已处理好,但要注意几个常见需求:
ts
// 判断环境
const isDev = process.env.NODE_ENV === 'development'
const isProd = !isDev
// app.isPackaged 是更可靠的"是否打包了"判断
const isPackaged = app.isPackaged
14.2 环境变量文件
项目根目录创建(不要提交到 git):
bash
.env # 所有环境共用
.env.development # 仅开发
.env.production # 仅生产
.env.local # 本地覆盖(最高优先级)
bash
# .env.development
VITE_API_URL=http://localhost:3000
VITE_LOG_LEVEL=debug
# .env.production
VITE_API_URL=https://api.yourapp.com
VITE_LOG_LEVEL=warn
渲染进程里用 import.meta.env.VITE_* 访问(只有 VITE_ 前缀的变量会暴露给渲染进程)。
主进程里用 process.env.*,但 electron-vite 不会自动注入 .env 到主进程------需要用 dotenv:
ts
// src/main/index.ts 顶部
import { config } from 'dotenv'
config({ path: `.env.${process.env.NODE_ENV}` })
14.3 生产配置文件(用户数据目录)
用户级别的配置(用户偏好、账号信息等)存在 userData 目录:
ts
// src/main/config.ts
import { app } from 'electron'
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
interface AppConfig {
theme: 'light' | 'dark' | 'system'
language: string
windowBounds?: { x: number; y: number; width: number; height: number }
}
const CONFIG_PATH = path.join(app.getPath('userData'), 'config.json')
const DEFAULTS: AppConfig = { theme: 'system', language: 'zh-CN' }
export async function loadConfig(): Promise<AppConfig> {
try {
const raw = await readFile(CONFIG_PATH, 'utf-8')
return { ...DEFAULTS, ...JSON.parse(raw) }
} catch {
return DEFAULTS
}
}
export async function saveConfig(config: Partial<AppConfig>) {
const current = await loadConfig()
await writeFile(CONFIG_PATH, JSON.stringify({ ...current, ...config }, null, 2))
}
15. 日志与错误监控
15.1 electron-log(本地日志)
bash
pnpm add electron-log
ts
// src/main/logger.ts
import log from 'electron-log'
import path from 'node:path'
import { app } from 'electron'
// 配置日志文件路径(默认已在 userData 下)
log.transports.file.resolvePathFn = () =>
path.join(app.getPath('userData'), 'logs', 'main.log')
log.transports.file.maxSize = 10 * 1024 * 1024 // 10 MB 后轮转
log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'warn'
log.transports.file.level = 'info'
export default log
渲染进程里:
ts
import log from 'electron-log/renderer'
log.info('User clicked save')
log.error('Failed to load config', error)
15.2 Sentry(生产错误上报)
bash
pnpm add @sentry/electron
ts
// src/main/index.ts
import * as Sentry from '@sentry/electron/main'
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: app.isPackaged ? 'production' : 'development',
release: app.getVersion(),
// 不要在开发时上报
enabled: app.isPackaged,
})
ts
// src/renderer/src/main.tsx
import * as Sentry from '@sentry/electron/renderer'
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
})
16. 性能优化
16.1 懒加载渲染进程内容
tsx
// 路由级别懒加载
import { lazy, Suspense } from 'react'
const HeavyFeature = lazy(() => import('./HeavyFeature'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyFeature />
</Suspense>
)
}
16.2 主进程 CPU 密集任务:用 Worker
主进程里跑重计算会阻塞 IPC 响应,用 worker_threads:
ts
// src/main/workers/hash-worker.ts
import { parentPort, workerData } from 'node:worker_threads'
import { createHash } from 'node:crypto'
const hash = createHash('sha256').update(workerData.content).digest('hex')
parentPort?.postMessage(hash)
ts
// src/main/index.ts
import { Worker } from 'node:worker_threads'
import path from 'node:path'
ipcMain.handle('file:hash', (_e, content: string) => {
return new Promise((resolve, reject) => {
const worker = new Worker(
path.join(__dirname, 'workers/hash-worker.js'),
{ workerData: { content } }
)
worker.on('message', resolve)
worker.on('error', reject)
})
})
16.3 渲染进程内存泄漏常见原因
ts
// ❌ 监听器未清理
useEffect(() => {
window.electronAPI.onThemeChange(handleTheme)
// 忘记返回清理函数
}, [])
// ✅ 正确
useEffect(() => {
const unlisten = window.electronAPI.onThemeChange(handleTheme)
return () => unlisten() // 组件卸载时清理
}, [])
preload 里监听器也要提供清理方法:
ts
contextBridge.exposeInMainWorld('electronAPI', {
onThemeChange: (cb: (theme: string) => void) => {
const handler = (_e: unknown, theme: string) => cb(theme)
ipcRenderer.on('theme:changed', handler)
// 返回清理函数
return () => ipcRenderer.removeListener('theme:changed', handler)
},
})
16.4 冷启动优化
ts
// 主窗口先隐藏,ready-to-show 后再显示(避免白屏闪烁)
const win = new BrowserWindow({
show: false,
backgroundColor: '#ffffff', // 设背景色避免黑屏
webPreferences: { /* ... */ },
})
win.once('ready-to-show', () => win.show())
17. CI/CD(GitHub Actions)
17.1 多平台自动构建并发布
yaml
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*' # 推 v1.0.0 这样的 tag 触发
permissions:
contents: write
jobs:
build:
strategy:
matrix:
include:
- os: windows-latest
script: build:win
- os: macos-latest
script: build:mac
- os: ubuntu-latest
script: build:linux
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build & publish
run: pnpm ${{ matrix.script }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# macOS 签名
CSC_LINK: ${{ secrets.MAC_CERT_P12_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERT_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# Windows 签名(可选)
WIN_CSC_LINK: ${{ secrets.WIN_CERT_P12_BASE64 }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CERT_PASSWORD }}
触发方式:
bash
git tag v1.0.1
git push origin v1.0.1
Actions 会在三个平台并行构建,完成后自动创建 GitHub Release 并上传安装包。
17.2 macOS 证书在 CI 里的处理
macOS 签名证书不能直接放进仓库,通过 Secrets 传:
bash
# 本地:把证书 base64 编码
base64 -i certificate.p12 | pbcopy # macOS
把输出粘贴到 Settings → Secrets → MAC_CERT_P12_BASE64。
18. 完整安全检查清单(生产版)
在发布前过一遍:
进程隔离
-
nodeIntegration: false -
contextIsolation: true -
sandbox: true(可选但推荐,限制主进程权限) - preload 只暴露最小必要 API
输入校验
- 所有从渲染进程来的路径参数都做路径穿越检查
- 所有 IPC handler 都做参数类型校验(用 zod 或手动)
- 外部 URL 只通过
shell.openExternal(),不loadURL()
内容安全策略
-
设置 CSP header,禁止
unsafe-eval和unsafe-inline:tswin.webContents.session.webRequest.onHeadersReceived((_details, callback) => { callback({ responseHeaders: { 'Content-Security-Policy': [ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" ], }, }) })
依赖安全
- 定期跑
pnpm audit,修复 high/critical 漏洞 - 不在
dependencies里放只有开发需要的包(增大安装包)
发布安全
- 代码签名(Windows + macOS)
- macOS 公证
- 不在日志里输出敏感信息(Token、密码)
19. 参考资源
- 官方文档:electronjs.org/docs/latest
- electron-vite:electron-vite.org
- electron-builder:www.electron.build
- electron-updater:www.electron.build/auto-update
- electron-log:github.com/megahertz/e...
- Sentry Electron:docs.sentry.io/platforms/j...
- 安全最佳实践:electronjs.org/docs/latest...