モジュール ワーカーによるウェブのスレッド化

ウェブ ワーカーの JavaScript モジュールにより、負荷の高い処理をバックグラウンド スレッドに簡単に移行できるようになりました。

JavaScript はシングルスレッドです。つまり、一度に 1 つの操作しか実行できません。これは直感的で、ウェブの多くのケースではうまく機能しますが、データ処理、解析、計算、分析などの面倒な作業を行う必要がある場合は問題になる可能性があります。ウェブで配信される複雑なアプリケーションが増えるにつれ、マルチスレッド処理の必要性が高まっています。

ウェブ プラットフォームでは、スレッド化と並列処理の主なプリミティブは Web Workers API です。ワーカーはオペレーティング システム スレッドに対する軽量の抽象化で、スレッド間通信用のメッセージ受け渡し API を公開します。これは、コストの高い計算を実行する場合や、大規模なデータセットを操作する場合に非常に便利です。これにより、メインスレッドをスムーズに実行しながら、1 つ以上のバックグラウンド スレッドでコストの高いオペレーションを実行できます。

ワーカー スクリプトの一般的な使用例を次に示します。ワーカー スクリプトは、メインスレッドからのメッセージをリッスンし、独自のメッセージを返信することで応答します。

page.js:

const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
  console.log(e.data);
});
worker.postMessage('hello');

worker.js:

addEventListener('message', e => {
  if (e.data === 'hello') {
    postMessage('world');
  }
});

Web Worker API は、10 年以上前からほとんどのブラウザで使用可能になっています。つまり、ワーカーはブラウザ サポートに優れ、高度に最適化されていますが、JavaScript モジュールよりも長い間使われています。ワーカーの設計時にモジュール システムが存在しなかったため、ワーカーにコードを読み込んでスクリプトを作成するための API は、2009 年に一般的だった同期スクリプト読み込み手法と同様です。

履歴: 従来のワーカー

Worker コンストラクタは、ドキュメント URL に対する相対 URL であるクラシック スクリプトの URL を受け取ります。このメソッドはすぐに新しいワーカー インスタンスへの参照を返します。このリファレンスは、メッセージング インターフェースと、ワーカーを即座に停止して破棄する terminate() メソッドを公開します。

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

importScripts() 関数は、ウェブワーカー内で追加のコードを読み込むために使用できますが、各スクリプトを取得して評価するためにワーカーの実行を一時停止します。また、従来の <script> タグのようにグローバル スコープでスクリプトを実行します。つまり、あるスクリプトの変数を別のスクリプトの変数で上書きできます。

worker.js:

importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

// global to the whole worker
function sayHello() {
  return 'world';
}

このため、これまでウェブワーカーはアプリケーションのアーキテクチャに多大な影響を及ぼしてきました。デベロッパーは、最新の開発手法をあきらめずにウェブワーカーを使用できるように、巧妙なツールや回避策を作成する必要がありました。たとえば、webpack などのバンドラは、生成されたコードに小さなモジュール ローダ実装を埋め込みます。生成されたコードでは、コードの読み込みには importScripts() を使用しますが、モジュールを関数でラップすることで、変数の競合を回避し、依存関係のインポートとエクスポートをシミュレートします。

モジュール ワーカーを入力

Chrome 80 には、エルゴノミクスとパフォーマンス上のメリットがある JavaScript モジュールを備えたウェブ ワーカー向けの新しいモード(モジュール ワーカー)が搭載されています。Worker コンストラクタが新しい {type:"module"} オプションを受け入れるようになりました。これにより、<script type="module"> に合わせてスクリプトの読み込みと実行が変更されます。

const worker = new Worker('worker.js', {
  type: 'module'
});

モジュール ワーカーは標準の JavaScript モジュールであるため、import ステートメントと export ステートメントを使用できます。すべての JavaScript モジュールと同様に、依存関係は特定のコンテキスト(メインスレッド、ワーカーなど)で 1 回だけ実行され、今後のインポートはすべて、すでに実行されたモジュール インスタンスを参照します。JavaScript モジュールの読み込みと実行もブラウザによって最適化されます。モジュールの実行前にモジュールの依存関係を読み込むことができるため、モジュール ツリー全体を並行して読み込むことができます。モジュールの読み込みでは、解析されたコードもキャッシュに保存されます。つまり、メインスレッドとワーカーで使用されるモジュールは 1 回解析するだけで済みます。

JavaScript モジュールに移行すると、ワーカーの実行をブロックすることなく、コードの遅延読み込みに動的インポートを使用できるようになります。動的インポートでは、グローバル変数に依存せずにインポートされるモジュールのエクスポートが返されるため、importScripts() を使用して依存関係を読み込むよりもはるかに明示的です。

worker.js:

import { sayHello } from './greet.js';
addEventListener('message', e => {
  postMessage(sayHello());
});

greet.js:

import greetings from './data.js';
export function sayHello() {
  return greetings.hello;
}

優れたパフォーマンスを確保するため、モジュール ワーカー内で古い importScripts() メソッドを使用できません。JavaScript モジュールを使用するようにワーカーを切り替えると、すべてのコードが厳格モードで読み込まれます。もう 1 つの重要な変更点は、JavaScript モジュールのトップレベル スコープの this の値が undefined であるのに対し、従来のワーカーでは値がワーカーのグローバル スコープになったことです。幸い、これまでもグローバル スコープへの参照を提供する self グローバルが存在していました。DOM 内だけでなく、Service Worker を含むすべてのタイプのワーカーで使用できます。

modulepreload を使用してワーカーをプリロードする

モジュール ワーカーによるパフォーマンスの大幅な改善の 1 つは、ワーカーとその依存関係をプリロードできることです。モジュール ワーカーでは、スクリプトは標準の JavaScript モジュールとして読み込まれて実行されます。つまり、modulepreload を使用してプリロードすることも、事前に解析することもできます。

<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">

<script>
  addEventListener('load', () => {
    // our worker code is likely already parsed and ready to execute!
    const worker = new Worker('worker.js', { type: 'module' });
  });
</script>

プリロード済みモジュールは、メインスレッドとモジュール ワーカーの両方で使用できます。これは、両方のコンテキストでインポートされるモジュールの場合や、モジュールがメインスレッドとワーカーのどちらで使用されるかを事前に把握できない場合に役立ちます。

以前は、ウェブ ワーカー スクリプトのプリロードに使用できるオプションは限定的であり、必ずしも信頼できるものではありませんでした。クラシック ワーカーにはプリロード用の独自の「ワーカー」リソースタイプがありましたが、<link rel="preload" as="worker"> を実装しているブラウザはありませんでした。そのため、ウェブワーカーのプリロードに使用できる主要な方法は、HTTP キャッシュに完全に依存した <link rel="prefetch"> を使用することでした。適切なキャッシュ ヘッダーと組み合わせて使用することで、ワーカーをインスタンス化する際にワーカー スクリプトのダウンロードを待つ必要がなくなりました。ただし、modulepreload とは異なり、この手法では依存関係のプリロードや事前解析はサポートされていません。

共有ワーカーの場合

共有ワーカーは更新され、Chrome 83 で JavaScript モジュールがサポートされます。専用ワーカーと同様に、{type:"module"} オプションを使用して共有ワーカーを作成すると、ワーカー スクリプトが従来のスクリプトではなくモジュールとして読み込まれるようになりました。

const worker = new SharedWorker('/worker.js', {
  type: 'module'
});

JavaScript モジュールをサポートする前は、SharedWorker() コンストラクタには URL とオプションの name 引数のみが必要でした。これは、従来の共有ワーカーの使用では引き続き機能しますが、モジュール共有ワーカーを作成するには、新しい options 引数を使用する必要があります。使用可能なオプションは、以前の name 引数に優先する name オプションを含め、専用ワーカーのオプションと同じです。

Service Worker についてはどうでしょうか。

Service Worker の仕様は、JavaScript モジュールをエントリ ポイントとして受け入れ、モジュール ワーカーと同じ {type:"module"} オプションを使用するようにすでに更新されていますが、この変更はブラウザにまだ実装されていません。これにより、次のコードを使用し、JavaScript モジュールを使用して Service Worker をインスタンス化できるようになります。

navigator.serviceWorker.register('/sw.js', {
  type: 'module'
});

このたび、仕様が更新されたため、各ブラウザで新しい動作の実装が開始されました。JavaScript モジュールを Service Worker に取り込むことに関連して追加の複雑さが生じるため、この作業には時間がかかります。Service Worker の登録では、更新をトリガーするかどうかを決定するときに、インポートしたスクリプトと以前のキャッシュ バージョンを比較する必要があります。また、Service Worker で使用する JavaScript モジュールにも実装する必要があります。また、Service Worker は、更新の確認時に特定のケースでスクリプトのキャッシュをバイパスできるようにする必要があります。

その他のリソースと参考資料