Electron での MessagePort
MessagePort
は、異なるコンテキスト間でメッセージを受け渡すことができるウェブ機能です。 window.postMessage
に似ていますが、こちらはチャンネルが別々になります。 このドキュメントの目的は、Electron で拡張した Channel Messaging モデルの説明と、アプリ内での MessagePort の使用方法の例示です。
MessagePort がどのようなものでどのように動作するのかを、以下で簡単に説明します。
// MessagePort はペアで作成されます。 接続されたメッセージポートのペアを
// チャンネルといいます。
const channel = new MessageChannel()
// port1 と port2 の違いは、その使い方だけです。 port1 に
// 送信されたメッセージは port2 で受信され、逆も同様です。
const port1 = channel.port1
const port2 = channel.port2
// 受信側がリスナーを登録する前にそのチャンネルへメッセージを送信しても
// 大丈夫です。 リスナーが登録されるまでメッセージはキューに溜められます。
port2.postMessage({ answer: 42 })
// ここでチャネルの他方である port1 をメインプロセスに送信します。 MessagePort を
// 他のフレームや Web Worker などに送信することも可能です。
ipcRenderer.postMessage('port', null, [port1])
// メインプロセスでは、そのポートを受け取ります。
ipcMain.on('port', (event) => {
// メインプロセスで MessagePort を受信すると、それは
// MessagePortMain に変化します。
const port = event.ports[0]
// MessagePortMain はウェブ方式のイベント API ではなく
// Node.js 方式のイベント API を使用しています。 そのため .onmessage = ... ではなく .on('message', ...) とします。
port.on('message', (event) => {
// data は { answer: 42 }
const data = event.data
})
// MessagePortMain は .start() メソッドが呼ばれるまでメッセージをキューに溜めます。
port.start()
})
Channel Messaging API のドキュメントは、MessagePort の動作原理をより詳しく知るのによいでしょう。
メインプロセスでの MessagePort
レンダラーでの MessagePort
クラスは、ウェブ上とまったく同じように動作します。 メインプロセスはウェブページではありませんが、Blink と統合していないので MessagePort
や MessageChannel
のクラスがありません。 In order to handle and interact with MessagePorts in the main process, Electron adds two new classes: MessagePortMain
and MessageChannelMain
. これらはレンダラーの類似クラスと同様に動作します。
MessagePort
objects can be created in either the renderer or the main process, and passed back and forth using the ipcRenderer.postMessage
and WebContents.postMessage
methods. 注意として、send
や invoke
のような通常の IPC メソッドは MessagePort
の転送に使用できず、postMessage
メソッドだけが MessagePort
を転送できます。
メインプロセス経由で MessagePort
を渡すと、他の方法では (同一オリジン制限などのため) 通信できないかもしれない 2 つのページを接続できます。
拡張: close
イベント
Electron は MessagePort
をより便利にするため、ウェブにない機能を追加しました。 それは、チャンネルの反対側が閉じられたときに発火する close
イベントです。 ポートはガベージコレクションによって暗黙的に閉じることもあります。
レンダラーでは、port.onclose
に代入するか port.addEventListener('close', ...)
を呼ぶことで close
イベントをリッスンできます。 メインプロセスでは、port.on('close', ...)
を呼ぶことで close
イベントをリッスンできます。
ユースケース例
2つのレンダラー間でMessageChannelを設定する
この例では、メインプロセスは MessageChannel を設定し、それぞれのポートを異なるレンダラーに送信します。 これにより、レンダラーはメインプロセスを中間プロセスとして使用せずに相互にメッセージを送信することができます。
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を通じてポートを受信し、リスナーを設定します。
const { ipcRenderer } = require('electron')
ipcRenderer.on('port', e => {
// ポートを受信できたら、グローバルに利用可能とします。
window.electronMessagePort = e.ports[0]
window.electronMessagePort.onmessage = messageEvent => {
// メッセージをハンドルします
}
})
この例では messagePort は window
オブジェクトに直接バインドされています。 contextIsolation
を使用し、期待するメッセージ毎に特定の contextBridge の呼び出しを設定した方がよいですが、この例では簡単のために設定しません。 コンテキスト分離の例はこのページの下にある コンテキストが分離されたページのメインプロセスとメインワールド間で直接やり取りする にあります。
すなわち、 window.electronMessagePort がグローバルに利用可能であり、アプリ内のどこからでも postMessage
を呼び出すことで、他のレンダラーにメッセージを送信できることを意味します。
// コード内の他の場所で、他のレンダラーのメッセージハンドラへメッセージを送信します
window.electronMessagePort.postMessage('ping')
ワーカープロセス
この例では、アプリに隠しウインドウとして実装されたワーカープロセスがあります。 メインプロセスを介して中継する際のパフォーマンスオーバーヘッドをなくして、アプリのページとワーカープロセスが直接通信できるようにしたいとします。
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')
app.whenReady().then(async () => {
// ワーカープロセスは隠し BrowserWindow であるため、Blink のすべての
// コンテキスト (<canvas>, audio, fetch() などを含む) にアクセスできます。
const worker = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true }
})
await worker.loadFile('worker.html')
// メインウインドウはワーカープロセスへ仕事を送り、
// MessagePort を介して結果を受信します。
const mainWindow = new BrowserWindow({
webPreferences: { nodeIntegration: true }
})
mainWindow.loadFile('app.html')
// 返信で MessagePort を転送する必要があるので、
// ここで ipcMain.handle() は使えません。
// 最上位フレームから送られるメッセージをリッスンします
mainWindow.webContents.mainFrame.ipc.on('request-worker-channel', (event) => {
// 新しいチャンネルを作成します ...
const { port1, port2 } = new MessageChannelMain()
// ...片方をワーカーに送り...
worker.webContents.postMessage('new-client', null, [port1])
// ... そしてもう一方をメインウインドウへ送ります。
event.senderFrame.postMessage('provide-worker-channel', null, [port2])
// これでメインウインドウとワーカーがメインプロセスを介さずに
// 通信できるようになりました!
})
})
<script>
const { ipcRenderer } = require('electron')
const doWork = (input) => {
// CPU の力が必要なこと。
return input * 2
}
// 複数のウィンドウがある、メインウインドウがリロードされた、
// などの場合に複数クライアントを取得することがあります。
ipcRenderer.on('new-client', (event) => {
const [ port ] = event.ports
port.onmessage = (event) => {
// イベントデータは任意のシリアライズ可能なオブジェクトにできます
// (イベントに他の MessagePort を載せることもできます!)
const result = doWork(event.data)
port.postMessage(result)
}
})
</script>
<script>
const { ipcRenderer } = require('electron')
// ワーカーとの通信に使用できるチャンネルを送るように
// メインプロセスへ要求します。
ipcRenderer.send('request-worker-channel')
ipcRenderer.once('provide-worker-channel', (event) => {
// 返信を受け取ったら、ポートを取り出し...
const [ port ] = event.ports
// ...結果を受信するハンドラを登録したら...
port.onmessage = (event) => {
console.log('received result:', event.data)
}
// ...メッセージを送信して動かしましょう!
port.postMessage(21)
})
</script>
ストリームの返信
Electron の組み込み IPC メソッドは、一方向通信 (send
など) と送信に対する返信 (invoke
など) の 2 つのモードしかサポートしていません。 MessageChannel を使用すれば、一つのリクエストに対してデータのストリームで応答する "応答ストリーム" を実装できます。
const makeStreamingRequest = (element, callback) => {
// 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 回見えるでしょう。
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()
})
コンテキストが分離されたページのメインプロセスとメインワールド間で直接やり取りする
When context isolation is enabled, IPC messages from the main process to the renderer are delivered to the isolated world, rather than to the main world. 分離したワールドを介さずに、メインワールドへ直接メッセージを送りたいこともあるでしょう。
const { BrowserWindow, app, MessageChannelMain } = require('electron')
const path = require('node:path')
app.whenReady().then(async () => {
// contextIsolation が有効な BrowserWindow を作成します。
const bw = new BrowserWindow({
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
})
bw.loadURL('index.html')
// この一方のチャンネルを、コンテキスト分離ページの
// メインワールドへ送ります。
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])
})
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)
})
<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>