JavaScriptのメッセージングをカプセル化する
WebExtensionはバックグラウンドスクリプト、コンテンツスクリプト、複数の拡張機能ページなどから構成され、それぞれのスクリプトで寿命や行える処理の内容が異なる。
ページに表示されたUIからバックグラウンドスクリプトでの処理を制御したい場合は、ページのスクリプトのスコープとバックグラウンドスクリプトのスコープとの間でメッセージのやり取りを行うことになる。
メインスレッドで重い処理を行うと、その間はメインスレッドがブロックされてページが反応しなくなる。
解決策として、イベントおよびWeb Workerを用いたマルチスレッド化が考えられる。
1. 重い処理を行うための関数をWeb Worker上で定義する。
2. メインスレッドはワーカースレッドにメッセージを送る(Worker.postMessage)。
3. ワーカースレッドでは、メッセージを受け取った際に1で定義された関数で重い処理を行い、処理の結果はメインスレッドにメッセージで送るようにしておく(DedicatedWorkerGlobalScope.postMessage)。
イベントやメッセージングの処理は見通しが悪くなることがある。mgn901.icon
code:ts
// メインスレッド
const worker = new Worker('/path/to/worker.js');
worker.postMessage({
type: 'sum',
});
worker.addEventListener('message', (message) => {
console.log(message);
});
code:ts
// ワーカースレッド
// isNumberArray は引数が number[] であるかを調べる関数であるとする。
const globalScope = self as DedicatedWorkerGlobalScope;
globalScope.addEventListener('message', (message) => {
if (message.type === 'sum' && isNumberArray(message.numbers)) {
globalScope.postMessage(message.numbers.reduce((prev, next) => prev + next));
}
});
見通しをよくする方法 mgn901.icon
メインスレッドからは、非同期関数を呼び出すだけで、ワーカースレッド内での処理結果を得られるようにする。
ワーカースレッドからは、何らかのクラスや関数に実際の処理を行うための関数を渡すだけで、メインスレッドからの呼び出しを受けられるようにする。
上を踏まえ、Web Workerとのメッセージのやり取りとその準備を次のように書けるライブラリを作成した。mgn901.icon
概念
ClientTerminal
メインスレッド上で初期化する。
Web Worker上の関数に引数を渡してワーカースレッドで処理を行い、その結果を解決するPromiseを返すrequestメソッドを備えている。
ClientTerminalがワーカースレッドにメッセージを送れるように、ワーカースレッドにメッセージを送るための関数をsendMessageFunctionに渡して初期化する。
ClientTerminalがワーカースレッドからのメッセージに反応できるように、引数として渡された関数をmessageイベントのリスナーに登録する関数をmessageListenerAdderに渡して初期化する。
ServerTerminal
ワーカースレッド上で初期化する。
メインスレッドからのメッセージを受け取った際に、特定の処理を行い、その結果をメインスレッドに返す。行いたい処理を関数としてparamsProcessorに渡して初期化する。
ServerTerminalがメインスレッドにメッセージを送れるように、メインスレッドにメッセージを送るための関数をsendMessageFunctionに渡して初期化する。
ServerTerminalがメインスレッドからのメッセージに反応できるように、引数として渡された関数をmessageイベントのリスナーに登録する関数をmessageListenerAdderに渡して初期化する。
メインスレッドでは、ClientTerminalの初期化後にClientTerminal.requestを呼び出し、その結果をresult変数に代入している。resultにはワーカースレッド上で定義されているsum関数の実行結果が代入される。
code:ts
// メインスレッド
import { ClientTerminal } from '@mgn901/asyncify-events';
const worker = new Worker('/path/to/worker.js');
// Asyncifies calling sum function in the worker thread.
const { request: sum, terminate } = new ClientTerminal<number[], number>({
channel: 'sum',
// ClientTerminal has an internal listener to respond to message events.
// In the initialization, ClientTerminal passes the following function the listener so that ClientTerminal can respond to message events.
messageListenerAdder: (onMessage) => { worker.addEventListener('message', (e) => { onMessage(e.data); }); },
messageListenerRemover: (onMessage) => { worker.removeEventListener('message', (e) => { onMessage(e.data); }); },
// Pass a function to send messages to the worker thread.
sendMessageFunction: worker.postMessage.bind(worker),
});
(async () => {
const result = await sum(1, 2);
console.log(result); // => 3
})();
code:ts
// ワーカースレッド
import { ServerTerminal } from '@mgn901/asyncify-events';
const globalScope = self as DedicatedWorkerGlobalScope;
const sum = (...numbers: number[]): number => numbers.reduce((prev, next) => prev + next);
// Enables to call sum function from the main thread.
// Pass the sum function to paramsProcessor property so that ServerTerminal can execute the function when receiving messages.
const { terminate } = new ServerTerminal<number[], number>({
channel: 'sum',
paramsProcessor: sum,
messageListenerAdder: (onMessage) => { globalScope.addEventLister('message', (e) => { onMessage(e.data); }); },
messageListenerRemover: (onMessage) => { globalScope.removeEventListener('message', (e) => { onMessage(e.data); }); },
sendMessageFunction: globalScope.postMessage.bind(globalScope),
});
この方法の良いところ mgn901.icon
既存の関数の処理を別のスコープに移したりWeb Workerに対応させたりするのが簡単になる。
上記サンプルコードはメインスレッドとWeb Workerの通信を例にしているが、ClientTerminalやServerTerminalの初期化時に渡すmessageListenerAdderやsendMessageFunctionを変えれば、WebExtensionのスコープ間通信などにも対応可能である。
仕組み
クライアントサーバーモデルをメッセージパッシングの部分集合(つまり一般的なメッセージパッシングに対して特別なもの)と捉えて実装している。 メッセージの送信を関数化するSendTerminalと、メッセージを通して関数を呼び出せるようにするためのReceiveTerminalを実装する。
SendTerminal
sendParamsメソッドやsendMessageメソッドからメッセージを送ることができる。
code:ts
// 初期化時のオプション
export interface ISendTerminalOptions<TParams extends any[]> {
channel: string;
sendMessageFunction: (message: IMessage<TParams>) => void;
}
ReceiveTerminal
メッセージを受け取った際に、初期化時に渡したparamsProcessorやmessageProcessorが呼び出されるようになっている。
code:ts
// 初期化時のオプション
export interface IReceiveTerminalOptions<TParams extends any[], TReturns extends any = void> {
channel: string;
paramsProcessor?: (...params: TParams) => TReturns;
messageProcessor?: (message: IMessage<TParams>) => TReturns;
messageListenerAdder: (onMessage: (message: IMessage<TParams>) => void) => void;
messageListenerRemover: (onMessage: (message: IMessage<TParams>) => void) => void;
}
ClientTerminalとServerTerminalは、内部にSendTerminalのインスタンスとReceiveTerminalのインスタンスを持つことで実現している。
ClientTerminal
requestメソッドが呼び出された際に、
Promiseを返し、返したPromiseを解決するためのresolve関数を内部で保持しておく。
内部のSendTerminalからメッセージを送信する。
内部のReceiveTerminalの初期化時には、Promiseを結果の値で解決できるように、適切な関数を内部で生成して渡している。
内部のReceiveTerminalでメッセージを受け取った際に、Promiseを結果の値で解決する。
ServerTerminal
内部のReceiveTerminalでメッセージを受け取った際に、初期化時に渡された関数を呼び出し、その結果を内部のSendTerminalからメッセージで送信する。
ビューと重い処理の分離をクライアントサーバーモデルに落とし込みたいのではなく、純粋にメッセージパッシングをしたいだけであれば、SendTerminalとReceiveTerminalを使えばよい。
例えば「ビューから制御されないワーカースレッドの処理の状況をメインスレッドに伝える」などが考えられる。
既存の似たアプローチ
mgn901.iconの方法と同様の考え方でWeb Workerを利用するコードの見通しをよくしているが、サンプルコードの見た目は大きく異なっている。
mgn901.iconの方法はWeb Worker以外のインターフェースにも対応可能である一方で、worker-libの方法はWeb Workerに特化していて、Workerの増減により並列度を上下する機能が実装されている。
事前に調べてから作ってもよかったかも?mgn901.icon
exposeで処理を包み、wrapでWorkerを包むだけで同じことができて、書き味がとても良い。
Chrome拡張機能におけるスコープ間通信に対応させる方法もある。
具体的には、Chrome拡張機能におけるRuntime.portをComlinkに渡せるように変換してから使うという方法。
mgn901.iconのソフトウェアでの活用例
スバラシ: Web Workerを活用したノンブロッキングな検索機能の実装に利用している。