先把盘子摆正:Electron 的通信,本质就是“不同世界如何说话”——主进程(Node 能力)vs 渲染进程(UI),中间靠预加载脚本(preload)搭桥。好消息是,常规项目 80% 的需求用两招就够了:ipcRenderer.invoke/ ipcMain.handle
(问答式)+ webContents.send
(广播式)。剩下 20% 的花活儿,交给场景来驱动。
一、通信全景图(很快过一遍)#
- 主进程:能做系统权限、文件操作、窗口管理、自动更新、菜单/托盘、下载等。
- 渲染进程:负责 UI 与交互,别直接碰 Node 能力。
- 预加载脚本(preload):唯一可信桥梁。
contextBridge.exposeInMainWorld
暴露“白名单 API”。
常用通道
ipcRenderer.invoke
↔ipcMain.handle
:请求-响应,最省心。拿结果、拿错误,一把梭。ipcRenderer.send
↔ipcMain.on
:事件流,适合“我告诉你一声”的通知。webContents.send
:主进程推消息到指定窗口(或广播)。
二、最常用的桥:预加载脚本(一次写好,全局安心)#
electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('api', { // 请求-响应(优先) openFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('dialog:openFile', filters),
// 事件流(主 -> 渲染) onDownloadProgress: (cb: (p: { percent: number }) => void) => { const listener = (_e, data) => cb(data) ipcRenderer.on('download:progress', listener) return () => ipcRenderer.removeListener('download:progress', listener) },})
渲染进程直接用:
const file = await window.api.openFile([{ name: 'Image', extensions: ['png','jpg'] }])const off = window.api.onDownloadProgress(({ percent }) => { console.log(percent) })
安全小抄
contextIsolation: true
,nodeIntegration: false
- 只暴露白名单 API;参数在主进程做校验,别让“野生数据”进系统调用
三、场景 1:文件/目录选择(权限稳稳当当)#
主进程
import { ipcMain, dialog } from 'electron'
ipcMain.handle('dialog:openFile', async (_evt, filters) => { const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters, }) return canceled ? null : filePaths[0]})
渲染进程
const file = await window.api.openFile([{ name: 'Text', extensions: ['txt','md'] }])if (file) { /* 走你的业务 */ }
为什么要这样?
- 权限边界清楚:渲染进程不直接调用
dialog
,全走主进程;后续想统一拦截/打点/限权,也有抓手。
四、场景 2:下载任务与进度回传(主拉渲染看)#
主进程用 session
或三方库(如 electron-dl);这里示范自管进度并推给渲染。
import { session, BrowserWindow, ipcMain } from 'electron'
function setupDownload(win: BrowserWindow) { session.defaultSession.on('will-download', (_e, item) => { const total = item.getTotalBytes() item.on('updated', (_evt, state) => { if (state === 'progressing') { const percent = Math.round((item.getReceivedBytes() / total) * 100) win.webContents.send('download:progress', { percent }) } }) item.once('done', (_evt, state) => { win.webContents.send('download:progress', { percent: state === 'completed' ? 100 : -1 }) }) })}
ipcMain.handle('download:start', async (_evt, url: string) => { const win = BrowserWindow.getFocusedWindow() if (win) await win.webContents.downloadURL(url) return true})
渲染进程
await window.api.downloadStart('https://example.com/big.zip')const off = window.api.onDownloadProgress(({ percent }) => { // percent: 0~100,-1 代表失败})
要点
- 进度走事件流(频繁小消息);开始/结果走 invoke(一次性)。
- 大文件别走 IPC 传 Buffer,传路径/URL 就行,省得卡主线程。
五、场景 3:自动更新提示(问主进程要结果,UI 在渲染)#
主进程
import { autoUpdater } from 'electron-updater'import { BrowserWindow } from 'electron'
function setupUpdater(win: BrowserWindow) { autoUpdater.on('update-available', (info) => { win.webContents.send('updater:event', { type: 'available', info }) }) autoUpdater.on('download-progress', (p) => { win.webContents.send('updater:event', { type: 'progress', percent: p.percent }) }) autoUpdater.on('update-downloaded', () => { win.webContents.send('updater:event', { type: 'ready' }) })}
ipcMain.handle('updater:check', async () => { const r = await autoUpdater.checkForUpdates() return { version: r?.updateInfo?.version }})ipcMain.handle('updater:install', async () => { autoUpdater.quitAndInstall() return true})
渲染进程
await window.api.updaterCheck()window.api.onUpdaterEvent((e) => { if (e.type === 'ready') { // 弹窗:确认后 window.api.updaterInstall() }})
套路
- 事件 → 渲染(让 UI “动”起来)
- 命令 → 主进程(执行敏感操作)
六、场景 4:窗口间同步(主进程当“消息总线”)#
主进程维护窗口池:
const windows = new Set<BrowserWindow>()
function broadcast(channel: string, data: any, exclude?: number) { windows.forEach(w => { if (w.webContents.id !== exclude) w.webContents.send(channel, data) })}
// 渲染 A 通知“主题改了”,主进程广播给其他窗口ipcMain.on('theme:update', (evt, theme) => { broadcast('theme:changed', theme, evt.sender.id)})
渲染 A
ipcRenderer.send('theme:update', 'dark')
渲染 B
ipcRenderer.on('theme:changed', (_e, theme) => applyTheme(theme))
为什么不直接窗口间互相发?
- 生命周期乱、易丢消息。主进程中转更可控,也好打日志与限流。
七、场景 5:后台任务(CPU 劲活儿别在渲染进程硬抗)#
用 child_process.fork
或 worker_threads
,结果回到主进程,再发给渲染。
主进程
import { fork } from 'node:child_process'import { ipcMain, BrowserWindow } from 'electron'import path from 'node:path'
ipcMain.handle('task:heavy', async (_evt, input: any) => { return new Promise((resolve, reject) => { const p = fork(path.join(__dirname, 'worker.js')) p.send({ input }) p.on('message', (msg) => { if (msg.progress != null) { BrowserWindow.getFocusedWindow()?.webContents.send('task:progress', msg) } if (msg.done) resolve(msg.result) }) p.on('error', reject) p.on('exit', (code) => code !== 0 && reject(new Error(`worker exit ${code}`))) })})
worker.js
process.on('message', ({ input }) => { let acc = 0 for (let i = 0; i <= 100; i++) { // ...重活 process.send?.({ progress: i }) } process.send?.({ done: true, result: acc })})
渲染进程
const result = await window.api.taskHeavy({ foo: 1 })window.api.onTaskProgress(({ progress }) => { /* 更新进度条 */ })
要点
- 重活丢子进程,通信只传“结果/进度/状态”,别塞大对象。
- 失败要可重试,避免“卡一半没声音”。
八、场景 6:打印 / 导出 PDF(传路径,不传二进制)#
主进程
ipcMain.handle('print:pdf', async (evt, { file }) => { const wc = BrowserWindow.fromWebContents(evt.sender)!.webContents const data = await wc.printToPDF({ landscape: false }) const fs = await import('node:fs/promises') await fs.writeFile(file, data) return true})
渲染进程
const ok = await window.api.printPdf({ file: '/Users/me/output.pdf' })
别在 IPC 里传巨大的 Buffer,走文件系统落地更稳。
九、工程化进阶(把“能用”打磨成“耐用”)#
- 通道设计与命名
- 资源型:
dialog:openFile
、行为型:download:start
、事件型:download:progress
- 小写、命名空间、动宾明确;别用随意字符串,后期治理会崩。
- 资源型:
- 契约与类型
- 建一个
ipc-contracts.ts
同步声明入参/出参类型;预加载window.api.d.ts
也暴露类型,IDE 能托底。 - 参数校验(最小也上 zod/yup 之类),防“脏输入”。
- 建一个
- 超时与幂等
invoke
包一层withTimeout(fn, ms)
;支持取消与重试。- 关键操作带请求 ID(uuid),重复请求直接短路。
- 错误模型
- 统一结构:
{ code: string; message: string; details?: any }
- 可预期错误分级:用户可修复/需重试/需反馈日志
- 统一结构:
- 性能与背压
- 高频推送(下载进度/任务进度)做节流(比如 50–100ms 一次)。
- 超大对象别走 IPC;写临时文件/DB,IPC 传“引用”。
- 生命周期与重复监听
- 开发热重载容易“叠监听”,在主进程加守护:初始化只跑一次;渲染侧
onXXX
返回取消函数,组件卸载时调用。
- 开发热重载容易“叠监听”,在主进程加守护:初始化只跑一次;渲染侧
- 安全底线
- 不开
remote
;不在渲染进程使用require
。 - 不把原始错误直接弹给用户(可能含路径/隐私),先脱敏再上报。
- 不开
- 监控与日志
- 日志带
reqId
与channel
,串起“一次调用的全路径”。 - 失败率/耗时埋点,找到“谁在拖后腿”。
- 日志带
- 测试与回归
- 集成测试:旧时代可用 Spectron;现在更推荐 Playwright + Electron adaptor(或以 CLI 启动应用,走端到端)。
- 关键 IPC 路由做“契约测试”(参数/返回校验),升级不背锅。
- 打包与路径
- asar 后的路径用
process.resourcesPath
推导,别写死相对路径(preload、icon、静态资源都一样)。
- asar 后的路径用
十、踩坑速记(遇见过就忘不了)#
- “怎么 invoke 没响应?”
- 看是否忘了
ipcMain.handle
;或 handler 里抛了未捕获异常;或被卡在同步阻塞。
- 看是否忘了
- “下载进度卡在 0%”
- 某些服务器没 Content-Length;自己估一个或直接走“状态轮询”的 UI。
- “多窗口主题不同步”
- 别直接互相发消息,主进程做中枢;窗口关了要及时
off
。
- 别直接互相发消息,主进程做中枢;窗口关了要及时
- “渲染崩了,主进程还在跑”
- 主进程兜底监听
render-process-gone
,把关键状态持久后再提示恢复。
- 主进程兜底监听
- “remote 能不能图省事?”
- 别。后期全是债。走 preload 白名单 + IPC,早用早安心。
收个尾#
通信的高级感,不在于你会多少 API,而在于“场景下的组合拳”:什么时候问答、什么时候广播、什么时候落到磁盘、什么时候忍痛割爱别传大对象。前半场把常见场景掰开揉碎,后半场给出工程化守则——够你从“能跑”进化到“敢上生产”。剩下的,就是多做几回,踩过一次坑就记一辈子。