LoginSignup
0
0

More than 1 year has passed since last update.

Web WorkerとShared Workerの間の、coWorker

Last updated at Posted at 2022-10-15

はじめに

この記事は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みて読み込み用のモジュールを切り替えられるとなおよし
   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の実装

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します。
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を持たせているクロージャになっているのがミソです。

    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ファイルを無事オブジェクトとして得られました。

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とメソッドとして定義するとうまくいかなかった。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0