メインコンテンツへ飛ぶ

Electron での MessagePort

MessagePort は、異なるコンテキスト間でメッセージを受け渡すことができるウェブ機能です。 window.postMessage に似ていますが、こちらはチャンネルが別々になります。 このドキュメントの目的は、Electron で拡張した Channel Messaging モデルの説明と、アプリ内での MessagePort の使用方法の例示です。

MessagePort がどのようなものでどのように動作するのかを、以下で簡単に説明します。

renderer.js (Renderer Process)
// 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])
main.js (Main Process)
// メインプロセスでは、そのポートを受け取ります。
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 と統合していないので MessagePortMessageChannel のクラスがありません。 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. 注意として、sendinvoke のような通常の IPC メソッドは MessagePort の転送に使用できず、postMessage メソッドだけが MessagePort を転送できます。

メインプロセス経由で MessagePort を渡すと、他の方法では (同一オリジン制限などのため) 通信できないかもしれない 2 つのページを接続できます。

拡張: close イベント

Electron は MessagePort をより便利にするため、ウェブにない機能を追加しました。 それは、チャンネルの反対側が閉じられたときに発火する close イベントです。 ポートはガベージコレクションによって暗黙的に閉じることもあります。

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

ユースケース例

2つのレンダラー間で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)
// コード内の他の場所で、他のレンダラーのメッセージハンドラへメッセージを送信します
window.electronMessagePort.postMessage('ping')

ワーカープロセス

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

main.js (Main Process)
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])
// これでメインウインドウとワーカーがメインプロセスを介さずに
// 通信できるようになりました!
})
})
worker.html
<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>
app.html
<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 を使用すれば、一つのリクエストに対してデータのストリームで応答する "応答ストリーム" を実装できます。

renderer.js (Renderer Process)
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 回見えるでしょう。
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()
})

コンテキストが分離されたページのメインプロセスとメインワールド間で直接やり取りする

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. 分離したワールドを介さずに、メインワールドへ直接メッセージを送りたいこともあるでしょう。

main.js (Main Process)
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])
})
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>