Blog

/

Frontend

Next.js - useCallback & useMemo

useCallbackmemo useMemo

Posted at

2024年10月27日

Posted on

Frontend

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 },
  // ... 100 多個商品
]

用戶在搜尋框輸入「藍牙」,或改變價格過濾(比如 50-200 元),列表需要立刻響應。

先看不用 useMemo 的做法:

'use client'

import { useState } from 'react'

export function ProductList() {
  const [products] = useState(/* 100+ 商品 */)
  const [priceFilter, setPriceFilter] = useState({ min: 0, max: 1000 })
  const [sortBy, setSortBy] = useState('name') // 'name' 或 'price' 或 'rating'

  // 每次組件重新渲染都會執行這個計算
  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 對非同步邏輯很有幫助。