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 などである必要がある。

Transferable - Web API | MDN

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 を使うとき、変数はすべて一文字に、外部からアクセスされるインターフェースはそのまま残る、ということを覚えておくと、出力後のコードが想像できるようになる。割とすぐ身につくテクニックなので、おすすめ。

History
822afe0 - Wip Mon Sep 7 15:08:09 2020 +0900