React.memo、useMemo 和 useCallback 有什么区别?

React.memo 跳过组件重渲染,useMemo 复用计算结果,useCallback 复用函数引用

先给出面试版结论:三者都是性能优化手段,但缓存的对象和生效位置不同。 React.memo 包裹组件,在父组件重渲染时根据 props 是否相等决定能否跳过该子组件;useMemo 在组件内部缓存一次计算的结果;useCallback 缓存函数本身,等价于针对函数引用的 useMemo。它们都只是一种优化,不是语义保证;依赖或 Context 变化、组件自身 state 更新等情况仍会触发相应工作。

1. 一张表先分清

对比项React.memouseMemouseCallback
使用对象函数组件计算结果函数引用
返回值记忆化后的组件calculateValue() 的结果传入的函数本身
比较什么默认逐个比较新旧 props逐个比较依赖数组逐个比较依赖数组
比较方式默认使用 Object.isObject.isObject.is
主要目的跳过不必要的子组件渲染避免昂贵计算,或稳定对象引用稳定函数引用
常见搭配接收稳定 props 的昂贵子组件昂贵计算、传给 memo 子组件的对象传给 memo 子组件的回调
自身 state 更新不能跳过不适用不适用
Context 变化消费的 Context 变化仍会重渲染依赖变化会重新计算依赖变化会返回新函数

不要把它们概括成“缓存组件、缓存变量、缓存函数”。React.memo 缓存的不是某份永远不变的 UI,而是在特定更新中提供一次 bailout(提前跳过)机会useMemouseCallback 保存的值也可能在未来被 React 丢弃并重新计算。

2. 它们分别解决什么问题?

函数组件的正常行为是:父组件重新渲染时,React 会继续协调它的子组件。即使子组件收到的有效信息没有变化,子组件函数也可能再次执行。

父组件更新

父组件重新执行并创建子元素

默认继续执行子组件

生成 React Element,参与后续协调

如果子组件渲染很昂贵,且输入经常不变,这些工作可能没有必要。React.memo 让 React 有机会在 props 相等时跳过子组件。

但对象和函数在每次执行父组件时通常会产生新引用:

<List options={{ sort: "name" }} onSelect={(id) => select(id)} />

即使内容相同,两次 { sort: "name" } 和两次箭头函数也不是同一个引用,memo 的默认比较就会认为 props 变了。useMemouseCallback 可以在确有必要时稳定这些引用,让 memo 的比较有意义。

因此三者经常组成一条链路:

useMemo / useCallback
在依赖不变时复用 props 引用

React.memo
比较新旧 props,发现都相等

跳过该子组件本次渲染

3. React.memo:根据 props 尝试跳过渲染

const ResultList = React.memo(function ResultList({ items, onSelect }) {
  console.log("ResultList render");

  return items.map((item) => (
    <button key={item.id} onClick={() => onSelect(item.id)}>
      {item.name}
    </button>
  ));
});

当父组件重渲染时,如果 itemsonSelect 与上次提交时的值分别满足 Object.is,React 通常可以跳过 ResultList 的重新渲染。

它不能挡住哪些更新?

memo 只处理父组件传入 props 这一条更新路径,不会让组件失去响应性:

  • 组件自己的 state 变化,仍会重渲染;
  • 组件读取的 Context 变化,仍会重渲染;
  • props 中出现新对象、新数组或新函数,默认比较会失败;
  • 开发环境 Strict Mode 的额外调用不能用来判断生产环境的真实渲染次数。

还可以传入自定义比较函数:

const Chart = React.memo(
  function Chart({ points }) {
    return <ExpensiveChart points={points} />;
  },
  (prevProps, nextProps) => prevProps.points === nextProps.points,
);

比较函数返回 true 表示 props 相等、可以跳过;返回 false 表示需要重新渲染。它的语义很容易写反。

自定义比较必须比较每一个会影响输出的 prop,包括函数。函数会闭包捕获父组件某次渲染的数据;如果错误地把新旧函数判为相等,子组件可能长期调用旧闭包。深度比较也可能比重新渲染更慢,所以必须用 Profiler 和真实数据验证。

4. useMemo:复用计算结果

function ProductPage({ products, query }) {
  const visibleProducts = useMemo(() => {
    return expensiveFilter(products, query);
  }, [products, query]);

  return <ProductList products={visibleProducts} />;
}

首次渲染时,React 调用计算函数并保存结果和依赖。后续渲染会逐项使用 Object.is 比较依赖:依赖均相等就复用旧结果,否则重新计算。

本次 dependencies 与上次已提交的 dependencies
                    ↓ Object.is 逐项比较
          ┌─────────┴─────────┐
        都相等              至少一个不同
          ↓                     ↓
      返回旧结果          执行计算函数并保存新结果

计算函数在 render 阶段执行,所以必须是纯函数:不能发请求、修改 DOM、写外部变量或依赖执行次数。并发渲染中一次未提交的 render 可能被打断或丢弃,开发环境 Strict Mode 也可能额外调用函数来帮助发现不纯逻辑。

useMemo 有两个主要场景:

  1. 计算本身确实昂贵,而且依赖相比组件其他更新变化得少;
  2. 需要把稳定的对象或数组传给 memo 子组件,或作为其他 Hook 的依赖。

不要用 useMemo 保证业务正确性:如果没有它代码就不能正常工作,通常说明状态、ref 或组件边界设计错了。

5. useCallback:复用函数引用

function ProductPage({ products }) {
  const [selectedId, setSelectedId] = useState(null);

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

  return <MemoizedList products={products} onSelect={handleSelect} />;
}

依赖不变时,useCallback 返回上一次的函数引用;它不会提前执行这个函数,也不会缓存函数执行后的返回值。

概念上可以这样理解:

useCallback(fn, dependencies);

// 近似等价于:
useMemo(() => fn, dependencies);

这只是帮助理解 API 关系的等价表达,不是 React 源码实现。

它最常见的用途是把回调传给 React.memo 包裹的子组件。若子组件没有使用 memo,只是为了“避免创建函数”而加 useCallback 往往没有收益:闭包仍会在本次组件执行时创建,并作为参数传给 useCallback;React 只是决定返回新引用还是旧引用。

6. 最小完整示例:三者如何协作

import { memo, useCallback, useMemo, useState } from "react";

const TodoList = memo(function TodoList({ todos, onToggle }) {
  console.log("TodoList render");

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <button onClick={() => onToggle(todo.id)}>
            {todo.done ? "完成" : "待办"}:{todo.title}
          </button>
        </li>
      ))}
    </ul>
  );
});

export default function App({ todos, toggleTodo }) {
  const [theme, setTheme] = useState("light");
  const [filter, setFilter] = useState("all");

  const visibleTodos = useMemo(() => {
    return todos.filter((todo) => {
      if (filter === "done") return todo.done;
      if (filter === "active") return !todo.done;
      return true;
    });
  }, [todos, filter]);

  const handleToggle = useCallback(
    (id) => {
      toggleTodo(id);
    },
    [toggleTodo],
  );

  return (
    <main className={theme}>
      <button onClick={() => setTheme((value) => value === "light" ? "dark" : "light")}>
        切换主题
      </button>
      <button onClick={() => setFilter("all")}>全部</button>
      <button onClick={() => setFilter("active")}>待办</button>
      <button onClick={() => setFilter("done")}>完成</button>
      <TodoList todos={visibleTodos} onToggle={handleToggle} />
    </main>
  );
}

关键链路如下:

  • setTheme 触发 App 重渲染;
  • todosfilter 没变,useMemo 复用原来的 visibleTodos 数组;
  • toggleTodo 没变,useCallback 复用原来的 handleToggle 函数;
  • TodoList 的两个 props 引用都没变,memo 可以跳过本次子组件渲染;
  • filter 变化时,visibleTodos 重新计算,TodoList 应当重新渲染。

示例为了突出机制省略了数据更新实现。真实项目应先用 Profiler 确认 TodoList 的渲染或过滤计算确实是瓶颈。

7. 底层如何保存和比较?

下面是简化模型,不等同于某个 React 版本的完整源码。

useMemouseCallback 都遵守 Hook 调用顺序。React 在当前 Fiber 的 Hook 链表中保存记忆化值和依赖,可以抽象为:

hook.memoizedState = [cachedValue, dependencies];

更新时,React 从 current Fiber 对应的 Hook 记录中读取旧依赖,与新依赖逐项比较。依赖相同就返回旧值,否则计算或保存新值。只有成功提交的 Fiber 才会成为下一次更新的 current;被中断或丢弃的 render 结果不会成为可靠的业务存储。

React.memo 会创建一种特殊的 React 元素类型,内部记录被包装组件和可选比较函数。协调到对应 Fiber 时,React 会结合新旧 props、ref、更新优先级和 Context 等信息判断能否 bailout。默认 props 比较本质上是浅层比较:比较 prop 键,并对每个值使用 Object.is,不会递归比较对象内部字段。

即使某个组件被跳过,React 仍需付出创建元素、进入协调路径和比较 props 等成本。优化收益来自“比较成本小于被跳过的渲染及子树协调成本”,而不是零成本缓存。

8. 常见陷阱

陷阱一:依赖写错导致旧闭包

const handleSubmit = useCallback(() => {
  submit(userId);
}, []); // ❌ 使用了 userId 却没有声明依赖

这个回调捕获首次渲染的 userId。正确做法是加入依赖,或重构数据流,而不是为了稳定引用删除依赖:

const handleSubmit = useCallback(() => {
  submit(userId);
}, [userId]);

陷阱二:memo 被一个不稳定 prop 击穿

const Row = memo(function Row({ style }) {
  return <div style={style}>内容</div>;
});

<Row style={{ color: "red" }} />;

内联对象每次都是新引用,因此 memo 无法跳过。可以在对象确实需要作为稳定 prop 时用 useMemo,也可以直接传更小的原始值,让子组件自己构造对象。

陷阱三:到处加缓存

缓存会增加依赖维护、比较、内存占用和阅读成本。对于廉价计算、必然每次变化的依赖、渲染很轻的组件,缓存可能没有收益。

陷阱四:用 useMemo 执行副作用

useMemo(() => {
  analytics.track("page_view"); // ❌ render 阶段副作用
}, []);

useMemo 不是生命周期 API。副作用应根据同步对象选择事件处理器或 effect。

陷阱五:把“重新渲染”当成“更新 DOM”

组件函数重新执行不代表 DOM 一定变化。React 还会进行协调,只有最终差异才在 Commit 阶段写入 DOM。优化目标应是用户可感知的耗时,而不是盲目追求零 render。

陷阱六:把缓存当成永久存储

React 可以因开发检查、组件挂起或未来特性等原因丢弃缓存。需要跨渲染可靠保存且改变后应渲染的数据用 state;不参与渲染的可变数据通常用 ref。

陷阱七:忽略 React Compiler

使用启用了 React Compiler 的项目时,编译器可以自动应用部分等价的记忆化优化,手写 memouseMemouseCallback 的需求会减少。但是否启用、能否成功分析以及项目约束各不相同;面试中应先说明项目版本和编译配置,不能据此认为这些 API 已经无意义。

9. 应该怎么选择?

按问题选择,而不是按语法选择:

Profiler 发现性能瓶颈

是昂贵计算重复执行? ── 是 → useMemo
        ↓ 否
是昂贵子组件在输入不变时重复渲染? ── 是 → React.memo

memo 因对象 prop 每次新建而失效? ── 是 → useMemo 或调整 props 设计

memo 因函数 prop 每次新建而失效? ── 是 → useCallback 或调整组件边界

通常先考虑更根本的结构优化:让 state 靠近真正使用它的组件、让组件接收最小必要 props、避免不必要的 effect 链式更新、保持 render 纯粹。之后再用 Profiler 定位热点并应用缓存。

10. 面试官递进追问

1. useMemouseCallback 的返回值有什么区别?

useMemo 返回计算函数的执行结果;useCallback 返回函数本身。后者可以概念性地理解为 useMemo(() => fn, deps)

为什么问: 检查是否真正理解“缓存结果”和“稳定函数引用”。回答应抓住返回值,而不只是背使用场景。

2. React.memo 比较的是组件渲染结果吗?

不是。它默认浅比较新旧 props,以决定是否有机会跳过组件执行,并不会先渲染两份 UI 再比较。

为什么问: 区分 props bailout 与虚拟 DOM diff。回答应抓住比较发生在子组件执行之前。

3. 为什么 memo 经常要配合另外两个 Hook?

对象、数组和函数在父组件每次执行时通常会生成新引用,会让浅比较失败。useMemouseCallback 可以在依赖不变时稳定引用。

为什么问: 检查能否串起完整因果链,而不是孤立记 API。

4. memo 后组件为什么仍然重渲染?

可能是 props 引用变化、自身 state 更新、读取的 Context 变化,或比较函数返回了 false。开发环境 Strict Mode 也会影响观察到的调用次数。

为什么问: 检查是否误认为 memo 能冻结组件。回答要分清更新来源。

5. useCallback 能避免创建函数吗?

通常不能这样表述。组件执行时函数表达式仍会被求值并传给 Hook;依赖不变时,React 返回之前保存的函数引用。它的主要价值是引用稳定,而非消灭函数创建成本。

为什么问: 检查是否理解 JavaScript 求值过程。回答应抓住“创建过,但返回旧引用”。

6. 自定义 props 比较为什么可能产生 bug?

如果漏掉函数 prop,子组件可能保留捕获旧 props 或 state 的回调;漏掉其他影响 UI 的 prop,也会让页面不更新。深比较还可能带来更高性能成本。

为什么问: 检查正确性边界。回答应同时覆盖闭包与比较成本。

7. 可以依赖 useMemo 保证对象永远不变吗?

不可以。它是性能优化,React 可能丢弃缓存。业务语义需要可靠持久化时,应根据数据是否驱动 UI 使用 state 或 ref。

为什么问: 检查是否把优化机制当成状态模型。回答应说清替代方案。

8. 实际项目中如何判断是否该使用它们?

用 React DevTools Profiler 记录真实交互,确认组件提交耗时、重渲染原因和计算热点;优化后用同一场景复测。如果比较和维护成本不低于省下的工作,就不应添加缓存。

为什么问: 检查工程能力。回答应包括测量、定位、修改、复测的闭环。

9. React Compiler 会让这三个 API 消失吗?

不会立刻消失。编译器能自动处理部分记忆化,但项目可能未启用,部分代码也可能无法优化;这些 API 的语义和遗留代码仍需理解。具体使用应以当前编译配置和性能测量为准。

为什么问: 检查对新工具的理解是否过度绝对化。回答要说明能力边界。

11. 常见错误回答

  • React.memo 缓存组件。” 太模糊。应说它比较 props,并在满足 bailout 条件时跳过本次组件渲染。
  • useCallback 能防止函数被创建。” 不准确。它主要保证依赖不变时返回旧函数引用。
  • “用了 memo 就不会重渲染。” 忽略了自身 state、Context 和不稳定 props。
  • “优化 React 就把三个都加上。” 没有成本模型。应先定位瓶颈,并比较缓存成本与省下的工作。
  • “依赖数组少写一点,引用就稳定了。” 这是用错误结果换表面性能,容易产生旧闭包。
  • “自定义深比较一定比重渲染快。” 没有数据支持;数据规模和结构变化后,深比较可能非常昂贵。

12. 最终记忆框架

核心关键词:组件 bailout、计算结果、函数引用、Object.is、依赖、性能测量

一句话本质:它们用比较和缓存的额外成本,换取跳过更昂贵工作的机会。

一分钟回答:讲清三者缓存对象、返回值和常见配合方式,再强调它们都是优化而非语义保证。

三分钟回答:补充 Object.is、对象和函数引用为什么会击穿 memo,以及 state、Context 仍能触发渲染。

十分钟回答:结合完整示例讲父组件更新到 bailout 的链路,再展开 Hook 在 Fiber 上保存值和依赖的简化模型、自定义比较与旧闭包风险、并发渲染边界、Profiler 验证和 React Compiler。

这套思路还能迁移到 useEffect 依赖数组、Context value 稳定性、selector 缓存、不可变数据和组件边界设计:关键始终是弄清楚“比较什么、何时失效、跳过了哪部分工作、成本是否值得”。