首页 / Electron 入门教程 / ipcMain 与 ipcRenderer

Electron 入门教程

ipcMain 与 ipcRenderer

本教程共 45 篇 · 第 13 篇 · 更新于 2026-08-03

ElectronipcMainipcRendererinvokehandle

13. ipcMain 与 ipcRenderer

前面铺垫了 IPC 模型与 contextBridge,本章落到具体 API。ipcMain 跑在主进程,ipcRenderer 跑在渲染进程(通常经 preload 暴露)。这一对模块,就是跨进程发消息的两端。

本节目标

  • 掌握 ipcRenderer.sendipcMain.on 的单向事件模式。
  • 掌握 ipcRenderer.invokeipcMain.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:openFileset-title。冒号没有语法含义,纯粹是命名空间,让多模块项目更易读。

约定俗成:

  • 单向事件用动词短语,如 set-titlelogcounter-value
  • 请求-响应用 模块:动作,如 dialog:openFileprefs:load
  • 避免太泛的名字如 datamessage,容易撞名难排查。

记住:主进程 handle 和渲染进程 invoke 必须用同一个通道名;onsend 同理。名字拼错,消息就会石沉大海。

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.senderWebContents,不是 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 很自然,且天然配对请求与响应。老式的 sendevent.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.handleipcMain.on 用的是同一套通道名但互不相干:同一通道上既 handleon 是允许的,但通常没必要。明确每个通道是「请求-响应」还是「单向事件」,代码意图才不会被混淆。

13-8 通道命名冲突与同步回值的旧写法

随着项目变大,通道名可能撞车。比如两个模块都叫 update,消息就会串。约定俗成的办法是用「命名空间」前缀:把模块名放在冒号前,如 prefs:updateeditor: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 串联后续逻辑,代码也更好读。

另一个常见坑是 handleon 混用同一通道:前者期待返回值、后者只收事件,混用会让语义混乱。一个通道只承担一种模式,是保持通信层清晰的最低成本纪律。

常见误区

  • 误区一:通道名主、渲两边拼写不一致,消息收不到。务必同名。
  • 误区二:把 event 直接透传给渲染进程。这会把 Electron 内部 API 暴露出去,应在 preload 里只取需要的参数。
  • 误区三:需要返回值却用 send/on,再自己维护回复通道。直接用 invoke/handle 更清晰。

小结

ipcRendereripcMain 是 IPC 的两端。只通知不要结果,就用 sendon;需要拿返回值,就用 invokehandle

主进程里可以通过 event.sender 反查来源窗口,通道名则必须两边完全一致。再加上按动作封装、不把原始模块交出去这两条习惯,你的通信层就既好用又安全。