深入理解 Reconciliation(Diff)算法
单节点 Diff、多节点 Diff、key 的作用机制、bailout 优化与性能陷阱
什么是 Reconciliation?
定义:Reconciliation(协调)是 React 将新的 React Element 树与当前 Fiber 树进行对比,找出需要更新的最小 DOM 操作集合的过程。核心就是 Diff 算法——对比新旧两棵树,标记哪些节点需要新增(Placement)、更新(Update)或删除(Deletion)。
涉及场景:
- 列表渲染:数组增删改排序时,Diff 决定如何最小化 DOM 操作
- 条件渲染:组件类型切换时,Diff 决定是复用还是重建
- 动态组件:key 变化触发组件卸载-重建
- 性能优化:理解 Diff 才能写出高性能的列表和条件渲染
作用:
- 最小化 DOM 操作:对比后只更新变化的部分,不重建整棵 DOM 树
- O(n) 复杂度:通过三条策略将树 Diff 从 O(n³) 降到 O(n)
- key 是核心:理解 key 的作用机制才能避免列表渲染的性能问题
- 面试高频:单节点/多节点 Diff 流程、key 的作用是必考题
一、Diff 的三条假设
传统树 Diff 算法的时间复杂度是 O(n³)(对比 + 最小编辑距离),React 通过三条假设将其降为 O(n):
- 同层比较:只比较同一层级的节点,不跨层移动
- 类型判断:不同类型的元素产生不同的树(直接替换整棵子树)
- 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 算法本身没有根本性变化,但相关优化包括:
- React Compiler:自动稳定 props 引用,更容易触发 bailout
- Server Components:服务端组件不参与客户端 Diff
- Activity(原 Offscreen):隐藏的组件保留 Fiber 树,切回时跳过完整 Diff
面试高频题
Q: React Diff 算法的时间复杂度是多少?为什么?
O(n)。通过三条假设(同层比较、类型不同则替换、key 标识节点身份)将通用树 Diff 的 O(n³) 降到 O(n)。牺牲了跨层移动识别的能力,但在实际 UI 场景中这种情况极少发生。
Q: 为什么不推荐用 index 作为 key?
- 列表项增删/排序时 index 会变化,导致 key 与实际数据不对应
- React 会错误复用组件,导致状态错乱(如输入框内容错位)
- 无法利用 key 进行高效的节点移动判断,退化为全量更新
Q: 多节点 Diff 的两轮遍历分别做什么?
- 第一轮:按顺序比较新旧节点,处理最常见的「内容更新、顺序不变」场景。遇到 key 不同则跳出
- 第二轮:将剩余旧节点存入 Map,遍历剩余新节点从 Map 中查找可复用节点,处理移动和新增。Map 中剩余的标记删除
Q: React Diff 中移动是怎么判断的?
使用 lastPlacedIndex 作为参考点。遍历新节点时,如果复用的旧节点的 oldIndex < lastPlacedIndex,说明该节点需要右移(标记 Placement)。否则不移动,并更新 lastPlacedIndex = oldIndex。