前阵子要快速交付一个小型桌面工具,我图省事直接拉了一套现成的“Electron + 前端框架”模板工程,以为半天就能跑完。结果从改菜单栏到接系统文件对话框,几乎每一步都在跟模板里预先埋好的旧配置搏斗,光排查一个启动白屏问题就耗掉一下午。那次之后我没再换下一个模板,而是自己从零搭了一套跨平台桌面应用骨架,代号就叫rea——React + Electron + App 三个词的首字母。它可以帮你快速创建桌面应用:窗口管理、进程通信、构建打包、安全配置都提前配好,同时把每一层边界讲明白,特别适合想真正搞懂桌面应用工程化的前端开发者,也适合要给业务做内部工具壳子、但不想背模板包袱的小团队。
rea 不追求“下载即可用”的无脑体验,它追求的是:所有关键配置都在你的眼皮底下,改任何一处都知道会影响什么。这篇文章就把我从零搭这套骨架时踩过的坑、做过的取舍、最后固定的方案,完整地写下来。内容会比较细,包括进程模型、Vite 多入口构建、IPC 通信设计、electron-builder 打包和 asar 路径问题,每一段都是实测过的。
1. 为什么我不直接套现成的桌面应用模板,而是自己搭 rea 这套骨架
1.1 模板先跑起来,但后续每个环节都在补课
现成模板确实能给你一个跑得通的最小 Demo,npm run dev一点,一个带默认页面的窗口就弹出来了。但问题往往出现在“从 Demo 到真实工具”的那一步。
我在某个旧模板里遇到的第一个雷,是它的构建配置停留在两三年之前,主进程和渲染进程用同一套打包参数处理。结果渲染进程的代码能正常跑,主进程里用的一些 Node 原生 API 却被编译成了错误的模块格式,运行时直接报“不是一个函数”。另一个常见情况是模板默认把安全选项设置得很宽松,nodeIntegration开着,contextIsolation关着。对 Demo 来说这种配置不会立刻出事,可一旦你把真实业务放进去,渲染进程里就跑着整个 Node.js 能力,任何 XSS 都可能变成磁盘文件被删的严重事故。
这类模板还普遍带有“版本漂移”问题。你为了新功能升级了某一个依赖,结果另一个依赖因为版本约束不兼容,整个启动过程直接崩掉。我一开始还想硬着头皮往下改,后来统计了一下:改造模板花的时间,比自己从零搭骨架还多。所以我没有继续做“补丁式开发”,而是干脆推翻,按自己的方式搭一套骨架,把版本和配置全部攥在手里。
1.2 rea 的定位与命名由来
rea 这个名字没什么高深含义,就是取了三个核心依赖的首字母:R(React)+ E(Electron)+ A(App)。它要解决的问题很具体:
- 快速开出一个跨平台窗口,菜单、图标、窗口尺寸都清晰可控;
- 渲染进程只写 React,不需要关心 Node 世界;
- 主进程和渲染进程之间有一套类型安全的通信方式;
- 开发模式热更新顺畅,生产模式打包体积可控。
说白了,rea 是一个“给桌面应用开发打底”的工程骨架,不是业务框架。它不帮你写具体功能,但帮你把最麻烦的底层问题先都解决掉。适合两类人:第一类是已经会前端但没接触过桌面端的朋友,想完整体验一遍“网页应用如何变成桌面应用”;第二类是团队里需要长期维护内部工具的人,与其每次用脚手架碰运气,不如一次沉淀好一套可复用的骨架。
1.3 核心设计取舍
我把“用现成模板”和“自建 rea”做过一个对比如下:
| 维度 | 直接用现成模板 | 自建 rea |
|---|---|---|
| 起步速度 | 快,几分钟能跑 | 稍慢,需要半天到一天 |
| 工程透明度 | 低,太多黑盒依赖 | 高,每个配置自己负责 |
| 依赖可控性 | 低,版本彼此绑死 | 高,按需引 |
| 安全基础 | 参差不齐,常默认宽松 | 默认最小权限 |
| 长期维护 | 累,升级一次受挫一次 | 可预期,按自身节奏升级 |
| 适用场景 | 一次性 Demo、原型验证 | 长期维护的工具型产品 |
这个取舍没有绝对的对错。如果你只是验证一个想法能不能跑通,模板最快;如果你的项目要维护半年以上,我建议从一开始就自建骨架。rea 的目标不是“省掉思路”,而是“每一层都按自己的意图重写一遍”,后面遇到问题你能更快定位,而不是在一个陌生配置里大海捞针。
2. rea 的进程模型与目录结构:先想清楚谁该干哪些活
2.1 主进程、预加载脚本、渲染进程的职责边界
在 Electron 里,应用会被拆成几个进程,概念上可以这样理解:
- 主进程是“管家”,负责创建窗口、控制生命周期、访问系统能力,例如弹出文件选择框、读取系统信息、读写配置文件;
- 渲染进程是“门面”,负责显示页面和处理用户交互,它运行的其实是一个浏览器环境;
- 预加载脚本是“保安亭”,运行在窗口加载页面之前,负责把主进程的能力安全地转交给渲染进程。
很多早期的教程喜欢让我们在渲染进程里直接require('fs')或打开nodeIntegration,这种做法极其危险。因为渲染进程的代码本质上运行在网页里,你的页面一旦被注入恶意脚本,别人就能直接读取你磁盘上的任何文件。rea 从第一版就固定了这样的安全基线:
webPreferences: { preload: path.join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, sandbox: true }contextIsolation: true意味着渲染进程的window对象里无法随意访问 Node 相关能力;sandbox: true进一步限制了 preload 脚本可使用的 API,必须经过指定的白名单方法才能与主进程通信。说句大实话:即使只是自用的内部工具,也建议把最小权限原则做进去,因为你不确定某天会不会在一个突如其来的安全公告里后悔当初为什么懒了那一下。
2.2 rea 的目录结构长什么样
rea 的目录结构核心原则是“按进程分目录”,而不是按业务功能分。因为这个项目本身是一套骨架,先守住进程边界,后面再加业务模块会顺手很多:
rea/ ├─ src/ │ ├─ main/ │ │ ├─ index.ts # 主进程入口,创建窗口 │ │ └─ windows/ │ │ └─ main-window.ts # 主窗口配置 │ ├─ preload/ │ │ ├─ index.ts # 预加载脚本入口 │ │ └─ rpc.ts # 暴露给渲染进程的 API 包装 │ └─ renderer/ │ ├─ index.html # 渲染进程页面模板 │ └─ src/ │ ├─ App.tsx │ └─ components/ ├─ electron-builder.yml # 打包配置 ├─ package.json └─ vite.config.ts这个结构最直观的好处是:开发时你知道主进程代码看main,页面代码看renderer,桥梁看preload,不会出现一个入口文件管所有事的情况。很多人把 Electron 项目写崩,不是因为代码太复杂,而是因为三块代码互相乱引用,最后根本分不清谁是谁。
2.3 主进程窗口创建的完整示例
创建窗口这部分看起来简单,但细节不少。rea 里主进程入口会读取一个环境变量,判断当前是开发模式还是生产模式,从而决定加载开发服务器地址还是本地 HTML 文件:
import { app, BrowserWindow } from 'electron'; import path from 'node:path'; function createMainWindow() { const win = new BrowserWindow({ width: 1200, height: 800, title: 'rea', webPreferences: { preload: path.join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, sandbox: true } }); const devUrl = process.env.VITE_DEV_SERVER_URL; if (devUrl) { win.loadURL(devUrl); win.webContents.openDevTools({ mode: 'detach' }); } else { win.loadFile(path.join(__dirname, '../renderer/index.html')); } } app.whenReady().then(() => { createMainWindow(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });这段代码是 rea 的“地基”。preload路径一定要写成绝对路径,很多白屏问题就是 preload 路径写错或者指向了不存在的位置。开发模式下加载loadURL,生产模式下加载loadFile,这个分支一旦写错,出来的就是一个空白窗口。
3. 用 Vite 同时驱动主进程、预加载脚本和渲染进程的构建
3.1 为什么我选择 Vite 而不是老牌打包工具
决定自己搭骨架时,我第一个选的构建工具不是各路模板默认的老牌打包器,而是 Vite。原因有三个方面。
第一是启动速度。Vite 基于原生 ES 模块开发服务,冷启动按毫秒算,比起某些模板动辄几十秒的编译等待舒服太多。桌面应用开发时改一行样式,你不会愿意等十秒再看效果。
第二是配置直观。Vite 对多入口的支持非常透明,主进程、preload、渲染进程可以定义成三个 input,再由一套配置统一输出。
第三是生态足够新。React 生态里的工具更新节奏快,Vite 作为活跃维护的项目,适配各种新特性比较及时。综合下来,它是目前搭 Electron 工程化最顺手的选择。
3.2 Vite 三入口配置的实际写法
rea 的vite.config.ts里最关键的是build.rollupOptions.input。我把它配置成三个入口,对应三个进程。
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'node:path'; export default defineConfig(({ command }) => { const isDev = command === 'serve'; return { plugins: [react()], base: './', build: { sourcemap: isDev, minify: !isDev, rollupOptions: { input: { main: path.resolve(__dirname, 'src/main/index.ts'), preload: path.resolve(__dirname, 'src/preload/index.ts'), renderer: path.resolve(__dirname, 'src/renderer/index.html') }, output: { entryFileNames: (chunk) => { if (chunk.name === 'main') return 'main/index.js'; if (chunk.name === 'preload') return 'preload/index.js'; return 'renderer/assets/[name]-[hash].js'; }, chunkFileNames: 'renderer/assets/[name]-[hash].js', assetFileNames: 'renderer/assets/[name]-[hash][extname]' } } }, resolve: { alias: { '@renderer': path.resolve(__dirname, 'src/renderer/src'), '@shared': path.resolve(__dirname, 'src/shared') } } }; });这里有几个必须注意的坑:
base: './'是给生产环境用的。如果你写成默认的/,打包出来的 HTML 加载资源时会从磁盘根目录去找,白屏没跑。- 主进程和 preload 脚本要输出成CommonJS,因为 Electron 会直接按 Node 模块加载它们,如果它们是 ES Module 格式,配合主进程的 CJS 解析逻辑容易出问题。Vite 默认输出 ESM,所以要在配置里对这两个入口做特殊处理,常见做法是单独给它们写一份
vite.main.config.ts,指定build.lib.formats = ['cjs']。为了不过度复杂,rea 里是在同一个配置文件里用环境变量切换模式区分开发和生产。 - 渲染进程的资源文件单独放在自己的目录下,不要和主进程输出混在一起,不然 electron-builder 的
files白名单会很难写。
3.3 开发阶段的进程启动顺序
开发模式下有两个进程要跑:Vite 开发服务器和 Electron 应用本身。启动顺序很关键,必须是先起 Vite,再起 Electron,否则 Electron 启动那一刻根本拿不到渲染页面地址。
rea 的 package.json 里脚本是这样写的:
{ "scripts": { "dev": "npm run dev:main && npm run dev:renderer", "dev:main": "vite build --mode development --watch", "dev:renderer": "concurrently -k \"vite --port 5173\" \"wait-on tcp:127.0.0.1:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"" } }大概流程是:
- 先启动 Vite 的渲染开发服务,监听 5173 端口;
wait-on等待端口通了再启动 Electron;- 通过
VITE_DEV_SERVER_URL环境变量告诉主进程:现在是开发模式,去加载那个开发服务器地址; - 主进程和 preload 的改动通过
vite build --watch自动重新编译,改动后手动重启 Electron 就能看到效果。
这套方案已经足够在小团队内部用。如果之后想要“改主进程代码自动重启整个应用”的体验,可以在此基础上引入一个简单的监视器,或者直接用 electron-vite 这类封装工具完成同样的效果。工具本身不重要,重要的是理解“主进程、preload、渲染进程三者各自构建、各自运行”这条主线。
4. IPC 通信设计:把事件满天飞改成类型安全的 RPC 层
4.1 为什么不让我在渲染进程里直接用 Node API
我最早做 Electron 的时候,图方便会在渲染进程里直接window.require('fs')去读文件。代码写起来是很快,可维护性几乎为零。
只要渲染进程拥有 Node 能力,你的应用安全边界就形同虚设。页面里任何一个未经过滤的输入都可能变成代码执行入口。就算不考虑安全问题,从工程组织角度来说,渲染进程直接操作磁盘也让代码很难测试。rea 做了这样的约定:渲染进程不直接碰任何系统能力,它只发送“请求”,等待主进程返回“结果”。
所以 rea 里的 IPC 不是一堆ipcMain.on('read-file')、ipcMain.on('write-file')的散装事件,而是一张统一的协议表。这样做的好处是:以后打开项目,一看协议表,就能知道这个应用提供了哪些能力,每个能力接收什么参数、返回什么结构。
4.2 协议表加共享类型,把错误消灭在编译阶段
先把协议定义在共享目录,比如src/shared/protocol.ts:
export type ReaRequest = | { type: 'fs:readFile'; id: number; payload: { path: string } } | { type: 'dialog:pickFile'; id: number; payload: Record<string, never> } | { type: 'system:getVersion'; id: number; payload: Record<string, never> }; export type ReaResult<T> = | { ok: true; data: T } | { ok: false; error: { code: string; message: string } };在主进程里注册一个统一的处理器:
import { ipcMain } from 'electron'; import type { ReaRequest, ReaResult } from '../shared/protocol'; import fs from 'node:fs/promises'; ipcMain.handle('rea:rpc', async (_event, req: ReaRequest) => { const { id, type } = req; switch (type) { case 'fs:readFile': { try { const content = await fs.readFile(req.payload.path, 'utf-8'); return { ok: true, data: content } satisfies ReaResult<string>; } catch (err) { return { ok: false, error: { code: 'FS_READ_FAILED', message: String(err) } }; } } case 'dialog:pickFile': case 'system:getVersion': // ... 类似实现 return { ok: false, error: { code: 'NOT_IMPLEMENTED', message: '' } }; } });在 preload 里只暴露一层薄薄的门面:
import { contextBridge, ipcRenderer } from 'electron'; contextBridge.exposeInMainWorld('rea', { invoke: <T>(type: string, payload: unknown) => ipcRenderer.invoke('rea:rpc', { type, payload }) as Promise<ReaResult<T>> });渲染进程里的 React 代码调用起来,长这样:
const res = await window.rea.invoke<string>('fs:readFile', { path: filePath }); if (res.ok) { console.log(res.data); } else { alert(`读取失败:${res.error.message}`); }这套写法的核心价值是:渲染进程永远不知道“主进程怎么实现功能”,主进程也永远不需要关心“页面是谁在调用”。两边只认协议。如果之后要加新能力,先改协议表,再改处理函数,最后改 preload 暴露方法,整个链路清晰可见。
4.3 超时、错误处理和参数校验的细节
很多 IPC 教程只教“发消息、收消息”,但真实项目里最难受的是三个问题:超时、异常、脏参数。
先说超时。渲染进程invoke一个请求,如果主进程里有死循环或者阻塞操作,这个 Promise 可能永远不返回。rea 的做法是在 preload 层给每个请求包一层节流逻辑:超过 10 秒直接 reject,并返回一个REQUEST_TIMEOUT错误。这个兜底对真实使用非常重要,用户不会想看到界面“卡住不动”的诡异状态。
再说错误处理。我的习惯是所有 IPC 返回统一走ReaResult<T>结构,成功返回{ ok: true, data },失败返回{ ok: false, error }。尽量不抛裸异常。因为ipcRenderer.invoke的错误对象在跨进程传输时会被序列化得面目全非,很多时候只能得到一串失去上下文的字符串;自己定义错误结构,至少能让错误信息在整个链路里保持一致。
最后是参数校验。渲染进程传过来的path等参数绝不直接信任。主进程应校验路径是否在允许读取的目录范围内、参数是否真的存在,未命中时返回明确错误。这听起来繁琐,却是桌面应用不容易出安全问题的关键。
5. 打包与体积优化:从 280MB 降到 110MB 的实测记录
5.1 electron-builder 的基础配置
打包是 Electron 开发里绕不开的一环。rea 用的是 electron-builder,配置写在electron-builder.yml里:
appId: com.example.rea productName: rea directories: output: dist files: - out/** - package.json asar: true win: target: nsis mac: target: dmg artifactName: rea-${version}-${platform}.${ext}最关键的是files白名单。很多新手打包体积失控,就是因为在files里塞了整个项目目录,把源码、测试文件、甚至.idea设置全部打进了安装包。rea 只允许out目录(构建产物)和package.json进入安装包,其他一律排除。这个步骤能挡掉大概 40% 的无效体积。
5.2 体积优化的三招实测
第一招,确认node_modules不会整体被打进包。Electron 会保留生产依赖,但如果你的dependencies里混入了只在开发阶段使用的工具,体积会迅速膨胀。rea 的应对措施很简单:所有与构建相关的依赖一律放devDependencies;运行时需要的依赖也尽量精简化,能用主进程原生 API 完成的事情就不引第三方库。
第二招,开启 asar 压缩。asar: true会把应用代码打包成一个归档文件,不仅减少了目录数量,也降低了一些体积。它能压缩的是代码文件,所以如果你的依赖里有大量纯二进制资源,这一招帮助有限。
第三招,裁剪图标和多余平台资源。最初我把各种尺寸的图标全部塞进buildResources,结果一下子多了几十兆。调整后只保留当前平台需要的几套尺寸,体积立刻降下来。另外,有些依赖会把.map源码映射文件一起带进包里,记得在构建配置里关掉 sourcemap,或者把这类文件排除。
实测中,一套只带基础功能的 Electron 应用,优化前约 280MB,优化后约 110MB,再往下压就需要借助其他技巧了。对于绝大多数内部工具来说,110MB 已经是一个可以接受的状态。
5.3 asar 包内的路径陷阱
用asar打包后,最常遇到的问题是文件路径。假设你的主进程代码里写了:
fs.readFileSync(__dirname + '/config.json')这段代码在开发模式没有任何问题,但打包后__dirname会变成类似app.asar/...的路径,fs默认无法直接读取 asar 内部的这种路径访问方式,配置文件也可能根本不存在。笼统地说,主进程不应该在 asar 里写文件。正确的落法是:
- 需要读写的用户文件,放到
app.getPath('userData')目录; - 需要随应用分发的只读资源,用
process.resourcesPath拼接; - 个别原生模块必须在包外运行时,在
electron-builder.yml里配置asarUnpack,把这些二进制文件从归档里释放出去。
刚开始踩这个坑的时候,我排查了很久才发现是路径问题,中间还一度怀疑是代码逻辑写错了。后来我养成了习惯:凡是要访问外部文件的路径,全部在启动时用日志打印一遍再往下走。路径透明了,问题就清楚了一大半。
6. 我在 rea 上踩过的坑和最终固定下来的几个经验
6.1 白屏问题排查清单
白屏是 Electron 应用里最常见的“灵异事件”。很多情况下窗口能弹出,但内容是纯白的。rea 开发过程中我遇到过各种因素导致的白屏,整理成一套排查清单:
- 生产加载路径:确认
loadFile指向的 HTML 文件路径存在。开发模式下用 loadURL,如果地址端口不对,也会白屏。 - 渲染进程资源路径:检查 Vite 的
base是否设为'./'。资源路径写成绝对路径时,打包后就会找不到资源。 - preload 脚本异常:preload 初始阶段如果抛错,页面可能正常显示,但之后调用的 API 全是 undefined。打开 DevTools 看控制台有没有红色报错。
- 安全配置冲突:
sandbox: true时 preload 里不能使用一些 Node 专属模块,如果用了,脚本会静默失败。必要时把 preload 里用到的能力换成纯 JS 实现,或者调整 sandbox 限制。 - 主进程没有准备窗口就加载:窗口的
ready-to-show事件没监听,窗口会在内容渲染完之前显示,表现出来就是闪烁白屏。rea 里监听该事件后再显式win.show(),能解决绝大部分闪烁。
排查时先看 DevTools,再看主进程日志,最后看路径,效率最高。
6.2 主进程改动如何自动重启
开发时最影响效率的是:每次改主进程代码都要手动关掉应用重新npm run dev。rea 的做法是给主进程也开启 watch 构建,然后用一个简单的重启逻辑。
我用的方式是concurrently组合任务:渲染进程的开发服务器保持常驻,主进程与 preload 的编译使用 watch 模式。在主进程代码变化后,用一个 watcher 触发app.relaunch(),从而让应用自动重启到最新代码。这段逻辑可以融入主进程自身:
import { app } from 'electron'; if (process.env.REA_DEV_AUTO_RELAUNCH === '1') { const fs = require('node:fs'); const path = require('node:path'); const mainBuildPath = path.join(__dirname, 'index.js'); fs.watch(path.dirname(mainBuildPath), { recursive: false }, () => { app.relaunch(); app.exit(0); }); }注意一定要通过环境变量控制,只在开发时开启,否则生产环境会不断重启。这个改动虽然不大,但对开发体验提升非常明显。
6.3 给后来者的几条建议
把这些经验沉淀下来后,我给后来者总结了几条非常实际的建议。
第一,先花时间理解进程边界,再动手写业务。很多新人一上来就抄代码,结果连“这个函数到底运行在哪个进程”都没想清楚。rea 的核心不是代码量,而是边界感——知道了谁该干什么,代码自然写得清晰。
第二,IPC 协议表一定要维护成文档。不要只写在代码注释里,最好在 README 里留一张表:需要什么功能、传什么参数、返回什么结构。等团队人多起来时,这张表能帮你省掉无数次沟通。
第三,别因为“内部工具”就放松安全基线。你永远不知道内部工具会不会被导入到客户环境,也不知道哪些同事会不经审查地往页面里粘贴内容。安全配置一上来就按严格模式定好,比后期补孔洞省心太多。
第四,每次升级依赖,跑四类验证:开发模式启动、生产包启动、安装包运行、断网环境运行。Electron 相关依赖在版本升级时经常出现“开发没事、打包后有事”的现象,固定一套回归清单能让你安心升级。
6.4 我记得最深的那个问题:生产包启动后菜单栏多出一个空窗口
这个问题本来不该出现在 rea 的基线上,但有一次我把主进程从 CommonJS 迁移到 ESM 时,出现过生产包启动后除了主窗口,还会多弹出一个透明空窗口的情况。排查后发现,是主进程里某个间接引入的模块在加载时触发了重复的new BrowserWindow()调用,而入口文件的执行顺序又因为迁移后的模块加载顺序发生了改变。
这件事给我最深的教训是:主进程的副作用代码要尽量集中在入口处,不要在每个模块的顶层初始化窗口或监事件。rea 现在的写法是所有窗口生命周期操作都收敛到src/main/windows下面,任何其他地方需要窗口,只能引用这里的函数,不直接new BrowserWindow()。模块加载顺序的变化会影响副作用触发的时机和次数,算是这类框架代码里一个非常隐蔽的坑。
踩过这一轮坑之后,我的体会是:rea 真正值钱的不是“能弹出窗口”的模板代码,而是里面那套“谁负责什么、谁允许碰什么”的边界约定。相比一开始就到处找现成脚手架,自建骨架不一定会更快,但后面的每一步都会更稳。如果你正准备把某个网页功能包成桌面工具,或者想真正端到端弄懂一个桌面应用,不妨从这样的骨架开始,把每一行配置当成自己需要负责的代码来读。后续我还在往里推多窗口管理和自动更新的能力,等稳定了再单独写一篇。