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

コード分割とメモ化

dynamic import、React.memo、useMemo、useCallback、仮想化を学ぼう

コード分割(Code Splitting)

コード分割は、JavaScriptバンドルを複数の小さなチャンクに分割し、 必要なタイミングで必要なコードだけを読み込む手法です。初期読み込みを高速化できます。

■ dynamic import(動的インポート)

// ❌ 静的インポート: すべて初期バンドルに含まれる
import { HeavyChart } from './HeavyChart';
import { PDFViewer } from './PDFViewer';

// ✅ 動的インポート: 使うときに読み込む
const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <p>チャートを読み込み中...</p>,
});

// ■ Next.js の dynamic(推奨)
import dynamic from 'next/dynamic';

// SSR を無効にしたい場合(ブラウザ専用ライブラリ)
const MapComponent = dynamic(() => import('./Map'), {
  ssr: false,
  loading: () => <div className="h-96 bg-gray-800 animate-pulse" />,
});

// 条件付き読み込み
export default function Dashboard() {
  const [showChart, setShowChart] = useState(false);

  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        チャートを表示
      </button>
      {showChart && <HeavyChart />}
    </div>
  );
}

React.lazy と Suspense

React.lazyはReact標準のコード分割機能です。 Suspenseと組み合わせて、読み込み中のフォールバックUIを提供します。

import { lazy, Suspense } from 'react';

// コンポーネントを遅延読み込み
const AdminPanel = lazy(() => import('./AdminPanel'));
const UserProfile = lazy(() => import('./UserProfile'));

// ルートレベルでの分割
function App() {
  return (
    <Suspense fallback={<LoadingSpinner />}>
      <Routes>
        <Route path="/admin" element={<AdminPanel />} />
        <Route path="/profile" element={<UserProfile />} />
      </Routes>
    </Suspense>
  );
}

// 名前付きエクスポートの遅延読み込み
const MyComponent = lazy(() =>
  import('./MyModule').then((module) => ({
    default: module.MyComponent,
  }))
);

// Suspense のネスト(セクションごとのローディング)
function Dashboard() {
  return (
    <div>
      <h1>ダッシュボード</h1>
      <Suspense fallback={<Skeleton />}>
        <ChartSection />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <TableSection />
      </Suspense>
    </div>
  );
}

React.memo による再レンダリング防止

React.memoは、propsが変わらない限り コンポーネントの再レンダリングをスキップする高階コンポーネントです。

import { memo } from 'react';

// ❌ 親が再レンダリングするたびに毎回レンダリングされる
function UserCard({ name, avatar }: { name: string; avatar: string }) {
  console.log('UserCard rendered');
  return (
    <div>
      <img src={avatar} alt={name} />
      <p>{name}</p>
    </div>
  );
}

// ✅ props が変わらなければスキップ
const UserCard = memo(function UserCard({
  name,
  avatar,
}: {
  name: string;
  avatar: string;
}) {
  console.log('UserCard rendered');
  return (
    <div>
      <img src={avatar} alt={name} />
      <p>{name}</p>
    </div>
  );
});

// カスタム比較関数
const UserCard = memo(
  function UserCard({ user }: { user: User }) {
    return <div>{user.name}</div>;
  },
  (prevProps, nextProps) => {
    // true を返すとスキップ(再レンダリングしない)
    return prevProps.user.id === nextProps.user.id;
  }
);

// ⚠️ memo が効かないケース
// オブジェクトや関数を直接渡すと毎回新しい参照になる
<UserCard
  style={{ color: 'red' }}  // ❌ 毎回新しいオブジェクト
  onClick={() => {}}         // ❌ 毎回新しい関数
/>

useMemo と useCallback

useMemoは計算結果のキャッシュ、useCallbackは関数参照のキャッシュに使います。 React.memoと組み合わせることで効果を発揮します。

import { useMemo, useCallback, useState } from 'react';

function ProductList({ products, category }: Props) {
  // ✅ useMemo: 重い計算をキャッシュ
  const filteredProducts = useMemo(() => {
    console.log('filtering...');  // category が変わったときだけ実行
    return products.filter((p) => p.category === category);
  }, [products, category]);

  // ✅ useMemo: 派生データのキャッシュ
  const stats = useMemo(() => ({
    total: filteredProducts.length,
    avgPrice: filteredProducts.reduce((sum, p) => sum + p.price, 0)
      / filteredProducts.length,
  }), [filteredProducts]);

  // ✅ useCallback: 関数参照をキャッシュ
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
  const handleSort = useCallback(() => {
    setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
  }, []);

  // ✅ useCallback: 子コンポーネントに渡す関数
  const handleDelete = useCallback((id: string) => {
    setProducts((prev) => prev.filter((p) => p.id !== id));
  }, []);

  return (
    <div>
      <p>合計: {stats.total}件 / 平均: ¥{stats.avgPrice}</p>
      <button onClick={handleSort}>並び替え</button>
      {filteredProducts.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          onDelete={handleDelete}  // memo と組み合わせて効果的
        />
      ))}
    </div>
  );
}

// ⚠️ 過度な最適化に注意
// 単純な計算にuseMemoは不要(メモ化自体のコストがある)
const doubled = useMemo(() => count * 2, [count]);  // ❌ 不要
const doubled = count * 2;                           // ✅ 十分速い

リスト仮想化(Virtualization)

数千〜数万件のリストを表示する場合、仮想化を使って画面に見えている要素だけをDOMに描画します。

// react-window を使った仮想化リスト
import { FixedSizeList } from 'react-window';

interface Item {
  id: number;
  name: string;
}

function VirtualList({ items }: { items: Item[] }) {
  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style} className="flex items-center px-4 border-b border-gray-800">
      <span className="text-gray-300">{items[index].name}</span>
    </div>
  );

  return (
    <FixedSizeList
      height={600}           // リストの高さ
      width="100%"
      itemCount={items.length}  // 10,000件あってもOK
      itemSize={50}           // 各行の高さ
    >
      {Row}
    </FixedSizeList>
  );
}

// 可変サイズの場合
import { VariableSizeList } from 'react-window';

function VariableList({ items }: { items: Item[] }) {
  const getItemSize = (index: number) => {
    return items[index].name.length > 50 ? 80 : 50;
  };

  return (
    <VariableSizeList
      height={600}
      width="100%"
      itemCount={items.length}
      itemSize={getItemSize}
    >
      {Row}
    </VariableSizeList>
  );
}

// ❌ 仮想化なし: 10,000件 → 10,000個のDOM要素
// ✅ 仮想化あり: 10,000件 → 画面に見える約20個だけ

Debounce と Throttle

高頻度で発生するイベント(入力、スクロール、リサイズ)の処理を制御して、不要な計算やAPIコールを減らします。

// ■ Debounce: 最後のイベントから一定時間後に実行
// 用途: 検索入力、フォームバリデーション

import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';

function SearchInput() {
  const [query, setQuery] = useState('');

  // 300ms 入力が止まったら検索実行
  const debouncedSearch = useCallback(
    debounce((term: string) => {
      fetch(`/api/search?q=${term}`).then(/* ... */);
    }, 300),
    []
  );

  return (
    <input
      value={query}
      onChange={(e) => {
        setQuery(e.target.value);
        debouncedSearch(e.target.value);
      }}
      placeholder="検索..."
    />
  );
}

// ■ Throttle: 一定間隔で最大1回だけ実行
// 用途: スクロール、リサイズ、マウス移動

import throttle from 'lodash/throttle';

function ScrollTracker() {
  useEffect(() => {
    const handleScroll = throttle(() => {
      const scrollY = window.scrollY;
      console.log('Scroll position:', scrollY);
    }, 100);  // 100ms ごとに最大1回

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return <div>...</div>;
}

// ■ カスタム useDebounce フック
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// 使用例
function Search() {
  const [input, setInput] = useState('');
  const debouncedInput = useDebounce(input, 300);

  useEffect(() => {
    if (debouncedInput) fetchResults(debouncedInput);
  }, [debouncedInput]);
}

まとめ

  • dynamic import / React.lazy でバンドルを分割し、初期読み込みを軽量化する
  • React.memo で不要な再レンダリングをスキップする
  • useMemo は重い計算のキャッシュ、useCallback は関数参照のキャッシュに使う
  • 大量リストには react-window 等の仮想化ライブラリを活用する
  • debounce / throttle で高頻度イベントの処理を最適化する
  • 過度な最適化は避け、計測結果に基づいて適用する