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

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

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

ただし、スクリプト自体の読み込みに時間のかかるタスクはどうすればよいでしょうか。これらのタスクは、ユーザー操作を妨げたり、読み込み中のページの INP に影響したりする可能性があります。このガイドでは、スクリプト評価によって開始されたタスクをブラウザがどのように処理するかを説明します。また、スクリプト評価作業を分割して、ページの読み込み中にメインスレッドがユーザー入力に迅速に対応できるようにする方法を検討します。

大量の JavaScript を送信するアプリケーションをプロファイリングすると、長いタスクで「スクリプトを評価」というラベルが付いていることがあります。

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 などのブラウザ(または Chrome から派生したブラウザ)では、type=module 属性を使用して ES モジュールを読み込むと、type=module を使用しない場合に通常表示されるタスクとは異なる種類のタスクが生成されます。たとえば、[Compile module] というラベルのアクティビティを含むモジュール スクリプトごとにタスクが実行されます。

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

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

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

少なくとも Chrome と関連するブラウザでは、ES モジュールを使用するとコンパイル手順が分割されます。これは長いタスクの管理という点では明らかに有利ですが、結果として発生するモジュール評価作業により、避けられないコストが発生します。できるだけ少ない JavaScript を配信するように努める必要がありますが、ブラウザに関係なく ES モジュールを使用すると、次のようなメリットがあります。

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

Safari と Firefox

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

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

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

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

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

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

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

ウェブワーカーは、JavaScript の特別なユースケースです。ウェブワーカーはメインスレッドに登録され、ワーカー内のコードは独自のスレッドで実行されます。これは、ウェブワーカーを登録するコードはメインスレッドで実行される一方で、ウェブワーカー内のコードは実行されないという点で、非常に有益です。これにより、メインスレッドの輻輳が軽減され、メインスレッドがユーザー操作に迅速に応答できるようになります。

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

トレードオフと考慮事項

スクリプトを小さなファイルに分割すると、サイズの大きなファイルを少数読み込むよりも長いタスクを制限できますが、スクリプトの分割方法を決める際には、いくつかの点を考慮する必要があります。

圧縮効率

圧縮は、スクリプトを分割する際の要因です。スクリプトが小さいほど、圧縮の効率は低下します。スクリプトが大きいほど、圧縮の効果は大きくなります。圧縮効率を高めることでスクリプトの読み込み時間を最小限に抑えることができますが、起動時のインタラクティビティを高めるためにスクリプトを十分に小さなチャンクに分割する必要があるため、バランスを取る必要があります。

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

  • webpack の場合は、SplitChunksPlugin プラグインが役立ちます。アセットサイズの管理に役立つ設定可能なオプションについては、SplitChunksPlugin のドキュメントをご覧ください。
  • Rollupesbuild などの他のバンドルツールでは、コードで動的な 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 つの方法は、バンドルを使用することです。ただし、スクリプトを分割してスクリプト評価作業を分散するようにバンドルを構成してください。

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

まとめ

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

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

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

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