Virtual DOM 和 Diff 算法

React 如何根据状态变化更新页面?

先记住一句话:组件重新渲染时,React 会生成新的 React element 树,在 Render 阶段与上一棵树协调,计算出需要提交的变化,最后在 Commit 阶段更新真实宿主环境。

这里常说的“Virtual DOM”是帮助理解的概念,不是一个独立的浏览器 DOM。更准确地说,JSX 会产生 React element,Fiber 则保存协调过程中的工作单元和更新信息。

1. 为什么需要 Virtual DOM?

直接手动操作 DOM,需要开发者自己维护状态与 DOM 之间的对应关系:哪里新增、哪里删除、哪些属性需要更新。React 采用声明式写法:

function Counter({ count }) {
  return <button className={count > 0 ? "active" : "idle"}>{count}</button>;
}

count 变化时,开发者只描述“新的 UI 应该是什么样”,React 负责协调新旧 UI 并提交必要的变化。

它带来的主要价值是:

  • 声明式:状态和 UI 的关系更清晰;
  • 可扩展:同一套组件模型可以映射到 DOM、React Native 等宿主环境;
  • 集中协调:React 可以统一处理更新优先级、组件状态和副作用边界。

不要简单理解成“Virtual DOM 一定比手写 DOM 快”。创建 element、执行组件和协调本身也有成本;性能优势来自减少不必要的宿主环境操作,以及让 UI 更新更容易组合和调度。

2. 一次更新经历什么?

可以把更新简化为两个阶段:

状态更新
Render:执行组件,生成并协调新的 Fiber 树
Commit:提交 DOM 变化并处理提交阶段工作

Render 阶段

  • 执行函数组件,计算新的 React element;
  • 对比新旧 Fiber,确定节点是复用、插入、移动还是删除;
  • 可以被中断、重做或放弃,因此不要在渲染函数中执行副作用。

Commit 阶段

  • 将确定的变化同步提交到宿主环境,例如 DOM;
  • 执行 ref 更新和布局相关的提交工作;
  • useEffect 通常在提交之后由 React 调度执行。

Render 阶段计算出的结果不会自动等于“每个 DOM 节点都重新创建”。只有真正需要变化的部分才会进入提交过程。

3. Diff 为什么通常不是 O(n³)?

如果对任意两棵树做最优的通用树编辑距离计算,复杂度会非常高。React 不追求所有情况下的最优解,而是利用 UI 的常见规律进行启发式协调:

  1. 主要比较同一层级的节点:React 不会为了寻找最优结果而在整棵树中跨层级匹配节点。
  2. 类型变化通常意味着新身份<div> 变成 <p>,或一个组件类型变成另一个组件类型时,React 通常会销毁旧子树并创建新子树。
  3. Key 用于识别同级列表项身份:同一个 key 加上相同类型,可以帮助 React 判断节点是否应该复用。

这些规则让常见场景下的协调接近 O(n),但不能把 O(n) 当成所有更新、所有情况下的绝对保证。

4. 单节点如何协调?

对于同一位置的单个节点,可以这样理解:

新旧节点情况典型处理
类型相同、key 相同复用已有节点,更新 props,并继续协调子节点
类型不同删除旧节点,创建新节点及其子树
新节点不存在删除旧节点
旧节点不存在创建新节点

例如:

// 同一个 input 节点,通常会复用 DOM 和组件状态
<input className={isError ? "error" : "normal"} />

// 类型变化,通常会产生不同的节点身份
isError ? <input /> : <textarea />;

对于组件,组件类型本身也是身份的一部分。把 UserCard 替换为 AdminCard,即使它们渲染出相似的 DOM,也不应默认认为是同一个组件实例。

5. 列表 Diff 与 Key

function TodoList({ todos }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

key 的作用是告诉 React:同一父节点下,这个列表项的身份是什么。 它不是传给子组件的普通 prop,子组件如果需要 id,仍然要显式传入 todo.id

列表协调时,React 会尝试根据 key 和类型复用旧节点:

  • key 相同、类型相同:保留组件实例和 DOM 节点,更新 props;
  • 新 key 没有对应旧节点:插入新节点;
  • 旧 key 不再出现:删除旧节点;
  • key 仍存在但位置变化:尽量移动并复用节点,而不是重新创建。

为什么不推荐使用 index 作为 key?

当列表是静态的、只追加且顺序永不变化时,index 可能没有明显问题。但如果列表会插入、删除、排序或过滤,index 会随着位置变化,无法稳定表示数据身份:

// ❌ 动态列表中容易造成状态错位
items.map((item, index) => <Item key={index} item={item} />);

// ✅ 使用数据本身稳定且唯一的 id
items.map((item) => <Item key={item.id} item={item} />);

例如列表头部插入一项后,原来的第一项会从 index 0 变成 1。React 可能复用错误的组件实例,导致输入框内容、动画状态或本地 state 跑到另一条数据上。

Key 不是全局唯一

Key 只需要在同一个直接父节点的兄弟节点中稳定且唯一:

<section>
  {leftItems.map((item) => <Item key={item.id} item={item} />)}
</section>
<section>
  {rightItems.map((item) => <Item key={item.id} item={item} />)}
</section>

两个不同列表可以使用相同的 key。Key 也不应随机生成,否则每次渲染都会被视为全新节点。

6. Key 也可以主动重置状态

Key 不只用于列表,也可以用来明确改变组件身份:

function App({ userId }) {
  return <ProfileEditor key={userId} userId={userId} />;
}

userId 变化时,ProfileEditor 会被视为新的组件实例,旧实例卸载,新的 state 从初始值开始。这比在 effect 中手动重置多个字段更直接,但也会同时丢弃该子树的本地状态。

7. 常见误区

Diff 会深度比较所有对象

不会。React element、props 和状态的处理有明确的引用与类型规则,不是对所有对象做通用深度比较。不要依赖深度比较来判断组件是否更新。

Virtual DOM 会把所有 DOM 重新生成

不会。Render 阶段会重新计算组件输出,但 Commit 阶段只提交必要变化;组件函数重新执行也不等于每个 DOM 节点都被销毁重建。

使用key 就一定能优化性能

Key 的首要作用是表达身份和保证状态正确复用。稳定 key 通常也有利于减少无谓重建,但它不能替代组件拆分、列表虚拟化或性能分析。

Diff 能自动发现所有业务上的相同数据

不能。React 只根据节点类型、位置和 key 协调,不会根据对象内容猜测两个列表项是否是同一条业务数据。

总结

概念作用
React element描述某次渲染结果的不可变对象
Fiber保存协调工作和组件更新信息的数据结构
Render计算并协调新的树,可能被中断或重做
Commit将必要变化提交到 DOM 等宿主环境
Diff / Reconciliation协调新旧树,决定复用、插入、移动或删除
Key标识同级列表项的稳定身份

面试时可以这样回答:React 在 Render 阶段生成新的 element/Fiber 树,并基于类型、位置和 key 做启发式协调;Render 结果在 Commit 阶段才会应用到真实 DOM。Key 的核心不是“加速”,而是帮助 React 正确识别同级节点身份、复用状态和处理列表变化。