ipcMain 与 ipcRenderer
本教程共 45 篇 · 第 13 篇 · 更新于 2026-08-03
13. ipcMain 与 ipcRenderer
前面铺垫了 IPC 模型与 contextBridge,本章落到具体 API。ipcMain 跑在主进程,ipcRenderer 跑在渲染进程(通常经 preload 暴露)。这一对模块,就是跨进程发消息的两端。
本节目标
- 掌握
ipcRenderer.send与ipcMain.on的单向事件模式。 - 掌握
ipcRenderer.invoke与ipcMain.handle的请求-响应模式。 - 理解事件名(channel)的约定与命名习惯。
- 看清
event.sender如何反查来源窗口。 - 写出一个可运行的完整示例。
13-1 单向事件:send 与 on
最基础的场景:渲染进程发个消息,主进程收到后做点事,不需要回值。用 ipcRenderer.send(channel, ...args) 发送,主进程用 ipcMain.on(channel, listener) 监听。
常见用途是从界面触发主进程能力,比如动态改窗口标题。下面主进程监听 set-title 通道,拿到标题后找到来源窗口并设标题。
// 主进程 main.js
const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')
function handleSetTitle (event, title) {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
}
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, 'preload.js') }
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
ipcMain.on('set-title', handleSetTitle)
createWindow()
})
preload 这边把 ipcRenderer.send 包成一个简单函数,通过 contextBridge 暴露给网页:
// preload.js(Preload 脚本)
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
setTitle: (title) => ipcRenderer.send('set-title', title)
})
渲染进程业务代码只需调用 window.electronAPI.setTitle('新标题')。注意:我们没有直接暴露 ipcRenderer.send,而是按动作封装,这是安全习惯。
13-2 请求-响应:invoke 与 handle
当渲染进程需要主进程「返回一个结果」时,用双向模式:ipcRenderer.invoke(channel, ...args) 返回一个 Promise,主进程用 ipcMain.handle(channel, listener) 响应,listener 的返回值会回到 Promise。
典型例子是打开系统文件框并返回选中路径。主进程调用 dialog.showOpenDialog,把路径作为结果返回。
// 主进程 main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron/main')
const path = require('node:path')
async function handleFileOpen () {
const { canceled, filePaths } = await dialog.showOpenDialog()
if (!canceled) {
return filePaths[0]
}
}
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, 'preload.js') }
})
mainWindow.loadFile('index.html')
}
app.whenReady().then(() => {
ipcMain.handle('dialog:openFile', handleFileOpen)
createWindow()
})
preload 把 invoke 包成 openFile,渲染进程用 await 拿结果:
// preload.js(Preload 脚本)
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
openFile: () => ipcRenderer.invoke('dialog:openFile')
})
// 渲染进程 renderer.js
const btn = document.getElementById('btn')
const filePathEl = document.getElementById('filePath')
btn.addEventListener('click', async () => {
const filePath = await window.electronAPI.openFile()
filePathEl.innerText = filePath
})
13-3 事件名约定
通道名是普通字符串,但好的命名能救命。官方示例常用「模块:动作」形式,如 dialog:openFile、set-title。冒号没有语法含义,纯粹是命名空间,让多模块项目更易读。
约定俗成:
- 单向事件用动词短语,如
set-title、log、counter-value。 - 请求-响应用
模块:动作,如dialog:openFile、prefs:load。 - 避免太泛的名字如
data、message,容易撞名难排查。
记住:主进程 handle 和渲染进程 invoke 必须用同一个通道名;on 和 send 同理。名字拼错,消息就会石沉大海。
13-4 用 event.sender 反查窗口
在 ipcMain 的监听器里,event 参数带有 sender(即发消息的 webContents)。你可以用 BrowserWindow.fromWebContents(event.sender) 反查出是哪个窗口发的,从而只对它做操作。
// 主进程 main.js
ipcMain.on('set-title', (event, title) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) win.setTitle(title)
})
这在多窗口应用里很实用:不同窗口发来同样的请求,你可以精准地改对应窗口,而不是误伤其它窗口。注意 event.sender 是 WebContents,不是 BrowserWindow,要用 fromWebContents 转换。
13-5 handle 抛错的处理
一个容易踩的坑:ipcMain.handle 里抛出的错误,传到渲染进程时会被序列化,对方只能拿到 message,原始错误对象的结构与自定义属性会丢失。
// 主进程 main.js
ipcMain.handle('risky', async () => {
throw new Error('出错了') // 渲染进程只能拿到 message
})
// 渲染进程
try {
await window.electronAPI.risky()
} catch (err) {
console.log(err.message) // '出错了'
}
因此,若你需要向渲染进程传递结构化错误信息,最好主动返回一个明确的对象(如 { ok: false, code: 'XXX' }),而不是依赖错误对象的自动序列化。
13-6 send 与 invoke 怎么选
简单判断:需要回值,用 invoke/handle;只通知、不要结果,用 send/on。
invoke 的好处是返回 Promise,调用处写 await 很自然,且天然配对请求与响应。老式的 send 配 event.reply 也能做双向,但需要额外监听回复通道,且不方便对应每次请求,官方已不推荐。
// 推荐:需要结果就用 invoke
// 渲染经 preload: ipcRenderer.invoke('get-config') -> Promise
// 主进程: ipcMain.handle('get-config', () => readConfig())
提示:同步的 ipcRenderer.sendSync 会阻塞渲染进程,官方建议避免使用。需要同步结果时,也请用异步 invoke。
13-7 移除监听与生命周期清理
窗口关掉后,如果主进程还留着针对它的 ipcMain 监听器,既浪费又可能在回调里访问已销毁窗口而报错。习惯上,在窗口 closed 时移除只服务于该窗口的监听器。
// 主进程 main.js
function handleSetTitle (event, title) {
const win = BrowserWindow.fromWebContents(event.sender)
if (win) win.setTitle(title)
}
function createWindow () {
const mainWindow = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, 'preload.js') }
})
ipcMain.on('set-title', handleSetTitle)
// 窗口销毁时把监听器摘掉,避免持有已释放的窗口
mainWindow.once('closed', () => {
ipcMain.removeListener('set-title', handleSetTitle)
})
mainWindow.loadFile('index.html')
}
渲染进程侧也类似:组件卸载时,用 ipcRenderer.removeListener 取消订阅某个 on 通道,避免回调在组件已不存在时仍被触发。保持「注册与注销成对」是长期运行应用不出内存问题的基本功。
另外,ipcMain.handle 与 ipcMain.on 用的是同一套通道名但互不相干:同一通道上既 handle 又 on 是允许的,但通常没必要。明确每个通道是「请求-响应」还是「单向事件」,代码意图才不会被混淆。
13-8 通道命名冲突与同步回值的旧写法
随着项目变大,通道名可能撞车。比如两个模块都叫 update,消息就会串。约定俗成的办法是用「命名空间」前缀:把模块名放在冒号前,如 prefs:update、editor:update。这不改变任何功能,只是让通道在全局唯一、可读。
早期 Electron 还有同步回值的写法:主进程在 ipcMain.on 里设 event.returnValue,渲染进程用 ipcRenderer.sendSync 同步取回。这种写法会阻塞渲染进程,官方已不推荐。作为对照,下面是正确的异步做法,以及不推荐的同步做法。
// 推荐:异步,不阻塞界面
ipcMain.handle('compute', async (event, n) => n * 2)
// 渲染经 preload: await ipcRenderer.invoke('compute', 21)
// 不推荐:同步会阻塞渲染进程,仅作历史对照
// ipcMain.on('compute-sync', (event, n) => { event.returnValue = n * 2 })
// ipcRenderer.sendSync('compute-sync', 21)
把同步写法列为「不要这样做」,是提醒你:除非万不得已,永远选异步 invoke/handle。它不卡界面,且天然用 Promise 串联后续逻辑,代码也更好读。
另一个常见坑是 handle 与 on 混用同一通道:前者期待返回值、后者只收事件,混用会让语义混乱。一个通道只承担一种模式,是保持通信层清晰的最低成本纪律。
常见误区
- 误区一:通道名主、渲两边拼写不一致,消息收不到。务必同名。
- 误区二:把
event直接透传给渲染进程。这会把 Electron 内部 API 暴露出去,应在 preload 里只取需要的参数。 - 误区三:需要返回值却用
send/on,再自己维护回复通道。直接用invoke/handle更清晰。
小结
ipcRenderer 与 ipcMain 是 IPC 的两端。只通知不要结果,就用 send 配 on;需要拿返回值,就用 invoke 配 handle。
主进程里可以通过 event.sender 反查来源窗口,通道名则必须两边完全一致。再加上按动作封装、不把原始模块交出去这两条习惯,你的通信层就既好用又安全。