先记住这个答案
memo 为组件建立一个基于 props 的优化边界。对于同一个位置、相同类型和 key 的组件,父级重新渲染时,默认比较每个 prop 的新旧值,全部通过 Object.is 比较才有机会跳过这次子组件渲染。它不会拦截组件自身状态更新或所消费的 Context 更新,也不会保留被卸载组件的实例。React 把它定义为性能优化,因此代码必须在不依赖跳过渲染的情况下仍然正确。
- 默认逐个 prop 使用 Object.is 比较
- 先保住组件身份,再讨论跳过渲染
- 本地状态与 Context 有独立的更新来源
浅比较比较的是每一项输入
如果上次和本次都是 name 等于小林、score 等于八十分,两个基本类型输入保持相等,即使父组件生成了新的 JSX,memo 仍有机会复用成绩卡。这里没有要求父组件传入的整个 props 容器是同一个对象。
如果 score 换成一个新建的对象,即便内部数字没变,Object.is 比较也不相等。不要把浅比较描述成只比较第一层字段的业务内容;对于对象这一项,它判断的是引用身份,而不是自动展开字段。
把无关计数与真正的成绩变化分开
下面将成绩卡定义在模块顶层,父级计数只影响外面的 output。增加计数不改变成绩卡输入,增加成绩则改变 score。验证时应该分别观察这两条路径,并检查修改成绩后界面仍能及时更新。
可以在开发工具中记录子组件提交,也可以在测试夹具里加入提交阶段探针。不要把渲染函数中的日志次数直接解释成用户看见的提交次数;开发严格模式和未提交的渲染尝试都会干扰这种推断。
import { memo, useState } from 'react';
const ScoreCard = memo(function ScoreCard({ score }: { score: number }) {
return <p>小林的成绩:{score}</p>;
});
export default function ScoreDemo() {
const [revision, setRevision] = useState(0);
const [score, setScore] = useState(80);
return <>
<button onClick={() => setRevision(n => n + 1)}>增加父计数</button>
<output>父计数:{revision}</output>
<button onClick={() => setScore(n => n + 1)}>增加成绩</button>
<ScoreCard score={score} />
</>;
}成绩卡只接收成绩,所以父计数变化提供了相同 props 的对照。示例没有把 memo 放在父函数内部,否则每次得到新的组件类型可能导致重新挂载,讨论浅比较就失去了前提。
哪些更新不会被这个边界挡住
在成绩卡内部增加展开状态,点击展开仍会触发它自己的更新。成绩卡读取主题 Context 时,提供者的新值也能使它更新。这些情况与父级重复传递相同成绩是不同路径,不能据此判断 memo 实现失效。
改变 key、切换组件类型或先移除再添加,会建立新的实例,之前的比较结果与局部状态都不能跨卸载继承。对需要长期保存的数据应另选合适的状态所有者,不能让 memo 承担持久化职责。
容易答错的地方
- 把相同 props 说成绝不执行
- React 允许在优化之外重新渲染组件,严格模式也会检查渲染纯度。如果组件只有在某次执行被跳过时才不会重复扣款或写日志系统,应把这些行为移到明确的事件或副作用生命周期中。
- 在父组件内部临时创建 memo 组件
- 在每轮父函数执行中调用 memo 并把结果当成子类型,会不断制造新的组件类型。应把组件声明移到模块作用域,先验证状态和焦点不会因为重新挂载丢失,再评估缓存收益。
面试官还会怎么问?
传递同一个对象但修改字段会怎样?
对象引用仍然相同,浅比较可能跳过需要发生的更新,界面因而陈旧。这是可变输入破坏了比较前提,修复办法是不可变地生成新对象,而不是要求 memo 检查已被覆盖的旧字段。
普通函数组件能自动获得这种优化吗?
需要区分运行时的普通父子更新和实际启用的 React Compiler。编译器可以生成自动缓存,但它不是仅靠安装 React 就开启的;面试或排查时应明确项目是否经过编译器转换。
如何证明这次优化值得保留?
选取真实的父级高频操作,在相同数据与环境下比较子树工作量和交互耗时。一次简单计数只能说明跳过条件,不能证明生产设备上的性能收益,更不能替代对慢计算或长列表的定位。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。