Skip to content

深入理解 Reconciliation(Diff)算法

单节点 Diff、多节点 Diff、key 的作用机制、bailout 优化与性能陷阱

什么是 Reconciliation?

定义:Reconciliation(协调)是 React 将新的 React Element 树与当前 Fiber 树进行对比,找出需要更新的最小 DOM 操作集合的过程。核心就是 Diff 算法——对比新旧两棵树,标记哪些节点需要新增(Placement)、更新(Update)或删除(Deletion)。

涉及场景

  • 列表渲染:数组增删改排序时,Diff 决定如何最小化 DOM 操作
  • 条件渲染:组件类型切换时,Diff 决定是复用还是重建
  • 动态组件:key 变化触发组件卸载-重建
  • 性能优化:理解 Diff 才能写出高性能的列表和条件渲染

作用

  1. 最小化 DOM 操作:对比后只更新变化的部分,不重建整棵 DOM 树
  2. O(n) 复杂度:通过三条策略将树 Diff 从 O(n³) 降到 O(n)
  3. key 是核心:理解 key 的作用机制才能避免列表渲染的性能问题
  4. 面试高频:单节点/多节点 Diff 流程、key 的作用是必考题

一、Diff 的三条假设

传统树 Diff 算法的时间复杂度是 O(n³)(对比 + 最小编辑距离),React 通过三条假设将其降为 O(n)

  1. 同层比较:只比较同一层级的节点,不跨层移动
  2. 类型判断:不同类型的元素产生不同的树(直接替换整棵子树)
  3. key 标识:通过 key 标识同一层级中哪些元素是"同一个"
假设1:跨层移动极少发生
      A                    A
     / \      →           / \
    B   C                B   D
   /                    /
  D                    C
                      /
                     D  ← 不会识别为"D从B下移到C下"
                         而是删除旧D + 创建新D

二、单节点 Diff

当新的 children 是单个元素(非数组)时,进入单节点 Diff。

javascript
// reconcileSingleElement 简化逻辑
function reconcileSingleElement(returnFiber, currentFirstChild, element) {
  const key = element.key;
  let child = currentFirstChild;

  // 遍历旧的子节点链表
  while (child !== null) {
    if (child.key === key) {
      // key 相同
      if (child.elementType === element.type) {
        // key 相同 且 type 相同 → 复用该节点
        deleteRemainingChildren(returnFiber, child.sibling); // 删除多余兄弟
        const existing = useFiber(child, element.props);     // 复用
        existing.return = returnFiber;
        return existing;
      } else {
        // key 相同 但 type 不同 → 不可能有其他匹配了,全部删除
        deleteRemainingChildren(returnFiber, child);
        break;
      }
    } else {
      // key 不同 → 标记删除这个旧节点,继续查找
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 没有可复用节点,创建新的
  const created = createFiberFromElement(element);
  created.return = returnFiber;
  return created;
}

判断流程图

开始遍历旧子节点

key 相同?
  ├── 是 → type 相同?
  │         ├── 是 → ✅ 复用节点(删除剩余兄弟)
  │         └── 否 → ❌ 删除所有旧节点,创建新节点
  └── 否 → ❌ 删除当前旧节点,继续遍历下一个兄弟

遍历完毕仍未匹配 → 创建新节点

示例

jsx
// 场景1:类型相同,复用
// 旧:<div className="old" />
// 新:<div className="new" />
// → 复用 div 节点,更新 className 属性

// 场景2:类型不同,重建
// 旧:<div>hello</div>
// 新:<p>hello</p>
// → 删除 div,创建 p(包括所有子节点)

// 场景3:key 触发重建
// 旧:<input key="a" />
// 新:<input key="b" />
// → key 不同,删除旧 input,创建新 input(状态清空)

三、多节点 Diff

当新的 children 是数组时,进入多节点 Diff。这是最复杂的部分。

React 的多节点 Diff 分为两轮遍历

第一轮:处理更新(最常见的场景)

javascript
// 假设:大多数情况下列表只是内容更新,顺序不变
// 所以先按顺序比较,尽可能多地复用

let oldFiber = currentFirstChild; // 旧子节点链表头
let newIdx = 0;                    // 新子节点数组索引

for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
  // 注意处理 oldFiber 位置
  if (oldFiber.index > newIdx) {
    // 旧节点位置超过当前新节点,说明有间隙
    nextOldFiber = oldFiber;
    oldFiber = null;
  } else {
    nextOldFiber = oldFiber.sibling;
  }

  // 尝试复用
  const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);

  if (newFiber === null) {
    // key 不同,无法复用 → 跳出第一轮
    break;
  }

  // 标记更新或插入
  placeChild(newFiber, lastPlacedIndex, newIdx);
  oldFiber = nextOldFiber;
}

第一轮结束的三种情况

情况1:新旧都遍历完 → 完美匹配,Diff 结束
  旧:A - B - C
  新:A'- B'- C'   (全部复用,更新 props)

情况2:新遍历完,旧还有 → 删除剩余旧节点
  旧:A - B - C - D
  新:A'- B'         (删除 C、D)

情况3:旧遍历完 或 key不匹配提前跳出 → 进入第二轮
  旧:A - B
  新:A'- B'- C - D   (新增 C、D)

  旧:A - B - C
  新:A'- C'- B'       (发生了移动)

第二轮:处理移动、新增、删除

javascript
// 将剩余旧节点放入 Map(key → Fiber)
const existingChildren = mapRemainingChildren(oldFiber);
// { 'keyB': FiberB, 'keyC': FiberC, ... }

// 遍历剩余新节点
for (; newIdx < newChildren.length; newIdx++) {
  // 从 Map 中查找可复用节点
  const newFiber = updateFromMap(existingChildren, newChildren[newIdx], newIdx);

  if (newFiber !== null) {
    if (newFiber.alternate !== null) {
      // 已找到复用节点,从 Map 中移除
      existingChildren.delete(newFiber.key ?? newIdx);
    }
    // 判断是否需要移动
    lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
  }
}

// Map 中剩余的旧节点全部标记删除
existingChildren.forEach(child => deleteChild(returnFiber, child));

移动判断逻辑(placeChild)

javascript
function placeChild(newFiber, lastPlacedIndex, newIndex) {
  newFiber.index = newIndex;

  const current = newFiber.alternate;
  if (current !== null) {
    const oldIndex = current.index;
    if (oldIndex < lastPlacedIndex) {
      // 旧位置在参考点左边 → 需要右移
      newFiber.flags |= Placement;
      return lastPlacedIndex; // 参考点不变
    } else {
      // 旧位置在参考点右边 → 不需要移动
      return oldIndex; // 更新参考点
    }
  } else {
    // 新节点,标记插入
    newFiber.flags |= Placement;
    return lastPlacedIndex;
  }
}

移动示例详解

旧列表:A(0) - B(1) - C(2) - D(3)
新列表:A(0) - C(1) - D(2) - B(3)

第一轮遍历:
  A-A: key相同,type相同 → 复用,lastPlacedIndex = 0
  B-C: key不同 → 跳出第一轮

第二轮遍历:
  将剩余旧节点放入 Map: { B: 1, C: 2, D: 3 }

  处理 C: 在Map中找到 C(oldIndex=2)
    oldIndex(2) >= lastPlacedIndex(0) → 不移动
    lastPlacedIndex = 2

  处理 D: 在Map中找到 D(oldIndex=3)
    oldIndex(3) >= lastPlacedIndex(2) → 不移动
    lastPlacedIndex = 3

  处理 B: 在Map中找到 B(oldIndex=1)
    oldIndex(1) < lastPlacedIndex(3) → 需要移动!标记 Placement
    lastPlacedIndex = 3(不变)

结果:只需要把 B 移动到最后
DOM 操作:insertBefore(B, null) → B 移到末尾

四、key 的深入理解

key 的作用

jsx
// 没有 key:React 按索引匹配
// 旧:[<li>苹果</li>, <li>香蕉</li>, <li>橙子</li>]
// 新:[<li>葡萄</li>, <li>苹果</li>, <li>香蕉</li>, <li>橙子</li>]
// React 的理解:3个更新(内容变了) + 1个新增
// DOM 操作:更新3个 li 的文本 + 插入1个 li = 4次操作

// 有 key:React 按 key 匹配
// 旧:[<li key="apple">苹果</li>, <li key="banana">香蕉</li>, <li key="orange">橙子</li>]
// 新:[<li key="grape">葡萄</li>, <li key="apple">苹果</li>, ...]
// React 的理解:3个不动 + 1个新增
// DOM 操作:插入1个 li = 1次操作

index 作为 key 的问题

jsx
// ❌ 列表头部插入时 index key 导致全部重渲染
function List() {
  const [items, setItems] = useState(['B', 'C']);

  const addToFront = () => setItems(['A', ...items]);

  return items.map((item, index) => (
    // key=0 从 B 变成了 A → React 认为是「更新」而非「插入」
    // 所有 input 的状态会错位
    <div key={index}>
      <span>{item}</span>
      <input />
    </div>
  ));
}

// ✅ 正确:使用稳定唯一 ID
items.map(item => <div key={item.id}>...</div>)

key 触发组件重置

jsx
// 利用 key 强制重建组件(重置状态)
function App() {
  const [userId, setUserId] = useState(1);

  return (
    // userId 变化 → key 变化 → Profile 组件完全卸载重建
    // 内部状态、effect 全部重置
    <Profile key={userId} userId={userId} />
  );
}

五、Diff 的性能陷阱与优化

陷阱1:不稳定的 key

jsx
// ❌ 每次渲染生成新 key → 每次都重建组件
items.map(item => <Card key={Math.random()} data={item} />)
items.map(item => <Card key={crypto.randomUUID()} data={item} />)

// ✅ 使用稳定的标识
items.map(item => <Card key={item.id} data={item} />)

陷阱2:跨类型切换

jsx
// ❌ 条件渲染导致类型切换 → 整棵子树重建
function Page({ isAdmin }) {
  if (isAdmin) {
    return <AdminPanel />;  // 位置0是 AdminPanel
  }
  return <UserPanel />;      // 位置0是 UserPanel → 类型不同,重建
}

// 如果两个面板结构相似,可以用 key 或同一组件 + props 控制

陷阱3:深层嵌套变化

jsx
// ❌ 组件层级变化 → 无法复用
// 旧
<div>
  <Card />
</div>

// 新(多了一层 wrapper)
<div>
  <section>
    <Card />    {/* 不同层级,Card 被重建 */}
  </section>
</div>

优化:列表尾部操作最高效

React 的多节点 Diff 按照从左到右的顺序比较,
所以:
  ✅ 尾部追加:只有 Placement(插入),无移动
  ⚠️ 头部插入:可能导致后续节点全部标记移动
  ⚠️ 整体反转:移动操作最多

设计列表操作时,尽量使用 push 而非 unshift

六、React 19 的 Diff 变化

React 19 在 Diff 算法本身没有根本性变化,但相关优化包括:

  1. React Compiler:自动稳定 props 引用,更容易触发 bailout
  2. Server Components:服务端组件不参与客户端 Diff
  3. Activity(原 Offscreen):隐藏的组件保留 Fiber 树,切回时跳过完整 Diff

面试高频题

Q: React Diff 算法的时间复杂度是多少?为什么?

O(n)。通过三条假设(同层比较、类型不同则替换、key 标识节点身份)将通用树 Diff 的 O(n³) 降到 O(n)。牺牲了跨层移动识别的能力,但在实际 UI 场景中这种情况极少发生。

Q: 为什么不推荐用 index 作为 key?

  1. 列表项增删/排序时 index 会变化,导致 key 与实际数据不对应
  2. React 会错误复用组件,导致状态错乱(如输入框内容错位)
  3. 无法利用 key 进行高效的节点移动判断,退化为全量更新

Q: 多节点 Diff 的两轮遍历分别做什么?

  • 第一轮:按顺序比较新旧节点,处理最常见的「内容更新、顺序不变」场景。遇到 key 不同则跳出
  • 第二轮:将剩余旧节点存入 Map,遍历剩余新节点从 Map 中查找可复用节点,处理移动和新增。Map 中剩余的标记删除

Q: React Diff 中移动是怎么判断的?

使用 lastPlacedIndex 作为参考点。遍历新节点时,如果复用的旧节点的 oldIndex < lastPlacedIndex,说明该节点需要右移(标记 Placement)。否则不移动,并更新 lastPlacedIndex = oldIndex