メインコンテンツにスキップ

Electron の MessagePort

MessagePort は、異なるコンテキスト間でメッセージを送受信する Web 機能です。window.postMessage のようなもので、異なるチャネルで使用されます。このドキュメントの目的は、Electron がチャネルメッセージモデルをどのように拡張するかについて説明し、MessagePort をアプリで使用する方法の例を示すことです。

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

チャネルメッセージ API のドキュメントは、MessagePort の仕組みについて詳しく知るための優れた方法です。

メインプロセスの MessagePort

レンダラーでは、MessagePort クラスは Web での場合とまったく同じように動作します。ただし、メインプロセスは Web ページではなく、Blink と統合されていないため、MessagePort クラスあるいは MessageChannel クラスはありません。メインプロセスで MessagePort を処理して対話するために、Electron には 2 つの新しいクラス、MessagePortMainMessageChannelMain が追加されました。これらもレンダラーの対応するクラスと同様に動作します。

MessagePort オブジェクトは、レンダラープロセスまたはメインプロセスで作成され、ipcRenderer.postMessage および WebContents.postMessage メソッドを使用して、送受信できます。通常の IPC メソッド(sendinvoke など)は MessagePort を転送するために使用できません。MessagePort を転送できるのは postMessage メソッドのみです。

MessagePort をメインプロセスを介して渡すことで、そうしなければ通信できない可能性のある 2 つのページ(同一生成元の制限のためなど)を接続できます。

拡張機能: close イベント

Electron は MessagePort の用途を広げるために、Web には存在しない 1 つの機能を MessagePort に追加しています。それは、チャネルのもう一端が閉じられたときに発生する close イベントです。また、ポートはガベージコレクションによって暗黙的に閉じられる可能性があります。

レンダラーでは、port.onclose に割り当てるか、port.addEventListener('close', ...) を呼び出すことで close イベントをリッスンできます。メインプロセスでは、port.on('close', ...) を呼び出すことで close イベントをリッスンできます。

ユースケースの例

2 つのレンダラーの間に 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 コールを設定することをお勧めしますが、この例の簡潔さのためにそうしていません。context isolation の例は、このページの コンテキスト分離されたページのメインプロセスとメインワールドの間で直接通信する の下にあります。

つまり、window.electronMessagePort はグローバルで使用でき、アプリ内のどこからでも postMessage を呼び出して別のレンダラーにメッセージを送信できます。

renderer.js(レンダラープロセス)
// elsewhere in your code to send a message to the other renderers message handler
window.electronMessagePort.postMessage('ping')

ワーカープロセス

この例では、アプリには、非表示のウィンドウとして実装されたワーカープロセスがあります。メインプロセスを介して中継するパフォーマンスオーバーヘッドなしに、アプリページがワーカープロセスと直接通信できるようにする必要があります。

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 メソッドは、2 つのモードのみをサポートしています。送信完了(例: 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>