React 开发者转 Electron 最容易栽的坑,不在 API 不熟,在心智模型翻转。按冲击程度排序:Main/Renderer/Preload 三进程 / fetch 在 Main 发 Network 看不到 / native 模块 NODE_MODULE_VERSION / preload 是 API 网关。
我最近在带一个组员把他写了几年的 React 应用搬到 Electron 桌面端。他装好 electron-builder 第二天就来找我:"为什么我的 HTTP 请求在 DevTools Network 看不到?为什么 better-sqlite3 装完报 NODE_MODULE_VERSION 不匹配?为什么 window.electronAPI 是 undefined?"
这些都不是 bug------是Web 开发者的心智模型和 Electron 现实之间存在大量不匹配 。市面上 Electron 入门教程大多按"API → 配置 → 打包"功能罗列,没人按实际冲击程度排序:哪些是认知层的根本翻转、哪些是运行时必栽的坑、哪些只是工程化细节。
这篇就是那份排序。从最大的思维转变开始往下。
1. 进程模型 ------ 最大的思维转变
Web 里只有一个 JS 运行环境(浏览器页面)。Electron 把它劈成三种进程,每种能力 / 限制完全不同:
| 进程 | 是什么 | 能干什么 | 典型职责 |
|---|---|---|---|
| Main | Node.js 进程,1 个 | 文件系统、native 模块、开窗口、网络、OS API、应用生命周期 | 钱包密钥逻辑、硬件交互、SQLite 读写、外部 HTTP、代码签名 |
| Renderer | Chromium 页面,N 个 | 跟浏览器一样:DOM、fetch、Web APIs | 你的 React 应用(业务视图、外壳视图、设置面板) |
| Preload | Renderer 加载前注入的桥 | 受控地把 Main 能力暴露给 Renderer | 每个 Renderer 配一份,只暴露具名 API |
关键心智:
你写 React 组件 = Renderer。但"读硬件钱包 / 写数据库 / 发后端请求 / 调 OS API"这些只能在 Main。两者之间隔着进程边界,不能直接调函数------这就是为什么一个钱包级 Electron 项目里所有密钥、签名、HTTP 都集中在 Main 进程,React 代码根本碰不到这些。
这套设计带来一个 web 开发者第一次必栽的认知坑------Network 面板的盲区 。如果某个 HTTP 请求挪到了 Main(为了隐藏 token、做凭据签名、避免 CORS),Renderer 的 DevTools Network 面板根本看不到这个请求。
我那位组员花了一下午 debug "为什么登录的 access-token POST 在 Network 里找不到"------最后才意识到那个请求其实从 Main 进程发出去的,调试器找错地方了 。这是进程模型的直接后果,不是 bug。
要看 Main 的网络请求,得用 chrome://inspect attach 到 Main 进程的 Node Inspector。
2. IPC ------ 替代你熟悉的"直接调用 / fetch"
Web 里组件直接 fetch('/api') 或 import { foo } 调函数。Electron 里 Renderer 想用 Main 的能力,只能发消息:
css
Renderer: window.electronAPI.callMain('connectWallet', {})
↓ IPC(ipcRenderer.invoke)
Main: ipcMain.handle('business:callMain', ...) → 干活 → 返回
要内化的几条:
- 全异步 :
invoke返回 Promise,没有同步调用 - 可序列化数据约束 :JSON 没问题;TypedArray / ArrayBuffer / Date / Map / Set 在 Electron 9+ 都可以直接传(底层 structured clone);但函数、class 实例、DOM 节点、Symbol 传不了,要自己处理
- handler 名字是契约 :Renderer 发
'business:connectWallet',Main 必须ipcMain.handle同名字符串------改一边忘改另一边,运行时静默失败。建议用 TypeScript 共享类型把 channel 名 + 入参 + 返回值都约束起来
如果你写过 React Native 或 Flutter 的 MethodChannel,会觉得这模式很熟------本质是同一个东西:把活儿委托给宿主进程。Electron 的 IPC 更轻量,但思维模式一样。
实际项目里通常会再封装一层 dispatch,把"调用方法名 + 参数"分发到具体 handler,避免每个新能力都要在两边注册一对 ipcMain.handle / electronAPI 函数。但这是工程封装,不改变 IPC 的根本模式。
3. Preload + contextBridge ------ 安全边界
Renderer 默认拿不到 Node(nodeIntegration: false 自 Electron 5 起就是默认,contextIsolation: true 自 Electron 12 起就是默认)。要给页面暴露能力,必须经过 preload 的 contextBridge:
javascript
// preload.ts ------ 显式枚举每个方法
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
callAppPromise: (method, params) =>
ipcRenderer.invoke('business:callAppPromise', method, params),
// 每个能力是一个具名 entry,不暴露 ipcRenderer.invoke 泛用入口
});
为什么不直接 nodeIntegration: true 图省事?
因为只要 Renderer 加载的内容不是 100% 可信(远程 URL、第三方 iframe、被 XSS 注入),一旦给它 Node 权限,XSS 直接升级成 RCE ------攻击者能 require('fs') 删硬盘、require('child_process') 跑任意命令。
这是钱包 / 金融 / 企业级 Electron 应用的硬约束 :preload 只显式暴露具名方法 ,绝不开 ipcRenderer.invoke 泛用入口。React 开发者最容易忽略的就是这个边界------你在 web 端没有"信任边界"概念(同源就全信),Electron 里 preload 就是那道闸。
如果你的 Renderer 加载的是远程 URL(比如自家 SaaS 的 web 端),更要严格------服务端被 XSS 就能调用 preload 暴露的所有方法。把 preload 当成 API 网关来设计,不是当成"通用桥"。
4. WebContentsView ------ Electron 才有的多视图组合牌
Web 开发者第一反应嵌入第三方内容 = <iframe>。Electron 里多了一张 web 没有的牌------WebContentsView(Electron 30+ 取代了 BrowserView):
javascript
const { BaseWindow, WebContentsView } = require('electron');
const win = new BaseWindow({ width: 1200, height: 800 });
// 主业务视图(可以是远程页面)
const mainView = new WebContentsView({
webPreferences: { preload: path.join(__dirname, 'preload-business.js') }
});
win.contentView.addChildView(mainView);
mainView.setBounds({ x: 0, y: 0, width: 800, height: 800 });
mainView.webContents.loadURL('https://app.example.com');
// 本地外壳视图(用 app:// 协议加载,见第 8 节)
const shellView = new WebContentsView({
webPreferences: { preload: path.join(__dirname, 'preload-shell.js') }
});
win.contentView.addChildView(shellView);
shellView.setBounds({ x: 800, y: 0, width: 400, height: 800 });
shellView.webContents.loadURL('app://shell/index.html');
每个 WebContentsView 是独立 OS 进程 + 独立 session + 独立 preload + 独立 cert pinning 。位置、显隐用代码控制,不是 CSS ------通过 Main 端 setBounds() 操作。
最有价值的场景是安全分层:
- "半信任的远程业务页面" → 独立 WebContentsView + 受限 preload + 拦截 will-navigate
- "本地可信外壳"(管钱包、签名、敏感操作) → 另一个 WebContentsView + 走
app://协议加载本地资源 + 富 preload
iframe 做不到这种进程级 + 信任级的双重隔离------它只有 site-isolation,preload 还是共用一份。
普通"包一个 SPA 进桌面"用不到 WebContentsView,但钱包 / IDE / 多视图协作类应用,这是 Electron 才有的能力。详细 API 在 blog180。
5. 安全配置(钱包项目尤其要懂)
Web 浏览器默认护着你:同源、CSP、沙箱、window.open 隔离。Electron 里这些都要你自己开:
javascript
new BrowserWindow({
webPreferences: {
contextIsolation: true, // preload 和页面 JS 隔离 context(Electron 12+ 默认)
sandbox: true, // Renderer 进 OS 沙箱(强烈建议)
nodeIntegration: false, // 页面拿不到 require(Electron 5+ 默认)
webSecurity: true, // 同源策略不能关------开发偷懒关了上线就送钥匙
},
});
// 还要补的:
mainWindow.webContents.on('will-navigate', (event, url) => {
// 拦截非预期跳转------只允许跳到信任域名,其他强制 shell.openExternal 外部打开
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
return { action: 'deny' }; // 默认拒绝 window.open,需要的具体放行
});
// HTML 里强制 CSP
// <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'" />
漏一条就是漏洞。Electron 官方有完整的 Security checklist,Doyensec / Trail of Bits 等审计团队的多份 Electron 应用报告反复指出"配置不当"是主要风险来源。
涉及钱包、企业凭据、医疗数据的项目,这一节是签到必做项,不是 nice-to-have。
6. Node.js 能力 + native 模块 ------ 第一个运行时大坑
转过来最爽的一点:Main 进程能用整个 Node 生态。
javascript
const fs = require('fs'); // 直接用
const crypto = require('crypto'); // 直接用
const Database = require('better-sqlite3'); // native 模块
const HID = require('@ledgerhq/hw-transport-node-hid'); // native 模块(USB 硬件)
坑在 native 模块 :这些是 C++ 编译产物,预编译二进制针对Node 的 ABI (Application Binary Interface)。Electron 的 Node ABI 通常和你系统 Node 的 ABI 不一样 ------直接 npm install better-sqlite3 装完,运行时报:
arduino
Error: The module was compiled against a different Node.js version using
NODE_MODULE_VERSION 115. This version of Node.js requires NODE_MODULE_VERSION 127.
(具体数字看你的 Node / Electron 版本组合------Node 20 = ABI 115、Node 22 = ABI 127,看到这个报错就是这个原因)
解决 :装完所有 native 依赖后跑 electron-builder install-app-deps(或 electron-rebuild),针对 Electron 的 Node ABI 重新编译。
实操:在 package.json 的 postinstall 脚本里固化这一步------否则团队新人 clone 项目就跑不起来。这是 Web 开发者最容易在第二天栽的坑,前一天感觉一切正常,第二天加了个 SQLite 或硬件库就崩。
7. 自定义协议 app://
Web 里页面从 http(s):// 来。Electron 可以注册 app:// 协议,从二进制内部读本地资源:
javascript
const { protocol, net, app } = require('electron');
const path = require('path');
app.whenReady().then(() => {
protocol.handle('app', (req) => {
const url = new URL(req.url);
const filePath = path.join(__dirname, 'renderer', url.pathname);
return net.fetch('file://' + filePath);
});
});
// 注册之后,本地外壳页面可以从 app://shell/index.html 或 app://core/main.html 加载
为什么不直接 file:///?两个原因:
- 代码签名信任链 :
app://的资源和二进制一起被代码签名打包到.asar------本地代码"跟二进制一起签名",比远程 URL 可信度高一档 - 避开 file:// 副作用 :file:// 在某些 Electron 版本下会让
BrowserRouterhistory mode 出问题(路由跟文件系统路径冲突);自定义协议无此问题
不是每个项目都需要。但安全敏感项目(钱包、企业内部工具、医疗记录)------这种"本地资源也走代码签名信任链"的设计值得抄。配合第 4 节的 WebContentsView 分层方案,能构造出"远程业务页 + 本地可信外壳"的强隔离架构。
8. 构建 / 打包 ------ 跟 web 完全不同
| Web | Electron | |
|---|---|---|
| 构建 | Vite/webpack → 静态文件 → CDN | 三份并行:renderer + main + preload |
| 产物 | dist/ 上传 |
.dmg / .exe / .AppImage,代码签名 + 公证 |
| 更新 | 用户刷新即最新 | electron-updater 拉新包 + 校验签名 |
| 入口 | index.html |
package.json 的 main 字段指向 Main JS |
工具链推荐:
electron-vite:基于 Vite,自动配好 renderer + main + preload 三份管线,dev 时 renderer HMR + main 改了自动重启。社区主流选择之一electron-forge:官方维护,更稳但配置略多- 打包用
electron-builder:跨平台代码签名 + 自动更新一体化
发布心理预期要降:
- macOS:要 Apple Developer 账号 + notarization(Apple 公证)
- Windows:OV 证书能签但 SmartScreen 信誉要积累,EV 证书才能立刻通过 SmartScreen
- Linux:相对简单,AppImage / deb / rpm 不强制签名
第一次跑通整套发布流程预留 1-2 周不算夸张 ------和 web 端 git push 触发 CI 是两个量级。
9. 调试 ------ 分清楚在调哪个进程
Web 端 F12 一套搞定。Electron 里有两套调试器:
- Renderer 进程 :Chromium DevTools(
Cmd+Option+I/Ctrl+Shift+I),跟 web 一样 - Main 进程 :Node Inspector(启动加
--inspect=5858+ Chromechrome://inspect,或 VS Code launch attach) - Preload 脚本 :运行在 Renderer 上下文但有 Node 能力------断点最不稳,建议保持最小(只做 contextBridge 转发,不塞业务逻辑)
先搞清楚 bug 在哪个进程,再选工具------否则就是第 1 节那位组员的下场(在 Renderer DevTools 找 Main 进程发的请求)。
工程化做法:VS Code 的 launch.json 配两个 launch(一个 attach Main、一个 attach Renderer),开发时按需切换。这把"两套调试"做成肌肉记忆,比每次现配 inspector 高效得多。
上手路径建议
如果你已经有成熟 React 项目想转 Electron,最稳的顺序:
- 第一周建立进程模型直觉:理解 Main / Renderer / Preload 三种身份,明确"你的 React 代码 = Renderer"。所有概念都从这条出发
- 用
electron-vite模板起项目,把 React 整体搬过去(90% 直接能跑) - 列出"需要 OS 能力"的功能(读写文件、托盘、通知、自动启动、硬件设备)→ 每个写一个 IPC handler + preload 显式 expose
- 审 fetch 调用:哪些保留远程、哪些挪到 Main、哪些改 IPC
- 配安全三件套 :CSP、
will-navigate拦截、setWindowOpenHandler------一个都不能省 - 加 native 模块时立即配
electron-builder install-app-deps:否则团队同步代码神秘崩 - 发布前预留 1-2 周跑通签名 + 公证 + 自动更新
一句话总结
Electron 不是"React 的桌面运行时" ------它是"一个浏览器进程 + 一个 Node.js 进程 + 一座桥",你的工作是设计这座桥怎么走、走什么、不走什么。
React 代码 90% 能复用------但数据层、安全模型、发布流程,全部要按 Electron 的规则重新设计。这份按冲击程度排序的指南是为了帮你别在小坑上花一周才悟到原本一天能掌握的大原则。
延伸阅读:
- Electron 官方 IPC 教程 - ipcMain / ipcRenderer / contextBridge 接口
- Electron Security Checklist - 官方安全清单
- electron-vite - renderer + main + preload 一体化构建工具
- 拆开 Electron safeStorage 黑盒(本博客 blog169) - 凭据存储的边界
- Electron 钱包私钥存储进阶(本博客 blog176) - 高敏数据进阶方案
- Electron WebContentsView API(本博客 blog180) - 多视图组合现代 API
- Flutter Desktop vs Electron(本博客 blog172) - 反向视角:不选 Electron 的话
原文首发于 chenguangliang.com