- 为什么需要 diff ?
复用 DOM 比直接替换(移除旧 DOM,创建新 DOM )性能好的多。
- diff 原则?
原地复用 > 移动后复用 >> 暴力替换
vue2——双端diff算法
原理:
-
首先进行首尾对比,这样找到的可复用节点一定是性能最优(即原地复用 DOM 节点,不需要移动)。
-
首尾对比完交叉对比,这一步即寻找移动后可复用的节点。
-
然后在剩余结点中对比寻找可复用 DOM,为了快速对比,于是创建一个 map 记录 key,然后通过 key 查找旧的 DOM。
-
最后进行善后工作,即移除多余节点或者新增节点。
具体来说就是新旧 VNode 节点的左右头尾两侧都有一个指针,用来遍历对比新旧 VNode 列表。
-
当新老 VNode 节点的 start 或者 end 满足同一节点时,将该 vnode 对应的真实 DOM 进行 patch 即可。
else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } -
如果直接对比不符合,那么进行交错对比,即 oldStart 与 newEnd,oldEnd 与 newStart。对比发现是同一个节点的话,进行 patch 并且将该 VNode 对应的真实 DOM 移动到正确的位置。
lse if (sameVnode(oldStartVnode, newEndVnode)) { // 移动之前先进行patch打补丁 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) // 以 oldStartVnode 为锚点,通过 insertBefore 移动真实 DOM canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) // 移动指针,后续继续比较 oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // 移动之前先进行patch打补丁 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 以 oldEndVnode 为锚点,通过 insertBefore 移动真实 DOM canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } -
经过以上步骤处理完双端后,对剩余节点的处理原则是:尽量寻找可复用的 DOM 节点。
这样做比简单的创建新节点、移除旧节点性能好的多。
因此接下来的目标是寻找可复用的节点,并移动到正确的位置,然后在不存在可复用节点时新增节点。
为了得到更优的时间复杂度,创建一个
{ key: oldVnode }的映射表(oldKeyToIdx)来方便查找。
PS:通过 Map 来进行查找的时间复杂度是 O(1)。本质上这一步查找可以通过遍历一个个查找可复用节点,但是时间复杂度是O(n)
然后从这个映射表中查找旧 vnode 列表是否存在可复用的节点,如果有,进行 patch 并且将该 VNode 对应的真实 DOM 移动到正确的位置。否则,新建一个节点。
else { // 创建一个 { key: oldVnode } 的映射表 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 查找这个表,如果 newStartVnode 中有 key,则直接去映射表中查;否则通过 findIdxInOld 查 idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx) if (isUndef(idxInOld)) { // 如果没找到,那么新建节点 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else { vnodeToMove = oldCh[idxInOld] // 相同节点的话 if (sameVnode(vnodeToMove, newStartVnode)) { // 进行patch patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue) // 因为该位置对应的节点处理完毕,因此,将该位置设置为 undefined,后续指针遍历进来后可以直接跳过遍历下一个 oldCh[idxInOld] = undefined // 后移动对应的真实DOM canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // 不是相同节点的话,那么需要新建节点 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } } newStartVnode = newCh[++newStartIdx] } -
接下来等diff主流程结束后,进行善后,即移除多余的节点或者新增节点。
// 按照算法处理完旧节点列表中最后一个节点(oldStartIdx === oldEndIdx)后,要么 oldStartIdx++,要么 oldEndIdx--,因此此时会出现 oldStartIdx > oldEndIdx if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // // 按照算法处理完新节点列表中最后一个节点(newStartIdx === newEndIdx)后,要么 newStartIdx++,要么 newEndIdx--,因此此时会出现 newStartIdx > newEndIdx } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) }
完整源码
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
快速diff算法前置知识
注:可以不做,但需要知道最长递增子序列的定义。
[1] leetcode.cn/problems/lo… 贪心+二分查找
vue3——快速diff算法
原理:
-
首先进行首尾对比,这样找到的一定是性能最优,即原地复用 DOM 节点,不需要移动。
-
然后创建一个新节点在旧的 dom 中的位置的映射表,这个映射表的元素如果不为空,代表可复用。
-
然后根据这个映射表计算出最长递增子序列,这个序列中的结点代表可以原地复用,不需要移动。之后移动剩下的新结点到正确的位置即递增序列的间隙中。
PS: 最后一步如果节点在旧vnode中存在,则移动到相应位置 —— 递增序列间隙,如果在旧的 vnode 中不存在,那么在递增序列间隙中新增节点。
1.头部节点对比
// i为遍历新 vnode 列表的指针, el: 旧 vnode 的尾部索引, e2: 新 vnode 尾部索引
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
// 如果节点相同, 那么 patch
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break // 节点不同,直接跳出循环
}
i++ // 到这里表示走的是节点相同的逻辑,那么头部索引后移,继续比较剩余节点
}
2.尾部节点对比
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
// 如果节点相同, 那么 patch
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break // 节点不同,直接跳出循环
}
e1-- // 到这里表示走的是节点相同的逻辑,那么尾部索引前移,继续比较剩余节点
e2--
}
是否需要新增节点
头尾处理完毕后,理想的情况是新、旧节点列表中有一方已经遍历完毕。那么首先判断是否需要新增节点
// el 是旧节点的尾索引,当 i > e1 时,说明经过前面步骤的处理,所有的旧节点都处理完毕,没有可以复用的 DOM 了,那么考虑是否需要新增节点
if (i > e1) {
// 此时如果 i <= e2,那么说明新节点中仍然有未被处理的节点,需要新增。
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,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
是否需要移除节点
// i > e2 表示新节点列表已经处理完毕
else if (i > e2) {
// 此时,如果 i <= e1,那么表示旧节点列表有多余元素,需要移除相应的真实 DOM
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
3.剩余节点中寻找可复用节点
当新、旧节点列表都有剩余元素,那么需要判断剩余节点中是否存在可复用节点,并判断是否需要进行 DOM 移动操作。
寻找可复用节点
为此,创建newIndexToOldIndexMap数组,用来存储新节点数组中的剩余节点在旧节点数组上的索引,后面将使用它计算出一个最长递增子序列。并初始化数组。
const newIndexToOldIndexMap = new Array(toBePatched)
// 相当于 newIndexToOldIndexMap.fill(0);
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
然后遍历旧节点数组,寻找可复用节点并填充newIndexToOldIndexMap数组。
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
let newIndex
if (prevChild.key != null) {
// 查找可复用节点的在新节点列表中的索引
newIndex = keyToNewIndexMap.get(prevChild.key)
}
// 填充 newIndexToOldIndexMap 数组,注意未处理节点的索引一般不是从0开始,而是s2开始,因此对应的索引是 newIndex - s2
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// 找到可复用节点后还需要打补丁
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
注:
- 为了获得高效的查找性能,于是创建新节点列表的
{ key: index }映射表。(即方便这个步骤newIndex = keyToNewIndexMap.get(prevChild.key))
const keyToNewIndexMap: Map<string | number, number> = new Map()
// 遍历新节点数组来填充好 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)
}
}
- 现在找到了这个可复用的节点,并进行了打补丁。那么如何判断节点是否需要移动?方法是新增两个变量。
let moved = false
// 代表遍历旧节点列表中发现的可复用节点在新节点列表中的最大索引
let maxNewIndexSoFar = 0
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {// 表示索引值不是递增的,那么需要移动 DOM 节点
moved = true
}
移动可复用节点到正确的位置
为此,我们需要根据newIndexToOldIndexMap计算出一个最长递增子序列。
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
最长递增子序列的作用是序列中的 DOM 元素相对位置不变,然后将其他 DOM 元素在它们之间找到正确的位置插入即可。具体看代码:
j = increasingNewIndexSequence.length - 1
// i 为新节点数组中剩余未处理节点数组中的最后一个元素索引
for (i = toBePatched - 1; i >= 0; i--) {
// 表示在新节点数组中的真实索引
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
// 获得下一个 DOM 元素,作为 insertBefore 的锚点
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// 没有相应可复用节点,那么挂载它
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 如果不是最长递增子序列中的元素,那么需要移动到正确位置
move(nextChild, container, anchor, MoveType.REORDER)
} else {
// 最长递增子序列中的元素保持不动,只需让索引前移
j--
}
}
}
完整源码
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) {
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,
isSVG,
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,
isSVG,
slotScopeIds,
optimized
)
} else {
// 不同就直接跳出循环
break
}
e1--
e2--
}
// 3. common sequence + mount
// 如果旧节点遍历完了, 依然有新的节点, 那么新的节点就是添加(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,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
// 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++
}
}
// 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
// 5.1 build key:index map for newChildren
const keyToNewIndexMap: Map<string | number, 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)
}
}
// 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
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,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
// 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 anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
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--
}
}
}
}
}
总结—— vue2,vue3 对比
vue2、vue3 的 diff 算法实现差异主要体现在:处理完首尾节点后,对剩余节点的处理方式。
在 vue2 中是通过对旧节点列表建立一个 { key, oldVnode }的映射表,然后遍历新节点列表的剩余节点,根据newVnode.key在旧映射表中寻找可复用的节点,然后打补丁并且移动到正确的位置。
而 vue3 则是建立一个存储新节点数组中的剩余节点在旧节点数组上的索引的映射关系数组,建立完成这个数组后也即找到了可复用的节点,然后通过这个数组计算得到最长递增子序列,这个序列中的节点保持不动,然后将新节点数组中的剩余节点移动到正确的位置。