第 8 章

useMemo 与 useCallback:何时优化,何时过度

第8章:useMemo 与 useCallback:何时优化,何时过度

useMemo 和 useCallback 是精准的性能工具,不是默认的编程习惯。过度使用比不用更有害。

本章核心问题:记忆化的真正价值在哪里?什么时候使用反而有害? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

记忆化的本质:用内存换 CPU

useMemouseCallback 都是**记忆化(memoization)**技术:保存上一次计算的结果,下次调用时如果输入没变就直接返回缓存值,跳过重新计算。

// 没有 useMemo:每次渲染都重新过滤
const filteredList = items.filter(item => item.active);

// 有 useMemo:只有 items 改变时才重新过滤
const filteredList = useMemo(
  () => items.filter(item => item.active),
  [items]
);
// 没有 useCallback:每次渲染都创建新函数
const handleClick = () => doSomething(id);

// 有 useCallback:只有 id 改变时才创建新函数
const handleClick = useCallback(() => doSomething(id), [id]);

关键问题:记忆化不是免费的。它消耗内存来缓存结果,消耗 CPU 来比较依赖。如果缓存命中率低(依赖频繁变化),记忆化不仅没有收益,还增加了开销。

JavaScript 的引用相等性

要理解为什么 useMemouseCallback 有时必要,必须先理解 JavaScript 的引用相等性

// 原始值:值相等即相等
5 === 5          // true
'hello' === 'hello' // true

// 对象:引用相等才相等
{} === {}        // false(两个不同的对象)
[] === []        // false(两个不同的数组)
(() => {}) === (() => {}) // false(两个不同的函数)

每次 React 渲染组件,函数体内的对象字面量、数组字面量和函数表达式都会创建全新的引用。即使内容完全相同,上次和这次创建的对象也不相等。

这在两个场景下造成问题:

场景一:React.memo 的子组件React.memo 通过浅比较 props 来决定是否跳过渲染。如果一个 prop 是每次渲染都新建的函数或对象,浅比较永远不相等,React.memo 失效。

场景二:useEffect 的依赖数组。如果依赖是每次渲染都新建的对象,Effect 每次渲染都会重新运行,即使数据内容没变。

useMemo 真正有效的场景

场景一:稳定引用供 React.memo 子组件使用

function Parent({ userId }: { userId: string }) {
  // 没有 useMemo:每次渲染都创建新对象,Child 的 memo 失效
  const config = { theme: 'dark', locale: 'zh-CN' };

  // 有 useMemo:config 引用稳定,Child 可以跳过渲染
  const config = useMemo(() => ({ theme: 'dark', locale: 'zh-CN' }), []);

  return <ExpensiveChild config={config} />;
}

const ExpensiveChild = React.memo(function ExpensiveChild({ config }) {
  // 只有 config 引用改变时才重渲染
  return <div>{/* 昂贵的渲染逻辑 */}</div>;
});

关键useMemo 只有在和 React.memo(或 useEffect 依赖)配合时才有意义。单独使用 useMemo 来"防止重渲染"是无效的,因为父组件重渲染时子组件仍然会重渲染,不管 prop 是否变化(除非子组件被 React.memo 包裹)。

场景二:真正昂贵的纯计算

function DataAnalytics({ dataset, filters }) {
  // 如果 dataset 有数万条记录,这个计算可能需要几百毫秒
  const aggregated = useMemo(() => {
    return dataset
      .filter(row => matchesFilters(row, filters))
      .reduce((acc, row) => aggregateRow(acc, row), createEmptyAggregation());
  }, [dataset, filters]);

  return <Chart data={aggregated} />;
}

如何判断计算是否"足够昂贵"?console.time 测量:

console.time('aggregation');
const result = expensiveCompute(data);
console.timeEnd('aggregation');
// 如果超过 1ms,值得考虑 useMemo
// 如果超过 10ms,几乎肯定应该 useMemo

Level 2 · 它是怎么运行的(3-5年经验)

useCallback 真正有效的场景

useCallback(fn, deps) 等价于 useMemo(() => fn, deps)——它只是记忆化函数的语法糖。

场景一:传给 React.memo 子组件的回调

function TodoList({ todos, onToggle }) {
  return todos.map(todo => (
    <TodoItem
      key={todo.id}
      todo={todo}
      onToggle={onToggle} // 如果 onToggle 每次都是新函数,TodoItem 的 memo 失效
    />
  ));
}

function Parent() {
  const [todos, setTodos] = useState(initialTodos);

  // 没有 useCallback:每次 Parent 重渲染,onToggle 都是新函数
  const handleToggle = (id) => {
    setTodos(prev => prev.map(t => t.id === id ? {...t, done: !t.done} : t));
  };

  // 有 useCallback:onToggle 引用稳定,TodoItem 能正确 memo
  const handleToggle = useCallback((id) => {
    setTodos(prev => prev.map(t => t.id === id ? {...t, done: !t.done} : t));
  }, []); // 使用函数式更新,不依赖 todos

  return <TodoList todos={todos} onToggle={handleToggle} />;
}

场景二:作为其他 Hook 的依赖

function useSearch(query: string) {
  const [results, setResults] = useState([]);

  // 如果 fetchResults 在每次渲染时都是新函数,Effect 每次都会重新运行
  const fetchResults = useCallback(async (q: string) => {
    const data = await api.search(q);
    setResults(data);
  }, []); // api 是稳定的外部引用

  useEffect(() => {
    fetchResults(query);
  }, [fetchResults, query]);

  return results;
}

React Compiler:自动记忆化时代来临

React 19 附带了 React Compiler(前称 React Forget),这是一个在编译期自动为 React 代码添加记忆化的 Babel/SWC 插件。

// 你写的代码
function TodoList({ todos, filter }) {
  const filtered = todos.filter(t => t.category === filter);
  const handleToggle = (id) => markDone(id);

  return filtered.map(t => (
    <TodoItem key={t.id} todo={t} onToggle={handleToggle} />
  ));
}

// React Compiler 自动转换后(概念示意,非实际输出)
function TodoList({ todos, filter }) {
  const filtered = useMemo(() => todos.filter(t => t.category === filter), [todos, filter]);
  const handleToggle = useCallback((id) => markDone(id), []);

  return filtered.map(t => (
    <TodoItem key={t.id} todo={t} onToggle={handleToggle} />
  ));
}

React Compiler 是否让手动记忆化过时?

理论上是的,实践中还不是。

React Compiler 正确工作的前提是代码遵循 React 的规则(Hooks 规则、纯渲染、不可变 state)。如果你的代码库有以下情况,Compiler 会保守地跳过优化:

截至 2024 年底,React Compiler 处于 Release Candidate 阶段,Meta 在生产环境使用,但仍有不少边缘情况。建议:

性能优化的正确方法论

先测量,再优化。

// 1. 用 React DevTools Profiler 找出真正慢的组件
// 2. 用 why-did-you-render 找出不必要的重渲染
// 3. 确认重渲染确实影响用户体验(而不只是控制台日志)
// 4. 才考虑记忆化

决策框架

是否有性能问题?
├── 否 → 不要记忆化
└── 是 → 找出慢在哪里(Profiler)
    ├── 组件渲染太频繁 → 考虑 React.memo + useCallback
    ├── 单次渲染太慢 → 考虑 useMemo(确认计算确实昂贵)
    ├── 两者都有 → 先解决渲染频率,再优化单次渲染
    └── 都没有 → 可能是网络、图片、字体等其他问题

真正有效的性能优化优先级

  1. 减少 state 提升:state 越高,重渲染范围越大
  2. 合理拆分组件:把变化的部分和稳定的部分分离
  3. 使用 key 优化列表渲染
  4. 代码分割和懒加载React.lazy、动态 import)
  5. 虚拟列表(对长列表用 react-virtualreact-window
  6. 才是 useMemo/useCallback(通常影响有限)

Level 3 · 规范怎么定义的(资深)

一个完整的正确示例

// 这是一个合理使用记忆化的组件
function ProductCatalog({ products, categoryFilter, onAddToCart }) {
  // ✓ 值得 memo:数据处理可能涉及大量数据
  const filteredAndSorted = useMemo(() => {
    return products
      .filter(p => p.category === categoryFilter)
      .sort((a, b) => b.rating - a.rating);
  }, [products, categoryFilter]);

  // ✓ 值得 callback:传给被 React.memo 包裹的 ProductCard
  const handleAddToCart = useCallback((productId) => {
    onAddToCart(productId);
  }, [onAddToCart]);

  // ✗ 不值得 memo:简单计算
  const totalCount = filteredAndSorted.length; // 不需要 useMemo

  return (
    <div>
      <p>共 {totalCount} 件商品</p>
      {filteredAndSorted.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
}

// 被 React.memo 包裹,handleAddToCart 的稳定性才有意义
const ProductCard = React.memo(function ProductCard({ product, onAddToCart }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product.id)}>加入购物车</button>
    </div>
  );
});

Level 4 · 边界与陷阱(所有人)

常见的过度优化

过度优化一:给没有 memo 的子组件用 useCallback

function Parent() {
  // 完全没有意义:Child 没有被 React.memo 包裹,
  // 不管 handleClick 是不是稳定的,Parent 重渲染时 Child 都会重渲染
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />;
}

function Child({ onClick }) {
  return <button onClick={onClick}>Click</button>;
  // 没有 React.memo 包裹,每次 Parent 渲染 Child 都渲染
}

过度优化二:记忆化简单计算

// 完全没必要:数组长度是 O(1) 操作,比依赖比较本身还快
const count = useMemo(() => items.length, [items]);

// 同样没必要:简单的字符串拼接
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

// 直接在渲染期间计算就好
const count = items.length;
const fullName = `${firstName} ${lastName}`;

记忆化有开销——它需要存储上一次的结果和依赖,并在每次渲染时比较依赖。如果被记忆的计算比依赖比较更便宜,useMemo 反而拖慢了性能。

过度优化三:到处用 memo 和 callback

"既然可能有用,不如都加上" 是一种危险的心态。过度的记忆化:

  1. 增加代码噪音,降低可读性
  2. 增加维护成本(依赖数组需要随逻辑变化而更新)
  3. 在小组件上可能实际上更慢(记忆化开销 > 重渲染开销)
  4. 掩盖了真正的性能问题(应该用 profiler 找根因)
本章评分
4.8  / 5  (44 评分)

💬 留言讨论