“rea”这个词,在外行眼里可能就是三个字母,但在我们搞前端的人看来,它就是 React + Electron App 的缩写。这几年,用网页技术做桌面应用的方案已经相当成熟,而 REA 可以说是其中组合起来最顺手的搭配之一。早年我还在用原生 JS 撸 DOM 的时候,压根不敢想前端能这么轻松地染指桌面端,直到后来项目里被逼着做了一个跨平台客户端,才正儿八经地把这套东西玩明白。
这篇文章不会跟你扯太多理论,就讲我在实际开发一个基于 REA 架构的桌面工具时,从技术选型、环境搭建、进程模型理解,到最终打包发布以及踩坑填坑的全过程。不管你是刚从前端转过头来想做桌面应用的新手,还是已经被 Electron 的体积和调试折磨到不行的老人,这篇东西应该都能给你一些参考。
1. 整体设计与思路拆解
1.1 为什么是 React 配 Electron,而不是别的
先说结论:量大、社区成熟、上手门槛对于前端来说最低。Electron 本质上是把 Chromium 和 Node.js 塞进了一个壳子里,所以你写网页的那一套 HTML、CSS、JS 的功夫全都能用上;React 负责构建层,它那套组件化的心智模型,特别适合做那种状态多、页面切换频繁的客户端界面。
有人可能会问,为什么不选 Tauri?这个问题我纠结过一阵。Tauri 确实体积小、性能好,但它要求你懂 Rust,而且它壳子里的 webview 在 Windows 上用的是 WebView2,兼容性和样式表现跟 Chrome 不太一样,调试起来会比较折腾。对于大多数没有系统学习过系统级语言、又想在短时间内交付项目的前端团队来说,Electron 的容错率和对前端生态的覆盖程度都要高得多。
在 REA 这个组合里,React 主要负责渲染进程,Electron 主要负责主进程和底层能力。两者的边界划分,我后面会详细讲,这是整个架构的命门。
1.2 版本选择背后的关键逻辑
搞 React 和 Electron 开发,版本问题永远是第一道坎。我见过太多人卡在安装依赖这一步,用npm install electron react react-dom一把梭,结果版本不兼容,跑起来各种报错。
从实践经验看,Electron 版本最好跟着官方推荐的稳定大版本走,不要盲目追新。是一个对 Vite 支持最好的版本,Vite 构建的 dev server 端口能跟 Electron 完美衔接。React 这边,18 或者 19 都行,但如果你要用到createRoot这套新 API,那就别用老掉牙的 16。版本锁定这事,直接决定你后面 debug 的时间是几小时还是几天。
1.3 架构层面的核心需求拆解
既然是桌面应用,那它必然要跟本地文件、系统剪切板、窗口行为打交道。这些能力 Electron 都提供了,但问题在于,我们该在哪个环节调用它们。
我在设计这个项目时,遵循的最核心原则就一条:主进程不碰 UI,渲染进程不碰 Node。当然,这里说的渲染进程不碰 Node 不是绝对禁止,而是说业务逻辑上不要为了图方便直接在渲染进程里进行原生文件系统的读写,否则日后的维护体验会让你有种在屎山上雕花的感觉。
整体流程通常是这样的:React 组件里触发一个用户操作,通过 preload 脚本暴露的 API,利用 IPC 发送消息给主进程,主进程在 Node 环境里完成文件操作、系统交互等脏活累活,再把结果回传给渲染进程,React 这边拿到数据后更新状态,触发界面刷新。这个单向数据流的思路,跟 React 的状态管理原则不谋而合。
2. 核心细节解析与实操要点
2.1 主进程、渲染进程、预加载脚本到底怎么分工
这三个概念,是 REA 开发里最基础也最容易绕晕的地方。
- 主进程:相当于一个 Node.js 环境,负责创建窗口、管理应用生命周期、调用系统 API。它拥有完整的 Node 能力,但拿不到网页的 DOM。
- 渲染进程:本质就是一个浏览器页面,跑着你的 React 代码。它能操作 DOM,但默认情况下来拿不到 Node 的能力。
- 预加载脚本(preload):这是连接两者的桥梁,运行在二者之间。通过 contextBridge 把主进程暴露的能力有选择地注入给渲染进程。
我见过很多新手直接在 renderer 里写require('fs'),React 应用直接跑飞。正确的姿势应该是在 preload 脚本里做好封装,像这样:
import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('publicAPI', { // 给渲染进程暴露的一个读取文件的方法 readFile: (path) => ipcRenderer.invoke('file:read', path), saveFile: (data) => ipcRenderer.invoke('file:save', data), minimizeWindow: () => ipcRenderer.send('window:minimize') })然后在 React 组件里,就可以安全地通过window.publicAPI.readFile(...)来触发读文件。
2.2 dev 环境下的热更新血泪史
这是 REA 开发中最扯淡、也最容易把新人劝退的问题:你在 React 里改了一行代码,页面不动。为什么会这样?因为 Electron 加载的是打包后的文件,或者加载的是 Vite dev server 的地址。如果你配置不对,就会出现页面上找不到开发服务器的情况。
后来我摸索出了一套稳妥的方案:开发时判断process.env.VITE_DEV_SERVER_URL是否存在,存在就加载这个地址,否则才加载打包后的 HTML 文件。
const win = new BrowserWindow({...}) if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL) } else { win.loadFile(path.join(__dirname, '../dist/index.html')) }配好 Vite 的环境变量,开发时设置VITE_DEV_SERVER_URL=http://localhost:5173。这样 React 直接走热更新,Electron 窗口只负责展示,刷新体验和写网页差不多。这一步搞定了,后面的开发效率才谈得上。
2.3 安全默认值问题
Electron 的默认安全策略在早年间确实宽松,但新版已经默认开启了contextIsolation。我强烈建议你在创建 BrowserWindow 的时候,把下面这些项显式写出来:
const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, preload: path.join(__dirname, 'preload.cjs') } })这里有个操作心得是:sandbox: true会让 preload 脚本里失去 Node 的全部能力(比如require('path')),所以如果你在 preload 里用了 Node 模块,且依赖的是 higher-level API,你可能得考虑用sandbox: false。不过出于安全,非必要不关沙箱。
3. 实操过程与核心环节实现
3.1 项目初始化:从零到弹出一个窗口
我习惯用 Vite 来初始化 React 项目,然后手动装 Electron。之所以不用现成的脚手架,是因为很多脚手架集成的依赖版本不太可控,出了问题查起来反而更麻烦。
按照我的实际还原步骤:
# 第一步:用 Vite 创建 React 项目 npm create vite@latest rea-demo -- --template react cd rea-demo # 第二步:安装 React 相关依赖 npm install react react-dom # 第三步:安装 Electron 和 Vite 的 Electron 插件 npm install -D electron vite-plugin-electron这里要注意,vite-plugin-electron是个好东西。它能把主进程和 preload 脚本也纳入 Vite 的构建列表里。如果你不用这个插件,就得手动盯着两个进程的文件变化,泪流满面。
然后在vite.config.js里加上插件配置:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import electron from 'vite-plugin-electron' export default defineConfig({ plugins: [ react(), electron([ { // 主进程入口 entry: 'electron/main.cjs', // 生产环境打包后输出位置 onstart: (args) => { args.reload() } }, { // 预加载脚本入口 entry: 'electron/preload.cjs' } ]) ] })跑npm run dev,Electron 窗口和 Vite dev server 就能同时启动。第一次跑通的时候,我还是蛮高兴的,因为这种丝滑的起步体验真的能给后面省不少心。
3.2 实现一个实用的文件读写工具
空窗口没意义,得赋予它业务价值。我那个模拟项目叫“某跨平台系统”,其中一个核心功能是把用户在界面上填写的配置信息保存到本地配置文件中,并能在下次启动时自动读取回显。
这个需求分解下来,就是两个核心 IPC 通道:一个用于读,一个用于写。主进程这边,监听file:save和file:read:
import { app, BrowserWindow, ipcMain, dialog } from 'electron' import { promises as fs } from 'fs' import path from 'path' // 写入配置 ipcMain.handle('file:save', async (event, content) => { const filePath = path.join(app.getPath('userData'), 'config.json') await fs.writeFile(filePath, JSON.stringify(content, null, 2), 'utf-8') return filePath }) // 读取配置 ipcMain.handle('file:read', async () => { const filePath = path.join(app.getPath('userData'), 'config.json') try { const raw = await fs.readFile(filePath, 'utf-8') return JSON.parse(raw) } catch (err) { return null // 文件不存在就返回 null } })app.getPath('userData')这个方法很有用,它会根据操作系统的约定自动返回当前用户的应用数据目录,避免了硬编码路径带来的兼容性问题。
3.3 渲染进程的 UI 联动
React 这边,核心就是调用window.publicAPI暴露的接口。
import { useEffect, useState } from 'react' export default function SettingsPanel() { const [config, setConfig] = useState({ theme: 'dark', autoSave: false }) // 组件挂载时拉取本地配置 useEffect(() => { window.publicAPI.readConfig().then((stored) => { if (stored) setConfig(stored) }) }, []) // 切换开关时保存配置 const handleToggle = async (key) => { const newConfig = { ...config, [key]: !config[key] } setConfig(newConfig) await window.publicAPI.saveConfig(newConfig) } return ( <div> <label> 深色模式 <input type="checkbox" checked={config.theme === 'dark'} onChange={() => handleToggle('theme')} /> </label> <label> 自动保存 <input type="checkbox" checked={config.autoSave} onChange={() => handleToggle('autoSave')} /> </label> </div> ) }这一节想强调一个细节:IPC 的 invoke 是 Promise 风格的,所以建议在 preload 里用invoke而不要用send+once搭配来搞事件监听。事件监听的方式很容易出现监听器泄漏,或者出现响应顺序错乱的问题。用invoke就能保持请求和响应一对一的关系,心智负担轻得多。
4. 常见问题与排查技巧实录
4.1 窗口白屏,控制台不报错
这个现象几乎每个 REA 新手都遇到过。最常见的原因是 dev server 还没起来,Electron 就抢先加载页面了。解决办法是,在开发脚本里给 electron 启动加一个延迟,或者等 Vite 输出 “ready” 之后再启动 Electron。
如果走生产打包后白屏,那就是loadFile的路径不对。Electron 生产环境的__dirname指向的是打包后代码所在的目录,你得确认/dist/index.html是否真的被打包进去了,以及build阶段的base是否设置成了相对路径'./'。
export default defineConfig({ base: './', // 关键:让打包后的资源路径变成相对路径,否则加载时找不到静态资源 // ... })4.2 preload 脚本的 ESM/CJS 报错
Node 的模块系统坑人不是一天两天了,Electron 里也未能幸免。如果你在 preload 里用了import,但 package.json 里"type"没有设为"module",很容易报Cannot use import statement outside a module。
我的处理习惯是,把 preload 脚本写成.cjs后缀,然后在里面用 CommonJS 的require语法。Electron 对 CJS 的支持要稳得多,少一事是一事。
// preload.cjs const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('publicAPI', { saveConfig: (data) => ipcRenderer.invoke('config:save', data), readConfig: () => ipcRenderer.invoke('config:read') })4.3 应用打包体积太大
Electron 打包体积大是个无解的历史性难题,毕竟里面塞了整个 Chromium。我试过从 80MB 优化到 60MB,主要做了这几件事:
- 使用 electron-builder,把压缩等级调高。
- 不要用
asar里去存一堆没用的 node_modules,主进程里用了哪些模块,就按需打包。 - 把图片、字体这类静态资源,能走 CDN 的就不要本地打包(除非本来就要求离线)。
有一点很现实:Electron 应用再怎么优化,体积也很难低于 50MB。所以这个指标不该盲目追,用户如果看重体积,就早做功耗和跨平台取舍的评估。
4.4 重复打开窗口的 D-Bus 报错
在 Linux 平台上,有时候会碰到failed to connect to dbus之类的报错。严格说这不是你代码的问题,而是缺失系统依赖。解决方法是安装对应的 dbus 相关运行库,或者在启动命令前设置环境变量。这块如果碰上了,建议随手记一下,因为不同发行版对 Electron 的依赖要求会有出入。
5. 打包与跨平台发布
5.1 使用 electron-builder 配置
打包这块,我一直用 electron-builder,理由是配置直观,而且对自动更新生态支持得比较好。我项目里的核心配置长这样:
appId: com.example.rea productName: ReaDemo directories: output: release files: - dist/** - electron/** asar: true win: target: - nsis mac: target: - dmg linux: target: - AppImage category: Utility运行npm run build之后,electron-builder 会先把 Vite 构建好的静态文件打进 asar 包,然后生成对应平台的安装包。这个过程其实相当于把前后端一起封箱,中间不需要你手动去拷贝文件。
5.2 代码签名与 Windows SmartScreen 的纠缠
如果你打算公开发布安装包,Windows 上会碰到 SmartScreen 的警告,这是微软对未签名应用的默认拦截机制。解决途径只有一条:申请代码签名证书,然后在 electron-builder 里配置。
但团队内部用的小工具,我个人建议不需要为它花高昂的证书费用,直接在文档里注明“选择 更多信息-仍要运行”即可。与其花大价钱过审,不如把钱留着升级 CI 打包机器。
5.3 跨平台兼容性踩坑
有个小细节值得提醒:文件路径分隔符的问题。Windows 用\,macOS/Linux 用/。如果你在渲染进程或者主进程里手动拼接了路径,一定记得用path.join或者path.resolve来处理,千万别图方便用字符串加法拼。在 Windows 上写死/有时候能跑,但一遇到带空格或者中文的目录名就崩。
另外,编写文件操作代码时,最好把文件名里的非法字符过滤掉。我在项目里就踩过用户把:和/输进文件名导致 Windows 报错的情况,加个简单的白名单校验就能避开。
6. 性能优化与后续扩展
6.1 窗口启动速度与页面渲染体验的平衡
Electron 应用常见的诟病是启动慢。一方面是因为 Chromium 加载本身有开销,另一方面是主进程如果同步做了太多初始化操作,会拖延窗口显示。
我的做法是,app.whenReady().then(...)之后,先创建窗口,再去做那些耗时的初始化工作(比如检查更新、读取大数据文件)。另外,窗口创建的时候可以设置show: false,等渲染进程的ready-to-show事件触发之后再显示,这样用户看到的不再是白屏闪烁,而是渲染完成的页面。
const win = new BrowserWindow({ show: false, // ... }) win.once('ready-to-show', () => { win.show() })一个看起来不起眼的小改动,实际体感差别非常大。强烈建议你们都在项目里加上这条。
6.2 用 SQLite 还是 JSON 文件做本地数据存储
针对 REA 项目,存储方案选择直接影响后续的查询能力和增量更新体验。纯 JSON 读写适合配置型数据,数据量在几百条以内没任何问题。但如果数据量多了,或者你要做筛选、排序、分页,那还是直接上 better-sqlite3 吧。
我推荐 better-sqlite3 的原因很简单:同步 API,贴近 Node 习惯,不需要你去绕 Promise 嵌套;性能也足够快。它需要在 electron-builder 里配置asarUnpack,否则原生模块没法在打包后的环境加载:
asarUnpack: - "**/node_modules/better-sqlite3/**"6.3 自动更新怎么做
Electron 自带的autoUpdater只支持 macOS 和 Windows,而且需要你有一个更新服务器去放着最新版本的更新资源。如果你没有部署后端,那最快的方式是用 GitHub 仓库的 Release 功能。electron-builder 有现成的electron-updater可以配合,只需要在代码里简单监听一下:
const { autoUpdater } = require('electron-updater') autoUpdater.on('update-downloaded', () => { autoUpdater.quitAndInstall() }) app.whenReady().then(() => { autoUpdater.checkForUpdates() })更新包的粒度控制要注意,建议让用户选择手动更新,而不是强制重启,否则正在编辑的数据容易丢。
7. 开发调试的独家心得
最后分享两个我吃了亏之后才长记性的经验。
第一个是关于 Electron 主进程的调试。最早我只会用console.log在主进程里打印,但生产的日志会跟渲染进程的混在一起,非常难看。后来我养成了一个习惯:在主进程的关键入口给process.env.DEBUG打标记,或者直接用 VSCode 的调试配置连接主进程,这样就能直接在编辑器里给主进程源码打断点。配置没有想象中复杂,launch.json里指定好runtimeExecutable为 Electron 的启动脚本就行。
第二个经验是关于错误捕获的。Electron 主进程一旦抛错,如果没有兜底,很容易整个应用悄无声息地挂掉。我在主进程顶部加了全局的错误处理:
process.on('uncaughtException', (err) => { console.error('Unhandled exception:', err) })同时,renderer页面的window.onerror也做一层捕获,把报错信息通过 IPC 送给主进程,写进日志文件。这样一来,即便用户那边发生问题,也不用让用户靠截图来反馈,直接去拉本地日志文件,问题的定位速度能上一个台阶。
做一个 REA 项目,说难不难,说简单也绝不简单。你要掌握的不只是 React 的组件思维,还要理解桌面平台的资源模型、窗口生命周期、原生交互方式。但换个角度看,这正是它的魅力所在:用一套熟悉的技术栈,覆盖两大主流桌面系统,整体的性价比在当前技术栈里确实很难被替代。如果你正在犹豫要不要入这个坑,希望这篇记录能帮你把第一道坎跨过去。