Skip to content

什么时候使用 useCallback 和 useMemo?如果滥用它们会带来什么后果?

深入理解 React Hooks:何时使用 useMemo 与 useCallback?

在 React 开发中,我们经常听到关于性能优化的建议,而 useMemouseCallback 正是其中最常被提及的两个 Hooks。它们的目标很明确:通过缓存(memoization)来避免不必要的计算和渲染

然而,“何时使用”以及“是否应该使用”却是一个值得深入探讨的话un。过早或不当的优化,不仅无法带来收益,反而会增加代码的复杂度和维护成本。

一切的起点:React 的重渲染机制

要理解这两个 Hooks 的价值,我们首先需要回顾 React 的一个核心行为:当一个组件的 state 或 props 发生变化时,它会触发自身以及其所有子组件的重渲染(re-render)

在重渲染过程中,组件函数内部的所有代码都会被重新执行。这意味着:

  1. 函数内部的变量(包括对象和函数)会被重新创建。

  2. 即使这些变量的内容“看起来”没变,但它们在内存中的引用地址却是全新的。

我们来看一个简单的例子:

javascript
function ParentComponent() {
  const [count, setCount] = useState(0);

  // 每次 ParentComponent 重渲染,这个对象都会被重新创建
  const config = { theme: 'dark' };
  
  // 这个函数也会被重新创建
  const handleClick = () => {
    console.log("Button clicked");
  };

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>
        Increment: {count}
      </button>
      <ChildComponent config={config} onClick={handleClick} />
    </div>
  );
}

// 使用 React.memo 包装,仅在 props 变化时才重渲染
const ChildComponent = React.memo(function Child({ config, onClick }) {
  console.log("ChildComponent is rendering...");
  return <button onClick={onClick}>Child Button</button>;
});

在这个例子中,即使我们用 React.memo 优化了 ChildComponent,但每次点击父组件的 Increment 按钮时,你仍然会在控制台看到 "ChildComponent is rendering..."。

原因在于,父组件每次重渲染,都会生成全新的 config 对象和 handleClick 函数。对于 ChildComponent 而言,它接收到的 props 引用地址每次都不同,React.memo 的浅比较判断它们“发生了变化”,从而导致了不必要的重渲染。

这就是 useMemouseCallback 需要解决的核心问题之一:保持引用的稳定性

useMemo:缓存计算结果

useMemo 用于缓存一个计算结果(值)。它会执行一个函数,并将其返回值缓存起来。只有当依赖项数组中的某个值发生变化时,它才会重新执行该函数。

语法: const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

什么时候应该使用 useMemo

1. 进行昂贵的计算

这是 useMemo 最直接的应用场景。如果你的组件在每次渲染时都需要执行一个复杂的、耗时的计算(例如,对一个巨大的数组进行过滤、排序或转换),useMemo 可以显著提升性能。

javascript
function TodoList({ todos, filter }) {
  // 这个计算可能非常耗时,特别是当 todos 数组很大时
  const visibleTodos = useMemo(() => {
    console.log("Filtering todos...");
    return todos.filter(todo => todo.text.includes(filter));
  }, [todos, filter]); // 仅在 todos 或 filter 变化时才重新计算

  return (
    <ul>
      {visibleTodos.map(todo => (
        <li key={todo.id}>{todo.text}</li>
      ))}
    </ul>
  );
}

在这个例子中,只要 todosfilter 不变,visibleTodos 就会直接从缓存中读取,避免了重复的 filter 操作。

2. 向子组件传递对象或数组,以保证引用稳定

回到我们最初的例子,useMemo 可以用来解决 config 对象被频繁创建的问题。

javascript
function ParentComponent() {
  const [count, setCount] = useState(0);

  // config 对象现在被缓存了,它的引用不会在重渲染时改变
  const config = useMemo(() => ({ theme: 'dark' }), []); // 空依赖数组,表示只在首次渲染时创建

  // ...
  return (
    <div>
      {/* ... */}
      <ChildComponent config={config} />
    </div>
  );
}

通过这种方式,ChildComponent 接收到的 config prop 将始终是同一个引用(除非 useMemo 的依赖项变化),React.memo 就能正确地跳过不必要的渲染。

useCallback:缓存函数本身

useCallbackuseMemo 非常相似,但它专门用于缓存一个函数本身

语法:

const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);

实际上,useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。它只是一个语法糖,让缓存函数的意图更明确。

什么时候应该使用 useCallback

1. 向子组件传递函数,以保证引用稳定

这是 useCallback 最常见的用途。同样,在我们的第一个例子中,我们可以用它来稳定 handleClick 函数的引用。

javascript
function ParentComponent() {
  const [count, setCount] = useState(0);

  // handleClick 函数被缓存,引用保持稳定
  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []); // 空依赖数组,函数永不重新创建

  // ...
  return (
    <div>
      {/* ... */}
      <ChildComponent onClick={handleClick} />
    </div>
  );
}

这样,被 React.memo 包装的 ChildComponent 在父组件重渲染时,就不会因为 onClick prop 的变化而重渲染了。

2. 作为其他 Hooks(如 useEffect)的依赖项

当你在 useEffect 的依赖数组中使用了一个函数时,如果这个函数没有被 useCallback 包装,它会在每次组件渲染时都变成一个新的函数,从而导致 useEffect 在每次渲染后都被触发。

javascript
function MyComponent({ userId }) {
  const [data, setData] = useState(null);

  // 如果 fetchData 没有被 useCallback 包装
  // 每次渲染它都是新函数,会导致 effect 无限循环
  const fetchData = useCallback(async () => {
    const response = await fetch(`/api/users/${userId}`);
    const result = await response.json();
    setData(result);
  }, [userId]); // 仅在 userId 变化时,才创建新的 fetchData 函数

  useEffect(() => {
    fetchData();
  }, [fetchData]); // 依赖于一个稳定的函数引用

  // ...
}

滥用的后果:为何不应“无脑”使用?

既然这两个 Hooks 如此有用,为什么我们不把所有函数和对象都用它们包起来呢?因为性能优化并非没有成本

  1. 增加了内存开销useMemouseCallback 本质上是在做缓存。缓存就需要占用内存来存储结果。如果对一些简单的、计算成本极低的值或函数进行缓存,占用的内存可能比节省的渲染成本还要高。

  2. 增加了初始渲染的计算量:在组件首次渲染时,useMemouseCallback 内部的逻辑本身也需要执行。此外,在每次重渲染时,React 还需要比较依赖项数组是否发生了变化。如果你的计算本身非常快,那么这些额外的比较和函数调用开销,可能会让你的组件变得更慢。

  3. 增加了代码复杂度和心智负担

    • 代码可读性降低:满屏的 useMemouseCallback 会让组件逻辑变得支离破碎。

    • 依赖项陷阱:忘记在依赖数组中包含所有相关的变量,是导致 bug 的常见原因(例如,函数中用到的 state 没有写进依赖,导致函数拿到的是陈旧的“闭包”值)。这要求开发者在编码时更加小心。

总结与实践原则

那么,我们应该如何做出决策?以下是一些可以遵循的指导原则:

  1. 不要过早优化:这是最重要的原则。首先让你的代码能够正常工作,然后通过性能分析工具(如 React DevTools Profiler)去发现真正的性能瓶颈。

  2. 定位问题再优化:只有当你通过分析工具,明确发现某个组件因为 props 引用变化而导致了不必要的、且代价高昂的重渲染时,才应该去考虑使用 useMemouseCallback

  3. 明确使用场景:

  • 使用 useMemo**
    • 当存在真正昂贵的计算时(如大数据量的数组操作)。
    • 当需要向一个被 React.memo 优化的子组件传递一个对象或数组时,为了保持引用稳定。
  • 使用 useCallback**
    • 当需要向一个被 React.memo 优化的子组件传递一个函数时。
    • 当一个函数需要被用作其他 Hooks(如 useEffect)的依赖项时。
  1. 对于简单场景,保持简单:如果一个组件的重渲染开销很小,或者它没有复杂的子组件树,那么因为重渲染而重新创建一些函数和对象,完全是可以接受的。在这种情况下,强行优化只会徒增烦恼。

useMemouseCallback 是 React 工具箱中强大的工具,但它们是“手术刀”,而非“万金油”。理解其背后的原理和成本,并在真正需要的地方审慎使用,才能让它们发挥出最大的价值。

不知道说啥了很无语了