跳到主要内容

进程间通信

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

IPC 通道

在 Electron 中,进程通过使用 ipcMainipcRenderer 模块在开发者定义的“通道”中传递消息进行通信。 这些通道是 任意的 (你可以给它们起任何你想要的名字) 并且是 双向的 (你可以对两个模块使用相同的通道名)。

在本指南中,我们将介绍一些基本的 IPC 模式,并提供具体的示例,你可以将其作为应用程序代码的参考。

理解上下文隔离的进程

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

  • 要全面了解 Electron 的进程模型,你可以阅读 进程模型文档
  • 要初步了解如何使用 contextBridge 模块从预加载脚本公开 API,请查阅 上下文隔离教程

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

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

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

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

const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')

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

ipcMain.on('set-title', (event, title) => {
const webContents = event.sender
const win = BrowserWindow.fromWebContents(webContents)
win.setTitle(title)
})

mainWindow.loadFile('index.html')
}

app.whenReady().then(() => {
createWindow()

app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})

1. 使用 ipcMain.on 监听事件

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

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

上面的 handleSetTitle 回调函数有两个参数:一个 IpcMainEvent 结构体和一个 title 字符串。 每当有消息通过 set-title 通道传来时,此函数将查找附加到消息发送者的 BrowserWindow 实例,并对其使用 win.setTitle API。

信息

确保你为以下步骤加载了 index.htmlpreload.js 入口文件!

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

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

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

preload.js (预加载脚本)
const { contextBridge, ipcRenderer } = require('electron')

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

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

安全警告

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

3. 构建渲染进程 UI

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

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://mdn.org.cn/en-US/docs/Web/HTTP/CSP -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
<title>Hello World!</title>
</head>
<body>
Title: <input id="title"/>
<button id="btn" type="button">Set</button>
<script src="./renderer.js"></script>
</body>
</html>

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

renderer.js (渲染进程)
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.invokeipcMain.handle 配对实现。

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

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

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()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})

1. 使用 ipcMain.handle 监听事件

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

关于错误处理

通过主进程中的 handle 抛出的错误不是透明的,因为它们被序列化了,并且只将原始错误的 message 属性提供给渲染进程。 有关详细信息,请参阅 #24427

main.js (主进程)
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()
})
// ...
关于通道名称

IPC 通道名称上的 dialog: 前缀对代码没有影响。 它仅用作帮助提高代码可读性的命名空间。

信息

确保你为以下步骤加载了 index.htmlpreload.js 入口文件!

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

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

preload.js (预加载脚本)
const { contextBridge, ipcRenderer } = require('electron')

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

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

3. 构建渲染进程 UI

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

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://mdn.org.cn/en-US/docs/Web/HTTP/CSP -->
<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>

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

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
})

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

注意:旧方法

ipcRenderer.invoke API 是在 Electron 7 中添加的,作为一种对开发者友好的方式来处理从渲染进程到主进程的双向 IPC。 但是,此 IPC 模式存在几种替代方法。

如果可能,避免使用旧方法

我们建议尽可能使用 ipcRenderer.invoke。以下双向渲染进程到主进程模式是为了历史目的而记录的。

信息

对于以下示例,我们将直接从预加载脚本中调用 ipcRenderer 以保持代码示例简洁。

使用 ipcRenderer.send

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

preload.js (预加载脚本)
// 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 (主进程)
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 (主进程)
const { ipcMain } = require('electron')
ipcMain.on('synchronous-message', (event, arg) => {
console.log(arg) // prints "ping" in the Node console
event.returnValue = 'pong'
})
preload.js (预加载脚本)
// 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 相同。

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

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

const { app, BrowserWindow, Menu, ipcMain } = 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: [
{
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')

// Open the DevTools.
mainWindow.webContents.openDevTools()
}

app.whenReady().then(() => {
ipcMain.on('counter-value', (_event, value) => {
console.log(value) // will print value to Node console
})
createWindow()

app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})

app.on('window-all-closed', function () {
if (process.platform !== 'darwin') app.quit()
})

1. 使用 webContents 模块发送消息

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

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: [
{
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)。

click: () => mainWindow.webContents.send('update-counter', -1)
信息

确保你为以下步骤加载了 index.htmlpreload.js 入口文件!

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

与之前的渲染进程到主进程示例一样,我们在预加载脚本中使用 contextBridgeipcRenderer 模块向渲染进程暴露 IPC 功能

preload.js (预加载脚本)
const { contextBridge, ipcRenderer } = require('electron')

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

加载预加载脚本后,你的渲染进程应该可以访问 window.electronAPI.onUpdateCounter() 监听器函数。

安全警告

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

信息

在这个最小示例中,你可以直接在预加载脚本中调用 ipcRenderer.on,而无需通过上下文桥暴露它。

preload.js (预加载脚本)
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
})
})

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

3. 构建渲染进程 UI

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

index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<!-- https://mdn.org.cn/en-US/docs/Web/HTTP/CSP -->
<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>

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

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.onUpdateCounter 函数。 第二个参数 value 对应于我们从原生菜单的 webContents.send 调用中传入的 1-1

可选:返回回复

主进程到渲染进程的 IPC 没有等效于 ipcRenderer.invoke 的方法。 相反,你可以从 ipcRenderer.on 回调函数中向主进程发送回复。

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

preload.js (预加载脚本)
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)
})
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)
})

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

main.js (主进程)
// ...
ipcMain.on('counter-value', (_event, value) => {
console.log(value) // will print value to Node console
})
// ...

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

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

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

对象序列化

Electron 的 IPC 实现使用 HTML 标准的 结构化克隆算法 来序列化进程之间传递的对象,这意味着只有某些类型的对象可以通过 IPC 通道传递。

特别是,DOM 对象 (例如 ElementLocationDOMMatrix)、由 C++ 类支持的 Node.js 对象 (例如 process.envStream 的某些成员) 以及由 C++ 类支持的 Electron 对象 (例如 WebContentsBrowserWindowWebFrame) 无法使用结构化克隆进行序列化。