Virtual DOM 和 Diff 算法
React 如何根据状态变化更新页面?
先记住一句话:组件重新渲染时,React 会生成新的 React element 树,在 Render 阶段与上一棵树协调,计算出需要提交的变化,最后在 Commit 阶段更新真实宿主环境。
这里常说的“Virtual DOM”是帮助理解的概念,不是一个独立的浏览器 DOM。更准确地说,JSX 会产生 React element,Fiber 则保存协调过程中的工作单元和更新信息。
1. 为什么需要 Virtual DOM?
直接手动操作 DOM,需要开发者自己维护状态与 DOM 之间的对应关系:哪里新增、哪里删除、哪些属性需要更新。React 采用声明式写法:
当 count 变化时,开发者只描述“新的 UI 应该是什么样”,React 负责协调新旧 UI 并提交必要的变化。
它带来的主要价值是:
- 声明式:状态和 UI 的关系更清晰;
- 可扩展:同一套组件模型可以映射到 DOM、React Native 等宿主环境;
- 集中协调:React 可以统一处理更新优先级、组件状态和副作用边界。
不要简单理解成“Virtual DOM 一定比手写 DOM 快”。创建 element、执行组件和协调本身也有成本;性能优势来自减少不必要的宿主环境操作,以及让 UI 更新更容易组合和调度。
2. 一次更新经历什么?
可以把更新简化为两个阶段:
Render 阶段
- 执行函数组件,计算新的 React element;
- 对比新旧 Fiber,确定节点是复用、插入、移动还是删除;
- 可以被中断、重做或放弃,因此不要在渲染函数中执行副作用。
Commit 阶段
Commit 阶段会把 Render 阶段已经确定的变化同步提交到宿主环境,例如浏览器 DOM。它不能像 Render 阶段那样被中断,否则真实 UI 可能停在不一致的中间状态。
具体可以拆成三段理解:
Before Mutation 阶段发生在 DOM 真正变化之前,主要用于读取旧 DOM 状态,例如类组件的 getSnapshotBeforeUpdate。如果需要在更新前保存滚动位置,这类逻辑就适合发生在这里。
Mutation 阶段是真正修改宿主环境的阶段,包括插入 DOM 节点、删除 DOM 节点、更新 DOM 属性和文本内容、解绑旧 ref,并处理卸载相关逻辑。useInsertionEffect 也属于这个提交流程中更早的同步 effect,主要供 CSS-in-JS 库插入样式。
Layout 阶段发生在 DOM 已经更新之后、浏览器通常还没绘制之前,主要包括类组件的 componentDidMount、componentDidUpdate,绑定新的 ref,以及执行 useLayoutEffect。因此 useLayoutEffect 可以读到更新后的 DOM,并且会阻塞浏览器绘制。
普通的 useEffect 属于 passive effect,通常不在同步 Commit 主流程里立即执行,而是在提交完成后由 React 调度。它适合请求数据、订阅、日志等不需要阻塞布局和绘制的副作用。
Render 阶段计算出的结果不会自动等于“每个 DOM 节点都重新创建”。只有真正需要变化的部分才会进入提交过程。
3. Diff 为什么通常不是 O(n³)?
如果对任意两棵树做最优的通用树编辑距离计算,复杂度会非常高。React 不追求所有情况下的最优解,而是利用 UI 的常见规律进行启发式协调:
- 主要比较同一层级的节点:React 不会为了寻找最优结果而在整棵树中跨层级匹配节点。
- 类型变化通常意味着新身份:
<div>变成<p>,或一个组件类型变成另一个组件类型时,React 通常会销毁旧子树并创建新子树。 - Key 用于识别同级列表项身份:同一个 key 加上相同类型,可以帮助 React 判断节点是否应该复用。
这些规则让常见场景下的协调接近 O(n),但不能把 O(n) 当成所有更新、所有情况下的绝对保证。
4. 单节点如何协调?
对于同一位置的单个节点,可以这样理解:
例如:
对于组件,组件类型本身也是身份的一部分。把 UserCard 替换为 AdminCard,即使它们渲染出相似的 DOM,也不应默认认为是同一个组件实例。
5. 列表 Diff 与 Key
key 的作用是告诉 React:同一父节点下,这个列表项的身份是什么。 它不是传给子组件的普通 prop,子组件如果需要 id,仍然要显式传入 todo.id。
列表协调时,React 会尝试根据 key 和类型复用旧节点:
- key 相同、类型相同:保留组件实例和 DOM 节点,更新 props;
- 新 key 没有对应旧节点:插入新节点;
- 旧 key 不再出现:删除旧节点;
- key 仍存在但位置变化:尽量移动并复用节点,而不是重新创建。
为什么不推荐使用 index 作为 key?
当列表是静态的、只追加且顺序永不变化时,index 可能没有明显问题。但如果列表会插入、删除、排序或过滤,index 会随着位置变化,无法稳定表示数据身份:
例如列表头部插入一项后,原来的第一项会从 index 0 变成 1。React 可能复用错误的组件实例,导致输入框内容、动画状态或本地 state 跑到另一条数据上。
Key 不是全局唯一
Key 只需要在同一个直接父节点的兄弟节点中稳定且唯一:
两个不同列表可以使用相同的 key。Key 也不应随机生成,否则每次渲染都会被视为全新节点。
6. Key 也可以主动重置状态
Key 不只用于列表,也可以用来明确改变组件身份:
当 userId 变化时,ProfileEditor 会被视为新的组件实例,旧实例卸载,新的 state 从初始值开始。这比在 effect 中手动重置多个字段更直接,但也会同时丢弃该子树的本地状态。
7. 常见误区
Diff 会深度比较所有对象
不会。React element、props 和状态的处理有明确的引用与类型规则,不是对所有对象做通用深度比较。不要依赖深度比较来判断组件是否更新。
Virtual DOM 会把所有 DOM 重新生成
不会。Render 阶段会重新计算组件输出,但 Commit 阶段只提交必要变化;组件函数重新执行也不等于每个 DOM 节点都被销毁重建。
使用 key 就一定能优化性能
Key 的首要作用是表达身份和保证状态正确复用。稳定 key 通常也有利于减少无谓重建,但它不能替代组件拆分、列表虚拟化或性能分析。
Diff 能自动发现所有业务上的相同数据
不能。React 只根据节点类型、位置和 key 协调,不会根据对象内容猜测两个列表项是否是同一条业务数据。
总结
面试时可以这样回答:React 在 Render 阶段生成新的 element/Fiber 树,并基于类型、位置和 key 做启发式协调;Render 结果在 Commit 阶段才会应用到真实 DOM。Key 的核心不是“加速”,而是帮助 React 正确识别同级节点身份、复用状态和处理列表变化。

