Webアプリケーションの担う役割が増えていますが、それに伴ってブラウザで実行するコード量、計算量が増えています。その結果として、ブラウザの動作が重たくなったり、バッテリー消費が増えたりすることがあります。そこで使ってみたいのがComlinkです。

Comlinkは、Web Workersを利用して、メインスレッドから重たい処理を分離し、アプリケーションのパフォーマンスを向上させるためのライブラリです。メインスレッドとWorker間の通信を簡素化し、非同期処理を容易にします。

本記事では、Comlinkの基本的な使い方を紹介します。

Comlinkについて

ComlinkはGoogleのエンジニアによって開発された、メインスレッドとWorker間の通信を簡単に、変数やメソッドを共有できるライブラリです。一般的にWorkerとの通信はpostMessageとonmessageを使って行いますが、Comlinkを使うことで、まるで同じスレッド内でオブジェクトを操作しているかのように扱うことができます。

使い方

まず、メインスレッド用とWorker用の2つのJavaScriptファイルを作成します。外部からインポートをしているので、 type="module" を指定してください。

import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";

async function init() {
  const worker = new Worker("worker.js");
  const obj = Comlink.wrap(worker);

  alert(カウンター: ${await obj.counter}); // 0
  await obj.inc();
  alert(カウンター: ${await obj.counter}); // 1
}

init();

そして、 worker.js ファイルを以下のように作成します。

importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");

const obj = {
  counter: 0,
  inc() {
    this.counter++;
  },
};

Comlink.expose(obj);

Comlink.wrap を使ってWorkerをラップし、 Comlink.expose を使ってWorker内のオブジェクトを公開します。これにより、メインスレッドからWorker内のオブジェクトのプロパティやメソッドにアクセスできるようになります。

上記のコードの場合、最初は カウンター: 0 と表示されます。これは、Worker内の counter プロパティが0で初期化されているためです。次に inc メソッドを呼び出しすと、 counter プロパティが1増加します。そして、 counter プロパティを取得すると カウンター: 1 と表示されます。

コールバックを受け取る

Workerからメインスレッドにコールバックを渡すことも可能です。以下の例では、Worker内でカウントアップするたびにメインスレッドに通知しています。

Worker側のコードは以下の通りです。

importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");

async function remoteFunction(cb) {
  await cb("A string from a worker");
}

Comlink.expose(remoteFunction);

そして、メインスレッド側のコードは以下の通りです。

import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";

function callback(value) {
  alert(Result: ${value});
}

async function init() {
  const remoteFunction = Comlink.wrap(new Worker("worker.js"));
  await remoteFunction(Comlink.proxy(callback));
}

init();

Comlink.proxy を使ってコールバック関数をラップし、Workerに渡します。Worker内でコールバックが呼び出されると、メインスレッドでアラートが表示されます。上記コードの場合は、 Result: A string from a worker と表示されます。

クラスインスタンスの利用

Comlinkを使うと、Worker内でクラスインスタンスを作成し、メインスレッドからそのインスタンスのメソッドを呼び出すこともできます。このインスタンスはそれぞれ独立して動作します。

importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");

class MyClass {
  constructor(init = 0) {
    console.log(init);
    this._counter = init;
  }

  get counter() {
    return this._counter;
  }

  increment(delta = 1) {
    this._counter += delta;
  }
}

Comlink.expose(MyClass);

そして、メインスレッド側のコードは以下の通りです。

import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";

let instance1, instance2;
async function showState() {
  alert(`instance1.counter = ${await instance1.counter},
    instance2.counter = ${await instance2.counter}`);
}

async function init() {
  const MyClass = Comlink.wrap(new Worker("worker.js"));
  instance1 = await new MyClass();
  instance2 = await new MyClass(42);
  await showState();
  await instance1.increment();
  await instance2.increment(23);
  await showState();
}

init();

上記コードでは、Worker側で MyClass を定義し、メインスレッドで2つのインスタンスを作成しています。最初のインスタンスはデフォルトの初期値0で、2番目のインスタンスは42で初期化されています。 increment メソッドを呼び出すことで、それぞれのカウンターが増加します。

結果として、 instance1.counter = 0, instance2.counter = 42 と表示され、次に instance1.counter = 1, instance2.counter = 65 と表示されます。それぞれのインスタンス変数が独立して動作していることが確認できます。

イベントリスナーの使い方

Workerでは、メインスレッド側のイベントリスナーは利用できません。一般的に計算処理であったり、データの取得処理をWorkerで行います。しかし、Comlinkを使うことで、Worker内でイベントリスナーを扱いやすくなります。

まず、イベントハンドリング側のJavaScriptファイルを以下のように作成します。

Comlink.transferHandlers.set("event", {
  canHandle(obj) {
    return obj instanceof Event;
  },
  serialize(obj) {
    if (!obj || !obj.target) return [null, []];
    return [
      {
        targetId: obj.target.id,
        targetClassList: [...obj.target.classList],
        detail: obj.detail,
      },
      [],
    ];
  },
  deserialize(obj) {
    return obj;
  },
});

次にメインスレッド側のコードを以下のように作成します。クリックイベントに対して、Comlinkを使ってWorker内の関数を呼び出しています。

const worker = new Worker("worker.js");
const api = Comlink.wrap(worker);

document
  .querySelector("#mainbtn")
  .addEventListener("click", api.onclick.bind(api));

そして、Worker側のコードは以下の通りです。これで、 ev に対してクリックイベントの情報(DOMなど)が渡されます。

importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
importScripts("event.transferhandler.js");

Comlink.expose({
  onclick(ev) {
    console.log(
      `Click! Button id: ${ev.targetId}, Button classes: ${JSON.stringify(
        ev.targetClassList
      )}`
    );
  },
});

SharedWorkerの利用

ComlinkはSharedWorkerとも互換性があります。SharedWorkerを使うと、複数のブラウザタブやウィンドウで同じWorkerインスタンスを共有できます。これにより、リソースの節約や状態の共有が可能になります。

メインスレッド側のコードは以下の通りです。

import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";

async function init() {
  const worker = new SharedWorker("worker.js");
  const obj = Comlink.wrap(worker.port);

  alert(Counter: ${await obj.counter});
  await obj.inc();
  alert(Counter: ${await obj.counter});
}

init();

そして、Worker側のコードは以下の通りです。これまでとの違いは、 onconnect イベントを使ってポートを取得し、 Comlink.expose にポートを渡している点です。これでSharedWorkerとして動作します。

importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
const obj = {
  counter: 0,
  inc() {
    this.counter++;
  },
};

onconnect = function (event) {
  const port = event.ports[0];
  Comlink.expose(obj, port);
};

まとめ

Comlinkを使うことで、Web Workersとの通信が非常に簡単になります。メインスレッドとWorker間でオブジェクトや関数を直接操作できるため、非同期処理が容易になるでしょう。パフォーマンスの向上やユーザー体験の改善に役立つComlinkをぜひ活用してみてください。

GoogleChromeLabs/comlink: Comlink makes WebWorkers enjoyable.