React.memo、useMemo 和 useCallback 有什么区别?
React.memo跳过组件重渲染,useMemo复用计算结果,useCallback复用函数引用
先给出面试版结论:三者都是性能优化手段,但缓存的对象和生效位置不同。 React.memo 包裹组件,在父组件重渲染时根据 props 是否相等决定能否跳过该子组件;useMemo 在组件内部缓存一次计算的结果;useCallback 缓存函数本身,等价于针对函数引用的 useMemo。它们都只是一种优化,不是语义保证;依赖或 Context 变化、组件自身 state 更新等情况仍会触发相应工作。
1. 一张表先分清
不要把它们概括成“缓存组件、缓存变量、缓存函数”。React.memo 缓存的不是某份永远不变的 UI,而是在特定更新中提供一次 bailout(提前跳过)机会;useMemo 和 useCallback 保存的值也可能在未来被 React 丢弃并重新计算。
2. 它们分别解决什么问题?
函数组件的正常行为是:父组件重新渲染时,React 会继续协调它的子组件。即使子组件收到的有效信息没有变化,子组件函数也可能再次执行。
如果子组件渲染很昂贵,且输入经常不变,这些工作可能没有必要。React.memo 让 React 有机会在 props 相等时跳过子组件。
但对象和函数在每次执行父组件时通常会产生新引用:
即使内容相同,两次 { sort: "name" } 和两次箭头函数也不是同一个引用,memo 的默认比较就会认为 props 变了。useMemo 和 useCallback 可以在确有必要时稳定这些引用,让 memo 的比较有意义。
因此三者经常组成一条链路:
3. React.memo:根据 props 尝试跳过渲染
当父组件重渲染时,如果 items 和 onSelect 与上次提交时的值分别满足 Object.is,React 通常可以跳过 ResultList 的重新渲染。
它不能挡住哪些更新?
memo 只处理父组件传入 props 这一条更新路径,不会让组件失去响应性:
- 组件自己的 state 变化,仍会重渲染;
- 组件读取的 Context 变化,仍会重渲染;
- props 中出现新对象、新数组或新函数,默认比较会失败;
- 开发环境 Strict Mode 的额外调用不能用来判断生产环境的真实渲染次数。
还可以传入自定义比较函数:
比较函数返回 true 表示 props 相等、可以跳过;返回 false 表示需要重新渲染。它的语义很容易写反。
自定义比较必须比较每一个会影响输出的 prop,包括函数。函数会闭包捕获父组件某次渲染的数据;如果错误地把新旧函数判为相等,子组件可能长期调用旧闭包。深度比较也可能比重新渲染更慢,所以必须用 Profiler 和真实数据验证。
4. useMemo:复用计算结果
首次渲染时,React 调用计算函数并保存结果和依赖。后续渲染会逐项使用 Object.is 比较依赖:依赖均相等就复用旧结果,否则重新计算。
计算函数在 render 阶段执行,所以必须是纯函数:不能发请求、修改 DOM、写外部变量或依赖执行次数。并发渲染中一次未提交的 render 可能被打断或丢弃,开发环境 Strict Mode 也可能额外调用函数来帮助发现不纯逻辑。
useMemo 有两个主要场景:
- 计算本身确实昂贵,而且依赖相比组件其他更新变化得少;
- 需要把稳定的对象或数组传给
memo子组件,或作为其他 Hook 的依赖。
不要用 useMemo 保证业务正确性:如果没有它代码就不能正常工作,通常说明状态、ref 或组件边界设计错了。
5. useCallback:复用函数引用
依赖不变时,useCallback 返回上一次的函数引用;它不会提前执行这个函数,也不会缓存函数执行后的返回值。
概念上可以这样理解:
这只是帮助理解 API 关系的等价表达,不是 React 源码实现。
它最常见的用途是把回调传给 React.memo 包裹的子组件。若子组件没有使用 memo,只是为了“避免创建函数”而加 useCallback 往往没有收益:闭包仍会在本次组件执行时创建,并作为参数传给 useCallback;React 只是决定返回新引用还是旧引用。
6. 最小完整示例:三者如何协作
关键链路如下:
setTheme触发App重渲染;todos、filter没变,useMemo复用原来的visibleTodos数组;toggleTodo没变,useCallback复用原来的handleToggle函数;TodoList的两个 props 引用都没变,memo可以跳过本次子组件渲染;filter变化时,visibleTodos重新计算,TodoList应当重新渲染。
示例为了突出机制省略了数据更新实现。真实项目应先用 Profiler 确认 TodoList 的渲染或过滤计算确实是瓶颈。
7. 底层如何保存和比较?
下面是简化模型,不等同于某个 React 版本的完整源码。
useMemo 和 useCallback 都遵守 Hook 调用顺序。React 在当前 Fiber 的 Hook 链表中保存记忆化值和依赖,可以抽象为:
更新时,React 从 current Fiber 对应的 Hook 记录中读取旧依赖,与新依赖逐项比较。依赖相同就返回旧值,否则计算或保存新值。只有成功提交的 Fiber 才会成为下一次更新的 current;被中断或丢弃的 render 结果不会成为可靠的业务存储。
React.memo 会创建一种特殊的 React 元素类型,内部记录被包装组件和可选比较函数。协调到对应 Fiber 时,React 会结合新旧 props、ref、更新优先级和 Context 等信息判断能否 bailout。默认 props 比较本质上是浅层比较:比较 prop 键,并对每个值使用 Object.is,不会递归比较对象内部字段。
即使某个组件被跳过,React 仍需付出创建元素、进入协调路径和比较 props 等成本。优化收益来自“比较成本小于被跳过的渲染及子树协调成本”,而不是零成本缓存。
8. 常见陷阱
陷阱一:依赖写错导致旧闭包
这个回调捕获首次渲染的 userId。正确做法是加入依赖,或重构数据流,而不是为了稳定引用删除依赖:
陷阱二:memo 被一个不稳定 prop 击穿
内联对象每次都是新引用,因此 memo 无法跳过。可以在对象确实需要作为稳定 prop 时用 useMemo,也可以直接传更小的原始值,让子组件自己构造对象。
陷阱三:到处加缓存
缓存会增加依赖维护、比较、内存占用和阅读成本。对于廉价计算、必然每次变化的依赖、渲染很轻的组件,缓存可能没有收益。
陷阱四:用 useMemo 执行副作用
useMemo 不是生命周期 API。副作用应根据同步对象选择事件处理器或 effect。
陷阱五:把“重新渲染”当成“更新 DOM”
组件函数重新执行不代表 DOM 一定变化。React 还会进行协调,只有最终差异才在 Commit 阶段写入 DOM。优化目标应是用户可感知的耗时,而不是盲目追求零 render。
陷阱六:把缓存当成永久存储
React 可以因开发检查、组件挂起或未来特性等原因丢弃缓存。需要跨渲染可靠保存且改变后应渲染的数据用 state;不参与渲染的可变数据通常用 ref。
陷阱七:忽略 React Compiler
使用启用了 React Compiler 的项目时,编译器可以自动应用部分等价的记忆化优化,手写 memo、useMemo、useCallback 的需求会减少。但是否启用、能否成功分析以及项目约束各不相同;面试中应先说明项目版本和编译配置,不能据此认为这些 API 已经无意义。
9. 应该怎么选择?
按问题选择,而不是按语法选择:
通常先考虑更根本的结构优化:让 state 靠近真正使用它的组件、让组件接收最小必要 props、避免不必要的 effect 链式更新、保持 render 纯粹。之后再用 Profiler 定位热点并应用缓存。
10. 面试官递进追问
1. useMemo 和 useCallback 的返回值有什么区别?
useMemo 返回计算函数的执行结果;useCallback 返回函数本身。后者可以概念性地理解为 useMemo(() => fn, deps)。
为什么问: 检查是否真正理解“缓存结果”和“稳定函数引用”。回答应抓住返回值,而不只是背使用场景。
2. React.memo 比较的是组件渲染结果吗?
不是。它默认浅比较新旧 props,以决定是否有机会跳过组件执行,并不会先渲染两份 UI 再比较。
为什么问: 区分 props bailout 与虚拟 DOM diff。回答应抓住比较发生在子组件执行之前。
3. 为什么 memo 经常要配合另外两个 Hook?
对象、数组和函数在父组件每次执行时通常会生成新引用,会让浅比较失败。useMemo 和 useCallback 可以在依赖不变时稳定引用。
为什么问: 检查能否串起完整因果链,而不是孤立记 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 缓存、不可变数据和组件边界设计:关键始终是弄清楚“比较什么、何时失效、跳过了哪部分工作、成本是否值得”。

