メインコンテンツへ飛ぶ

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 のクラスがありません。 メインプロセスで MessagePort をハンドルしてやり取りするために、Electron は 2 つの新しいクラス MessagePortMainMessageChannelMain を追加しています。 これらはレンダラーの類似クラスと同様に動作します。

MessagePort オブジェクトは、レンダラープロセスかメインプロセスのいずれかで作成し、ipcRenderer.postMessageWebContents.postMessage メソッドを使用して反対側へ送ります。 注意として、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()
})

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

コンテキスト分離 が有効になっている場合、メインプロセスからレンダラーへの IPC メッセージは、メインワールドではなく分離されたワールドへ送られます。 分離したワールドを介さずに、メインワールドへ直接メッセージを送りたいこともあるでしょう。

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>