目次
31 章

パフォーマンス・バンドル・Web Vitals

Core Web Vitals(LCP・INP・CLS)の測定と改善。Lighthouse・バンドル分析・code splitting・RSC streaming・next/image・next/font・INP 最適化まで。

30章で自動テストによる機能的安全網を入れました。ただし「動く」と「速く動く」は別の問題です。本章ではユーザーが実際に体感するパフォーマンスを測定し改善する道具を見ていきます。

14章(パフォーマンス最適化)が React 内部の再レンダリングのコストを扱ったとすれば、本章はそれより外側のコストを見ます。バンドルのダウンロード、初回ペイント、インタラクション応答 — ユーザーが直接感じる次元です。そして26章(Suspense と use())で作った streaming がこの次元でどう作用するかも、もう一度見ていきます。本章の実運用 RUM データは次の33章(デプロイと観測性)で Sentry / PostHog と出会うことになります。

測定なしに最適化しない #

パフォーマンス改善の第一原則です。印象や勘で最適化すると、たいてい間違った場所に手を入れてコストだけ増えます。

間違った流れ
「遅い気がする」 → useMemo をさらに追加 → 実際にもっと遅くなる
正しい流れ
測定 → 最大のコストを特定 → それ一つを改善 → 再測定 → 繰り返し

本章で登場するすべての道具は上の「測定」段階に入ります。道具を知っておくことも大事ですが、より大事なのは 常に測定を先に する姿勢です。

Core Web Vitals — 3つの指標 #

Google が検索ランキングとユーザー体験の標準として定めた3つの指標です。

LCP (Largest Contentful Paint) #

ページで最も大きなコンテンツ(画像、大きなテキストブロック)が画面に見えるまでの時間。

  • Good: 2.5秒以内
  • Needs improvement: 2.5〜4秒
  • Poor: 4秒以上

22章で見た CSR の最大の弱点がこの指標でした。RSC + SSR に移ると LCP が大きく改善されます。

INP (Interaction to Next Paint) #

ユーザーがクリック / タップ / キー入力を行ったとき、次のペイントが発生するまでの時間。2024年に FID (First Input Delay) を置き換えた新指標です。ページ全体の寿命の中で最も悪いインタラクションの遅延を基準とします。

  • Good: 200ms 以内
  • Needs improvement: 200〜500ms
  • Poor: 500ms 以上

JS の long task が INP を破壊する主犯です。14章の useMemo / React Compiler と直接つながります。

CLS (Cumulative Layout Shift) #

ページ寿命の間に発生した累積的なレイアウトのずれの量。画像 / フォントが遅れて読み込まれてコンテンツがジャンプする現象を捕まえます。

  • Good: 0.1 以下
  • Needs improvement: 0.1〜0.25
  • Poor: 0.25 以上

画像に width/height を与えなかったり、フォントが fallback から swap されてテキストサイズが変わったりすると CLS が悪化します。

測定ツール #

Lighthouse — Lab データ #

Chrome DevTools に組み込まれている道具。Lab 環境(シミュレートされたネットワークと CPU) で一度の測定を返します。

Lighthouse 実行
DevTools → Lighthouse タブ → Analyze page load

長所: 即座の結果、改善提案も併せて表示。

限界: lab 環境のため、実ユーザーの多様な環境(低スペック端末、遅いネットワーク)を完全には反映できません。

PageSpeed Insights (PSI) #

Google がホストする Web ツール — pagespeed.web.dev。Lighthouse + 実ユーザーデータ(CrUX) を並べて見せます。

CrUX (Chrome User Experience Report) は Chrome ユーザーが実際に見たデータの28日平均です。実ユーザー環境の分布を反映します。

web-vitals ライブラリ — Production RUM #

Lab データは1度のスナップショットなので、実ユーザーが体験する幅は見えません。Real User Monitoring (RUM) がその空白を埋めます。

src/app/web-vitals.ts
'use client';

import { onCLS, onINP, onLCP } from 'web-vitals';

type Metric = {
  name: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
};

function report(metric: Metric) {
  // 33章で扱う PostHog / Sentry へ送信
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}

export function reportVitals() {
  onCLS(report);
  onINP(report);
  onLCP(report);
}

Next.js には専用の useReportWebVitals フックがあります。

src/app/layout.tsx — Next.js の useReportWebVitals
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals(metric => {
    navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
  });
  return null;
}
src/app/layout.tsx — root layout に組み込む
import { WebVitals } from './web-vitals';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <WebVitals />
        {children}
      </body>
    </html>
  );
}

navigator.sendBeacon はページが閉じられるときも安定して送信されます。普通の fetch はページ遷移中にキャンセルされることがあります。

バンドル分析 #

クライアントへ送られる JavaScript が小さいほど、ページが速く立ち上がります。バンドルに何が入っているかを見る道具が @next/bundle-analyzer です。

インストール
pnpm add -D @next/bundle-analyzer
next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';

const analyze = withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' });

export default analyze({
  // ... その他 Next.js 設定
});
分析の実行
ANALYZE=true pnpm build

ビルドが終わるとブラウザにツリーマップが開きます。大きな箱が重いパッケージです。よく見つかる重い候補です。

  • momentdate-fns の全体 import(tree shaking が効かないパターン)
  • チャートライブラリ(rechartschart.js)
  • マークダウン / MDX パーサがクライアントバンドルに入った場合
  • lodash の全体 import

それぞれの対策です。

  • momentdate-fns または native の Intl.DateTimeFormat
  • チャートライブラリ → チャートのあるページでのみ next/dynamic で lazy import
  • マークダウンパーサ → Server Component でのみ import(自動でクライアントへは行かない)
  • lodashlodash-es の named import のみ

Code Splitting と Lazy Import #

バンドルサイズを減らす最も直感的な方法は 必要なときだけダウンロード です。

next/dynamic — コンポーネント単位の lazy #

重いコンポーネントを lazy import
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <p>チャート読み込み中...</p>,
  ssr: false,  // ブラウザでのみレンダー(SSR しない)
});

export default function DashboardPage() {
  return (
    <main>
      <h1>ダッシュボード</h1>
      <HeavyChart />
    </main>
  );
}

ssr: false は SSR をスキップするオプションです。ブラウザ専用ライブラリ(例: chart.js の一部)が SSR でエラーを出す場合に使います。ただし SSR をスキップすると LCP が遅くなりうるので、本当に必要な場合だけ 使ってください。

React.lazy + Suspense — 一般的なパターン #

React.lazy を使う
import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

export default function DashboardPage() {
  return (
    <Suspense fallback={<p>チャート読み込み中...</p>}>
      <HeavyChart />
    </Suspense>
  );
}

Next.js では next/dynamic のほうが一般的ですが、通常の Vite 環境では React.lazy を使います。

Route-level vs Component-level #

  • Route-level: ルート単位でコードを分けます。Next.js の App Router では自動です。別のページのコードはそのページに到達したときに初めてダウンロードされます。
  • Component-level: 同じページ内の重いコンポーネントだけを lazy。上の next/dynamic パターン。

ルート分割は自動なので意識することは少ないです。コンポーネント単位の lazy は「このコンポーネントが遅れて現れてもユーザー体験に問題がない」ときに効果が大きいです。

RSC Streaming が LCP に与える影響 #

26章の Suspense + streaming は 体感 LCP に直接影響します。26章で短く触れた部分を、本章でもう一度見ていきます。

Streaming のないページ #

🐢 すべてのデータをページ関数の最上部で await
export default async function Page() {
  const profile = await getProfile();   // 100ms
  const posts = await getPosts();       // 2000ms
  const stats = await getStats();       // 3000ms
  return (
    <div>
      <Profile data={profile} />
      <Posts data={posts} />
      <Stats data={stats} />
    </div>
  );
}

このページの LCP は 3000ms 以降 です。最も遅いデータを待ってからでないと HTML がクライアントに行かないからです。

Streaming を適用 #

🚀 Suspense で streaming
export default async function Page() {
  const profile = await getProfile();  // 100ms

  return (
    <div>
      <Profile data={profile} />
      <Suspense fallback={<Skeleton />}><PostsSection /></Suspense>
      <Suspense fallback={<Skeleton />}><StatsSection /></Suspense>
    </div>
  );
}

このページは 100ms の時点で最初の HTML chunk がクライアントへ 届きます。LCP の候補が Profile にあれば、LCP は 100ms 付近です。同じデータ量でも体感速度が 30倍速くなります。

Suspense 境界の選択が LCP に直接影響する #

LCP の候補がどこにあるかを意識し、Suspense 境界を置きます。

  • LCP 候補を Suspense の中に置かないでください。候補が fallback に隠れたら意味がありません。
  • 遅い子を Suspense の中に切り出して、LCP 候補が速く見えるようにします。

これが26章の「境界をどこに置くか」ガイドラインの、パフォーマンス次元での読み替えです。

画像 — next/image と LCP #

LCP の候補が画像である場合が多いです。next/image は LCP に直接影響する機能を自動で処理します。

next/image の基本
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      width={1200}
      height={600}
      alt="ヒーロー画像"
      priority  // LCP 候補なら priority を指定
    />
  );
}

要点。

  • priority: LCP 候補画像に付けます。preload hint が自動で入り、最も速くダウンロードされます。
  • width / height: ピクセルではなく縦横比情報。layout shift を防ぎ CLS を守ります。
  • 自動フォーマット変換(WebP / AVIF)、レスポンシブ srcset の生成。

LCP がうまく取れないなら、まず疑う場所 は hero 画像に priority が抜けていないかどうかです。

フォント — next/font と CLS #

Web フォントが読み込まれる前は fallback フォントが使われ、届くと swap されてテキストサイズが微妙に変わります。そのジャンプが CLS を破壊します。

next/font はこの問題を自動で処理します。

src/app/layout.tsx — next/font の使用
import { Pretendard } from 'next/font/google';

const pretendard = Pretendard({
  subsets: ['latin'],
  display: 'swap',
  preload: true,
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko" className={pretendard.className}>
      <body>{children}</body>
    </html>
  );
}

自動で処理されること。

  • フォントをビルド時にホスティング(Google Fonts などの外部 CDN 依存を除去)
  • フォントメタデータ解析による fallback とのメトリックマッチング(size-adjust)
  • 自動 preload

next/font を使わず外部フォントを直接 <link> で呼ぶと CLS スコアが下がりがちです。新規プロジェクトは next/font が標準 です。

INP の改善 #

INP はユーザーのインタラクションへの応答速度です。最もよくある原因は メインスレッドを止める長い同期処理 です。

Long Task の識別 #

DevTools の Performance タブで録画すると、long task(50ms 以上)が赤色で表示されます。その処理が何かを分析します。

よく見つかる long task。

  • 重いリストのレンダリング(仮想化していない 1000+ 項目)
  • 大きな JSON のパース(数 MB の応答)
  • 同期的な重い計算(ソート、フィルタ、統計)

React 19 の React Compiler #

14章と28章で見た React Compiler は自動メモ化で不要な再レンダリングを減らし、INP に間接的に貢献します。ただし INP の本質的な解は 計算自体を小さくするか、別スレッドへ移すこと です。

useTransition — 優先度の低い更新 #

優先度の低い更新は transition で
'use client';

import { useState, useTransition } from 'react';

export default function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);  // 即時更新(high priority)
    startTransition(() => {
      setResults(computeExpensiveResults(e.target.value));  // 後回しでも OK
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <p>検索中...</p>}
      <ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
    </>
  );
}

startTransition の中の更新は React が優先度を下げて処理します。ユーザー入力は即時反映され、重い結果の更新はメインスレッドに余裕があるときに処理されます。

scheduler.yield()(実験的) #

長い同期計算をインタラクションの合間に譲る新しい API です。

scheduler.yield の使用(実験)
async function heavyWork(items: Item[]) {
  for (const item of items) {
    processItem(item);
    if ('scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();  // インタラクションがあれば譲る
    }
  }
}

ブラウザサポートがまだ均一でないので polyfill または fallback が必要です。一般的にはより安全な方法が Web Worker に移すこと ですが、単純なケースでは scheduler.yield() が軽い解になります。

14章(パフォーマンス最適化)との関係 #

14章の useMemo / useCallback / memo と React Compiler が、本章のどの指標に作用するかを整理しておきます。

14章の道具影響する指標適用場所
memoINPよく再レンダリングされる子コンポーネント
useMemoINP高コスト計算結果のキャッシュ
useCallbackINP子の memo を生かすときだけ意味がある
React CompilerINP上の3つを自動化

14章の道具はすべて レンダリングコストINP に作用します。LCP / CLS は14章ではなく本章の道具(画像、フォント、streaming)が改善する指標です。3つの役割が違うことをはっきり押さえると、最適化の方向を見失わずに済みます。

自分でやってみよう — 小さなページの Web Vitals 改善 #

本書の例題サイトの一つを選んで、次のサイクルを一度回してみてください。

  1. 測定: production ビルド(pnpm build && pnpm start)で立ち上げたページに Lighthouse をかけます。LCP / INP / CLS のスコアと一緒に「Opportunities」セクションを見ます。
  2. バンドル分析: ANALYZE=true pnpm build でツリーマップを開き、最も大きな箱3つを確認します。
  3. 改善 — LCP: hero 画像に priority を追加し、next/image に置き換えます。再び測定して差を見ます。
  4. 改善 — CLS: フォントを next/font に移し、画像には width/height をすべて指定します。再び測定。
  5. 改善 — INP: 検索やフィルタのようなインタラクションを useTransition で包み、大きな一覧には仮想化またはページネーションを適用。再び測定。

各ステップでスコアの変化を記録しておくと、「どの道具がどの指標にどれだけ影響するか」が明確になります。

練習問題 #

  1. 指標の分類. 次の5つの症状が LCP / INP / CLS のうちどれを壊すかを答えてください。(a) ヒーロー画像が大きいのに lazy で読み込み、(b) クリック後の画面反応に 1 秒かかる、(c) ページ読み込み中にテキストサイズが少しジャンプ、(d) JS バンドルが 3MB、(e) 広告スロットに width がなくコンテンツが押し下げられる。
  2. streaming の価値評価. 22章で見た CSR / SSR / RSC モデルのうち、streaming が最も大きな差を生むページはどんな形かを1段落で説明してみてください。「データ依存が強いページ」と「データがほとんどないページ」それぞれでの streaming の価値の差を併せて押さえると、答えに近づきます。
  3. バンドルダイエット計画. @next/bundle-analyzer で自分の(または本書の例題)プロジェクトを分析し、最も大きなパッケージ2つを選び、次のどの戦略で削るかを書いてみてください — (a) 軽い代替に置き換え、(b) next/dynamic で lazy、(c) Server Component でのみ使ってクライアントから外す。それぞれの戦略のトレードオフを1行ずつ書きます。

一行まとめ: パフォーマンス改善は「測定 → 識別 → 1つだけ改善 → 再測定」のサイクルです。Core Web Vitals(LCP・INP・CLS)の3指標を Lighthouse(lab)と web-vitals(RUM)で測定します。LCP は next/image の priority と RSC streaming、CLS は next/font と画像サイズ指定、INP は14章のメモ化と useTransition が扱います。バンドル分析で最も大きな重量を識別し、next/dynamic または Server Component でクライアントから外します。production RUM データは次の33章(デプロイと観測性)で PostHog / Sentry に流していきます。

次の章 #

次の 32章 認証とセッション — Auth.js v5 / OAuth / JWT では、フルスタックアプリの最初の入り口となる認証とセッションを扱います。RSC / Client Component / Server Action / middleware の4箇所でセッションをどう読むかを整理し、JWT vs DB session の選択基準と Auth.js v5 の標準セットアップまでひとまとめにします。

X