【源码+图解】:Vue2、Vue3、React的Diff

2 阅读8分钟

先从一个最简单的问题开始:为什么需要 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 == AB == B

当前状态:

old: C   D   E   Fnew: 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:

  1. 只做一次线性遍历,O (n) 无额外计算开销,天然支持 Fiber 可中断
  2. lastPlacedIndex 是唯一标尺
    • 旧索引 >= 它 → 不动
    • 旧索引 < 它 → 必须移动
  3. 不追求全局最少 DOM 操作,只保证顺序安全
  4. 先复用、再新增、最后删除,逻辑极简、执行极快
  5. 完全适配异步渲染,是 React 架构的必然选择

四、三大框架 Diff 最终对比

  • Vue2:双端贪心

    四指针抢稳定区,乱序边遍历边移动,局部最优

  • Vue3:LIS 全局最优

    先分析、再不动、最后最少移动,DOM 操作最少

  • React:lastPlacedIndex 线性遍历

    不计算、不回溯、只保顺序,架构最优、可中断