【Electron】进程模型与 IPC:一次调用如何穿越三个世界
上一篇把主进程、渲染进程、Preload、Context Isolation、IPC 这几个概念串在一起介绍了一遍,中间用一段读文件的代码示范了一次 IPC 调用的完整流程。但那条流程是扁平展开的,几个关键问题没有拆开细讲:contextBridge 的"桥"到底是怎么搭的、IPC 有几种模式、为什么不能把 ipcRenderer 直接丢给网页用。这篇把它们逐个拆开。
三种执行上下文
Electron 里一段 JS 代码可能跑在三个地方。
主进程 跑在 Node.js 环境里。它能创建窗口(BrowserWindow)、管理菜单(Menu)、弹文件对话框(dialog),也能用 fs 读写文件、用 child_process 启动子进程。主进程始终是可信的:它执行的代码是你写的,没有未受控的外部来源。
渲染进程 跑在 Chromium 环境里,行为跟在浏览器里加载一个网页差不多。默认情况下渲染进程拿不到 Node.js 环境,也调不了 Electron 的桌面 API。它默认不可信:渲染进程加载的是网页,网页可能被 XSS 注入、可能引入第三方脚本。
Preload 处在它们之间。它在渲染进程加载网页之前被 Electron 注入执行,能接触到网页的 window,又能调部分 Node API。不过具体能调什么要看 webPreferences。默认 sandbox: true 的情况下,preload 只能用 contextBridge、ipcRenderer 和几个安全的 Node 内置模块(events、timers、url),想读写文件或启动子进程是做不到的。
但是 preload 和网页的 window 是隔开的。这就是 Electron 里的"主世界(Main World)"和"隔离世界(Isolated World)"的意思。contextIsolation(Electron 12 起默认开启)把两个 window 隔离在不同的 V8 上下文里,preload 往自己的 window 上挂变量,网页那边是看不到的。所以标题说的"三个世界"具体是指:主进程(Node.js 环境)、渲染进程里的隔离世界(preload)、渲染进程里的主世界(网页)。
contextBridge:桥是怎么搭的
为什么不能在 window 直接挂变量
如果 preload 里这么写:
// preload.js
window.myAPI = { openFile: () => ipcRenderer.invoke("dialog:openFile") };
网页那边 window.myAPI 拿到的值是 undefined。两个 window 隔着 V8 的隔离上下文,preload 往自己这边挂的任何东西,网页那边都看不见。
contextBridge 就是来解决这个问题的:
// preload.js
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
openFile: () => ipcRenderer.invoke("dialog:openFile"),
});
网页那边确实能调用 window.electronAPI.openFile 了,那么 contextBridge 是怎么跨过这个隔离边界的呢?
函数走代理,值走拷贝
contextBridge 对不同类型的东西做了不同的处理。
非函数的值(字符串、数字、布尔值、对象、数组)会被结构化克隆(Structured Clone)到另一侧,然后冻结。两边各持一份独立副本,网页侧拿到的副本是只读的,改不了;preload 侧改了原值,网页侧也不会感知到。结构化克隆是一种序列化算法,类似 JSON.stringify,但支持更多原生类型(Map、Set、Date 等)。
函数 的处理方式不一样。字符串和数字可以拷贝一份过去,但函数不一样:它包含着对闭包作用域的引用,没办法拆开复制。contextBridge 的做法是把函数调用代理到隔离世界里执行。网页侧调用时,实际执行仍然发生在 preload 的隔离世界里。
哪些类型能传、哪些不能,官方文档有一张完整的类型支持对照表。Symbol 在表里是 ❌,传不过去。带自定义原型的类会丢失原型链。实际开发中最常遇到的是 Error:它能跨桥传递,但抛到另一侧时只剩 message 和 name,stack 和自定义属性都会丢失。
别直接暴露 ipcRenderer
// 不安全
contextBridge.exposeInMainWorld("electronAPI", {
send: ipcRenderer.send,
});
这段代码在 Electron 29 之前会让 window.electronAPI.send 变成一个空对象。contextBridge 遇到 ipcRenderer 这种带 C++ 内部绑定的对象,无法正常桥接。Electron 29 之后官方禁止了这种用法,这样写会直接报错。
正确做法是一个 IPC 消息暴露一个对应的方法:
// 安全
contextBridge.exposeInMainWorld("electronAPI", {
setTitle: (title) => ipcRenderer.send("set-title", title),
openFile: () => ipcRenderer.invoke("dialog:openFile"),
});
回调也会泄漏
还有一个类似的隐患。ipcRenderer.on 的回调第一个参数是 IpcRendererEvent 对象,它有 sender 属性,通过 sender 能拿到底层的 ipcRenderer。所以就算只监听一个特定的 channel,直接把回调函数丢给网页也会出事:网页拿到 event.sender.send 之后,可以不经过 preload 的那层封装,直接往主进程发任何 channel 的消息。contextBridge 不会自动拦掉 event 上的 sender,这条路径得开发者自己包一层把 event 挡掉(官方安全文档专门列了这条提醒)。
// 不安全(event 对象带 sender,会暴露 ipcRenderer)
contextBridge.exposeInMainWorld("electronAPI", {
onUpdateCounter: (callback) =>
ipcRenderer.on("update-counter", callback),
});
// 安全(用匿名函数包一层,只传需要的参数给网页)
contextBridge.exposeInMainWorld("electronAPI", {
onUpdateCounter: (callback) =>
ipcRenderer.on("update-counter", (_event, value) => callback(value)),
});
IPC 的四种模式
IPCMessage 在 Electron 里有四种典型用法。
Pattern 1:渲染进程 → 主进程(单向通知)
渲染进程发一条消息过去,主进程收到就行,不需要返回值。发送端用 ipcRenderer.send,主进程用 ipcMain.on 接收。适合"窗口标题该改了""开发者工具打开"这类通知,告诉主进程做件事,不关心结果。
// preload.js
contextBridge.exposeInMainWorld("electronAPI", {
setTitle: (title) => ipcRenderer.send("set-title", title),
});
// main.js
ipcMain.on("set-title", (event, title) => {
const win = BrowserWindow.fromWebContents(event.sender);
win.setTitle(title);
});
Pattern 2:渲染进程 → 主进程(请求-响应)
发消息过去,等主进程返回结果。ipcRenderer.invoke 返回一个 Promise,主进程用 ipcMain.handle 注册 handler,handler 的返回值会作为 Promise 的 resolve 值回到渲染进程。这是现代 Electron 应用里最常用的模式。
// preload.js
contextBridge.exposeInMainWorld("electronAPI", {
openFile: () => ipcRenderer.invoke("dialog:openFile"),
});
// main.js
ipcMain.handle("dialog:openFile", async () => {
const { canceled, filePaths } = await dialog.showOpenDialog();
if (canceled) return null;
return filePaths[0];
});
invoke/handle 是 Electron 7(约 2019 年)引入的。在这之前做双向通信只能用 send + event.reply() 组合,需要手动注册第二个 listener 来接返回值,配对麻烦。还有一个叫 sendSync 的同步方案,虽然也能返回结果,但会卡住整个渲染进程直到收到响应,界面跟着冻结,官方建议只在没别的办法时用,优先用 invoke。
Pattern 3:主进程 → 渲染进程
主进程往渲染进程发消息,不能用 ipcMain,它只管监听。发消息要通过 WebContents 实例的 send 方法:
// main.js
mainWindow.webContents.send("update-counter", 1);
// preload.js
contextBridge.exposeInMainWorld("electronAPI", {
onUpdateCounter: (callback) =>
ipcRenderer.on("update-counter", (_event, value) => callback(value)),
});
渲染进程在监听里如果需要回复主进程,可以反向调用 ipcRenderer.send 或 invoke。
反过来的方向(主进程回复渲染进程,或主动给某个 frame 发消息)有个 frame 定位的问题。一个 WebContents 里可能加载了多个 frame(比如页面里嵌了 iframe),主进程要精确指定发到哪个 frame,有几种方式:
event.reply():回复到原始发送这条消息的 frame。iframe 里发的消息也能正确回复回去,是官方推荐的回复方式。event.sender.send():消息会发送到最外层的主 frame。如果原消息是从 iframe 发出的,用这种方式回复 iframe 会收不到。event.sender.sendToFrame(frameId, ...):发到任意指定 frame,通过event.senderFrame拿到目标 frame 的 ID 后精确指定。
Pattern 4:渲染进程 ↔ 渲染进程
两个渲染进程之间不能直接用 Electron 的 IPC 模块通信。一种做法是让主进程中转:渲染 A 发一条 IPC 到主进程,主进程再 webContents.send 给渲染 B。
另一种是用 MessagePort 建一条点对点通道:主进程创建一对 MessageChannelMain,通过 webContents.postMessage() 把两个端分别发给两个渲染进程,之后它们就能直接对话了。注意不能用 invoke/handle 来传 MessagePort,这两个方法不支持 transfer list,必须走 postMessage。这种方式延迟更低,但配置比较复杂,两边都得注册 MessagePort 的监听。
一次完整的数据流拆解
还是拿读文件调用的例子:
// preload.js
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
openFile: () => ipcRenderer.invoke("dialog:openFile"),
});
// main.js
const { ipcMain, dialog } = require("electron");
ipcMain.handle("dialog:openFile", async () => {
const { canceled, filePaths } = await dialog.showOpenDialog();
if (canceled) return null;
return filePaths[0];
});
// renderer.js
const content = await window.electronAPI.openFile();
这行 await window.electronAPI.openFile() 背后是一条跨进程的完整链路:
-
网页调用
window.electronAPI.openFile()。这个openFile是 contextBridge 在网页侧设置的代理,实现体还在 preload 里。 -
preload 里的
ipcRenderer.invoke("dialog:openFile")执行。invoke把 channel 名和参数序列化后,经由 Chromium 底层的 mojo 管道(Chromium 内部用的 IPC 框架,Electron 的 IPC 包装在它上面)发给主进程。 -
主进程这边
ipcMain.handle("dialog:openFile", ...)注册的 handler 收到了消息。handler 是异步的,内部调dialog.showOpenDialog(),弹出一个系统级的文件选择对话框。 -
用户选好文件后,
showOpenDialog返回文件路径。handler 把路径作为返回值返回。 -
返回值走同样的 mojo 管道序列化回渲染进程。因为 handler 是
handle注册的,invoke拿到的就是一个已经 resolve 的 Promise。 -
渲染进程
await拿到值,写进页面。
数据在两个方向上各序列化/反序列化一次。序列化走的是结构化克隆算法(Structured Clone Algorithm),和浏览器里 window.postMessage 用的是同一套。有一个特例是 MessagePort 对象:它不走结构化克隆,而是通过 postMessage 的第二个参数(transferable)转移所有权。
Object 序列化的边界
结构化克隆算法能处理大多数 JS 内置类型(Object、Array、Map、Set、Date、RegExp、Blob、ArrayBuffer),但有三类对象它处理不了。
DOM 对象:Element、DOMMatrix、File 这些是浏览器运行时的对象,主进程那边的 V8 上下文没有对应的解码逻辑,试图通过 invoke/handle 传过去会抛异常。
Node.js 的 C++ 绑定对象:process.env、Stream 的部分成员这些底层绑了 C++ 数据的对象,序列化机制不支持。
Electron 对象:BrowserWindow、WebContents、WebFrame 的引用也不能通过 IPC 传。不过这条限制说的是 invoke/handle 的参数路径。MessagePort 有专门的传输通道(webContents.postMessage() + MessageChannelMain),不走结构化克隆那套。
安全的 IPC 调用
前一篇介绍了核心原则:渲染进程不可信。每一条从渲染进程发来的消息都要当成不可信客户端发来的请求处理。
invoke/handle 的 handler 收到的第一个参数是 IpcMainInvokeEvent,上面有个 senderFrame 属性,记录了这条消息是从哪个 frame 发过来的。可以用它来验证来源:
ipcMain.handle("get-user-data", (event) => {
const senderOrigin = new URL(event.senderFrame.url).origin;
if (senderOrigin !== "https://myapp.com") return null;
return userDataService.get();
});
需要通过 senderOrigin 校验,将 协议、域名、端口 一并检查。这么做的原因是,渲染进程加载的页面可能嵌了 iframe(比如第三方广告 iframe),只校验域名的话 http://evil-myapp.com(非 HTTPS)就能绕过,iframe 里的代码如果也能调到你暴露的 API,就能往主进程发消息。校验 senderFrame 确保只有你信任的来源才能调 handler。
前文说强调了不能直接把 ipcRenderer 暴露给网页、不能把回调直接传过去,但就算暴露的是封装好的函数,只要还在网页能调到的范围内,iframe 或 XSS 注入的代码一样能调用它。后端校验和前端防御是两层独立的东西,不能因为前端包了一层就觉得安全了。
工程问题:channel 定义散落与类型安全
channel 名(比如 "dialog:openFile"、"set-title")散落在 preload、主进程 handler、渲染进程代码三个地方。项目大了以后,拼错名字、参数类型对不上、改了 handler 忘了改 preload 是常见的事情。
正确的做法是把 channel 集中到一个文件里进行定义:
// channels.ts
export const IPC_CHANNELS = {
OPEN_FILE: "dialog:openFile",
SET_TITLE: "set-title",
GET_USER_DATA: "get-user-data",
} as const;
再配一个接口,给每个 channel 声明参数和返回值类型:
// ipc-protocol.ts
interface IpcProtocol {
[IPC_CHANNELS.OPEN_FILE]: { args: []; result: string | null };
[IPC_CHANNELS.SET_TITLE]: { args: [title: string]; result: void };
}
然后写一个带泛型的 typedInvoke,调用时类型自动推导:
// typed-invoke.ts
import { ipcRenderer } from "electron";
import type { IpcProtocol } from "./ipc-protocol";
function typedInvoke<T extends keyof IpcProtocol>(
channel: T,
...args: IpcProtocol[T]["args"]
): Promise<IpcProtocol[T]["result"]> {
return ipcRenderer.invoke(channel, ...args) as Promise<IpcProtocol[T]["result"]>;
}
这样 typedInvoke("dialog:openFile") 的返回类型自动就是 Promise<string | null>,参数传错了编译期就能报出来。如果还想再进一步,可以用 ts-morph(TypeScript Compiler API 的包装库,提供代码分析和代码生成能力)从 channels.ts 自动生成 ipc-protocol.ts。
channel 命名上用命名空间前缀可以避免冲突:"dialog:"、"workspace:"、"file:"。这个 dialog: 前缀没什么特殊含义,就是一个提高可读性的习惯。
版本演进
回到 IPC 调用链路涉及的各版本。按时间顺序:
| 版本 | 变化 |
|---|---|
| Electron 7 | invoke / handle 引入(约 2019 年),双向 IPC 不用再手动配 send + reply 了 |
| Electron 12 | contextIsolation 默认开启(2021 年),preload 和网页的 window 隔开了 |
| Electron 14 | remote 模块从内置移出,变成独立包 @electron/remote。既然 preload 和网页已经隔开,remote 那种在渲染进程直接调主进程对象的方式不再必要 |
| Electron 20 | sandbox 默认开启,preload 连 require 都被限制到极小子集 |
| Electron 27 | ipcRenderer.sendTo() 弃用,渲染进程之间改用 MessageChannel 通信 |
| Electron 29 | ipcRenderer 不能再通过 contextBridge 传递(2024 年),直接传会报错 |
每个版本都在缩小渲染进程的权限范围。