Vue.jsを使ったSPAで、メインスレッドは極力Vue.jsのレンダリングに使わせたいので、重い処理はWebWorkerへ投げることがあります。
WebWorkerは非同期のためaddEventListener()
などでメッセージをハンドリングするわけですが、ハンドラを普通に書くとVue.jsのライフサイクルから外れてしまいます。
強引にライフサイクル内でイベントリスナを登録してしまうと、多重登録となりやすく危険です。
<div id="vue">
<input type="file" v-model="largeBlob">
<div>{{result}}</div>
<button @click="heavyTask()">Parse!</button>
</div>
const worker = new Worker("./worker.js");
// 受けるときはライフサイクル外になってしまう
worker.addEventListner("message", ({data}) => data);
const vue = new Vue({
el: "#vue",
data(){
return {
largeBlob: undefined,
result: ""
};
},
methods: {
async heavyTask(){
// heavyTaskが呼ばれるたびに多重登録されてしまう
worker.addEventListner("message", ({data}) => this.result = data);
// ライフサイクル内で送ることはできる
const _largeBlob = await this.largeBlob?.arrayBuffer();
worker.postMessage(_largeBlob, [_largeBlob]);
},
}
});
そこでWebWorkerの処理結果をmethods
内のメソッドへバインドすることで、安全にVue.jsのライフサイクルへ組み込む方法をご紹介します。
擬似チャネルインターフェイス
まずDedicatedWorkersはチャネル名を指定できません。
ですが、実装していくうちコンテキストによって処理内容を変えたい場合が出てくると思います。
その答えのひとつとして "擬似チャネルインターフェイス" という方法を私は思いつきました。
なお、これはVue.jsに限った話ではなく、汎用的に使えるテクニックです。
まず、送受信メッセージをオブジェクトでカプセル化してch
などのチャネル名を指定するプロパティを作ります。
送受信メッセージの中身は、配列としてbody
など名前を付けてプロパティへ格納すると、もし中身がArrayBuffer
だった場合はpostMessage()
の第2引数のTransferable
で指定しやすいのでオススメです。
const message = {
ch: "parse",
body: [_largeBlob]
};
worker.postMessage(message, message.body);
いっぽう、ワーカースレッドはメインスレッドから送られてきたメッセージをチャネルごとに分けて処理する必要があります。
まずはfunction _channel_parse(data)
など、チャネルインターフェイス用だと分かりやすい接頭辞とチャネル名を繋げたメソッドを作り、引数にはメインスレッドから受信したメッセージを受け取ります。
そして、ワーカースレッド側のメッセージイベントハンドラを組み立てるわけですが、先ほど作ったメソッドはグローバルスコープへ定義されているはずです。
具体的にいうとDedicatedWorkerGlobalScope
かself
かglobalThis
から参照できます。
ここが大事で、JavaScriptはプロパティへアクセスする際にブラケット記法を使うことで、変数値でプロパティへアクセスすることができます。
つまりself
を親オブジェクトとして、そのプロパティ(つまり_channel_
メソッド)へチャネル名でアクセスする、というのが擬似チャネルインターフェイスの特徴です。
チャネルメソッドの返り値は、メインスレッド同様にオブジェクトでカプセル化しチャネル名プロパティを付与します。
async function blobParser(blob){
// 何らかの重い処理
return result;
}
async function _channel_parse(data){
return {
ch: "parse",
body: [await blobParser(data)]
};
}
self.addEventListner("message", ({data})=>{
const message = await self[`_channel_${data.ch}`](data);
self.postMessage(message, message.body);
});
そして再びメインスレッドへ戻ってきます。
ここでようやくVue.jsのライフサイクルへ組み込む処理となります。
といっても、やってることは先ほどのワーカースレッドとほとんど同じです。
先ほどの親オブジェクトはDedicatedWorkerGlobalScope
でしたが、Vue.jsのライフサイクルフックはthis
で参照できるためself
をthis
に変えるだけです。
new Vue({
el: "#vue",
data(){
return {
largeblob: undefined,
result: ""
};
},
mounted(){
// チャネル名でmethodsへバインドする
// マウント後に1回だけ登録されるので、多重登録は発生しない
worker.addEventListener("message", ({data}) => this[`_channel_${data.ch}`](data));
},
methods: {
// WebWorkerの処理結果メッセージを引数として受け取れる
_channel_parse(data){
this.result = data.body[0];
},
async heavyTask(){
const message = {
ch: "parse",
body: [await this.largeBlob?.arrayBuffer()]
};
worker.postMessage(message, message.body);
},
}
});