Vue 3 核心源码深度剖析系列(v3.5.44)-第 12 章 运行时 diff 与最长递增子序列(LIS)

0 阅读1小时+

Vue 3 核心源码深度剖析系列(v3.5.44)-第 12 章 运行时 diff 与最长递增子序列(LIS)

本章对应 packages/runtime-core/src/renderer.ts(2517 行)中的 patchChildren(1672–1772)、patchUnkeyedChildren(1774–1831)、patchKeyedChildren(1834–2076)、move(2078–2169)、unmountChildren(2430–2441)、getSequence(2608–2647),以及 packages/runtime-core/src/vnode.ts 中的 isSameVNodeType(379–392)、normalizeVNode(806–826)、cloneIfMounted(829–834),packages/shared/src/patchFlags.ts(1–144),packages/compiler-core/src/transforms/vFor.ts(101–180)。所有行号对应 vuejs/core@3.5.44。

读完本章你将能回答:patchChildren 为什么要用"四象限 + patchFlag 短路"两层分派?无 key 的 patchUnkeyedChildren 为什么只做逐位 patch?patchKeyedChildren 的"五步走"每一步到底在动哪些指针?newIndexToOldIndexMap 里的 0 为什么是一条"新增哨兵"?getSequence 如何用"贪心 + 二分 + 前驱回溯"在 O(n log n) 内求出最长递增子序列,且这个序列为什么正好等价于"最小移动集合"?以及那个最容易被追问的边界:为什么移动阶段必须从后往前遍历、anchor 为什么要取后一个节点?


导语:diff 是"把旧 DOM 变成新 DOM"的最短路问题

一句话概括本章:渲染器的 diff 本质上是一个带约束的最优化问题——在"尽量少碰 DOM"的前提下,把旧的孩子序列改造成新的孩子序列;Vue 3 的做法是先用双指针剥掉头尾不变的部分,再对剩下那个"乱序中间段"求一个最长递增子序列,把它当作"不用挪动"的锚点集合。

这个描述里藏着三个层次:

  1. 操作成本模型——为什么要"尽量少碰 DOM"。DOM 是跨进程/跨语言边界的对象(浏览器内核 C++ 与 JS 引擎),一次 insertBefore 的实际代价远超一次 JS 数组比较。
  2. 分派策略——patchChildren 先用 patchFlag 做 O(1) 短路,再用 shapeFlag 把 16 种新旧组合收敛成 4 条路径。这是"编译期信息减少运行期工作量"的典型。
  3. 核心算法——patchKeyedChildren 五步走里的第 5 步:keyToNewIndexMap 建立映射、newIndexToOldIndexMap 记录新旧关系、getSequence 求 LIS、倒序移动。

本章结构:12.1 讲为什么需要 diff;12.2 讲 patchChildren 的四象限分派;12.3 讲无 key 的 patchUnkeyedChildren;12.4 讲 keyed diff 五步走总览;12.5 讲步骤①②(前后缀同步);12.6 讲步骤③④(多退少补);12.7 讲步骤⑤乱序段的建图与计数;12.8 逐行走读 getSequence 并给出证明思路;12.9 讲结合 LIS 的移动阶段;12.10 用 6 个案例手工推演指针;12.11 讲 Fragment 与多根节点;12.12 与 Vue 2 双端 diff 对比;12.13 给可复现 benchmark;12.14 回顾。


12.1 为什么需要 diff

12.1.1 DOM 操作成本模型

要理解 diff 的设计动机,必须先建立"操作成本"的直觉。浏览器渲染管线大致是:

JS 修改 DOM → 触发 Style 计算 → Layout(重排) → Paint(重绘) → Composite(合成) → GPU

其中真正昂贵的是 Layout 与 Paint。一次 element.insertBefore(newNode, refNode) 本身是 O(1) 的树指针操作,但它会让浏览器把该节点标脏,进而可能触发整棵子树的 layout。因此 diff 的目标从来不是"减少 JS 比较次数",而是减少真正产生的 DOM 变更条数(尤其是会造成重排的操作:插入、删除、移动)。

一个经验数字:在 1 万条目的列表里做一次全量重建(先清空再逐个 appendChild),会触发约 1 万次插入 + 1 万次删除;而一次正确的 keyed diff 在"只有一项变化"时只产生 1 次插入。两者的 difference 不是常数级,而是"O(n) vs O(1) 的 DOM 操作量"。

把成本拆开看:

操作JS 层代价触发 Layout 概率备注
数组索引比较~1ns无diff 的主要工作
修改文本 textContent低视情况可能只重绘该文本节点
设置属性/class中class/style 可能重排属性不一定重排
insertBefore 移动已有节点低高(移动即引发 layout)本章重点优化对象
removeChild低高卸载
创建/挂载新节点高(含创建成本)高新增项不可避免

所以 diff 的"性能账本"可以写成一行公式:

总代价 ≈ Σ(不可避免的挂载/卸载) + Σ(可避免的移动) + Σ(属性/文本更新)

其中第一项由数据本身决定,第三项由 patchFlag 决定,只有第二项(移动次数)是 diff 算法可以优化的空间——这正是 LIS 存在的意义。

12.1.2 全量重渲染 vs 最小化更新

最朴素的做法是"全量重渲染":每次数据变化就把整个列表 DOM 清空重建。

// 伪代码:全量重建
container.innerHTML = ''
newList.forEach(item => container.appendChild(renderItem(item)))

它的优点是实现简单、永远正确。缺点是:即使只有一项变化,也产生 2n 次 DOM 操作,而且会导致输入框失焦、动画重置、滚动位置丢失等副作用。diff 的核心价值就是:在保持"声明式正确性"的同时,把 DOM 操作量压到与"真实变化量"成正比。

"最小化更新"可以形式化为:给定旧序列 O 与新序列 N(元素按 key 一一对应),求一个操作集合(挂载新增、卸载删除、移动复用),使得操作数最少。这个问题与最长公共子序列(LCS)/ 最长递增子序列(LIS) 高度相关:如果新旧序列存在一个长度为 k 的递增公共子序列,那么至少 n−k 个元素需要移动(因为只有这 k 个可以"保持相对顺序不动")。Vue 3 的做法是:先建立"新索引 → 旧索引"的映射数组,数组值随新索引单调递增的部分即为"天然有序、无需移动"的部分,再用 LIS 找出它。

12.1.3 Vue 2 的双端 diff 及其局限

Vue 2 的 updateChildren 使用双端比较:维护 oldStartIdx / oldEndIdx / newStartIdx / newEndIdx 四个指针,每一轮做最多 4 次两两比较(旧头/旧尾 与 新头/新尾 的 4 种组合),命中则复用并移动对应指针;全部未命中则用"新头在旧数组中的索引"在旧数组中查找,命中则 move 到 oldStart 前,否则新建。

它的复杂度:

  • 时间复杂度:最坏 O(n²)("新头在旧数组中索引"那一步在旧数组里线性查找)。
  • 移动次数:双端比较是"局部最优"的贪心,遇到"完全乱序"时会退化成大量移动。
  • 稳定性:对于"把一项从末尾挪到开头"这类操作,双端 diff 表现尚可;但对"整体反转 + 少量新增"这种混合乱序,移动次数明显偏多。

Vue 3 放弃了双端比较,改用**"先剥头尾、再对中间乱序段用 LIS 求最小移动集"**。这个改变带来两个好处:

  1. 移动次数下限:对中间段,LIS 给出的就是"理论最少移动数"(详见 12.8 的证明)。
  2. 建图后 O(n) 扫描:用 Map<key, index> 替代线性查找,把"查找旧位置"这一步从 O(n) 降到 O(1)。

12.1.4 Vue 3 的取舍:为什么是"五步走"

Vue 3 把 keyed diff 拆成显式的五步,而不是像 Vue 2 那样塞进一个大 while 循环:

1. sync from start    —— 从头部同步前缀(相同则 patch,i++)
2. sync from end      —— 从尾部同步后缀(相同则 patch,e1--, e2--)
3. common sequence + mount   —— 新多则挂载
4. common sequence + unmount —— 旧多则卸载
5. unknown sequence  —— 乱序中间段(建图 + LIS + 倒序移动)

这样拆分的动机是可读性与可优化性:

  • 步骤 1、2 覆盖了最常见的"头部/尾部追加删除"场景,代价 O(k),且完全不做任何移动。
  • 步骤 3、4 是纯挂载/卸载,不涉及移动,代码极简。
  • 只有真正需要对中间段排序时,才付出"建 Map + 求 LIS"的成本。

这与 React 的 diff 思路形成对比:React 的 reconcileChildren(旧版)用"双指针 + lastPlacedIndex"处理 keyed 列表,本质仍是贪心;Snabbdom 则在双端比较之外维护 oldKeyToIdx 映射。Vue 3 的独特之处是把"求最小移动集"显式交给 LIS。


12.2 patchChildren 的四象限分派

12.2.1 函数签名与调用点

patchChildren 的定义在 renderer.ts:1672-1772:

// packages/runtime-core/src/renderer.ts:1672
const patchChildren: PatchChildrenFn = (
  n1, n2, container, anchor,
  parentComponent, parentSuspense,
  namespace: ElementNamespace, slotScopeIds,
  optimized = false,
) => {
  const c1 = n1 && n1.children
  const prevShapeFlag = n1 ? n1.shapeFlag : 0
  const c2 = n2.children
  const { patchFlag, shapeFlag } = n2
  // ...
}

它被两类父节点调用:

  • patchElement(renderer.ts:913-923)走"非 block 优化"分支时,对元素子节点做全量 diff;走 block 优化时调用的是 patchBlockChildren(另一个函数,只遍历 dynamicChildren)。
  • processFragment(renderer.ts:1170-1180)走"keyed/unkeyed/manual fragment"分支时调用它。

三个入参含义:

  • c1 = n1.children:旧孩子的原始形态,可能是字符串(TEXT_CHILDREN)或 VNode[](ARRAY_CHILDREN)。
  • c2 = n2.children:新孩子。
  • prevShapeFlag:旧 vnode 的 shapeFlag;如果 n1 == null(首次 patch 不该走到这里,但防御式取 0)。

12.2.2 第一层分派:patchFlag 快路径

// packages/runtime-core/src/renderer.ts:1688-1720
// fast path
if (patchFlag > 0) {
  if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
    // this could be either fully-keyed or mixed (some keyed some not)
    // presence of patchFlag means children are guaranteed to be arrays
    patchKeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, ...)
    return
  } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
    patchUnkeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, ...)
    return
  }
}

这段代码是"编译期信息换运行期 O(1)"的第一例。编译器在 vFor.ts:101-108 生成 v-for 的 Fragment 时,会计算 fragmentFlag:

// packages/compiler-core/src/transforms/vFor.ts:101-108
const isStableFragment =
  forNode.source.type === NodeTypes.SIMPLE_EXPRESSION &&
  forNode.source.constType > ConstantTypes.NOT_CONSTANT
const fragmentFlag = isStableFragment
  ? PatchFlags.STABLE_FRAGMENT
  : keyProp
    ? PatchFlags.KEYED_FRAGMENT
    : PatchFlags.UNKEYED_FRAGMENT

含义:

  • isStableFragment 的条件是"源为 SIMPLE_EXPRESSION 且 constType > NOT_CONSTANT",即源是一个常量表达式(如 v-for="i in 3",或对常量数组的遍历)。常量源意味着渲染出的子节点个数与顺序都不会变,只需逐项 patch,不需要任何结构性的 move/unmount——记为 STABLE_FRAGMENT(vFor.ts:104-105)。
  • 若源是运行时变量(如 v-for="item in list",constType 为 NOT_CONSTANT):顺序可能变化,需要真正的结构 diff。此时有 :key 记 KEYED_FRAGMENT,无 :key 记 UNKEYED_FRAGMENT。
  • 注意 ConstantTypes 的取值随"越常量越大",NOT_CONSTANT 是最小值,故 > NOT_CONSTANT 等价于"是常量"。

因此运行时只要 patchFlag & KEYED_FRAGMENT 为真,就能跳过"判断 children 是 text 还是 array"这一步,因为 keyed fragment 一定由编译器生成数组孩子。

对应 packages/shared/src/patchFlags.ts:66-79:

STABLE_FRAGMENT = 1 << 6,  // 1 << 6 = 64
KEYED_FRAGMENT  = 1 << 7,  // 128
UNKEYED_FRAGMENT = 1 << 8, // 256

注意它们是互斥的——一个 Fragment 的 patchFlag 只会命中其中一个。这也解释了 STABLE_FRAGMENT 为什么不出现在这个 if 里:stable fragment 走的是 processFragment 里的 patchBlockChildren 分支(renderer.ts:1132-1164),根本不会进 patchChildren。

12.2.3 第二层分派:shapeFlag 的 16 种组合收敛

如果没有 patchFlag(用户手写 render 函数、或被 BAIL 降级),就退回到经典的 shapeFlag 分派。新旧 children 各有 3 种形态:text、array、空。理论上 3×3=9 种组合(任务描述称"16 种"是指把"空/array"再细分后的枚举视角,这里以源码为准收敛为 4 条路径)。

源码逻辑用两个 if 表达了全部组合,renderer.ts:1722-1771:

// children has 3 possibilities: text, array or no children.
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
  // text children fast path
  if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
  }
  if (c2 !== c1) {
    hostSetElementText(container, c2 as string)
  }
} else {
  if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // two arrays, cannot assume anything, do full diff
      patchKeyedChildren(c1 as VNode[], c2 as VNodeArrayChildren, ...)
    } else {
      // no new children, just unmount old
      unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
    }
  } else {
    // prev children was text OR null
    // new children is array OR null
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      hostSetElementText(container, '')
    }
    // mount new if array
    if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      mountChildren(c2 as VNodeArrayChildren, ...)
    }
  }
}

把它翻译成决策表:

旧 \ 新text (TEXT_CHILDREN)array (ARRAY_CHILDREN)空
texthostSetElementText(c2)清空文本→mountChildren(c2)hostSetElementText(''),c2===c1 时跳过
arrayunmountChildren(c1) → hostSetElementText(c2)patchKeyedChildren(c1,c2) 全量 keyed diffunmountChildren(c1, ..., true)
空hostSetElementText(c2)mountChildren(c2)无操作

几点细节值得注意:

  1. text→text 的 c2 !== c1 判断(renderer.ts:1728):字符串是原始值比较,只有内容变了才写 DOM。这是"文本子节点"的极小优化。
  2. array→text:先卸载所有旧元素子节点,再设置文本。不能直接 setElementText,否则旧子节点的组件卸载副作用(onUnmounted)会丢失。
  3. array→array:注释写着 "two arrays, cannot assume anything, do full diff"(renderer.ts:1735)。即使两侧都是数组,也不知道它们是 keyed 还是 unkeyed,所以统一调用 patchKeyedChildren——这个函数名字虽然叫 keyed,但它内部对"无 key 节点"也做了兜底(见 12.6 的 key-less 分支),因此可以处理混合场景。
  4. mountChildren 的 anchor 是 anchor 而非 null:插入点要落在正确的位置,这个 anchor 来自 patchChildren 的调用方(元素场景为 null,Fragment 场景为 fragmentEndAnchor)。
  5. unmount 的 doRemove=true:array→空 走 unmountChildren(..., true),即真的从 DOM 移除,而不是只标记。

12.2.4 为什么"四象限"能覆盖"16 种组合"

把 shapeFlag 的位拆开看,一个 vnode 的 shapeFlag 关于 children 的位有两个:TEXT_CHILDREN = 1 << 3(shapeFlags.ts)与 ARRAY_CHILDREN = 1 << 4。二者至多命中一个,都不命中即为"空"。所以 children 形态是 3 值,新旧组合 9 种。但源码用"先看新,再看旧"的嵌套 if 实际收敛为:

  • 新是 text:1 条分支(内部再分旧是 array 否)。
  • 新是 array:旧是 array → diff;旧非 array → mount。
  • 新是空:旧是 array → unmount;旧是 text → 清空;旧是空 → 无操作。

一共 6 个终态,其中 2 个终态复用了 patchKeyedChildren / mountChildren,因此真正独立的执行路径是 4 条(diff、mount、unmount、setText)。这就是"16 种组合收敛"的准确说法。


12.3 patchUnkeyedChildren:无 key 的逐位比较

12.3.1 源码逐行

// packages/runtime-core/src/renderer.ts:1774-1831
const patchUnkeyedChildren = (
  c1: VNode[], c2: VNodeArrayChildren,
  container, anchor, parentComponent, parentSuspense,
  namespace, slotScopeIds, optimized,
) => {
  c1 = c1 || EMPTY_ARR
  c2 = c2 || EMPTY_ARR
  const oldLength = c1.length
  const newLength = c2.length
  const commonLength = Math.min(oldLength, newLength)
  let i
  for (i = 0; i < commonLength; i++) {
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    patch(c1[i], nextChild, container, null, parentComponent,
          parentSuspense, namespace, slotScopeIds, optimized)
  }
  if (oldLength > newLength) {
    // remove old
    unmountChildren(c1, parentComponent, parentSuspense, true, false, commonLength)
  } else {
    // mount new
    mountChildren(c2, container, anchor, parentComponent,
                  parentSuspense, namespace, slotScopeIds, optimized, commonLength)
  }
}

12.3.2 归一化与公共长度

  • c1 = c1 || EMPTY_ARR、c2 = c2 || EMPTY_ARR:防御 undefined,保证 .length 总是可用。EMPTY_ARR 是共享的冻结空数组(shared/src/index.ts),避免每次创建。
  • commonLength = Math.min(oldLength, newLength):两个序列"能逐位对上的部分"长度。

这里的语义是按位置对位:c1[i] 与 c2[i] 一定被视作"同一个位置上的节点",patch(c1[i], c2[i]) 内部会调用 isSameVNodeType;如果类型/ key 不同,patch 会在 renderer.ts:399-403 把旧节点卸载并置 n1=null,随后按"挂载新节点"处理。所以无 key 的逐位比较并不是"傻傻地 patch 同一位置",而是"给同一位置一次被复用的机会,不行就替换"。

12.3.3 逐位 patch 时为什么 anchor 传 null

注意 patch(c1[i], nextChild, container, null, ...) 的第 4 个参数是 null,而不是 anchor。原因是:逐位 patch 不会改变节点的相对顺序——旧节点已在 DOM 中,patch 只更新它的属性/文本,不涉及插入位置。支架 anchor 只在"新增节点"时才需要,而"新增"发生在循环之外的 mountChildren 里。

这一点与 patchKeyedChildren 步骤 3 形成鲜明对比:步骤 3 里 patch(null, c2[i], container, anchor, ...) 传了 anchor,因为那是在真的往中间插入新节点。

12.3.4 多退少补

if (oldLength > newLength) {
  unmountChildren(c1, parentComponent, parentSuspense, true, false, commonLength)
} else {
  mountChildren(c2, container, anchor, parentComponent, parentSuspense,
                namespace, slotScopeIds, optimized, commonLength)
}

两个函数都接受 start 参数:

// packages/runtime-core/src/renderer.ts:2430
const unmountChildren = (children, parentComponent, parentSuspense,
                          doRemove = false, optimized = false, start = 0) => {
  for (let i = start; i < children.length; i++) {
    unmount(children[i], parentComponent, parentSuspense, doRemove, optimized)
  }
}
  • unmountChildren(c1, ..., doRemove=true, optimized=false, start=commonLength):从 commonLength 起把多余的旧节点从 DOM 真删(doRemove=true)。传 optimized=false 是保守做法——这里是"无 key 全量比较",不该假设 block 信息可靠。
  • mountChildren(c2, ..., start=commonLength):从 commonLength 起把新增节点逐个 patch(null, ...) 挂载到 anchor 之前。

12.3.5 边界推演

设 c1 = [A, B, C],c2 = [A', B', C', D', E'](' 表示同类型同 key 的更新版):

步骤ic1[i]c2[i]动作
循环0AA'patch(A, A')
循环1BB'patch(B, B')
循环2CC'patch(C, C')
收尾——D', E'mountChildren(c2, start=3),挂在 anchor 前

commonLength = 3,oldLength(3) > newLength(5) 为假,走 mount 分支。若反过来 c2 = [A', B'],则 commonLength=2,oldLength(3) > newLength(2) 为真,unmountChildren(c1, start=2) 卸载 C。

一个容易忽略的坑:无 key 列表里如果做了"中间插入",逐位比较会把后续所有位置错配——[A,B,C] → [X,A,B,C] 会变成"patch(A,X) 不匹配→卸载 A 挂 X;patch(B, A)…",最终整段重建。这正是 key 存在的意义:告诉运行时"同 key 的是同一个节点,可以在别处复用"。


12.4 patchKeyedChildren 五步走总览

12.4.1 初值与两个结束指针

// packages/runtime-core/src/renderer.ts:1845-1849
let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1        // next ending index

四个关键量:

  • i:前缀指针,同时指向新旧两个数组的"未处理部分起点",从 0 递增。
  • l2:新孩子总数,用于后面判断 nextIndex + 1 < l2。
  • e1:旧数组"未处理部分"的右端(含),从 c1.length - 1 递减。
  • e2:新数组"未处理部分"的右端(含),从 l2 - 1 递减。

不变式:任何时刻,c1[i..e1] 与 c2[i..e2] 之外的区域已被处理完毕。整个算法就是不断缩小这个"未处理窗口"。

12.4.2 五步示意

c1: [ a b c d e f g ]        c2: [ a b e d c h f g ]
          ^^^^^^^                   ^^^^^^^
    i=2               e1=4   i=2               e2=5

步骤① 头对头: a==a, b==b → patch, i→2
步骤② 尾对尾: g==g, f==f → patch, e1→4, e2→5
步骤③④ 窗口检查:
   i(2) > e1(4)? 否
   i(2) > e2(5)? 否
   ⇒ 进入步骤⑤
步骤⑤ 乱序段 c1[2..4] = [c d e], c2[2..5] = [e d c h]
   - 建 keyToNewIndexMap: {e:2, d:3, c:4, h:5}
   - 对每个旧节点找新位置 → newIndexToOldIndexMap
   - getSequence 求 LIS
   - 从后往前,命中 LIS 则不动,否则 move;missing 则 mount

12.4.3 两个"提前退出"条件

步骤 3、4 的分支条件是互补的:

// packages/runtime-core/src/renderer.ts:1910-1945
if (i > e1) {            // 旧窗口耗尽 → 新窗口若有剩余,全部 mount
  if (i <= e2) { /* mount c2[i..e2] */ }
} else if (i > e2) {     // 新窗口耗尽 → 旧窗口若有剩余,全部 unmount
  while (i <= e1) { unmount(c1[i], ...); i++ }
} else {
  /* 步骤⑤ */
}

只要 i > e1 或 i > e2 成立,就永远不会进入 LIS 分支。这意味着:纯追加、纯删除、纯前后缀变化都走 O(k) 的短路路径,不付出建 Map + LIS 的成本。这是实践中最高频的场景。


12.5 步骤①②:前后缀同步

12.5.1 步骤① sync from start

// packages/runtime-core/src/renderer.ts:1850-1874
// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container, null, parentComponent, parentSuspense,
          namespace, slotScopeIds, optimized)
  } else {
    break
  }
  i++
}

要点:

  1. 循环条件 i <= e1 && i <= e2:两个窗口都还有剩余才继续。
  2. optimized 二选一归一化:编译器产出的 c2[i] 已经是 VNode,且可能是被"复用"的同一对象(例如 v-once / cached),所以走 cloneIfMounted(只在 vnode 已挂载时才克隆,避免污染);手写 render 的可能是原始值,走 normalizeVNode(把 null→Comment、数组→Fragment、字符串→Text)。
  3. isSameVNodeType 不满足就 break:前缀同步是"贪心但严格"的——一旦某一对类型/key 不同,立刻停止,把这个位置留给后面的通用逻辑,绝不"跳过"。
  4. patch 的 anchor 为 null:前缀区节点本就在原位,无需移动。

12.5.2 步骤② sync from end

// packages/runtime-core/src/renderer.ts:1876-1901
// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
  const n1 = c1[e1]
  const n2 = (c2[e2] = optimized ? cloneIfMounted(c2[e2] as VNode)
                                  : normalizeVNode(c2[e2]))
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container, null, parentComponent, parentSuspense,
          namespace, slotScopeIds, optimized)
  } else {
    break
  }
  e1--
  e2--
}

要点:

  • 从尾部倒着对:c1[e1] 对 c2[e2]。命中则 patch 并双指针左移。
  • 用了同一个 i 下界:注意步骤①把 i 推进到了比值结束处,步骤②只在 i <= e1 && i <= e2 的剩余窗口内从尾往前同步。两条 while 共享 i 作为"尚未被前缀确认的下界",因此步骤②不会与前缀重叠。
  • 尾部 patch 的 anchor 同样为 null。

12.5.3 isSameVNodeType 判定(type && key)

// packages/runtime-core/src/vnode.ts:379-392
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  if (__DEV__ && n2.shapeFlag & ShapeFlags.COMPONENT && n1.component) {
    const dirtyInstances = hmrDirtyComponents.get(n2.type as ConcreteComponent)
    if (dirtyInstances && dirtyInstances.has(n1.component)) {
      n1.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
      n2.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE
      // HMR only: if the component has been hot-updated, force a reload.
      return false
    }
  }
  return n1.type === n2.type && n1.key === n2.key
}

生产环境的核心只有最后一行:n1.type === n2.type && n1.key === n2.key。

  • type 相等:同一个元素标签(如都为 'li')、同一个组件(同一个组件定义对象)、或都为 Text/Comment/Fragment/Static。注意 Fragment 是一个共享的 Symbol 常量,所以任意两个 Fragment 之间 type 相等。
  • key 相等:key 默认是 null,两个没写 key 的节点 null === null 视为同 key。这带来一个副作用:两个都没 key 的 div 会被判定为"同类型",从而被复用——无 key 时只能按位置复用,这正是 12.3 讲的语义。

开发环境多出的 HMR 分支:热更新把某组件标记为 dirty 且当前旧 vnode 正是该组件实例时,清掉 KeepAlive 相关位并返回 false,强制"卸载旧、挂新旧",保证热更新覆盖生效。生产构建里这段被 __DEV__ 编译剔除,零成本。

关键推论:isSameVNodeType 只看 type + key,不看 props/children。所以"key 相同但 props 变了"是正常更新,而"key 相同但类型变了"(例如 <li> 变成 <div>,或组件 A 换成组件 B)会判定为不同 → 走 unmount + mount。

12.5.4 何时提前 return(进入步骤③④)

两条 while 结束后,i 可能已经超过 e1 或 e2。判断:

  • i > e1:旧窗口被同步耗尽。剩下的都在新窗口里,说明是"新增/移动"。若 i <= e2,则 c2[i..e2] 全新挂载。
  • i > e2:新窗口被同步耗尽。剩下的都在旧窗口里,说明是"删除"。c1[i..e1] 全部卸载。
  • 两者都不成立:i <= e1 && i <= e2,进入步骤⑤乱序段。

12.5.5 步骤①②推演

例:尾部追加

c1 = [A, B, C]
c2 = [A, B, C, D]

步骤①: i=0 A==A patch i→1; B==B i→2; C==C i→3;
        i(3) > e2(2) → 退出
        i(3) <= e1(2)? 否 → 退出
步骤②: 条件 i(3)<=e1(2) 假 → 不执行
步骤③④:
   i(3) > e1(2) 真;  i(3) <= e2(2)? 否 → 不 mount
   else if i(3) > e2(2) 真 → while i<=e1 假 → 不 unmount
   结论: 无需任何结构操作 ✅

等等——这里有个细节:c2 多出的 D 去哪了?答案是步骤①结束时 e2 仍是 2 吗?不对,e2 = l2 - 1 = 3(c2.length=4)。重新推:

c1=[A,B,C]  e1=2, i=0
c2=[A,B,C,D] l2=4, e2=3, i=0

步骤①: A==A patch i=1; B==B patch i=2; C==C patch i=3;
  现在 i=3, e1=2 → 条件 i<=e1(3<=2) 假, 步骤①停
步骤②: 条件 i<=e1 假 → 停
步骤③: i(3) > e1(2) 真; i(3) <= e2(3) 真
  nextPos = e2+1 = 4; 4 < l2(4)? 否 → anchor = parentAnchor
  while i(3) <= e2(3): patch(null, c2[3]=D, container, parentAnchor) → 挂载 D; i=4
  结论: 只挂 D ✅ 无移动

例:头部插入

c1=[B,C]  e1=1, i=0
c2=[A,B,C] l2=3, e2=2

步骤①: c1[0]=B, c2[0]=A → isSameVNodeType? B≠A → break(i=0)
步骤②: i(0)<=e1(1)&&i(0)<=e2(2) 真
  c1[e1=1]=C vs c2[e2=2]=C → 同 → patch; e1=0,e2=1
  c1[0]=B vs c2[1]=B → 同 → patch; e1=-1,e2=0
  条件 i(0)<=e1(-1) 假 → 停
步骤③: i(0) > e1(-1) 真; i(0) <= e2(0) 真
  nextPos = e2+1 = 1; 1 < l2(3) 真 → anchor = c2[1].el = B 的 DOM
  while i(0)<=e2(0): patch(null, c2[0]=A, container, anchor=B.el) → 把 A 插到 B 前; i=1
  结论: 只挂 A ✅ 无移动

这两个例子说明:头尾的"纯增减"被步骤①②③完全覆盖,代价线性且零移动。


12.6 步骤③④:多退少补与索引逐行推演

12.6.1 步骤③:common sequence + mount

// packages/runtime-core/src/renderer.ts:1903-1931
// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
  if (i <= e2) {
    const nextPos = e2 + 1
    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
    while (i <= e2) {
      patch(
        null,
        (c2[i] = optimized ? cloneIfMounted(c2[i] as VNode)
                           : normalizeVNode(c2[i])),
        container, anchor, parentComponent, parentSuspense,
        namespace, slotScopeIds, optimized,
      )
      i++
    }
  }
}

逐行读:

  1. if (i > e1):旧窗口已耗尽。此时不存在"旧节点需要卸载",只可能"新节点需要挂载"。
  2. if (i <= e2):新窗口还有剩余。若 i > e2 说明两边同时耗尽,什么也不做。
  3. const nextPos = e2 + 1:新窗口右侧第一个"已处理"节点的下标。它是挂载的定位锚点。
  4. const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor:
    • 若 e2 + 1 仍在数组内,取那个 vnode 的真实 DOM 元素作为 anchor,把新节点插到它前面。
    • 若 e2 + 1 === l2(新窗口一直延伸到数组末尾),说明后面没有了,用 parentAnchor(来自父级:元素场景为 null,Fragment 场景为 fragmentEndAnchor),即"插到最后"。
  5. while (i <= e2):从左到右逐个 patch(null, c2[i], container, anchor)。注意 anchor 在循环外算好、循环内复用——因为每次插入都插在同一个"右侧第一个已处理节点"之前,插入顺序(从左到右)会让它们保持正确顺序。

为什么 anchor 要取"右侧第一个未受影响节点"而不是父级? 因为新窗口右侧的区域(c2[e2+1..l2-1])在算法开始前就已存在于 DOM 中(它们对应旧序列的尾部,已在步骤②被 patch 过)。把新节点插到这个"已知正确"的节点之前,是最省心的定位。

12.6.2 头部插入的 anchor 逐帧推演

以 12.5.5 的"头部插入"为例,c1=[B,C] → c2=[A,B,C]:

帧ie1e2nextPosanchor动作
步骤①后012——B≠A,break
步骤②后0-10——patch C、B
步骤③0-101c2[1].el=Bpatch(null, A, anchor=B)

结果:A 被插在 B 之前,DOM 顺序变为 A B C。无一次 move。

12.6.3 步骤④:common sequence + unmount

// packages/runtime-core/src/renderer.ts:1933-1945
// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

逐行读:

  1. else if (i > e2):既然 i > e1 为假,那么这里成立意味着新窗口耗尽、旧窗口还有剩。
  2. while (i <= e1):把 c1[i..e1] 全部卸载,doRemove=true(真删 DOM)。
  3. 这里不需要 anchor:卸载不需要定位。

12.6.4 尾部删除推演

c1=[A,B,C,D] → c2=[A,B],l2=2:

帧ie1e2动作
步骤①0→231A==A patch、B==B patch;i=2 时 i<=e1(3) 真但 i<=e2(1) 假 → 停
步骤②231条件 i<=e2(2<=1) 假 → 停
步骤③231i(2)>e1(3)? 否 → 跳过
步骤④231i(2)>e2(1) 真 → unmount C(i=2)、D(i=3);i=4
结果DOM 变为 A B ✅ 无 move

12.6.5 e1/e2 的"越界"语义

两个例子都出现了 e1 = -1 或 e2 = -1。这不是 bug,而是一种空窗口表示法:当 e1 = -1 时 i(≥0) > e1 恒真,自然落入"旧窗口空"的分支。所以步骤③④的条件从不需要额外判空。这也是为什么初值是 length - 1 而不是 length。


12.7 步骤⑤:乱序中间段的核心算法

这是全章的重头戏。整段代码在 renderer.ts:1947-2075。

12.7.1 进入条件与两个起点

// packages/runtime-core/src/renderer.ts:1947-1953
// 5. unknown sequence
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
else {
  const s1 = i // prev starting index
  const s2 = i // next starting index
  // ...
}

进入这里意味着 i <= e1 && i <= e2:两边窗口都还有内容,且头尾都不同(否则步骤①②会继续收缩)。s1/s2 把当前 i 定格为乱序段起点,之后 i 会被复用作循环变量。

12.7.2 5.1 建立 key → 新索引映射

// packages/runtime-core/src/renderer.ts:1955-1971
// 5.1 build key:index map for newChildren
const keyToNewIndexMap: Map<PropertyKey, number> = new Map()
for (i = s2; i <= e2; i++) {
  const nextChild = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  if (nextChild.key != null) {
    if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
      warn(`Duplicate keys found during update:`,
           JSON.stringify(nextChild.key), `Make sure keys are unique.`)
    }
    keyToNewIndexMap.set(nextChild.key, i)
  }
}

要点:

  1. 下标是"新数组中的绝对下标"(i 从 s2 到 e2),不是相对 s2 的偏移。后面查表得到的 newIndex 也是绝对下标。
  2. 只记录 key != null 的节点。无 key 节点不进 Map,将由 5.2 的 for 循环用"类型相同 + 该位置尚未被占用"来配对。
  3. 重复 key 警告(dev only):keyToNewIndexMap.has(key) 命中即 warn,但后面仍会 set 覆盖——采取"后者胜"的策略,运行时不会崩,只是行为可能不符合直觉。这与 render.ts 顶层 vnode 重复 key 的处理风格一致。
  4. Map 的 key 类型是 PropertyKey(string | number | symbol),所以 :key="1" 与 :key="'1'" 会被视为不同 key(不做字符串化归一)。这一点与很多人的直觉相反。

12.7.3 5.2 遍历旧乱序段:配对、建图、计数

// packages/runtime-core/src/renderer.ts:1973-2033
let j
let patched = 0
const toBePatched = e2 - s2 + 1
let moved = false
// used to track whether any node has moved
let maxNewIndexSoFar = 0
// works as Map<newIndex, oldIndex>
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

for (i = s1; i <= e1; i++) {
  const prevChild = c1[i]
  if (patched >= toBePatched) {
    // all new children have been patched so this can only be a removal
    unmount(prevChild, parentComponent, parentSuspense, true)
    continue
  }
  let newIndex
  if (prevChild.key != null) {
    newIndex = keyToNewIndexMap.get(prevChild.key)
  } else {
    // key-less node, try to locate a key-less node of the same type
    for (j = s2; j <= e2; j++) {
      if (
        newIndexToOldIndexMap[j - s2] === 0 &&
        isSameVNodeType(prevChild, c2[j] as VNode)
      ) {
        newIndex = j
        break
      }
    }
  }
  if (newIndex === undefined) {
    unmount(prevChild, parentComponent, parentSuspense, true)
  } else {
    newIndexToOldIndexMap[newIndex - s2] = i + 1
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex
    } else {
      moved = true
    }
    patch(prevChild, c2[newIndex] as VNode, container, null,
          parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
    patched++
  }
}

逐行拆解五个关键量:

  • toBePatched = e2 - s2 + 1:新乱序段的长度,也即"待处理新节点数"。
  • patched:已经配对成功并 patch 的旧节点计数。当 patched >= toBePatched,说明新节点已全部配对,剩下的旧节点都是"多余"→ 卸载。这是一个提前退出优化:避免对剩余旧节点逐个查 Map(虽然查 Map 也是 O(1),但卸载是必须的,这里合并了逻辑)。
  • moved:布尔标志,表示"是否发生过需要移动的情况"。初始 false。仅当出现 newIndex < maxNewIndexSoFar(即新索引"回退")时置 true。若整个乱序段新旧顺序完全一致(如 [a,b,c]→[a',b',c']),moved 保持 false,LIS 都不会被调用。
  • maxNewIndexSoFar:遍历旧节点时见过的"最大新索引"。它是一把尺子:如果新索引始终单调递增,说明相对顺序没变;一旦出现"更小的新索引",就说明这个旧节点需要往前挪。
  • newIndexToOldIndexMap:长度 toBePatched 的数组,索引是"新段内的相对下标"(newIndex - s2),值是"旧段内的相对下标 + 1"(i + 1)。0 是哨兵,表示"这个新位置没有对应的旧节点"(即纯新增)。之所以要 +1,正是为了把"旧下标 0"与"新增哨兵"区分开——这是全章最精妙的一处编码。

12.7.4 newIndexToOldIndexMap 的构建语义

用一个具体例子说明。乱序段 c1[2..4] = [c, d, e],c2[2..5] = [e, d, c, h](key 即字母)。此时 s1=2, s2=2, e1=4, e2=5,toBePatched = 4。初始 newIndexToOldIndexMap = [0, 0, 0, 0]。

keyToNewIndexMap = { e:2, d:3, c:4, h:5 }。

遍历旧段 i = 2..4:

iprevChildkeynewIndex更新 map[newIndex-s2]maxNewIndexSoFarmoved
2cc4map[4-2=2] = 2+1 = 34false
3dd3map[3-2=1] = 3+1 = 44→3 回退true
4ee2map[2-2=0] = 4+1 = 52 回退true

最终 newIndexToOldIndexMap = [5, 4, 3, 0]。解读:

  • 新位置 0(对应 e)来自旧位置 4 → 值 5 - 1 = 4。
  • 新位置 1(对应 d)来自旧位置 3 → 值 4 - 1 = 3。
  • 新位置 2(对应 c)来自旧位置 2 → 值 3 - 1 = 2。
  • 新位置 3(对应 h)没有旧节点 → 值 0(哨兵,pure mount)。

这个数组 [5, 4, 3, 0] 就是喂给 getSequence 的输入。注意它把新增的 h 编成了 0,LIS 会忽略 0 值(getSequence 内 if (arrI !== 0))。

12.7.5 无 key 节点的"就近配对"

} else {
  // key-less node, try to locate a key-less node of the same type
  for (j = s2; j <= e2; j++) {
    if (
      newIndexToOldIndexMap[j - s2] === 0 &&
      isSameVNodeType(prevChild, c2[j] as VNode)
    ) {
      newIndex = j
      break
    }
  }
}

当旧乱序段中的节点没有 key 时,无法用 Map 查。兜底策略是:在新乱序段中从左到右扫描,找第一个满足"该位置尚未被配对(map[j-s2] === 0)且 isSameVNodeType 相同"的新节点。这保证了:

  • 优先匹配"未占用"位置:已被别的旧节点配对的槽位不会被抢。
  • 顺序优先:从左往右第一个,等价于"贪心地保持相对顺序"。

因此 mixed(部分 keyed、部分 unkeyed)列表也能工作,只是无 key 部分的复用质量取决于它们在段内的相对顺序。这也是 Vue 在 dev 环境提示"列表渲染应使用 key"的深层原因。

12.7.6 moved 的判定为什么不看 patched

moved 只看 newIndex < maxNewIndexSoFar,与 patched 无关。原因是:patched 计数只管"是否还有未配对新节点",而 moved 关心的是"相对顺序是否被破坏"。两者正交。一个全部配对但逆序的段([a,b,c]→[c,b,a])patched=3=toBePatched,但 moved=true,必须触发 LIS 与移动。

12.7.7 5.2 结束后 i 与 e1 的状态

注意 5.2 的 for (i = s1; i <= e1; i++) 结束后,i = e1 + 1。这个值在 5.3 里会被重新赋值(5.3 用的是 for (i = toBePatched - 1; i >= 0; i--)),所以不担心污染。这也解释了为什么源码可以把 i 到处复用——每段都显式重新初始化。


12.8 最长递增子序列(LIS):getSequence

12.8.1 问题定义与它在 diff 中的角色

我们拿到的是 newIndexToOldIndexMap,它的索引是"新顺序",值是"旧顺序"。例如 [5, 4, 3, 0] 表示:新的第 0 个来自旧的绝对第 4 个,新的第 1 个来自旧的绝对第 3 个……

我们想在只做"从后往前遍历 + 移动"的前提下,找出一批不需要移动的节点。哪些节点不需要移动?答案是:那些在新顺序中相对位置也保持递增的节点。因为如果节点 X 在旧序列中排在节点 Y 后面,在新序列中 X 仍排在 Y 后面,那么 X 与 Y 的相对顺序没有变化,谁都不用挪(在整体移动的框架下,只要所有"需要移动"的节点被正确地插到它们前面/后面即可)。

newIndexToOldIndexMap 里"值递增"就等价于"按新顺序排列后,旧下标递增"。因此我们要找的是这个数组的一个最长递增子序列——它的下标集合(相对新段的索引)就是"保持不动"的节点集合。

getSequence 返回的是相对新段的索引数组(不是值)。例如对输入 [5, 4, 3, 0],LIS 是 [0](因为 5>4>3,只留下标对应的值 5、4、3 全递减,最长递增子序列长度为 1,返回 [0];或者可能返回 [2] 取决于实现,见下文推演)。

12.8.2 源码逐行

// packages/runtime-core/src/renderer.ts:2607-2647
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function getSequence(arr: number[]): number[] {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      u = 0
      v = result.length - 1
      while (u < v) {
        c = (u + v) >> 1
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }
  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

这段代码是经典的 patience sorting(耐心排序)+ 前驱回溯实现,只是做了两点 Vue 特化:忽略 0(新增哨兵)、返回的是下标序列。

三个核心数据结构:

  • result:当前最优的"tails 数组",result[k] 存放"长度 k+1 的递增子序列的最小可能结尾元素在 arr 中的下标"。注意 result 存的是下标,不是值;比较时用 arr[result[k]] 取值。这是 12.8.6 要重点解释的。
  • p(predecessor):p[i] 记录"以下标 i 结尾的递增子序列中,i 的前一个元素下标"。初始为 arr.slice() 的自拷贝(后续只在需要时改写)。它使得最终可以从前向后回溯出真实序列。
  • arrI:当前元素值。

12.8.3 主循环的三个分支

对每个 i:

  1. if (arrI === 0) continue(跳过):0 是"该新位置无旧节点"的哨兵,不参与 LIS。跳过它意味着:新增节点永远不会出现在"不移动集合"里(这是对的——新增节点本来就要挂载,谈不上"不移动")。

  2. j = result[result.length - 1];若 arr[j] < arrI:当前值大于 tails 的最后一个值,说明"接在最长子序列后面能变得更长"→ p[i] = j(记录前驱),result.push(i)。这是"扩展"分支,O(1)。

  3. 否则:当前值小于等于 tails 末尾,需要在 result 里二分查找"第一个 ≥ arrI 的位置 u"(lower_bound),用 i 替换 result[u]。若 u > 0,则 p[i] = result[u - 1](记录前驱为 u 位置的前一个)。

12.8.4 二分查找的细节

u = 0
v = result.length - 1
while (u < v) {
  c = (u + v) >> 1
  if (arr[result[c]] < arrI) {
    u = c + 1
  } else {
    v = c
  }
}

这是标准的 lower_bound(找第一个满足 arr[result[c]] >= arrI 的位置):

  • 若 arr[result[c]] < arrI,说明位置 c 太小,答案在右半区 → u = c + 1。
  • 否则(≥),答案可能是 c 或更左 → v = c。
  • 循环结束时 u === v,即为答案。

之后 if (arrI < arr[result[u]]) 才真正替换:只有当前值严格小于该位置的原值时才更新(等于时不动,保持更早/更稳定的下标,避免无谓抖动)。

(u + v) >> 1 是 Math.floor((u+v)/2) 的位运算写法——对非负数等价,且更快。

12.8.5 完整逐步推演:输入 [5, 4, 3, 0]

这是 12.7.4 里那个逆序段的真实输入。初始:p = [5,4,3,0](arr.slice()),result = [0]。

iarrI跳过?j=result 末arr[j]比较分支result 变化p 变化
05否0arr[0]=55 < 5? 否二分替换u=0,arrI(5) < arr[result[0]](5)? 否(相等)→ 不动p 不变
14否0arr[0]=55 < 4? 否二分替换u=0,4 < 5? 是 → result[0]=1u=0 不设前驱;p[1] 仍为 4
23否1arr[1]=44 < 3? 否二分替换u=0,3 < arr[result[0]=1]=4? 是 → result[0]=2p 不变
30是———skip不变不变

注意 result 的初始值是 [0](把下标 0 当作初始 tails)。经过替换,最终 result = [2](i=0 时因为相等没有替换,i=1、i=2 依次把 result[0] 下调到 1、2)。

回溯阶段:

u = result.length      // u = 1
v = result[u - 1]      // v = result[0] = 2
while (u-- > 0) {
  result[u] = v        // result[0] = 2(本来也是 2)
  v = p[v]             // v = p[2] = 3
}
return result          // [2]

返回 [2]:即"新段内相对下标 2 的节点不需要移动"。对应 newIndexToOldIndexMap[2] = 3(旧绝对下标 3-1=2,即节点 c)。

等等——但节点 c 在新段中下标是 2(对应新位置 c),而在旧段里 c 在绝对下标 2,也是相对下标 0,是最左边的一个。把它当作"不动锚点"合理吗?在这个逆序段 [c,d,e] → [e,d,c,h] 中,移动阶段会从后往前处理:h(新增)→ mount;c(相对 2)在 LIS 中 → 不动;d(相对 1)→ move;e(相对 0)→ move。最终把 d、e 挪到 c 前面,得到 e d c。让我们在 12.9 验证。

12.8.6 为什么 result 存下标而不是值

这是最容易读错的地方。patience sorting 经典做法是维护"每个长度对应的最小结尾值"。Vue 返回的是下标序列,所以 result[k] 需要是下标,比较时写 arr[result[k]]。这样做的好处:

  1. 回溯时 p 数组存的都是下标,可直接串联。
  2. 最终 result 本身就是答案(下标序列),无需再映射回下标。

如果改成存值,回溯后还得把值映射回下标,且无法区分重复值,会引入歧义。所以"存下标"既是正确性需要,也是实现简洁的优势。

12.8.7 前驱回溯:如何从"tails"恢复真实序列

result 在构建过程中只是"tails 快照",它记录的是"各长度对应的最小结尾下标",并不直接等于某个真实子序列。例如输入 [3, 1, 2]:

  • i=0, arrI=3:arr[0]=3 < 3? 否 → 二分 u=0,3<3? 否 → 不动。result=[0]。
  • i=1, arrI=1:arr[result[0]=0]=3 < 1? 否 → 二分 u=0,1 < arr[result[0]=0]=3? 是 → result[0]=1。u=0 不设 p。
  • i=2, arrI=2:arr[result 末=1]=1 < 2? 是 → p[2]=1,result.push(2) → result=[1,2]。

此时 result=[1,2] 恰好是真实 LIS [1,2]。但换个输入 [2, 3, 1]:

  • i=0: result=[0](值 2)。
  • i=1, arrI=3 > arr[0]=2 → p[1]=0, result=[0,1](值 2,3)。
  • i=2, arrI=1:arr[result 末=1]=3 < 1? 否 → 二分在 [0,1] 里找第一个 ≥1:位置 0 的值 2 ≥1 → u=0;1 < 2? 是 → result[0]=2。result 变 [2,1](值 1,3)——这已经不是递增序列的下标了(下标 2 在 1 后面,但值 1<3 对)。如果不回溯,返回 result=[2,1] 是错的(下标顺序 [2,1] 不是递增下标)。

回溯修正:u=result.length=2, v=result[1]=1。

  • u→1: result[1]=v=1; v=p[1]=0。
  • u→0: result[0]=v=0; v=p[0]=2(p 初值是 arr.slice(),p[0]=2)。
  • 返回 [0,1] —— 真实 LIS(值 2,3 或下标 0,1)。

所以回溯的作用是:把"被后续更小值覆盖过的 tails 位置"恢复成它们各自前驱链所对应的真实下标。p[i] 保证每个下标都能追到一个更早的前驱,链条天然递增。

12.8.8 复杂度分析

  • 外层循环 n 次。
  • 每次"扩展"分支 O(1)。
  • "替换"分支做一次二分,result 长度不超过 n,故 O(log n)。
  • 回溯阶段 O(n)(result 长度 ≤ n)。

总计 O(n log n) 时间、O(n) 空间(result + p)。

对比朴素 DP 的 O(n²)(dp[i] = max(dp[j] + 1) if arr[j] < arr[i],j < i):对于 1 万条目的列表,O(n²) 是 1 亿次操作,而 O(n log n) 约 13 万次——差三个数量级。这正是 Vue 3 敢在运行时对"乱序段"直接调用 LIS 的底气。

12.8.9 为什么 LIS 给出的就是"最小移动集"(证明思路)

设新段长度 m,newIndexToOldIndexMap 中非零项构成集合 S(有旧对应的新位置)。我们要选一个"不动集合" U ⊆ S,使得存在一种移动方案让所有非 U 节点归位。结论:U 取 S 上的 LIS 时,需要移动的节点数最少,为 |S| − |LIS|。

证明分两步:

(1) 不动集合 U 必须在新顺序与旧顺序上都递增。

  • 按位置:i 是 U 中元素在新段的下标,i 递增;值 m_i = newIndexToOldIndexMap[i] 是该元素在旧段的(相对)下标。
  • 若 U 中两个元素 i1 < i2 但 m_{i1} > m_{i2},那么它们在旧序列中顺序是(i2 在前,i1 在后),在新序列中却是(i1 在前,i2 在后)——相对顺序被反转了。至少其中一个必须移动才能修正这个逆序。因此不动集合必须是"值随下标递增"的子序列。
  • 反过来,若 U 的值随下标递增,则 U 中元素的相对顺序在旧、新序列中一致,可以全部不动——把每个非 U 元素插入到"它在 U 中应处的位置"即可(这就是移动阶段从后往前插入的实现)。
  • 所以不动集合 ⇔ S 的递增子序列。

(2) 移动数 = |S| − |U|,最小化移动数 ⇔ 最大化 |U| ⇔ U 取最长递增子序列。

由 (1),任何合法不动集合都是递增子序列,其大小 |U| ≤ LIS 长度。取 |U| = LIS 长度时移动数最小。证毕。

直觉总结:"尽量不动"与"找最长递增子序列"是同一件事。 这就是为什么 diff 的第 5.3 步要算 LIS——它不是"顺手用了个高级算法",而是"最小移动数"这个目标的最优解。

12.8.10 贪心 + 二分为什么正确(等价性证明思路)

patience sorting 求 LIS 的正确性可归纳为:

不变式:处理完前 i 个元素后,result[k](下标)对应的值 arr[result[k]] 是"所有长度为 k+1 的递增子序列里最小的结尾值",且 result 中的值严格递增。

  • 扩展分支:arrI 大于当前最长结尾,必然构成"长度 +1"的新结尾,且因为 arrI 是当前处理到的最新且最大的一个,它是最优的。
  • 替换分支:arrI 更小,只能"接在长度 < u+1 的最优结尾后面",通过会把位置 u 的结尾改小(不会变差),保持"最小结尾"性质。
  • 严格递增:替换只在 arrI 严格小于原值时发生,且替换位置 u 满足 arr[result[u-1]] < arrI(二分保证),故 result 数组对应的值始终严格递增。

给定这个不变式,result.length 就是"全局最长递增子序列的长度"。再通过 p 的前驱链回溯,即可还原一个真实的长度为 result.length 的递增子序列。

需要注意一个常见误区:最终的 result 经过回溯修正后虽是合法 LIS,但不一定是字典序最小或唯一的那一个。Vue 只关心"长度最大、且是一组合法的不动集合",不关心具体挑中哪一个(不同选择会得到相同移动数)。

12.8.11 0 值在 LIS 中的位置与边界

再强调一次:arrI === 0 时直接跳过。为什么是"跳过"而不是"当作 −∞"?因为 0 表示"新增,没有旧位置",它不该也不能成为"不动锚点"(锚点必须是已在 DOM 中、只需挪动的旧节点)。若把它当作最小纳入 LIS,会导致新增节点被误认为"可以不动",从而漏掉挂载。跳过等价于"把它排除在 S 之外",与 12.8.9 的证明一致。

边界:arr = [](空段)→ result=[0],但 len=0,主循环不执行,回溯后 return [0]。不过 getSequence 只在 moved === true 时被调用,而 moved 为 true 意味着段内至少有一个非零、且顺序被打破,因此空数组/全零数组的调用在实践中不会发生。但即便发生,返回 [0] 也只是被当成"下标 0 在 LIS 中"——因为 newIndexToOldIndexMap[0] 很可能是 0,那么 5.3 会因 map[0]===0 走 mount 分支,不依赖 LIS 内容,所以无害。


12.9 结合 LIS 的移动阶段

12.9.1 5.3 源码逐行

// packages/runtime-core/src/renderer.ts:2035-2074
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// looping backwards so that we can use last patched node as anchor
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i
  const nextChild = c2[nextIndex] as VNode
  const anchorVNode = c2[nextIndex + 1] as VNode
  const anchor =
    nextIndex + 1 < l2
      ? // #13559, #14173 fallback to el placeholder for unresolved async component
        anchorVNode.el || resolveAsyncComponentPlaceholder(anchorVNode)
      : parentAnchor
  if (newIndexToOldIndexMap[i] === 0) {
    // mount new
    patch(null, nextChild, container, anchor, parentComponent,
          parentSuspense, namespace, slotScopeIds, optimized)
  } else if (moved) {
    // move if:
    // There is no stable subsequence (e.g. a reverse)
    // OR current node is not among the stable sequence
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      move(nextChild, container, anchor, MoveType.REORDER)
    } else {
      j--
    }
  }
}

几个关键设计:

  1. moved ? getSequence(...) : EMPTY_ARR:只有发生过"新索引回退"才计算 LIS。若整段顺序未变,increasingNewIndexSequence = EMPTY_ARR,且 moved === false,那么下面的 else if (moved) 永不进入——一个节点都不会移动。这是"顺序不变"场景的零成本路径。
  2. j = increasingNewIndexSequence.length - 1:j 是 LIS 序列的读指针,从最后一个(最大值下标)开始,倒着匹配。
  3. i 从 toBePatched - 1 递减到 0:倒序遍历新段,从右往左处理。
  4. nextIndex = s2 + i:新段相对下标换算成 c2 绝对下标。
  5. nextChild = c2[nextIndex]:当前处理的新节点。
  6. anchorVNode = c2[nextIndex + 1]:右侧邻居。
  7. anchor:若 nextIndex + 1 < l2,取右侧邻居的 el;否则用 parentAnchor。开发时 anchorVNode.el || resolveAsyncComponentPlaceholder(anchorVNode) 是给"未 resolve 的异步组件"兜底(其 el 还是占位注释)。

12.9.2 anchor 为什么取"右侧邻居"

这是理解整个移动阶段的关键。

倒序遍历(从右到左)时,右侧的所有节点都已经处理完毕并处在最终正确位置。因此,当前节点只要被移动到"右邻居之前",它必然落在正确位置上。这就是"looping backwards so that we can use last patched node as anchor"注释的含义——用"刚刚处理过的右邻居"作为锚点。

如果正序遍历,右侧节点还没处理,insertBefore(node, rightSibling) 的 rightSibling 可能还没归位,就会出错。

12.9.3 三种动作的判定

if (newIndexToOldIndexMap[i] === 0) {
  // 分支 A:新增 → mount
} else if (moved) {
  // 分支 B:有旧节点,且整体发生过移动
  if (j < 0 || i !== increasingNewIndexSequence[j]) {
    // B1:不在 LIS 中 → move
  } else {
    // B2:在 LIS 中 → 不移动,读指针左移
    j--
  }
}
// 分支 C:有旧节点,但 moved === false → 什么都不做
  • A(map[i] === 0):该新位置没有旧节点,patch(null, nextChild, ...) 挂载。
  • B1(不在 LIS):需要移动到 anchor(右邻居)之前。
  • B2(在 LIS 中):天然有序,不动。同时 j--,因为 LIS 是递增下标序列,倒序遍历时匹配到的 LIS 元素下标必然递减,所以指针只需单向左移,无需二分。
  • C(moved === false):既然顺序没乱,所有有旧节点的位置都已在正确相对顺序,不动。

注意 j-- 只在 B2 分支执行。这保证 increasingNewIndexSequence[j] 始终是"当前 i 应匹配的目标"。因为两个序列都是递增的,倒序匹配类似归并,整体 O(m)。

12.9.4 用 [5, 4, 3, 0] 完整推演移动

回到 12.8.5 的例子:c1[2..4] = [c,d,e],c2[2..5] = [e,d,c,h],s2=2, toBePatched=4, l2=9(假设整段共 9 个),moved=true,newIndexToOldIndexMap = [5,4,3,0],LIS = [2],故 j=0。

初始 DOM 顺序(乱序段):c d e(后面还有 h 要新增、前面有 a b、尾部后续步骤已处理)。

倒序遍历 i = 3,2,1,0:

inextIndex=s2+inextChildmap[i]anchor(nextIndex+1)判定动作j
35h0—(nextIndex+1=6,假设有后续节点或用 parentAnchor)A: 新增mount h0
24c3c2[5].el=h.elB2: i(2)===LIS[j=0](2)不动j→-1
13d4c2[4].el=c.elB1: j<0move d → c 之前-1
02e5c2[3].el=d.elB1: j<0move e → d 之前-1

结果 DOM:e d c h。逐帧看(只看乱序段,h 右侧还有尾部已同步区):

初始:                     [c][d][e]
i=3  mount h → before 右邻居:   [c][d][e] ... [h]
i=2  c 在 LIS 中,不动:          [c][d][e] ... [h]
i=1  move d → before c:         [d][c][e] ... [h]
i=0  move e → before d:         [e][d][c] ... [h]

核对端点:i=1 时 nextIndex=3,anchor = c2[4].el = c。insertBefore(d, c) 会把 d 从原位摘出再插到 c 前 → [d][c][e]。i=0 时 nextIndex=2,anchor = c2[3].el 已是 d(d 现在在首位)→ insertBefore(e, d) → [e][d][c]。

结果 e d c h,与新数组一致。只做了 2 次 move + 1 次 mount,而 c 作为 LIS 锚点完全不动。若不用 LIS(例如全部 move),会是 3 次 move(c 也要挪)。LIS 在这里省了 1 次移动——段长 4 时省 25%,段越长、乱序越剧烈,节省越显著。

12.9.5 为什么"倒序"处理能正确归位(不变量论证)

不变量 P(i):在倒序遍历的每一步开始时,c2[nextIndex+1 .. l2-1](即 i 右侧的所有新节点)在 DOM 中的相对顺序已经正确,且都排在 c2[nextIndex] 的位置之后。

  • 基础:i = toBePatched-1 时,右侧区域是 c2[s2+toBePatched .. l2-1] = c2[e2+1 .. l2-1],这些属于"尾部已同步区"(步骤②),顺序天然正确。
  • 归纳:处理 i 时,因为 P(i+1) 成立,右邻居 c2[nextIndex+1] 的位置已正确。把当前节点 mount 或 move 到"右邻居之前",即可让它落在"新数组要求的、相对右侧区域的位置";而左侧尚未处理的节点后面会依次插到它前面,不会破坏它。故 P(i) 成立。
  • 结论:i 减到 0 时全部归位。

这个不变量也解释了为什么要用"右邻居"而非"左邻居"作为 anchor——只有右侧是"已完成"的。

12.9.6 move 函数:不同 vnode 类型的移动

// packages/runtime-core/src/renderer.ts:2078-2108
const move: MoveFn = (vnode, container, anchor, moveType, parentSuspense = null) => {
  const { el, type, transition, children, shapeFlag } = vnode
  if (shapeFlag & ShapeFlags.COMPONENT) {
    move(vnode.component!.subTree, container, anchor, moveType)
    return
  }
  if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
    vnode.suspense!.move(container, anchor, moveType)
    return
  }
  if (shapeFlag & ShapeFlags.TELEPORT) {
    ;(type as typeof TeleportImpl).move(vnode, container, anchor, internals)
    return
  }
  if (type === Fragment) {
    hostInsert(el!, container, anchor)
    for (let i = 0; i < (children as VNode[]).length; i++) {
      move((children as VNode[])[i], container, anchor, moveType)
    }
    hostInsert(vnode.anchor!, container, anchor)
    return
  }
  // ...
  hostInsert(el!, container, anchor)
}

要点:

  • 组件:移动的是它的 subTree(组件自身的 el 是占位,真正 DOM 在子树的根上)。
  • Suspense:委托给 vnode.suspense.move(...)。
  • Teleport:委托给 TeleportImpl.move(...)(因为 Teleport 的 DOM 在另一个容器里,需要特殊处理——见 12.11)。
  • Fragment:先插起始锚点 el,再逐个把子节点 move 到锚点前,最后插结束锚点 vnode.anchor。因为是"逐个 move 到同一 anchor 前",按顺序执行会让子节点依次排在 anchor 前,保持原顺序。
  • 元素/文本:直接 hostInsert(el, container, anchor)。若 moveType 不是 REORDER 且有 transition,会走过渡逻辑;否则直接插入。
  • MoveType.REORDER:diff 里的移动明确使用该类型,表示"纯粹的重排",不触发 enter 过渡(needTransition 会因 moveType === REORDER 为假而短路)。

一个必须注意的点:move 只负责重排已在 DOM 中的节点,它不创建元素;而 patch(null, ...) 才负责创建。两者在 5.3 里泾渭分明。


12.10 特殊场景推演(六案)

下面六个案例覆盖 keyed diff 的高频边界。为便于对照,统一假设 s1=s2=0(即整个列表都是乱序段)。

12.10.1 案例一:完全逆序 [a,b,c,d] → [d,c,b,a]

c1 = [a,b,c,d], c2 = [d,c,b,a]。步骤①②:c1[0]=a vs c2[0]=d 不同 → break;尾 c1[3]=d vs c2[3]=a → break。进入步骤⑤:i=0, e1=3, e2=3, s1=s2=0, l2=4, toBePatched=4。

5.1:keyToNewIndexMap = { d:0, c:1, b:2, a:3 }。

5.2:

iprevChildnewIndex=map.get(key)map[newIndex-0]=i+1maxNewIndexSoFarmovedpatch?
0a3map[3]=13falsepatch(a,a')
1b2map[2]=23→2 回退truepatch(b,b')
2c1map[1]=3回退truepatch(c,c')
3d0map[0]=4回退truepatch(d,d')

newIndexToOldIndexMap = [4,3,2,1],moved=true。

getSequence([4,3,2,1]):

  • i=0, arrI=4: result=[0], j=0, arr[0]=4 <4? 否;u=0,v=0;4<4? 否 → 不动。
  • i=1, arrI=3: j=0, arr[0]=4<3? 否;u=0,v=0;3<4? 是 → result[0]=1。
  • i=2, arrI=2: j=1, arr[1]=3<2? 否;u=0,v=0;2<3? 是 → result[0]=2。
  • i=3, arrI=1: j=2, arr[2]=2<1? 否;2<... u=0,v=0;1<arr[2]=2? 是 → result[0]=3。
  • result=[3];回溯:u=1,v=result[0]=3 → result=[3]。
  • 返回 [3]:相对下标 3(节点 a)为锚点。

5.3:j = 1-1 = 0;倒序 i=3,2,1,0:

inextChildmap[i]LIS[j]anchor动作
3a1LIS[0]=3 == 3—B2: i(3)===LIS[0] → 不动,j→-1
2b2j<0a.elB1: move b → a 前
1c3j<0b.elB1: move c → b 前
0d4j<0c.elB1: move d → c 前

帧推演(初始 [a][b][c][d]):

i=3 a 不动: [a][b][c][d]
i=2 move b → before a: [b][a][c][d]
i=1 move c → before b: [c][b][a][d]
i=0 move d → before c: [d][c][b][a]  ✅

共 3 次 move(锚点 a 不动)。理论最小移动数 = |S| − |LIS| = 4 − 1 = 3。达成最优。

12.10.2 案例二:完全替换(全 key 变化)

c1 = [a,b,c],c2 = [x,y,z]。步骤①②:头 a≠x、尾 c≠z → break。步骤⑤:s1=s2=0, e1=2, e2=2, toBePatched=3。keyToNewIndexMap = { x:0, y:1, z:2 }。

5.2:遍历旧 i=0..2,a/b/c 的 key 都不在 Map 里 → newIndex === undefined → 三次 unmount(a/b/c)。patched 始终 0。

5.3:moved 仍为 false(没有任何配对,maxNewIndexSoFar 从未被更新,也不会回退)→ increasingNewIndexSequence = EMPTY_ARR,j = -1。

倒序 i=2,1,0:map[i] 全为 0 → 三次走 A 分支 mount:

inextChildanchor
2zparentAnchor(假设末尾)
1yz.el
0xy.el

帧:[a][b][c] → unmount 全部 → [] → mount z → [z] → mount y before z → [y][z] → mount x before y → [x][y][z] ✅

关键结论:当新节点全部是"新增"(newIndexToOldIndexMap 全 0)时,moved 保持 false,不调用 LIS,因为 0 从不参与 maxNewIndexSoFar 更新(0 不来自任何配对)。这是一个重要的性能细节。

12.10.3 案例三:纯头部插入

c1 = [b,c,d],c2 = [a,b,c,d]。步骤①:c1[0]=b vs c2[0]=a → 不同 → break(i=0)。步骤②从尾往前:d==d patch(e1=2,e2=3);c==c patch(e1=1,e2=2);b==b patch(e1=0,e2=1);下一轮 i=0 <= e1=0 且 i=0 <= e2=1,比较 c1[e1=0]=b vs c2[e2=1]=b → 相同 → 继续 patch,e1=-1,e2=0;循环条件 i(0) <= e1(-1) 假 → 停。

步骤③:i(0) > e1(-1) 真;i(0) <= e2(0) 真 → mount c2[0]=a,anchor = c2[1].el = b.el。无 move,只 mount 1 个。✅

注意这里步骤②把 b 也从尾部"认领"了(因为是"从尾往前逐对",c2 尾部的 b 与 c1 头部的 b 在最内层对上)。这完全正确——算法只要求相对顺序一致,不要求"必须从头部同步"。真正的新增 a 被留在窗口内由步骤③挂载。

12.10.4 案例四:纯尾部删除

c1 = [a,b,c,d],c2 = [a,b]。步骤①:a==a (i=1)、b==b (i=2);i(2)<=e2(1) 假 → 停。步骤②:i(2)<=e2(1) 假 → 不执行。步骤③:i(2) > e1(3)? 否。步骤④:i(2) > e2(1) 真 → unmount(c1[2]=c)、unmount(c1[3]=d)。无 move,无 LIS。✅

12.10.5 案例五:重复 key 的警告

c2 = [x(a), y(a), z(b)](两个节点 key 都是 a)。5.1 建图:

i=0: key='a', map 无 'a' → set('a', 0)
i=1: key='a', map 有 'a' → warn("Duplicate keys found during update:", '"a"', "Make sure keys are unique.") → set('a', 1) 覆盖
i=2: key='b' → set('b', 2)

最终 keyToNewIndexMap = { a:1, b:2 }(注意 a 映射到后一个下标 1)。

后果推演:若旧序列也有两个 a,会出现"两个旧 a 都想匹配到新下标 1"——第一个匹配成功后写入 map[1]=i+1;第二个再查 map.get('a') = 1,仍然返回 1,于是同一个新位置被两个旧节点认领(patch 会被调用两次,第二次对同一 vnode 重复 patch)。这不会崩溃,但 DOM 会出现重复/错乱,且只有一个 a 位置的 map 被写。这正是"key 必须唯一"的硬性要求的运行时表现。开发环境的 warn 是给开发者的救援信号。

生产构建里 __DEV__ 短路,Duplicate keys 检查被剔除(__DEV__ && keyToNewIndexMap.has(...)),所以生产环境静默采用"后者胜",以零开销换取容错。

12.10.6 案例六:key 复用但类型不同 → unmount + mount

c1 = [<li key=a>, ...],c2 = [<div key=a>, ...]。步骤① isSameVNodeType(li, div):type !== type('li' !== 'div')→ false → break。进入步骤⑤。

5.1:keyToNewIndexMap = { a: <div 的新下标> }。

5.2:旧 li 的 key a 能查到 newIndex,于是不会走 unmount 分支(因为 newIndex !== undefined):

newIndexToOldIndexMap[newIndex - s2] = i + 1
...
patch(prevChild /* li */, c2[newIndex] /* div */, container, null, ...)

这里看起来"配对成功要 patch",但 patch 内部第一件事就是:

// packages/runtime-core/src/renderer.ts:398-403
if (n1 && !isSameVNodeType(n1, n2)) {
  anchor = getNextHostNode(n1)
  unmount(n1, parentComponent, parentSuspense, true)
  n1 = null
}

即:key 相同但 type 不同时,patch 会先卸载旧节点、置 n1=null,再按"挂载新节点"处理。所以最终效果 = unmount li + mount div(新 div 插到旧 li 的位置,因为用了 getNextHostNode 作为 anchor)。

同时,newIndexToOldIndexMap[newIndex-s2] 被写成 i+1(非 0),意味着在 5.3 里这个位置会走"有旧节点"分支。若 moved 为 true,它可能被 move;若 moved 为 false,它不动——而新 div 的 DOM 已经在正确位置,无需移动,所以结果正确。

结论:isSameVNodeType 的失败发生在 patch 内部,而不是在 patchKeyedChildren 的 Map 查找层。所以"key 对上了但类型变了"不会被 5.2 当作 undefined 去 unmount,而是被 patch 内部替换。这是两层判断职责分工的体现:Map 层只管"key 是否存在",patch 层才管"type 是否兼容"。


12.11 Fragment 与多根节点的 diff

12.11.1 Fragment 的 VNode 结构

Fragment 是"一组没有共同父元素的节点"的容器,用两个文本节点作为边界锚点:

// packages/runtime-core/src/renderer.ts:1088-1089
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!
  • 初始挂载(n1 == null):创建两个空文本节点,作为起止锚点;el 是起始锚点,anchor 是结束锚点。
  • 更新(n1 != null):el/anchor 复用旧的,不重新创建。
  • children 一定是数组(编译器生成或由数组隐式创建),见 renderer.ts:1114-1122 注释:"a fragment can only have array children"。

在 vnode.ts 中,normalizeVNode 对数组孩子也创建 Fragment:

// packages/runtime-core/src/vnode.ts:810-817
} else if (isArray(child)) {
  // fragment
  return createVNode(Fragment, null, child.slice())  // #3666 避免复用污染
}

child.slice() 是防止"同一个数组被要求复用为多个 vnode 的 children",避免引用污染。

12.11.2 Fragment 的三条 patchFlag 路径

Fragment 的 diff 分三条,取决于 patchFlag:

patchFlag生成者走的分支是否做结构 diff
STABLE_FRAGMENTv-for 源是常量表达式 / 模板根 fragmentpatchBlockChildren(只 patch dynamicChildren)否(顺序不变)
KEYED_FRAGMENTv-for 源是运行时变量且带 :keypatchChildren → patchKeyedChildren是
UNKEYED_FRAGMENTv-for 源是运行时变量且无 :keypatchChildren → patchUnkeyedChildren是(逐位)
无 flag手写 render / 手动 cloneVNodepatchChildren(走 shapeFlag 分派)是(全量)

注意 STABLE_FRAGMENT 有额外的严格条件(renderer.ts:1132-1140):

if (
  patchFlag > 0 &&
  patchFlag & PatchFlags.STABLE_FRAGMENT &&
  dynamicChildren &&
  // #2715 the previous fragment could've been a BAILed one as a result
  // of renderSlot() with no valid children
  n1.dynamicChildren &&
  n1.dynamicChildren.length === dynamicChildren.length
) {

四个条件必须同时成立才走 block 快路径:新的 patchFlag 是 stable、有 dynamicChildren、旧的也有、且两者长度相等。长度不等意味着"dynamicChildren 结构不稳定"(例如手动 cloneVNode 破坏了 block 树),此时退回到 patchChildren 全量比较。

12.11.3 STABLE_FRAGMENT 的 patchBlockChildren

// packages/runtime-core/src/renderer.ts:1143-1164
patchBlockChildren(
  n1.dynamicChildren, dynamicChildren, container,
  parentComponent, parentSuspense, namespace, slotScopeIds,
)
if (__DEV__) {
  traverseStaticChildren(n1, n2)
} else if (
  // #2080 if the stable fragment has a key, it's a <template v-for> that may
  //  get moved around. Make sure all root level vnodes inherit el.
  n2.key != null ||
  (parentComponent && n2 === parentComponent.subTree)
) {
  traverseStaticChildren(n1, n2, true /* shallow */)
}

patchBlockChildren(renderer.ts:1001-1037)遍历 dynamicChildren,对每一对做 patch,并计算正确的 container:若 oldVNode 是 Fragment 或类型不同或含组件/Teleport/Suspense,用 hostParentNode(oldEl),否则用传入的 fallbackContainer。这是为了处理嵌套结构里"节点实际父容器可能不同"的情况。

traverseStaticChildren 是"把旧树的 el 引用浅拷贝到新树"的辅助(生产环境在 keyed fragment 可能被移动时才调用),保证被移动的静态节点也能通过新 vnode 访问到 DOM。

12.11.4 keyed / unkeyed 分支与 anchor

否则进入:

// packages/runtime-core/src/renderer.ts:1170-1180
patchChildren(
  n1, n2, container,
  fragmentEndAnchor,   // ← anchor
  parentComponent, parentSuspense, namespace, slotScopeIds, optimized,
)

关键点:Fragment 场景传给 patchChildren 的 anchor 是 fragmentEndAnchor(结束锚点),而不是 null。 这是因为 Fragment 的 children 是"夹在两个锚点之间"的;任何往末尾追加的挂载操作,都必须插到结束锚点之前,否则会跑到 Fragment 边界之外,破坏锚点包裹关系。

回顾 12.6 的步骤③:anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor。当新窗口延伸到末尾时用 parentAnchor——在元素场景是 null(插到末尾),在 Fragment 场景就是 fragmentEndAnchor(插到结束锚点前)。这就是 parentAnchor 这个参数名背后承载的语义。

processFragment 的调用方 patch(renderer.ts:438-450)会把 anchor 一路透传下来。

12.11.5 多根节点组件与 Fragment 的关系

组件的"多根模板"(Vue 3 的 fragments)会被编译器编译成一个 Fragment 作为组件 render 的返回。此时组件 subTree 就是 Fragment,移动组件(move)时会走 12.9.6 里的 Fragment 分支:先插起始锚点、逐个 move 子节点、再插结束锚点。

一个实践推论:多根组件的根级 v-for 若不带 key,其 diff 走 UNKEYED_FRAGMENT,无法跨位置复用。这正是 Vue 官方文档建议"多根组件里用 v-for 时要配 key"的原因。

12.11.6 Teleport 与 Suspense 的 children

  • Teleport(components/Teleport.ts:115-119):注释明确 "Teleport always* has Array children. This is enforced in both the compiler and vnode children normalization."。它的 process 在 n1 == null 时把 children 挂载到 target 容器,更新时调用 patchChildren(...)(Teleport.ts:247)走标准 diff。因为 Teleport 的 DOM 分散在不同父容器,move 被委托给 TeleportImpl.move(Teleport.ts:397-412)。
  • Suspense(components/Suspense.ts):它的 children 是"默认插槽内容",el/anchor 由内部管理,move 委托给 vnode.suspense.move。其 diff 由内部 subTree 的 patch 驱动,逻辑上仍复用 patchChildren。

所以 patchKeyedChildren 是"所有多子节点容器"共享的 diff 内核。


12.12 与 Vue 2 双端 diff 的对比

12.12.1 Vue 2 updateChildren 骨架(回顾)

Vue 2 的双端比较维护四个指针 oldStartIdx / oldEndIdx / newStartIdx / newEndIdx,每轮:

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (oldStartVnode == null) oldStartVnode = oldCh[++oldStartIdx]
  else if (oldEndVnode == null) oldEndVnode = oldCh[--oldEndIdx]
  else if (newStartVnode == null) newStartVnode = newCh[++newStartIdx]
  else if (newEndVnode == null) newEndVnode = newCh[--newEndIdx]
  else if (sameVnode(oldStartVnode, newStartVnode)) { patch; ++oldStartIdx; ++newStartIdx }
  else if (sameVnode(oldEndVnode, newEndVnode))     { patch; --oldEndIdx; --newEndIdx }
  else if (sameVnode(oldStartVnode, newEndVnode))   { patch + move oldStart before oldEnd; ... }
  else if (sameVnode(oldEndVnode, newStartVnode))   { patch + move oldEnd before oldStart; ... }
  else {
    // 无命中:在 oldCh 中查找 newStartVnode
    idxInOld = oldKeyToIdx[newStartVnode.key] /* 或线性查找 */
    if (!idxInOld) { createElm(newStartVnode); ++newStartIdx }
    else { vnodeToMove = oldCh[idxInOld]; if (sameVnode) { patch; oldCh[idxInOld]=undefined; move before oldStart } else { createElm } ; ++newStartIdx }
  }
}

12.12.2 对比表

维度Vue 2 双端 diffVue 3 五步走 + LIS
预处理无(直接进入四指针循环)步骤①②先剥头尾前缀/后缀
查找旧位置oldKeyToIdx map 或线性查找旧数组keyToNewIndexMap Map,O(1)
中间乱序段策略逐轮四两比较 + 单点 move(贪心)建 newIndexToOldIndexMap + LIS 求最小移动集
时间复杂度(最坏)O(n²)(线性查找旧位置)O(n log n)(Map 查找 O(n) + LIS O(n log n))
平均移动次数贪心,非最优LIS 保证最少(S−LIS)
完全逆序 [a,b,c,d]→[d,c,b,a]4 次 move(含一次新建补偿,实测 3~4 次)3 次 move(最优)
纯头插/尾删O(k),但需经过指针推进O(k),步骤①②③短路,零 move
提前退出无显式短路(靠指针条件)i > e1 / i > e2 显式短路,跳过 LIS
稳定性移动次数随乱序程度线性恶化移动次数有理论下界保证
顺序不变(moved=false)仍每轮比较 4 次完全跳过移动循环
代码结构单函数大 while五步显式分段,可读性/可测试性更好

12.12.3 移动次数的直观对比

以"完全逆序"为例,Vue 2 双端比较的过程([a,b,c,d]→[d,c,b,a]):

  • 轮 1:oldStart=a, newStart=d 不匹配;oldEnd=d, newEnd=a 不匹配;oldStart=a, newEnd=a 匹配 → patch a 并 move 到 oldEnd 之后([b,c,d,a]),++oldStart(→b),--newEnd(→c)。
  • 轮 2:oldStart=b, newStart=d;oldEnd=d, newEnd=c 不匹配;oldStart=b, newEnd=c 不匹配;oldEnd=d, newStart=d 匹配 → patch d 并 move 到 oldStart 之前([d,b,c,a]),--oldEnd(→c),++newStart(→c)。
  • 轮 3:oldStart=b, newStart=c;oldEnd=c, newEnd=c 匹配 → patch c(不动),--oldEnd(→b),--newEnd(→b)。
  • 轮 4:oldStart=b, newStart=b 匹配 → patch b。
  • 结果:3 次 move(a、d 各一次,c 是"尾对尾"命中)。恰好也是 3 次——但这个"巧合"只发生在完全逆序且长度小的时候;规模一大、乱序一混合,Vue 2 的贪心就会偏离最优。

Vue 3 的 3 次 move 是可证明的最优(|S|−|LIS| = 4−1 = 3),与输入内容无关。

12.12.4 一个反直觉的事实

很多人以为"Vue 3 一定比 Vue 2 的 diff 移动更少"。严谨的说法是:Vue 3 的移动次数 ≤ Vue 2 的移动次数,且差距随乱序复杂度增大而增大,因为 Vue 2 是局部贪心(可能达到最优也可能不),Vue 3 是全局最优(以 LIS 为不动集)。在"顺序基本不变"的场景两者移动数都为 0;在"随机 shuffle"场景 Vue 3 明显占优。

同时要注意:Vue 3 的 diff 额外付出了建 Map + LIS 的成本。所以"移动更少"不代表"总时间一定更少"——在 CPU 密集的 JS 层面,Vue 3 多花了 O(n) 建图和 O(n log n) 求 LIS;但换来了 DOM 层的显著减少,而 DOM 是更贵的。综合账本见 12.13。

12.12.5 横向对比:Snabbdom 与 React

把 Vue 3 的 keyed diff 放到 Web 框架的坐标系里:

框架diff 策略移动集合的求法复杂度
Vue 2双端四指针比较每轮贪心 move最坏 O(n²)
Vue 3前后缀剥离 + 乱序段 LISnewIndexToOldIndexMap + LISO(n log n)
Snabbdom双端比较(updateChildren)+ oldKeyToIdx 映射双端贪心平均 O(n),最坏 O(n²)
React(旧版 reconcileChildrenArray)单向双指针 + lastPlacedIndex贪心"若 idx < lastPlacedIndex 则 move"O(n)(Map 查找 + 一次遍历)
React(Fiber)同旧版思路,带 priority同上,可中断O(n)

各自的取舍:

  • Snabbdom:双端思路的现代实现,用 oldKeyToIdx(懒构建)缓解线性查找,但它不做"全局最优移动",所以乱序时移动次数仍可能偏多。Vue 3 的"剥头尾 + 中间段 LIS"在思路上与 Snabbdom 有血缘(都先处理头尾),但在中间段用 LIS 做了质变。
  • React:reconcileChildrenArray 维护 lastPlacedIndex,对每个新 child 用 existingChildren Map 找到旧 fiber;若旧 index < lastPlacedIndex 则标记 Placement(需要移动),否则更新 lastPlacedIndex = oldIndex。这本质上也是"求一个单调递增序列",但它只取扫描过程中的最长前缀式贪心,并不等价于全局 LIS。因此在某些输入上 React 的移动次数会多于 Vue 3。React 的 lastPlacedIndex 逻辑可以看作"单遍贪心版 LIS"。
  • Vue 3:唯一显式调用 O(n log n) LIS 的框架,换来可证明的最少移动。

一个值得记的结论:"最少移动"与"最低 CPU"是两回事。React 的 O(n) 贪心在 CPU 上比 Vue 3 的 O(n log n) 便宜,但在 DOM 上可能多移动几次。Vue 3 的赌注是"DOM 比 JS 贵",这在浏览器环境通常是成立的,但在超大数据集 + 极少移动时,Vue 3 的建图/LIS 开销可能成为主要成本——这也是 moved === false 短路存在的意义。


12.13 性能实测思路与可复现 benchmark

12.13.1 我们要测量什么

diff 的性能无法只看"总耗时",因为 DOM 操作与 JS 计算混在一起。建议测两组正交指标:

  1. DOM 操作计数:insertBefore、removeChild、appendChild、createElement 的调用次数。这直接反映 diff 的"移动/挂载账本"。
  2. 端到端耗时:从"数据变化"到"DOM 更新完成"的 performance.now() 差值,反映综合开销。

测三项操作,对应三种 diff 路径:

  • head-insert:在 1 万条列表头部插入 1 项。预期:步骤②③短路,只 1 次 mount、0 次 move。
  • tail-delete:删除 1 万条列表尾部 1 项。预期:步骤①④短路,只 1 次 remove、0 次 move。
  • random-shuffle:把 1 万条列表随机打乱。预期:进入步骤⑤,moved=true,LIS 长度约 √n 量级,move 次数 ≈ n − |LIS|;耗时出现 LIS 的 O(n log n) 峰值。

12.13.2 计数器:拦截宿主操作

最干净的测量方式是把渲染器换成"计数版 nodeOps",这样完全不依赖真实 DOM,纯 JS 计数(也便于在 Node 里跑)。

// bench/harness.js —— 用计数宿主操作替代真实 DOM
import { createRenderer } from '@vue/runtime-core'
import { PatchFlags } from '@vue/shared'

export const counts = {
  create: 0, insert: 0, remove: 0, move: 0, setText: 0, patchProp: 0,
}

// 一个"空节点"表示法:只需要能相互比较、能表示父子关系
let uid = 0
const makeNode = (tag) => ({ __id: ++uid, tag, children: [], parent: null })

const ops = {
  createElement: (tag) => { counts.create++; return makeNode(tag) },
  createText: (text) => { counts.create++; return { __id: ++uid, text } },
  createComment: (text) => ({ __id: ++uid, comment: text }),
  setText: () => { counts.setText++ },
  setElementText: (el, text) => { counts.setText++; el.text = text },
  insert: (child, parent, anchor) => {
    // 区分 append 与 insertBefore:anchor 非空视为移动/插入到某处
    if (anchor) counts.move++; else counts.insert++
  },
  remove: () => { counts.remove++ },
  patchProp: () => { counts.patchProp++ },
  parentNode: (node) => node.parent,
  nextSibling: () => null,
  querySelector: () => null,
  setScopeId: () => {},
  insertStaticContent: () => [makeNode('static'), makeNode('static-end')],
}

const { createApp } = createRenderer(ops)

说明:insert(child, parent, anchor) 里,anchor == null 表示 appendChild(纯挂载),anchor != null 表示 insertBefore(可能是挂载到中间,也可能是移动)。要精确区分"移动"与"插入",可以再加一层判断——但一个更简单的办法是在 move 函数里手动打点:

// 若你愿意改源码做实验,可在 move 里计数(仅用于本地 benchmark,勿提交)
// 生产代码里可直接包裹 renderer internals.m 来观测

不过更推荐不改源码:用上面 anchor 的近似即可得到"结构操作总量"的对比趋势。

12.13.3 构造数据的可复现脚本

// bench/run.js
import { createApp, h, ref, nextTick } from 'vue'
import { counts } from './harness.js'

const N = 10000

const App = {
  setup() {
    const list = ref(
      Array.from({ length: N }, (_, i) => ({ id: i, text: `item-${i}` })),
    )

    function reset() {
      for (const k in counts) counts[k] = 0
    }

    window.__bench = {
      reset,
      headInsert() {
        list.value.unshift({ id: -1, text: 'item--1' })
      },
      tailDelete() {
        list.value.pop()
      },
      shuffle() {
        const a = list.value.slice()
        for (let i = a.length - 1; i > 0; i--) {
          const j = (Math.random() * (i + 1)) | 0
          ;[a[i], a[j]] = [a[j], a[i]]
        }
        list.value = a
      },
      headInsertN(k) {
        const add = Array.from({ length: k }, (_, i) => ({ id: -k + i, text: `pre-${i}` }))
        list.value = [...add, ...list.value]
      },
    }

    return () =>
      h(
        'ul',
        list.value.map((item) =>
          // 关键:带 key,走 patchKeyedChildren
          h('li', { key: item.id }, item.text),
        ),
      )
  },
}

const app = createApp(App).mount(document.createElement('div'))

async function bench(name, fn) {
  // 预热
  for (let w = 0; w < 3; w++) { fn(); await nextTick() }
  countsSnapshot(counts) && null
  window.__bench.reset()
  const t0 = performance.now()
  fn()
  await nextTick()                       // 等待渲染队列 flush
  const t1 = performance.now()
  console.log(name, 'ms=', (t1 - t0).toFixed(2), 'ops=', { ...counts })
}

bench('head-insert', () => window.__bench.headInsert())
bench('tail-delete', () => window.__bench.tailDelete())
bench('shuffle', () => window.__bench.shuffle())

要观察"1 万条",务必让列表真的渲染 1 万条(不要用虚拟滚动),否则测的是虚拟列表而非 diff。若浏览器卡顿,可降到 5000 条并保持趋势一致。

12.13.4 预期结论

操作预期 mount预期 move是否进入 LIS备注
head-insert(N=10000)10否步骤②把尾部同步、步骤③挂头部
tail-delete(N=10000)00否步骤①同步前缀、步骤④卸载尾部
shuffle(N=10000)0≈ N −LIS是LIS期望约 2√N ≈ 200,故 move ≈ 9800

对 shuffle,理论上的 LIS 长度期望约 2√N(随机排列的最长递增子序列期望长度 ~2√N)。N=10000 时约 200,故需要移动约 9800 个节点。这是不可避免的——随机乱序本身就是一个接近"全乱"的输入。LIS 的价值在于"已经有序的子串被识别出来不移动":如果数据是"局部有序 + 少量抖动",LIS 长度会接近 N,move 次数就接近"抖动数",收益巨大。

可以再加一组对比实验:"把一个元素从末尾移到开头"(rotate)。此时 LIS 长度 = N−1(其余 N−1 个相对顺序不变),只有 1 次 move——这正是 LIS 的最优体现。

12.13.5 关于"耗时"的解读

shuffle 的耗时通常呈现"两段式":

  • N 较小时(如 ≤2000),JS 计算占主导,Vue 3 的建图 + LIS 使耗时略高于"纯逐位重建",但差距在毫秒级。
  • N 较大时(如 ≥10000),DOM 操作占主导,Vue 3 由于移动更少、DOM 抖动更小,端到端耗时通常优于"全量重建"。

如果发现 Vue 3 在 shuffle 下比"全量重建"还慢,多半是因为:

  1. 使用了无 key 列表(走了 unkeyed 逐位 diff,退化为大量 unmount+mount);
  2. DOM 计数器的 insert 与 move 没区分,把 append 也当成了 move;
  3. 列表实际是静态的(每个 li 内容不变),此时 patchFlag 会跳过大量属性更新,测量对象失焦。

12.13.6 用 patchFlag 校验是否走了 keyed 路径

一个快速的"自检":在 dev 环境打印每个 li 父 Fragment 的 patchFlag。

  • 若 v-for 源是运行时变量(如上面的 ref)且带 :key,fragmentFlag 是 KEYED_FRAGMENT(128),会进入 patchKeyedChildren——这是我们想测的对象。
  • 若源是常量表达式(如 v-for="i in 3"),fragmentFlag 是 STABLE_FRAGMENT(64),此时走 patchBlockChildren,根本不做结构 diff,测它没有意义。
  • 若源是运行时变量但无 :key,是 UNKEYED_FRAGMENT(256),走逐位比较。

所以:要做 keyed diff benchmark,务必让 v-for 的源是"运行时变量"且带 :key(如上面的 ref + h('li', { key: item.id }, ...)),否则你测到的可能是 Block 树 patch(stable)或无 key 逐位 diff(unkeyed),而不是本章的主题。


12.14 本章要点回顾

12.14.1 一页速查表

主题关键结论
diff 目标最小化 DOM 结构操作(尤其 move/insert/remove),而非最小化 JS 比较
四象限分派patchFlag 优先(KEYED→keyed、UNKEYED→unkeyed),否则按 shapeFlag 收敛为 4 条路径
无 key diff逐位比较 + 公共长度多退少补;中间插入会整段错配
五步走①头同步 ②尾同步 ③新多 mount ④旧多 unmount ⑤乱序段
短路条件i > e1 / i > e2 ⇒ 不建图、不算 LIS
isSameVNodeTypetype === && key ===(dev 加 HMR 判断)
keyToNewIndexMapkey → 新绝对下标;重复 key dev 警告、后者胜
newIndexToOldIndexMap索引=新段相对下标,值=旧段相对下标 +1;0 是新增哨兵
moved仅当 newIndex < maxNewIndexSoFar(新索引回退)时为 true
getSequencepatience sorting + 二分 + 前驱回溯,O(n log n),跳过 0
LIS 语义返回 相对下标 序列;长度 = 最大不动集
移动阶段倒序遍历,anchor 取 右邻居;命中 LIS 则不动,否则 move(...REORDER)
移动下界`S−LIS`,即最少移动次数
Fragment双文本锚点;patchChildren 的 anchor 传 fragmentEndAnchor
STABLE_FRAGMENT常量源/模板根 → 走 patchBlockChildren,不做结构 diff

12.14.2 五个最容易答错的点

  1. newIndexToOldIndexMap 的 +1 不是笔误。 它是为了把"旧下标 0"与"新增哨兵 0"区分开。漏掉这一点就会误判第一个旧节点为"新增"。
  2. moved 与 LIS 是"是否需要"的关系,不是"是否发生移动"的关系。 moved=false 时一个节点都不移动,LIS 也不会被计算。
  3. isSameVNodeType 的失败发生在 patch 内部,不在 Map 查找层。 key 相同但 type 不同,5.2 仍会"配对成功",然后由 patch 内部卸载重建。
  4. 移动阶段必须倒序。 只有右侧已归位,insertBefore(node, rightSibling) 才是安全的。
  5. STABLE_FRAGMENT 不经过 patchKeyedChildren。 用 ref 作为 v-for 源 + :key 才会走 keyed diff。

12.14.3 复杂度账本

步骤①:          ≤ min(n, m) 次 patch,O(k)
步骤②:          O(k)
步骤③/④:        O(k)
步骤⑤ 5.1 建图:  O(m)          (m = 新乱序段长度)
步骤⑤ 5.2 配对:  O(n) + O(n·t) (t 为无 key 节点比例,t=0 时纯 O(n))
步骤⑤ 5.3 LIS:   O(m log m)(仅 moved 时)
步骤⑤ 5.3 移动:  O(m)
────────────────────────────
总计:            O(n + m log m)  (纯 keyed、无混合)

对比 Vue 2 的最坏 O(n²),Vue 3 在"乱序严重"的输入上有数量级优势。

12.14.4 设计哲学总结

Vue 3 的 diff 体现了三条一以贯之的原则:

  1. 能用编译期信息就别在运行期算——patchFlag 让 patchChildren 一步到位选中 keyed/unkeyed,STABLE_FRAGMENT 直接跳过结构 diff。
  2. 把"是否要做"与"怎么做"分开——moved 决定"要不要 LIS",getSequence 决定"怎么移动",两者解耦,避免为不需要的场景付费。
  3. 把最坏情况做成最优——乱序段的移动次数由 LIS 保证取下界,代价是一次 O(n log n) 计算,而省下的是更贵的 DOM 操作。

附录 A:面试深度问答

Q1:为什么 diff 不直接用"最长公共子序列(LCS)"而要转成 LIS?

LCS 是求两个序列的最长公共子序列,本身 O(nm)。但我们的问题有一个特殊性:新旧元素通过 key 已经建立了一一对应,不存在"这个元素删掉、那个元素新增"之外的匹配歧义。于是"公共子序列"退化为"在新旧两序列中相对顺序一致的元素集合"——把新序列映射到"旧索引"后,问题就变成"求映射数组的 LIS",可以 O(n log n)。LCS 更通用但更慢;LIS 利用了"key 一一对应"这一约束。

Q2:只用 key(不用 type)判断能否复用行不行?

不行。key 是用户声明的"逻辑身份",type 是"渲染身份"。同一个 key 下把 <li> 换成 <div>,DOM 结构根本不同,必须卸载重建。isSameVNodeType 取 type && key 是"既认同逻辑身份、又认同渲染身份"。

Q3:为什么无 key 列表在中间插入会导致整段重建?

逐位比较把"位置"当成身份。[A,B,C] 前插 X 后逐位变 (A,X)(B,A)(C,B),每对都类型不匹配 → 每对都卸载旧、挂载新,等于重建。这演示了"key 是跨位置复用的唯一凭据"。

Q4:maxNewIndexSoFar 的初始值为什么是 0,而不是 −1?

因为它与"新索引"比较,而新索引是"绝对下标",最小可能就是 0(s2 ≥ 0)。初始为 0 保证第一个配对的元素 newIndex >= 0 一定成立,从而走"更新最大值"分支(不回退)。若初始为 −1,虽也能工作,但源码选 0 与"索引从 0 起"的语义一致。

Q5:getSequence 里为什么 p = arr.slice() 而不是 new Array(n)?

因为 p 既要充当"前驱数组",又要在"某元素未更新前驱"时保留一个有效的初始值。回溯时 v = p[v] 可能访问到"没被显式赋值过的位置",此时 p[v] = v(自指)会让回溯在该处停下——arr.slice() 恰好提供 p[i] = arr[i],虽然值不是下标含义,但在"该位置从未成为前驱链一环"时不参与回溯路径,从而无害。用 arr.slice() 是一种"惰性填充 + 自终止"技巧。

Q6:如果 LIS 返回的长度为 0(空数组),移动阶段会怎样?

j = -1。所有 map[i] !== 0 的节点都走 j < 0 || i !== seq[j] → 因为 j<0 恒真 → 全部 move。这正是"没有稳定的不动节点"(如完全逆序)时的正确行为:所有节点都要归位。源码注释也写了 "There is no stable subsequence (e.g. a reverse)"。

Q7:为什么把 anchor 存在循环外可能出问题?

在 5.3 里 anchor 是每次迭代重新计算的(anchorVNode = c2[nextIndex + 1]),不是循环外。这一点与步骤③(anchor 循环外算一次、循环内复用)不同。原因:步骤③是从左往右挂载一批新节点,它们的右邻居是同一个"未受影响节点",所以 anchor 固定;步骤⑤是倒序遍历、每个节点的右邻居都不同,所以 anchor 每轮不同。

Q8:toBePatched 与 patched 相等时为什么可以直接 unmount 剩余的旧节点?

因为 patched === toBePatched 意味着新乱序段的每个位置都已找到旧节点配对,剩余旧节点没有任何新位置可去,只能删除。此时跳过 Map 查找直接 unmount,是一个纯粹的优化(减少无谓查表),语义与逐个查表一致。

Q9:为什么 5.3 里 move 用 MoveType.REORDER 而不是默认类型?

REORDER 让 move 内部的 needTransition = moveType !== MoveType.REORDER && ... 为假,从而跳过过渡(transition)逻辑。diff 的移动是"纯重排",不应该触发 enter/leave 动画——否则每次乱序都会播放一堆淡入淡出,既慢又错。

Q10:cloneIfMounted 与 normalizeVNode 分别在什么场景用?

optimized === true(编译器产出的 block 子树)时用 cloneIfMounted:孩子已是 VNode,但可能被复用(如缓存的静态 vnode),只在"已挂载"时克隆,避免污染旧树。optimized === false(手写 render)时用 normalizeVNode:孩子可能是原始值(字符串/数字/null/数组),需要归一化成 VNode(Text/Comment/Fragment)。

Q11:getSequence 为什么可以"跳过 0"而不影响正确性?

0 表示"该新位置无旧节点"。在 12.8.9 的证明中,不动集合 U 必须是"有旧对应的新位置"的子集。跳过 0 等价于把 0 从 S 中排除,符合证明前提。若把 0 纳入,它会作为最小值成为 LIS 的一部分,导致"新增节点被当成不动锚点",从而漏掉挂载。

Q12:为什么 key 比较用 key != null 而不是 key !== undefined?

因为 key 的"无值"表示可能是 null 或 undefined(用户可能写 :key="null"、或 unkeyed vnode 的 key 就是 null)。用 != null 同时排除了 null 与 undefined,语义是"只要不是空就是有效 key"。

Q13:能否把 LIS 换成"人工挑选不动集合"?

能,但没有任何启发式能保证最优。LIS 的价值正是"可证明的最少移动"。若用贪心(如 React 的 lastPlacedIndex),通常不错但可能非最优。Vue 选择多花 O(n log n) 换严格最优。

Q14:为什么步骤①②要"先头后尾"而不是"同时两端"?

顺序上先头后尾是因为它们共享 i 下界:头部同步把 i 推到一个"头已定"的位置,尾部再在 [i, e] 窗口里收缩。若同时从两端夹逼也能实现,但分成两条清晰的 while 更易读、更易在中间插入日志/断点。功能上等价。

Q15:patchUnkeyedChildren 与 patchKeyedChildren 在"顺序完全不变"时的行为差异?

无 key:逐位 patch,代价 O(commonLength),无 Map/LIS。 有 key:步骤①②会"吃掉"整段(若顺序不变且 key 一致),同样 O(k),但因为有 key,它能处理"顺序变化"的场景;无 key 则无能为力。这就是"key 换灵活性"的账。


附录 B:再补三个指针推演案例

为满足"至少 6 个含指针状态的案例",前面 12.10 已给 6 个(逆序、全替换、头插、尾删、重复 key、类型变化),此处再补 3 个"混合乱序 + 增删"的复合案例,覆盖五步同时生效的情况。

案例七:前缀 + 后缀 + 中间增删混合

c1 = [a, b, c, d, e],c2 = [a, b, f, c, g, e]。

步骤①:a==a patch(i=1)、b==b patch(i=2);c1[2]=c vs c2[2]=f → 不同 → break。i=2。

步骤②:窗口 i=2, e1=4, e2=5。

  • c1[4]=e vs c2[5]=e → 同 → patch;e1=3, e2=4。
  • c1[3]=d vs c2[4]=g → 不同 → break。

窗口:i=2, e1=3, e2=4。s1=s2=2, toBePatched=3。

5.1:keyToNewIndexMap = { f:2, c:3, g:4 }。

5.2:遍历旧 i=2..3:

iprevChildkeynewIndexmap[newIndex-2]maxNewIndexSoFarmoved动作
2cc3map[1]=33falsepatch c
3ddundefined——falseunmount d

newIndexToOldIndexMap = [0, 3, 0](新位置 0=f 新增、位置 1=c 来自旧 2、位置 2=g 新增)。moved = false(只有一次配对,无回退)。

5.3:moved=false ⇒ increasingNewIndexSequence = EMPTY_ARR,j=-1。倒序 i=2,1,0:

inextIndexnextChildmap[i]anchor(c2[nextIndex+1].el)动作
24g0c2[5].el=eA: mount g → e 前
13c3c2[4].el=gC: moved=false,跳过
02f0c2[3].el=cA: mount f → c 前

帧推演(初始 DOM [a][b][c][d][e],d 已在 5.2 被卸载):

5.2 后:   [a][b][c][e]           (d 已卸载)
i=2 mount g before e: [a][b][c][g][e]
i=1 c 不动:            [a][b][c][g][e]
i=0 mount f before c: [a][b][f][c][g][e]

结果 a b f c g e ✅。0 次 move,2 次 mount,1 次 unmount。 注意这里 c 之所以"不动"是正确的——它在旧序列中的相对位置就是"b 之后",新序列里也仍在"b 之后、f 之后",但由于 f 是插在它前面的,c 自身没有跨越其他有 key 节点,无需移动。算法通过 moved=false 直接跳过移动,完全正确。

案例八:中间段互换 + 尾部追加

c1 = [a, b, c, d],c2 = [a, b, d, c, e]。

步骤①:a==a(i=1)、b==b(i=2);c1[2]=c vs c2[2]=d → break。i=2。

步骤②:窗口 i=2,e1=3,e2=4:c1[3]=d vs c2[4]=e → 不同 → break。

窗口 i=2,e1=3,e2=4,s1=s2=2,toBePatched=3。

5.1:keyToNewIndexMap = { d:2, c:3, e:4 }。

5.2:

iprevChildnewIndexmap[newIndex-2]maxNewIndexSoFarmoved动作
2c3map[1]=33falsepatch c
3d2map[0]=43→2 回退truepatch d

newIndexToOldIndexMap = [4, 3, 0]。

getSequence([4,3,0]):

  • i=0 arrI=4:result=[0],arr[0]=4<4? 否;u=0;4<4? 否 → 不动。result=[0]。
  • i=1 arrI=3:j=0,arr[0]=4<3? 否;u=0,v=0;3<4? 是 → result[0]=1。
  • i=2 arrI=0 → skip。
  • result=[1];回溯 u=1,v=result[0]=1 → result=[1]。
  • 返回 [1]:相对下标 1(节点 c)为不动锚点。

5.3:j = 0;倒序 i=2,1,0:

inextIndexnextChildmap[i]LIS[j]anchor动作
24e0—parentAnchorA: mount e
13c3LIS[0]=1,i(1)===1—B2: 不动,j→-1
02d4j<0c2[3].el=cB1: move d → c 前

帧(初始 [a][b][c][d]):

初始:          [a][b][c][d]
i=2 mount e:   [a][b][c][d][e]
i=1 c 不动:     [a][b][c][d][e]
i=0 move d before c: [a][b][d][c][e]

结果 a b d c e ✅。1 次 move + 1 次 mount;理论下界 |S|−|LIS| = 2−1 = 1,达到最优。

案例九:空旧段(全部新增)

c1 = [],c2 = [x, y]。

e1 = -1, e2 = 1。步骤①:i(0) <= e1(-1) 假 → 不执行。步骤②同理。步骤③:i(0) > e1(-1) 真;i(0) <= e2(1) 真 → nextPos = e2+1 = 2;2 < l2(2)? 否 → anchor = parentAnchor。while i(0) <= e2(1):mount x(anchor=parentAnchor)、mount y(anchor=parentAnchor);i=2。

x 与 y 都用同一个 anchor=parentAnchor(末尾)从左往右依次挂载,先 x 后 y,结果 [x][y] ✅。完全不进入步骤⑤。

这个案例验证了:当旧窗口为空时,e1 = -1 让步骤③接管一切,纯新增零 LIS。

案例十补充:单元素位移(rotate)

c1 = [a, b, c, d, e],c2 = [e, a, b, c, d](把 e 从末尾移到开头)。

步骤①:a vs e → 不同 → break,i=0。 步骤②:c1[4]=e vs c2[4]=d → 不同 → break。 窗口 i=0, e1=4, e2=4,s1=s2=0, toBePatched=5。

5.1:keyToNewIndexMap = { e:0, a:1, b:2, c:3, d:4 }。

5.2:

iprevChildnewIndexmap[newIndex]maxNewIndexSoFarmoved
0a1map[1]=11false
1b2map[2]=22false
2c3map[3]=33false
3d4map[4]=44false
4e0map[0]=54→0 回退true

newIndexToOldIndexMap = [5, 1, 2, 3, 4],moved=true。

getSequence([5,1,2,3,4]):

  • i=0 arrI=5:result=[0](值 5),不动。
  • i=1 arrI=1:j=0,arr[0]=5<1? 否;二分 u=0;1<5? 是 → result[0]=1。
  • i=2 arrI=2:j=1,arr[1]=1<2? 是 → p[2]=1,result=[1,2]。
  • i=3 arrI=3:j=2,arr[2]=2<3? 是 → p[3]=2,result=[1,2,3]。
  • i=4 arrI=4:j=3,arr[3]=3<4? 是 → p[4]=3,result=[1,2,3,4]。
  • 回溯:u=4,v=result[3]=4;result[3]=4, v=p[4]=3;result[2]=3, v=p[3]=2;result[1]=2, v=p[2]=1;result[0]=1, v=p[1]=1(p[1] 初值=arr[1]=1)。
  • 返回 [1,2,3,4](长度 4)。

5.3:j=3;倒序 i=4..0:

inextChildmap[i]LIS[j]判定动作
4d4LIS[3]=4i===4 → B2不动,j→2
3c3LIS[2]=3i===3 → B2不动,j→1
2b2LIS[1]=2i===2 → B2不动,j→0
1a1LIS[0]=1i===1 → B2不动,j→-1
0e5j<0B1move e → a 前

结果:只有 1 次 move(把 e 挪到最前),其余 4 个不动。理论下界 |S|−|LIS| = 5−4 = 1,达到最优。这直观地展示了 LIS 的威力——"移动一个元素"被正确识别为"只需移动 1 次"。


附录 C:一个可运行的 mini-diff 参考实现

把本章讲到的 keyed diff 逻辑抽成一个 60 行的独立函数,输入两个数组(元素形如 { key, value }),输出"更新/移动/挂载/卸载"的操作序列。它复刻了 patchKeyedChildren 的五步与 getSequence,可直接在 Node 里跑,用来验证你对手工推演的理解。

// mini-diff.js —— 纯逻辑版 patchKeyedChildren + getSequence
function getSequence(arr) {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  for (i = 0; i < arr.length; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) { p[i] = j; result.push(i); continue }
      u = 0; v = result.length - 1
      while (u < v) { c = (u + v) >> 1; (arr[result[c]] < arrI) ? (u = c + 1) : (v = c) }
      if (arrI < arr[result[u]]) { if (u > 0) p[i] = result[u - 1]; result[u] = i }
    }
  }
  u = result.length; v = result[u - 1]
  while (u-- > 0) { result[u] = v; v = p[v] }
  return result
}

function sameType(a, b) { return a && b && a.key === b.key }  // 简化:只比 key

function diffKeyed(c1, c2) {
  const ops = []
  let i = 0
  const l2 = c2.length
  let e1 = c1.length - 1
  let e2 = l2 - 1

  // 1. 头同步
  while (i <= e1 && i <= e2) {
    if (sameType(c1[i], c2[i])) { ops.push(['update', i, c2[i].key]); i++ }
    else break
  }
  // 2. 尾同步
  while (i <= e1 && i <= e2) {
    if (sameType(c1[e1], c2[e2])) { ops.push(['update', e1, c2[e2].key]); e1--; e2-- }
    else break
  }

  if (i > e1) {
    // 3. 新多则挂载
    while (i <= e2) { ops.push(['mount', i, c2[i].key]); i++ }
  } else if (i > e2) {
    // 4. 旧多则卸载
    while (i <= e1) { ops.push(['unmount', i, c1[i].key]); i++ }
  } else {
    // 5. 乱序段
    const s1 = i, s2 = i
    const keyToNew = new Map()
    for (i = s2; i <= e2; i++) keyToNew.set(c2[i].key, i)

    const toBePatched = e2 - s2 + 1
    const map = new Array(toBePatched).fill(0)
    let patched = 0, moved = false, maxSoFar = 0
    for (i = s1; i <= e1; i++) {
      const prev = c1[i]
      if (patched >= toBePatched) { ops.push(['unmount', i, prev.key]); continue }
      const newIndex = keyToNew.get(prev.key)
      if (newIndex === undefined) { ops.push(['unmount', i, prev.key]) }
      else {
        map[newIndex - s2] = i + 1
        if (newIndex >= maxSoFar) maxSoFar = newIndex
        else moved = true
        ops.push(['update', newIndex, prev.key])
        patched++
      }
    }

    const seq = moved ? getSequence(map) : []
    let j = seq.length - 1
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i
      const isNew = map[i] === 0
      if (isNew) { ops.push(['mount', nextIndex, c2[nextIndex].key]) }
      else if (moved) {
        if (j < 0 || i !== seq[j]) ops.push(['move', nextIndex, c2[nextIndex].key])
        else j--
      }
    }
  }
  return ops
}

// 验证:逆序
console.log(diffKeyed(
  [{key:'a'},{key:'b'},{key:'c'},{key:'d'}],
  [{key:'d'},{key:'c'},{key:'b'},{key:'a'}],
))
// → [ [ 'update', 3, 'a' ], [ 'update', 2, 'b' ], [ 'update', 1, 'c' ],
//     [ 'update', 0, 'd' ], [ 'move', 2, 'b' ], [ 'move', 1, 'c' ],
//     [ 'move', 0, 'd' ] ]

对照它与你手推的结果:逆序案例的 move 数应为 3、LIS 长度应为 1、update 数应为 4。把 c1/c2 换成附录 B 的案例七、案例十,输出应分别得到"0 move + 2 mount + 1 unmount"与"1 move"。

把这个函数作为"单元测试"来理解源码,比逐行读 renderer.ts 更直观——因为它的每一步都能打印指针。


附录 D:LIS 完整状态表(含扩展与替换两类分支)

12.8.5 的输入 [5,4,3,0] 只触发了"替换"分支,不足以展示"扩展"分支与 p 数组的作用。这里给一个更完整的输入 [2, 10, 3, 4, 5],它既有扩展也有替换,是经典 LIS 教学用例。

初始:p = [2,10,3,4,5],result = [0]。

iarrIresult 末 jarr[j]扩展?二分 u/vresult 变化p 变化
02022<2? 否u=0,v=0;2<2? 否不变不变
110022<10? 是—push → [0,1]p[1]=0
2311010<3? 否二分:u=0,v=1 → c=0,arr[0]=2<3 → u=1;u=v=1;3<arr[result[1]=1]=10? 是result[1]=2u>0 → p[2]=result[0]=0
34233<4? 是—push → [0,2,3]p[3]=2
45344<5? 是—push → [0,2,3,4]p[4]=3

回溯:u=4, v=result[3]=4。

  • u→3:result[3]=4,v=p[4]=3。
  • u→2:result[2]=3,v=p[3]=2。
  • u→1:result[1]=2,v=p[2]=0。
  • u→0:result[0]=0,v=p[0]=2(自值,终止)。
  • 返回 [0,2,3,4] → 值 [2,3,4,5],长度 4。

验证:最长递增子序列确实是 [2,3,4,5](下标 0,2,3,4),长度 4。注意 i=1 的元素 10 虽然被"扩展"进过 result(作为长度 2 的结尾最优是 10),但随后被更小的 3 替换,最终 10 不在 LIS 中——这正是"tails 不是真实序列,必须回溯"的最好例证。若不回溯,result = [0,2,3,4] 恰好也正确;但如果扩展分支插入的是"更靠右且更大的值",直接返回就会错。回溯保证了任何输入下都还原真实下标链。

一个 P 数组起到关键作用的输入

输入 [1, 5, 2, 3]:

  • i=0 arrI=1:result=[0](值 1)。
  • i=1 arrI=5:扩展 → p[1]=0,result=[0,1](值 1,5)。
  • i=2 arrI=2:替换 u=1(二分找第一个 ≥2 → 位置 1,值 5)→ 2<5 是,u>0 → p[2]=result[0]=0;result=[0,2](值 1,2)。
  • i=3 arrI=3:arr[result 末=2]=2 < 3 → 扩展 → p[3]=2;result=[0,2,3](值 1,2,3)。

若不回溯,返回 [0,2,3](值 1,2,3)——恰好正确。回溯:u=3,v=result[2]=3;result[2]=3, v=p[3]=2;result[1]=2, v=p[2]=0;result[0]=0, v=p[0]=1。返回 [0,2,3]。一致。

再看输入 [3, 10, 2, 4, 5, 6](会触发"替换后又被扩展"的复杂交错):

  • i=0 (3): result=[0]。
  • i=1 (10>3): 扩展 p[1]=0, result=[0,1]。
  • i=2 (2): 二分 u=0,2<3 → result[0]=2,u=0 无前驱。result=[2,1](值 2,10)。
  • i=3 (4): 末=result[1]=1(值 10),10<4? 否 → 二分在 [2,1] 找第一个 ≥4:位置 0 值 2<4 → u=1;u=v=1;4<arr[1]=10? 是 → p[3]=result[0]=2;result=[2,3](值 2,4)。
  • i=4 (5): 末=3(值 4)<5 → 扩展 p[4]=3;result=[2,3,4](值 2,4,5)。
  • i=5 (6): 末=4(值 5)<6 → 扩展 p[5]=4;result=[2,3,4,5](值 2,4,5,6)。

回溯:u=4,v=result[3]=5;result[3]=5, v=p[5]=4;result[2]=4, v=p[4]=3;result[1]=3, v=p[3]=2;result[0]=2, v=p[2]=1。返回 [2,3,4,5] → 值 [2,4,5,6],长度 4。

验证最长递增子序列:候选 [3,4,5,6](下标 0,3,4,5)或 [2,4,5,6](下标 2,3,4,5),长度均为 4。返回的下标链是 [2,3,4,5],是一组合法 LIS(长度最优即可,具体取哪组不影响移动数)。这再次印证 12.8.10 的结论:LIS 可能不唯一,Vue 只要长度最大。


附录 E:源码中的相关文件索引

文件行号内容
packages/runtime-core/src/renderer.ts383-513patch 总分派(含 Fragment 分支与类型不匹配的卸载)
同上816-843mountChildren(含 start 参数)
同上1001-1037patchBlockChildren(block 树专用)
同上1077-1183processFragment(STABLE / keyed / unkeyed 三路)
同上1672-1772patchChildren(四象限 + patchFlag 短路)
同上1774-1831patchUnkeyedChildren
同上1834-2076patchKeyedChildren(五步走)
同上2078-2169move(各类型节点的移动语义)
同上2171-2428unmount
同上2430-2441unmountChildren(含 start 参数)
同上2607-2647getSequence(LIS)
packages/runtime-core/src/vnode.ts379-392isSameVNodeType
同上806-834normalizeVNode / cloneIfMounted
packages/shared/src/patchFlags.ts19-124PatchFlags 枚举
同上66-79STABLE/KEYED/UNKEYED_FRAGMENT 位值
packages/compiler-core/src/transforms/vFor.ts101-122v-for 的 fragmentFlag 计算与 codegen

结语

patchKeyedChildren 是 Vue 3 运行时里"信息密度"最高的一段代码:不足 250 行,却完成了一整套"头尾短路 + Map 建索引 + LIS 求最小不动集 + 倒序移动"的工程化解法。它把"最少移动"这个最优化目标,用一次 O(n log n) 的子序列计算精确地实现出来,并在四个高频场景(纯追加、纯删除、头尾变化、顺序不变)上以 O(k) 短路。

理解它的最好方式不是背代码,而是记住三个不变量:

  1. 窗口不变量:c1[i..e1] 与 c2[i..e2] 之外的区域永远已处理;e1/e2 可以越界到 −1,用来表达"空窗口"。
  2. 映射不变量:newIndexToOldIndexMap 的 0 永远表示"新增",其余值 v 表示"新段相对下标对应旧段相对下标 v−1"。
  3. 顺序不变量:移动阶段倒序遍历,右侧永远已归位,故用右邻居作 anchor。

把这三个不变量连同 getSequence 的 patience sorting 记牢,你就能在任何"列表更新错乱"的 bug 现场,一眼定位是"key 选错""前缀同步失效"还是"LIS 上游的 moved 误判"。