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

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

Interaction to Next Paint(INP)を最適化する場合、ほとんどのアドバイスは、インタラクション自体を最適化することです。たとえば、長時間のタスクの最適化ガイドでは、setTimeout による生成などの手法が説明されています。これらの手法は、長いタスクを回避することでメインスレッドに余裕を持たせるというメリットがあります。これにより、単一の長いタスクを待つのではなく、インタラクションやその他のアクティビティをより早く実行できるようになります。

しかし、スクリプト自体を読み込むことによる長いタスクについてはどうでしょうか。これらのタスクはユーザー インタラクションを妨げ、読み込み中のページの INP に影響を与える可能性があります。このガイドでは、スクリプト評価によって開始されたタスクをブラウザがどのように処理するかについて説明します。また、ページの読み込み中にメインスレッドがユーザー入力に対する応答性を高められるよう、スクリプト評価の作業を分割する方法も確認します。

スクリプトの評価とは

JavaScript を大量に消費するアプリケーションをプロファイリングしている場合、問題の原因が [evaluate Script](スクリプトを評価)とラベル付けされている長時間のタスクを見たことがあるかもしれません。

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

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

必要であるとはいえ、スクリプトの評価は問題となる可能性があります。ユーザーが最初に表示された直後にページを操作しようとする可能性があるためです。ただし、ページのレンダリングが完了したからといって、ページの読み込みが完了したとは限りません。ページがスクリプトの評価でビジー状態になるため、読み込み中に行われる操作が遅延することがあります。この時点でインタラクションが発生する保証はありませんが(それを担当するスクリプトがまだ読み込まれていない可能性があるため)、準備ができた JavaScript に依存するインタラクションが存在する可能性があります。または、インタラクティビティが JavaScript にまったく依存していない可能性もあります。

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

スクリプトの評価を行うタスクの起動方法は、読み込むスクリプトが一般的な <script> 要素で読み込まれるか、スクリプトが type=module で読み込まれたモジュールかによって異なります。ブラウザでは処理方法が異なる傾向があるため、主要なブラウザ エンジンによるスクリプト評価の処理方法は、スクリプトの評価動作がブラウザによってどのように異なるかが変わってきます。

<script> 要素で読み込まれたスクリプト

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

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

スクリプトの評価作業を分割するには、JavaScript の大きなチャンクを読み込まないようにし、追加の <script> 要素を使用して個々の小さなスクリプトをロードします。

ページの読み込み中は常に JavaScript をできる限り少なくするよう心がけてください。スクリプトを分割することで、メインスレッドをブロックする大きなタスクではなく、メインスレッドをブロックしない小さなタスクの数を増やせます。

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

スクリプト評価のためのタスクの分割は、インタラクション中で実行されるイベント コールバック中の収益にある程度類似していると考えることができます。ただし、スクリプト評価では、結果を生成するメカニズムによって、ロードする JavaScript が複数の小さなスクリプトに分割されます。メインスレッドをブロックする可能性が高い大規模なスクリプトの数が減るわけではありません。

<script> 要素と type=module 属性で読み込まれたスクリプト

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

Chromium ベースのブラウザ

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

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

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

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

その結果、少なくとも Chrome や関連ブラウザでは、ES モジュールを使用するとコンパイル手順が分割されます。これは、長いタスクを管理するという点では明らかです。ただし、結果としてモジュールの評価作業に伴って、やむを得ないコストが発生します。JavaScript の提供は最小限にするよう心がけてください。ブラウザに関係なく、ES モジュールを使用することで次のようなメリットがあります。

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

Safari と Firefox

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

動的な import() で読み込まれたスクリプト

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

INP を改善するうえで、動的 import() には 2 つの利点があります。

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

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

ウェブワーカーに読み込まれたスクリプト

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

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

トレードオフと考慮事項

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

圧縮効率

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

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

  • 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 モジュールがバンドルされていない場合、上記のコードによってネットワーク リクエスト チェーンが発生します。a.js<script> 要素からリクエストされると、b.js に対して別のネットワーク リクエストがディスパッチされ、c.js に対する別のリクエストが関係します。これを回避する 1 つの方法は、バンドラを使用することです。ただし、スクリプトを分割してスクリプトの評価作業を分散させるようにバンドラを構成するようにしてください。

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

おわりに

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

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

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

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

Markus Spiske 著「Unsplash」のヒーロー画像