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

メインスレッド外のアーキテクチャでは、アプリの信頼性とユーザー エクスペリエンスを大幅に改善できます。

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

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

人間の知覚や心理学に関する経験的データに基づく Core Web Vitals のようなパフォーマンス ガイドラインに洗練されたウェブアプリを確実に準拠させるには、メインスレッド(OMT)からコードを実行する方法が必要です。

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

JavaScript はデフォルトでシングル スレッド言語であり、メインスレッドタスクを実行します。一方、ウェブ ワーカーは、デベロッパーがメインスレッド以外で処理を行うスレッドを別途作成できるようにすることで、メインスレッドから脱出する方法を提供しています。Web Worker のスコープは限定されており、DOM に直接アクセスすることはできませんが、メインスレッドを圧迫するような大量の処理を行う必要がある場合は、非常に有用です。

Core Web Vitals が懸念される場合は、メインスレッド以外で処理を実行するのが有用です。特に、メインスレッドから Web Worker に処理をオフロードすることで、メインスレッドの競合を減らし、ページの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);
});

確かに、このアプローチは若干限定的です。これまで、Web Worker は主に、負荷の高い処理をメインスレッドから分離するために使用されてきました。複数のオペレーションを 1 つのウェブ ワーカーで処理しようとすると、すぐに扱いにくくなります。メッセージ内のパラメータだけでなくオペレーションもエンコードする必要があり、リクエストとレスポンスを照合するためにブックキーピングを行う必要があります。ウェブワーカーが広く普及していないのは、この複雑さにあると考えられます。

しかし、メインスレッドとウェブワーカー間の通信の難しさを少しでも取り除けば、このモデルは多くのユースケースに非常に適しています。幸い、これを行うライブラリがあります。

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

Comlink を設定するには、Web Worker にインポートし、メインスレッドに公開する一連の関数を定義します。次に、メインスレッドで 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 や、WebUSBWebRTCWeb Audio などの多くの API にアクセスできないため、このようなアクセスに依存するアプリの部分をワーカーに配置することはできません。それでも、ワーカーに移行された小さなコードは、ユーザー インターフェースの更新など、メインスレッドに存在する必要があるもののために、メインスレッドのヘッドルームを増やします。

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

ただし、UI の問題と状態管理などの他の問題とを切り離すモデルに移行すると、フレームワーク ベースのアプリでもウェブ ワーカーは非常に役立ちます。PROXX ではまさにそのアプローチを取ります。

PROXX: OMT の事例紹介

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

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

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

OMT は、PROXX のフィーチャー フォンのパフォーマンスに興味深い影響を与えました。OMT 以外のバージョンでは、ユーザーが UI を操作すると、UI が 6 秒間フリーズします。フィードバックは表示されず、ユーザーは 6 秒待ってから次の操作を行う必要がある。

OMT 以外のバージョンの PROXX の UI レスポンス時間。

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

PROXX の OMT バージョンの UI レスポンス時間。

これは意識的なトレードオフです。高性能デバイスのユーザーに不利な影響を与えることなく、制約のあるデバイスのユーザーにより良い感覚を与えることを目的としています。

OMT アーキテクチャの影響

PROXX の例が示すように、OMT を使用すると、アプリをさまざまなデバイスで確実に実行できますが、アプリが高速化するわけではありません。

  • 処理はメインスレッドから移動しただけで、処理を減らしているわけではありません。
  • Web Worker とメインスレッド間の追加の通信オーバーヘッドにより、処理速度がわずかに遅くなることがあります。

トレードオフを考慮する

JavaScript の実行中にメインスレッドがスクロールなどのユーザー操作を自由に処理できるため、合計待ち時間がわずかに長くなる可能性がありますが、フレームのドロップは少なくなります。フレームをドロップするよりも、ユーザーに少し待ってもらうことをおすすめします。フレームをドロップする場合の誤差の範囲は小さくなります。フレームのドロップはミリ秒単位で行われますが、ユーザーが待ち時間を認識するまでに数百ミリ秒の猶予があります。

デバイス間でパフォーマンスが予測できないため、OMT アーキテクチャの目標は、並列化によるパフォーマンス上のメリットではなく、リスクの軽減(ランタイム条件の変化に応じてアプリの堅牢性を高めること)です。レジリエンスの向上と UX の改善は、速度のわずかなトレードオフに十分見合う価値があります。

ツールに関する注意事項

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

まとめ

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

また、OMT には次のような二次的な利点もあります。

Web Worker は怖いものではありません。Comlink のようなツールはワーカーの作業を不要にして、幅広いウェブ アプリケーションで現実的な選択肢となっています。