React 性能优化误区:结合实战代码,彻底搞懂 useCallback 的真正用途

18 阅读4分钟

在 React 开发中,useCallback 是最容易被误用(Overused)的 Hook 之一。很多开发者看到组件重渲染(Re-render),下意识地就想把所有函数都包上一层 useCallback,认为这样能提升性能。

但事实往往相反:在错误的地方使用 useCallback,不仅不能优化性能,反而会增加内存开销和代码复杂度。

今天我们结合 Hacker News 搜索代码,来拆解 useCallback 到底解决了什么问题,以及什么时候才应该用它。


1. 案发现场:代码真的需要优化吗?

让我们先看代码中的这一部分:

// App.js (原始代码)
export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");

  // ❌ 疑问:这里是否需要 useCallback?
  const handleChange = (e) => {
    setSearchTerm(e.target.value);
  };

  return (
    <form>
      {/* 这里的 input 是原生 DOM 标签 */}
      <input onChange={handleChange} ... />
    </form>
  );
}

现状分析:

  1. 当用户输入字符,handleChange 执行 -> setSearchTerm 更新状态。
  2. App 组件触发重渲染(Re-render)。
  3. 在这次新的渲染中,handleChange 函数被重新创建(在内存中生成了一个全新的函数引用)。
  4. 这个新函数被传递给 <input> 标签。

结论:

在你的当前代码中,完全不需要 useCallback。

原因:

接收 handleChange 的是 <input>,这是一个原生 DOM 元素。原生元素不具备“通过对比 Props 来决定是否更新”的能力。无论你传给它的是旧函数还是新函数,只要父组件渲染,React 都会重新把事件绑定更新一遍。

在这里加 useCallback,就像是给一次性纸杯买保险——成本(缓存机制、依赖对比的计算量)支出了,但没有任何收益。


2. 核心概念:引用相等性 (Referential Equality)

要理解 useCallback,必须理解 JavaScript 中的一个基础概念:

const functionA = () => { console.log('hi'); };
const functionB = () => { console.log('hi'); };

console.log(functionA === functionB); // false ❌
console.log(functionA === functionA); // true ✅

在 React 函数组件中,每次渲染,组件内部定义的函数都会被重新创建。虽然代码逻辑没变,但在计算机内存里,它已经是一个全新的对象了。

useCallback 的唯一作用就是:在多次渲染之间,强行保留同一个函数引用,只要依赖项不变,它返回的永远是内存里的同一个地址。


3. 什么时候才需要它?(引入 React.memo)

只有当这个函数被传递给经过优化的子组件时,useCallback 才是必须的。

假设随着项目变大,你把 <input> 封装成了一个独立的、功能复杂的组件 FancyInput,并且为了性能,你使用了 React.memo

场景 A:有 memo,但没用 useCallback (无效优化)

// 这是一个被 memo 保护的组件
// 它的原则是:只有 props 变了,我才重新渲染
const FancyInput = React.memo(function FancyInput({ onChange, value }) {
  console.log("FancyInput 渲染了!"); 
  return <input className="fancy" onChange={onChange} value={value} />;
});

export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");
  
  // 每次 App 渲染,这里都会生成一个新的函数地址
  const handleChange = (e) => setSearchTerm(e.target.value); 

  return (
    <>
       {/* 悲剧发生在这里:
         尽管 searchTerm 没变 (假设是其他 state 触发了 App 更新),
         但因为 handleChange 的内存地址变了,
         React.memo 认为 props.onChange 变了。
         结果:FancyInput 依然会强制重渲染!
       */}
      <FancyInput onChange={handleChange} value={searchTerm} />
    </>
  );
}

场景 B:memo + useCallback (黄金搭档)

这时候,useCallback 就要登场了。它是为了配合 React.memo 工作的。

export default function App() {
  const [searchTerm, setSearchTerm] = React.useState("js");

  // ✅ 正确使用:缓存函数引用
  const handleChange = React.useCallback((e) => {
    setSearchTerm(e.target.value);
  }, []); // 依赖项为空,永远不重建

  return (
    {/* 现在,当 App 因为其他原因重渲染时,
      handleChange 还是原来的内存地址。
      React.memo 发现 props 没变,于是跳过 FancyInput 的渲染。
      性能提升达成!
    */}
    <FancyInput onChange={handleChange} value={searchTerm} />
  );
}

4. 另一个场景:作为 useEffect 的依赖

代码中其实有一个潜在的地方可能需要 useCallback,那就是当函数本身被放在 useEffect 的依赖数组里时。

// 假设这是定义在组件内的函数
const fetchNews = async (query) => {
  const data = await searchHackerNews(query);
  setResults(data.hits);
};

useEffect(() => {
  fetchNews(debouncedSearchTerm);
}, [debouncedSearchTerm, fetchNews]); // ⚠️ fetchNews 是依赖项

如果fetchNews 不包裹 useCallback,每次渲染 fetchNews 都会变成新函数,导致 useEffect 认为依赖变了,从而无限循环或者不必要的频繁执行

在这种情况下,必须使用 useCallback 锁住 fetchNews


总结:决策清单

回到代码,请按照这个清单来决定是否使用 useCallback

  1. 这个函数是传给原生 DOM (div, button, input) 的吗?

    • 是 -> 不用 (用了也没用)。
    • 否 -> 看下一条。
  2. 这个函数是传给子组件的,且子组件用了 React.memo 吗?

    • 是 -> (为了让 memo 生效)。
    • 否 -> 不用 (大部分子组件都很轻量,不需要 memo)。
  3. 这个函数会被作为 useEffect 或其他 Hook 的依赖项吗?

    • 是 -> (防止死循环或频繁触发 Effect)。

最终建议:

在App 组件当前的状态下,保持原样是最好的选择。代码清晰、逻辑简单,没有任何不必要的性能开销。