きめ細かいチャンクによる、Next.js と Gatsby のページ読み込みパフォーマンスの向上

Next.js と Gatsby の新しい webpack チャンキング戦略により、重複するコードが最小限に抑えられ、ページの読み込みパフォーマンスが向上します。

Chrome は、JavaScript オープンソース エコシステムのツールやフレームワークと連携しています。最近、Next.js と Gatsby の読み込みパフォーマンスを向上させるために、新しい最適化がいくつか追加されました。この記事では、両方のフレームワークにデフォルトで搭載されるようになった、改善されたきめ細かいチャンキング戦略について説明します。

はじめに

多くのウェブ フレームワークと同様に、Next.js と Gatsby はコアバンドラとして webpack を使用しています。webpack v3 では、CommonsChunkPlugin が導入され、異なるエントリ ポイント間で共有されるモジュールを 1 つ(または複数)の「コモンズ」チャンクとして出力できるようになりました。共有コードは個別にダウンロードして、ブラウザ キャッシュに早期に保存できるため、読み込みパフォーマンスが向上します。

このパターンは、次のようなエントリポイントとバンドル構成を採用する多くのシングルページ アプリケーション フレームワークで一般的になりました。

共通のエントリポイントとバンドルの構成

すべての共有モジュール コードを 1 つのチャンクにバンドルするというコンセプトは実用的ですが、制限があります。すべてのエントリ ポイントで共有されていないモジュールは、それを使用しないルート用にダウンロードされるため、必要以上に多くのコードがダウンロードされることになります。たとえば、page1 が common チャンクを読み込むと、page1 が moduleC を使用していなくても、moduleC のコードが読み込まれます。この理由やその他の理由から、webpack v4 ではこのプラグインが削除され、新しいプラグイン SplitChunksPlugin が導入されました。

チャンキングの改善

ほとんどのユーザーは、SplitChunksPlugin のデフォルト設定で問題ありません。複数のルートで重複するコードが取得されないように、複数の条件に応じて複数の分割チャンクが作成されます。

ただし、このプラグインを使用する多くのウェブ フレームワークは、チャンク分割に「単一コモンズ」アプローチを採用しています。たとえば、Next.js は、50% を超えるページで使用されているモジュールと、すべてのフレームワーク依存関係(react、react-dom など)を含む commons バンドルを生成します。

const splitChunksConfigs = {
  …
  prod: {
    chunks: 'all',
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: 'commons',
        chunks: 'all',
        minChunks: totalPages > 2 ? totalPages * 0.5 : 2,
      },
      react: {
        name: 'commons',
        chunks: 'all',
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler|use-subscription)[\\/]/,
      },
    },
  },

フレームワーク依存のコードを共有チャンクに含めると、任意のエントリポイントでダウンロードしてキャッシュに保存できますが、ページの半分以上で使用される共通モジュールを含めるという使用状況ベースのヒューリスティックはあまり効果的ではありません。この比率を変更すると、次の 2 つの結果のいずれかになります。

  • 比率を下げると、不要なコードがダウンロードされる量が増えます。
  • 比率を上げると、複数のルートで重複するコードが増えます。

この問題を解決するため、Next.js は SplitChunksPlugin に別の構成を採用し、どのルートでも不要なコードを削減しています。

  • 十分に大きいサードパーティ モジュール(160 KB を超えるもの)は、独自のチャンクに分割されます。
  • フレームワークの依存関係(react、react-dom など)用に、別の frameworks チャンクが作成されます。
  • 必要な数の共有チャンクが作成されます(最大 25 個)。
  • 生成されるチャンクの最小サイズが 20 KB に変更されました

このきめ細かいチャンク戦略には、次の利点があります。

  • ページの読み込み時間が短縮される。単一の共有チャンクではなく、複数の共有チャンクを出力することで、エントリ ポイントの不要な(または重複する)コードの量を最小限に抑えることができます。
  • ナビゲーション中のキャッシュ保存を改善。大規模なライブラリとフレームワークの依存関係を個別のチャンクに分割すると、アップグレードが行われるまでどちらも変更される可能性が低いため、キャッシュが無効になる可能性が低くなります。

Next.js が採用した構成全体は、webpack-config.ts で確認できます。

HTTP リクエストの増加

SplitChunksPlugin は、きめ細かいチャンク化の基礎を定義しました。このアプローチを Next.js などのフレームワークに適用することは、まったく新しいコンセプトではありませんでした。しかし、多くのフレームワークは、いくつかの理由から、単一のヒューリスティックと「コモンズ」バンドル戦略を引き続き使用していました。これには、HTTP リクエストが増えるとサイトのパフォーマンスに悪影響を及ぼす可能性があるという懸念も含まれます。

ブラウザが単一のオリジンに対して開くことができる TCP 接続の数には上限があるため(Chrome の場合は 6)、バンドラが出力するチャンクの数を最小限に抑えることで、リクエストの合計数をこの上限未満に抑えることができます。ただし、これは HTTP/1.1 にのみ当てはまります。HTTP/2 の多重化により、単一の接続を使用して複数のリクエストを並列でストリーミングできます。つまり、一般的に、バンドラから出力されるチャンクの数を制限することを心配する必要はありません。

すべての主要なブラウザが HTTP/2 をサポートしています。Chrome チームと Next.js チームは、Next.js の単一の「コモンズ」バンドルを複数の共有チャンクに分割してリクエスト数を増やすことで、読み込みパフォーマンスに何らかの影響があるかどうかを確認したいと考えていました。まず、maxInitialRequests プロパティを使用して並列リクエストの最大数を変更しながら、単一サイトのパフォーマンスを測定しました。

リクエスト数の増加に伴うページ読み込みのパフォーマンス

1 つのウェブページで複数のトライアルを 3 回実行した平均では、最大初期リクエスト数を 5 から 15 に変更しても、load、start-render、First Contentful Paint の時間はすべてほぼ同じでした。興味深いことに、数百件のリクエストに積極的に分割した後にのみ、パフォーマンスのオーバーヘッドがわずかに発生することがわかりました。

数百件のリクエストがある場合のページ読み込みパフォーマンス

これにより、信頼できるしきい値(20 ~ 25 リクエスト)を下回ることで、読み込みパフォーマンスとキャッシュ効率の適切なバランスが実現されることがわかりました。ベースライン テストの後、maxInitialRequest の数として 25 が選択されました。

並行して発生するリクエストの最大数を変更した結果、共有バンドルが複数になり、各エントリ ポイントに適切に分離することで、同じページの不要なコードの量が大幅に削減されました。

チャンキングの増加による JavaScript ペイロードの削減

このテストは、リクエスト数を変更して、ページ読み込みのパフォーマンスに悪影響があるかどうかを確認することのみを目的としていました。この結果から、テストページで maxInitialRequests を 25 に設定すると、ページの読み込み速度を低下させることなく JavaScript ペイロードのサイズを削減できるため、最適であることがわかります。ページのハイドレーションに必要な JavaScript の総量はほぼ同じままでした。これが、コード量を減らしてもページの読み込みパフォーマンスが必ずしも向上しなかった理由です。

webpack では、生成されるチャンクのデフォルトの最小サイズとして 30 KB が使用されます。ただし、maxInitialRequests 値を 25 にして最小サイズを 20 KB にすると、キャッシュ保存が改善されました。

きめ細かいチャンクによるサイズ削減

Next.js を含む多くのフレームワークは、クライアントサイド ルーティング(JavaScript で処理)に依存して、ルート遷移ごとに新しいスクリプトタグを挿入します。しかし、ビルド時にこれらの動的チャンクをどのように事前に決定するのでしょうか。

Next.js は、サーバーサイドのビルド マニフェスト ファイルを使用して、出力されたチャンクが異なるエントリ ポイントで使用されるかどうかを判断します。この情報をクライアントにも提供するために、すべてのエントリ ポイントのすべての依存関係をマッピングする、簡略化されたクライアントサイドのビルド マニフェスト ファイルが作成されました。

// Returns a promise for the dependencies for a particular route
getDependencies (route) {
  return this.promisedBuildManifest.then(
    man => (man[route] && man[route].map(url => `/_next/${url}`)) || []
  )
}
Next.js アプリケーションでの複数の共有チャンクの出力。

この新しいきめ細かいチャンキング戦略は、まず Next.js でフラグの背後にロールアウトされ、そこで多数のアーリー アドプターでテストされました。多くのサイトで、サイト全体で使用される JavaScript の合計が大幅に削減されました。

ウェブサイト JS の合計変化 違い(%)
https://www.barnebys.com/ -238 KB -23%
https://sumup.com/ -220 KB -30%
https://www.hashicorp.com/ -11 MB -71%
JavaScript のサイズ削減 - すべてのルート(圧縮)

最終バージョンは、バージョン 9.2 でデフォルトでリリースされました。

Gatsby

Gatsby は、共通モジュールを定義するために、使用量ベースのヒューリスティックを使用する同じアプローチに従っていました。

config.optimization = {
  …
  splitChunks: {
    name: false,
    chunks: `all`,
    cacheGroups: {
      default: false,
      vendors: false,
      commons: {
        name: `commons`,
        chunks: `all`,
        // if a chunk is used more than half the components count,
        // we can assume it's pretty global
        minChunks: componentsCount > 2 ? componentsCount * 0.5 : 2,
      },
      react: {
        name: `commons`,
        chunks: `all`,
        test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
      },

webpack の構成を最適化して同様のきめ細かいチャンキング戦略を採用することで、多くの大規模サイトで JavaScript のサイズが大幅に削減されました。

ウェブサイト JS の合計変化 違い(%)
https://www.gatsbyjs.org/ -680 KB -22%
https://www.thirdandgrove.com/ -390 KB -25%
https://ghost.org/ -1.1 MB -35%
https://reactjs.org/ -80 KB -8%
JavaScript のサイズ削減 - すべてのルート(圧縮)

PR を参照して、このロジックが webpack 構成にどのように実装されているかを確認してください。このロジックは、v2.20.7 でデフォルトで提供されています。

まとめ

細分化されたチャンクを送信するというコンセプトは、Next.js、Gatsby、webpack に固有のものではありません。使用しているフレームワークやモジュール バンドラに関係なく、大規模な「コモンズ」バンドル アプローチに従っている場合は、アプリケーションのチャンキング戦略の改善を検討する必要があります。

  • バニラ React アプリケーションに適用された同じチャンキング最適化を確認するには、こちらのサンプル React アプリをご覧ください。このアプリは、簡素化されたバージョンの詳細なチャンキング戦略を使用しており、同じ種類のロジックをサイトに適用するのに役立ちます。
  • ロールアップの場合、チャンクはデフォルトで細かく作成されます。動作を手動で構成する場合は、manualChunks をご覧ください。