Sail Web Dev Archive

Minimal translations and notes for focused web dev reading.

無用的 useCallback

2025/7/28 ReactJavaScriptuseCallbackPerformance
無用的 useCallback

本文是 The Useless useCallback 的繁體中文翻譯,原文由 TkDodo 撰寫。

Photo by Nik Shuliahin


我本來以為自己已經寫了很多關於記憶化的內容,但最近又一直看到一種模式,讓我覺得還是得再說一次。所以今天想聊聊 useCallback,以及略提 useMemo,闡述我認為它們在哪些情境下完全沒有意義。

為什麼要記憶化?

useCallback 建立記憶化的函式,或用 useMemo 建立記憶化的值,通常只有兩個理由:

效能優化

有些事情很慢,而慢通常不是好事。理想狀況是把它變快,但我們不一定辦得到。所以退一步,我們會試著讓那件慢事少做一點。

在 React 裡,很多時候慢的是子樹重新渲染,所以如果我們覺得這次渲染「沒有必要」,就會想辦法避開。

因此我們有時會用 React.memo 包元件。這是一場艱苦的戰鬥,多半不值得打,但它確實存在。

如果我們把函式或非原始值 (譯注:例如物件或陣列) 傳給記憶化元件,就得確保這些引用是穩定的。原因是 React 會用 Object.is 比較記憶化元件的 props,判斷是否能跳過渲染子樹。若引用不穩定,例如每次渲染都重新建立,記憶化就會「失效」:

function Meh() {
  return (
    <MemoizedComponent
      value={{ hello: 'world' }}
      onChange={(result) => console.log('result')}
    />
  )
}

function Okay() {
  const value = useMemo(() => ({ hello: 'world' }), [])
  const onChange = useCallback((result) => console.log(result), [])

  return <MemoizedComponent value={value} onChange={onChange} />
}

沒錯,有時候 useMemo 內部計算本身就很慢,記憶化是為了避免重算。這些 useMemo 完全沒問題,但我不覺得它們是最常見的使用場景。

避免 effect 太頻繁觸發

如果不是作為 prop 傳給記憶化元件,記憶化的值很可能最終會成為 effect 的依賴(有時還會經過好幾層自訂 hook)。

effect 的依賴規則和 React.memo 一樣:用 Object.is 逐一比較,決定 effect 要不要重新執行。所以如果我們沒有小心處理依賴的記憶化,它可能每次渲染都會跑。


仔細想一下,其實這兩種情況本質上是一樣的:都是透過快取維持相同的引用,避免某件事發生。所以使用 useCallbackuseMemo 的共同理由就是:

我需要引用穩定性。

我覺得人生也需要一點穩定,那到底哪些情況下追求穩定是白忙一場?

1. 沒記憶化,也沒有任何效能收益

回到上面的例子,只改一個小地方:

function Okay() {
  const value = useMemo(() => ({ hello: 'world' }), [])
  const onChange = useCallback((result) => console.log(result), [])

  return <Component value={value} onChange={onChange} />
}

看出差異了嗎?沒錯,我們不再把 valueonChange 傳給記憶化元件,現在它只是一般的 React 函式元件。我常看到這種情況,尤其是值最後被傳給 React 內建元件時:

function MyButton() {
  const onClick = useCallback(
    (event) => console.log(event.currentTarget.value),
    []
  )

  return <button onClick={onClick} />
}

在這裡,記憶化 onClick 什麼都沒達成,因為 button 根本不在乎 onClick 是否引用穩定。

什麼都沒達成?

「什麼都沒達成」這個說法稍微不精準,因為底層確實會做一些事。React 會建立快取保存 onClick 函式,追蹤依賴並在每次渲染時比較它們。傳給 useCallback 的內聯函式也會在每次渲染時重新建立,只是如果回傳快取版本,它會立刻被丟棄。

所以技術上確實增加了一點內部開銷。但我不想糾結這個「開銷」,因為它不是問題的核心。

因此,如果你的自訂元件沒有被記憶化,它大概也不會在乎引用穩定性。

等等,如果那個 Component 內部把這些 props 拿來做 useEffect,或建立其他記憶化值,再傳給子元件的記憶化元件呢?如果我現在移除這些記憶化,會不會把事情弄壞?

這就帶到第二點:

2. 把 props 當作依賴

把非原始 props 放進元件內部的依賴陣列,多半是不對的,因為這個元件無法掌控那些 props 的引用穩定性。常見例子:

function OhNo({ onChange }) {
  const handleChange = useCallback(
    (e: React.ChangeEvent) => {
      trackAnalytics('changeEvent', e)
      onChange?.(e)
    },
    [onChange]
  )

  return <SomeMemoizedComponent onChange={handleChange} />
}

這個 useCallback 很可能是無用的,最多只能說取決於使用者怎麼用這個元件。現實中很常見的呼叫方式是直接寫內聯函式:

<OhNo onChange={() => props.doSomething()} />

這種用法非常合理,也沒什麼問題。它讓行為跟事件處理器靠在一起,也避免把一堆東西拉到檔案最上面,用一個難看的 handleChange 命名。

寫這段程式碼的人唯一能知道它會破壞某些記憶化的方法,就是深入元件內部去看 props 的使用方式。這實在太糟了。

其他補救方式包含「所有東西都記憶化」的政策,或強制命名規範,例如要求需要引用穩定性的 props 加上 mustBeMemoized 前綴。這些都不是好方法。

一個真實案例

我現在在 Sentry 工作,它是開源的,所以我有很多真實案例可以連結。其中一個是 useHotkeys 自訂 hook。重要片段大概長這樣:

export function useHotkeys(hotkeys: Hotkey[]): {
  const onKeyDown = useCallback(() => ..., [hotkeys])

  useEffect(() => {
    document.addEventListener('keydown', onKeyDown)

    return () => {
      document.removeEventListener('keydown', onKeyDown)
    }
  }, [onKeyDown])
}

這個 hook 接收 hotkeys 陣列作為輸入,接著建立一個記憶化的 onKeyDown,再把它傳給 effect。顯然這是為了避免 effect 太常觸發,但因為 hotkeys 是陣列,使用者就必須自己手動記憶化它。

我去找所有 useHotkeys 的使用處,驚喜地發現除了其中一個以外,其他都記憶化了輸入。但故事還沒完,如果再往下看,事情還是會崩壞。比如看這個用法

const paginateHotkeys = useMemo(() => {
  return [
    { match: 'right', callback: () => paginateItems(1) },
    { match: 'left', callback: () => paginateItems(-1) },
  ]
}, [paginateItems])

useHotkeys(paginateHotkeys)

useHotkeys 傳進的是 paginateHotkeys,它確實被記憶化了,但它依賴 paginateItems。那 paginateItems 從哪來?它是另一個 useCallback,而且依賴 screenshotscurrentAttachmentIndex。那 screenshots 呢?

const screenshots = attachments.filter(({ name }) =>
  name.includes('screenshot')
)

它只是一次沒有記憶化的 attachments.filter,每次都會建立新陣列,直接破壞所有下游的記憶化。結果 paginateItemspaginateHotkeysonKeyDown 三個記憶化保證都在每次渲染時重算,就像它們根本不存在一樣。


我希望這個例子能說明為什麼我強烈反對濫用記憶化。依我的經驗,它太容易失效了,真的不值得,而且還讓我們必須閱讀的程式碼變得更複雜、更難懂。

這裡的修法不是把 screenshots 也記憶化。那只會把責任轉嫁給 attachments,而它是元件的 prop。在這三個呼叫點上,我們距離真正需要記憶化的地方(useHotkeys)至少還隔了兩層。最後會變成導航地獄,沒人敢移除任何記憶化,因為根本搞不清楚它到底在做什麼。

如果可以的話,我們最好把這些事交給編譯器(React Compiler)。一旦它能在所有地方運作,那會非常棒。但在那之前,我們得先找到能繞過「需要引用穩定性」這個限制的模式:

Latest Ref 模式

我之前寫過這個模式;做法基本上是把我們想在 effect 裡命令式存取的值放進 ref,然後用另一個刻意在每次渲染都會跑的 effect 來更新它:

export function useHotkeys(hotkeys: Hotkey[]): {
  const hotkeysRef = useRef(hotkeys)

  useEffect(() => {
    hotkeysRef.current = hotkeys
  })

  const onKeyDown = useCallback(() => ..., [])

  useEffect(() => {
    document.addEventListener('keydown', onKeyDown)

    return () => {
      document.removeEventListener('keydown', onKeyDown)
    }
  }, [])
}

接著我們就能在 effect 裡使用 hotkeysRef,不必把它放進依賴陣列,也不用擔心如果忽略 linter 會碰到過時閉包問題。

React Query 也用這個模式來追蹤最新的 options,例如在 PersistQueryClientProvideruseMutationState 裡,所以我認為這已經是被驗證過的模式。想像一下如果這個函式庫要求使用者手動記憶化 options

UseEffectEvent

還有好消息:React 已經意識到,我們常需要在響應式 effect 中命令式存取某些最新值,但又不想明確地重新觸發它,所以他們打算把這個模式做成一等原語(first-class primitive):useEffectEvent

等它發布後,我們就能把程式碼重構成:

export function useHotkeys(hotkeys: Hotkey[]): {
  const onKeyDown = useEffectEvent(() => ...)

  useEffect(() => {
    document.addEventListener('keydown', onKeyDown)

    return () => {
      document.removeEventListener('keydown', onKeyDown)
    }
  }, [])
}

這會讓 onKeyDown 不是 響應式的,它能一直「看到」hotkeys 的最新值,而且在各次渲染之間引用穩定。這等於兩全其美,還不必寫一堆無用的 useCallbackuseMemo


今天就先到這裡。如果你有任何問題,歡迎在 bluesky 上找我,或在下方留言。