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
- 安裝 React DevTools 瀏覽器擴充套件
- 打開 DevTools → Profiler 分頁
- 點擊「Record」按鈕開始錄製
- 操作 UI
- 停止錄製,查看每個元件的渲染時間
程式碼內的效能量測
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 |
本課重點回顧
- React.memo:props 不變就跳過渲染
- useMemo:記憶昂貴的計算結果
- useCallback:記憶函式參考
- 不要過度優化:先量測,再優化
- 大列表用虛擬化
- 使用 Profiler 分析效能瓶頸