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

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

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

ただし、スクリプト自体の読み込みに時間のかかるタスクはどうすればよいでしょうか。これらのタスクは、ユーザー操作を妨げたり、読み込み中のページの 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 で可視化された、複数のタスクでのモジュールのコンパイル作業。
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 の特別なユースケースです。ウェブワーカーはメインスレッドに登録され、ワーカー内のコードは独自のスレッドで実行されます。これは、ウェブワーカーを登録するコードがメインスレッドで実行される一方で、ウェブワーカー内のコードは実行されないという点で、非常に有益です。これにより、メインスレッドの輻輳が軽減され、メインスレッドがユーザー操作に迅速に応答できるようになります。

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

トレードオフと考慮事項

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

圧縮効率

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

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

  • 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 つの方法は、バンドルを使用することです。ただし、スクリプトを分割してスクリプト評価作業を分散するようにバンドルを構成してください。

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

まとめ

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

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

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

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