Web WorkerとShared Workerの間の、coWorker

# はじめに

この記事はShared Worker相当の「coWorker」を紹介する記事です。

coWorkerが必要な動機に次の3点がありました。

* 元データがExcelファイルで提供されており、素朴な実装ではブラウザメインスレッドを占有してしまうこと
* Web Workerでファイルの読み込みを任せて回避できても、Excelファイルの数が多いので起動にオーバーヘッドがあること
* Shared Workerで回避したくても、iOSのSafariはShared Workerに長く対応していなかった(iOS 7-15.6)ため

そこで、Web Workerを少し工夫して、これらの問題を解決することにしました。

---

# coWorkerの使い方

* xlsxReader = new coWorker()して、
* xlsxReader.execute({content: file, type: 'binary'});で実行
    * Excelファイルを読み込みためにxlsx-styleモジュールを使っています。
    * ファイルの拡張子とかMIME-Typeみて読み込み用のモジュールを切り替えられるとなおよし

```javascript
   const xlsxReader = new coWorker(function () {
        // 別スレッドで動いてもらいたいプログラムを引数にして生成する

            onmessage = function (e) {
                    var reader = new FileReader();
                    // ファイルサイズが大きな場合に備えてprogressイベントをメインスレッドに返すようにしておく
                    reader.onprogress = (ev) => {
                        postMessage({
                            type: 'progress',
                            loaded: ev.loaded,
                            total: ev.total,
                        });
                    };

                    // 必要に応じてonloadendなど設定しておくとよい
                    reader.onload = function (ev) {
                        var data = ev.target.result;
                        var workbook = XLSX.read(data, {
                            type: 'binary',
                        }); // if binary string, read with type 'binary'

                        postMessage({ src: workbook, port: e.data.port }); // DO SOMETHING WITH workbook HERE
                    };
                    reader.readAsBinaryString(e.data.content);
                }
            };
        });
    }
```


---

# coWorkerの実装

```javascript
class coWorker extends Worker {
    constructor(src) {
        // see. https://qiita.com/dojyorin/items/8e874b81648a21c1ea1d
        if (!(typeof src === 'function' || typeof src === 'string')) {
            throw new Error("argument must be 'function' or 'string'");
        }
        const locationRoot = globalThis.location.href.replace('index.html', '');
        const modules = `
        importScripts('${locationRoot}node_modules/xlsx-style/dist/xlsx.core.min.js');
        `.toString();

        const workcode = (function (f) {
            if (typeof f === 'function')
                return f
                    .toString()
                    .replace(/^.+?\{/s, '')
                    .replace(/\}$/s, '');
            return f;
        })(src);

        const ctx = URL.createObjectURL(
            new Blob([modules + workcode], { type: 'text/javascript' })
        );
        super(ctx);
        this._ctx = ctx;
        this.onmessages = [];
        this.onerrors = [];
    }

    terminate() {
        super.terminate();
        URL.revokeObjectURL(this._ctx);
    }

    execute(data) {
        if (this.onmessage == null)
            this.onmessage = (ev) => {
                if (ev.data.type == 'progress') {
                    this.onprogress(
                        new ProgressEvent('progress', {
                            lengthComputable: true,
                            loaded: ev.data.loaded,
                            total: ev.data.total,
                        })
                    );
                } else {
                    const port = ev.data.port;
                    this.onmessages[port](ev);
                }
            };
        if (this.onerror == null) {
            this.onerror = (ev) => {
                const port = ev.data.port;
                this.errors[port](ev);
            };
        }
        return new Promise((resolv, reject) => {
            const port = this.onmessages.length;
            this.onmessages[port] = (ev) => {
                delete this.onmessages[ev.data.port];
                delete this.errors[ev.data.port];
                resolv(ev.data.src);
            };
            this.onerrors[port] = (ev) => {
                delete this.onmessages[ev.data.port];
                delete this.errors[ev.data.port];
                reject(ev);
            };
            data.port = port;
            this.postMessage(data);
        });
    }
    onprogress(ev) {
        console.log(ev);
    }
}

```

---

# 解説

まず、
* xlsxReader = new coWorker()して、
* xlsxReader.execute({content: file, type: 'binary'});により、coWorker#executeメソッドによりPromiseを戻り値として得ます。
    * port番号を割り当てて、dataにportを追加してからpostMessageでWeb Workerに制御を移します。
    * このプロミスは、ファイル読み込みを成功した場合resolvで、何らかの原因で失敗した場合rejectします。

```javascript
class coWorker extend Worker{
  execute(data) {
      ... 略
      return new Promise((resolv, reject) => {
            const port = this.onmessages.length;
            this.onmessages[port] = (ev) => {
                delete this.onmessages[ev.data.port];
                delete this.errors[ev.data.port];
                resolv(ev.data.src);
            };
            this.onerrors[port] = (ev) => {
                delete this.onmessages[ev.data.port];
                delete this.errors[ev.data.port];
                reject(ev);
            };
            data.port = port;
            this.postMessage(data);
  }
}
```

次に、coWorker側です。postMessageを受け取るのはmessageハンドラです。messageハンドラには、FileReaderを都度生成して、入力ファイルの解析結果をオブジェクトとして戻すためにpostMessageを呼んでいます。ここで元のcoWorkerに制御が移ります。呼び出された時に入力データはe.dataで参照できるので、coWorkerに制御を戻す引数にport: e.data.portを持たせているクロージャになっているのがミソです。

```javascript
    const xlsxReader = new InlineWorker(function () {
            onmessage = function (e) {
                    var reader = new FileReader();
                    reader.onprogress = (ev) => {
                        postMessage({
                            type: 'progress',
                            loaded: ev.loaded,
                            total: ev.total,
                        });
                    };

                    reader.onload = function (ev) {
                        var data = ev.target.result;
                        var workbook = XLSX.read(data, {
                            type: 'binary',
                        }); // if binary string, read with type 'binary'

                        postMessage({ src: workbook, port: e.data.port }); // DO SOMETHING WITH workbook HERE
                    };
                    reader.readAsBinaryString(e.data.content);
}}

```

coWorkerのmessageハンドラは、登録してあるthis.onmessages\[port\](ev)を呼び出すようにしています。
その場合の引数evで渡ってくるデータは{src: workbook, port: e.data.port}でしたから、読み出す前に割り当てたport番号と同じものを送り戻す工夫をこうやってしています。

this.onmessgesは、呼び出した時にport番号に割り当てられた関数です。不要となったthis.messagesとthis.errorsとの該当ポート番号の関数を削除して、戻り値を戻すだけの中身です。これで、Promiseは成功裏には結果を戻し、Excelファイルを無事オブジェクトとして得られました。

```javascript
class coWorker extend Worker{
  execute(data) {
     if (this.onmessage == null)
            this.onmessage = (ev) => {
                if (ev.data.type == 'progress') {
                    this.onprogress(
                        new ProgressEvent('progress', {
                            lengthComputable: true,
                            loaded: ev.data.loaded,
                            total: ev.data.total,
                        })
                    );
                } else {
                    const port = ev.data.port;
                    this.onmessages[port](ev);
                }
            };
     if (this.onerror == null) {
            this.onerror = (ev) => {
                const port = ev.data.port;
                this.errors[port](ev);
            };
      }
        // 以下、再掲
      return new Promise((resolv, reject) => {
            const port = this.onmessages.length;
            this.onmessages[port] = (ev) => {
                delete this.onmessages[ev.data.port];
                delete this.errors[ev.data.port];
                resolv(ev.data.src);
            };
  ... 略    
}
```

---

# まとめ

* Excelファイルの読み込みをブラウザメインスレッドを占有せずに、
* Web Workerでファイルの読み込みを任せつつ、都度起動のオーバーヘッドを回避しつつ、
* Shared workerに対応していないiOSのSafariでも動作可能になりました。

余談
* coWorkerにonmessageとメソッドとして定義するとうまくいかなかった。