useCallback 和 useMemo 都是用來記憶的 Hook,能把計算結果快取起來,下次相同的輸入時直接返回快取的結果,不需要重新計算。其中 useCallback 記憶的是函數;useMemo 記憶化的是任意值(通常是計算結果)。
const memoizedCallback = useCallback(() => {
}, [依賴])
const memoizedValue = useMemo(() => {
}, [依賴])實際上大多數情況下用不到記憶 Hook,記憶本身也有成本,如果用錯了反而會降低效能。
Hook | 記憶 | 何時用 | 成本 |
|---|
useCallback | 函數引用 | 傳給 React.memo 的子組件 | 增加依賴追蹤複雜度 |
useMemo | 計算結果 | 昂貴的計算或傳給 memo 組件 | 計算成本 + 追蹤成本 |
不用記憶 Hook | 無 | 簡單值、簡單計算 | 最快 |
實例 1:POS 系統的購物車(useCallback)
假設你在做 POS 點餐系統。有一個購物車列表,顯示顧客點了哪些餐點。每一行是一個餐點,用戶可以改數量或刪除。
購物車的結構可能是這樣:
[
{ id: 1, name: '咖啡', price: 50, quantity: 2 },
{ id: 2, name: '漢堡', price: 80, quantity: 1 },
{ id: 3, name: '薯條', price: 30, quantity: 3 }
]收銀員可能會頻繁改數量。比如客人說「咖啡改成 3 杯」,或「薯條不要了」。
先看不用 useCallback 的做法:每個購物車項目是一個 memo 化的組件(因為購物車項目很多,改一個不應該重新渲染其他的)
const CartItem = React.memo(function CartItem({ item, onUpdateQuantity, onDelete }) {
console.log(`CartItem ${item.id} 渲染`)
return (
<div className="cart-item">
<span>{item.name}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => onUpdateQuantity(item.id, +e.target.value)}
/>
<button onClick={() => onDelete(item.id)}>刪除</button>
<span>${item.price * item.quantity}</span>
</div>
)
})購物車容器:
'use client'
import { useState } from 'react'
export function CartContainer() {
const [cart, setCart] = useState([
{ id: 1, name: '咖啡', price: 50, quantity: 2 },
{ id: 2, name: '漢堡', price: 80, quantity: 1 },
{ id: 3, name: '薯條', price: 30, quantity: 3 }
])
const handleUpdateQuantity = (itemId, newQuantity) => {
setCart(cart.map(item =>
item.id === itemId ? { ...item, quantity: newQuantity } : item
))
}
const handleDelete = (itemId) => {
setCart(cart.filter(item => item.id !== itemId))
}
const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
return (
<div>
{cart.map(item => (
<CartItem
key={item.id}
item={item}
onUpdateQuantity={handleUpdateQuantity}
onDelete={handleDelete}
/>
))}
<div>總計:${total}</div>
</div>
)
}當收銀員改咖啡的數量,handleUpdateQuantity 函數重新建立,因為 onUpdateQuantity 是新函數,所有的 CartItem 都會重新渲染(即使 memo 化了)。結果就是在修改咖啡數量時,漢堡和薯條那行也重新渲染。在 POS 系統裡購物車可能有幾十行,改一個項目導致全部重新渲染會很卡。
我們可以用 useCallback 改善,把 handleUpdateQuantity 和 handleDelete 用 useCallback 記憶化:
const handleUpdateQuantity = useCallback((itemId, newQuantity) => {
setCart(prev => prev.map(item =>
item.id === itemId ? { ...item, quantity: newQuantity } : item
))
}, [])
const handleDelete = useCallback((itemId) => {
setCart(prev => prev.filter(item => item.id !== itemId))
}, [])注意用了函數式更新 prev =>,這樣 dependency 就是空陣列。handleUpdateQuantity 和 handleDelete 被記憶化,不會重新建立,CartItem 也就不會不必要地重新渲染。
現在改咖啡數量時,只有咖啡那行重新渲染,漢堡和薯條不動,對 POS 系統的效能會有差異。這就是 useCallback 真正的價值,購物車項目越多,效果越明顯。
實例 2:電商商城的商品列表過濾(useMemo)
電商平台的商品列表通常有 100 多個商品。用戶可以按價格、分類、評分過濾和排序。假設商品列表是這樣:
[
{ id: 1, name: '藍牙耳機', category: '3C', price: 199, rating: 4.5, stock: 50 },
{ id: 2, name: '手機殼', category: '3C', price: 29, rating: 4.0, stock: 200 },
]用戶在搜尋框輸入「藍牙」,或改變價格過濾(比如 50-200 元),列表需要立刻響應。
先看不用 useMemo 的做法:
'use client'
import { useState } from 'react'
export function ProductList() {
const [products] = useState()
const [priceFilter, setPriceFilter] = useState({ min: 0, max: 1000 })
const [sortBy, setSortBy] = useState('name')
const filtered = products
.filter(p => p.price >= priceFilter.min && p.price <= priceFilter.max)
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price
if (sortBy === 'rating') return b.rating - a.rating
return a.name.localeCompare(b.name)
})
return (
<div>
<div>
<input
type="range"
min="0"
max="1000"
value={priceFilter.max}
onChange={(e) => setPriceFilter({...priceFilter, max: +e.target.value})}
/>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="name">按名稱</option>
<option value="price">按價格</option>
<option value="rating">按評分</option>
</select>
</div>
{filtered.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}這樣子問題在哪?假設用戶在搜尋框搜索「藍牙」,這個搜索不在 ProductList 管理(可能在父組件),導致 ProductList 重新渲染。每次重新渲染,filter 和 sort 都要重新執行一遍 100 多個商品。
但搜索框內容根本不影響過濾邏輯,真正影響的只有 priceFilter 和 sortBy。如果搜索框導致的重新渲染無關,我們可以用 useMemo 快取 filtered 結果。用 useMemo 改善:
const filtered = useMemo(() => {
return products
.filter(p => p.price >= priceFilter.min && p.price <= priceFilter.max)
.sort((a, b) => {
if (sortBy === 'price') return a.price - b.price
if (sortBy === 'rating') return b.rating - a.rating
return a.name.localeCompare(b.name)
})
}, [products, priceFilter, sortBy])現在只有 products、priceFilter、sortBy 變化時,才重新計算過濾和排序。如果父組件因為搜索框變化重新渲染 ProductList,filtered 結果會被快取,不用重新計算。
實際效果是,用戶在搜索框打字時,商品列表不會卡頓。過濾和排序的計算只在實際需要時執行。
實例 3:POS 系統的結帳計算(useMemo)
在 POS 系統裡,結帳時要計算很多東西。購物車可能有幾十項,每項有不同的稅率(飲品 5%,食物 8%,酒 10%)。還要計算折扣、會員優惠等。
計算邏輯可能像這樣:
const calculateTotal = () => {
const taxByCategory = {
飲品: 0.05,
食物: 0.08,
酒: 0.10
}
let subtotal = 0
let taxTotal = 0
cart.forEach(item => {
const itemSubtotal = item.price * item.quantity
const tax = itemSubtotal * (taxByCategory[item.category] || 0)
subtotal += itemSubtotal
taxTotal += tax
})
let discount = 0
if (customer.isMember) {
discount = subtotal * 0.1
}
if (subtotal > 500) {
discount += 50
}
const total = subtotal + taxTotal - discount
return { subtotal, tax: taxTotal, discount, total }
}這個計算邏輯本身不複雜,但在 POS 系統裡會被調用很多地方:結帳按鈕、總計顯示、收款提示,都要用到這個計算結果。
每個地方都獨立調用 calculateTotal,會導致重複計算,如果用 useMemo:
const totals = useMemo(() => {
const taxByCategory = {
飲品: 0.05,
食物: 0.08,
酒: 0.10
}
let subtotal = 0
let taxTotal = 0
cart.forEach(item => {
const itemSubtotal = item.price * item.quantity
const tax = itemSubtotal * (taxByCategory[item.category] || 0)
subtotal += itemSubtotal
taxTotal += tax
})
let discount = 0
if (customer.isMember) {
discount = subtotal * 0.1
}
if (subtotal > 500) {
discount += 50
}
const total = subtotal + taxTotal - discount
return { subtotal, tax: taxTotal, discount, total }
}, [cart, customer.isMember, subtotal])現在購物車不變時,這個計算結果被快取,不管重新渲染多少次,totals 都是同一個物件引用。
實例 4:評論區(useCallback + useMemo 結合)
電商平台的商品詳情頁下面有「評論區」區塊,用戶可以上傳圖片、按讚、評論。評論區的列表有幾十張圖片,每張圖片有一個「讚」按鈕,用戶點讚時,要請求後端。
如果不用 useCallback,每張圖片的「讚」按鈕都會因為 onLike 函數變化而重新渲染。即使 memo 化了。用 useCallback 記憶化 onLike 函數:
const onLike = useCallback((imageId) => {
fetch(`/api/images/${imageId}/like`, { method: 'POST' })
.then(r => r.json())
.then(result => {
setImages(prev => prev.map(img =>
img.id === imageId ? { ...img, likes: result.likes } : img
))
})
}, [])這樣每張圖片的「讚」按鈕只在自己的資料變化時重新渲染,不會因為別的圖片被點讚而重新渲染。同時,如果列表有排序功能(按時間、按讚數),排序後的列表結果可以用 useMemo 快取:
const sortedImages = useMemo(() => {
const copy = [...images]
if (sortBy === 'likes') {
return copy.sort((a, b) => b.likes - a.likes)
}
if (sortBy === 'recent') {
return copy.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
}
return copy
}, [images, sortBy])結合起來,用戶點讚時,只有那張圖片的讚數更新,其他圖片不重新渲染。排序結果也被快取。
實例 5:什麼時候真的不用記憶 Hook
優些輸入框像是姓名會有 onChange 事件。不需要用到 useCallback:
const handleNameChange = (e) => {
setCustomer({ ...customer, name: e.target.value })
}
const handleAddressChange = (e) => {
setCustomer({ ...customer, address: e.target.value })
}
return (
<>
<input value={customer.name} onChange={handleNameChange} />
<input value={customer.address} onChange={handleAddressChange} />
</>
)輸入框本身很快。onChange 函數建立和執行的成本遠小於渲染速度。不需要 useCallback。如果加上 useCallback,反而增加複雜度:
const handleNameChange = useCallback((e) => {
setCustomer(prev => ({ ...prev, name: e.target.value }))
}, [])
const handleAddressChange = useCallback((e) => {
setCustomer(prev => ({ ...prev, address: e.target.value }))
}, [])代碼變複雜,沒有獲得任何效能提升。
小結
列表組件很適合用 memo 化的項目組件,搭配 useCallback 的事件處理器。這能避免改一項導致全部重新渲染。複雜的計算(排序、過濾、稅計算、優惠組合)可以用 useMemo 快取。簡單的 onChange 事件不用記憶化。輸入框、簡單計算都不需要。
下次聊聊 useRef 和 useTransition。useRef 不只是 DOM 操作,useTransition 對非同步邏輯很有幫助。