minlink - browser/node で使える Worker ラッパー
mizchi/minlink: Minimum(> 1kb) and isomorphic worker wrapper with comlink like rpc. というライブラリを作った。自分で使ってみて、とても便利だったので紹介する。
概要
- Browser WebWorker / Node worker_threads を同じ API でラップして、関数呼び出しのように使える
- Magical なことをしない軽量 comlink みたいなもの
- 双方向で API をラップできる
Web Worker を使う思想的な背景は off-the-main-thread の時代 - mizchi's blog にて。
使い方: Browser
WebWorker をラップする。
// browser worker.js
import { expose } from "minlink/dist/browser";
const impl = {
async foo(n; number) {
return n + 1;
},
};
expose(self, impl);
// browesr main.js
import { wrap } from "minlink/dist/browser";
const api = wrap(new Worker("./worker.js"));
const ret = await api.exec("foo", 1);
console.log(ret); // => 2
await api.terminate();
worker 側で expose した関数を、exec 関数で 関数名、引数1, 引数2,...
という感じで呼び出せる。
WebWorker の使用上、返却するのは JSON シリアライズできるか、あるいは transferrable を実装している ArrayBuffer などである必要がある。
transferrable なオブジェクトを含む場合は、第一引数がこうなる。
const buf = new Uint8Array([...]);
await api.exec(["sendBuf", [buf]], { buffer: buf });
使い方: Node
Node 14+ で使える Worker Threads の Worker をラップする。これは postMessage と似ているが、API は非互換で同じではない。
これを同じく expose 関数でラップすることで、同じ API のインスタンスにする。
// main.mjs
import { wrap } from "comlink/dist/node.mjs";
import { Worker } from "worker_threads";
const worker = new Worker("./worker.mjs");
const api = wrap(worker);
const res = await api.exec("foo", 1);
console.log("response", res);
// worker.mjs
import { expose } from "minlink/dist/node.mjs";
import { parentPort } from "worker_threads";
expose(parentPort, {
async foo(n) {
return n + 1;
},
});
内部的な実装は別物だが node でも同じ API が使えることで、 Web Worker + Comlink が使える人が違和感なく使えるようにしてある。
これを作った動機として、 developit/web-worker: Consistent Web Workers in browser and Node. の comlink 版がほしかった。
使い方: TypeScript
exec 関数への変換が、このままだと型が付かないので、実装から自動で exec の型の推論をできるような仕組みを用意した。
// browser/worker.ts
import { expose } from "minlink/dist/browser";
const impl = {
async foo(n; number) {
return n + 1;
},
};
// 型を export する
export type RemoteImpl = typeof impl;
expose(self, impl);
// browesr/main.ts
// Typescript 3.9+ の Type only import で型だけ import する
import type { RemoteImpl } from "./worker";
import { wrap } from "minlink/dist/browser";
const api = wrap<RemoteImpl>(new Worker("/worker.js")); // take RemoteImpl as `wrap(...)`'s type argument.
const ret1 = await api.exec("foo", 1); // pass
const ret2 = await api.exec("foo", "invalid arg"); // type error
内部的には、こういう型で実現している。
export type RemoteCall<O extends RemoteImpl, N extends keyof O = keyof O> = {
exec<T extends keyof O = N>(
func: T,
...args: Parameters<O[T]>
): ReturnType<O[N]>;
terminate(): Promise<void>;
};
Parameters<T>
と ReturnType<T>
は TypeScript の組み込み型で、Parameters<T>
は与えられた関数の引数をタプルの配列化し、ReturnType<T>
は関数の返り値を返す。これを使って exec
の型を組み直している。
問題
ブラウザでは非同期の Promise で通信のレスポンスを待つので、Worker にもっていくと最低 1tick 分(16ms) の遅延がある。
node で使う場合、一旦 node 14+ で使える .mjs で使う想定だが、 ts で使う場合にいい感じにシームレスな方法が思いつかない。 ts が .mjs をサポートしていないので、それ待ち。
Support `.mjs` output · Issue #18442 · microsoft/TypeScript
rollup で .mjs で吐くのが現実的な気がする。
Comlink との違い
インスパイア元: GoogleChromeLabs/comlink: Comlink makes WebWorkers enjoyable.
- node 対応がある
- 軽量(ES5 で 6.6k => 2.2k)
- コールバック関数や Map/Set の自動変換がない
comlink 3.7k + proxy-polyfill 2.9k に対し、 minlink ES5 ビルドが 2.4k というサイズで収まっている。ES2019 ビルドなら 830b。
おまけ
ビルドサイズを小さくするにあたって、 terser のビルド結果と睨みながら実装していたのだが、オブジェクトのパラメータ部分が削られない。
関数名を定数化 / 引数オブジェクトを避ける
function get(opts: { aaa: boolean, bbb: string }) {...}
このような関数は、とくに理由がないなら次のようにした。
function get(aaa: boolean, bbb: string) {...}
内部的にしか参照しない関数名なら、全部定数化した。
const RESOLVE = 1;
const REJECT = 2;
const obj = {
[RESOLVE]() {...},
[REJECT]() {...},
};
エラー時のスタックトレースは読みづらい。しかしビルドサイズはスタックトレースより尊いという判断をした。
内部的な構造体を配列にする
↑ と似たような方向性だが、とにかくなんでも定数化+配列化するとコード量自体は減る。
このノリでオブジェクトをやめて配列にすると、コードはかなり読みづらくなってしまうが、 TypeScript 4 の named tuple を使うと、比較的読みやすくなった。
const REQUEST_MARK = "m$s";
const RESPONSE_MARK = "m$r";
type Request = [
mark: typeof REQUEST_MARK,
id: number,
cmd: Cmd,
...args: Transferrable[]
];
type Response = [
mark: typeof RESPONSE_MARK,
id: number,
error: boolean,
result: Transferrable
];
比較的読みやすいだけであって、一般的には勧められない。 1 byte でも減らしたいときに。
グローバル変数
グローバル変数を触るコードは minify されない。
window.API_XXX_YYY = "abc";
delete window.API_XXX_YYY;
これはこうするとコードの総量が減る。
const t = "API_XXX_YYY";
window[t] = "abc";
delete window[t];
おわり
このへんのテクニックは ampproject/worker-dom: The same DOM API and Frameworks you know, but in a Web Worker. で覚えた。
terser を使うとき、変数はすべて一文字に、外部からアクセスされるインターフェースはそのまま残る、ということを覚えておくと、出力後のコードが想像できるようになる。割とすぐ身につくテクニックなので、おすすめ。