スクリプトの評価と時間のかかるタスク

スクリプトを読み込む場合、ブラウザが実行前にスクリプトを評価するのに時間がかかるため、処理に時間がかかる可能性があります。スクリプトの評価の仕組みと、ページの読み込み中に長時間のタスクが発生しないようにする方法をご確認ください。

Interaction to Next Paint(INP)の最適化に関して言えば、ほとんどのアドバイスはインタラクション自体を最適化することです。たとえば、長いタスクの最適化ガイドでは、setTimeoutisInputPending などでの yield などの手法が説明されています。これらの手法は、長いタスクを回避してメインスレッドに余裕を持たせるため便利です。これにより、1 つの長いタスクを待つのではなく、インタラクションやその他のアクティビティをより早く実行する機会を増やすことができます。

しかし、スクリプト自体を読み込むことから生じる時間のかかるタスクについてはどうでしょうか。これらのタスクはユーザー操作に干渉し、読み込み中にページの INP に影響する可能性があります。このガイドでは、スクリプト評価によって開始されたタスクをブラウザがどのように処理するかについて説明します。また、ページの読み込み中のユーザー入力に対する応答をメインスレッドが受けやすくするために、スクリプト評価の処理を分割する方法についても説明します。

スクリプト評価とは何ですか?

JavaScript を多数含むアプリケーションをプロファイリングしたことがある場合、問題の原因に「Assess Script」というラベルが付いている長いタスクに気付いたことがあるかもしれません。

Chrome DevTools のパフォーマンス プロファイラで可視化されるようなスクリプト評価作業。この処理により起動時に長時間のタスクが発生し、メインスレッドがユーザー操作に応答できなくなります。
Chrome DevTools のパフォーマンス プロファイラに表示されるスクリプト評価の動作。このケースでは、メインスレッドが他の処理(ユーザー操作を促進するタスクなど)を行うのを妨げる長時間のタスクが発生するほど、その処理は十分です。

JavaScript は実行前のジャストインタイムでコンパイルされるため、スクリプト評価はブラウザで JavaScript を実行するうえで不可欠な要素です。スクリプトが評価されると、まずエラーがないか解析されます。パーサーがエラーを検出しない場合、スクリプトはバイトコードにコンパイルされ、実行を続行できます。

その必要はありますが、スクリプトの評価には問題が生じる可能性があります。最初のレンダリング後すぐにユーザーがページを操作しようとする可能性があるためです。ただし、ページのレンダリングが完了しても、ページの読み込みが完了したとは限りません。ページがスクリプトの評価で集中しているため、読み込み中に行われる操作が遅れる可能性があります。この時点で目的のインタラクションが達成される保証はありませんが(そのスクリプトを処理するスクリプトがまだ読み込まれていないため)、JavaScript に依存しているインタラクションや、JavaScript にまったく依存していないインタラクションが存在する可能性があります。

スクリプトと評価するタスクの関係

スクリプト評価を行うタスクがどのように開始されるかは、読み込むスクリプトが通常の <script> 要素を介して読み込まれるのか、スクリプトが type=module で読み込まれたモジュールなのかによって異なります。ブラウザは処理方法が異なる傾向があるため、主要なブラウザ エンジンによるスクリプト評価の処理方法に関して、各エンジンのスクリプト評価動作の違いについて説明します。

<script> 要素を含むスクリプトの読み込み

スクリプトを評価するためにディスパッチされるタスクの数は、一般的に、ページの <script> 要素の数と直接関係があります。各 <script> 要素により、リクエストされたスクリプトを評価するタスクが開始されます。これにより、解析、コンパイル、実行が可能になります。Chromium ベースのブラウザ、Safari、Firefox が該当します。

十分なデータが必要なのは、本番用のスクリプトの管理にバンドラを使用しており、ページの実行に必要なすべてのものを 1 つのスクリプトにバンドルするように構成したとします。これがウェブサイトの場合、そのスクリプトを評価するためにディスパッチされるタスクは 1 つであると考えられます。これは悪いことでしょうか?そうとも限りません。ただし、そのスクリプトが巨大でない限り、

JavaScript の大きなチャンクの読み込みを避けることでスクリプト評価作業を分割し、追加の <script> 要素を使用してより小さな個々のスクリプトを読み込むことができます。

ページの読み込み時に読み込む JavaScript はできる限り最小限に抑える必要がありますが、スクリプトを分割することで、メインスレッドをブロックする可能性のある 1 つの大きなタスクではなく、メインスレッドをまったくブロックしない小さなタスク、または少なくとも最初に実行したタスクよりも少ないタスクの数を増やすことができます。

Chrome DevTools のパフォーマンス プロファイラで可視化された、スクリプト評価に関連する複数のタスク。サイズの大きなスクリプトが少なくなるのではなく、複数の小さなスクリプトが読み込まれるため、タスクが長時間タスクになる可能性が低くなり、メインスレッドがユーザー入力により迅速に応答できるようになります。
ページの HTML に複数の <script> 要素が存在するため、スクリプトを評価するために複数のタスクが生成されています。この方法は、1 つの大きなスクリプト バンドルをユーザーに送信して、メインスレッドをブロックする可能性が高くなります。

スクリプト評価のタスクの分割は、インタラクション中に実行されるイベント コールバック中に破棄することにある程度似ていると考えることができます。しかし、スクリプト評価では、主スレッドをブロックする可能性のある少数の大きなスクリプトではなく、読み込みのメカニズムによって、読み込む JavaScript が複数の小さなスクリプトに分割されます。

<script> 要素と type=module 属性を使用したスクリプトの読み込み

<script> 要素の type=module 属性を使用して、ES モジュールをブラウザでネイティブに読み込めるようになりました。この方法でスクリプトを読み込むと、特にインポート マップと組み合わせて使用すると、本番環境で使用するためにコードを変換する必要がないなど、デベロッパーにとってメリットがあります。ただし、スクリプトをこの方法で読み込むと、ブラウザごとに異なるタスクがスケジュールされます。

Chromium ベースのブラウザ

Chrome などのブラウザ(またはそれから派生したブラウザ)では、type=module 属性を使用して ES モジュールを読み込むと、type=module を使用していない場合とは異なる種類のタスクが生成されます。たとえば、各モジュール スクリプトのタスクには、「Compile module」というラベルの付いたアクティビティが含まれます。

Chrome DevTools で視覚化したように、モジュールのコンパイルは複数のタスクで動作します。
Chromium ベースのブラウザでのモジュール読み込み動作。各モジュール スクリプトは、評価前に内容をコンパイルするために Compile module の呼び出しを生成します。

モジュールをコンパイルすると、その後そのモジュールの中で実行されるコードにより、[モジュールを評価] というラベルが付いたアクティビティが開始されます。

Chrome DevTools のパフォーマンス パネルに表示されるモジュールのジャストインタイム評価。
モジュール内のコードが実行されると、そのモジュールはジャストインタイムで評価されます。

この場合、少なくとも Chrome や関連するブラウザでは、ES モジュールを使用するとコンパイル手順が分割されます。これは、時間のかかるタスクを管理するという点では明らかに利点となります。しかし、モジュール評価の作業には、避けられない費用がかかります。提供する JavaScript はできる限り少なくする必要がありますが、ブラウザに関係なく ES モジュールを使用すると次のようなメリットがあります。

  • すべてのモジュール コードは、厳格モードで自動的に実行されます。これにより、厳密でないコンテキストでは実現できなかった JavaScript エンジンによる最適化が可能になります。
  • type=module を使用して読み込まれたスクリプトは、デフォルトでは「遅延」として扱われます。type=module で読み込まれたスクリプトで async 属性を使用すると、この動作を変更できます。

Safari と Firefox

Safari や Firefox にモジュールが読み込まれると、それぞれが個別のタスクで評価されます。つまり、理論的には、静的な import ステートメントのみで構成されるトップレベル モジュールを 1 つ他のモジュールに読み込むことができます。読み込まれたモジュールごとに、個別のネットワーク リクエストとそれを評価するためのタスクが発生します。

動的な import() を使用したスクリプトの読み込み

スクリプトを読み込むもう一つの方法は、動的 import() です。ES モジュールの先頭に配置する必要がある静的な import ステートメントとは異なり、動的な import() 呼び出しはスクリプト内の任意の場所に使用して、JavaScript のチャンクをオンデマンドで読み込むことができます。この手法はコード分割と呼ばれます。

INP の向上に関して、動的 import() には 2 つの利点があります。

  1. 後で読み込むためにモジュールを延期することで、その時点で読み込まれる JavaScript の量を減らすことで、起動時のメインスレッドの競合を減らすことができます。これによりメインスレッドが解放され、ユーザー操作に対する応答性が向上します。
  2. 動的な import() 呼び出しが行われると、各呼び出しによって、各モジュールのコンパイルと評価がそれぞれのタスクに効果的に分離されます。当然ながら、非常に大きなモジュールを読み込む動的な import() は、かなり大きなスクリプト評価タスクを開始します。そのため、動的 import() 呼び出しと同時にインタラクションが発生すると、メインスレッドがユーザー入力に応答する機能が妨げられる可能性があります。したがって、読み込む JavaScript をできる限り少なくすることが依然として非常に重要です。

動的な import() 呼び出しは、すべての主要なブラウザ エンジンで同じように動作します。つまり、結果として得られるスクリプト評価タスクは、動的にインポートされるモジュールの量と同じです。

ウェブワーカーでスクリプトを読み込む

ウェブワーカーは、特殊な JavaScript のユースケースです。ウェブワーカーはメインスレッドに登録され、ワーカー内のコードは独自のスレッドで実行されます。Web Worker を登録するコードはメインスレッドで実行されますが、Web Worker 内のコードは実行されないという意味で、これは非常に便利です。これにより、メインスレッドの輻輳が軽減され、ユーザー操作に対するメインスレッドの応答性が向上します。

メインスレッドの作業を減らすだけでなく、ウェブ ワーカー自体は、importScripts またはモジュール ワーカーをサポートするブラウザで静的な import ステートメントを介して、ワーカーのコンテキストで使用される外部スクリプトを読み込むことができます。その結果、ウェブ ワーカーから要求されたスクリプトはすべて、メインスレッドの外部で評価されます。

トレードオフと考慮事項

スクリプトを別々の小さなファイルに分割することで、少数の大きなファイルを読み込むのではなく、時間のかかるタスクを制限できますが、スクリプトの分割方法を決定する際は、いくつかの点を考慮することが重要です。

圧縮効率

スクリプトを分割する際には、圧縮が重要になります。スクリプトが小さいと、圧縮の効率がいくぶん低下します。スクリプトのサイズが大きいほど、圧縮によるメリットがはるかに大きくなります。圧縮効率を高めると、スクリプトの読み込み時間を可能な限り短縮できますが、スクリプトを十分な小さなチャンクに分割して起動時のインタラクティビティを向上させるには、バランスを取る必要があります。

Bundler は、ウェブサイトが依存するスクリプトの出力サイズを管理するのに最適なツールです。

  • webpack については、SplitChunksPlugin プラグインが役立ちます。アセットのサイズを管理するためのオプションについては、SplitChunksPlugin のドキュメントをご覧ください。
  • Rollupesbuild などの他のバンドラでは、コード内で動的な import() 呼び出しを使用することで、スクリプト ファイルサイズを管理できます。これらのバンドラと Webpack は、動的にインポートされるアセットを自動的に固有のファイルに分割するため、初期バンドルのサイズが大きくならなくなります。

キャッシュの無効化

キャッシュの無効化は、再アクセス時のページの読み込み速度に大きく影響します。大規模なモノリシック スクリプト バンドルを配布する場合、ブラウザ キャッシュに関してデメリットになります。これは、パッケージの更新やバグの修正を通じてファーストパーティ コードを更新すると、バンドル全体が無効になり、再度ダウンロードが必要になるためです。

スクリプトを分割することで、スクリプト評価作業を小さなタスクに分割するだけでなく、リピーターがネットワークからではなくブラウザのキャッシュからより多くのスクリプトを取得する可能性も高くなります。これにより、全体的にページの読み込みが速くなります。

ネストされたモジュールと読み込みパフォーマンス

本番環境に ES モジュールを出荷し、type=module 属性で読み込む場合は、モジュールのネストが起動時間に与える影響に注意する必要があります。モジュールのネストとは、ある ES モジュールが別の ES モジュールを静的にインポートし、そのモジュールが別の ES モジュールを静的にインポートすることです。

// a.js
import {b} from './b.js';

// b.js
import {c} from './c.js';

ES モジュールがバンドルされていない場合、上記のコードによりネットワーク リクエスト チェーンが発生します。<script> 要素から a.js がリクエストされると、b.js に対する別のネットワーク リクエストがディスパッチされ、それに c.js に対する別のリクエストが伴います。これを避ける 1 つの方法は、バンドラを使用することです。ただし、必ずバンドラを設定して、スクリプトを分割してスクリプト評価作業を分散するようにしてください。

バンドラを使用しない場合、ネストされたモジュール呼び出しを回避するもう 1 つの方法は、modulepreload リソースヒントを使用することです。これにより、事前に ES モジュールがプリロードされ、ネットワーク リクエスト チェーンを回避できます。

まとめ

ブラウザ内でスクリプトの評価を最適化することは、間違いなく厄介な作業です。この方法は、ウェブサイトの要件と制約によって異なります。しかし、スクリプトを分割することで、スクリプト評価の作業が多数の小さなタスクに分散されるため、メインスレッドをブロックするのではなく、メインスレッドがユーザー操作をより効率的に処理できるようになります。

ここでは、大規模なスクリプト評価タスクを分割するためにできることをまとめます。

  • type=module 属性を指定せずに <script> 要素を使用してスクリプトを読み込む場合は、非常に大きなスクリプトを読み込まないでください。そのようなスクリプトを使用すると、メインスレッドをブロックする、リソースを大量に消費するスクリプト評価タスクが開始されます。この作業を分割するために、スクリプトをより多くの <script> 要素に分散させます。
  • type=module 属性を使用してブラウザで ES モジュールをネイティブに読み込むと、個々のモジュール スクリプトごとに、評価対象の個々のタスクが開始されます。
  • 動的な import() 呼び出しを使用して、初期バンドルのサイズを小さくします。これはバンドラでも機能します。バンドラは動的にインポートされた各モジュールを「スプリット ポイント」として扱い、動的にインポートされたモジュールごとに別々のスクリプトを生成します。
  • 圧縮効率やキャッシュの無効化などのトレードオフを必ず比較検討してください。スクリプトが大きいほど圧縮率は高くなりますが、より少ないタスクで高コストのスクリプト評価作業が必要になり、ブラウザ キャッシュの無効化が発生し、全体的にキャッシュ効率が低下します。
  • ES モジュールをバンドルせずにネイティブに使用する場合は、modulepreload リソースヒントを使用して起動中の読み込みを最適化します。
  • 通常どおり、できる限り JavaScript の提供は最小限に留めてください。

これは間違いなくバランスの取れた作業ですが、動的 import() を使用してスクリプトを分割し、初期ペイロードを減らすことで、起動のパフォーマンスを向上させ、重要な起動期間中のユーザー操作に適切に対応できます。これにより、INP 指標のスコアが向上し、ユーザー エクスペリエンスが向上します。

Unsplash より、Markus Spiske のヒーロー画像。