03 · IPC 进程间通信:四种模式实例

目标:掌握主进程 ↔ 渲染进程通信的全部模式。IPC 是 Electron 开发的核心——调用原生 API、菜单触发页面更新都靠它。 官方对应:进程间通信

IPC 通道基础

  • ipcMain(主进程)+ ipcRenderer(渲染进程,需经 preload 暴露)
  • 通道名任意、双向、字符串命名(官方喜欢 dialog:openFile 这种命名空间风格,前缀无魔法含义)
  • 渲染进程不能直接 require('electron') 用 ipcRenderer,必须 preload + contextBridge
  • 消息序列化用结构化克隆算法:DOM 对象、C++ 支撑对象(WebContents、BrowserWindow、process.env)不可传输

模式 1:渲染器 → 主进程(单向)

场景实例:输入框修改窗口标题(ipcRenderer.send + ipcMain.on)

main.js:

const { app, BrowserWindow, ipcMain } = require('electron')
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()
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})
 
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

preload.js:

const { contextBridge, ipcRenderer } = require('electron')
 
contextBridge.exposeInMainWorld('electronAPI', {
  setTitle: (title) => ipcRenderer.send('set-title', title)
})

index.html + renderer.js:

Title: <input id="title"/>
<button id="btn" type="button">Set</button>
<script src="./renderer.js"></script>
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
  window.electronAPI.setTitle(titleInput.value)
})

模式 2:渲染器 → 主进程(双向,推荐)

场景实例:打开原生文件对话框并返回路径(ipcRenderer.invoke + ipcMain.handle,Promise 风格)

main.js:

const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('node:path')
 
async function handleFileOpen () {
  const { canceled, filePaths } = await dialog.showOpenDialog()
  if (!canceled) {
    return filePaths[0]   // 返回值会作为 Promise 结果回到 invoke 调用处
  }
}
 
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()
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})
 
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

preload.js:

const { contextBridge, ipcRenderer } = require('electron')
 
contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile')
})

index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy"
          content="default-src 'self'; script-src 'self'" />
    <title>Dialog</title>
  </head>
  <body>
    <button type="button" id="btn">Open a File</button>
    File path: <strong id="filePath"></strong>
    <script src="./renderer.js"></script>
  </body>
</html>

renderer.js:

const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')
 
btn.addEventListener('click', async () => {
  const filePath = await window.electronAPI.openFile()
  filePathElement.innerText = filePath
})

注意:handle 里抛出的错误会被序列化,渲染器只能拿到 message 属性(见 electron#24427)。

两种旧方法(了解即可,不推荐)

方法缺点
ipcRenderer.send + event.reply需要另写一个 ipcRenderer.on 收回复;请求与响应难配对
ipcRenderer.sendSync + event.returnValue同步阻塞渲染进程,避免使用

模式 3:主进程 → 渲染器

场景实例:原生菜单控制页面计数器(webContents.send + ipcRenderer.on)

main.js(构建菜单,点击时向渲染器发消息):

const { app, BrowserWindow, Menu, ipcMain } = require('electron')
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')
}
 
app.whenReady().then(() => {
  // 可选:接收渲染器回传的最新值
  ipcMain.on('counter-value', (_event, value) => console.log(value))
  createWindow()
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})
 
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

preload.js(注意回调包装方式——安全要点):

const { contextBridge, ipcRenderer } = require('electron')
 
contextBridge.exposeInMainWorld('electronAPI', {
  // 不要把 callback 直接传给 ipcRenderer.on!
  // event.sender 会泄露整个 ipcRenderer 实例
  onUpdateCounter: (callback) =>
    ipcRenderer.on('update-counter', (_event, value) => callback(value)),
  counterValue: (value) => ipcRenderer.send('counter-value', value)
})

index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="Content-Security-Policy"
          content="default-src 'self'; script-src 'self'" />
    <title>Menu Counter</title>
  </head>
  <body>
    Current value: <strong id="counter">0</strong>
    <script src="./renderer.js"></script>
  </body>
</html>

renderer.js:

const counter = document.getElementById('counter')
window.electronAPI.onUpdateCounter((value) => {
  const oldValue = Number(counter.innerText)
  const newValue = oldValue + value
  counter.innerText = newValue.toString()
  window.electronAPI.counterValue(newValue)  // 回传给主进程
})

主进程→渲染器没有 invoke 等价物;需要回复就用 ipcRenderer.send 另开一条通道回传。

模式 4:渲染器 → 渲染器

没有直连方式,两种选择:

  1. 主进程中转:A 渲染器 → 主进程 → B 渲染器(简单)
  2. MessagePort:主进程把端口分发给两个渲染器,建立后直连(高频通信时更高效,见消息端口)

安全红线(必须背下来)

// 错误:暴露整个模块 = 渲染器可发任意通道消息
contextBridge.exposeInMainWorld('api', ipcRenderer)
 
// 正确:逐个包装具体通道
contextBridge.exposeInMainWorld('api', {
  openFile: () => ipcRenderer.invoke('dialog:openFile')
})
  • 永远不要直接暴露 ipcRenderer 或其 .on/.send/.invoke 方法
  • 接收类回调要用包装函数只传数据参数,不传 event
  • 主进程处理特权消息时验证 event.senderFrame 的 URL(见 06-security-performance)

模式选择速查

需求模式
页面通知主进程做事,不需要结果模式 1 send/on
页面调用主进程并等返回值(文件操作、查询)模式 2 invoke/handle ⭐ 默认选择
菜单/托盘/快捷键驱动页面更新、推送通知模式 3 webContents.send
多窗口协作模式 4 中转或 MessagePort

练习

  1. 综合练习:做一个”记事本”——菜单”打开”触发文件对话框(模式 2),读文件内容显示到页面;菜单”保存”把页面内容写回(模式 2)
  2. 用模式 3 做一个菜单项”清空计数器”,点击后页面归零
  3. 开两个窗口,用模式 4(主进程中转)让窗口 A 的输入实时同步到窗口 B

小结

  • invoke/handle 是现代 Electron IPC 的默认姿势(Promise、可配对)
  • 所有暴露都经 contextBridge 白名单包装,绝不裸暴露
  • 下一章:04-native-features — 菜单/托盘/通知/快捷键实战