Web ワーカーを使用してブラウザのメインスレッド以外で JavaScript を実行する

メインスレッド外のアーキテクチャを使用すると、アプリの信頼性とユーザー エクスペリエンスを大幅に向上させることができます。

過去 20 年間で、ウェブはいくつかのスタイルと画像を含む静的なドキュメントから、複雑で動的なアプリケーションへと劇的に進化しました。しかし、1 つだけ大きく変わっていないことがあります。それは、サイトのレンダリングと JavaScript の実行を行うために、ブラウザタブごとに 1 つのスレッド(一部の例外を除く)しか使用していないことです。

その結果、メインスレッドは非常に過負荷になっています。ウェブアプリが複雑になるにつれて、メインスレッドはパフォーマンスの大きなボトルネックになります。さらに悪いことに、デバイスの性能がパフォーマンスに大きな影響を与えるため、特定のユーザーのメインスレッドでコードを実行するのにかかる時間はほぼ完全に予測不可能です。この予測不可能性は、ユーザーがウェブにアクセスするデバイスが多様化するにつれて、ますます高まっていきます。制約の多いフィーチャー フォンから、高機能でリフレッシュ レートの高いフラッグシップ マシンまで、さまざまなデバイスが使用されています。

高度なウェブアプリが、人間の知覚と心理に関する経験的データに基づく Core Web Vitals などのパフォーマンス ガイドラインを確実に満たすようにするには、メインスレッド外(OMT)でコードを実行する方法が必要です。

ウェブワーカーを使用する理由

JavaScript は、デフォルトではメインスレッドでタスクを実行するシングルスレッド言語です。しかし、ウェブワーカーは、メインスレッドから作業を処理するための別のスレッドを作成できるようにすることで、メインスレッドからのエスケープ ハッチのような役割を果たします。ウェブワーカーのスコープは制限されており、DOM に直接アクセスすることはできませんが、メインスレッドを圧迫するような大量の処理が必要な場合は、非常に有益です。

Core Web Vitals に関しては、メインスレッド以外で処理を実行することが有益な場合があります。特に、メインスレッドからウェブワーカーに処理をオフロードすると、メインスレッドの競合が軽減され、ページの Interaction to Next Paint(INP)応答性指標が改善されます。メインスレッドで処理する作業が少なくなると、ユーザー操作に迅速に応答できるようになります。

メインスレッドの処理(特に起動時)を減らすことで、長いタスクが減り、Largest Contentful Paint(LCP)が改善する可能性もあります。LCP 要素のレンダリングには、メインスレッドの時間が必要です。これは、テキストや画像のレンダリング(頻繁かつ一般的な LCP 要素)に必要です。メインスレッドの作業を全体的に削減することで、ページの LCP 要素が、ウェブワーカーが処理できる高負荷な作業によってブロックされる可能性を低減できます。

ウェブワーカーを使用したスレッド化

他のプラットフォームでは通常、スレッドに関数を指定して、プログラムの残りの部分と並行して実行できるようにすることで、並列処理をサポートしています。同じ変数に両方のスレッドからアクセスできます。また、これらの共有リソースへのアクセスをミューテックスとセマフォで同期して、競合状態を防ぐことができます。

JavaScript では、ウェブワーカーからほぼ同様の機能を取得できます。ウェブワーカーは 2007 年から存在し、2012 年以降はすべての主要なブラウザでサポートされています。ウェブワーカーはメインスレッドと並行して実行されますが、OS スレッドとは異なり、変数を共有することはできません。

ウェブワーカーを作成するには、ワーカー コンストラクタにファイルを渡します。これにより、そのファイルが別のスレッドで実行されます。

const worker = new Worker("./worker.js");

postMessage API を使用してメッセージを送信することで、ウェブワーカーと通信します。postMessage 呼び出しでメッセージ値をパラメータとして渡し、メッセージ イベント リスナーをワーカーに追加します。

main.js

const worker = new Worker('./worker.js');
worker.postMessage([40, 2]);

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  // ...
});

メインスレッドにメッセージを返すには、ウェブワーカーで同じ postMessage API を使用し、メインスレッドにイベント リスナーを設定します。

main.js

const worker = new Worker('./worker.js');

worker.postMessage([40, 2]);
worker.addEventListener('message', event => {
  console.log(event.data);
});

worker.js

addEventListener('message', event => {
  const [a, b] = event.data;

  // Do stuff with the message
  postMessage(a + b);
});

このアプローチには、ある程度の制限があります。これまで、ウェブワーカーは主に、1 つの重い処理をメインスレッドから移動するために使用されてきました。1 つのウェブワーカーで複数のオペレーションを処理しようとすると、すぐに扱いにくくなります。パラメータだけでなく、メッセージ内のオペレーションもエンコードする必要があります。また、リクエストにレスポンスを一致させるために、ブックキーピングを行う必要があります。この複雑さが、ウェブワーカーが広く採用されていない理由と考えられます。

しかし、メインスレッドとウェブワーカー間の通信の難しさを軽減できれば、このモデルは多くのユースケースに最適です。幸いなことに、まさにそれを行うライブラリがあります。

Comlink は、postMessage の詳細を考慮せずにウェブワーカーを使用できるようにすることを目的としたライブラリです。Comlink を使用すると、スレッドをサポートする他のプログラミング言語と同様に、ウェブワーカーとメインスレッド間で変数を共有できます。

Comlink を設定するには、ウェブワーカーにインポートし、メインスレッドに公開する一連の関数を定義します。次に、メインスレッドで Comlink をインポートし、ワーカーをラップして、公開された関数にアクセスします。

worker.js

import {expose} from 'comlink';

const api = {
  someMethod() {
    // ...
  }
}

expose(api);

main.js

import {wrap} from 'comlink';

const worker = new Worker('./worker.js');
const api = wrap(worker);

メインスレッドの api 変数は、ウェブワーカーの変数と同じように動作しますが、すべての関数が値自体ではなく値の Promise を返す点が異なります。

ウェブワーカーに移動すべきコードはどれですか。

ウェブワーカーは、DOM や、WebUSB、WebRTC、Web Audio などの多くの API にアクセスできないため、そのようなアクセスに依存するアプリの一部をワーカーに配置することはできません。それでも、ワーカーに移動した小さなコードは、ユーザー インターフェースの更新など、メインスレッドに必要な処理のために、メインスレッドのヘッドルームを増やすことができます。

ウェブ デベロッパーにとっての問題の一つは、ほとんどのウェブアプリが Vue や React などの UI フレームワークに依存してアプリ内のすべてをオーケストレートしていることです。すべてがフレームワークのコンポーネントであるため、本質的に DOM に関連付けられています。そのため、OMT アーキテクチャへの移行は困難であるように思われます。

しかし、UI の関心事を状態管理などの他の関心事から分離するモデルに移行すれば、フレームワーク ベースのアプリでもウェブワーカーは非常に有用です。これはまさに PROXX で採用されているアプローチです。

PROXX: OMT のケーススタディ

Google Chrome チームは、オフラインでの動作や魅力的なユーザー エクスペリエンスなど、プログレッシブ ウェブアプリの要件を満たすマインスイーパーのクローンとして PROXX を開発しました。残念ながら、初期バージョンのゲームは、フィーチャー フォンなどの制約のあるデバイスではパフォーマンスが低く、チームはメインスレッドがボトルネックになっていることに気づきました。

チームは、ウェブワーカーを使用して、ゲームのビジュアル状態をロジックから分離することにしました。

  • メインスレッドは、アニメーションと遷移のレンダリングを処理します。
  • ウェブワーカーは、純粋に計算処理であるゲームロジックを処理します。

OMT は、PROXX のフィーチャー フォンでのパフォーマンスに興味深い影響を与えました。OMT 以外のバージョンでは、ユーザーが操作してから 6 秒間 UI がフリーズします。フィードバックはなく、ユーザーは 6 秒間待ってからでないと、他の操作を行うことができません。

OMT 以外のバージョンの PROXX における UI 応答時間。

しかし、OMT バージョンでは、UI の更新が完了するまでに 12 秒かかります。これはパフォーマンスの低下のように見えますが、実際にはユーザーへのフィードバックの増加につながります。この速度低下は、アプリが OMT 以外のバージョンよりも多くのフレームを送信しているために発生します。OMT 以外のバージョンでは、フレームはまったく送信されません。そのため、ユーザーは処理が発生していることを認識し、UI が更新される間もプレイを続けることができるため、ゲームの操作性が大幅に向上します。

OMT バージョンの PROXX における UI の応答時間。

これは意図的なトレードオフです。ハイエンド デバイスのユーザーに不利益を与えることなく、制約のあるデバイスのユーザーにより良いエクスペリエンスを提供します。

OMT アーキテクチャの影響

PROXX の例が示すように、OMT を使用すると、アプリをより幅広いデバイスで確実に実行できるようになりますが、アプリの速度は向上しません。

  • メインスレッドから処理を移動するだけで、処理を減らすわけではありません。
  • ウェブワーカーとメインスレッド間の通信オーバーヘッドが増加するため、処理がわずかに遅くなることがあります。

トレードオフを考慮する

JavaScript の実行中にメインスレッドがスクロールなどのユーザー操作を処理できるため、合計待機時間がわずかに長くなる可能性があるものの、ドロップされるフレームは少なくなります。フレーム落ちの誤差は小さいため、フレーム落ちよりもユーザーを少し待たせる方が好ましいです。フレーム落ちはミリ秒単位で発生しますが、ユーザーが待ち時間を認識するまでには数百ミリ秒かかります。

デバイス間のパフォーマンスは予測できないため、OMT アーキテクチャの目標は、並列化によるパフォーマンスの向上ではなく、リスクの軽減、つまり、実行時の条件が大きく変化してもアプリをより堅牢にすることです。復元力の向上と UX の改善は、速度のわずかなトレードオフを補って余りあるものです。

ツールに関する注意事項

ウェブワーカーはまだ主流ではないため、webpack や Rollup などのほとんどのモジュール ツールは、ウェブワーカーをそのままサポートしていません。(Parcel はサポートしています)。幸い、ウェブワーカーを webpack と Rollup で動作させるためのプラグインがあります。

まとめ

特にグローバル化が進む市場において、アプリの信頼性とアクセス性をできる限り高めるには、制約のあるデバイスをサポートする必要があります。世界中のほとんどのユーザーは、そのようなデバイスでウェブにアクセスしているからです。OMT は、ハイエンド デバイスのユーザーに悪影響を与えることなく、このようなデバイスのパフォーマンスを向上させる有望な方法です。

また、OMT には次のような二次的なメリットもあります。

  • JavaScript の実行コストを別のスレッドに移動する。
  • 解析コストが移動するため、UI の起動が速くなる可能性があります。これにより、First Contentful Paint や Time to Interactive が短縮され、Lighthouse スコアが向上する可能性があります。

ウェブワーカーは、恐れる必要はありません。Comlink などのツールは、ワーカーから作業を取り除き、幅広いウェブ アプリケーションでワーカーを有効な選択肢にしています。