<CodeLearn/>
パフォーマンス レッスン5

パフォーマンス総合演習

遅いページを分析し、学んだテクニックを使って高速化しよう

演習:遅いECサイトを最適化せよ

以下のパフォーマンスに問題のあるページを分析し、 段階的に改善していきましょう。まず問題点を特定し、それぞれに適切な最適化を適用します。

// ❌ 最適化前: 問題だらけのECサイトトップページ
import _ from 'lodash';                    // 問題1: lodash 全体をインポート
import moment from 'moment';               // 問題2: 重いライブラリ
import { AllIcons } from 'react-icons';    // 問題3: アイコン全体
import HeavyChart from './HeavyChart';     // 問題4: 不要な初期読み込み

export default function StorePage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [search, setSearch] = useState('');

  useEffect(() => {
    fetch('/api/products')
      .then((res) => res.json())
      .then(setProducts);
  }, []);

  // 問題5: 毎レンダリングでフィルタリング
  const filtered = products.filter((p) =>
    p.name.toLowerCase().includes(search.toLowerCase())
  );

  // 問題6: 入力のたびにAPI呼び出し
  const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
    fetch(`/api/search?q=${e.target.value}`);
  };

  return (
    <div>
      {/* 問題7: 巨大な未最適化画像 */}
      <img src="/hero-4000x2000.png" />

      {/* 問題8: width/height 未指定(CLS発生) */}
      <img src="/banner.jpg" />

      <input onChange={handleSearch} />

      <HeavyChart data={products} />

      {/* 問題9: 10,000件を全部レンダリング */}
      {filtered.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onAdd={() => addToCart(product)}  // 問題10: 毎回新しい関数
          style={{ margin: 8 }}             // 問題11: 毎回新しいオブジェクト
        />
      ))}
    </div>
  );
}

Step 1: ボトルネックを特定する

まずLighthouseと DevTools を使って問題を数値で把握します。

■ Lighthouse 結果(最適化前)

  Performance:   32 🔴
  LCP:           6.8s  → 目標 2.5s以下
  TBT:           1200ms → 目標 200ms以下
  CLS:           0.45  → 目標 0.1以下
  FCP:           3.2s
  Speed Index:   5.1s

■ 問題の分類

  バンドルサイズ:
    - lodash 全体 (72KB gzip)
    - moment.js (67KB gzip)
    - react-icons 全体 (200KB+)
    - HeavyChart 初期バンドルに含まれる

  画像:
    - hero画像: 4000x2000px, PNG, 2.3MB → LCP悪化
    - banner画像: width/height未指定 → CLS発生

  レンダリング:
    - 10,000件のリストを全件DOM化
    - フィルタリングが毎レンダリングで実行
    - 子コンポーネントへの関数/オブジェクト参照が毎回新規

  ネットワーク:
    - 入力のたびにAPI呼び出し(debounce なし)

Step 2: バンドルサイズを削減する

不要なコードを減らし、必要なタイミングで必要なコードだけ読み込むようにします。

// ✅ 修正1: lodash → 個別インポート
// Before: import _ from 'lodash';         // 72KB
// After:
import debounce from 'lodash/debounce';    // 1KB

// ✅ 修正2: moment → dayjs に置換
// Before: import moment from 'moment';     // 67KB
// After:
import dayjs from 'dayjs';                 // 2KB
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
// moment().format('YYYY-MM-DD') → dayjs().format('YYYY-MM-DD')

// ✅ 修正3: アイコンの個別インポート
// Before: import { AllIcons } from 'react-icons';  // 200KB+
// After:
import { FaShoppingCart } from 'react-icons/fa';    // 数KB
import { FiSearch } from 'react-icons/fi';

// ✅ 修正4: HeavyChart を dynamic import
// Before: import HeavyChart from './HeavyChart';
// After:
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => (
    <div className="h-64 bg-gray-800 animate-pulse rounded-lg" />
  ),
});

// バンドルサイズ改善:
// Before: 初期JS 540KB (gzip)
// After:  初期JS 180KB (gzip) → 67%削減

Step 3: 画像を最適化する

画像フォーマット、サイズ、読み込みタイミングを最適化してLCPとCLSを改善します。

import Image from 'next/image';

// ✅ 修正5: ヒーロー画像の最適化
// Before:
//   <img src="/hero-4000x2000.png" />  // 2.3MB, PNG, サイズ未指定
// After:
<Image
  src="/hero.jpg"
  alt="ストアのヒーロー画像"
  width={1200}
  height={600}
  priority                    // LCP要素にはpriority
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQ..."
  sizes="100vw"
/>
// → 自動でWebP変換、リサイズ: 2.3MB → 85KB

// ✅ 修正6: バナー画像のCLS修正
// Before:
//   <img src="/banner.jpg" />  // width/height なし → CLS
// After:
<Image
  src="/banner.jpg"
  alt="セールバナー"
  width={800}
  height={200}
  loading="lazy"              // ファーストビュー外はlazy
/>

// ✅ 商品画像の最適化
{filtered.map((product) => (
  <Image
    key={product.id}
    src={product.image}
    alt={product.name}
    width={300}
    height={300}
    loading="lazy"
    sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
  />
))}

// 画像改善結果:
// LCP: 6.8s → 2.1s
// CLS: 0.45 → 0.02

Step 4: レンダリングを最適化する

メモ化、仮想化、debounceを適用してINP/TBTを改善します。

import { memo, useMemo, useCallback, useState, useEffect } from 'react';
import { FixedSizeList } from 'react-window';
import debounce from 'lodash/debounce';

// ✅ 修正7: ProductCard をメモ化
const ProductCard = memo(function ProductCard({
  product,
  onAdd,
}: {
  product: Product;
  onAdd: (id: string) => void;
}) {
  return (
    <div className="p-4 border border-gray-800 rounded-lg">
      <Image src={product.image} alt={product.name} width={300} height={300} />
      <h3>{product.name}</h3>
      <p>¥{product.price.toLocaleString()}</p>
      <button onClick={() => onAdd(product.id)}>カートに追加</button>
    </div>
  );
});

export default function StorePage() {
  const [products, setProducts] = useState<Product[]>([]);
  const [search, setSearch] = useState('');

  // ✅ 修正8: フィルタリングをメモ化
  const filtered = useMemo(() => {
    return products.filter((p) =>
      p.name.toLowerCase().includes(search.toLowerCase())
    );
  }, [products, search]);

  // ✅ 修正9: コールバック関数をメモ化
  const handleAdd = useCallback((id: string) => {
    addToCart(id);
  }, []);

  // ✅ 修正10: 検索をdebounce
  const debouncedSearch = useCallback(
    debounce((term: string) => {
      fetch(`/api/search?q=${term}`);
    }, 300),
    []
  );

  const handleSearchInput = (e: ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
    debouncedSearch(e.target.value);
  };

  // ✅ 修正11: 仮想化リストで大量商品を表示
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style}>
      <ProductCard
        product={filtered[index]}
        onAdd={handleAdd}
      />
    </div>
  );

  return (
    <div>
      <Image src="/hero.jpg" alt="ヒーロー" width={1200} height={600} priority />
      <input onChange={handleSearchInput} placeholder="商品を検索..." />

      <HeavyChart data={products} />

      <FixedSizeList
        height={800}
        width="100%"
        itemCount={filtered.length}
        itemSize={320}
      >
        {Row}
      </FixedSizeList>
    </div>
  );
}

改善結果のビフォー・アフター

すべての最適化を適用した結果を比較しましょう。

■ Lighthouse スコア比較

                Before    After     改善率
  ─────────────────────────────────────────
  Performance    32 🔴    94 🟢    +194%
  LCP            6.8s     2.1s     -69%
  TBT            1200ms   120ms    -90%
  CLS            0.45     0.02     -96%
  FCP            3.2s     1.1s     -66%

■ バンドルサイズ

  初期JS:  540KB → 180KB  (-67%)
  画像:    2.8MB → 210KB  (-92%)
  合計:    3.5MB → 450KB  (-87%)

■ 適用したテクニック一覧

  バンドル最適化:
    ✅ lodash → 個別インポート
    ✅ moment → dayjs に置換
    ✅ react-icons 個別インポート
    ✅ HeavyChart の dynamic import

  画像最適化:
    ✅ next/image で自動WebP変換
    ✅ priority で LCP 要素を優先
    ✅ width/height 指定で CLS 防止
    ✅ lazy loading で初期読み込み削減

  レンダリング最適化:
    ✅ React.memo で不要な再レンダリング防止
    ✅ useMemo でフィルタリング結果キャッシュ
    ✅ useCallback で関数参照安定化
    ✅ react-window で仮想化リスト
    ✅ debounce でAPI呼び出し制御

まとめ

  • パフォーマンス改善は「計測 → 分析 → 改善 → 再計測」のサイクルで行う
  • まずLighthouseでボトルネックを特定してから、優先度の高い問題から対処する
  • バンドルサイズ削減(Tree Shaking、dynamic import)は即効性が高い
  • 画像最適化はLCPとCLSの両方に大きく影響する
  • メモ化と仮想化はレンダリングパフォーマンス(INP/TBT)の改善に効果的
  • debounce/throttleでネットワークとCPUの無駄遣いを防ぐ