3. 状态提升后子组件重复渲染问题初探
状态提升让多个子组件共享同一份数据,但代价也随之而来:父组件任意状态变化都会导致它全部子组件重新渲染,即使这些子组件接收的 props 毫无变化。本章将剖析渲染触发机制,识别不必要的更新,并给出几种立即可用的初步优化方向。
3.1 渲染触发机制
React 组件的渲染遵循一套明确的触发链:
- 状态变化(调用
setState或dispatch)引发所在组件的重新渲染。 - 该组件重新执行函数体,生成新的虚拟 DOM(React Element)树。
- 新虚拟 DOM 与上一次的虚拟 DOM 进行 diff 比较。
- 每次比较后,React 会递归地对该组件下的所有子组件执行相同的流程——无论子组件的 props 是否实际改变。
这一递归机制在状态提升后尤为突出:父组件中任意 useState 的变化都会导致整棵子树重新调用渲染函数。
关键指标:父组件每次渲染时,子组件渲染函数被调用的次数等于子组件的总数。如果子组件数量多或渲染开销大,性能损耗会急剧放大。
3.2 不必要的子组件更新
下面的例子直观展示了“不必要的更新”:
// 父组件 - 状态提升后
function Parent() {
const [count, setCount] = React.useState(0);
return (
<div>
<p>当前计数:{count}</p>
<ChildA count={count} />
<ChildB text="静态文本" />
</div>
);
}
// 子组件 A - 依赖 count
function ChildA({ count }) {
console.log("ChildA 渲染");
return <span>A: {count}</span>;
}
// 子组件 B - 不依赖 count,只接收常量
function ChildB({ text }) {
console.log("ChildB 渲染"); // 每次父组件更新都会打印
return <span>B: {text}</span>;
}
当点击按钮修改 count 时,控制台会同时输出 ChildA 渲染 和 ChildB 渲染。ChildB 的 props 并未变化,却依然被重新调用了函数体。
3.3 初步优化思路
React 提供了几种“拒绝不必要的渲染”的轻量级手段,本章先介绍最直接的一种——React.memo(高阶组件)。
3.3.1 使用 React.memo 包裹子组件
// 用 React.memo 包装只依赖静态 props 的子组件
const ChildB = React.memo(function ChildB({ text }) {
console.log("ChildB 渲染");
return <span>B: {text}</span>;
});
// 父组件保持不变,再次点击更新 count
// 控制台:ChildA 渲染(每次),ChildB 渲染(仅首次或 props 变化时)
React.memo 默认使用浅比较(Shallow Equal)来对比新旧 props。本例中 text="静态文本" 在两次渲染间引用相同,因此 ChildB 跳过渲染。
| 优化方案 | 子组件 A(props 变化) | 子组件 B(props 不变) |
|---|---|---|
| 无优化 | 每次重新渲染 | 每次重新渲染(浪费) |
| React.memo | 每次重新渲染 | 跳过渲染 |
3.3.2 其他辅助思路(预告)
- useMemo:缓存计算值,避免子组件因引用变化而重渲染。
- useCallback:缓存回调函数引用,避免子组件因函数 props 变化而重渲染。
- 拆分组件:将频繁变化的部分与稳定部分分离,减少受影响的子节点。
⚠️ 初步优化注意事项:
- React.memo 只做浅比较,对于对象/数组 props,即使内容相同但引用不同,仍会触发渲染。
- 不要对每次渲染都使用 memo —— 它本身有比较开销。应在内部耗时长或子组件数量大的地方使用。
- 对于“props 始终变化”的组件,memo 只会增加无意义的比较。
接下来的章节将深入 useMemo、useCallback 以及更细粒度的优化策略。基础认知已经建立:状态提升虽能共享数据,却也引入了“过度渲染”的连锁问题;React.memo 是边界清晰、成本最低的初步防线。