本文面向:想把 Web 应用打包成桌面软件的前端开发者。 预计阅读时间:12 分钟 最终效果:理解 Electron 的核心概念(主进程、渲染进程、安全模型),掌握 BrowserWindow 配置、服务器嵌入和窗口状态持久化。
Electron 是什么
Electron 把 Chromium(Chrome 的内核)和 Node.js 打包到了一起。你的 Web 前端跑在 Chromium 里,你的后端逻辑跑在 Node.js 里,两者通过 IPC(进程间通信)连接。VS Code、Slack、Discord 都是 Electron 应用。
对 Web 开发者来说,最大的好处是:你已有的 HTML/CSS/JS/React 代码可以直接复用,不需要学 Swift 或 C++。
最小项目结构
一个 Electron 项目至少需要三样东西:
perl
my-app/
package.json # 入口指向 main.js
main.js # 主进程:创建窗口、管理生命周期
preload.js # 预加载脚本:安全地暴露 API 给前端
index.html # 你的 Web 页面
package.json 里的 "main": "main.js" 告诉 Electron 从哪个文件启动。运行 npx electron . 就能打开一个桌面窗口,里面显示你的 HTML。
主进程 vs 渲染进程
这是 Electron 最核心的概念:
- 主进程(Main Process) :运行
main.js的 Node.js 环境。它能访问文件系统、操作系统 API、创建窗口。一个应用只有一个主进程。 - 渲染进程(Renderer Process) :每个
BrowserWindow里跑的是一个独立的 Chromium 页面,和你在浏览器里打开网页一样。它不能直接访问 Node.js API。
两个进程各司其职。主进程负责"管家"工作(窗口、托盘、菜单、系统交互),渲染进程负责"展示"工作(UI、用户交互)。它们通过 contextBridge 安全地通信。
BrowserWindow 配置
创建窗口的核心是 new BrowserWindow(options)。ChatCrystal 的配置如下:
typescript
const win = new BrowserWindow({
width: state.width, // 从保存的状态恢复,或默认 1280
height: state.height, // 默认 800
x: state.x,
y: state.y,
minWidth: 900, // 最小宽度,防止窗口被拖得太小
minHeight: 600,
show: false, // 先不显示,等页面加载完再显示
title: "ChatCrystal",
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
});
几个关键点:
show: false配合ready-to-show事件使用,避免窗口先闪一下白屏再加载内容。minWidth/minHeight保证 UI 不会被压到变形。webPreferences是安全相关的配置,下面专门讲。
安全设置
Electron 的安全模型遵循一个原则:渲染进程不应该有特权。三个配置项实现这一点:
contextIsolation: true:渲染进程的 JavaScript 和 preload 脚本运行在不同的上下文里。即使网页被注入恶意代码,它也无法访问 preload 脚本里的 Node.js 对象。
nodeIntegration: false :渲染进程里不能直接 require('fs') 之类的 Node.js 模块。这是关闭的,因为网页内容可能来自用户输入(比如 AI 对话内容),如果能执行任意 Node.js 代码就是远程代码执行漏洞。
sandbox: true:进一步限制,让渲染进程连 Chromium 的扩展 API 都用不了,只保留最基本的 Web 能力。
那渲染进程怎么和主进程通信?通过 preload 脚本:
typescript
// preload.ts
import { contextBridge } from "electron";
contextBridge.exposeInMainWorld("electronAPI", {
isElectron: true,
versions: {
electron: process.versions.electron,
node: process.versions.node,
chrome: process.versions.chrome,
},
});
contextBridge.exposeInMainWorld 是唯一安全的方式,它把指定的对象挂到 window.electronAPI 上。前端代码可以读 window.electronAPI.isElectron 来判断自己是不是跑在 Electron 里,但无法访问任何危险的 Node.js API。
CSP(内容安全策略) 是另一层防护。ChatCrystal 在生产环境设置了严格的 CSP 头:
typescript
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self';" +
" script-src 'self';" +
" style-src 'self' 'unsafe-inline';" +
" img-src 'self' data: blob:;" +
" font-src 'self' data:;" +
" connect-src 'self' http://localhost:* ws://localhost:*;" +
" object-src 'none';" +
" base-uri 'self'",
],
},
});
});
CSP 告诉浏览器:只允许加载同源的脚本,禁止内联脚本(script-src 'self'),禁止插件(object-src 'none')。这对 ChatCrystal 尤其重要,因为它会渲染 AI 对话内容,必须防止 XSS 注入。
注意开发环境跳过了 CSP,因为 Vite 的 HMR(热更新)需要注入内联脚本。
嵌入 Fastify 服务器
很多 Electron 应用只是展示静态页面,但 ChatCrystal 需要一个后端服务器来处理数据库、向量搜索等逻辑。做法是把 Fastify 服务器直接嵌入主进程:
typescript
async function startServer(port: number) {
const serverEntry = pathToFileURL(
path.join(app.getAppPath(), "server", "dist", "server", "src", "index.js"),
).href;
const serverModule = await Function(
"specifier",
"return import(specifier)",
)(serverEntry);
return serverModule.createServer({ port, host: "127.0.0.1" });
}
这里有个技巧:Function("specifier", "return import(specifier)") 是一个绕过 Electron 主进程 CJS 限制的 workaround。Electron 的主进程默认是 CommonJS 模块,不能直接用 await import() 加载 ESM 模块。通过 Function 构造器可以绕过这个限制。
启动时先检测端口,如果 3721 被占用就随机分配一个:
typescript
function findFreePort(preferred: number): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.listen(preferred, "127.0.0.1", () => {
srv.close(() => resolve(preferred));
});
srv.on("error", () => {
const srv2 = net.createServer();
srv2.listen(0, "127.0.0.1", () => {
const port = (srv2.address() as net.AddressInfo).port;
srv2.close(() => resolve(port));
});
});
});
}
然后创建窗口,加载服务器的 URL:
typescript
mainWindow = createWindow();
const url = devUrl || `http://localhost:${serverPort}`;
await mainWindow.loadURL(url);
开发模式下 devUrl 指向 Vite 开发服务器(http://localhost:13721),生产模式下指向内嵌的 Fastify 服务器。
窗口状态持久化
用户把窗口拖到副屏、调了大小,下次打开应该恢复到原来的位置和尺寸。ChatCrystal 用一个 JSON 文件实现:
typescript
function loadWindowState(): WindowState {
try {
const data = readFileSync(getWindowStatePath(), "utf-8");
return JSON.parse(data);
} catch {
return { width: 1280, height: 800, isMaximized: false };
}
}
function saveWindowState(win: BrowserWindow): void {
const isMaximized = win.isMaximized();
const bounds = isMaximized
? (lastNormalBounds ?? win.getBounds())
: win.getBounds();
writeFileSync(getWindowStatePath(), JSON.stringify(state));
}
保存的路径是 app.getPath("userData")/window-state.json,这是 Electron 提供的用户数据目录,跨平台且不会和应用代码混在一起。
还有一个细节:如果用户拔掉了外接显示器,上次保存的窗口位置可能在屏幕外面。所以恢复时要检查:
typescript
if (state.x !== undefined && state.y !== undefined) {
const displays = screen.getAllDisplays();
const visible = displays.some((d) => {
const b = d.bounds;
return (
state.x! >= b.x - 50 &&
state.x! < b.x + b.width &&
state.y! >= b.y - 50 &&
state.y! < b.y + b.height
);
});
if (!visible) {
state.x = undefined; // 重置位置,让系统自动放置
state.y = undefined;
}
}
- 50 的容差是为了处理窗口边缘刚好贴着屏幕边界的情况。
单实例锁
桌面应用通常只允许运行一个实例。用户双击图标时,如果已经有实例在跑,应该把已有窗口激活,而不是再开一个。
typescript
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
}
拿到锁的实例继续运行。没拿到锁的直接退出。同时监听 second-instance 事件,当用户再次尝试启动时,把已有窗口显示出来:
typescript
app.on("second-instance", () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
应用生命周期
Electron 的生命周期事件串起了整个应用的运行逻辑。ChatCrystal 的启动流程在 app.whenReady() 里:
typescript
app.whenReady().then(async () => {
// 1. 确定数据目录
const dataDir = getDataDir();
mkdirSync(dataDir, { recursive: true });
// 3. 设置环境变量
process.env.ELECTRON = "true";
process.env.DATA_DIR = dataDir;
if (app.isPackaged) {
process.env.ELECTRON_PACKAGED = "true";
}
// 4. 设置 CSP(生产环境)
// 5. 检测端口
serverPort = await findFreePort(3721);
// 6. 启动 Fastify 服务器(开发模式跳过,服务器单独运行)
if (!process.env.VITE_DEV_URL) {
const server = await startServer(serverPort);
serverShutdown = server.shutdown;
}
// 7. 创建窗口、加载页面、创建托盘
mainWindow = createWindow();
await mainWindow.loadURL(url);
createTray(mainWindow, serverPort);
});
退出时,before-quit 事件触发优雅关闭:
typescript
app.on("before-quit", (e) => {
if (!isQuitting) {
e.preventDefault();
isQuitting = true;
const timeout = setTimeout(() => {
app.exit(1); // 10 秒超时强制退出
}, 10000);
gracefulShutdown()
.finally(() => {
clearTimeout(timeout);
app.quit();
});
}
});
gracefulShutdown 依次关闭 Fastify 服务器和系统托盘。10 秒超时是为了防止关闭流程卡死------如果数据库保存之类的事情出了问题,应用不会永远挂在那里。
窗口关闭时不是真正退出,而是隐藏到托盘:
typescript
win.on("close", (e) => {
saveWindowState(win);
if (!isQuitting) {
e.preventDefault();
win.hide();
}
});
用户通过托盘菜单的"Quit"才真正退出,这时 isQuitting 已经被设为 true,close 事件不会被拦截。
下一步
你现在了解了 Electron 的核心概念:主进程与渲染进程的分工、BrowserWindow 配置、安全模型、服务器嵌入、窗口状态持久化、单实例锁和生命周期管理。
如果你想深入:
- IPC 通信 :
ipcMain.handle/ipcRenderer.invoke用于渲染进程调用主进程的功能(比如打开文件对话框) - 自动更新 :
electron-updater可以实现应用内更新 - 打包发布 :
electron-builder可以打包成 Windows 安装包(NSIS)、macOS DMG、Linux AppImage - 性能优化:懒加载窗口、减少主进程阻塞操作
ChatCrystal 的完整 Electron 代码在 electron/ 目录下,可以直接作为参考项目。从一个能跑的最小结构开始,逐步加上你需要的功能,这是最快的学习路径。