Alex

彻底搞懂 React 不可变数据、Memo 与渲染本质

  • React
  • 前端

修改了对象属性 UI 却不刷新?子组件没有任何 props 也会疯狂重新执行?Diff 算法是不是就是浅比较?

这篇文章按一条主线展开:渲染两阶段 → 子组件为何重渲染 → 浅比较与 Diff 的分工 → 不可变数据 → 深层状态怎么改


1. 什么是 React 口中的「渲染」?

很多同学一听到「渲染」,想到的是浏览器屏幕上的像素刷新。但在 React 的语境里,这并不完整。

React 的更新流程被拆成两个阶段:

1.1 Render 阶段(这才是「渲染」)

含义组件函数被重新执行,在 JS 内存中生成全新的虚拟 DOM 树(Fiber 树)
特点完全在内存中进行,不涉及真实 DOMconsole.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

  1. 浅比较本身有开销 — 对简单子组件,直接执行往往比遍历对比 props 更快。
  2. 引用的陷阱 — 父组件传内联函数或对象(如 onClick={() => {}}),每次刷新引用都会变,默认 memo 仍会失效。
  3. React 19 的方向React Compiler 在构建时自动分析并插入高精度缓存,未来有望减少手写 memo 的负担。

3. 浅比较 ≠ Diff:经典面试题

结论:React 的 Diff 算法本身并不是「浅比较」,它比浅比较复杂得多。

大家常说的「浅比较」,发生在 Diff 之前——组件决定「要不要更新」的阶段。

3.1 浅比较发生在哪里?

浅比较(Object.is===)主要用在组件是否值得继续往下走的关口:

场景行为
useStatesetCount(prev => prev) 若值未变,浅比较发现 oldState === newState直接拦截,连 Diff 都不触发
React.memo浅比较新旧 props;若一致,复用上次渲染结果,不进入子组件 Diff

3.2 Diff 算法在干嘛?

若浅比较没拦住(或根本没有这道关),React 会执行组件函数,生成新的虚拟 DOM 树(Fiber 树)。此时 Diff 才真正登场:对比新旧两棵树,找出差异,再在 Commit 阶段落到真实 DOM。

Diff 的本质是树的层级遍历与属性对比,而不是比一下内存地址就完事:

  1. 同层对比 — React 不跨层级对比节点,只比较同一层。
  2. 类型对比 — 节点类型变了(如 <div><p>),直接销毁旧节点及其子树,再创建新节点。
  3. 属性扫描 — 类型相同时,遍历 props(classNamestyleid 等),逐个比对键值对。
  4. 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.jsAPI 自成一派,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 比地址;useStatememo 在此拦截
2Render执行组件函数,生成虚拟 DOM
3Diff同层、类型、属性、Key;不是浅比较
4Commit把差异写到真实 DOM

日常开发再记两条:

  • 改深层状态 → 优先 Immer
  • 保证浅比较有效 → 坚持 不可变更新