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

Next.js と Gatsby の新しい Webpack チャンク戦略では、コードの重複を最小限に抑え、ページ読み込みのパフォーマンスを向上させています。

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

はじめに

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

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

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

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

チャンキングの改善

ほとんどのユーザーには、SplitChunksPlugin のデフォルトの設定をそのまま使用できます。複数のルート間で重複するコードが取得されないように、いくつかのconditionsに応じて複数の分割チャンクが作成されます。

ただし、このプラグインを使用する多くのウェブ フレームワークは、依然として「単一共通」アプローチに従ってチャンク分割を行っています。たとえば、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)[\\/]/,
      },
    },
  },

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

  • この比率を減らすと、より多くの不要なコードがダウンロードされます。
  • この比率を上げると、より多くのコードが複数のルートで複製されます。

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

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

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

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

Next.js で採用されたすべての構成は webpack-config.ts で確認できます。

HTTP リクエストの増加

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

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

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

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

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 は以前、使用状況に基づくヒューリスティックを使用して共通のモジュールを定義するのと同じアプローチに従っていました。

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/ -80KB -8%
JavaScript のサイズ削減 - すべてのルートにわたる(圧縮)

PR を確認し、このロジックを v2.20.7 でデフォルトで出荷される webpack 構成にどのように実装したかを理解してください。

まとめ

きめ細かなチャンクを配布するという概念は、Next.js、Gatsby、さらには Webpack に固有のものではありません。使用するフレームワークやモジュール バンドラに関係なく、大規模な「共通」バンドル アプローチに従っている場合は、アプリのチャンク戦略の改善を検討する必要があります。

  • 標準の React アプリケーションに適用された同じチャンク最適化を確認したい場合は、React サンプルアプリをご覧ください。このアプリでは、きめ細かいチャンク戦略を簡略化したバージョンを使用して、同じ種類のロジックをサイトに適用することができます。
  • ロールアップの場合、デフォルトでチャンクが細かく作成されます。動作を手動で構成する場合は、manualChunks をご覧ください。