フロントエンドアプリのコード分割戦略
フロントエンドのコード分割戦略を比較。ルートや重いコンポーネントを分割し、操作前に先読みして、ネットワークウォーターフォールで効果を測定します。
コード分割(Code Splitting)とは、JavaScript バンドルを複数のチャンクに分け、ブラウザが必要になった時点でそのチャンクだけをダウンロードするようにする手法です。これにより、初回ページ読み込みでダウンロードするのはアプリ全体ではなく、最初のページに必要なコードだけになります。
Lighthouse は大量の未使用 JavaScript を報告しており、すでに 1 つのコンポーネントに React.lazy を追加した。それでも本番環境では依然として巨大なエントリーチャンクが 1 つ配信されている。こうした状況への対処法は、あちこちに lazy() を追加することではありません。どの分割に価値があるのか、それぞれにどんなコストがかかるのか、そして効果があったかどうかをどう判断するのかを把握する必要があります。
本記事では、効果が出やすい順に 5 つの戦略を紹介し、その計測方法と、コード分割がかえってアプリを遅くしてしまう 3 つのパターンについて解説します。
重要なポイント
- ルートベースの分割は、ほぼすべてのシングルページアプリケーション(SPA)で最初に行うべき分割です。各ルートが独立したチャンクになり、訪問者はアクセスしたルートのコードだけをダウンロードします。
- webpack 5 と Rolldown(Vite 8 のバンドラー)は、遅延読み込みされる複数のチャンク間で共有されるコードを、すでに自動で共通チャンクに切り出しています。そのため、手作業で作る
vendor.jsは、現在ではサイズ削減よりもキャッシュの安定性のために役立つものになっています。 - クリック時ではなくホバー時やフォーカス時に
import()を開始すれば、ユーザーがクリックするか迷っている間にチャンクのダウンロードが進みます。 - エントリーチャンクが小さくなっただけでは何の証明にもなりません。証拠となるのは、スロットリングしたネットワークのウォーターフォールで、最初の画面に必要なコードの読み込みが以前より早く完了していることです。
- 最初の画面に必要なコードを分割しても、待ち時間はなくなりません。待ち時間が後ろにずれるだけです。
なぜ単一バンドルだと最初のページが遅くなるのか
分割を行わない SPA では、アプリで使われる可能性のあるすべてのルート、モーダル、ライブラリを含んだエントリーバンドルが 1 つだけ配信されます。マーケティングページにアクセスしたユーザーは、設定画面や管理ダッシュボード、チャートライブラリを一度も開かないとしても、それらをダウンロードし、パースし、実行することになります。こうした未使用のコードは、実際にページを描画するコードと帯域幅やメインスレッドの時間を奪い合います。
以下の 5 つのコード分割戦略は、それぞれ適用条件、コスト、効果の確認方法が異なります。
| 戦略 | 適用場面 | コスト | 効果の確認方法 |
|---|---|---|---|
| ルート分割 | 複数ルートを持つあらゆる SPA | ナビゲーションごとのフォールバック表示 | エントリーチャンクに他のルートが含まれなくなる |
| コンポーネント分割 | インタラクションの後ろやファーストビュー外にある重い UI | ローディング状態と追加のリクエスト | トリガーされたときだけチャンクが読み込まれる |
| 手動ベンダーチャンク | 長期間キャッシュしたい安定した依存関係 | 設定のメンテナンスと実行順序が変わる可能性 | デプロイをまたいでもハッシュが変わらない |
| インタラクションによるプリフェッチ | クリックされる可能性が高い要素 | 使われないコードをときどき取得してしまう | クリック前にリクエストが開始される |
| 分割しない | 最初の画面に必要なコード | なし | 新たなリクエストチェーンが発生しない |
まずはルートベースのコード分割から
ルートベースの分割は、ほぼすべての SPA で最初に行う価値のある分割です。各ルートが独立したチャンクになり、訪問者はアクセスしたルートのコードだけをダウンロードします。必要なコード量は少なく、ルートの切り替えは短いローディング状態を挟むのに自然なタイミングです。React の動的インポートを理解するでは、React.lazy と Suspense を使ったパターンを解説しています。Next.js や SvelteKit などのメタフレームワークは、ルート単位の分割を自動で行います。
重いコンポーネントはインタラクションの後ろで分割する
コンポーネント単位の分割は、重量があり、初回描画時には表示されない UI に適しています。たとえば、モーダル、リッチテキストエディター、チャートウィジェット、ファーストビュー外のコメントセクションなどです。コストは、ローディング状態と、ユーザーがその機能を使いたいタイミングで発生する 1 回のネットワークリクエストです。
主要なフレームワークはいずれも、動的 import() の上に同様のプリミティブを用意しています。
// React
import { lazy } from 'react';
const Chart = lazy(() => import('./Chart'));
// Next.js
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'));
// Vue
import { defineAsyncComponent } from 'vue';
const Chart = defineAsyncComponent(() => import('./Chart.vue'));
React の lazy コンポーネントは Suspense バウンダリの内側で描画され、チャンクの読み込み中はフォールバックが表示されます。Next.js の next/dynamic は React.lazy() と Suspense を 1 回の呼び出しにまとめたもので、Vue には defineAsyncComponent があります。Svelte では、import() を条件付きレンダリングと組み合わせて直接使います。
ファーストビュー外のコンテンツについては、要素がビューポートに近づいたタイミングで読み込みを開始します。Intersection Observer を 1 つ作成し、最初の交差を検知したら切断してから、lazy コンポーネントを描画します。Observer のパターンについては JavaScript における遅延読み込みで解説しています。
バンドラーがすでに自動で行っている分割
webpack 5 のデフォルトの splitChunks 設定や Rollup(Vite 8 では Rolldown)は、遅延読み込みされる複数のチャンクで共有されるコードを自動的に共通チャンクへ切り出します。webpack はデフォルトでは、非同期チャンクに対してのみ(chunks: 'async')、かつ新しいチャンクが最小サイズを超える場合にのみこの処理を行います。組み込みの defaultVendors キャッシュグループは、すでに node_modules にマッチするようになっています。各バンドラーの現状については、JavaScript バンドラーの現状で概観できます。
バンドラーがすでに共有コードを抽出しているため、手作業で作る単一の vendor.js は、webpack や Rolldown が行っている処理をほぼ重複させるだけです。現在、ベンダーチャンクを手動で定義する主な理由はキャッシュの安定性です。めったに変更されない依存関係がデプロイをまたいで同じコンテンツハッシュを保てば、再訪問したユーザーはそれらを再ダウンロードせずに済みます。
// webpack 5
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'framework',
chunks: 'all',
},
},
},
},
};
ここでは chunks: 'all' が必要です。これがないと、エントリーからインポートされたフレームワークのコードがエントリーチャンク内に残ったままとなり、デプロイのたびにエントリーのハッシュが変わってしまいます。
Vite 7 以前では、同じことを build.rollupOptions.output.manualChunks で記述していました。Vite 8 は Rolldown でビルドを行います。Vite 8 では manualChunks のオブジェクト形式はサポートされなくなり、関数形式は非推奨となり、代わりに codeSplitting のグループを使うようになりました。
// Vite 8 (Rolldown)
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rolldownOptions: {
output: {
codeSplitting: {
groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom)[\\/]/ }],
},
},
},
},
});
グループの対象範囲は狭く保ちましょう。Rolldown のドキュメントでは、手動で作成したグループによってモジュールの実行順序が変わる可能性があると警告されており、これは副作用を持つモジュールにとって重要です。
クリック時ではなくホバー時に読み込みを開始する
インタラクションによるプリフェッチでは、ボタンがクリックされたときではなく、ポインターがボタンに入ったときやボタンがフォーカスを受け取ったときに import() を開始します。これにより、ユーザーが迷っている間にチャンクのダウンロードが進みます。意図から操作までの間にダウンロードが重なるため、フォールバックが表示されるのはごく短時間か、まったく表示されなくなります。
import { lazy, Suspense, useState } from 'react';
const loadEditor = () => import('./RichTextEditor');
const RichTextEditor = lazy(loadEditor);
function ReplyBox() {
const [open, setOpen] = useState(false);
return (
<>
<button onMouseEnter={loadEditor} onFocus={loadEditor} onClick={() => setOpen(true)}>
Reply
</button>
{open && <Suspense fallback={<p>Loading editor…</p>}><RichTextEditor /></Suspense>}
</>
);
}
lazy とイベントハンドラーは 1 つのローダー関数を共有しています。モジュールシステムは最初の import() の結果をキャッシュするため、クリック後の描画ではすでに進行中のリクエストが再利用されます。コストは、ユーザーがホバーしたもののクリックしなかった場合に多少の帯域幅が無駄になることです。
webpackPrefetch などのヒントは webpack のマジックコメントです。Next.js の Turbopack は webpack 互換のマジックコメントをいくつか解釈しますが、ドキュメントに記載されているのは webpackIgnore のようなインポート制御用のコメントのみで、webpackPrefetch は含まれていません。また、その他のバンドラーは一般的にこれらを解釈しません。利用する前に、お使いのバンドラーのドキュメントを確認してください。イベントハンドラーから import() をトリガーする方法であれば、どのバンドラーでも動作します。
バンドルサイズではなくウォーターフォールを計測する
エントリーチャンクが小さくなっただけでは何の証明にもなりません。分割が効果を上げた証拠となるのは、スロットリングしたネットワークのウォーターフォールにおいて、最初の画面に必要なコードの読み込みが以前より早く完了していることです。
- お使いのバンドラー向けのバンドルビジュアライザーを実行し、各チャンクの中身を確認します。webpack の場合は、Webpack Bundle Analyzer のウォークスルーで、重いモジュールやページで使われていないコードを見つける方法を紹介しています。
- Chrome DevTools の Network パネルを開き、JS でフィルタリングして、スロットリングしたモバイルネットワークのプロファイルを適用します。
- 変更前と変更後を比較します。初回描画に必要な最後のチャンクがいつ読み込みを完了するか、そして新たな逐次リクエストのチェーンが発生していないかを確認します。
チェーンは階段状に見えます。各チャンクのリクエストが、前のリクエストが終わってから開始されるパターンです。ネストした lazy インポートはこのパターンを生み出します。
コード分割がかえって遅くなるのはどんなときか
コード分割が逆効果になるパターンは、予測可能な 3 つに分類できます。
小さなチャンクが多すぎる。 各チャンクは個別のリクエストです。小さなコンポーネントまで分割すると、リクエストとラウンドトリップが増え、削減できるバイト数以上のコストがかかります。
クリティカルパスを分割してしまう。 最初の画面に必要なコードを分割しても、待ち時間はなくなりません。後ろにずれるだけです。ブラウザはまずエントリーチャンクをダウンロードして実行し、動的 import() に到達してから、ようやくコンテンツを描画するチャンクをリクエストすることになります。
以前はコンテンツがあった場所にスピナーが表示される。 ユーザーがこれまで即座に見られていたコンテンツをフォールバックに置き換えると、合計の読み込み時間が短縮されていても、アプリが遅くなったように感じられることがあります。
すべての lazy バウンダリにはエラーバウンダリを組み合わせましょう。チャンクのリクエストは失敗することがあり、デプロイ前に開かれたタブが、すでに存在しないハッシュ付きファイルをリクエストする可能性もあります。コード分割されたアプリのセッションリプレイからは、バンドルレポートではわからないことが見えてきます。たとえば、ユーザーが Suspense のフォールバックで待たされている様子や、セッション途中のデプロイ後に ChunkLoadError に遭遇している様子などです。
残すべき分割の選び方
まずはルートから始め、次にインタラクションの後ろにある重いコンポーネントを分割します。ホバー時やフォーカス時にプリフェッチを行い、キャッシュハッシュの安定性を特に求める場合を除いて、共有コードの処理はバンドラーに任せましょう。すべての分割について、スロットリング環境下で変更前後のウォーターフォールを比較してください。最初の画面の描画が早くならない、あるいはインタラクションの応答が早くならないのであれば、その分割は元に戻しましょう。
よくある質問
React.lazy は名前付きエクスポートでも使えますか?
直接は使えません。React.lazy では、ローダーが返す Promise が、コンポーネントをデフォルトエクスポートとして持つモジュールで解決される必要があります。そのため、名前付きエクスポートの場合はマッピングし直す必要があります:lazy(() => import('./Charts').then((m) => ({ default: m.BarChart })))。チャンクの分割のされ方は変わりません。小さなラッパーファイルからコンポーネントをデフォルトとして再エクスポートする方法も有効で、lazy の呼び出しを短く保てます。
デプロイ後に lazy チャンクの読み込みが失敗した場合、どう復旧すればよいですか?
ページをリロードして、ブラウザに新しいエントリーチャンクと現在のチャンク URL を取得させます。React は lazy ローダーから返された最初の Promise を保持し、ローダーを 2 回目に呼び出すことはないため、再レンダリングだけではリトライされません。Vite では、動的インポートが失敗すると window 上で 'vite:preloadError' イベントが発火し、リスナー内で event.preventDefault() を呼び出すとエラーを抑制できます。リロードループを避けるため、自動リロードの回数には上限を設けてください。
webpackPrefetch と webpackPreload の違いは何ですか?
webpackPrefetch は、親チャンクの読み込み完了後、ブラウザのアイドル時間にチャンクをダウンロードします。次の画面など、後で必要になる可能性が高いコード向けです。webpackPreload は、親チャンクと同時に中程度の優先度でチャンクを取得します。親がすぐに要求するコード向けです。どちらも link ヒントを出力する webpack のマジックコメントです。Preload を選ぶケースは少なく、多用するとパフォーマンスを損なう可能性があります。
Vite はチャンクのウォーターフォールを自動的に防いでくれますか?
部分的には防いでくれます。非同期チャンクが共有の共通チャンクに依存している場合、Vite は動的インポートにプリロード処理を加えて書き換え、両者を順番にではなく並列に取得するようにします。また、エントリーチャンクとその直接のインポートに対して modulepreload リンクを出力します。ただし、これが対象とするのは静的な依存関係のみです。lazy コンポーネントが描画時にさらに import() を呼び出す場合は、依然として逐次リクエストが発生し、ウォーターフォールに現れます。