Skip to content
概述
Electron 的主进程与渲染进程运行在彼此隔离的环境中,不能直接访问对方的变量或函数。进程间通信(IPC)是两者交换数据的唯一机制。ipcMain 与 ipcRenderer 模块分别工作在主进程和渲染进程中,通过开发者命名的“通道”传递消息。与操作系统的原生 IPC 手段不同,Electron 的 IPC 是一套面向 Web 开发者的异步消息抽象,底层依赖消息传递机制和结构化克隆算法完成序列化。
前几篇已说明,每个窗口对应一个 BrowserWindow 实例和一个渲染进程。这里不再重复进程模型的细节,只需要记住:主进程掌管原生能力,渲染进程展示 UI,IPC 是它们之间的桥梁。
两种通信模型:send/on 与 invoke/handle
从 API 形态上看,Electron 的 IPC 提供了两套编程模型。
第一套是 send/on:渲染进程通过 ipcRenderer.send(channel, ...args) 向主进程推送消息,主进程通过 ipcMain.on(channel, listener) 监听。主进程若要回复渲染进程,需要在回调中调用 event.reply(channel, ...args),渲染进程再注册一个对应的 ipcRenderer.on 来接收回复。这种模式本质上是“触发即忘”的单向推送,回复需要另起一个通道,逻辑上相当于两个独立的单向消息。
第二套是 invoke/handle:主进程通过 ipcMain.handle(channel, handler) 注册处理器,渲染进程通过 ipcRenderer.invoke(channel, ...args) 发起调用,invoke 返回 Promise。处理器内部可以返回任意可序列化的值或 Promise,结果通过 resolve 返回给渲染进程。这套模型的语义是完整的请求-响应,与 HTTP 的 request-response 循环类似。
在实际开发中,绝大多数需要调用主进程并等待结果的场景都应当使用 invoke/handle,因为它直接返回 Promise,可以自然融入 async/await 流程。send/on + event.reply 方式容易在代码中形成散落的监听逻辑,回调和通道名分离,随着功能增多维护成本会快速上升。
ipcRenderer.sendSync 是较早的同步调用方式,会阻塞渲染进程直到主进程返回,容易导致 UI 卡顿,官方建议避免使用该 API。
准备工作:在 preload 中暴露 IPC 接口
渲染进程默认无法直接访问 ipcRenderer——前提是开启了 contextIsolation 并关闭了 nodeIntegration(新建项目的默认配置)。按照安全模型,IPC 方法需要经由 preload 脚本通过 contextBridge.exposeInMainWorld 暴露给页面。
以下是一个最小化的 preload 模板:
js
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// 用于 send/on 模型的单向发送
sendMessage(channel, ...args) {
const validChannels = ['set-title', 'log-action'];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, ...args);
}
},
// 用于 invoke/handle 模型的请求-响应
invoke(channel, ...args) {
const validChannels = ['get-version', 'read-file'];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
},
// 用于接收主进程主动推送
onMessage(channel, callback) {
const validChannels = ['update-counter'];
if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => callback(...args));
}
},
// 移除监听
removeListener(channel, callback) {
ipcRenderer.removeListener(channel, callback);
}
});该模板的三个关键点:
- 通过
contextBridge.exposeInMainWorld把方法挂到window.electronAPI上,而非直接暴露window.ipcRenderer。 - 使用白名单机制限制通道名,避免渲染进程随意监听或发送任意 IPC 通道。
- 区分接口类型:
sendMessage只负责发送,不期待返回;invoke返回 Promise;onMessage用于注册监听主进程推送。
预加载脚本的安全设计将在下一篇详细展开,这里先固定模板,后续示例均以此为基础。
单向消息:ipcRenderer.send 与 ipcMain.on
渲染进程发送
渲染进程通过 preload 暴露的 sendMessage 发出单向消息。典型场景:页面按钮点击后,让主进程更改窗口标题。
js
// renderer.js
document.getElementById('btn').addEventListener('click', () => {
window.electronAPI.sendMessage('set-title', '新标题');
});主进程监听:
js
// main.js
const { ipcMain, BrowserWindow } = require('electron');
ipcMain.on('set-title', (event, title) => {
const webContents = event.sender;
const targetWin = BrowserWindow.fromWebContents(webContents);
targetWin.setTitle(title);
});event.sender 是发送消息的 webContents 实例,通过 BrowserWindow.fromWebContents 可以获取对应的窗口对象。
如果需要向特定窗口发送消息,而非对所有监听该通道的窗口广播,可以使用 ipcRenderer.sendTo(webContentsId, channel, ...args)。这在多窗口应用中较为常见。
sendTo的第一个参数是webContents.id,主进程可以通过win.webContents.id取得它,再经由其他途径传递给渲染进程(例如 URL 查询参数或主进程推送)。- 若调用
sendTo时目标webContents已被销毁,消息会静默丢失,不会抛出错误。
主进程监听与 event.reply
主进程可以通过 ipcMain.on(channel, listener) 添加监听器,并使用 ipcMain.removeListener(channel, listener) 或 ipcMain.removeAllListeners(channel) 清理。
若需要回复消息,回调内可通过 event.reply 发出新的 IPC 消息:
js
ipcMain.on('ping', (event) => {
event.reply('pong', 'some data');
});渲染进程则必须提前注册监听来接收回复:
js
// 通过 preload 暴露的 onMessage
window.electronAPI.onMessage('pong', (data) => {
console.log(data);
});这种模式有两个明显的缺陷:
- 通道分散 — 发送和回复使用不同的通道名,两者在代码中没有显式关联。
- 逻辑割裂 — 调用方发送
ping之后,接收回复的代码写在另一个回调中,容易形成多层监听。
因此,当需要“发一个请求,拿到一个结果”时,invoke/handle 是更合适的选择。
双向调用:ipcMain.handle 与 ipcRenderer.invoke
处理器注册与调用
主进程注册处理器:
js
ipcMain.handle('get-version', () => {
return process.version;
});处理器可以返回任意值,也可以返回 Promise。异步操作直接使用 async 即可:
js
ipcMain.handle('read-file', async (event, filePath) => {
const fs = require('fs/promises');
const content = await fs.readFile(filePath, 'utf-8');
return content;
});渲染进程通过 preload 暴露的 invoke 调用:
js
const version = await window.electronAPI.invoke('get-version');
console.log(version);invoke 返回 Promise,调用处必须使用 await 或 .then() 处理。若主进程处理器抛出异常,Promise 会被拒绝,详见错误处理部分。
Promise 风格与回调风格的区别
与 send/on + event.reply 相比,invoke/handle 的区别主要体现在以下几点:
- 返回值:invoke 直接拿到处理器 return 的数据,无需额外监听另一个通道。
- 错误传递:handle 内部的异常会自动变成 reject,渲染进程可以用 try/catch 捕获;send/on 模型则需要自行设计错误回传通道。
- 注册范围:同一通道同一时刻只能有一个 handler,重复调用
ipcMain.handle会覆盖之前的处理器。需要多路分发时,应在处理器内部依据参数路由。 - 生命周期:handle 注册后持续有效,不再需要时可调用
ipcMain.removeHandler(channel)移除。
在 invoke/handle 出现之前,开发者往往使用多个 send/on 和 event.reply 拼凑出请求-响应的效果,这类代码随功能膨胀会变得难以追踪。
主进程主动推送:webContents.send
IPC 并非只能由渲染进程发起,主进程也可以通过 webContents.send 向指定窗口推送消息。
消息的来源通常是菜单操作、定时器或系统事件。示例:
js
// 主进程
const { Menu, BrowserWindow } = require('electron');
const win = BrowserWindow.getFocusedWindow();
Menu.setApplicationMenu(Menu.buildFromTemplate([{
label: '操作',
submenu: [
{
label: '增加计数',
click: () => {
win.webContents.send('update-counter', 1);
}
}
]
}]));渲染进程通过 preload 暴露的 onMessage 接收:
js
window.electronAPI.onMessage('update-counter', (increment) => {
console.log('计数增加', increment);
});需要注意,当窗口尚未完成 ready-to-show 时发送消息,渲染进程的脚本可能还未执行,消息会丢失。一个常见的做法是渲染进程初始化后主动发出“就绪”信号,主进程收到后再开始推送。
错误处理
在 ipcMain.handle 中抛出的异常会被 Electron 捕获,并使对应的 invoke 返回的 Promise 进入 rejected 状态。
js
// 主进程
ipcMain.handle('risky-op', () => {
throw new Error('something went wrong');
});js
// 渲染进程
try {
await window.electronAPI.invoke('risky-op');
} catch (err) {
console.error(err.message); // 'something went wrong'
}如果处理器是异步的,内部的 reject 或 async 函数中的 throw 效果相同。错误对象在传递过程中会经过结构化克隆,不能传递完整的栈信息,通常只能拿到 message 和 name。如需传递更多上下文,可以在处理器内构造一个纯对象 { message, code, ... } 来 reject。
对于 send/on 模型,异常不会自动回传。主进程处理器内部的错误会被 Node.js 进程捕获,但渲染进程完全感知不到,需要开发者自行 catch 并通过 event.reply 显式发送错误信息。
通道命名与数据序列化
通道名称是任意字符串,在 ipcMain 和 ipcRenderer 中使用相同的字符串即可建立连接。通道没有命名空间隔离,全局可见。常见的做法是集中定义通道常量:
js
// shared/ipcChannels.js
module.exports = {
SET_TITLE: 'set-title',
GET_VERSION: 'get-version',
READ_FILE: 'read-file',
UPDATE_COUNTER: 'update-counter'
};主进程和 preload 脚本都引用该文件,避免字符串拼写错误。
通过 IPC 传递的数据会经过结构化克隆算法(Structured Clone Algorithm)进行序列化。这意味着:
- 可以传递
Object、Array、Date、Buffer、Map、Set、ArrayBuffer等。 - 不能传递函数、DOM 节点、
Symbol、不可序列化的原型链。 - 传递
Error对象时会丢失stack等不可复制属性。 - 大体积数据应避免频繁通过 IPC 发送,序列化和反序列化会占用主进程和渲染进程的 CPU 时间。
限制与注意点
- 不绕过 preload:应始终保持
contextIsolation: true和nodeIntegration: false,所有 IPC 接口通过 preload 暴露,拒绝直接在渲染进程引入electron模块。 - 避免 sendSync:同步调用会阻塞渲染进程,导致窗口卡死,官方明确建议不使用该 API。
- invoke 无内置超时:如果主进程处理器长时间未返回,Promise 会一直 pending。渲染进程中需要自行封装超时逻辑,例如借助
Promise.race。 - handle 覆盖:
ipcMain.handle是单处理器模型,同一通道重复注册会静默覆盖前一个处理器。需要多路分发时,应在处理器内部检查参数并路由。 - 监听器泄漏:
ipcRenderer.on添加监听后,若未手动移除,在窗口生命周期内会一直存在。重复加载页面可能导致旧监听残留。可在 preload 脚本中用window.addEventListener('beforeunload', ...)清理,或使用ipcRenderer.once。 - 序列化开销:虽然 IPC 经过系统底层优化,但仍存在信息拷贝成本。传递大型二进制数据时,可考虑使用共享内存或自定义协议,而非直接通过 IPC 管道。
- 缺少内建的请求/响应配对:在 send/on 模型中,如果发送了两个
ping,随后回来两个pong,无法直接从消息本身区分哪个pong对应哪个ping,除非在消息体中附加requestId。invoke/handle 则每次调用返回独立的 Promise,天然不会混淆。
