先从一个最简单的问题开始:为什么需要 Diff?
因为 DOM 操作很贵,但状态更新很频繁。框架要做的是:在“变化频繁”和“操作昂贵”之间找到一个平衡。
但这里有一个背景知识:两棵树的最优 Diff 是 O(n³)。所以所有框架都做了限制:只比较同一层 不跨层移动、用 key 标识节点,在这个前提下,Diff 才能降到 O(n)。
问题就变成了:在 O(n) 的前提下,如何尽可能减少 DOM 操作?Vue2、Vue3、React,给出了三种完全不同的答案。
一、Vue2 Diff 双端对比
源码位置:
src/core/vdom/patch.js
核心函数:
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
一组数据:
old: A B C D E F
new: A B D X F E
并标记旧节点位置:
A(0) B(1) C(2) D(3) E(4) F(5)
(1)初始化四指针
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newEndIdx = newCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
如图:
oldStart oldEnd
↓ ↓
old: A B C D E F
new: A B D X F E
↑ ↑
newStart newEnd
(2)核心 while 循环(Diff 主体)
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
核心就一件事:在 while 循环里,不断缩小区间
每一轮循环,Vue2会尝试这四种匹配:
① 旧头 vs 新头
② 旧尾 vs 新尾
③ 旧头 vs 新尾
④ 旧尾 vs 新头
为什么是这四种?因为:用户最常见的操作,其实就是“头尾增删”和“头尾移动”
(3)四种命中逻辑
if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(...)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(...)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 头 → 尾(右移)
patchVnode(...)
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 尾 → 头(左移)
patchVnode(...)
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
初始状态:
old: A B C D E F
↑ ↑
new: A B D X F E
↑ ↑
第一轮:
旧头 A vs 新头 A ✅
patchVnode(A, A)
oldStart++
newStart++
当前状态:
old: B C D E F
↑ ↑
new: B D X F E
↑ ↑
第二轮:
旧头 B vs 新头 B ✅
当前状态:
old: C D E F
↑ ↑
new: D X F E
↑ ↑
第三轮:
旧头 C vs 新头 D ❌
旧尾 F vs 新尾 E ❌
旧头 C vs 新尾 E ❌
旧尾 F vs 新头 D ❌
稳定区已经被完全消耗,呈现典型“中间乱序 + 插入”结构,四指针无法继续收敛,进入 key-based fallback。
(4)fallback:key map
if (!oldKeyToIdx) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
idxInOld = oldKeyToIdx[newStartVnode.key]
建立一个old的map:
C → 2
D → 3
E → 4
F → 5
(5)处理乱序
if (isUndef(idxInOld)) {
// 新增
createElm(...)
} else {
vnodeToMove = oldCh[idxInOld]
patchVnode(vnodeToMove, newStartVnode)
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
Step 1:处理 D(复用)
D 在 old 中存在(index = 3)
insertBefore(D, C)
DOM:
D C E F
Step 2:处理 X(新增)
X 不存在 → 创建
仍然插入到当前锚点 C 前:
D X C E F
Step 3:处理 F(复用)
F 在 old 中存在(index = 5)
插入到 C 前:
D X F C E
Step 4:处理 E(复用)
E 在 old 中存在(index = 4)
插入到 C 前:
D X F E C
(6)收尾阶段(补齐 + 删除)
① new 还有剩余 → 批量新增
if (newStartIdx <= newEndIdx) {
const before =
newCh[newEndIdx + 1] == null
? null
: newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
}
new: D X F E ✔ 已全部处理
所以这里不会执行
② old 还有剩余 → 删除
if (oldStartIdx <= oldEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
old: C(未被复用)
C 会被删除
删除后 DOM:
D X F E
二、Vue3 Diff LIS
Vue2 在乱序区间采用的是贪心策略:一边遍历一边移动节点,谁先匹配就先操作 DOM。这种方式只关注当前是否可复用,没有全局视角,容易产生多余移动,本质是“过程上的局部最优”。
而 Vue3 则先对结构做一次整体分析,通过映射和 LIS 找出哪些节点可以完全不动,再对剩余节点做最少移动。它优化的是最终结果,在全局范围内减少 DOM 操作,本质是“结果上的全局最优”。
源码位置:
packages/runtime-core/src/renderer.ts
核心函数:
function patchKeyedChildren(c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized)
old: A B C D E F
new: A B D X F E
(1)前置处理:头尾同步(和 Vue2 一样)
// sync from start
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[i], c2[i])) {
patch(...)
} else {
break
}
i++
}
// sync from end
while (i <= e1 && i <= e2) {
if (isSameVNodeType(c1[e1], c2[e2])) {
patch(...)
} else {
break
}
e1--
e2--
}
执行过程:
old: A B C D E F
new: A B D X F E
头部同步:
A == A ✅
B == B ✅
指针变化:
i = 2
尾部同步:
F != E ❌ → 停止
当前区间:
old: C D E F (2 → 5)
new: D X F E (2 → 5)
(2)构建 key → index(new)
const keyToNewIndexMap = new Map()
for (let j = s2; j <= e2; j++) {
keyToNewIndexMap.set(c2[j].key, j)
}
得到:
D → 2
X → 3
F → 4
E → 5
(3)遍历 old,建立映射关系
const toBePatched = e2 - s2 + 1
const newIndexToOldIndexMap = new Array(toBePatched).fill(0)
for (let i = s1; i <= e1; i++) {
const oldVNode = c1[i]
const newIndex = keyToNewIndexMap.get(oldVNode.key)
if (newIndex === undefined) {
// 删除
unmount(oldVNode)
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1
patch(oldVNode, c2[newIndex])
}
}
构造数组(关键!):
new: D X F E
index: 0 1 2 3
newIndexToOldIndexMap: 4 0 6 5
解释:
D → old index 3 → +1 = 4
X → 新节点 → 0
F → old index 5 → +1 = 6
E → old index 4 → +1 = 5
为什么 +1?因为:0 被用来表示“新增节点”
(4)计算 LIS(最长递增子序列)
目标:找出“不需要移动”的节点
序列:
[4, 0, 6, 5] → 去掉 0 → [4, 6, 5]
LIS:
[4, 6] → 对应 D、F
(5)倒序遍历 + DOM 操作
源码:
for (let i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex]
const anchor =
nextIndex + 1 < l2
? c2[nextIndex + 1].el
: parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 新增
patch(null, nextChild, container, anchor)
} else {
if (i !== increasingNewIndexSequence[j]) {
// 移动
move(nextChild, container, anchor)
} else {
j--
}
}
}
开始执行(倒序!):
new: D X F E
idx: 0 1 2 3
Step 1:E(i = 3)
newIndexToOldIndexMap[3] = 5 ≠ 0
不在 LIS → 需要移动
锚点:
anchor = null(最后一个)
操作:
insert(E)
DOM:
C D F E
Step 2:F(i = 2)
在 LIS ✅ → 不动
Step 3:X(i = 1)
= 0 → 新节点
锚点:
anchor = F
操作:
insert(X before F)
DOM:
C D X F E
Step 4:D(i = 0)
在 LIS ✅ → 不动
(6)最终收尾:删除旧节点
在步骤(3)中:
if (newIndex === undefined) {
unmount(oldVNode)
}
C → 被删除
最终 DOM:
D X F E
三、React Diff Fiber
如果说 Vue2 和 Vue3 的演进,是从“边遍历边移动”的贪心策略,升级为“先整体分析再最少移动”的全局优化,那么 React 则走了一条完全不同的路径。
它并没有像 Vue3 一样引入最长递增子序列(LIS)去追求“最少 DOM 操作”,而是选择在一次线性遍历中,通过维护一个 lastPlacedIndex 来判断节点是否需要移动。也就是说,React 关心的不是“全局最优解”,而是“在顺序不被破坏的前提下,尽量少动”。
这种策略让 React 在复杂度上始终保持 O(n),同时避免了额外的序列计算开销,也更契合 Fiber 架构下可中断、可恢复的更新模型。
源码位置:
packages/react-reconciler/src/ReactChildFiber.new.js
核心函数:
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes)
同样用这组数据:
old: A B C D E F
new: A B D X F E
(1)第一轮:从左到右线性扫描(和 Vue 一样先吃稳定区)
源码:
while (oldFiber !== null && newIdx < newChildren.length) {
if (sameNode(oldFiber, newChildren[newIdx])) {
useFiber(...)
} else {
break
}
oldFiber = oldFiber.sibling
newIdx++
}
执行:
A == A ✅
B == B ✅
当前状态:
old: C D E F
↑
new: D X F E
↑
到这里 React 停止“顺序复用”
(2)构建 old 的 Map(剩余节点)
源码:
const existingChildren = mapRemainingChildren(returnFiber, oldFiber)
得到:
C → index 2
D → index 3
E → index 4
F → index 5
(3)第二轮:遍历 new(核心 Diff)
源码:
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx]
)
}
一边遍历 new,一边决定:复用 / 新建 / 是否移动
引入一个关键变量:
let lastPlacedIndex = 0
含义:当前“已经放好”的最大 index
开始执行:
new: D X F E
idx: 0 1 2 3
Step 1:D
D 在 old 中 → index = 3
判断:
if (oldIndex < lastPlacedIndex) → 需要移动
此时:
3 >= 0 → 不移动
更新:
lastPlacedIndex = 3
DOM(逻辑上):
C D E F
D 被复用,但没动
Step 2:X
不存在 → 新建
React 行为:
创建 Fiber(标记 Placement)
DOM(逻辑):
C D X E F
Step 3:F
F 在 old 中 → index = 5
判断:
5 >= lastPlacedIndex(3) → 不移动
更新:
lastPlacedIndex = 5
F 也不动
Step 4:E
E 在 old 中 → index = 4
判断:
4 < lastPlacedIndex(5) → 需要移动 ⚠️
标记:
Placement(需要移动)
DOM(最终阶段会变):
C D X F E
(4)删除旧节点
源码:
existingChildren.forEach(child => deleteChild(returnFiber, child))
剩余:
C(未被复用)→ 删除
最终 DOM:
D X F E
react的diff:
- 只做一次线性遍历,O (n) 无额外计算开销,天然支持 Fiber 可中断
- lastPlacedIndex 是唯一标尺
- 旧索引 >= 它 → 不动
- 旧索引 < 它 → 必须移动
- 不追求全局最少 DOM 操作,只保证顺序安全
- 先复用、再新增、最后删除,逻辑极简、执行极快
- 完全适配异步渲染,是 React 架构的必然选择
四、三大框架 Diff 最终对比
-
Vue2:双端贪心
四指针抢稳定区,乱序边遍历边移动,局部最优
-
Vue3:LIS 全局最优
先分析、再不动、最后最少移动,DOM 操作最少
-
React:lastPlacedIndex 线性遍历
不计算、不回溯、只保顺序,架构最优、可中断