Menu

React useCallback 教程:什么时候才真正有用

useCallback 在多次渲染之间返回同一个函数,直到它的依赖发生变化。只有当这个函数被传给 memo 子组件或用作 Effect 的依赖时,它才有帮助。了解它的语法、与 useMemo 的区别,以及要避免的错误。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

useCallback 在多次渲染之间缓存一个函数:在它的某个依赖发生变化之前,它都返回同一个函数对象。只有当有东西按引用比较这个函数时,这一点才有意义,实际中指的是用 memo 包裹的子组件,或者把这个函数列为依赖的 Effect。

两个按钮都经过了 memo 化,做的事情也完全相同。切换主题并观察 Console:只出现了 render Inline function,因为 saveInline 每次渲染都是一个新函数,所以 memo 看到的是一个变化了的 prop。saveCached 每次都是同一个函数,所以那个按钮被跳过了。

它缓存了什么

const cachedFn = useCallback(fn, dependencies);
  • fn 是你想保留的函数。React 不会调用它,而是把它返回给你。
  • dependencies 列出 fn 读取的组件中的每一个值。每次渲染时,React 都会用 Object.is 把它们和上一次的值进行比较。
  • 如果都没变,你拿到的是上一次渲染中的函数。如果有变化,你拿到的是这一次传入的函数,React 会把它保留到下一次。

注意这意味着什么:useCallback 里的箭头函数在每次渲染时仍然会被创建,因为它只是一个参数。useCallback 并没有省下这部分工作。它唯一的作用,是交还一个更早的、完全相同的函数,让引用保持不变。

useCallback 与 useMemo

它们是同一种机制。useMemo 缓存调用函数得到的结果,useCallback 缓存函数本身:

// These two lines are equivalent
const handleSave = useCallback(() => save(draft), [draft]);
const handleSave = useMemo(() => () => save(draft), [draft]);

函数用 useCallback,因为读起来更清楚;计算得到的值,比如过滤后的列表或一个选项对象,用 useMemo。

用更新函数去掉依赖

被缓存的函数必须列出它读取的每一个 state 值,每当这个 state 变化,函数也会随之变化。对于一个由 memo 化的行组成的列表,这就成了问题:一个读取 todos 的 remove 函数会在 todos 每次变化时都变化,所以每次删除后,剩下的每一行都会重新渲染。改用 setter 的更新函数写法,这个函数就完全不再读取 todos 了。

删除一行:Console 保持安静,因为其他行拿到的 todo 对象和 remove 函数都和之前一样。把函数改成 useCallback((id) => setTodos(todos.filter((t) => t.id !== id)), [todos]) 再试一次:现在每次删除后,剩下的每一行都会打印一次渲染。

函数作为 Effect 的依赖

如果 Effect 调用了组件中声明的函数,就必须把这个函数列为依赖。如果函数每次渲染都是新的,Effect 就会在每次渲染后运行。useCallback 能让它保持稳定,直到它自己的输入发生变化:

输入消息不会导致重新连接。切换房间时,会打印一条与旧房间断开的日志和一条连接新房间的日志。删掉 useCallback 包裹(保留箭头函数),每次按键都会断开再重连。

不过这里有个更简单的修复方法:把函数移到 Effect 内部。这样 Effect 直接依赖 roomId,也就不需要任何函数保持稳定了。

useEffect(() => {
    function createOptions() {
        return { serverUrl: 'wss://chat.example', roomId };
    }
    const options = createOptions();
    // ...
}, [roomId]);

当函数只被 Effect 使用时,优先采用这种写法。useCallback 留给那些你还要往下传、或从自定义 Hook 返回的函数,因为调用方可能会把它们放进自己的依赖数组。

检查函数是否发生了变化

当某个 memo 子组件一直在渲染而你不确定原因时,检查一下作为 prop 的函数在两次渲染之间是不是真的同一个对象。把上一次的函数存在 ref 里,然后比较:

点击 Count:Console 显示 increment changed: false,因为 step 没有变化。点击 Step,它就会显示 true:step 是依赖,所以 React 交还了新的函数。如果一个函数每次渲染都在变化,通常意味着它的某个依赖本身每次渲染都是新的,比如组件中声明的某个对象或另一个函数。

在自定义 Hook 中使用 useCallback

返回函数的自定义 Hook 很适合使用 useCallback,即使你不知道谁会调用它。使用这个 Hook 的组件可能会把这些函数传给 memo 子组件,或把它们列在 Effect 里,而它无法从外部让这些函数保持稳定。在 Hook 内部包裹它们,就能默认为每个调用方提供稳定的引用:

function useCounter(initial = 0) {
    const [count, setCount] = useState(initial);
    const increment = useCallback(() => setCount((c) => c + 1), []);
    const reset = useCallback(() => setCount(initial), [initial]);
    return { count, increment, reset };
}

常见错误:什么都包一层

给一个直接传给 <button> 或 <input> 的处理函数套上 useCallback,没有任何用处。React 不会因为 DOM 元素的处理函数没变就跳过任何工作,而没有用 memo 包裹的组件,在父组件渲染时无论如何都会渲染。

// Pointless: <button> does not care that the function is the same
const handleClick = useCallback(() => setOpen(true), []);
return <button onClick={handleClick}>Open</button>;

每多一个 useCallback,就多一个可能被写错的依赖数组,而过期的依赖是实实在在的 bug(函数会一直读取旧的 state)。只有当接收方是 memo 子组件或依赖数组时才加它,而不是默认就加。

如果你的构建用到了 React Compiler 1.0,它会自动对函数和值做 memo 化,大多数手写的 useCallback 调用也就不再需要了。没有这个编译器时,上面的规则依然适用,而在现有代码中,手动的 useCallback 仍然很常见。

常见问题

useCallback 有什么作用?

它在每次渲染时都返回同一个函数对象,直到它的某个依赖发生变化。它并不能阻止函数被创建;它只是交还之前某次渲染中的函数,而不是新创建的那个。

什么时候应该用 useCallback?

当你把一个函数传给用 memo 包裹的子组件时,或者当这个函数是某个 Effect 或其他 Hook 的依赖时。这两种情况下,每次渲染都生成新函数会让优化失效,或者让 Effect 重新运行。

useCallback 和 useMemo 有什么区别?

useCallback(fn, deps) 缓存的是 fn 本身。useMemo(() => value, deps) 缓存的是函数返回的值。useCallback(fn, deps) 和 useMemo(() => fn, deps) 完全等价。

应该把每个函数都包在 useCallback 里吗?

不应该。如果函数传给的是普通 DOM 元素,或者是没有 memo 化的组件,稳定的引用不会带来任何变化,多出来的依赖数组反而让代码更难读,也更容易写错。

useCallback 会让组件变快吗?

单靠它不会。只有当下游有东西按引用比较这个函数时,它才有帮助,比如 memo 或 Effect 的依赖数组。

Coddy 编程语言插图

和 Coddy 一起学编程

开始