跳转至

进程间通信

进程间通信(IPC)是在 Electron 中构建功能丰富的桌面应用程序的关键部分。由于主进程和渲染进程在 Electron 的进程模型中承担不同的职责,IPC 是执行许多常见任务的唯一方式,例如从 UI 调用原生 API,或通过原生菜单触发 Web 内容的变更。

IPC 通道

在 Electron 中,进程通过 ipcMain 和 ipcRenderer 模块,经由开发者定义的“通道”传递消息来进行通信。这些通道是任意的(你可以随意命名)并且双向的(你可以在两个模块中使用相同的通道名称)。

在本指南中,我们将通过具体示例介绍一些基本的 IPC 模式,供你在编写应用代码时参考。

理解上下文隔离的进程

在继续了解实现细节之前,你应该熟悉在上下文隔离的渲染进程中使用 预加载脚本 导入 Node.js 和 Electron 模块的概念。

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

要从渲染进程向主进程发送单向 IPC 消息,你可以使用 [ipcRenderer.send][] API 发送消息,然后由 [ipcMain.on][] API 接收。

你通常使用此模式从 Web 内容中调用主进程 API。我们将通过创建一个可以以编程方式更改窗口标题的简单应用来演示此模式。

对于此演示,你需要向主进程、渲染进程以及预加载脚本中添加代码。完整代码如下,但我们将在接下来的章节中逐一解释每个文件。

```fiddle docs/fiddles/ipc/pattern-1

### 1. 使用 `ipcMain.on` 监听事件 {#1-listen-for-events-with-ipcmainon}

在主进程中,使用 `ipcMain.on` API 在 `set-title` 通道上设置一个 IPC 监听器:

```js {7-11,23} title='main.js (Main Process)'
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()
})
// ...

上述 handleSetTitle 回调有两个参数:IpcMainEvent 结构和一个 title 字符串。每当 set-title 通道收到消息时,此函数会查找与消息发送者关联的 BrowserWindow 实例,并对其使用 win.setTitle API。

:::info 确保为以下步骤加载 index.html 和 preload.js 入口文件! :::

2. 通过预加载脚本暴露 ipcRenderer.send

要向上面创建的监听器发送消息,你可以使用 ipcRenderer.send API。默认情况下,渲染进程无法访问 Node.js 或 Electron 模块。作为应用开发者,你需要使用 contextBridge API 选择要从预加载脚本中暴露哪些 API。

在你的预加载脚本中,添加以下代码,将全局 window.electronAPI 变量暴露给渲染进程。

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

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

此时,你将能够在渲染进程中使用 window.electronAPI.setTitle() 函数。

:::caution 安全警告 出于安全原因,我们不会直接暴露整个 ipcRenderer.send API。请务必尽可能限制渲染进程对 Electron API 的访问。 :::

3. 构建渲染进程 UI

在我们 BrowserWindow 加载的 HTML 文件中,添加一个由文本输入框和按钮组成的基本用户界面:

```html {11-12} title='index.html'

Hello World! Title:
为了使这些元素具有交互性,我们将导入的 `renderer.js` 文件中添加几行代码,利用从预加载脚本暴露的 `window.electronAPI` 功能:

```js title='renderer.js (Renderer Process)' @ts-expect-error=[4,5]
const setButton = document.getElementById('btn')
const titleInput = document.getElementById('title')
setButton.addEventListener('click', () => {
  const title = titleInput.value
  window.electronAPI.setTitle(title)
})

此时,你的演示应该可以完全正常运行。试试使用输入框,看看你的 BrowserWindow 标题会发生什么变化!

模式 2:渲染进程到主进程(双向)

双向 IPC 的一个常见应用是从渲染进程代码中调用主进程模块并等待结果。可以通过 [ipcRenderer.invoke][] 与 [ipcMain.handle][] 配合使用来实现。

在下面的示例中,我们将从渲染进程打开一个原生文件对话框,并返回所选文件的路径。

对于此演示,你需要向主进程、渲染进程以及预加载脚本中添加代码。完整代码如下,但我们将在接下来的章节中逐一解释每个文件。

```fiddle docs/fiddles/ipc/pattern-2

### 1. 使用 `ipcMain.handle` 监听事件 {#1-listen-for-events-with-ipcmainhandle}

在主进程中,我们将创建一个 `handleFileOpen()` 函数,该函数调用 `dialog.showOpenDialog` 并返回用户所选文件路径的值。每当渲染进程通过 `dialog:openFile` 通道发送 `ipcRenderer.invoke` 消息时,此函数都会作为回调被使用。随后,返回值将作为 Promise 返回给原始的 `invoke` 调用。



:::caution 关于错误处理
在主进程中通过 `handle` 抛出的错误不会完整透传,因为它们会被序列化,并且只会将原始错误的 `message` 属性提供给渲染进程。详情请参见
[#24427](https://github.com/electron/electron/issues/24427)。
:::

```js {6-13,25} title='main.js (Main Process)'
const { app, BrowserWindow, dialog, ipcMain } = require('electron')

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()
})
// ...

:::tip 关于通道名称 IPC 通道名称中的 dialog: 前缀对代码没有影响。它仅作为一个命名空间,有助于提高代码可读性。 :::

:::info 请确保在后续步骤中加载 index.html 和 preload.js 入口点! :::

2. 通过 preload 暴露 ipcRenderer.invoke

在 preload 脚本中,我们暴露一个单行的 openFile 函数,该函数调用并返回 ipcRenderer.invoke('dialog:openFile') 的值。我们将在下一步中使用此 API,从渲染进程的用户界面调用 原生对话框。

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

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

:::caution 安全警告 出于[安全原因][],我们不会直接暴露整个 ipcRenderer.invoke API。请尽可能限制渲染进程对 Electron API 的访问。 :::

3. 构建渲染进程 UI

最后,让我们构建要加载到 BrowserWindow 中的 HTML 文件。

```html {10-11} title='index.html'

Dialog File path:
该 UI 由一个 `#btn` 按钮元素组成,用于触发我们的 preload API,以及一个 `#filePath` 元素,用于显示所选文件的路径。让这些部分工作起来只需要在渲染进程脚本中编写几行代码:

```js title='renderer.js (Renderer Process)' @ts-expect-error=[5]
const btn = document.getElementById('btn')
const filePathElement = document.getElementById('filePath')

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

在上面的代码片段中,我们监听 #btn 按钮的点击事件,并调用 window.electronAPI.openFile() API 来激活原生打开文件对话框。然后,我们在 #filePath 元素中显示所选文件的路径。

注意:传统方法

ipcRenderer.invoke API 是在 Electron 7 中添加的,作为从渲染进程处理双向 IPC 的一种对开发者友好的方式。然而,还存在一些针对这种 IPC 模式的替代方法。

:::warning 尽可能避免传统方法 我们建议尽可能使用 ipcRenderer.invoke。以下从渲染进程到主进程的双向模式仅出于历史目的而记录。 :::

:::info 在以下示例中,我们直接从 preload 脚本调用 ipcRenderer,以保持代码示例简短。 :::

使用 ipcRenderer.send

我们用于单向通信的 ipcRenderer.send API 也可以用于执行双向通信。在 Electron 7 之前,这是通过 IPC 进行异步双向通信的推荐方式。

preload.js (Preload Script)
// You can also put expose this code to the renderer
// process with the `contextBridge` API
const { ipcRenderer } = require('electron')

ipcRenderer.on('asynchronous-reply', (_event, arg) => {
  console.log(arg) // prints "pong" in the DevTools console
})
ipcRenderer.send('asynchronous-message', 'ping')
main.js (Main Process)
ipcMain.on('asynchronous-message', (event, arg) => {
  console.log(arg) // prints "ping" in the Node console
  // works like `send`, but returning a message back
  // to the renderer that sent the original message
  event.reply('asynchronous-reply', 'pong')
})

这种方法有几个缺点:

  • 你需要设置第二个 ipcRenderer.on 监听器,以在渲染进程中处理响应。使用 invoke 时,响应值会作为 Promise 返回给原始 API 调用。
  • 没有明显的方法将 asynchronous-reply 消息与原始的 asynchronous-message 消息配对。如果通过这些通道频繁地来回发送消息,你需要添加额外的应用代码来单独跟踪每次调用和响应。

使用 ipcRenderer.sendSync

ipcRenderer.sendSync API 向主进程发送消息,并_同步_等待响应。

main.js (Main Process)
const { ipcMain } = require('electron')

ipcMain.on('synchronous-message', (event, arg) => {
  console.log(arg) // prints "ping" in the Node console
  event.returnValue = 'pong'
})
preload.js (Preload Script)
// You can also put expose this code to the renderer
// process with the `contextBridge` API
const { ipcRenderer } = require('electron')

const result = ipcRenderer.sendSync('synchronous-message', 'ping')
console.log(result) // prints "pong" in the DevTools console

这段代码的结构与 invoke 模型非常相似,但由于性能原因,我们建议避免使用此 API。其同步特性意味着它会阻塞渲染进程,直到收到回复。

模式 3:主进程到渲染进程

当从主进程向渲染进程发送消息时,你需要指定哪个渲染进程接收该消息。消息需要通过其 [WebContents][] 实例发送到渲染进程。该 WebContents 实例包含一个 send 方法,可以像使用 ipcRenderer.send 一样使用。

为了演示该模式,我们将构建一个由原生操作系统菜单控制的数字计数器。

对于这个演示,你需要在主进程、渲染进程和一个 preload 脚本中添加代码。完整代码如下,但我们将在以下各节中逐一解释每个文件。

```fiddle docs/fiddles/ipc/pattern-3

### 1. 使用 `webContents` 模块发送消息 {#1-send-messages-with-the-webcontents-module}

对于这个演示,我们需要首先在主进程中使用 Electron 的 `Menu` 模块构建一个自定义菜单,该菜单使用 `webContents.send` API 从主进程向目标渲染进程发送 IPC 消息。

```js {11-26} title='main.js (Main Process)'
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: [
        {
          click: () => mainWindow.webContents.send('update-counter', 1),
          label: 'Increment'
        },
        {
          click: () => mainWindow.webContents.send('update-counter', -1),
          label: 'Decrement'
        }
      ]
    }
  ])
  Menu.setApplicationMenu(menu)

  mainWindow.loadFile('index.html')
}
// ...

就本教程而言,需要注意的是,click 处理函数会通过 update-counter 通道向渲染进程发送一条消息(1 或 -1)。

```js @ts-type={mainWindow:Electron.BrowserWindow} click: () => mainWindow.webContents.send('update-counter', -1)

:::info
请确保在以下步骤中加载 `index.html` 和 `preload.js` 入口文件!
:::

### 2. 通过 preload 暴露 `ipcRenderer.on` {#2-expose-ipcrendereron-via-preload}

与之前的渲染进程到主进程示例一样,我们在 preload 脚本中使用 `contextBridge` 和 `ipcRenderer` 模块,向渲染进程暴露 IPC 功能:

```js title='preload.js (Preload Script)'
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electronAPI', {
  onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value))
})

加载 preload 脚本后,你的渲染进程应能访问 window.electronAPI.onUpdateCounter() 监听函数。

:::caution 安全警告 出于[安全原因][],我们不会直接暴露整个 ipcRenderer.on API。请尽可能限制渲染进程访问 Electron API。也不要直接将回调传递给 ipcRenderer.on,因为这会通过 event.sender 泄露 ipcRenderer。请使用自定义处理函数,仅使用所需参数调用 callback。 :::

:::info 对于这个极简示例,你可以直接在 preload 脚本中调用 ipcRenderer.on,而不是通过上下文桥暴露它。

preload.js (Preload Script)
const { ipcRenderer } = require('electron')

window.addEventListener('DOMContentLoaded', () => {
  const counter = document.getElementById('counter')
  ipcRenderer.on('update-counter', (_event, value) => {
    const oldValue = Number(counter.innerText)
    const newValue = oldValue + value
    counter.innerText = newValue
  })
})

然而,与通过上下文桥暴露 preload API 相比,这种方法的灵活性有限,因为你的监听器无法直接与渲染进程代码交互。 :::

3. 构建渲染进程 UI

为了将所有内容串联起来,我们将在加载的 HTML 文件中创建一个界面,其中包含一个 #counter 元素,我们将用它来显示值:

```html {10} title='index.html'

Menu Counter Current value: 0
最后,为了让值在 HTML 文档中更新,我们将添加几行 DOM 操作,以便每当触发 `update-counter` 事件时,`#counter` 元素的值都会更新。

```js title='renderer.js (Renderer Process)' @ts-window-type={electronAPI:{onUpdateCounter:(callback:(value:number)=>void)=>void}}
const counter = document.getElementById('counter')

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

在上面的代码中,我们将一个回调传入从 preload 脚本暴露的 window.electronAPI.onUpdateCounter 函数。第二个 value 参数对应我们从原生菜单的 webContents.send 调用中传入的 1 或 -1。

可选:返回回复

对于主进程到渲染进程的 IPC,没有与 ipcRenderer.invoke 等效的机制。相反,你可以在 ipcRenderer.on 回调中向主进程发送回复。

我们可以通过对上一个示例的代码进行少量修改来演示这一点。在渲染进程中,暴露另一个 API,通过 counter-value 通道向主进程发送回复。

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

contextBridge.exposeInMainWorld('electronAPI', {
  onUpdateCounter: (callback) => ipcRenderer.on('update-counter', (_event, value) => callback(value)),
  counterValue: (value) => ipcRenderer.send('counter-value', value)
})

```js title='renderer.js (Renderer Process)' @ts-window-type={electronAPI:{onUpdateCounter:(callback:(value:number)=>void)=>void,counterValue:(value:number)=>void}} 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) })

在主进程中,监听 `counter-value` 事件并适当处理。

```js title='main.js (Main Process)'
// ...
ipcMain.on('counter-value', (_event, value) => {
  console.log(value) // will print value to Node console
})
// ...

模式 4:渲染进程到渲染进程

在 Electron 中,使用 ipcMain 和 ipcRenderer 模块无法直接在渲染进程之间发送消息。要实现这一点,你有两个选择:

  • 使用主进程作为渲染进程之间的消息代理。这涉及将一个渲染进程中的消息发送到主进程,然后由主进程将该消息转发到另一个渲染进程。
  • 将 MessagePort 从主进程传递给两个渲染进程。初始设置完成后,这将允许渲染进程之间直接通信。

对象序列化

Electron 的 IPC 实现使用 HTML 标准 Structured Clone Algorithm 来序列化在进程之间传递的对象,这意味着只有特定类型的对象才能通过 IPC 通道传递。

特别是,DOM 对象(例如 Element、Location 和 DOMMatrix)、由 C++ 类支持的 Node.js 对象(例如 process.env、Stream 的一些成员),以及由 C++ 类支持的 Electron 对象(例如 WebContents、BrowserWindow 和 WebFrame)都无法使用 Structured Clone 进行序列化。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 el/electron