diff算法整体分为5步:
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0
const l2 = c2.length
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) {
...
}
// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
....
}
// 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) {
...
}
// 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) {
....
}
// 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 {
...
}
}
}
整个diff的分步共分为 5 步,分别为:
sync from start:自前向后的对比sync from end:自后向前的对比common sequence + mount:新节点多于旧节点,需要挂载common sequence + unmount:旧节点多于新节点,需要卸载unknown sequence:乱序
这5步的对比决定了一组DOM更新时的最优方案。
diff算法详细解析:
第一步: sync from start 自前向后的对比
核心的目的是:把两组 dom 自前开始,相同的 dom 节点(vnode)完成对比处理
const patchKeyedChildren = (
oldChildren,
newChildren,
container,
parentAnchor
) => {
// 索引
let i = 0
// 新的子节点的长度
const newChildrenLength = newChildren.length
//旧的子节点最大(最后一个)下标
let oldChildrenEnd = oldChildren.length - 1
// 新的子节点最大(最后一个)下标
let newChildrenEnd = newChildrenLength - 1
// 1. 自前向后的 diff 对比。经过该循环之后,从前开始的相同 vnode 将被处理
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
const oldVNode = oldChildren[i]
const newVNode = normalizeVNode(newChildren[i])
// 如果 oldVNode 和 newVNode 被认为是同一个 vnode,则直接 patch 即可
if (isSameVNodeType(oldVNode, newVNode)) {
patch(oldVNode, newVNode, container, null)
}
// 如果不被认为是同一个 vnode,则直接跳出循环
else {
break
}
// 下标自增
i++
}
主要进行了两大步的处理逻辑:
-
自前向后的 diff 比对中,会 依次获取相同下标的
oldChild和newChild:- 如果
oldChild和newChild为 相同的VNode,则直接通过patch进行打补丁即可 - 如果
oldChild和newChild为 不相同的VNode,则会跳出循环
- 如果
-
每次处理成功,则会自增
i标记,表示:自前向后已处理过的节点数量
第二步: sync from end:自后向前的对比
核心的目的是:把两组 dom 自后开始,相同的 dom 节点(vnode)完成对比处理
// 2. 自后向前的 diff 对比。经过该循环之后,从后开始的相同 vnode 将被处理
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
const oldVNode = oldChildren[oldChildrenEnd]
const newVNode = normalizeVNode(newChildren[newChildrenEnd])
if (isSameVNodeType(oldVNode, newVNode)) {
patch(oldVNode, newVNode, container, null)
} else {
break
}
// 最后的下标递减
oldChildrenEnd--
newChildrenEnd--
}
第三步: common sequence + mount:新节点多于旧节点,需要挂载
新旧节点数量不一致的情况。具体可以分为两种:
- 新节点的数量多于旧节点的数量(如:
arr.push(item)) - 旧节点的数量多于新节点的数量(如:
arr.pop(item))
// 3. 新节点多余旧节点时的 diff 比对。
if (i > oldChildrenEnd) {
if (i <= newChildrenEnd) {
const nextPos = newChildrenEnd + 1
// 重点:找到锚点
const anchor =
nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor
while (i <= newChildrenEnd) {
patch(null, normalizeVNode(newChildren[i]), container, anchor)
i++
}
}
}
上面的代码可知:
-
对于 新节点多于旧节点 的场景具体可以再细分为两种情况:
- 多出的新节点位于 尾部
- 多出的新节点位于 头部
-
这两种情况下的区别在于:插入的位置不同
-
明确好插入的位置之后,直接通过
patch进行打补丁即可。
第四步:common sequence + unmount:旧节点多于新节点,需要卸载
对于旧节点多于新节点时,对应的场景也可以细分为两种:
- 执行
arr.pop():这样可以从 尾部 删除数据。即:多出的旧节点位于 尾部 - 执行
arr.shift():这样可以从 头部 删除数据。即:多出的旧节点位于 头部
// 4. 旧节点多与新节点时的 diff 比对。
else if (i > newChildrenEnd) {
while (i <= oldChildrenEnd) {
// 卸载 dom
unmount(oldChildren[i])
i++
}
}
第五步: unknown sequence 乱序对比------ 最长递增子序列:减少移动的次数
通过源码可以发现:vue 通过 getSequence 函数处理的最长递增子序列
// 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 {
// 旧子节点的开始索引:oldChildrenStart
const s1 = i
// 新子节点的开始索引:newChildrenStart
const s2 = i
// 5.1 创建一个 <key(新节点的 key):index(新节点的位置)> 的 Map 对象 keyToNewIndexMap。通过该对象可知:新的 child(根据 key 判断指定 child) 更新后的位置(根据对应的 index 判断)在哪里
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
// 通过循环为 keyToNewIndexMap 填充值(s2 = newChildrenStart; e2 = newChildrenEnd)
for (i = s2; i <= e2; i++) {
// 从 newChildren 中根据开始索引获取每一个 child(c2 = newChildren)
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// child 必须存在 key(这也是为什么 v-for 必须要有 key 的原因)
if (nextChild.key != null) {
// key 不可以重复,否则你将会得到一个错误
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
// 把 key 和 对应的索引,放到 keyToNewIndexMap 对象中
keyToNewIndexMap.set(nextChild.key, i)
}
}
// 5.2 循环 oldChildren ,并尝试进行 patch(打补丁)或 unmount(删除)旧节点
let j
// 记录已经修复的新节点数量
let patched = 0
// 新节点待修补的数量 = newChildrenEnd - newChildrenStart + 1
const toBePatched = e2 - s2 + 1
// 标记位:节点是否需要移动
let moved = false
// 配合 moved 进行使用,它始终保存当前最大的 index 值
let maxNewIndexSoFar = 0
// 创建一个 Array 的对象,用来确定最长递增子序列。它的下标表示:《新节点的下标(newIndex),不计算已处理的节点。即:n-c 被认为是 0》,元素表示:《对应旧节点的下标(oldIndex),永远 +1》
// 但是,需要特别注意的是:oldIndex 的值应该永远 +1 ( 因为 0 代表了特殊含义,他表示《新节点没有找到对应的旧节点,此时需要新增新节点》)。即:旧节点下标为 0, 但是记录时会被记录为 1
const newIndexToOldIndexMap = new Array(toBePatched)
// 遍历 toBePatched ,为 newIndexToOldIndexMap 进行初始化,初始化时,所有的元素为 0
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
// 遍历 oldChildren(s1 = oldChildrenStart; e1 = oldChildrenEnd),获取旧节点(c1 = oldChildren),如果当前 已经处理的节点数量 > 待处理的节点数量,那么就证明:《所有的节点都已经更新完成,剩余的旧节点全部删除即可》
for (i = s1; i <= e1; i++) {
// 获取旧节点(c1 = oldChildren)
const prevChild = c1[i]
// 如果当前 已经处理的节点数量 > 待处理的节点数量,那么就证明:《所有的节点都已经更新完成,剩余的旧节点全部删除即可》
if (patched >= toBePatched) {
// 所有的节点都已经更新完成,剩余的旧节点全部删除即可
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
// 新节点需要存在的位置,需要根据旧节点来进行寻找(包含已处理的节点。即:n-c 被认为是 1)
let newIndex
// 旧节点的 key 存在时
if (prevChild.key != null) {
// 根据旧节点的 key,从 keyToNewIndexMap 中可以获取到新节点对应的位置
newIndex = keyToNewIndexMap.get(prevChild.key)
} else {
// 旧节点的 key 不存在(无 key 节点)
// 那么我们就遍历所有的新节点(s2 = newChildrenStart; e2 = newChildrenEnd),找到《没有找到对应旧节点的新节点,并且该新节点可以和旧节点匹配》(s2 = newChildrenStart; c2 = newChildren),如果能找到,那么 newIndex = 该新节点索引
for (j = s2; j <= e2; j++) {
// 找到《没有找到对应旧节点的新节点,并且该新节点可以和旧节点匹配》(s2 = newChildrenStart; c2 = newChildren)
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
// 如果能找到,那么 newIndex = 该新节点索引
newIndex = j
break
}
}
}
// 最终没有找到新节点的索引,则证明:当前旧节点没有对应的新节点
if (newIndex === undefined) {
// 此时,直接删除即可
unmount(prevChild, parentComponent, parentSuspense, true)
}
// 没有进入 if,则表示:当前旧节点找到了对应的新节点,那么接下来就是要判断对于该新节点而言,是要 patch(打补丁)还是 move(移动)
else {
// 为 newIndexToOldIndexMap 填充值:下标表示:《新节点的下标(newIndex),不计算已处理的节点。即:n-c 被认为是 0》,元素表示:《对应旧节点的下标(oldIndex),永远 +1》
// 因为 newIndex 包含已处理的节点,所以需要减去 s2(s2 = newChildrenStart)表示:不计算已处理的节点
newIndexToOldIndexMap[newIndex - s2] = i + 1
// maxNewIndexSoFar 会存储当前最大的 newIndex,它应该是一个递增的,如果没有递增,则证明有节点需要移动
if (newIndex >= maxNewIndexSoFar) {
// 持续递增
maxNewIndexSoFar = newIndex
} else {
// 没有递增,则需要移动,moved = true
moved = true
}
// 打补丁
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 自增已处理的节点数量
patched++
}
}
// 5.3 针对移动和挂载的处理
// 仅当节点需要移动的时候,我们才需要生成最长递增子序列,否则只需要有一个空数组即可
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
// j >= 0 表示:初始值为 最长递增子序列的最后下标
// j < 0 表示:《不存在》最长递增子序列。
j = increasingNewIndexSequence.length - 1
// 倒序循环,以便我们可以使用最后修补的节点作为锚点
for (i = toBePatched - 1; i >= 0; i--) {
// nextIndex(需要更新的新节点下标) = newChildrenStart + i
const nextIndex = s2 + i
// 根据 nextIndex 拿到要处理的 新节点
const nextChild = c2[nextIndex] as VNode
// 获取锚点(是否超过了最长长度)
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
// 如果 newIndexToOldIndexMap 中保存的 value = 0,则表示:新节点没有用对应的旧节点,此时需要挂载新节点
if (newIndexToOldIndexMap[i] === 0) {
// 挂载新节点
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
// moved 为 true,表示需要移动
else if (moved) {
// j < 0 表示:不存在 最长递增子序列
// i !== increasingNewIndexSequence[j] 表示:当前节点不在最后位置
// 那么此时就需要 move (移动)
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
// j 随着循环递减
j--
}
}
}
}
乱序处理总结
1 对新子节点建立 key 与索引的 map:keyToNewIndexMap 。
2 遍历旧子节点对能够匹配到的旧节点进行 patch 操作,对于不会存在的旧子节点进行移除操作。
3 对新子节点进行移动或新增操作
总结:
-
diff算法主要通过Vue中的patchKeyedChildren方法来实现。 -
patchKeyedChildren方法主要分为五个步骤来处理各场景逻辑,分别是:自前向后、自后向前、新节点多于旧节点、旧节点多于新节点、乱序比对(核心)。 -
自前向后逻辑主要通过i作为下标获取新旧节点元素,再判断新旧节点的type和key是否相同,执行patch方法进行挂载更新,还是break跳出该逻辑。 -
自前向后和自后向前逻辑主要区别在于一个从前向后遍历,一个从后向前遍历。 -
新节点多于旧节点分为向前向后新增两种情况,主要通过判断获取anchor锚点值来决定多余的新节点插入位置。 -
旧节点多于新节点同样也分向前向后删除两种情况,主要通过unmount方法进行多余节点的卸载。