Electron IPC 是什么?
Electron 的本质是两个不同类型的进程,IPC 是它们之间唯一合法沟通的桥。
- 进程模型:一个 main 进程(Node.js 全权限)+ N 个 renderer 进程(Chromium 受限沙箱)
- IPC 核心:main 用
ipcMain监听,renderer 用ipcRenderer发起,contextBridge是安全转交 - 三种调用模式:
send/on(单向)、invoke/handle(异步返回值)、MessagePort(双向长连接) - 安全铁律:默认开
contextIsolation+nodeIntegration: false,主进程做权限守门人 - 反模式:在 renderer 里直接
require('fs')、把 token 放 IPC payload、不校验 sender
Electron 项目最容易出问题的地方不是 UI、不是打包,是 IPC。这一层如果搞错,要么是页面调不到 Node API,要么是把整个文件系统暴露给渲染进程,被 XSS 一键拖库。
这篇文章把 Electron IPC 一次讲透,从进程模型到安全配置。
一、Electron 为什么要拆成多个进程?
Electron 不是单个程序,至少由两类进程组成:
| 进程 | 数量 | 运行时 | 权限 | 职责 |
|---|---|---|---|---|
| main 进程 | 1 个 | Node.js | 全 | 创建窗口、菜单、系统托盘、文件 IO、子进程 |
| renderer 进程 | N 个(每窗口一个) | Chromium | 受限 | 渲染 HTML/JS/CSS、处理用户交互 |
renderer 进程默认不能直接访问 Node.js API。这是 Chromium 多进程架构的硬规则——每个 tab 跑在独立沙箱里,挂掉一个不影响其他。Electron 在此基础上让 renderer 也能加载本地资源(file://),但保留 Node 隔离。
那 renderer 想读文件、调用系统 API 怎么办?只能通过 IPC 找 main 进程代办。
二、IPC 是什么?
IPC(Inter-Process Communication) 就是 main 和 renderer 之间的消息通道。
类比一下:
- renderer 像浏览器里的前端页面
- main 像后端 API 服务
- IPC 就是它们之间的 HTTP
Electron 提供两种 API:
// main 进程(src/main.ts)
import { ipcMain } from 'electron';
ipcMain.handle('read-config', async () => {
return await fs.readFile('config.json', 'utf-8');
});
// renderer 进程(src/preload.ts 通过 contextBridge 暴露)
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
readConfig: () => ipcRenderer.invoke('read-config'),
});
// renderer 页面里直接用
const config = await window.api.readConfig();
三个角色各司其职:
- ipcMain:main 进程侧的消息接收器
- ipcRenderer:renderer 进程侧的消息发起器
- contextBridge:在 preload 脚本里把 ipcRenderer 方法白名单化给页面用
三、三种 IPC 调用模式
模式 1:send + on(单向,无返回值)
适合通知类消息("窗口已创建"、"开始下载"):
// renderer → main
ipcRenderer.send('log', '用户登录了');
// main
ipcMain.on('log', (event, msg) => console.log(msg));
send 是单向的,main 没办法直接回值给 renderer。
模式 2:invoke + handle(异 步双向,最常用)
renderer 发起请求,main 返回 Promise:
// main
ipcMain.handle('save-file', async (_e, path, content) => {
await fs.writeFile(path, content);
return { ok: true };
});
// renderer
const result = await window.api.saveFile('/tmp/a.txt', 'hello');
这是 90% 场景的首选。
模式 3:MessagePort(建立长连接)
需要高频双向通信(实时数据流、长任务进度推送)时用:
// main 创建 port
const { port1, port2 } = new MessageChannelMain();
mainWindow.webContents.postMessage('init-port', null, [port2]);
// port1 留在 main 监听
port1.on('message', (e) => console.log(e.data));
四、安全配置:IPC 的 90% bug 出在这里
contextBridge 那一层不是装饰,是 安全护栏。默认配置下,renderer 是不能直接 require('fs') 的,但如果 preload 脚本写错,等于亲手把大门打开。
危险配置 vs 安全配置
// ❌ 危险:renderer 直接拿 Node API
new BrowserWindow({
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
// ✅ 安全:强制隔离
new BrowserWindow({
webPreferences: {
contextIsolation: true, // 默认就是 true,显式写出来
nodeIntegration: false, // 默认就是 false,显式写出来
sandbox: true, // 启用 OS 沙箱
preload: path.join(__dirname, 'preload.js'),
},
});
preload 是唯一的桥
// preload.ts - 只暴露白名单 API
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
readFile: (path: string) => ipcRenderer.invoke('fs:read', path),
});
// ❌ 反模式:把整个 ipcRenderer 交出去
contextBridge.exposeInMainWorld('ipc', ipcRenderer);
// 任何 XSS 都能 invoke 任意 channel,权限失控
main 端必须校验 sender
main 不能假设 invoke 进来的请求都合法:
ipcMain.handle('fs:read', async (event, path: string) => {
// 校验路径在允许目录内
const safe = path.startsWith('/allowed/');
if (!safe) throw new Error('Forbidden');
// 校验来源窗口是否受信任
if (!isTrustedWindow(event.senderFrame)) throw new Error('Untrusted');
return await fs.readFile(path, 'utf-8');
});
五、一个完整的例子:文件读写
main.ts:
import { app, BrowserWindow, ipcMain } from 'electron';
import { promises as fs } from 'fs';
import path from 'path';
ipcMain.handle('fs:read', async (_e, p: string) => {
return await fs.readFile(p, 'utf-8');
});
ipcMain.handle('fs:write', async (_e, p: string, content: string) => {
await fs.writeFile(p, content);
return { ok: true };
});
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
});
win.loadFile('index.html');
});
preload.ts:
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('fs', {
read: (p: string) => ipcRenderer.invoke('fs:read', p),
write: (p: string, content: string) => ipcRenderer.invoke('fs:write', p, content),
});
renderer 页面:
async function onRead() {
const text = await window.fs.read('/tmp/config.json');
console.log(text);
}
到这里一个最小可用闭环就完成了。
六、调试 IPC 必知
IPC 是异步 + 跨进程,错误信息经常 只在一端抛出,调试体验很差。三个必备技巧:
- handler 里
console.log(event.senderFrame)——能拿到来源 URL,定位是不是恶意 invoke webContents.on('ipc-message')全局监听——记录所有 IPC 调用,便于排查调用链- handler 第一个参数是
IpcMainInvokeEvent,不是普通 event,包含了sender、senderFrame等信息
七、常见踩坑
| 现象 | 原因 | 解决 |
|---|---|---|
window.api is undefined | preload 没加载或路径错 | 检查 webPreferences.preload 和打包后的 __dirname |
| invoke 返回 undefined | handler 没 return | handler 必须有返回值 |
| 频繁掉用卡顿 | 每次 invoke 都序列化大对象 | 用 MessagePort 走长连接 |
| 生产环境 IPC 失效 | contextIsolation 默认行为变了 | 显式声明,别依赖默认 |
| preload 报错但 renderer 不显示 | preload 错误会静默吞掉 | main 端 preload.js 改成 .ts 编译前先在 dev 单独跑一遍 |
八、总结
Electron IPC 三条铁律:
- main 是权限守门人:所有 Node API 必须在 main 端调用,renderer 只能通过 IPC 请求
- preload 是白名单出口:
contextBridge.exposeInMainWorld暴露什 么,页面就只能用什么,绝不交出整个ipcRenderer - handler 必须校验 sender:路径白名单 + 来源窗口校验,缺一不可
理解了进程模型,IPC 就是顺水推舟;理解不了,所有 Electron 项目最终都会演变成安全审计事故。
References
- Electron 官方文档 - Inter-Process Communication —— Electron 官方, 2026