スクリプトを読み込む際、ブラウザが実行前にスクリプトを評価するのに時間がかかり、長時間タスクが発生することがあります。スクリプトの評価の仕組みと、ページ読み込み中に長いタスクが発生しないようにするための対策について説明します。
Interaction to Next Paint(INP)の最適化に関しては、ほとんどのアドバイスはインタラクション自体を最適化することです。たとえば、長いタスクの最適化に関するガイドでは、setTimeout を使用したイールドなどの手法について説明しています。これらの手法は、長いタスクを回避することでメインスレッドに余裕を持たせ、インタラクションや他のアクティビティが単一の長いタスクを待つよりも早く実行される機会を増やすことができるため、有益です。
ただし、スクリプトの読み込み自体に起因する長いタスクはどうでしょうか?これらのタスクはユーザー インタラクションを妨げ、読み込み中のページの INP に影響する可能性があります。このガイドでは、ブラウザがスクリプト評価によって開始されたタスクを処理する方法について説明し、スクリプト評価の作業を分割して、ページの読み込み中にメインスレッドがユーザー入力により応答できるようにする方法について説明します。
スクリプト評価とは
大量の JavaScript を配信するアプリケーションをプロファイリングしたことがある場合は、原因が [Evaluate Script] とラベル付けされた長いタスクが表示されたことがあるかもしれません。
JavaScript は実行前にジャストインタイムでコンパイルされるため、スクリプトの評価はブラウザで JavaScript を実行するうえで不可欠な部分です。スクリプトが評価されると、まずエラーが解析されます。パーサーでエラーが見つからなかった場合、スクリプトは バイトコードにコンパイルされ、実行に進むことができます。
スクリプトの評価は必要ですが、ユーザーが最初のレンダリング直後にページを操作しようとする可能性があるため、問題が生じる可能性があります。ただし、ページがレンダリングされたからといって、ページの読み込みが完了したとは限りません。ページの読み込み中に発生するインタラクションは、ページがスクリプトの評価でビジー状態になっているため、遅延する可能性があります。この時点でインタラクションが発生する保証はありません(インタラクションを担当するスクリプトがまだ読み込まれていない可能性があるため)。ただし、準備が整っている JavaScript に依存するインタラクションや、インタラクティブ性が JavaScript にまったく依存しないインタラクションが存在する可能性があります。
スクリプトとそれを評価するタスクの関係
スクリプト評価を担当するタスクの開始方法は、読み込むスクリプトが一般的な <script> 要素で読み込まれるか、type=module で読み込まれるモジュール スクリプトであるかによって異なります。ブラウザは処理方法が異なる傾向があるため、主要なブラウザ エンジンがスクリプト評価をどのように処理するかについては、スクリプト評価の動作が異なる場合に説明します。
<script> 要素で読み込まれたスクリプト
スクリプトの評価のためにディスパッチされるタスクの数は、一般的にページ上の <script> 要素の数と直接的な関係があります。各 <script> 要素は、リクエストされたスクリプトを評価するタスクを開始し、スクリプトを解析、コンパイル、実行できるようにします。これは、Chromium ベースのブラウザ、Safari、Firefox の場合です。
これがなぜ重要なのでしょうか。たとえば、本番環境のスクリプトを管理するためにバンドラーを使用しており、ページを実行するために必要なものをすべて 1 つのスクリプトにバンドルするように構成したとします。ウェブサイトがこのケースに該当する場合、そのスクリプトを評価するために 1 つのタスクがディスパッチされることが想定されます。これは悪いことですか?必ずしもそうではありません。ただし、スクリプトが巨大な場合は除きます。
JavaScript の大きなチャンクの読み込みを避けて、追加の <script> 要素を使用して個々の小さなスクリプトを読み込むことで、スクリプト評価の作業を分割できます。
ページ読み込み時に読み込む JavaScript の量をできるだけ少なくするよう常に努める必要がありますが、スクリプトを分割すると、メインスレッドをブロックする可能性のある 1 つの大きなタスクではなく、メインスレッドをまったくブロックしない、または少なくとも元の状態よりもブロックしない、より多くの小さなタスクが作成されます。
<script> 要素が存在するため、スクリプトを評価するために複数のタスクが生成されました。これは、1 つの大きなスクリプト バンドルをユーザーに送信するよりも望ましい方法です。後者の場合、メインスレッドがブロックされる可能性が高くなります。スクリプト評価のタスクを分割することは、インタラクション中に実行されるイベント コールバック中のイールドに似ています。ただし、スクリプト評価では、読み込む JavaScript を、メインスレッドをブロックする可能性の高い少数の大きなスクリプトではなく、複数の小さなスクリプトに分割するイールド メカニズムが使用されます。
<script> 要素と type=module 属性で読み込まれたスクリプト
<script> 要素の type=module 属性を使用すると、ブラウザで ES モジュールをネイティブに読み込むことができるようになりました。このスクリプト読み込みのアプローチには、特に インポート マップと組み合わせて使用する場合に、本番環境で使用するコードを変換する必要がないなど、デベロッパー エクスペリエンス上のメリットがあります。ただし、この方法でスクリプトを読み込むと、ブラウザごとに異なるタスクがスケジュールされます。
Chromium ベースのブラウザ
Chrome などのブラウザ(または Chrome から派生したブラウザ)では、type=module 属性を使用して ES モジュールを読み込むと、通常 type=module を使用しない場合に表示されるタスクとは異なる種類のタスクが生成されます。たとえば、各モジュール スクリプトのタスクが実行され、モジュールのコンパイルというラベルの付いたアクティビティが実行されます。
モジュールがコンパイルされると、その後に実行されるコードは、モジュールの評価というラベルの付いたアクティビティを開始します。
少なくとも Chrome と関連ブラウザでは、ES モジュールを使用するとコンパイル ステップが分割されます。これは長いタスクの管理という点で明確なメリットがありますが、結果として生じるモジュールの評価作業は、避けられないコストが発生することを意味します。できるだけ少ない JavaScript を配信するよう努めるべきですが、ブラウザに関係なく ES モジュールを使用すると、次のようなメリットがあります。
- すべてのモジュール コードは自動的に厳格モードで実行されます。これにより、厳格でないコンテキストでは実行できない JavaScript エンジンによる最適化が可能になります。
type=moduleを使用して読み込まれたスクリプトは、デフォルトで遅延されたものとして扱われます。type=moduleで読み込まれたスクリプトでasync属性を使用すると、この動作を変更できます。
Safari と Firefox
Safari と Firefox でモジュールが読み込まれると、それぞれが別のタスクで評価されます。つまり、理論的には、静的 import ステートメントのみで構成される単一の最上位モジュールを他のモジュールに読み込むことができ、読み込まれた各モジュールで、評価するための個別のネットワーク リクエストとタスクが発生します。
動的 import() で読み込まれたスクリプト
動的 import() は、スクリプトを読み込むもう 1 つの方法です。ES モジュールの先頭に記述する必要がある静的な import ステートメントとは異なり、動的な import() 呼び出しはスクリプト内の任意の場所に記述して、JavaScript のチャンクをオンデマンドで読み込むことができます。この手法はコード分割と呼ばれます。
動的 import() には、INP の改善に関して次の 2 つの利点があります。
- 読み込みが後回しにされるモジュールは、起動時に読み込まれる JavaScript の量を減らすことで、メインスレッドの競合を減らします。これにより、メインスレッドが解放され、ユーザー操作への応答性が向上します。
- 動的
import()呼び出しが行われると、各呼び出しは各モジュールのコンパイルと評価をそれぞれのタスクに効果的に分離します。もちろん、非常に大きなモジュールを読み込む動的import()は、かなり大きなスクリプト評価タスクを開始します。インタラクションが動的import()呼び出しと同時に発生した場合、メインスレッドがユーザー入力に応答する能力を妨げる可能性があります。そのため、できるだけ少ない JavaScript を読み込むことが依然として非常に重要です。
動的 import() 呼び出しは、すべての主要なブラウザ エンジンで同様に動作します。結果として得られるスクリプト評価タスクは、動的にインポートされるモジュールの数と同じになります。
ウェブ ワーカーで読み込まれたスクリプト
ウェブ ワーカーは、JavaScript の特別なユースケースです。ウェブ ワーカーはメインスレッドに登録され、ワーカー内のコードは独自のスレッドで実行されます。これは、ウェブ ワーカーを登録するコードはメインスレッドで実行されるものの、ウェブ ワーカー内のコードは実行されないという点で、非常に有益です。これにより、メインスレッドの輻輳が軽減され、メインスレッドがユーザー操作に対してより応答しやすくなります。
ウェブ ワーカーは、メインスレッドの作業を減らすだけでなく、importScripts または モジュール ワーカーをサポートするブラウザの静的 import ステートメントのいずれかを使用して、ワーカー コンテキストで使用する外部スクリプトを読み込むこともできます。その結果、ウェブ ワーカーによってリクエストされたスクリプトはメインスレッド外で評価されます。
トレードオフと考慮事項
スクリプトを個別の小さなファイルに分割すると、読み込むファイルが少なくなり、大きなタスクを制限できますが、スクリプトをどのように分割するかを決める際には、いくつかの点を考慮することが重要です。
圧縮効率
スクリプトを分割する際には、圧縮が要因となります。スクリプトが小さい場合、圧縮の効率はやや低下します。スクリプトが大きいほど、圧縮によるメリットが大きくなります。圧縮効率を高めると、スクリプトの読み込み時間を可能な限り短縮できますが、起動時のインタラクティビティを向上させるために、スクリプトを十分に小さなチャンクに分割するには、バランスを取る必要があります。
バンドラーは、ウェブサイトが依存するスクリプトの出力サイズを管理するのに最適なツールです。
- webpack に関しては、
SplitChunksPluginプラグインが役立ちます。アセットサイズの管理に役立つ設定可能なオプションについては、SplitChunksPluginのドキュメントをご覧ください。 - Rollup や esbuild などの他のバンドラーでは、コードで動的な
import()呼び出しを使用してスクリプト ファイルのサイズを管理できます。これらのバンドラー(webpack も含む)は、動的にインポートされたアセットを自動的に独自のファイルに分割するため、初期バンドルのサイズが大きくなるのを防ぐことができます。
キャッシュの無効化
キャッシュの無効化は、再訪問時のページの読み込み速度に大きく影響します。大きなモノリシック スクリプト バンドルを配信すると、ブラウザ キャッシュの点で不利になります。これは、パッケージの更新やバグの修正のリリースなどによってファーストパーティ コードを更新すると、バンドル全体が無効になり、再度ダウンロードする必要があるためです。
スクリプトを分割すると、スクリプト評価の作業が小さなタスクに分割されるだけでなく、リピーターがネットワークではなくブラウザのキャッシュからスクリプトを取得する可能性も高まります。これにより、ページ読み込み全体が高速になります。
ネストされたモジュールと読み込みパフォーマンス
本番環境で ES モジュールを配信し、type=module 属性で読み込む場合は、モジュールのネストが起動時間に与える影響に注意する必要があります。モジュールのネストとは、ES モジュールが別の 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 つは、バンドラーを使用することです。ただし、スクリプト評価の作業を分散するためにスクリプトを分割するようにバンドラーを構成する必要があります。
バンドラーを使用しない場合は、modulepreload リソースヒントを使用してネストされたモジュール呼び出しを回避することもできます。これにより、ネットワーク リクエスト チェーンを回避するために ES モジュールが事前に読み込まれます。
まとめ
ブラウザでのスクリプトの評価を最適化することは、間違いなく難しいことです。アプローチは、ウェブサイトの要件と制約によって異なります。ただし、スクリプトを分割すると、スクリプト評価の作業が多数の小さなタスクに分散されるため、メインスレッドがブロックされるのではなく、ユーザー操作をより効率的に処理できるようになります。
まとめると、大きなスクリプト評価タスクを分割するには、次の方法があります。
type=module属性なしで<script>要素を使用してスクリプトを読み込む場合は、非常に大きなスクリプトの読み込みを避けてください。そのようなスクリプトは、メインスレッドをブロックするリソース集約型のスクリプト評価タスクを開始します。この作業を分割するには、スクリプトをより多くの<script>要素に分散します。type=module属性を使用してブラウザで ES モジュールをネイティブに読み込むと、個別のモジュール スクリプトごとに評価用の個別のタスクが開始されます。- 動的な
import()呼び出しを使用すると、初回バンドルのサイズを縮小できます。これはバンドラーでも機能します。バンドラーは動的にインポートされた各モジュールを「分割点」として扱い、動的にインポートされた各モジュールに対して個別のスクリプトを生成します。 - 圧縮効率やキャッシュの無効化などのトレードオフを必ず検討してください。スクリプトが大きいほど圧縮率は高くなりますが、タスク数が少なく、スクリプト評価のコストが高くなる可能性が高く、ブラウザのキャッシュが無効になるため、キャッシュ効率が全体的に低下します。
- バンドルせずに ES モジュールをネイティブに使用する場合は、
modulepreloadリソースヒントを使用して、起動時の読み込みを最適化します。 - これまでと同様に、JavaScript の配信はできるだけ少なくしてください。
確かにバランスを取る必要がありますが、スクリプトを分割し、動的 import() で初期ペイロードを削減することで、起動時のパフォーマンスを向上させ、重要な起動期間中のユーザー インタラクションをより適切に処理できます。これにより、INP 指標のスコアが向上し、ユーザー エクスペリエンスの改善につながります。