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 でも、Web Worker とほぼ同様の機能を使用できます。Web Worker は 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 をセットアップします。次に、メインスレッドで 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 や、WebUSBWebRTCウェブ オーディオなどの多くの API にアクセスできないため、そのようなアクセスに依存するアプリの部分をワーカーに配置することはできません。それでも、ワーカーに移動される小さなコードはすべて、ユーザー インターフェースの更新など、そこに存在しなければならないもののためにメインスレッドの空き容量を増やします。

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

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

PROXX: OMT の事例紹介

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

そこで、ゲームの表示状態をロジックから分離するために、ウェブ ワーカーを使用することにしました。

  • メインスレッドは、アニメーションと遷移のレンダリングを処理します。
  • ウェブワーカーはゲームロジックを処理しますが、これは純粋にコンピューティングです。
で確認できます。

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

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
非 OMT バージョンの PROXX での UI 応答時間。

一方、OMT 版では、ゲームが UI の更新を完了するまでに 12 秒かかっています。これはパフォーマンスの低下に見えますが、実際にはユーザーに対するフィードバックの増加につながります。速度が低下しているのは、アプリが非 OMT バージョンよりも多くのフレームを出荷しているためです。OMT ではフレームがまったく配布されていません。ユーザーは、何かが起こっていることを認識し、UI が更新されるたびにプレイを続けることができるため、ゲームの気分が大幅に改善されます。

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
PROXX の OMT バージョンでの UI 応答時間。

これは意識的なトレードオフです。制限のあるデバイスを使用しているユーザーには、ハイエンド デバイスのユーザーにペナルティを課すことなく、より快適なエクスペリエンスを提供することができます。

OMT アーキテクチャの意味

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

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

トレードオフを考慮する

JavaScript の実行中にメインスレッドはスクロールなどのユーザー操作を自由に処理できるため、合計待ち時間がわずかに長くなるとしても、フレーム落ちは少なくなります。フレームのドロップはフレーム落ちよりもユーザーに少しお待たせするようおすすめします。フレームのドロップはミリ秒単位で発生し、ユーザーが待ち時間を認識するまでには数百ミリ秒かかります。

デバイス間のパフォーマンスは予測不可能であるため、OMT アーキテクチャの目標はリスクの軽減、つまり、並列化のパフォーマンス上のメリットではなく、実行時の状況が大きく変化する状況でもアプリの堅牢性を高めることです。復元力の向上と UX の改善は、速度のわずかなトレードオフを上回る価値があります。

ツールに関する注意事項

ウェブワーカーはまだ主流ではないため、webpackRollup などのほとんどのモジュール ツールは、すぐにはサポートされていません。(Parcel は使用できます)。幸いなことに、ウェブ ワーカーを webpack や Rollup と連携させるためのプラグインがあります。

まとめ

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

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

  • JavaScript の実行コストを別のスレッドに移動する。
  • これにより、解析費用が移動し、UI の起動が速くなる可能性があります。 これにより、First Contentful Paint が減少する可能性があります。 またはTime to Interactiveです その結果 Lighthouse のスコア。

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

James Peacock 氏による Unsplash のヒーロー画像