前端知库

Electron 的 IPC 和 contextBridge 怎样设计?

说明渲染进程到主进程的单向与双向通信、主进程推送、渲染进程之间的 MessagePort 通信,以及通过 contextBridge 暴露窄接口、处理可序列化数据的设计方法。

参考答案

简答

  • 渲染进程需要结果时用 ipcRenderer.invoke 配合主进程的 ipcMain.handle,返回 Promise;只通知不等结果时用 ipcRenderer.send 配合 ipcMain.on。
  • 主进程向渲染进程推送要指定目标窗口,调用 webContents.send,渲染进程用 ipcRenderer.on 接收;这个方向没有对应的 invoke。
  • 渲染进程之间没有直接通道,可以由主进程转发,或由主进程分发一对 MessagePort 让它们直接通信。
  • preload 通过 contextBridge 只暴露具体的业务函数,不暴露 ipcRenderer 本身;监听回调要包一层,避免把 event 对象传给页面。
  • IPC 数据使用结构化克隆算法,DOM 对象、BrowserWindow 等由 C++ 支撑的对象不能传递;sendSync 会阻塞渲染进程,官方建议避免使用。

四种通信模式

1. 渲染进程 → 主进程,单向

JavaScript
// preload.js
contextBridge.exposeInMainWorld('api', {
  setTitle: (title) => ipcRenderer.send('window:set-title', title),
});

// main.js
ipcMain.on('window:set-title', (event, title) => {
  BrowserWindow.fromWebContents(event.sender)?.setTitle(String(title));
});

2. 渲染进程 → 主进程,双向

JavaScript
// preload.js
contextBridge.exposeInMainWorld('api', {
  openFile: () => ipcRenderer.invoke('dialog:open-file'),
});

// main.js
ipcMain.handle('dialog:open-file', async () => {
  const { canceled, filePaths } = await dialog.showOpenDialog({});
  return canceled ? null : filePaths[0];
});

invoke 从 Electron 7 开始提供,官方推荐尽量使用它。handler 中抛出的错误会被序列化,渲染进程只能拿到 message,需要错误码时应在返回值里显式约定。

旧写法 send + event.reply 需要另外注册监听器,也没有内置机制把响应和请求对应起来;sendSync 在主进程回复前会阻塞渲染进程。

3. 主进程 → 渲染进程

JavaScript
// main.js
win.webContents.send('menu:zoom', 1);

// preload.js
contextBridge.exposeInMainWorld('api', {
  onZoom: (callback) => {
    const listener = (_event, value) => callback(value);
    ipcRenderer.on('menu:zoom', listener);
    return () => ipcRenderer.removeListener('menu:zoom', listener);
  },
});

不要写成 ipcRenderer.on('menu:zoom', callback):回调的第一个参数是 event,可以通过 event.sender 拿到 ipcRenderer,等于把整个 IPC 能力交给了页面。返回取消函数可以让组件卸载时移除监听,避免重复注册和内存泄漏。

4. 渲染进程 ↔ 渲染进程

主进程用 MessageChannelMain 创建一对端口,通过 webContents.postMessage 分别发给两个窗口,之后两个窗口直接收发消息,不再经过主进程转发。utility 进程也可以用同样的方式和渲染进程直连。

接口设计建议

  • 按业务命名:通道名用 领域:动作 的形式(如 note:save),前缀只是为了可读,Electron 不会据此做任何处理。
  • 窄接口:每个能力一个函数,参数固定,不暴露 invoke(channel, ...args) 这种通用转发。
  • 集中声明:把通道名、参数和返回值类型放在一个共享的 TypeScript 文件里,主进程、preload 和页面共用,避免拼写不一致。
  • 只传可序列化数据:IPC 使用结构化克隆,Element、process.env、WebContents、BrowserWindow 等对象无法传递,函数也不能跨进程调用。传大文件时优先传路径或分块处理。
  • 主进程不信任输入:每个 handler 校验发送方和参数,具体做法见《Electron 应用的主进程、渲染进程和 IPC 有哪些安全边界?》。

常见追问

  • 为什么不直接用 @electron/remote?它让渲染进程同步调用主进程对象,既有性能问题,也扩大了渲染进程的权限,官方性能指南建议避免使用。
  • 多个窗口同时调用同一个 handler 怎么区分?handler 的 event.sender 和 event.senderFrame 标识了调用方,可以据此找到对应窗口或校验来源。进程职责划分见《Electron 的主进程、渲染进程、preload 和 utility 进程分别负责什么?》。