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 的做法是先用双指针剥掉头尾不变的部分,再对剩下那个"乱序中间段"求一个最长递增子序列,把它当作"不用挪动"的锚点集合。
这个描述里藏着三个层次:
- 操作成本模型——为什么要"尽量少碰 DOM"。DOM 是跨进程/跨语言边界的对象(浏览器内核 C++ 与 JS 引擎),一次
insertBefore的实际代价远超一次 JS 数组比较。 - 分派策略——
patchChildren先用patchFlag做 O(1) 短路,再用 shapeFlag 把 16 种新旧组合收敛成 4 条路径。这是"编译期信息减少运行期工作量"的典型。 - 核心算法——
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 求最小移动集"**。这个改变带来两个好处:
- 移动次数下限:对中间段,LIS 给出的就是"理论最少移动数"(详见 12.8 的证明)。
- 建图后 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) | 空 |
|---|---|---|---|
| text | hostSetElementText(c2) | 清空文本→mountChildren(c2) | hostSetElementText(''),c2===c1 时跳过 |
| array | unmountChildren(c1) → hostSetElementText(c2) | patchKeyedChildren(c1,c2) 全量 keyed diff | unmountChildren(c1, ..., true) |
| 空 | hostSetElementText(c2) | mountChildren(c2) | 无操作 |
几点细节值得注意:
- text→text 的
c2 !== c1判断(renderer.ts:1728):字符串是原始值比较,只有内容变了才写 DOM。这是"文本子节点"的极小优化。 - array→text:先卸载所有旧元素子节点,再设置文本。不能直接
setElementText,否则旧子节点的组件卸载副作用(onUnmounted)会丢失。 - array→array:注释写着 "two arrays, cannot assume anything, do full diff"(
renderer.ts:1735)。即使两侧都是数组,也不知道它们是 keyed 还是 unkeyed,所以统一调用patchKeyedChildren——这个函数名字虽然叫 keyed,但它内部对"无 key 节点"也做了兜底(见 12.6 的 key-less 分支),因此可以处理混合场景。 mountChildren的 anchor 是anchor而非null:插入点要落在正确的位置,这个 anchor 来自patchChildren的调用方(元素场景为null,Fragment 场景为fragmentEndAnchor)。- 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 的更新版):
| 步骤 | i | c1[i] | c2[i] | 动作 |
|---|---|---|---|---|
| 循环 | 0 | A | A' | patch(A, A') |
| 循环 | 1 | B | B' | patch(B, B') |
| 循环 | 2 | C | C' | 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++
}
要点:
- 循环条件
i <= e1 && i <= e2:两个窗口都还有剩余才继续。 optimized二选一归一化:编译器产出的c2[i]已经是 VNode,且可能是被"复用"的同一对象(例如 v-once / cached),所以走cloneIfMounted(只在 vnode 已挂载时才克隆,避免污染);手写 render 的可能是原始值,走normalizeVNode(把null→Comment、数组→Fragment、字符串→Text)。isSameVNodeType不满足就 break:前缀同步是"贪心但严格"的——一旦某一对类型/key 不同,立刻停止,把这个位置留给后面的通用逻辑,绝不"跳过"。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++
}
}
}
逐行读:
if (i > e1):旧窗口已耗尽。此时不存在"旧节点需要卸载",只可能"新节点需要挂载"。if (i <= e2):新窗口还有剩余。若i > e2说明两边同时耗尽,什么也不做。const nextPos = e2 + 1:新窗口右侧第一个"已处理"节点的下标。它是挂载的定位锚点。const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor:- 若
e2 + 1仍在数组内,取那个 vnode 的真实 DOM 元素作为 anchor,把新节点插到它前面。 - 若
e2 + 1 === l2(新窗口一直延伸到数组末尾),说明后面没有了,用parentAnchor(来自父级:元素场景为null,Fragment 场景为fragmentEndAnchor),即"插到最后"。
- 若
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]:
| 帧 | i | e1 | e2 | nextPos | anchor | 动作 |
|---|---|---|---|---|---|---|
| 步骤①后 | 0 | 1 | 2 | — | — | B≠A,break |
| 步骤②后 | 0 | -1 | 0 | — | — | patch C、B |
| 步骤③ | 0 | -1 | 0 | 1 | c2[1].el=B | patch(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++
}
}
逐行读:
else if (i > e2):既然i > e1为假,那么这里成立意味着新窗口耗尽、旧窗口还有剩。while (i <= e1):把c1[i..e1]全部卸载,doRemove=true(真删 DOM)。- 这里不需要 anchor:卸载不需要定位。
12.6.4 尾部删除推演
c1=[A,B,C,D] → c2=[A,B],l2=2:
| 帧 | i | e1 | e2 | 动作 |
|---|---|---|---|---|
| 步骤① | 0→2 | 3 | 1 | A==A patch、B==B patch;i=2 时 i<=e1(3) 真但 i<=e2(1) 假 → 停 |
| 步骤② | 2 | 3 | 1 | 条件 i<=e2(2<=1) 假 → 停 |
| 步骤③ | 2 | 3 | 1 | i(2)>e1(3)? 否 → 跳过 |
| 步骤④ | 2 | 3 | 1 | i(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)
}
}
要点:
- 下标是"新数组中的绝对下标"(
i从s2到e2),不是相对s2的偏移。后面查表得到的newIndex也是绝对下标。 - 只记录
key != null的节点。无 key 节点不进 Map,将由 5.2 的for循环用"类型相同 + 该位置尚未被占用"来配对。 - 重复 key 警告(dev only):
keyToNewIndexMap.has(key)命中即warn,但后面仍会set覆盖——采取"后者胜"的策略,运行时不会崩,只是行为可能不符合直觉。这与render.ts顶层 vnode 重复 key 的处理风格一致。 - 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:
| i | prevChild | key | newIndex | 更新 map[newIndex-s2] | maxNewIndexSoFar | moved |
|---|---|---|---|---|---|---|
| 2 | c | c | 4 | map[4-2=2] = 2+1 = 3 | 4 | false |
| 3 | d | d | 3 | map[3-2=1] = 3+1 = 4 | 4→3 回退 | true |
| 4 | e | e | 2 | map[2-2=0] = 4+1 = 5 | 2 回退 | 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:
-
if (arrI === 0) continue(跳过):0是"该新位置无旧节点"的哨兵,不参与 LIS。跳过它意味着:新增节点永远不会出现在"不移动集合"里(这是对的——新增节点本来就要挂载,谈不上"不移动")。 -
j = result[result.length - 1];若arr[j] < arrI:当前值大于 tails 的最后一个值,说明"接在最长子序列后面能变得更长"→p[i] = j(记录前驱),result.push(i)。这是"扩展"分支,O(1)。 -
否则:当前值小于等于 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]。
| i | arrI | 跳过? | j=result 末 | arr[j] | 比较 | 分支 | result 变化 | p 变化 |
|---|---|---|---|---|---|---|---|---|
| 0 | 5 | 否 | 0 | arr[0]=5 | 5 < 5? 否 | 二分替换 | u=0,arrI(5) < arr[result[0]](5)? 否(相等)→ 不动 | p 不变 |
| 1 | 4 | 否 | 0 | arr[0]=5 | 5 < 4? 否 | 二分替换 | u=0,4 < 5? 是 → result[0]=1 | u=0 不设前驱;p[1] 仍为 4 |
| 2 | 3 | 否 | 1 | arr[1]=4 | 4 < 3? 否 | 二分替换 | u=0,3 < arr[result[0]=1]=4? 是 → result[0]=2 | p 不变 |
| 3 | 0 | 是 | — | — | — | 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]]。这样做的好处:
- 回溯时
p数组存的都是下标,可直接串联。 - 最终
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--
}
}
}
几个关键设计:
moved ? getSequence(...) : EMPTY_ARR:只有发生过"新索引回退"才计算 LIS。若整段顺序未变,increasingNewIndexSequence = EMPTY_ARR,且moved === false,那么下面的else if (moved)永不进入——一个节点都不会移动。这是"顺序不变"场景的零成本路径。j = increasingNewIndexSequence.length - 1:j是 LIS 序列的读指针,从最后一个(最大值下标)开始,倒着匹配。i从toBePatched - 1递减到 0:倒序遍历新段,从右往左处理。nextIndex = s2 + i:新段相对下标换算成c2绝对下标。nextChild = c2[nextIndex]:当前处理的新节点。anchorVNode = c2[nextIndex + 1]:右侧邻居。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:
| i | nextIndex=s2+i | nextChild | map[i] | anchor(nextIndex+1) | 判定 | 动作 | j |
|---|---|---|---|---|---|---|---|
| 3 | 5 | h | 0 | —(nextIndex+1=6,假设有后续节点或用 parentAnchor) | A: 新增 | mount h | 0 |
| 2 | 4 | c | 3 | c2[5].el=h.el | B2: i(2)===LIS[j=0](2) | 不动 | j→-1 |
| 1 | 3 | d | 4 | c2[4].el=c.el | B1: j<0 | move d → c 之前 | -1 |
| 0 | 2 | e | 5 | c2[3].el=d.el | B1: j<0 | move 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:
| i | prevChild | newIndex=map.get(key) | map[newIndex-0]=i+1 | maxNewIndexSoFar | moved | patch? |
|---|---|---|---|---|---|---|
| 0 | a | 3 | map[3]=1 | 3 | false | patch(a,a') |
| 1 | b | 2 | map[2]=2 | 3→2 回退 | true | patch(b,b') |
| 2 | c | 1 | map[1]=3 | 回退 | true | patch(c,c') |
| 3 | d | 0 | map[0]=4 | 回退 | true | patch(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:
| i | nextChild | map[i] | LIS[j] | anchor | 动作 |
|---|---|---|---|---|---|
| 3 | a | 1 | LIS[0]=3 == 3 | — | B2: i(3)===LIS[0] → 不动,j→-1 |
| 2 | b | 2 | j<0 | a.el | B1: move b → a 前 |
| 1 | c | 3 | j<0 | b.el | B1: move c → b 前 |
| 0 | d | 4 | j<0 | c.el | B1: 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:
| i | nextChild | anchor |
|---|---|---|
| 2 | z | parentAnchor(假设末尾) |
| 1 | y | z.el |
| 0 | x | y.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_FRAGMENT | v-for 源是常量表达式 / 模板根 fragment | patchBlockChildren(只 patch dynamicChildren) | 否(顺序不变) |
KEYED_FRAGMENT | v-for 源是运行时变量且带 :key | patchChildren → patchKeyedChildren | 是 |
UNKEYED_FRAGMENT | v-for 源是运行时变量且无 :key | patchChildren → patchUnkeyedChildren | 是(逐位) |
| 无 flag | 手写 render / 手动 cloneVNode | patchChildren(走 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 双端 diff | Vue 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 | 前后缀剥离 + 乱序段 LIS | newIndexToOldIndexMap + LIS | O(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 用existingChildrenMap 找到旧 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 计算混在一起。建议测两组正交指标:
- DOM 操作计数:
insertBefore、removeChild、appendChild、createElement的调用次数。这直接反映 diff 的"移动/挂载账本"。 - 端到端耗时:从"数据变化"到"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) | 1 | 0 | 否 | 步骤②把尾部同步、步骤③挂头部 | ||||
| tail-delete(N=10000) | 0 | 0 | 否 | 步骤①同步前缀、步骤④卸载尾部 | ||||
| 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 下比"全量重建"还慢,多半是因为:
- 使用了无 key 列表(走了 unkeyed 逐位 diff,退化为大量 unmount+mount);
- DOM 计数器的
insert与move没区分,把 append 也当成了 move; - 列表实际是静态的(每个
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 | ||||
isSameVNodeType | type === && key ===(dev 加 HMR 判断) | ||||
keyToNewIndexMap | key → 新绝对下标;重复 key dev 警告、后者胜 | ||||
newIndexToOldIndexMap | 索引=新段相对下标,值=旧段相对下标 +1;0 是新增哨兵 | ||||
moved | 仅当 newIndex < maxNewIndexSoFar(新索引回退)时为 true | ||||
getSequence | patience sorting + 二分 + 前驱回溯,O(n log n),跳过 0 | ||||
| LIS 语义 | 返回 相对下标 序列;长度 = 最大不动集 | ||||
| 移动阶段 | 倒序遍历,anchor 取 右邻居;命中 LIS 则不动,否则 move(...REORDER) | ||||
| 移动下界 | ` | S | − | LIS | `,即最少移动次数 |
| Fragment | 双文本锚点;patchChildren 的 anchor 传 fragmentEndAnchor | ||||
| STABLE_FRAGMENT | 常量源/模板根 → 走 patchBlockChildren,不做结构 diff |
12.14.2 五个最容易答错的点
newIndexToOldIndexMap的+1不是笔误。 它是为了把"旧下标 0"与"新增哨兵 0"区分开。漏掉这一点就会误判第一个旧节点为"新增"。moved与 LIS 是"是否需要"的关系,不是"是否发生移动"的关系。moved=false时一个节点都不移动,LIS 也不会被计算。isSameVNodeType的失败发生在patch内部,不在 Map 查找层。 key 相同但 type 不同,5.2 仍会"配对成功",然后由patch内部卸载重建。- 移动阶段必须倒序。 只有右侧已归位,
insertBefore(node, rightSibling)才是安全的。 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 体现了三条一以贯之的原则:
- 能用编译期信息就别在运行期算——
patchFlag让patchChildren一步到位选中 keyed/unkeyed,STABLE_FRAGMENT直接跳过结构 diff。 - 把"是否要做"与"怎么做"分开——
moved决定"要不要 LIS",getSequence决定"怎么移动",两者解耦,避免为不需要的场景付费。 - 把最坏情况做成最优——乱序段的移动次数由 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]=evsc2[5]=e→ 同 → patch;e1=3, e2=4。c1[3]=dvsc2[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:
| i | prevChild | key | newIndex | map[newIndex-2] | maxNewIndexSoFar | moved | 动作 |
|---|---|---|---|---|---|---|---|
| 2 | c | c | 3 | map[1]=3 | 3 | false | patch c |
| 3 | d | d | undefined | — | — | false | unmount 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:
| i | nextIndex | nextChild | map[i] | anchor(c2[nextIndex+1].el) | 动作 |
|---|---|---|---|---|---|
| 2 | 4 | g | 0 | c2[5].el=e | A: mount g → e 前 |
| 1 | 3 | c | 3 | c2[4].el=g | C: moved=false,跳过 |
| 0 | 2 | f | 0 | c2[3].el=c | A: 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:
| i | prevChild | newIndex | map[newIndex-2] | maxNewIndexSoFar | moved | 动作 |
|---|---|---|---|---|---|---|
| 2 | c | 3 | map[1]=3 | 3 | false | patch c |
| 3 | d | 2 | map[0]=4 | 3→2 回退 | true | patch 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:
| i | nextIndex | nextChild | map[i] | LIS[j] | anchor | 动作 |
|---|---|---|---|---|---|---|
| 2 | 4 | e | 0 | — | parentAnchor | A: mount e |
| 1 | 3 | c | 3 | LIS[0]=1,i(1)===1 | — | B2: 不动,j→-1 |
| 0 | 2 | d | 4 | j<0 | c2[3].el=c | B1: 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:
| i | prevChild | newIndex | map[newIndex] | maxNewIndexSoFar | moved |
|---|---|---|---|---|---|
| 0 | a | 1 | map[1]=1 | 1 | false |
| 1 | b | 2 | map[2]=2 | 2 | false |
| 2 | c | 3 | map[3]=3 | 3 | false |
| 3 | d | 4 | map[4]=4 | 4 | false |
| 4 | e | 0 | map[0]=5 | 4→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:
| i | nextChild | map[i] | LIS[j] | 判定 | 动作 |
|---|---|---|---|---|---|
| 4 | d | 4 | LIS[3]=4 | i===4 → B2 | 不动,j→2 |
| 3 | c | 3 | LIS[2]=3 | i===3 → B2 | 不动,j→1 |
| 2 | b | 2 | LIS[1]=2 | i===2 → B2 | 不动,j→0 |
| 1 | a | 1 | LIS[0]=1 | i===1 → B2 | 不动,j→-1 |
| 0 | e | 5 | j<0 | B1 | move 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]。
| i | arrI | result 末 j | arr[j] | 扩展? | 二分 u/v | result 变化 | p 变化 |
|---|---|---|---|---|---|---|---|
| 0 | 2 | 0 | 2 | 2<2? 否 | u=0,v=0;2<2? 否 | 不变 | 不变 |
| 1 | 10 | 0 | 2 | 2<10? 是 | — | push → [0,1] | p[1]=0 |
| 2 | 3 | 1 | 10 | 10<3? 否 | 二分:u=0,v=1 → c=0,arr[0]=2<3 → u=1;u=v=1;3<arr[result[1]=1]=10? 是 | result[1]=2 | u>0 → p[2]=result[0]=0 |
| 3 | 4 | 2 | 3 | 3<4? 是 | — | push → [0,2,3] | p[3]=2 |
| 4 | 5 | 3 | 4 | 4<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.ts | 383-513 | patch 总分派(含 Fragment 分支与类型不匹配的卸载) |
| 同上 | 816-843 | mountChildren(含 start 参数) |
| 同上 | 1001-1037 | patchBlockChildren(block 树专用) |
| 同上 | 1077-1183 | processFragment(STABLE / keyed / unkeyed 三路) |
| 同上 | 1672-1772 | patchChildren(四象限 + patchFlag 短路) |
| 同上 | 1774-1831 | patchUnkeyedChildren |
| 同上 | 1834-2076 | patchKeyedChildren(五步走) |
| 同上 | 2078-2169 | move(各类型节点的移动语义) |
| 同上 | 2171-2428 | unmount |
| 同上 | 2430-2441 | unmountChildren(含 start 参数) |
| 同上 | 2607-2647 | getSequence(LIS) |
packages/runtime-core/src/vnode.ts | 379-392 | isSameVNodeType |
| 同上 | 806-834 | normalizeVNode / cloneIfMounted |
packages/shared/src/patchFlags.ts | 19-124 | PatchFlags 枚举 |
| 同上 | 66-79 | STABLE/KEYED/UNKEYED_FRAGMENT 位值 |
packages/compiler-core/src/transforms/vFor.ts | 101-122 | v-for 的 fragmentFlag 计算与 codegen |
结语
patchKeyedChildren 是 Vue 3 运行时里"信息密度"最高的一段代码:不足 250 行,却完成了一整套"头尾短路 + Map 建索引 + LIS 求最小不动集 + 倒序移动"的工程化解法。它把"最少移动"这个最优化目标,用一次 O(n log n) 的子序列计算精确地实现出来,并在四个高频场景(纯追加、纯删除、头尾变化、顺序不变)上以 O(k) 短路。
理解它的最好方式不是背代码,而是记住三个不变量:
- 窗口不变量:
c1[i..e1]与c2[i..e2]之外的区域永远已处理;e1/e2可以越界到 −1,用来表达"空窗口"。 - 映射不变量:
newIndexToOldIndexMap的0永远表示"新增",其余值v表示"新段相对下标对应旧段相对下标v−1"。 - 顺序不变量:移动阶段倒序遍历,右侧永远已归位,故用右邻居作 anchor。
把这三个不变量连同 getSequence 的 patience sorting 记牢,你就能在任何"列表更新错乱"的 bug 现场,一眼定位是"key 选错""前缀同步失效"还是"LIS 上游的 moved 误判"。