本节摘要:IPC 是 Electron 主进程与渲染进程之间的官方通信通道,核心 API 是 ipcMain 与 ipcRenderer,现代推荐用 invoke/handle 组成请求响应语义,用 send/on 组成消息推送语义。本节讲两种形态的用法、消息序列化的成本、通道命名与错误处理的设计模式,并展开一个"图片导入进度推送"的完整案例。
进程之间的对话只有两种形态:问一句等回答(请求响应),和不等你问我就说(消息推送)。Electron 的 API 恰好各配了一对。
请求响应用 invoke 与 handle。页面发起调用,主进程注册处理器,一次调用返回一个 Promise,错误会自动跨进程传播——这是现代代码的默认选择:
// 预加载脚本:把 IPC 包装成受控接口暴露给页面 const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('bridge', { // invoke:请求响应语义,返回 Promise readConfig: (key) => ipcRenderer.invoke('config:get', key), saveConfig: (key, value) => ipcRenderer.invoke('config:set', key, value) });
// 主进程:handle 注册处理器,抛出的异常会传回渲染端的 reject const { app, ipcMain } = require('electron'); const fs = require('node:fs/promises'); const path = require('node:path'); const configPath = () => path.join(app.getPath('userData'), 'config.json'); ipcMain.handle('config:get', async (_event, key) => { const data = JSON.parse(await fs.readFile(configPath(), 'utf8') .catch(() => '{}')); if (!(key in data)) throw new Error(`配置项不存在: ${key}`); return data[key]; }); ipcMain.handle('config:set', async (_event, key, value) => { const data = JSON.parse(await fs.readFile(configPath(), 'utf8') .catch(() => '{}')); data[key] = value; await fs.writeFile(configPath(), JSON.stringify(data, null, 2)); return true; });
消息推送给旧式 API 留了位置:webContents.send 由主进程主动广播,ipcRenderer.on 在页面里订阅。它适合"主进程那边发生了什么,界面需要知道"的场景——下载进度、设备插拔、更新状态。
理解 IPC 的开销模型,比背 API 更重要。调用 invoke 时,参数在渲染进程被结构化克隆(序列化),经进程间管道送到主进程,反序列化后交给处理器;返回值原路再走一遍。这带来三条实践推论:
其一,函数、类实例、DOM 节点不能过桥,能过的只有可被克隆的普通数据。其二,大对象过桥有真实成本,一次传几十兆的 Buffer,序列化与拷贝的时间在低端机上以百毫秒计。其三,每次调用都有固定往返开销,把循环里上千次 invoke 合并成"一次传数组",是 IPC 优化里性价比最高的一招:
// 反模式:循环逐条请求,1000 次往返开销 for (const id of ids) { await window.bridge.readConfig(id); // 慢 } // 正模式:一次请求批处理 const values = await window.bridge.readConfigBatch(ids);

背景:一个图片管理应用要支持"选文件夹批量导入"。导入由主进程执行(要递归读盘、生成缩略图),但进度条在页面上。这是"请求响应 + 消息推送"混合使用的教科书场景。
操作:页面先 invoke 一个开始导入的命令拿到总任务句柄;主进程干活的间隙用 webContents.send 推进度;页面订阅进度事件更新界面;完成后主进程再推一个完成事件,携带统计结果。
// 主进程:导入 + 双向通信 const { ipcMain, BrowserWindow } = require('electron'); ipcMain.handle('import:start', async (event, dirPath) => { const files = await collectImageFiles(dirPath); // 假设已实现递归收集 const sender = event.sender; // 发消息回渲染进程的句柄 let done = 0; for (const file of files) { await makeThumbnail(file); // 缩略图生成,耗时步骤 done += 1; // 推送进度:节流,每完成 5% 才发一次,避免消息洪峰 if (done % Math.max(1, Math.floor(files.length / 20)) === 0) { sender.send('import:progress', { done, total: files.length }); } } sender.send('import:done', { total: files.length, failed: 0 }); return files.length; });
// 页面侧:命令走 invoke,事件走 on const start = document.getElementById('start'); const bar = document.getElementById('bar'); start.addEventListener('click', async () => { const dir = await window.bridge.pickDirectory(); // 另一个 invoke 命令 const total = await window.bridge.importStart(dir); // 触发导入 console.log('开始导入,共', total, '张'); }); // 订阅推送(预加载脚本里同样要 wrap 后再 expose) bridge.onProgress((p) => { bar.style.width = Math.round((p.done / p.total) * 100) + '%'; });
结果:界面进度条平滑推进,主进程与页面各干各的,互不卡顿。
解读:两个设计决策值得咀嚼——进度用推送而不是让页面轮询 invoke(轮询会产生大量空往返);推送做了节流(千张图片推千次消息,渲染进程光是处理事件就可能掉帧)。变式:如果导入要在应用关闭后仍能续传,就得把任务状态落盘并把执行方搬到独立子进程,IPC 拓扑随之改变——通信设计永远服务于架构,而不是反过来。
通道修好了,下一节给通道装安检门:上下文隔离与预加载脚本,为什么它们是现代 Electron 的强制配置。