Vue 版本:以
vue@3.x代码为参考,主要梳理 Diff 算法的核心流程。
Vue 3 的 Diff 算法借鉴了纯文本 diff 算法的思想,参考了 viv 和 inferno 框架的实现,只对需要处理的节点本身进行 Diff 操作。通过预处理和最长递增子序列(LIS)算法,Vue 3 能够显著减少不必要的 DOM 操作,提升渲染性能。
核心策略:先头后尾找公共,新增删除看剩余,中间乱序靠 key 映射 + 最长递增子序列
Diff 算法流程
1. 同步头部节点(Sync from Start)
从前往后比较新旧节点列表,跳过相同的节点,直到遇到第一个不同的节点。
- 比较
c1[i]和c2[i],如果相同则继续后移i - 遇到不同节点时停止,此时
i即为需要处理的起始位置
示例:
旧列表:(a b) c
新列表:(a b) d e
结果:i = 2,跳过前两个相同节点
2. 同步尾部节点(Sync from End)
从后往前比较新旧节点列表,跳过相同的节点,直到遇到第一个不同的节点。
- 比较
c1[e1]和c2[e2],如果相同则继续前移e1和e2 - 遇到不同节点时停止,此时
e1和e2即为需要处理的结束位置
示例:
旧列表:a (b c)
新列表:d e (b c)
结果:e1 = 0, e2 = 1,跳过后两个相同节点
3. 处理新增节点(Common Sequence + Mount)
如果 i > e1 && i <= e2,说明新列表中有新增节点,需要挂载这些节点。
示例:
旧列表:(a b)
新列表:c (a b)
情况:i = 0, e1 = -1, e2 = 0
操作:在位置 0 处新增节点 c
4. 处理删除节点(Common Sequence + Unmount)
如果 i > e2 && i <= e1,说明旧列表中有多余节点,需要卸载这些节点。
示例:
旧列表:a (b c)
新列表:(b c)
情况:i = 0, e1 = 0, e2 = -1
操作:删除节点 a
5. 处理乱序节点(Unknown Sequence)
如果上述情况都不满足,说明中间部分存在乱序,需要执行移动和更新操作。
处理流程:
-
建立 key 到索引的映射:遍历新节点列表,建立
key -> newIndex的映射表,方便快速查找旧节点在新列表中的位置。 -
遍历旧节点,建立新索引到旧索引的映射:
- 查找每个旧节点在新节点列表中的位置。
- 建立
newIndexToOldIndexMap数组,记录新节点索引对应的旧节点索引(偏移 +1,0 表示新增节点)。 - 通过比较
newIndex和maxNewIndexSoFar判断节点是否需要移动。
-
计算最长递增子序列:如果检测到节点移动,使用 LIS 算法找出不需要移动的节点序列。
-
执行移动和挂载操作:
- 从后往前遍历新节点列表。
- 如果节点不在 LIS 中,执行移动操作。
- 如果节点是新增的(
newIndexToOldIndexMap[i] === 0),执行挂载操作。
示例:
旧列表:[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
操作:处理中间乱序部分 [c d e] -> [e d c h]
最长递增子序列(LIS)算法
最长递增子序列算法是 Vue 3 Diff 算法的核心优化,找出不需要移动的节点序列,实现最小化 DOM 操作。
算法原理
核心思想:贪心算法 + 二分查找 + 回溯列表
算法创建一个数组 result 用于记录递增序列的索引下标,通过贪心策略和二分查找找到局部最优解,然后通过回溯指针数组还原出真正的递增子序列。
算法步骤详解
示例数组:[1, 30, 100, 200, 300, 50, 60]
第一步:贪心查找递增序列
-
预设初始索引
result = [0](对应值 1) -
贪心策略:
- 比较 30 > 1,符合递增,
result[1] = 1(对应值 30) - 比较 100 > 30,符合递增,
result[2] = 2(对应值 100) - 依次类推,直到 300,此时
result = [0, 1, 2, 3, 4]
- 比较 30 > 1,符合递增,
-
遇到不满足递增的元素:
- 比较 50 < 300,不满足递增
- 通过二分查找(在有序递增数组
result中),找到首个比 50 大的数的索引位置 - 这里找到的是索引 2(对应值 100)
- 将
result[2]替换为 50 的索引 5,即[0, 1, 2, 3, 4]变为[0, 1, 5, 3, 4] - 同样处理 60,最终得到
[0, 1, 5, 6, 4]
问题:此时的结果并不满足原有数组的递增子序列,索引值并非递增(局部最优解导致全局最优解出现偏差)。50 和 60 跑到了 300 前面,违反了序列的原则。
第二步:回溯修正
解决方案:给每个当前队列中的索引添加指向前置节点的指针,即代码里的 p 数组。在完成遍历后,使用指针从尾部开始回溯,还原出真正的递增子序列。
- 前置指针和当前
arr存储的索引位置对应,代表当前arr索引的值指向的前置索引 - 在遍历过程中,
p前置索引列表中,数值 50 和 100 的前置指针都指向 30
回溯过程:
- 从后往前遍历
result进行回溯 - 首先是索引 4(对应值 300),其
p的前置指针为索引 3(对应值 200),所以修正为 3 - 索引 3(对应值 200)的前置为索引 2(对应值 100),修正为 2,这里把
result中的索引 5(对应值 50)顶替掉 - 继续下去,100 > 30 > 1,最终修正为
[0, 1, 2, 3, 4],而非局部贪心得到的[0, 1, 5, 6, 4]
最终结果:[0, 1, 2, 3, 4] 对应值 [1, 30, 100, 200, 300],而非 [1, 30, 50, 60, 300],符合递增子序列的原则。
代码实现
// 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;
// 1. 遍历,第一次递增子序列查找
for (i = 0; i < len; i++) {
// 获取当前数组项
const arrI = arr[i];
// arrI 为 0 表示新增节点,无需参与后续步骤
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;
// 假如当前c处索引值的数组项 < 当前数组项,往后找:因为要找到最近最小于的项才行
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
// 假如当前c处索引值的数组项 > 当前数组项,往前找
v = c;
}
}
// 假如找到的u处索引数组项 > 当前项且 u > 0,即有完成二分查找过程
if (arrI < arr[result[u]]) {
if (u > 0) {
// 定义当前索引的前置指针指向前一个值
p[i] = result[u - 1];
}
// 在当前索引处插入索引项,完成递增子序列处理
result[u] = i;
}
}
}
// 2. 第二次遍历,根据指针列表回溯结果
u = result.length;
v = result[u - 1];
while (u-- > 0) {
// 从头往尾部,将序列还原为正常的递增子序列结果
result[u] = v;
v = p[v];
}
// 返回结果
return result;
}
Diff 算法主要实现
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
let i = 0
const l2 = c2.length
// 获取结束索引
// e1 是 oldVNodes,e2 是 newVNode
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index
// 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++
}
// 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--
}
// 3. common sequence + mount
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
// 假如 i > oldEnd && i <= newEnd,证明有节点需要新增
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++
}
}
}
// 4. common sequence + unmount
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
// 假如 i > newEnd && i <= oldEnd,证明有节点需要删除
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
// 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 newStart
// 5.1 build key:index map for newChildren
// 建立在新节点中的列表索引,后续遍历旧节点时,通过 key 快速查找旧节点在新节点中的位置
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) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 loop through old children left to be patched and try to patch
// matching nodes & remove nodes that are no longer present
// 遍历旧节点列表部分,进行复用和删除
let j
let patched = 0
// 获取需要 patch 的节点数量
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]
// 已经超过 patch 范围,说明节点可以被移除,证明新节点处理完,旧的卸载
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,尝试定位相同且无key的节点索引
// 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 {
// 记录新列表LIS部分的相对索引(偏移+1,0表示新增节点)
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++
}
}
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved
// 递增子序列处理位置
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
// 从列表末尾开始遍历
// 倒序遍历可以利用「下一个节点的 el 作为锚点」
// 避免移动节点后影响后续节点的锚点位置,保证 DOM 操作的正确性。
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, fallback to el placeholder for unresolved async component
anchorVNode.el || anchorVNode.placeholder
: parentAnchor
// 为 0 代表新增节点
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 {
// 在 LIS 中,无需移动
j--
}
}
}
}
}
快速 Diff 算法优势
-
预处理优化:先处理头部和尾部的相同节点,然后快速缩小需要 diff 的范围,减少不必要的比较操作。
-
最长递增子序列优化:通过 LIS 算法找出不需要移动的节点,完成后只移动真正需要移动的节点,最小化 DOM 操作次数。
-
Key 映射优化:建立 key 到索引的映射表,实现 O(1) 时间复杂度的节点查找,避免嵌套循环查找带来的 O(n²) 复杂度。
-
倒序遍历优化:在处理节点移动时,从后往前遍历可以利用下一个节点的 DOM 元素作为锚点,避免移动节点后影响后续节点的锚点位置,保证 DOM 操作的正确性。
思考
1. Vue 3 的快速 Diff 算法相比 Vue 2 双端 Diff 有哪些改进?
Vue 3 引入了最长递增子序列算法,能够更精确地识别不需要移动的节点,在复杂列表场景下能够减少更多的 DOM 操作。虽然 LIS 是 O(n log n) 复杂度,但是整体压缩了要移动的 DOM 节点个数,解决了 Vue 2 无法确定最小化移动次数的问题。且在编译阶段下借助 patchFlag 优化单个节点的更新粒度,标记节点动态部分,运行时只更新标记部分,在编译阶段实现类似剪枝的操作,后续运行时避免不必要的 Diff 和全量属性对比。
2. 为什么需要回溯指针数组?
贪心算法在寻找递增子序列时,可能会用较小的值替换较大的值,导致结果不满足真正的递增子序列,通过回溯指针数组,可以从尾部开始还原出真正的递增子序列,保证算法过程准确。
3. 为什么倒序遍历新节点列表?
倒序遍历可以利用下一个节点的 DOM 元素作为锚点,避免移动节点后影响后续节点的锚点位置,如果是正序遍历,移动节点后可能会改变后续节点的锚点,导致 DOM 操作错误。
总结
- Diff 算法流程:Vue 3 的 Diff 算法分为五个步骤:同步头部节点、同步尾部节点、处理新增节点、处理删除节点、处理乱序节点。通过预处理快速缩小范围,然后通过 key 映射和 LIS 算法处理复杂的节点移动场景。
- 最长递增子序列算法:使用贪心算法 + 二分查找 + 回溯列表的方式,找出不需要移动的节点序列。通过贪心策略和二分查找找到局部最优解,然后通过回溯指针数组还原出真正的递增子序列,最小化 DOM 操作次数。
- 关键优化点:预处理优化快速缩小范围;LIS 算法精确识别不需要移动的节点;key 映射实现 O(1) 查找;倒序遍历保证 DOM 操作的正确性。
参考内容
- vuejs/core - patchKeyedChildren
- Longest Increasing Subsequence - Wikipedia
- Vue 设计与实现 - 第 11 章 快速 Diff 算法
952

被折叠的 条评论
为什么被折叠?



