跳到主内容

Electron 中的 MessagePort

MessagePort 是一种网络特性,允许在不同上下文之间传递消息。 它类似于 window.postMessage,但在不同的通道上。 本文档旨在描述 Electron 如何扩展通道消息传递模型,并提供一些在应用中使用 MessagePorts 的示例。

这是一个关于 MessagePort 是什么以及它是如何工作的非常简短的例子:

renderer.js (渲染进程)
// MessagePorts are created in pairs. A connected pair of message ports is
// called a channel.
const channel = new MessageChannel()

// The only difference between port1 and port2 is in how you use them. Messages
// sent to port1 will be received by port2 and vice-versa.
const port1 = channel.port1
const port2 = channel.port2

// It's OK to send a message on the channel before the other end has registered
// a listener. Messages will be queued until a listener is registered.
port2.postMessage({ answer: 42 })

// Here we send the other end of the channel, port1, to the main process. It's
// also possible to send MessagePorts to other frames, or to Web Workers, etc.
ipcRenderer.postMessage('port', null, [port1])
main.js (主进程)
// In the main process, we receive the port.
ipcMain.on('port', (event) => {
// When we receive a MessagePort in the main process, it becomes a
// MessagePortMain.
const port = event.ports[0]

// MessagePortMain uses the Node.js-style events API, rather than the
// web-style events API. So .on('message', ...) instead of .onmessage = ...
port.on('message', (event) => {
// data is { answer: 42 }
const data = event.data
})

// MessagePortMain queues messages until the .start() method has been called.
port.start()
})

Channel Messaging API 文档是了解 MessagePort 工作原理的好途径。

主进程中的 MessagePort

在渲染器中,MessagePort 类的行为与在 Web 上完全一致。 但是,主进程不是一个网页——它没有集成 Blink——因此它没有 MessagePortMessageChannel 类。 为了在主进程中处理和交互 MessagePort,Electron 添加了两个新类:MessagePortMainMessageChannelMain。 这些类的行为类似于渲染器中的相应类。

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

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

扩展:close 事件

为了让 MessagePort 更有用,Electron 在 MessagePort 中添加了一个 Web 上没有的特性。 这就是 close 事件,当通道的另一端关闭时触发。 端口也可以通过垃圾回收隐式关闭。

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

示例用例

在两个渲染器之间建立 MessageChannel

在这个示例中,主进程建立一个 MessageChannel,然后将每个端口发送到不同的渲染器。 这使得渲染器能够互相发送消息,而无需使用主进程作为中间人。

main.js (主进程)
const { BrowserWindow, app, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// create the windows.
const mainWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadMain.js'
}
})

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

// set up the channel.
const { port1, port2 } = new MessageChannelMain()

// once the webContents are ready, send a port to each webContents with postMessage.
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 和 preloadSecondary.js (预加载脚本)
const { ipcRenderer } = require('electron')

ipcRenderer.on('port', e => {
// port received, make it globally available.
window.electronMessagePort = e.ports[0]

window.electronMessagePort.onmessage = messageEvent => {
// handle message
}
})

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

这意味着 window.electronMessagePort 是全局可用的,您可以在应用中的任何位置在其上调用 postMessage,将消息发送到另一个渲染器。

renderer.js (渲染进程)
// elsewhere in your code to send a message to the other renderers message handler
window.electronMessagePort.postMessage('ping')

Worker 进程

在此示例中,您的应用有一个作为隐藏窗口实现的 worker 进程。 您希望应用页面能够直接与 worker 进程通信,而无需通过主进程中继产生的性能开销。

main.js (主进程)
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// The worker process is a hidden BrowserWindow, so that it will have access
// to a full Blink context (including e.g. <canvas>, audio, fetch(), etc.)
const worker = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true }
})
await worker.loadFile('worker.html')

// The main window will send work to the worker process and receive results
// over a MessagePort.
const mainWindow = new BrowserWindow({
webPreferences: { nodeIntegration: true }
})
mainWindow.loadFile('app.html')

// We can't use ipcMain.handle() here, because the reply needs to transfer a
// MessagePort.
// Listen for message sent from the top-level frame
mainWindow.webContents.mainFrame.ipc.on('request-worker-channel', (event) => {
// Create a new channel ...
const { port1, port2 } = new MessageChannelMain()
// ... send one end to the worker ...
worker.webContents.postMessage('new-client', null, [port1])
// ... and the other end to the main window.
event.senderFrame.postMessage('provide-worker-channel', null, [port2])
// Now the main window and the worker can communicate with each other
// without going through the main process!
})
})
worker.html
<script>
const { ipcRenderer } = require('electron')

const doWork = (input) => {
// Something cpu-intensive.
return input * 2
}

// We might get multiple clients, for instance if there are multiple windows,
// or if the main window reloads.
ipcRenderer.on('new-client', (event) => {
const [ port ] = event.ports
port.onmessage = (event) => {
// The event data can be any serializable object (and the event could even
// carry other MessagePorts with it!)
const result = doWork(event.data)
port.postMessage(result)
}
})
</script>
app.html
<script>
const { ipcRenderer } = require('electron')

// We request that the main process sends us a channel we can use to
// communicate with the worker.
ipcRenderer.send('request-worker-channel')

ipcRenderer.once('provide-worker-channel', (event) => {
// Once we receive the reply, we can take the port...
const [ port ] = event.ports
// ... register a handler to receive results ...
port.onmessage = (event) => {
console.log('received result:', event.data)
}
// ... and start sending it work!
port.postMessage(21)
})
</script>

回复流

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

renderer.js (渲染进程)
const makeStreamingRequest = (element, callback) => {
// MessageChannels are lightweight--it's cheap to create a new one for each
// request.
const { port1, port2 } = new MessageChannel()

// We send one end of the port to the main process ...
ipcRenderer.postMessage(
'give-me-a-stream',
{ element, count: 10 },
[port2]
)

// ... and we hang on to the other end. The main process will send messages
// to its end of the port, and close it when it's finished.
port1.onmessage = (event) => {
callback(event.data)
}
port1.onclose = () => {
console.log('stream ended')
}
}

makeStreamingRequest(42, (data) => {
console.log('got response data:', data)
})
// We will see "got response data: 42" 10 times.
main.js (主进程)
ipcMain.on('give-me-a-stream', (event, msg) => {
// The renderer has sent us a MessagePort that it wants us to send our
// response over.
const [replyPort] = event.ports

// Here we send the messages synchronously, but we could just as easily store
// the port somewhere and send messages asynchronously.
for (let i = 0; i < msg.count; i++) {
replyPort.postMessage(msg.element)
}

// We close the port when we're done to indicate to the other end that we
// won't be sending any more messages. This isn't strictly necessary--if we
// didn't explicitly close the port, it would eventually be garbage
// collected, which would also trigger the 'close' event in the renderer.
replyPort.close()
})

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

当启用上下文隔离时,从主进程到渲染器的 IPC 消息被传递到隔离世界,而不是主世界。 有时您希望将消息直接传递到主世界,而无需通过隔离世界。

main.js (主进程)
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()

// It's OK to send a message on the channel before the other end has
// registered a listener. Messages will be queued until a listener is
// registered.
port2.postMessage({ test: 21 })

// We can also receive messages from the main world of the renderer.
port2.on('message', (event) => {
console.log('from renderer main world:', event.data)
})
port2.start()

// The preload script will receive this IPC message and transfer the port
// over to the main world.
bw.webContents.postMessage('main-world-port', null, [port1])
})
preload.js (预加载脚本)
const { ipcRenderer } = require('electron')

// We need to wait until the main world is ready to receive the message before
// sending the port. We create this promise in the preload so it's guaranteed
// to register the onload listener before the load event is fired.
const windowLoaded = new Promise(resolve => {
window.onload = resolve
})

ipcRenderer.on('main-world-port', async (event) => {
await windowLoaded
// We use regular window.postMessage to transfer the port from the isolated
// world to the main world.
window.postMessage('main-world-port', '*', event.ports)
})
index.html
<script>
window.onmessage = (event) => {
// event.source === window means the message is coming from the preload
// script, as opposed to from an <iframe> or other source.
if (event.source === window && event.data === 'main-world-port') {
const [ port ] = event.ports
// Once we have the port, we can communicate directly with the main
// process.
port.onmessage = (event) => {
console.log('from main process:', event.data)
port.postMessage(event.data.test * 2)
}
}
}
</script>