メインスレッド外のアーキテクチャを使用すると、アプリの信頼性とユーザー エクスペリエンスを大幅に改善できます。
過去 20 年間で、ウェブは、いくつかのスタイルと画像を含む静的なドキュメントから、複雑で動的アプリケーションへと劇的に進化しました。ただし、1 つだけ大きな変更点があります。サイトのレンダリングと JavaScript の実行を行うスレッドは、ブラウザのタブごとに 1 つだけです(一部の例外を除く)。
その結果、メインスレッドは非常に過負荷になっています。ウェブアプリの複雑さが増すにつれ、メインスレッドはパフォーマンスの大きなボトルネックになります。さらに、デバイスの機能がパフォーマンスに大きな影響を与えるため、特定のユーザーのメインスレッドでコードを実行する時間はほぼ完全に予測不可能です。ユーザーがアクセスするデバイスの種類が、制約の多いフィーチャー フォンから高性能で高リフレッシュ レートのフラッグシップ マシンまで、ますます多様になるにつれて、この予測不可能性はさらに増大するでしょう。
人間の知覚と心理に関する実証データに基づくCore Web Vitals などのパフォーマンス ガイドラインを高度なウェブアプリで確実に満たすには、メインスレッド外(OMT)でコードを実行する方法が必要です。
ウェブワーカーを使用する理由
JavaScript はデフォルトでシングル スレッド言語であり、メインスレッドでタスクを実行します。ただし、Web Worker を使用すると、デベロッパーはメインスレッドとは別のスレッドを作成して、メインスレッドの処理をオフロードできるため、メインスレッドから逃れられるようになります。Web Worker のスコープは限定されており、DOM に直接アクセスすることはできませんが、メインスレッドを圧倒するほどの作業を行う必要がある場合は、非常に有用です。
Core Web Vitals が懸念される場合は、メインスレッドから処理を実行すると効果的です。特に、メインスレッドから Web Worker に処理をオフロードすることで、メインスレッドの競合を減らし、ページのInteraction to Next Paint(INP)応答性指標を改善できます。メインスレッドで処理する作業が少ないと、ユーザー操作にすばやく応答できます。
特に起動時にメインスレッドの処理を減らすと、長いタスクを減らすことで Largest Contentful Paint(LCP)の改善につながる可能性があります。LCP 要素のレンダリングには、頻繁に使用される一般的な LCP 要素であるテキストや画像のレンダリングにメインスレッドの時間が必要です。メインスレッドの処理を全体的に減らすことで、ページの LCP 要素が、代わりにウェブワーカーが処理できる負荷の高い処理によってブロックされる可能性が低くなります。
ウェブワーカーによるスレッド処理
他のプラットフォームでは通常、スレッドに関数を渡して、プログラムの他の部分と並行して実行することで、並列処理をサポートしています。どちらのスレッドからも同じ変数にアクセスできます。また、これらの共有リソースへのアクセスをミューテックスとセマフォで同期して、競合状態を防ぐことができます。
JavaScript では、2007 年から存在し、2012 年以降すべての主要ブラウザでサポートされているウェブワーカーから、ほぼ同様の機能を利用できます。Web Worker はメインスレッドと並行して実行されますが、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: ウェブワーカーの負担を軽減
Comlink は、postMessage
の詳細を気にすることなくウェブワーカーを使用できるようにすることを目的としたライブラリです。Comlink を使用すると、スレッド処理をサポートする他のプログラミング言語と同様に、Web Worker とメインスレッド間で変数を共有できます。
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 や WebUSB、WebRTC、Web Audio などの多くの API にアクセスできないため、このようなアクセスに依存するアプリの部分をワーカーに配置することはできません。それでも、ワーカーに移行された小さなコードは、ユーザー インターフェースの更新など、メインスレッドに存在する必要があるもののために、メインスレッドのヘッドルームを増やします。
ウェブ デベロッパーにとっての問題の 1 つは、ほとんどのウェブアプリが Vue や React などの UI フレームワークに依存してアプリ内のすべてをオーケストレートすることです。すべてがフレームワークのコンポーネントであるため、本質的に DOM に関連付けられています。そのため、OMT アーキテクチャへの移行は困難と思われます。
ただし、UI に関する懸念事項が状態管理などの他の懸念事項から分離されたモデルに移行すると、フレームワーク ベースのアプリでもウェブワーカーが非常に便利になります。PROXX ではまさにそのアプローチを取ります。
PROXX: OMT のケーススタディ
Google Chrome チームは、オフラインでの動作や魅力的なユーザー エクスペリエンスなど、プログレッシブ ウェブアプリの要件を満たす「Minesweeper」クローンとして PROXX を開発しました。残念ながら、初期バージョンのゲームは、フィーチャー フォンなどの制約のあるデバイスでパフォーマンスが低下しました。このため、メインスレッドがボトルネックであることが判明しました。
チームは、ウェブワーカーを使用してゲームのビジュアル ステートとロジックを分離することにしました。
- メインスレッドは、アニメーションと遷移のレンダリングを処理します。
- ウェブワーカーは、純粋に計算的なゲームロジックを処理します。
OMT は、PROXX のフィーチャー フォンのパフォーマンスに興味深い効果をもたらしました。OMT 以外のバージョンでは、ユーザーが UI を操作すると、UI が 6 秒間フリーズします。フィードバックはなく、ユーザーは 6 秒間待ってから他の操作を行わなければなりません。
一方、OMT バージョンでは、UI の更新が完了するまでに 12 秒かかります。これはパフォーマンスの低下のように見えますが、実際にはユーザーへのフィードバックの増加につながります。遅延が発生するのは、OMT 以外のバージョンではフレームがまったく送信されないのに対し、OMT バージョンではフレームがより多く送信されるためです。ユーザーは何か処理が行われていることを認識し、UI の更新中にゲームを続けることができるため、ゲームの操作性が大幅に向上します。
これは意識的なトレードオフです。制約のあるデバイスのユーザーには、ハイエンド デバイスのユーザーを不利にすることなく、より良い感覚のエクスペリエンスを提供します。
OMT アーキテクチャの影響
PROXX の例に示すように、OMT を使用すると、より幅広いデバイスでアプリを安定して実行できますが、アプリの速度は向上しません。
- 処理をメインスレッドから移動しているだけで、処理を減らしているわけではありません。
- Web Worker とメインスレッド間の追加の通信オーバーヘッドにより、処理速度がわずかに遅くなることがあります。
トレードオフを考慮する
JavaScript の実行中にメインスレッドがスクロールなどのユーザー操作を自由に処理できるため、合計待ち時間がわずかに長くなる可能性がありますが、フレームのドロップは少なくなります。フレームをドロップするよりも、ユーザーに少し待ってもらうことをおすすめします。フレームをドロップする場合の誤差の範囲は小さくなります。フレームのドロップはミリ秒単位で行われますが、ユーザーが待ち時間を認識するまでに数百ミリ秒の猶予があります。
デバイス間でパフォーマンスが予測できないため、OMT アーキテクチャの目標は、並列化によるパフォーマンス上のメリットではなく、リスクの軽減(ランタイム条件の変化に応じてアプリの堅牢性を高めること)です。レジリエンスの向上と UX の改善は、速度のわずかなトレードオフに十分見合う価値があります。
ツールに関する注意事項
ウェブワーカーはまだ主流ではないため、webpack や Rollup などのほとんどのモジュール ツールは、ウェブワーカーを標準でサポートしていません。(Parcel は対応しています)。幸い、webpack と Rollup でウェブワーカーを動作させるプラグインがあります。
- webpack の worker-plugin
- Rollup の rollup-plugin-off-main-thread
まとめ
特にグローバル化が進む市場において、アプリの信頼性とアクセス性を可能な限り高めるには、制約のあるデバイスをサポートする必要があります。制約のあるデバイスは、世界中のほとんどのユーザーがウェブにアクセスする方法です。OMT は、ハイエンド デバイスのユーザーに悪影響を及ぼすことなく、このようなデバイスのパフォーマンスを向上させる有望な方法です。
また、OMT には次のような二次的なメリットもあります。
- JavaScript の実行コストを別のスレッドに移動します。
- 解析コストが移動するため、UI の起動が速くなる可能性があります。これにより、First Contentful Paint やTime to Interactive が短縮され、Lighthouse スコアが向上する可能性があります。
Web Worker は怖いものではありません。Comlink などのツールは、作業者の負担を軽減し、幅広いウェブ アプリケーションで実用的なものになっています。