WebWorkerで擬似チャネルインターフェイスを作りVue.jsのライフサイクルへバインドする

Vue.jsを使ったSPAで、メインスレッドは極力Vue.jsのレンダリングに使わせたいので、重い処理はWebWorkerへ投げることがあります。

WebWorkerは非同期のため`addEventListener()`などでメッセージをハンドリングするわけですが、ハンドラを普通に書くとVue.jsのライフサイクルから外れてしまいます。

強引にライフサイクル内でイベントリスナを登録してしまうと、多重登録となりやすく危険です。

```html
<div id="vue">
    <input type="file" v-model="largeBlob">
    <div>{{result}}</div>
    <button @click="heavyTask()">Parse!</button>
</div>
```

```js
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`で指定しやすいのでオススメです。

```js:MainThread
const message = {
    ch: "parse",
    body: [_largeBlob]
};
worker.postMessage(message, message.body);
```

いっぽう、ワーカースレッドはメインスレッドから送られてきたメッセージをチャネルごとに分けて処理する必要があります。

まずは`function _channel_parse(data)`など、チャネルインターフェイス用だと分かりやすい接頭辞とチャネル名を繋げたメソッドを作り、引数にはメインスレッドから受信したメッセージを受け取ります。

そして、ワーカースレッド側のメッセージイベントハンドラを組み立てるわけですが、先ほど作ったメソッドはグローバルスコープへ定義されているはずです。
具体的にいうと`DedicatedWorkerGlobalScope`か`self`か`globalThis`から参照できます。

ここが大事で、JavaScriptはプロパティへアクセスする際にブラケット記法を使うことで、変数値でプロパティへアクセスすることができます。
つまり`self`を親オブジェクトとして、そのプロパティ(つまり`_channel_`メソッド)へチャネル名でアクセスする、というのが擬似チャネルインターフェイスの特徴です。

チャネルメソッドの返り値は、メインスレッド同様にオブジェクトでカプセル化しチャネル名プロパティを付与します。

```js:WorkerThread
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`に変えるだけです。

```js:MainThread
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);
        },
    }
});
```