React中的useMemo和useCallback:何时真正需要

1. 概述

useMemo 和 useCallback 经常被感性地使用:要么把所有东西都包装“以防万一”,要么永远不用。两种方法都错了。本文解释这两个 hook 背后的机制,指出它们真正有用的三种情况,以及哪些时候应该跳过。你还将编写一个测试来证明 memoization 真正有效。

2. 根本问题:每次渲染都会重新创建

每当组件重新渲染时,整个函数体都会从头开始执行。在其中声明的所有对象、数组和函数都会被重新创建。

function Checkout({ customer }) {
  const shipping = { city: customer.city, express: true };
  const handlePay = () => console.log('为', customer.name, '进行支付');

  return <PaymentForm shipping={shipping} onPay={handlePay} />;
}

每次 Checkout 重新渲染后,shipping 和 handlePay 都是新的引用,尽管内容没有变化。JavaScript 按引用比较对象和函数:

{ city: '顺化' } === { city: '顺化' }   // false
(() => 1) === (() => 1)               // false

通常这是无害的,因为创建对象很便宜。只有当某些东西比较引用时才会成为问题。那可能是 React.memo、useEffect 的依赖数组,或其他 hook。useMemo 和 useCallback 就是为了在多次渲染之间保持引用稳定而生的。

3. useMemo:记住一个值

useMemo(calculate, deps) 在第一次渲染时调用 calculate 并保存结果。在后续渲染中,React 仅在 deps 中的某个元素改变时才重新计算,使用 Object.is 进行比较。

3.1. 避免每次渲染都进行昂贵的计算

import { useMemo, useState } from 'react';

function OrderReport({ orders }) {
  const [keyword, setKeyword] = useState('');
  const [darkMode, setDarkMode] = useState(false);

  const visibleOrders = useMemo(() => {
    const kw = keyword.toLowerCase();
    return orders
      .filter((o) => o.customer.toLowerCase().includes(kw))
      .sort((a, b) => b.total - a.total);
  }, [orders, keyword]);

  return (
    <div className={darkMode ? 'dark' : ''}>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <button onClick={() => setDarkMode((d) => !d)}>切换界面</button>
      <ul>
        {visibleOrders.map((o) => (
          <li key={o.id}>{o.customer}: {o.total}</li>
        ))}
      </ul>
    </div>
  );
}

说明:

  • filter 和 sort 仅在 orders 或 keyword 改变时重新运行。
  • 当你点击“切换界面”时,darkMode 改变,组件重新渲染。此时 useMemo 返回已保存的数组,不重新过滤。
  • sort 在这里是安全的,因为 filter 已经返回了一个新数组。没有任何操作直接修改 props 的 orders。

如何知道计算是否“昂贵”?测量而不是猜测:

console.time('过滤订单');
const result = orders.filter((o) => o.customer.includes(keyword));
console.timeEnd('过滤订单');
过滤订单: 0.08ms

一个实用的参考:如果计算在开发机器上花费不到 1ms 左右,就几乎不需要 memoization。记得在生产构建中测量,更好的做法是在 DevTools 中启用 CPU 节流,因为用户的机器通常比你的慢。

3.2. 保持对象稳定以传递

第二种情况与计算速度无关。这里你需要一个引用稳定的对象作为依赖:

function CustomerOrders({ customerId, status }) {
  const [orders, setOrders] = useState([]);

  const query = useMemo(() => ({ customerId, status }), [customerId, status]);

  useEffect(() => {
    fetchOrders(query).then(setOrders);
  }, [query]);

  return <OrderTable orders={orders} />;
}

如果省略 useMemo,query 将在每次渲染后是一个新对象。那么 effect 会持续重新运行,而且因为 setOrders 会导致重新渲染,所以你可能陷入无限 API 调用的循环。

注意: 在这个例子中还有一个更简单的方法。你在 effect 内部创建对象,并将原始值放入依赖中:useEffect(() => { fetchOrders({ customerId, status }).then(setOrders); }, [customerId, status]);。优先使用这种方法。仅当对象必须存在于 effect 外部时才使用 useMemo,例如当它被传递到子组件时。

4. useCallback:记住一个函数

useCallback(fn, deps) 等价于 useMemo(() => fn, deps)。它返回你传入的函数本身,并保持其引用不变,直到 deps 改变。useCallback 不会让函数运行得更快。这个 Hook 只有在函数在某处被比较时才有意义。

4.1. 结合 React.memo

React.memo 会跳过子组件的渲染,如果 props 与上次相同(浅比较)。如果某个 prop 是每次都重新创建的函数,memo 就失效了。

import { memo, useCallback, useState } from 'react';

const OrderRow = memo(function OrderRow({ order, onSelect, onRender }) {
  onRender?.(order.id);
  return <li onClick={() => onSelect(order.id)}>{order.customer}</li>;
});

export function OrderList({ orders, onRender }) {
  const [selectedId, setSelectedId] = useState(null);
  const [note, setNote] = useState('');

  const handleSelect = useCallback((id) => setSelectedId(id), []);

  return (
    <>
      <input aria-label="note" value={note} onChange={(e) => setNote(e.target.value)} />
      <ul>
        {orders.map((o) => (
          <OrderRow key={o.id} order={o} onSelect={handleSelect} onRender={onRender} />
        ))}
      </ul>
      <p>正在选择: {selectedId ?? '无'}</p>
    </>
  );
}

说明:

  • handleSelect 的依赖为空,因为 setSelectedId 总是稳定的(React 为 useState 的 setter 保证这一点)。
  • 当你在备注框中输入时,OrderList 重新渲染。然而,每个 OrderRow 都接收相同的 order、handleSelect 和 onRender,所以 memo 会跳过它们。
  • 如果把 handleSelect 改成普通的 arrow function,每一行都会在每次按键时重新渲染。

要记住的点:如果接收方没有 memo,useCallback 几乎没有用。 将已 memoized 的回调传递给 DOM 的 <button> 标签不会节省任何东西。

4.2. 将函数用作依赖

function useOrderPolling(customerId) {
  const [orders, setOrders] = useState([]);

  const refresh = useCallback(async () => {
    const data = await fetchOrders({ customerId });
    setOrders(data);
  }, [customerId]);

  useEffect(() => {
    refresh();
    const id = setInterval(refresh, 10_000);
    return () => clearInterval(id);
  }, [refresh]);

  return { orders, refresh };
}

refresh 既在 effect 中使用,又被返回给外部组件。得益于 useCallback,interval 只在 customerId 改变时才重新创建。任何使用 refresh 作为依赖的组件都不会被无谓地重新运行。

这是编写自定义 hook时值得遵守的规则:返回出去的函数应该被 useCallback 包装。你无法提前知道 hook 的使用者是否会将其放入依赖中或传递给 memo。

5. 通过测试验证

与其相信 memo“肯定有效”,你可以为它编写测试。安装 Vitest 和 React Testing Library:

npm install -D vitest jsdom @testing-library/react

在 vite.config.js 中配置 jsdom 环境:

export default {
  test: { environment: 'jsdom' },
};

4.1 章节中 OrderList 的测试:

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { OrderList } from './OrderList';

const orders = [
  { id: 1, customer: '兰' },
  { id: 2, customer: '明' },
];

describe('OrderList', () => {
  it('仅输入备注时不重新渲染OrderRow', () => {
    const onRender = vi.fn();
    render(<OrderList orders={orders} onRender={onRender} />);
    expect(onRender).toHaveBeenCalledTimes(2);

    fireEvent.change(screen.getByLabelText('note'), { target: { value: '快递配送' } });

    expect(onRender).toHaveBeenCalledTimes(2);
  });

  it('选择订单时仍然更新', () => {
    render(<OrderList orders={orders} />);
    fireEvent.click(screen.getByText('明'));
    expect(screen.getByText('正在选择: 2')).toBeTruthy();
  });
});
 ✓ OrderList > 仅输入备注时不重新渲染OrderRow
 ✓ OrderList > 选择订单时仍然更新

尝试删除 useCallback 并重新运行。第一个测试会失败,显示“expected 2 calls”…更确切地说是收到 4 次调用而不是 2 次。这是 hook 正在发挥作用的明确证据。在外部声明 onRender 并传递相同的引用,否则这个 prop 本身会破坏 memo。

除了测试,你应该使用 React DevTools 的 Profiler 标签,启用“Highlight updates”选项,以直观地看到哪些组件重新渲染。

6. 何时不应该使用

Memoization 不是免费的。React 必须保存旧值、保存依赖数组,并在每次渲染时比较它们。代码也变得更难理解。在以下情况下应该跳过它:

  • 便宜的计算: 加几个数字,连接字符串,映射几十个元素。
  • 原始值: useMemo(() => a + b, [a, b]) 返回一个数字。数字已经按值比较,所以 memo 无法帮助。
  • 将回调传递给 DOM 元素或未wrapped React.memo的子组件。
  • 子组件仍然接收每次都改变的 prop。 例如 <OrderRow order={{ ...o }} /> 或传递 JSX 作为 children。JSX 也是每次渲染时的新对象,所以 memo 总是会比较出差异。
  • 可以通过重构而不是 memo 来解决。 将 state 移到更接近使用地方,或通过 children 传递重组件,通常能更彻底地消除多余渲染。

注意: 不要依赖 useMemo 来确保逻辑正确。React 有权清除缓存,例如在某些 Suspense 情况下或将来。你的代码必须正确运行,即使没有 memo。这个 Hook 只是性能优化。同样地,不要在 useMemo 中放置副作用,如 API 调用或写入 localStorage。它们的位置应该在 useEffect 或事件处理程序中。

最常见的错误是缺少依赖,导致陈旧的闭包:

const handleSave = useCallback(() => {
  saveOrder({ ...order, note });
}, [order]); // 缺少 `note`

handleSave 将始终使用第一次创建时的 note 值。启用 eslint-plugin-react-hooks 中的 react-hooks/exhaustive-deps 规则,并且不要关闭其警告。

7. React Compiler 改变了什么

React Compiler 从 2025 年底开始就已经稳定,它在构建时分析组件,并自动插入 memoization,其粒度比你手动编写的更细。在 Vite 或 Babel 项目中尝试:

npm install -D babel-plugin-react-compiler

当启用编译器时,你几乎不需要手动编写 useMemo、useCallback 或 memo。旧的 hook 仍然有效。当你需要精确控制 effect 的依赖时,它们很有用。编译器只能优化遵循 React 规则的代码:纯组件,不直接修改 props 或 state。因此,理解本文中的两个 hook 仍然很必要,以便阅读旧代码并在编译器跳过某个组件时进行调试。

8. 总结

useMemo 和 useCallback 解决同一个问题:在多次渲染之间保持引用稳定。它们在三种情况下真正值得使用:

  1. 计算被证明是昂贵的。
  2. 值或函数被传递到 wrapped React.memo 的组件。
  3. 值或函数在 useEffect 或其他 hook 的依赖中,包括从自定义 hook 返回的函数。

在这些情况之外,保持代码简单。用 Profiler 测量,用测试证明,并考虑启用 React Compiler 让工具代替你做这部分工作。

reacthooksusememousecallbackperformance