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

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

スルマ
スルマ

過去 20 年間で、ウェブは、スタイルと画像が少ない静的なドキュメントから、複雑で動的なアプリケーションへと劇的に進化しました。ただ、現状はほとんど変わっていません。それは、サイトをレンダリングして JavaScript を実行する処理を行うスレッドが、ブラウザタブごとに 1 つだけ(一部例外がある)ということです。

その結果、メインスレッドは非常に負荷がかかりすぎています。ウェブアプリの複雑さが増すと、メインスレッドがパフォーマンス上の大きなボトルネックになります。さらに悪いことに、デバイスの機能がパフォーマンスに大きな影響を与えるため、特定のユーザーのメインスレッドでコードを実行するのにかかる時間はほとんど予測できません。このような予測の困難さは、制約が厳しい多機能スマートフォンから高性能でリフレッシュ レートの高い主力機まで、ユーザーがますます多様なデバイスからウェブにアクセスするにつれて、さらに大きくなっていきます。

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

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

JavaScript は、デフォルトではメインスレッド上でタスクを実行するシングルスレッド言語です。しかし、ウェブ ワーカーは、デベロッパーが別のスレッドをスピンアップして、メインスレッドからの作業を処理できるようにすることで、メインスレッドから一種のエスケープ ハッチを提供します。ウェブ ワーカーの範囲は限られており、DOM に直接アクセスすることはできませんが、メインスレッドを圧迫するような相当量の作業が必要な場合には大きなメリットがあります。

ウェブに関する主な指標に関しては、メインスレッド以外で処理を実行すると便利です。特に、メインスレッドからウェブ ワーカーに作業をオフロードすると、メインスレッドの競合を減らすことができ、Interaction to Next Paint(INP)First Input Delay(FID) などの重要な応答性の指標を改善できます。メインスレッドは処理する作業が少ないほど、ユーザーの操作により迅速に応答できます。

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

ウェブワーカーによるスレッド化

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

JavaScript でも、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 つの Web Worker で複数のオペレーションを処理すると、すぐに手に負えなくなります。パラメータだけでなくメッセージ内のオペレーションもエンコードし、リクエストに対するレスポンスを照合するためのブックキーピングを行う必要があります。ウェブワーカーが広く普及していなかったのは、おそらくその複雑さが原因です。

しかし、メインスレッドとウェブ ワーカー間の通信の難しさを解消できれば、このモデルは多くのユースケースに非常に適している可能性があります。幸い、そのような処理を行うライブラリがあります。

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 や多くの API(WebUSBWebRTCウェブ オーディオなど)にアクセスできないため、このようなアクセスに依存するアプリの要素をワーカーに配置することはできません。それでも、コードをワーカーに移動するたびに、ユーザー インターフェースの更新など、そこに追加すべきもののためにメインスレッドのヘッドルームが増えます。

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

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

PROXX: OMT の事例紹介

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

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

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

OMT は PROXX のフィーチャー フォンのパフォーマンスに興味深い効果をもたらしました。OMT 以外のバージョンでは、UI はユーザーが操作してから 6 秒間フリーズします。フィードバックはなく、ユーザーは別の操作ができるようになるまで 6 秒待たなければなりません。

非 OMT バージョンの PROXX での UI 応答時間。

しかし、OMT バージョンでは、ゲームが UI の更新を完了するまでに 12 秒かかります。これはパフォーマンスの低下に見えますが、実際にはユーザーからのフィードバックの増加につながります。速度低下は、アプリがフレームをまったく配送していない OMT 以外のバージョンよりも多くのフレームを配送しているために発生します。そのため、ユーザーは何が起こっているかがわかるため、UI が更新されてもプレイを続けることができるため、ゲームが大幅に改善されています。

OMT バージョンの PROXX での UI 応答時間。

これは意識的なトレードオフです。つまり、ハイエンド デバイスのユーザーにペナルティをかけることなく、制約のあるデバイスのユーザー エクスペリエンスをより快適に提供するということです。

OMT アーキテクチャの影響

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

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

トレードオフを考慮する

JavaScript の実行中にメインスレッドはスクロールなどのユーザー操作を自由に処理できるため、総待機時間が少し長くなってもフレーム落ちは少なくなります。フレームをドロップするよりも、ユーザーに少し待機させる方が、フレームをドロップするよりも誤差の範囲が小さくなるからです。フレームのドロップはミリ秒単位で発生しますが、ユーザーが待ち時間を検知するまでに数百ミリ秒かかります。

デバイス間でパフォーマンスは予測できないため、OMT アーキテクチャの目標は、並列化によるパフォーマンス上のメリットではなく、リスクを低減することです。つまり、ランタイム条件が変動する状況でもアプリをより堅牢にすることです。復元力の向上と UX の改善は、スピードを犠牲にすることなく、大きなメリットとなります。

ツールに関する注意事項

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

まとめ

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

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

ウェブワーカーも心配する必要はありません。Comlink のようなツールは、ワーカーの仕事を取り除き、幅広いウェブ アプリケーションに有効な選択肢となっています。

Unsplash のヒーロー画像(James Peacock 作成)