Electron 桌面应用开发完全指南:从环境搭建到生产发布
为什么选择 Electron
跨平台桌面应用开发一直是个让人头疼的问题。写 Windows 用 C# 和 WPF,写 macOS 用 Swift 和 AppKit,写 Linux 用 GTK 或 Qt——三套代码,三份维护成本。Electron 的出现改变了这个局面:一套 HTML/CSS/JavaScript,打包后同时跑在 Windows、macOS 和 Linux 上。
但凡用过 VS Code、Figma、Slack、Discord 的开发者,其实每天都在用 Electron 应用。VSCode 的启动速度、Figma 的渲染性能,证明了 Electron 如果架构得当,体验完全可以媲美原生。
我帮团队迁移过一个内部工具——从 C# WinForms 改到 Electron。后续两个季度,新功能的迭代速度提升了将近三倍。原因很简单:招前端比招 C# 桌面开发容易得多,组件生态也丰富得多。
这篇文章是我踩坑后的完整总结。我不会罗列 API 文档——那些你随时能查到——我关注的是实际项目中真正会遇到的问题和对应的解决方案。
项目初始化:别再抄官方的 quick-start 了
官方 electron-quick-start 示例用裸 JavaScript,没有构建工具,没有模块化,没有热更新。真实项目这样起步,两个月后你会在屎山里改 bug。
推荐用 electron-vite 或者手动搭建 Vite + Electron:
npm create @quick-start/electron@latest my-app -- --template vue-ts
或者手搭:
mkdir my-electron-app && cd my-electron-app
npm init -y
npm i -D electron electron-builder vite @vitejs/plugin-vue typescript
npm i vue
项目目录结构建议:
my-electron-app/
├── electron/ # 主进程代码
│ ├── main.ts # 入口,创建窗口
│ ├── preload.ts # 预加载脚本(安全桥接)
│ └── ipc/ # IPC 通道管理
│ ├── file.ts # 文件操作相关
│ └── system.ts # 系统信息相关
├── src/ # 渲染进程(前端代码)
│ ├── App.vue
│ ├── main.ts
│ └── components/
├── resources/ # 应用图标等静态资源
├── electron-builder.yml # 打包配置
├── vite.config.ts
└── package.json
这里最关键的设计决策是:主进程和渲染进程完全分离。主进程 (electron/) 只做系统级操作,渲染进程 (src/) 是标准的前端项目。这样两个方向的人可以各自独立开发,互不干扰。
进程模型:搞清楚谁负责什么
Electron 有两个核心进程,不把这个概念吃透,后续的安全问题和性能问题会让你反复返工。
| 进程 | 运行环境 | 能做什么 | 不能做什么 |
|---|---|---|---|
| 主进程 (Main) | Node.js | 创建窗口、读写文件、调用系统 API、管理应用生命周期 | 不能直接操作 DOM |
| 渲染进程 (Renderer) | Chromium | 渲染 HTML/CSS、响应用户交互、操作 DOM | 默认不能访问 Node.js API |
两者通过 IPC(进程间通信) 桥接。主进程暴露有限的 API 给渲染进程,渲染进程通过 ipcRenderer 调用。
一个典型的通信流程:
用户点击"保存文件"
→ 渲染进程: ipcRenderer.invoke('save-file', fileData)
→ 主进程: ipcMain.handle('save-file', async (event, data) => {
dialog.showSaveDialog() → fs.writeFileSync()
})
→ 返回结果给渲染进程
主进程入口示例
// electron/main.ts
import { app, BrowserWindow } from 'electron';
import { registerIpcHandlers } from './ipc';
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // 必须开启
nodeIntegration: false, // 必须关闭
sandbox: true, // 沙箱模式
},
});
// 开发环境加载 Vite 开发服务器
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
}
app.whenReady().then(() => {
registerIpcHandlers();
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
Preload 脚本——应用的安全门
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
// 文件操作
saveFile: (content: string, defaultName?: string) =>
ipcRenderer.invoke('save-file', content, defaultName),
openFile: () =>
ipcRenderer.invoke('open-file'),
// 系统信息
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getPlatform: () => process.platform,
// 窗口控制
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
});
渲染进程中这样调用:
// src/components/SaveButton.vue
const handleSave = async () => {
const result = await window.electronAPI.saveFile(
editorContent.value,
'untitled.txt'
);
if (result.success) {
showToast(`已保存到 ${result.filePath}`);
}
};
contextBridge 是关键——它限制了渲染进程可调用的 API 范围。永远不要直接暴露 ipcRenderer 给渲染进程,那样等于把 Node.js 完全开放给了网页内容。
安全性:七个必须遵守的规则
Electron 的安全模型经历了多次迭代,老教程里的做法很多已经不安全了。截至 2026 年,以下是硬性要求:
| # | 规则 | 配置 | 原因 |
|---|---|---|---|
| 1 | contextIsolation: true |
webPreferences | 隔离渲染进程的 JS 上下文和 preload |
| 2 | nodeIntegration: false |
webPreferences | 禁止渲染进程直接 require Node 模块 |
| 3 | sandbox: true |
webPreferences | 操作系统级沙箱,限制进程权限 |
| 4 | 使用 contextBridge |
preload.ts | 白名单式暴露 API,而非直接挂载 |
| 5 | 验证 IPC 消息来源 | ipcMain.handle | event.senderFrame 校验调用方 URL |
| 6 | 禁止远程内容 | webPreferences | 不加载远程 URL,除非有 CSP 策略 |
| 7 | 签名和公证 | electron-builder | macOS 必须公证,Windows 建议签名 |
IPC 消息校验
// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
// 校验调用来源
if (!event.senderFrame?.url.startsWith('file://')) {
throw new Error('Unauthorized caller');
}
// 校验参数类型
if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
throw new Error('Invalid content');
}
const { filePath } = await dialog.showSaveDialog({
defaultPath: 'untitled.txt',
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (!filePath) return { success: false, canceled: true };
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true, filePath };
});
窗口管理:不仅仅是 new BrowserWindow
无边框窗口与自定义标题栏
大多数现代桌面应用都抛弃了系统原生标题栏——VS Code、Figma、Notion 都是这样。实现方式:
const mainWindow = new BrowserWindow({
frame: false, // 无边框
titleBarStyle: 'hidden', // macOS 隐藏标题栏但保留红绿灯
// Windows 下用 hidden,macOS 下用 hiddenInset
...(process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset' }
: { frame: false }),
});
然后在渲染进程中实现自定义标题栏:
<!-- src/components/TitleBar.vue -->
<template>
<div class="title-bar" :class="{ 'is-mac': isMac }">
<div class="title-bar-drag" @dblclick="toggleMaximize">
<span class="app-title">{{ title }}</span>
</div>
<div class="window-controls" v-if="!isMac">
<button @click="minimize" class="control-btn minimize">─</button>
<button @click="toggleMaximize" class="control-btn maximize">□</button>
<button @click="close" class="control-btn close">✕</button>
</div>
</div>
</template>
<style scoped>
.title-bar {
display: flex;
height: 32px;
background: #1e1e1e;
-webkit-app-region: no-drag;
}
.title-bar-drag {
flex: 1;
-webkit-app-region: drag;
display: flex;
align-items: center;
padding-left: 12px;
}
.app-title {
color: #ccc;
font-size: 12px;
}
.window-controls { display: flex; }
.control-btn {
width: 46px;
height: 32px;
border: none;
background: transparent;
color: #ccc;
font-size: 12px;
cursor: pointer;
}
.control-btn:hover { background: rgba(255,255,255,0.1); }
.control-btn.close:hover { background: #e81123; color: #fff; }
</style>
注意 -webkit-app-region: drag ——这行 CSS 让标题栏区域支持拖拽移动窗口。按钮区域必须用 no-drag 覆盖,否则点按钮会变成拖拽。
多窗口通信
复杂应用往往需要多个窗口(设置页、预览窗口、弹窗)。窗口间通信推荐用主进程中转:
// 主进程中转消息
ipcMain.on('broadcast-to-windows', (event, channel: string, data: unknown) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win.webContents.id !== event.sender.id) {
win.webContents.send(channel, data);
}
});
});
或者直接在 preload 中暴露 MessagePort:
// 通过 MessageChannel 直接通信,不经过主进程中转
const { port1, port2 } = new MessageChannelMain();
ipcMain.on('request-port', (event) => {
event.ports[0].postMessage('port-opened');
});
原生模块集成:当 Node.js 不够用时
有些场景 JavaScript 性能不够——比如图片批量处理、视频编解码。这时需要 C++ 原生模块。
方案一:Node.js Native Addon (node-addon-api)
// native/thumbnail.cc
#include <napi.h>
Napi::Value GenerateThumbnail(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string inputPath = info[0].As<Napi::String>();
std::string outputPath = info[1].As<Napi::String>();
// 使用 FFmpeg/libvips 生成缩略图
// ...
return Napi::String::New(env, outputPath);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("generateThumbnail",
Napi::Function::New(env, GenerateThumbnail));
return exports;
}
NODE_API_MODULE(thumbnail, Init)
编译后在主进程中加载:
const { generateThumbnail } = require('./native/build/Release/thumbnail.node');
方案二:子进程调用外部工具
更推荐的方式——解耦,崩溃不影响主进程:
// electron/ipc/image.ts
import { spawn } from 'child_process';
ipcMain.handle('compress-image', async (event, inputPath: string) => {
return new Promise((resolve, reject) => {
const child = spawn('oxipng', ['--opt', 'max', '--strip', 'safe', inputPath]);
let stderr = '';
child.stderr.on('data', (data) => { stderr += data; });
child.on('close', (code) => {
if (code === 0) resolve({ success: true });
else reject(new Error(`oxipng exited with ${code}: ${stderr}`));
});
});
});
自动更新:用户无感升级
自动更新是桌面应用的标配。electron-updater(基于 electron-builder)是最成熟的方案:
// electron/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow, dialog } from 'electron';
export function setupAutoUpdater(mainWindow: BrowserWindow) {
// 配置更新源(可以是 GitHub Releases、自建服务器、OSS)
autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://releases.your-app.com/updates/',
});
// 每 4 小时检查一次
setInterval(() => {
autoUpdater.checkForUpdates();
}, 4 * 60 * 60 * 1000);
autoUpdater.on('update-available', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: '发现新版本',
message: '正在后台下载更新...',
});
});
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: '更新已就绪',
message: '新版本已下载,点击确认立即重启安装',
}).then(() => {
autoUpdater.quitAndInstall();
});
});
autoUpdater.on('error', (err) => {
console.error('Update error:', err.message);
});
// 应用启动 5 秒后检查一次
setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}
自建更新服务器的目录结构
updates/
├── latest.yml # 最新版本元信息
├── YourApp-1.0.0.exe
├── YourApp-1.0.0.exe.blockmap
├── YourApp-1.0.1.exe
└── YourApp-1.0.1.exe.blockmap
latest.yml 内容由 electron-builder 自动生成。部署到 Nginx 或 OSS 即可。
打包与分发
electron-builder.yml 配置示例:
appId: com.yourcompany.yourapp
productName: YourApp
directories:
output: release
files:
- dist/**/*
- electron/**/*.js
- package.json
win:
target:
- target: nsis
arch: [x64, arm64]
icon: resources/icon.ico
sign: scripts/sign-windows.js
mac:
target:
- target: dmg
arch: [x64, arm64]
icon: resources/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
entitlements: resources/entitlements.mac.plist
notarize:
teamId: YOUR_TEAM_ID
linux:
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
category: Development
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
打包命令:
# 本地开发
npm run dev
# 构建当前平台
npm run build
# 构建全平台(需 CI 环境)
npm run build:win
npm run build:mac
npm run build:linux
性能优化:别让你的应用变成 Chrome
Electron 被诟病最多的是内存占用。一个空 Electron 自带 50-80MB,加上实际页面很容易上 200MB。以下是在项目中验证过的优化策略:
1. 延迟加载非首屏窗口
// 不要在 app.whenReady 时创建所有窗口
// 按需创建,用完立即销毁
let settingsWindow: BrowserWindow | null = null;
ipcMain.handle('open-settings', async () => {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({ /* ... */ });
settingsWindow.loadURL('...');
settingsWindow.on('closed', () => {
settingsWindow = null;
});
});
2. 渲染进程的懒加载
const win = new BrowserWindow({
webPreferences: {
backgroundThrottling: true, // 后台标签页降低帧率
offscreen: false, // 不渲染不可见内容
},
});
// 窗口不活动时降低绘制频率
win.on('blur', () => {
win.webContents.setBackgroundThrottling(true);
});
3. 使用 BrowserView 替代子窗口
对于需要嵌入第三方内容(如预览 HTML、PDF)的场景,用 BrowserView 而非新窗口:
const view = new BrowserView({
webPreferences: { sandbox: true, contextIsolation: true },
});
mainWindow.setBrowserView(view);
view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
view.webContents.loadURL('https://preview.your-app.com/doc/123');
4. 内存泄漏排查清单
| 检查项 | 工具 | 指标 |
|---|---|---|
| 关闭窗口后进程未退出 | 任务管理器 / Activity Monitor | 残留 electron 进程 |
| webContents 未销毁 | webContents.getAllWebContents() |
数量持续增长 |
| IPC 监听未清理 | ipcMain.eventNames() |
removeHandler 遗漏 |
| 大对象引用未释放 | Chrome DevTools Memory 面板 | 堆快照对比 |
调试技巧
主进程调试
# 启动时附加调试器
electron --inspect=5858 .
# Chrome 中打开 chrome://inspect 连接
VSCode 的 launch.json 配置:
{
"type": "node",
"request": "launch",
"name": "Debug Main Process",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"runtimeArgs": ["--inspect=5858", "."],
"port": 5858
}
渲染进程调试
开发模式下默认开启 DevTools。生产环境可以通过命令行参数强制打开:
if (process.argv.includes('--devtools')) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
真机联调
# 获取应用路径
process.execPath
# → /Applications/YourApp.app/Contents/MacOS/YourApp (macOS)
# → C:\Users\...\AppData\Local\Programs\your-app\YourApp.exe (Windows)
# 直接运行可执行文件查看控制台输出
常见踩坑记录
坑 1:macOS 公证失败
"notarization failed: The binary is not signed"
解决:确认 hardenedRuntime: true,entitlements 文件正确,且使用 Apple Developer ID 证书(非 Mac App Store 证书需单独公证)。
坑 2:Windows 下路径过长
Windows 路径限制 260 字符。node_modules 深层嵌套极易超标。解决方案:
// package.json
{
"scripts": {
"postinstall": "electron-builder install-app-deps"
}
}
或者启用 Windows 长路径支持(Windows 10 1607+):
组策略 → 计算机配置 → 管理模板 → 系统 → 文件系统 → 启用 Win32 长路径
坑 3:asar 打包后 native 模块加载失败
electron-builder 默认将代码打包为 app.asar,但 .node 原生模块无法从 asar 内部加载。
# electron-builder.yml
asarUnpack:
- "node_modules/sharp/**"
- "node_modules/better-sqlite3/**"
- "native/**/*.node"
坑 4:启动白屏时间过长
优化策略:
- Vite 构建产物做 code split,首屏只加载必要代码
- 使用骨架屏或启动图覆盖白屏期
- 主进程在
ready-to-show事件后才显示窗口:
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
相关工具
- Base64 编码解码 — 处理内嵌资源与文件传输
- JSON 格式化 — 调试 IPC 消息与配置文件
- 图片压缩 — 优化应用内图片资源的体积
本站提供浏览器本地工具,免注册即可试用 →