16.1 React 的渲染機制

State/Props 改變
      ↓
React 呼叫元件函式(渲染)
      ↓
產生新的虛擬 DOM
      ↓
與舊的虛擬 DOM 比較(Diffing)
      ↓
更新真實 DOM(只更新有變化的部分)

重點:父元件重新渲染時,所有子元件也會重新渲染,即使它們的 props 沒有改變。


16.2 React.memo — 避免不必要的重新渲染

import { useState, memo } from 'react';

// 未優化:每次父元件渲染,Child 都會渲染
const Child = ({ name }) => {
  console.log(`Child ${name} 渲染了`);
  return <p>Hello, {name}</p>;
};

// 用 memo 包裹:只有 props 改變時才重新渲染
const MemoizedChild = memo(({ name }) => {
  console.log(`MemoizedChild ${name} 渲染了`);
  return <p>Hello, {name}</p>;
});

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <Child name="Alice" />           {/* 每次都渲染 */}
      <MemoizedChild name="Bob" />     {/* 只在 name 改變時才渲染 */}
    </div>
  );
}

自訂比較函式

const HeavyComponent = memo(
  ({ data, style }) => {
    console.log('HeavyComponent 渲染了');
    return <div style={style}>{data.length} items</div>;
  },
  (prevProps, nextProps) => {
    // 回傳 true 表示 props 相同(不需要渲染)
    // 回傳 false 表示 props 不同(需要渲染)
    return (
      prevProps.data.length === nextProps.data.length &&
      prevProps.style === nextProps.style
    );
  }
);

16.3 useMemo — 記憶計算結果

useMemo 快取昂貴計算的結果,只在依賴改變時重新計算。

import { useState, useMemo } from 'react';

function ExpensiveList() {
  const [numbers] = useState(() =>
    Array.from({ length: 10000 }, (_, i) => Math.random() * 1000)
  );
  const [searchTerm, setSearchTerm] = useState('');
  const [count, setCount] = useState(0);

  // ❌ 沒有 useMemo:每次渲染都重新計算(包括 count 改變時)
  // const sortedAndFiltered = numbers
  //   .filter(n => n.toString().includes(searchTerm))
  //   .sort((a, b) => a - b);

  // ✅ 有 useMemo:只有 numbers 或 searchTerm 改變時才重新計算
  const sortedAndFiltered = useMemo(() => {
    console.log('重新計算排序與過濾...');
    return numbers
      .filter((n) => n.toFixed(2).includes(searchTerm))
      .sort((a, b) => a - b);
  }, [numbers, searchTerm]);

  return (
    <div>
      <input
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="搜尋數字..."
      />
      {/* 改變 count 不會重新計算 sortedAndFiltered */}
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <p>顯示 {sortedAndFiltered.length} / {numbers.length} 筆</p>
      <ul>
        {sortedAndFiltered.slice(0, 20).map((n, i) => (
          <li key={i}>{n.toFixed(2)}</li>
        ))}
      </ul>
    </div>
  );
}

16.4 useCallback — 記憶函式參考

useCallback 快取函式參考,避免每次渲染都建立新的函式。

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

const SearchInput = memo(({ onSearch }) => {
  console.log('SearchInput 渲染了');
  return (
    <input
      onChange={(e) => onSearch(e.target.value)}
      placeholder="搜尋..."
    />
  );
});

SearchInput.displayName = 'SearchInput';

function App() {
  const [query, setQuery] = useState('');
  const [count, setCount] = useState(0);

  // ❌ 每次渲染都建立新函式 → SearchInput 每次都重新渲染
  // const handleSearch = (value) => setQuery(value);

  // ✅ 用 useCallback 記憶函式 → SearchInput 不會不必要地渲染
  const handleSearch = useCallback((value) => {
    setQuery(value);
  }, []);

  return (
    <div>
      <SearchInput onSearch={handleSearch} />
      <p>搜尋:{query}</p>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
    </div>
  );
}

何時使用 useMemo / useCallback?

場景 使用
昂貴的計算(排序、過濾大量資料) useMemo
傳遞函式給 memo() 包裹的子元件 useCallback
建立 Context 的 value 物件 useMemo
作為 useEffect 的依賴 useCallback / useMemo

不要過度優化!

// ❌ 不需要 useMemo(計算非常簡單)
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// ✅ 直接計算就好
const fullName = `${firstName} ${lastName}`;

// ❌ 不需要 useCallback(子元件沒有用 memo)
const handleClick = useCallback(() => setCount(c => c + 1), []);

// ✅ 直接定義就好
const handleClick = () => setCount(c => c + 1);

16.5 列表虛擬化

當列表有數千項時,只渲染可見範圍的元素:

import { useState, useRef, useEffect } from 'react';

function VirtualizedList({ items, itemHeight = 40, containerHeight = 400 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const containerRef = useRef(null);

  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(startIndex + visibleCount + 1, items.length);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  const visibleItems = items.slice(startIndex, endIndex);

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{
        height: containerHeight,
        overflow: 'auto',
        border: '1px solid #e2e8f0',
        borderRadius: '8px',
      }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ position: 'absolute', top: offsetY, width: '100%' }}>
          {visibleItems.map((item, index) => (
            <div
              key={startIndex + index}
              style={{
                height: itemHeight,
                padding: '0 16px',
                display: 'flex',
                alignItems: 'center',
                borderBottom: '1px solid #f0f0f0',
                backgroundColor: (startIndex + index) % 2 === 0 ? '#f7fafc' : 'white',
              }}
            >
              {item}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

// 使用
function App() {
  const items = Array.from({ length: 10000 }, (_, i) => `項目 #${i + 1}`);

  return (
    <div style={{ maxWidth: '600px', margin: '20px auto' }}>
      <h2>虛擬化列表(10,000 項,只渲染可見部分)</h2>
      <VirtualizedList items={items} />
    </div>
  );
}

16.6 效能分析工具

React DevTools Profiler

  1. 安裝 React DevTools 瀏覽器擴充套件
  2. 打開 DevTools → Profiler 分頁
  3. 點擊「Record」按鈕開始錄製
  4. 操作 UI
  5. 停止錄製,查看每個元件的渲染時間

程式碼內的效能量測

import { Profiler } from 'react';

function onRenderCallback(
  id,           // 元件 Profiler 的 id
  phase,        // "mount" 或 "update"
  actualDuration, // 這次渲染花費的時間
  baseDuration, // 不使用 memo 時預估的渲染時間
) {
  console.log(`[${id}] ${phase}: ${actualDuration.toFixed(2)}ms`);
}

function App() {
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <ExpensiveComponent />
    </Profiler>
  );
}

16.7 優化檢查清單

優化項目 方法
避免子元件不必要渲染 React.memo
快取昂貴計算 useMemo
穩定函式參考 useCallback
大列表 虛擬化(react-window / react-virtuoso)
程式碼分割 React.lazy + Suspense
圖片優化 懶載入、WebP 格式、適當尺寸
避免 Context 導致全域渲染 拆分 Context、useMemo 包裝 value

本課重點回顧

  1. React.memo:props 不變就跳過渲染
  2. useMemo:記憶昂貴的計算結果
  3. useCallback:記憶函式參考
  4. 不要過度優化:先量測,再優化
  5. 大列表用虛擬化
  6. 使用 Profiler 分析效能瓶頸