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

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

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

はじめに

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

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

一般的なエントリポイントとバンドル構成

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

チャンク処理の改善

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

ただし、このプラグインを使用する多くのウェブ フレームワークでは、チャンク分割に「単一の共通」アプローチが採用されています。たとえば、Next.js では、50% を超えるページで使用されているモジュールとすべてのフレームワークの依存関係(reactreact-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)[\\/]/,
      },
    },
  },

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

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

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

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

この粒度の細かいチャンク化戦略には、次のようなメリットがあります。

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

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

HTTP リクエストの増加

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

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

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

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

1 つのウェブページで複数のトライアルを 3 回実行した平均では、最大初期リクエスト数(5 ~ 15)を変化させても、loadstart-renderFirst Contentful Paint の時間はほぼ同じでした。興味深いことに、パフォーマンスのオーバーヘッドがわずかに発生したのは、リクエストを数百に分割した後だけでした。

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

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

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

チャンク分割の増加による JavaScript ペイロードの削減

このテストは、リクエスト数を変更して、ページの読み込みパフォーマンスに悪影響がないかどうかを確認するだけでした。テストページで maxInitialRequests25 に設定すると、ページの速度を低下させることなく 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 に固有のものではありません。使用するフレームワークやモジュール バンドラーに関係なく、大きな「commons」バンドル アプローチを採用している場合は、アプリケーションのチャンク化戦略の改善を検討する必要があります。

  • 同じチャンク最適化をバニラ React アプリケーションに適用する方法については、こちらの React アプリのサンプルをご覧ください。このサンプルでは、粒度の細かいチャンク戦略の簡略版を使用しており、同じようなロジックをサイトに適用する際の参考になります。
  • Rollup の場合、デフォルトではチャンクは細かく作成されます。動作を手動で構成する場合は、manualChunks をご覧ください。