無用的 useCallback
本文是 The Useless useCallback 的繁體中文翻譯,原文由 TkDodo 撰寫。
Photo by Nik Shuliahin
- #1: 記憶化的艱苦奮戰
- #2: 無用的 useCallback
我本來以為自己已經寫了很多關於記憶化的內容,但最近又一直看到一種模式,讓我覺得還是得再說一次。所以今天想聊聊 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 要不要重新執行。所以如果我們沒有小心處理依賴的記憶化,它可能每次渲染都會跑。
仔細想一下,其實這兩種情況本質上是一樣的:都是透過快取維持相同的引用,避免某件事發生。所以使用 useCallback 或 useMemo 的共同理由就是:
我需要引用穩定性。
我覺得人生也需要一點穩定,那到底哪些情況下追求穩定是白忙一場?
1. 沒記憶化,也沒有任何效能收益
回到上面的例子,只改一個小地方:
function Okay() {
const value = useMemo(() => ({ hello: 'world' }), [])
const onChange = useCallback((result) => console.log(result), [])
return <Component value={value} onChange={onChange} />
}
看出差異了嗎?沒錯,我們不再把 value 和 onChange 傳給記憶化元件,現在它只是一般的 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,而且依賴 screenshots 和 currentAttachmentIndex。那 screenshots 呢?
const screenshots = attachments.filter(({ name }) =>
name.includes('screenshot')
)
它只是一次沒有記憶化的 attachments.filter,每次都會建立新陣列,直接破壞所有下游的記憶化。結果 paginateItems、paginateHotkeys、onKeyDown 三個記憶化保證都在每次渲染時重算,就像它們根本不存在一樣。
我希望這個例子能說明為什麼我強烈反對濫用記憶化。依我的經驗,它太容易失效了,真的不值得,而且還讓我們必須閱讀的程式碼變得更複雜、更難懂。
這裡的修法不是把 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,例如在 PersistQueryClientProvider 或 useMutationState 裡,所以我認為這已經是被驗證過的模式。想像一下如果這個函式庫要求使用者手動記憶化 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 的最新值,而且在各次渲染之間引用穩定。這等於兩全其美,還不必寫一堆無用的 useCallback 或 useMemo。
今天就先到這裡。如果你有任何問題,歡迎在 bluesky 上找我,或在下方留言。