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

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

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

はじめに

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

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

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

実用的ではありますが、すべての共有モジュール コードを 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 などのフレームワークに適用することは、まったく新しいコンセプトではありませんでした。ただし、多くのフレームワークでは、いくつかの理由から、単一のヒューリスティックと「共通」バンドル戦略を引き続き使用していました。これには、HTTP リクエストの増加がサイトのパフォーマンスに悪影響を及ぼす可能性があるという懸念も含まれます。

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

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

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

1 つのウェブページで複数のトライアルを平均 3 回実行したところ、最大初期リクエスト数を 5 ~ 15 個と変えてテストしても、loadレンダリング開始First 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 のサイズ削減 - すべてのルート(圧縮済み)

このロジックを webpack 構成に実装する方法については、PR をご覧ください。このロジックは v2.20.7 でデフォルトで出荷されています。

まとめ

きめ細かいチャンクを配信するコンセプトは、Next.js、Gatsby、さらには webpack に固有のものではありません。使用しているフレームワークやモジュール バンドラーに関係なく、大きな「共通」バンドル アプローチを採用している場合は、アプリケーションのチャンク化戦略の改善を検討する必要があります。

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