コード分割 JavaScript

サイズの大きい JavaScript リソースを読み込むと、ページの読み込み速度に大きく影響します。JavaScript を小さなチャンクに分割し、起動時にページが機能するために必要なもののみをダウンロードすると、ページの読み込み応答性が大幅に向上し、ページの Interaction to Next Paint(INP)が改善されます。

大きな JavaScript ファイルをダウンロード、解析、コンパイルするページでは、ページが応答しなくなることがあります。ページ要素は、ページの最初の HTML の一部であり、CSS によってスタイルが設定されているため、表示されます。しかし、こうしたインタラクティブな要素を提供するために必要となる JavaScript や、ページで読み込まれるその他のスクリプトは、これらの要素が機能するために JavaScript を解析して実行する可能性があります。その結果、ユーザーは操作が大幅に遅延した、あるいはまったく機能しなくなったと感じる可能性があります。

これは多くの場合、JavaScript が解析されてメインスレッドでコンパイルされるため、メインスレッドがブロックされているために発生します。このプロセスに時間がかかりすぎると、インタラクティブなページ要素がユーザー入力に迅速に反応しない可能性があります。解決方法の 1 つは、ページが機能するために必要な JavaScript だけを読み、他の JavaScript は、コード分割と呼ばれる手法で後から読み込めるようにすることです。このモジュールでは、この 2 つの手法のうち後者を中心に説明します。

コード分割により、起動時の JavaScript の解析と実行を削減

Lighthouse では、JavaScript の実行時間が 2 秒を超えると警告がスローされ、3.5 秒を超えるとエラーになります。過度の JavaScript 解析と実行は、ページのライフサイクルのどの時点でも問題となる可能性があります。JavaScript の処理と実行を担当するメインスレッド タスクが実行されているタイミングと、ユーザーがページを操作した時刻が一致すると、インタラクションの入力遅延が長くなる可能性があります。

さらに、初回のページ読み込み時には、JavaScript の過剰な実行や解析が特に問題となります。これは、ページのライフサイクルの中で、ユーザーがページを操作する可能性が高いためです。実際、読み込み応答性の指標である Total Blocking Time(TBT)INP強い相関があり、ユーザーは最初のページ読み込み時にインタラクションを試行する傾向が強いことを示唆しています。

ページがリクエストする各 JavaScript ファイルの実行にかかった時間を報告する Lighthouse の監査は、コード分割の対象となるスクリプトを正確に特定するうえで有用です。さらに、Chrome DevTools のカバレッジ ツールを使用して、ページの読み込み時にページの JavaScript のどの部分が使用されていないかを正確に特定することもできます。

コード分割は、ページの最初の JavaScript ペイロードを削減できる便利な手法です。JavaScript バンドルを次の 2 つの部分に分割できます。

  • JavaScript はページの読み込み時に必要となるため、他のときに読み込むことができません。
  • 後で読み込める残りの JavaScript です(ほとんどの場合、ユーザーがページ上の特定のインタラクティブ要素を操作したとき)。

コード分割を行うには、動的 import() 構文を使用します。この構文は、起動時に特定の JavaScript リソースをリクエストする <script> 要素とは異なり、ページのライフサイクルの後半で JavaScript リソースをリクエストします。

document.querySelectorAll('#myForm input').addEventListener('blur', async () => {
  // Get the form validation named export from the module through destructuring:
  const { validateForm } = await import('/validate-form.mjs');

  // Validate the form:
  validateForm();
}, { once: true });

上記の JavaScript スニペットでは、ユーザーがフォームの <input> フィールドのいずれかをぼかした場合にのみ、validate-form.mjs モジュールがダウンロード、解析、実行されます。この場合、フォームの検証ロジックを処理する JavaScript リソースは、実際に使用される可能性が最も高いときにのみページに関係します。

webpackParcelRollupesbuild などの JavaScript バンドラは、ソースコードで動的な import() 呼び出しが発生するたびに JavaScript バンドルを小さなチャンクに分割するように構成できます。これらのツールのほとんどは、この処理を自動的に行いますが、esbuild では特に、この最適化を有効にする必要があります。

コード分割に関する参考情報

コード分割は、最初のページ読み込み時のメインスレッドの競合を減らす効果的な方法ですが、JavaScript のソースコードを監査してコード分割の可能性を探す場合は、以下の点に注意する必要があります。

可能であればバンドラを使用する

一般的に、デベロッパーは開発プロセスにおいて JavaScript モジュールを使用します。これによってコードの可読性と保守性が向上するため、デベロッパー エクスペリエンスが大幅に向上します。ただし、JavaScript モジュールを本番環境にリリースする際に、最適なパフォーマンス特性が得られない可能性があります。

最も重要な点は、バンドラを使用してソースコード(コード分割するモジュールを含む)を処理し、最適化することです。バンドルは、JavaScript ソースコードに最適化を適用するだけでなく、バンドルサイズなどのパフォーマンスに関する考慮事項の圧縮率とのバランスを取るうえでも非常に効果的です。圧縮の効果はバンドルサイズとともに高くなりますが、バンドルは、スクリプトの評価が原因で長いタスクを発生させるほど大きくならないようにバンドルします。

また、バンドルされていないモジュールをネットワーク経由で大量に配布する問題も回避できます。JavaScript モジュールを使用するアーキテクチャは、たいてい大規模で複雑なモジュール ツリーを持ちます。モジュール ツリーがバンドルされていない場合、各モジュールは個別の HTTP リクエストを表し、モジュールをバンドルしないとウェブアプリでのインタラクティビティが遅延する可能性があります。<link rel="modulepreload"> リソースヒントを使用して大きなモジュール ツリーをできるだけ早く読み込むこともできますが、読み込みパフォーマンスの観点からは JavaScript バンドルが推奨されます。

ストリーミング コンパイルを誤って無効にしない

Chromium の V8 JavaScript エンジンには、本番環境の JavaScript コードが可能な限り効率的に読み込まれるように、多くの最適化機能が最初から組み込まれています。 こうした最適化の 1 つがストリーミング コンパイルです。これは、ブラウザにストリーミングされる HTML の増分解析のように、ネットワークから届いた JavaScript のチャンクをストリーミングしてコンパイルします。

Chromium でウェブ アプリケーションに対してストリーミング コンパイルを確実に行うには、いくつかの方法があります。

  • JavaScript モジュールを使用しないように本番環境のコードを変換します。Bundler は、コンパイル ターゲットに基づいて JavaScript ソースコードを変換できます。ターゲットは通常、特定の環境に固有のものです。V8 では、モジュールを使用しないすべての JavaScript コードにストリーミング コンパイルが適用されますが、JavaScript モジュール コードを JavaScript モジュールとその機能を使用しない構文に変換するようにバンドラを構成できます。
  • JavaScript モジュールを本番環境にリリースする場合は、.mjs 拡張機能を使用します。本番環境の JavaScript がモジュールを使用するかどうかにかかわらず、モジュールを使用する JavaScript と使用しない JavaScript には、特別なコンテンツ タイプはありません。V8 に関しては、JavaScript モジュールを本番環境にリリースするときに .js 拡張機能を使用して、ストリーミング コンパイルを実質的に無効にできます。JavaScript モジュールに .mjs 拡張機能を使用する場合、V8 では、モジュール ベースの JavaScript コードのストリーミング コンパイルが壊れないようにできます。

これらの考慮事項が原因でコード分割を使うのが妨げにならないようにしましょう。コード分割は、ユーザーにとって初期の JavaScript ペイロードを削減する効果的な方法ですが、バンドラを使用して V8 のストリーミング コンパイルの動作を維持する方法を把握することで、ユーザーにとって可能な限り高速な本番環境用 JavaScript コードを提供できます。

動的インポートのデモ

Webpack

webpack には SplitChunksPlugin というプラグインが付属しています。このプラグインを使用すると、バンドラが JavaScript ファイルを分割する方法を構成できます。webpack は、動的な import() ステートメントと静的 import ステートメントの両方を認識します。SplitChunksPlugin の動作を変更するには、その構成で chunks オプションを指定します。

  • chunks: async はデフォルト値で、動的な import() 呼び出しを指します。
  • chunks: initial は静的な import 呼び出しを指します。
  • chunks: all は、動的 import() インポートと静的インポートの両方に対応し、async インポートと initial インポートの間でチャンクを共有できます。

デフォルトでは、webpack は動的 import() ステートメントを検出するたびに、そのモジュール用に別のチャンクを作成します。

/* main.js */

// An application-specific chunk required during the initial page load:
import myFunction from './my-function.js';

myFunction('Hello world!');

// If a specific condition is met, a separate chunk is downloaded on demand,
// rather than being bundled with the initial chunk:
if (condition) {
  // Assumes top-level await is available. More info:
  // https://v8.dev/features/top-level-await
  await import('/form-validation.js');
}

上記のコード スニペットでは、Webpack のデフォルト構成が 2 つの別々のチャンクになります。

  • main.js チャンク(webpack は initial チャンクとして分類されます)には、main.js モジュールと ./my-function.js モジュールが含まれます。
  • async チャンク。form-validation.js のみが含まれます(構成されている場合は、リソース名にファイル ハッシュが含まれます)。このチャンクは、condition が truthy の場合にのみダウンロードされます。

この構成では、実際に必要になるまで form-validation.js チャンクの読み込みを延期できます。これにより、最初のページ読み込み時のスクリプトの評価時間を短縮して、読み込みの応答性を向上させることができます。form-validation.js チャンクのスクリプトのダウンロードと評価は、指定された条件が満たされると行われます。この場合、動的にインポートされたモジュールがダウンロードされます。一例として、ポリフィルが特定のブラウザに対してのみダウンロードされる場合や、上記の例のように、インポートされたモジュールがユーザー操作に必要である場合が挙げられます。

一方、SplitChunksPlugin 構成を変更して chunks: initial を指定すると、コードは最初のチャンクでのみ分割されます。これは、静的にインポートされたチャンク、または Webpack の entry プロパティにリストされているチャンクです。上記の例では、単一のスクリプト ファイル内の form-validation.js main.js の組み合わせがチャンクとなるため、最初のページ読み込みのパフォーマンスが低下する可能性があります。

SplitChunksPlugin のオプションは、大きなスクリプトを複数の小さなスクリプトに分割するように構成することもできます。たとえば、maxSize オプションを使用して、チャンクが maxSize によって指定されたサイズを超えた場合にチャンクを別々のファイルに分割するように Webpack に指示できます。大きなスクリプト ファイルを複数の小さなファイルに分割すると、読み込みの応答性が向上します。これは、CPU 負荷の高いスクリプトの評価作業が、より小さなタスクに分割され、メインスレッドが長時間ブロックされる可能性が低い場合があるためです。

また、サイズの大きい JavaScript ファイルを生成すると、スクリプトがキャッシュ無効化により影響を受ける可能性が高くなります。たとえば、フレームワークとファーストパーティ製アプリコードの両方を含む非常に大きなスクリプトを送信する場合、バンドルされたリソースにはフレームワークのみが更新され、それ以外のものが更新されなければ、バンドル全体が無効になる可能性があります。

一方、スクリプト ファイルが小さいほど、リピーターがキャッシュからリソースを取得する可能性が高くなるため、再訪問時のページの読み込み速度が向上します。ただし、サイズの小さいファイルは、大きいファイルよりも圧縮によるメリットが少なく、ページ読み込み時のネットワークのラウンドトリップ時間が、プライムされていないブラウザ キャッシュで長くなることがあります。キャッシュの効率、圧縮の効率、スクリプトの評価時間の間でバランスを取るように注意する必要があります。

webpack のデモ

webpack SplitChunksPlugin のデモ

理解度テスト

コード分割を行う際に使用する import ステートメントのタイプはどれですか。

動的な import()
正解です。
静的な import
もう一度お試しください。

JavaScript モジュールの先頭で、他の場所に置く必要がない import ステートメントのタイプは次のうちどれですか。

動的な import()
もう一度お試しください。
静的な import
正解です。

webpack で SplitChunksPlugin を使用する場合、async チャンクと initial チャンクの違いは何ですか?

async チャンクは動的 import() を使用して読み込まれ、initial チャンクは静的 import を使用して読み込まれます。
正解です。
async チャンクは静的 import を使用して読み込まれ、initial チャンクは動的 import() を使用して読み込まれます。
もう一度お試しください。

次のトピック: 画像と <iframe> 要素の遅延読み込み

高コストなリソースである傾向がありますが、読み込みを遅らせることができるリソースタイプは JavaScript だけではありません。イメージ要素と <iframe> 要素は、それぞれ自体がコストの高いリソースになる可能性があります。JavaScript と同様に、画像と <iframe> 要素の読み込みを遅延読み込みによって遅らせることができます。これについては、このコースの次のモジュールで説明します。