はじめに
この記事は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とメソッドとして定義するとうまくいかなかった。