mCell
6 / 10

useCallback

从“缓存函数引用”到依赖数组、配合 memo/子组件、避免闭包陷阱与常见误区的系统讲解

先用一句话理解 useCallback

useCallback 用来在多次渲染之间缓存函数本身的引用:依赖不变就复用同一个函数引用,依赖变了才创建新函数。


为什么需要 useCallback

函数组件每次渲染都会重新执行,组件内声明的函数也会重新创建:

jsx
function Parent() { const handleClick = () => { ... }; return <Child onClick={handleClick} />; }

即使逻辑完全相同,handleClick 的引用每次都变,这会带来两个常见问题:

  • 子组件用了 React.memo 也挡不住重渲染(因为 props 引用变了)
  • useEffect 依赖里放了这个函数,导致 effect 频繁重跑

useCallback 的价值就是:让函数引用稳定


最基本用法

jsx
import { useCallback, useState } from 'react' function Counter() { const [n, setN] = useState(0) const add = useCallback(() => { setN((x) => x + 1) }, []) return <button onClick={add}>{n}</button> }

结构拆开看:

  • 第一个参数:你要缓存的函数
  • 第二个参数:依赖数组(deps)

依赖数组决定“何时换新函数”

  • 依赖不变:返回同一个函数引用
  • 依赖变化:创建并返回一个新函数(捕获新的闭包变量)

这点很重要:useCallback 不会让函数“更快”,它只是让“引用不变”。


useCallback 与 useMemo 的关系

记法:

  • useMemo 缓存“值”
  • useCallback 缓存“函数”

它们本质关系是:

jsx
useCallback(fn, deps) === useMemo(() => fn, deps)

所以你可以把 useCallback 当成语义更明确的“函数版 useMemo”。


典型场景一:配合 React.memo 减少子组件重渲染

子组件:

jsx
const Child = React.memo(function Child({ onAdd }) { console.log('Child render') return <button onClick={onAdd}>add</button> })

父组件如果这样写:

jsx
function Parent() { const [n, setN] = useState(0) const onAdd = () => setN((x) => x + 1) return ( <> <div>{n}</div> <Child onAdd={onAdd} /> </> ) }

Parent 每次渲染都会创建新 onAdd,导致 Child 也会渲染。

改成:

jsx
const onAdd = useCallback(() => setN((x) => x + 1), [])

只要依赖不变,Child 就能真正享受 memo 的收益。


典型场景二:避免 useEffect 因函数依赖而频繁重跑

jsx
function Demo({ query }) { const fetchData = useCallback(async () => { const res = await fetch(`/api?q=${query}`) return res.json() }, [query]) useEffect(() => { fetchData().then(console.log) }, [fetchData]) }

这里 fetchData 依赖 query,当 query 变时函数变,effect 也合理重跑。


最常见坑:依赖数组写错导致“闭包旧值”

看这个例子:

jsx
function Demo() { const [count, setCount] = useState(0) const add = useCallback(() => { setCount(count + 1) }, []) // ❌ }

因为 deps 是 []add 只创建一次,闭包里永远是初始 count,点击永远只能把 0 加到 1。

两种修法:

修法 A:把 count 放进依赖

jsx
const add = useCallback(() => { setCount(count + 1) }, [count])

修法 B:用函数式更新消除依赖(更常见)

jsx
const add = useCallback(() => { setCount((c) => c + 1) }, [])

经验:只要更新依赖旧 state,优先用函数式更新,这样 deps 更干净。


useCallback 不是默认必用

如果你不满足下面任意一个条件,通常不需要用:

  • 你把函数作为 props 传给 memo 化子组件,并且子组件重渲染确实是瓶颈
  • 你把函数放进某个 Hook 的依赖数组里(useEffect/useMemo 等),并且你需要控制重跑频率
  • 你需要函数引用稳定(例如订阅/取消订阅、第三方库要求 stable callback)

否则加 useCallback 可能只是增加代码复杂度。


什么时候用 useRef 而不是 useCallback

当你遇到“我想要一个永远稳定的回调引用,但里面要拿最新值”:

  • useCallback 要么依赖变→引用变
  • [] 稳定引用→闭包可能旧

这时常见组合是:useRef 存最新值 + 稳定函数读 ref。

jsx
function Demo({ onEvent }) { const onEventRef = useRef(onEvent) useEffect(() => { onEventRef.current = onEvent }, [onEvent]) const handler = useCallback((e) => { onEventRef.current(e) }, []) return <div onClick={handler}>...</div> }

常见误区清单

  • 以为 useCallback 能提升函数执行速度(它只稳定引用)
  • deps 写 [] 却在回调里读 state/props,导致闭包旧值
  • deps 写成不稳定对象(每次都新引用),导致 useCallback 失效
  • 为了“看起来专业”到处加 useCallback,导致代码更难维护
  • 忽略真正的优化手段:减少渲染、拆分组件、memo 合理使用、避免不必要 state

记忆模型

  • useCallback 缓存的是“函数引用”
  • 依赖不变 → 引用不变;依赖变 → 新函数
  • 想稳定且不读旧值:优先函数式更新;必要时 ref 存最新值

最小练习题

父组件有两个 state:counttext。要求:

  • text 改变时子组件不重渲染
  • 点击子组件按钮能正确让 count + 1

提示:ChildReact.memo,父组件用 useCallback

jsx
const Child = React.memo(function Child({ onAdd }) { console.log('Child render') return <button onClick={onAdd}>add</button> }) function Parent() { const [count, setCount] = useState(0) const [text, setText] = useState('') // TODO: 实现稳定的 onAdd,且能正确更新 count const onAdd = () => setCount(count + 1) return ( <> <div>count: {count}</div> <input value={text} onChange={(e) => setText(e.target.value)} /> <Child onAdd={onAdd} /> </> ) }

参考答案:

jsx
const onAdd = useCallback(() => { setCount((c) => c + 1) }, [])

留言讨论

Discussion

欢迎交流与反馈