跳转到主内容

Electron 中的消息端口

MessagePort是一个允许在不同上下文之间传递消息的Web功能。 就像 window.postMessage, 但是在不同的通道上。 此文档的目标是描述 Electron 如何扩展 Channel Messaging model ,并举例说明如何在应用中使用 MessagePorts

下面是 MessagePort 是什么和如何工作的一个非常简短的例子:

renderer.js (Renderer Process)
// 消息端口是成对创建的。 连接的一对消息端口
// 被称为通道。
const channel = new MessageChannel()

// port1 和 port2 之间唯一的不同是你如何使用它们。 消息
// 发送到port1 将被port2 接收,反之亦然。
const port1 = channel.port1
const port2 = channel.port2

// 允许在另一端还没有注册监听器的情况下就通过通道向其发送消息
// 消息将排队等待,直到一个监听器注册为止。
port2.postMessage({ answer: 42 })

// 这次我们通过 ipc 向主进程发送 port1 对象。 类似的,
// 我们也可以发送 MessagePorts 到其他 frames, 或发送到 Web Workers, 等.
ipcRenderer.postMessage('port', null, [port1])
main.js (Main Process)
// 在主进程中,我们接收端口对象。
ipcMain.on('port', (event) => {
// 当我们在主进程中接收到 MessagePort 对象, 它就成为了
// MessagePortMain.
const port = event.ports[0]

// MessagePortMain 使用了 Node.js 风格的事件 API, 而不是
// web 风格的事件 API. 因此使用 .on('message', ...) 而不是 .onmessage = ...
port.on('message', (event) => {
// 收到的数据是: { answer: 42 }
const data = event.data
})

// MessagePortMain 阻塞消息直到 .start() 方法被调用
port.start()
})

关于 channel 消息接口的使用文档详见 Channel Messaging API

主进程中的 MessagePorts

在渲染器中, MessagePort 类的行为与它在 web 上的行为完全一样。 但是,主进程不是网页(它没有 Blink 集成),因此它没有 MessagePortMessageChannel 类。 为了在主进程中处理 MessagePorts 并与之交互,Electron 添加了两个新类: MessagePortMainMessageChannelMain。 这些行为 类似于渲染器中 analogous 类。

MessagePort 对象可以在渲染器或主 进程中创建,并使用 ipcRenderer.postMessageWebContents.postMessage 方法互相传递。 请注意,通常的 IPC 方法,例如 sendinvoke 不能用来传输 MessagePort, 只有 postMessage 方法可以传输 MessagePort

通过主进程传递 MessagePort,就可以连接两个可能无法通信的页面 (例如,由于同源限制) 。

扩展: close 事件

Electron在 MessagePort 添加了一个在Web上本不存在的功能,以使MessagePort更加好用。 这个功能就是 close 事件, 在通道的另一端关闭时会触发该事件。 端口也可以通过垃圾回收而隐式关闭。

在渲染进程中,你可以通过将事件分配给port.onclose 或调用 port.addEventListener('close', ...) 来监听 close 事件。 在主进程中,你可以通过调用 port.on('close', ...) 来监听 close 事件。

实例使用

在两个渲染进程之间建立 MessageChannel

在这个示例中,主进程设置了一个MessageChannel,然后将每个端口发送给不同的渲染进程。 这样可以让渲染进程彼此之间发送消息,而无需使用主进程作为中转。

main.js (Main Process)
const { BrowserWindow, app, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// 创建窗口
const mainWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadMain.js'
}
})

const secondaryWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadSecondary.js'
}
})

// 建立通道
const { port1, port2 } = new MessageChannelMain()

// webContents准备就绪后,使用postMessage向每个webContents发送一个端口。
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.postMessage('port', null, [port1])
})

secondaryWindow.once('ready-to-show', () => {
secondaryWindow.webContents.postMessage('port', null, [port2])
})
})

接下来,在你的预加载脚本中通过IPC接收端口,并设置相应的监听器。

preloadMain.js and preloadSecondary.js (Preload scripts)
const { ipcRenderer } = require('electron')

ipcRenderer.on('port', e => {
// 接收到端口,使其全局可用。
window.electronMessagePort = e.ports[0]

window.electronMessagePort.onmessage = messageEvent => {
// 处理消息
}
})

在这个示例中,messagePort 直接绑定到了 window 对象上。 更好的方法是使用 contextIsolation,并为每个预期的消息设置特定的 contextBridge 调用, 但为了示例简洁,这里没有这样做。 你可以在本页面下方的 直接在上下文隔离页面的主进程和主世界之间进行通信部分找到一个上下文隔离的示例。

这意味着 window.electronMessagePort 在全局范围内可用,你可以在应用程序的任何地方调用postMessage 方法,以便向另一个渲染进程发送消息。

renderer.js (Renderer Process)
// elsewhere in your code to send a message to the other renderers message handler
window.electronMessagePort.postMessage('ping')

Worker进程

在这个示例中,你的应用程序有一个作为隐藏窗口存在的 Worker 进程。 你希望应用程序页面能够直接与 Worker 进程通信,而不需要通过主进程进行中继,以避免性能开销。

main.js (Main Process)
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// Worker 进程是一个隐藏的 BrowserWindow
// 它具有访问完整的Blink上下文(包括例如 canvas、音频、fetch()等)的权限
const worker = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true }
})
await worker.loadFile('worker.html')

// main window 将发送内容给 worker process 同时通过 MessagePort 接收返回值
const mainWindow = new BrowserWindow({
webPreferences: { nodeIntegration: true }
})
mainWindow.loadFile('app.html')

// 在这里我们不能使用 ipcMain.handle() , 因为回复需要传输
// MessagePort.
// 监听从顶级 frame 发来的消息
mainWindow.webContents.mainFrame.ipc.on('request-worker-channel', (event) => {
// 建立新通道 ...
const { port1, port2 } = new MessageChannelMain()
// ... 将其中一个端口发送给 Worker ...
worker.webContents.postMessage('new-client', null, [port1])
// ... 将另一个端口发送给主窗口
event.senderFrame.postMessage('provide-worker-channel', null, [port2])
// 现在主窗口和工作进程可以直接相互通信,无需经过主进程!
})
})
worker.html
<script>
const { ipcRenderer } = require('electron')

const doWork = (input) => {
// 一些对CPU要求较高的任务
return input * 2
}

// 我们可能会得到多个 clients, 比如有多个 windows,
// 或者假如 main window 重新加载了.
ipcRenderer.on('new-client', (event) => {
const [ port ] = event.ports
port.onmessage = (event) => {
// 事件数据可以是任何可序列化的对象 (事件甚至可以
// 携带其他 MessagePorts 对象!)
const result = doWork(event.data)
port.postMessage(result)
}
})
</script>
app.html
<script>
const { ipcRenderer } = require('electron')

// 我们请求主进程向我们发送一个通道
// 以便我们可以用它与 Worker 进程建立通信
ipcRenderer.send('request-worker-channel')

ipcRenderer.once('provide-worker-channel', (event) => {
// 一旦收到回复, 我们可以这样做...
const [ port ] = event.ports
// ... 注册一个接收结果处理器 ...
port.onmessage = (event) => {
console.log('received result:', event.data)
}
// ... 并开始发送消息给 work!
port.postMessage(21)
})
</script>

回复流

Electron的内置IPC方法只支持两种模式:即发即弃(例如, send),或请求-响应(例如, invoke)。 使用MessageChannels,你可以实现一个“响应流”,其中单个请求可以返回一串数据。

renderer.js (Renderer Process)
const makeStreamingRequest = (element, callback) => {
// MessageChannels 是轻量的
// 为每个请求创建一个新的 MessageChannel 带来的开销并不大
const { port1, port2 } = new MessageChannel()

// 我们将端口的一端发送给主进程 ...
ipcRenderer.postMessage(
'give-me-a-stream',
{ element, count: 10 },
[port2]
)

// ... 保留另一端。 主进程将向其端口发送消息
// 并在完成后关闭它
port1.onmessage = (event) => {
callback(event.data)
}
port1.onclose = () => {
console.log('stream ended')
}
}

makeStreamingRequest(42, (data) => {
console.log('got response data:', data)
})
// 我们会看到 "got response data: 42" 出现了10次
main.js (Main Process)
ipcMain.on('give-me-a-stream', (event, msg) => {
// 渲染进程向我们发送了一个 MessagePort
// 并期望得到响应
const [replyPort] = event.ports

// 在这里,我们同步发送消息
// 我们也可以将端口存储在某个地方,异步发送消息
for (let i = 0; i < msg.count; i++) {
replyPort.postMessage(msg.element)
}

// 当我们处理完成后,关闭端口以通知另一端
// 我们不会再发送任何消息 这并不是严格要求的
// 如果我们没有显式地关闭端口,它最终会被垃圾回收
// 这也会触发渲染进程中的'close'事件
replyPort.close()
})

直接在上下文隔离页面的主进程和主世界之间进行通信

[context isolation][] 已启用。 IPC 消息从主进程发送到渲染器是发送到隔离的世界,而不是发送到主世界。 有时候你希望不通过隔离的世界,直接向主世界发送消息。

main.js (Main Process)
const { BrowserWindow, app, MessageChannelMain } = require('electron')
const path = require('node:path')

app.whenReady().then(async () => {
// Create a BrowserWindow with contextIsolation enabled.
const bw = new BrowserWindow({
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
})
bw.loadURL('index.html')

// We'll be sending one end of this channel to the main world of the
// context-isolated page.
const { port1, port2 } = new MessageChannelMain()

// 允许在另一端还没有注册监听器的情况下就通过通道向其发送消息 消息将排队等待,直到有一个监听器注册为止。
port2.postMessage({ test: 21 })

// 我们也可以接收来自渲染器主进程的消息。
port2.on('message', (event) => {
console.log('from renderer main world:', event.data)
})
port2.start()
// 预加载脚本将接收此 IPC 消息并将端口
// 传输到主进程。
bw.webContents.postMessage('main-world-port', null, [port1])
})
preload.js (Preload Script)
const { ipcRenderer } = require('electron')

// 在发送端口之前,我们需要等待主窗口准备好接收消息 我们在预加载时创建此 promise ,以此保证
// 在触发 load 事件之前注册 onload 侦听器。
const windowLoaded = new Promise(resolve => {
window.onload = resolve
})

ipcRenderer.on('main-world-port', async (event) => {
await windowLoaded
// 我们使用 window.postMessage 将端口
// 发送到主进程
window.postMessage('main-world-port', '*', event.ports)
})
index.html
<script>
window.onmessage = (event) => {
// event.source === window 意味着消息来自预加载脚本
// 而不是来自iframe或其他来源
if (event.source === window && event.data === 'main-world-port') {
const [ port ] = event.ports
// 一旦我们有了这个端口,我们就可以直接与主进程通信
port.onmessage = (event) => {
console.log('from main process:', event.data)
port.postMessage(event.data.test * 2)
}
}
}
</script>

[context isolation]: latest/tutorial/context-isolation. md