首页 / Electron 入门教程 / 双向通信模式

Electron 入门教程

双向通信模式

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

Electron双向通信webContents错误处理可取消请求

14. 双向通信模式

前两章讲了单个方向的收发。真实应用里,通信往往是「来回」的:界面请求主进程,主进程回结果;或者主进程主动把消息推给界面(比如菜单点击、下载进度)。本章把这些双向模式串起来。

本节目标

  • 掌握「渲染 → 主 → 渲染」的完整往返链路。
  • 学会用 webContents.send 让主进程主动推送消息。
  • 理解 IPC 中的错误处理方式。
  • 了解如何让请求可被取消。
  • 看清渲染进程之间不能直接通信的限制。

14-1 渲染到主再回渲染

这是最常见的双向链路:渲染进程 invoke 请求,主进程 handle 处理后 return 结果,Promise 在渲染进程落地。上一章的文件框例子就是这个模式。

关键在于:主进程的返回值就是渲染进程 await 拿到的值。你可以返回字符串、对象、数组等可克隆数据。如果主进程还要用到 Node API(读文件、调系统),都在这层完成,渲染进程完全不碰 Node。

// 主进程 main.js
ipcMain.handle('get-app-info', () => {
  return {
    name: '我的应用',
    version: '1.0.0',
    platform: process.platform
  }
})

// preload.js(Preload 脚本)
contextBridge.exposeInMainWorld('electronAPI', {
  getAppInfo: () => ipcRenderer.invoke('get-app-info')
})

// 渲染进程
const info = await window.electronAPI.getAppInfo()

这条链路里,preload 只做「转手」,主进程做「干活」,渲染进程做「展示」。职责清晰,也最安全。

14-2 主进程主动推送到渲染

反过来,主进程有时要主动找渲染进程说话:系统菜单点了「加一」、后台任务有进度、定时器到点了。这时主进程用 webContents.send(channel, ...args) 向某个渲染进程发消息。

注意:主进程发送时必须指定「发给谁」——也就是某个窗口的 webContents。下面例子用应用菜单的点击,向窗口推送一个计数增量。

// 主进程 main.js
const { app, BrowserWindow, Menu } = require('electron/main')
const path = require('node:path')

function createWindow () {
  const mainWindow = new BrowserWindow({
    webPreferences: { preload: path.join(__dirname, 'preload.js') }
  })

  const menu = Menu.buildFromTemplate([
    {
      label: app.name,
      submenu: [
        { label: 'Increment', click: () => mainWindow.webContents.send('update-counter', 1) },
        { label: 'Decrement', click: () => mainWindow.webContents.send('update-counter', -1) }
      ]
    }
  ])
  Menu.setApplicationMenu(menu)
  mainWindow.loadFile('index.html')
}

渲染进程需要在 preload 里监听这个推送,并通过 contextBridge 把「订阅」能力暴露出去:

// preload.js(Preload 脚本)
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  onUpdateCounter: (callback) =>
    ipcRenderer.on('update-counter', (_event, value) => callback(value))
})
// 渲染进程 renderer.js
const counter = document.getElementById('counter')

window.electronAPI.onUpdateCounter((value) => {
  const old = Number(counter.innerText)
  counter.innerText = (old + value).toString()
})

14-3 安全暴露推送监听

注意上面 preload 的写法:我们没有把整个 ipcRenderer.on 交出去,而是包了一层,只把 _event 之外的 value 传给回调。

这很重要。官方安全指南明确提醒:不要把 callback 直接当 ipcRenderer.on 的监听器传进去,否则 event.sender 等危险 API 会泄漏给渲染进程。正确做法是自定义处理函数,只把需要的参数交出去。

// ✅ 安全:只透传业务参数
onUpdateCounter: (callback) =>
  ipcRenderer.on('update-counter', (_event, value) => callback(value))

// ❌ 不安全:直接把回调当监听器,会泄漏 event
// onUpdateCounter: (callback) => ipcRenderer.on('update-counter', callback)

14-4 错误处理

请求-响应模式下,主进程 handle 抛错,渲染进程 invoke 的 Promise 会 reject。但如前所述,错误对象会被序列化,只保留 message

要在渲染侧优雅处理,有两个办法。一是直接 try/catch 接住 message;二是主进程主动返回结构化结果,让渲染进程自己判断成功失败。

// 主进程:用结构化结果代替抛错
ipcMain.handle('read-file', async (event, p) => {
  try {
    const fs = require('node:fs/promises')
    const content = await fs.readFile(p, 'utf-8')
    return { ok: true, content }
  } catch (e) {
    return { ok: false, error: e.message }
  }
})

// 渲染进程
const res = await window.electronAPI.readFile('x.txt')
if (!res.ok) {
  console.warn('读取失败:', res.error)
}

单向 send/on 模式没有 Promise,错误要靠你在主进程里 try/catch 后,再用 webContents.send 把错误推回去。

14-5 可取消的请求

有些请求很慢(大文件处理、网络请求),用户可能想中途取消。IPC 本身没有内置「取消」原语,但你可以用「请求 + 取消」两个通道自己实现。

思路:渲染进程发 start-job 并带一个 jobId;想取消时发 cancel-job 带同一个 jobId;主进程用一张表记录进行中的任务,收到取消就中止。

// 主进程 main.js
const jobs = new Map()

// 一个可被中断的耗时任务,这里用定时器模拟
function doWork ({ signal }) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => resolve('done'), 5000)
    signal.addEventListener('abort', () => {
      clearTimeout(timer)
      reject(new Error('已取消'))
    })
  })
}

ipcMain.handle('start-job', async (event, jobId) => {
  const controller = new AbortController()
  jobs.set(jobId, controller)
  try {
    return await doWork({ signal: controller.signal })
  } finally {
    jobs.delete(jobId) // 无论成功、失败还是取消都清表
  }
})

ipcMain.on('cancel-job', (event, jobId) => {
  jobs.get(jobId)?.abort()
})

preload 把两个动作分别暴露,渲染进程拿到 jobId 后可在用户点击「取消」时调用。这样既保持了安全封装,又给了用户控制权。

14-6 渲染进程之间不能直接通信

要记住:Electron 没有「渲染进程直接给另一个渲染进程发 IPC」的通道。ipcMain/ipcRenderer 只连接「主」与「单个渲染」。

若窗口 A 想通知窗口 B,标准做法是用主进程中转:A 发消息给主进程,主进程用 webContents.send 转发给 B。或者更进阶地,用 MessagePort 在主进程协助下建立两渲染进程间的直连通道(见官方 message-ports 教程)。

// 主进程:把消息从 A 转发给其它窗口
ipcMain.on('relay', (event, payload) => {
  const sender = BrowserWindow.fromWebContents(event.sender)
  BrowserWindow.getAllWindows()
    .filter(w => w !== sender)
    .forEach(w => w.webContents.send('relayed', payload))
})

14-7 订阅的清理与 off

渲染进程通过 onUpdateCounter 这样的函数订阅了主进程的推送,但当窗口或组件销毁时,如果不取消订阅,监听器会一直存在,造成内存泄漏,甚至对已不存在的 DOM 进行操作而报错。

清理办法是在 preload 暴露「取消订阅」的能力,内部调用 ipcRenderer.removeListener。注意 removeListener 需要传入当初 on 时同一个函数引用,所以最好把监听器保存下来。

// preload.js(Preload 脚本)
const { contextBridge, ipcRenderer } = require('electron')

const listenerMap = new Map()

contextBridge.exposeInMainWorld('electronAPI', {
  onUpdateCounter: (callback) => {
    const handler = (_event, value) => callback(value)
    listenerMap.set(callback, handler)
    ipcRenderer.on('update-counter', handler)
  },
  offUpdateCounter: (callback) => {
    const handler = listenerMap.get(callback)
    if (handler) ipcRenderer.removeListener('update-counter', handler)
  }
})

渲染进程在组件卸载时调用 window.electronAPI.offUpdateCounter(callback),即可干净退订。把「订阅」和「退订」成对暴露,是双向通信里经常被忽略、却很关键的一环。

14-8 大数据传输与选项:MessagePort

前面提到渲染进程之间不能直接通信,主进程中转适合小消息。若要传大量数据或建立长连接,官方推荐用 MessagePort:主进程创建一对端口,分别通过 IPC 交给两个渲染进程,之后两边就能像 postMessage 一样直接通信,不必每次都绕主进程。

MessagePort 本身也通过 IPC 传递,渲染进程用 ipcRenderer.postMessage(channel, message, [transfer]) 把端口交给主进程,ipcMain.on 里通过事件的 ports 属性取出。它适合频繁、大批量的数据传输,比如编辑器间同步大文档。

// 渲染进程:把端口发给主进程
const { port1, port2 } = new MessageChannel()
ipcRenderer.postMessage('give-port', { text: 'hello' }, [port1])

// 主进程:收到后转给另一个窗口
ipcMain.on('give-port', (event, msg) => {
  const [port] = event.ports
  // 把 port 通过 webContents.send 转交给目标窗口
})

对大多数桌面应用,主进程中转已足够。只有当你确认真的有「渲染对渲染的高频大数据」需求时,才引入 MessagePort。先选简单方案,需求真的到来再加复杂度,是避免过度设计的好原则。

常见误区

  • 误区一:以为 webContents.send 能一次广播给所有窗口。它是某个 webContents 上的方法,要群发就得自己遍历 getAllWindows() 逐个调用。
  • 误区二:直接把 ipcRenderer.on 的回调交出,泄漏 event。应只透传业务参数。
  • 误区三:以为渲染进程能直接给另一个渲染进程发消息。必须经主进程中转或用 MessagePort。

小结

双向通信主要有三类。渲染进程用 invoke 请求、主进程 handle 后返回,这是最常见的往返。主进程要主动说话,就用目标窗口的 webContents.send 推过去。窗口之间要通气,则由主进程中转。

错误处理上,可以直接 try/catch 接住 message,也可以让主进程返回结构化结果。慢请求用「启动 + 取消」两个通道实现中断。而无论哪种模式,preload 里都要把监听器包一层,这是整条链路不出漏洞的前提。