Alex
彻底搞懂 React 不可变数据、Memo 与渲染本质
- React
- 前端
修改了对象属性 UI 却不刷新?子组件没有任何 props 也会疯狂重新执行?Diff 算法是不是就是浅比较?
这篇文章按一条主线展开:渲染两阶段 → 子组件为何重渲染 → 浅比较与 Diff 的分工 → 不可变数据 → 深层状态怎么改。
1. 什么是 React 口中的「渲染」?
很多同学一听到「渲染」,想到的是浏览器屏幕上的像素刷新。但在 React 的语境里,这并不完整。
React 的更新流程被拆成两个阶段:
1.1 Render 阶段(这才是「渲染」)
| 含义 | 组件函数被重新执行,在 JS 内存中生成全新的虚拟 DOM 树(Fiber 树) |
| 特点 | 完全在内存中进行,不涉及真实 DOM;console.log 通常在这一步触发 |
1.2 Commit 阶段(提交 / 挂载)
| 含义 | React 拿着 Diff 算出的差异,去修改浏览器里的真实 DOM |
| 特点 | 触发重排和重绘,用户才能在屏幕上看到变化 |
核心结论: 组件函数重新执行、生成了虚拟 DOM,就已经叫重渲染(Re-render)。最终有没有变更真实 DOM,是 Commit 阶段的事。
2. 默认悲观策略:为什么没 props 的子组件也会重渲染?
function Child() {
console.log("Child render");
return <div>Child</div>;
}
export default function App() {
const [count, setCount] = useState(1);
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>点击: {count}</button>
<Child />
</div>
);
}| 现象 | 每次点击按钮,Child 内部的 console.log 都会触发 |
| 原因 | React 默认悲观更新——父组件变了,子组件大概率也会受影响 |
当 App 重新执行时,<Child />(本质是 React.createElement(Child))会生成新的虚拟 DOM 节点,React 会进入 Child 内部重新执行组件函数。
2.1 优化手段:React.memo
用 React.memo 包裹 Child 后,Render 阶段前会多一道浅比较:新旧 props 都是 {} 时,跳过子组件函数执行,复用上一次的渲染结果。
2.2 为什么 React 不默认给所有组件加 memo?
- 浅比较本身有开销 — 对简单子组件,直接执行往往比遍历对比
props更快。 - 引用的陷阱 — 父组件传内联函数或对象(如
onClick={() => {}}),每次刷新引用都会变,默认memo仍会失效。 - React 19 的方向 — React Compiler 在构建时自动分析并插入高精度缓存,未来有望减少手写
memo的负担。
3. 浅比较 ≠ Diff:经典面试题
结论:React 的 Diff 算法本身并不是「浅比较」,它比浅比较复杂得多。
大家常说的「浅比较」,发生在 Diff 之前——组件决定「要不要更新」的阶段。
3.1 浅比较发生在哪里?
浅比较(Object.is 或 ===)主要用在组件是否值得继续往下走的关口:
| 场景 | 行为 |
|---|---|
useState | setCount(prev => prev) 若值未变,浅比较发现 oldState === newState,直接拦截,连 Diff 都不触发 |
React.memo | 浅比较新旧 props;若一致,复用上次渲染结果,不进入子组件 Diff |
3.2 Diff 算法在干嘛?
若浅比较没拦住(或根本没有这道关),React 会执行组件函数,生成新的虚拟 DOM 树(Fiber 树)。此时 Diff 才真正登场:对比新旧两棵树,找出差异,再在 Commit 阶段落到真实 DOM。
Diff 的本质是树的层级遍历与属性对比,而不是比一下内存地址就完事:
- 同层对比 — React 不跨层级对比节点,只比较同一层。
- 类型对比 — 节点类型变了(如
<div>→<p>),直接销毁旧节点及其子树,再创建新节点。 - 属性扫描 — 类型相同时,遍历 props(
className、style、id等),逐个比对键值对。 - Key 对比 — 列表子节点强依赖
key,在旧树里找对应节点,判断移动、删除或新增。
3.3 一个比喻:门卫 vs 质检员
| 角色 | 对应 | 在做什么 |
|---|---|---|
| 门卫 | 浅比较 | 只看「车牌号」(内存地址)。一样则原路返回;变了则放行 |
| 质检员 | Diff | 把箱子(虚拟 DOM 节点)逐个对照旧库存:挪位置、换标签、扔空箱 |
正因为 Diff 要逐层对比节点和属性,开销不小。在第一关用不可变数据配合浅比较,把不必更新的组件拦在外面,React 就能少做大量 Diff 运算——这也是第 4 节不可变数据如此重要的原因。
4. 为什么 React 必须死守「不可变数据」?
不可变数据(Immutable Data): 数据一旦创建就不能被修改;要改就必须生成新对象(新内存地址)。
4.1 极速的浅比较
若允许直接改对象内部属性,内存地址不变,React 要知道数据是否变化,只能做深对比,性能代价很高。
不可变数据下,React 用 === 做浅比较即可:
旧 state 指针 !== 新 state 指针 => 触发重渲染把 O(n) 的深层递归对比,变成 O(1) 的地址对比。详见 第 3 节:不可变数据让第一关浅比较有效,从而给后面的 Diff 减负。
4.2 状态可预测
可变对象在组件间传递时,一方悄悄修改会影响另一方,Bug 极难排查。不可变更新让「谁改了状态」一目了然。
5. 深层嵌套不可变数据的四条路线
当数据嵌套很深(如 user.profile.address.city = 'Shanghai'),原生展开运算符 ... 容易写成代码地狱。常见演进方案:
| 方案 | 推荐度 | 说明 |
|---|---|---|
| Immer | ★★★★★ | 命令式写法 + 不可变结果;基于 Proxy 维护 draft |
| 状态扁平化 | ★★★★ | 像数据库 normalize,拆成一层状态,从根上减少嵌套 |
| Lodash/fp | ★★★ | 路径字符串更新;难享受 TypeScript 类型推导 |
| Immutable.js | — | API 自成一派,toJS() / fromJS() 成本高,新项目一般不选 |
5.1 Immer(首选)
import { produce } from "immer";
setUser(
produce((draft) => {
draft.profile.address.city = "Shanghai";
}),
);5.2 状态扁平化(架构推荐)
嵌套过深往往说明状态设计可优化。拆成扁平的一层状态,从根上减少嵌套更新。
5.3 Lodash/fp(过渡方案)
set("profile.address.city", "Shanghai", user);路径字符串难以享受 TypeScript 类型推导,适合过渡期。
5.4 Immutable.js(不推荐)
API 自成一派,频繁 toJS() / fromJS(),学习成本与迁移成本较高。
6. 总结
React 更新可以记成四道关:
| 关卡 | 名称 | 要点 |
|---|---|---|
| 1 | 浅比较 | === / Object.is 比地址;useState、memo 在此拦截 |
| 2 | Render | 执行组件函数,生成虚拟 DOM |
| 3 | Diff | 同层、类型、属性、Key;不是浅比较 |
| 4 | Commit | 把差异写到真实 DOM |
日常开发再记两条:
- 改深层状态 → 优先 Immer
- 保证浅比较有效 → 坚持 不可变更新