当组件发生更新时会重新执行 render
方法生成新的 vnode
节点,而当 新旧 vnode
都是 一组节点 时,为了以最小的性能开销完成 更新操作,需要比较两组子节点,其中用于比较的算法就叫 Diff
算法。Vue
中的 Diff
算法实际上也是一个逐步演进的过程,那么下面就来看看它是如何演进、优化成如今的 Diff
算法的。
在进行 新旧 两组子节点的更新时,去遍历 新旧 一组子节点中 长度较短 的一组,目的是为了尽可能多的调用 pacth
函数进行更新。
理想状态 指新旧一组节点中 新旧节点前后位置没有发生变化.
在这个前提下新的一组节点可以比旧的一组子节点多、少或相等:
commonLength
commonLength
作为循环结束的条件,通过 patch
函数对当前遍历到的 新旧 进行 pacth
更新操作commonLength
长度后的,就代表是属于其他多余的节点,这个多余的节点会根据 新旧 的具体情况进行不同的处理:* 新的 一组子节点有剩余,则代表有新的节点需要 挂载,通过 patch
函数进行挂载操作* 旧的 一组子节点有剩余,则代表有旧的节点需要 卸载,通过 unmount
进行卸载操作非理想状态 指的是 新旧 一组子节点中相 同位置 的 节点不相同.
此时简单 diff
算法仍然会以 commonLength
进行遍历,并通过 patch(n1, n2)
的方式去更新,但在 pacth
函数中由于 n1
、n2
节点不是相同节点,此时会直接将 旧节点 进行 卸载,然后将 新节点 进行 挂载 操作,哪怕是当前 新旧 一组节点中在不同位置有相同的节点可复用,但简单 diff
算法完全不知道是否有可复用的节点,它完全是依赖于 pacth
来判断当前新旧节点是否是相同的节点。
显然,简单 diff
算法下课通过减少 DOM
操作的次数,提升了一定的更新性能,但在非理想状态下,其更新方式和简单直接的更新方式一致:即卸载旧节点、挂载新节点,这意味着它仍然有被优化的空间。
上述算法的缺陷在于 非理想状态 的 diff 的过程仍然比较固定,即只能比较同位置的节点是否一致,那么优化的方式也是显而易见,只需要引入 key 用来标识 新旧一组子节点中 是否存在相同 key
的节点,若存在则复用 真实 DOM 节点,即更新和移动 DOM 节点即可。
key
寻找可复用的节点,找到可复用节点进行 patch
更新lastIndex
决定是否要进行 移动find
变量为 false
时认为当前节点是需要进行 挂载has
变量判断是否需要进行 卸载以下是简单的伪代码实现:
function patchChildren(n1, n2, container) {if (typeof n2 === "string") {// 省略代码} else if (Array.isArray(n2.children)) {const oldChildren = n1.children; // 旧的一组子节点const newChildren = n2.children; // 新的一组子节点let lastIndex = 0; // 用于判断当前节点移动的位置// 遍历新的一组子节点:更新、移动、挂载for (let i = 0; i < newChildren.length; i++) {const newVnode = newChildren[i];let find = false; // 标识是否能在旧的一组子节点中找到可复用的节点let j = 0;// 遍历旧的一组子节点for (j; j < oldChildren.length; j++) {const oldVnode = oldChildren[j];// 根据 key 判断是否是相同节点,及是否可复用if (newVnode.key === oldVnode.key) {find = true;// 通过 patch 进行【更新】patch(oldVnode, newVnode, container);// 若 j < lastIndex 的值,表示需要【移动】if (j < lastIndex) {// 获取当前节点对应的上一个 preVnode 节点const preVnode = newChildren[i - 1];// 若上一个 preVnode 节点不存在,则表示当前 vnode 是头节点if (preVnode) {// 获取上一个节点对应的 preVnode 对应的真实 DOM 的下一个兄弟元素作为锚点元素const anchor = preVnode.el.nextSibling;// 移动操作insert(newVnode.el, container, anchor);}} else {lastIndex = j;}break;}}// 若 find 仍然为 false,则意味着没有可复用节点// 即当前的 newVnode 节点需要被【挂载】if (!find) {// 挂载也需要挂载到正确的位置,因此需要锚点元素const preVnode = newChildren[i - 1];let anchor = null;if (preVnode) {// 若存在前一个 newVnode 节点,则将其真实 DOM 对应的下一个兄弟元素,作为锚点元素anchor = preVnode.el.nextSibling;} else {// 若不存在前一个 newVnode 节点,则将容器节点中的第一个子元素,作为锚点元素anchor = container.firstChild;}// 基于锚点元素挂载新节点patch(null, newVnode, container, anchor);}}// 遍历旧的一组子节点:卸载多余旧节点for (let i = 0; i < oldChildren.length; i++) {// 当前旧节点const oldVnode = oldChildren[i];// 拿旧的子节点 oldVnode 去新的一组子节点中寻找具有相同 key 值的节点const has = newChildren.find(vnode => vnode.key === oldVnode.key);// 若没有找到相同 key 的节点,则说明需要删除或卸载当前旧节点if(!has){unmount(oldVnode);}}} else {// 省略代码}
}
实际上 diff
操作的目的是 更新、移动、挂载、卸载
的过程,基于 key
可以在 新旧一组子节点 中尽可能找到可复用节点,即尽可能的通过 DOM 移动操作来完成更新,避免过多地对 DOM 元素进行销毁和重建。
虽然实现了尽可能复用 DOM 节点,但是上述算法对 DOM 的 移动操作 仍然不是最优的,其中 lastIndex
记录的是 旧的一组子节点中上次被更新的索引位置:
p3
节点移动到末尾节点即可p1
和 p2
节点对应的真实 DOM
节点被移动双端 Diff 算法是一种同时对新旧两组子节点的两个端点进行比较的算法.
这是在实践中总结出来的,通常在一组子节点中对基于两端的操作是比较常见的,因此可以基于这样的假设去尽量减少每个新节点都要通过遍历一次旧的一组子节点的操作。
只要 命中 以下 四种假设,则可以直接通过 patch()
进行 更新
若 不能命中 这四种假设,那么仍然需要基于 key
通过遍历找到 当前新节点 在 老的一组子节点 中的位置索引:
pacth()
进行 更新* 且 不是 同一节点(key
相同,但节点类型不同),则视为新元素,并进行 挂载 操作当 老节点 或者 新节点 被遍历完了,就需要对剩余的节点进行操作:
oldStartIdx > oldEndIdx
表示 老节点遍历完成,若 新节点有剩余,则说明剩余的节点是新增的节点,需要进行挂载 操作newStartIdx > newEndIdx
表示 新节点遍历完成,若 老节点有剩余,则说明剩余部分节点需要被删除,需要进行 卸载 操作与基于 key
的简单 diff
算法相比,在相同情况下,原来简单 diff
算法需要两次移动 DOM
操作才能完成的更新,双端 diff
算法只需要一次 DOM
移动即可完成更新:
oldChildren[oldEndIdx] === newChildren[newStartIdx]
,需要通过 pacth
进行 更新,并将当前旧尾节点对应的 DOM 元素 移动 到旧头结点之前* 此时 oldEndIdx
需要 -1
,而 newStartIdx
需要 +1
oldChildren[oldStartIdx] === newChildren[newStartIdx]
,由于此时属于新旧头结点相同,只需要通过 pacth
进行 更新 即可* 此时 oldStartIdx
和 newStartIdx
都需要 +1
oldChildren[oldStartIdx] === newChildren[newStartIdx]
,由于此时属于新旧头结点相同,只需要通过 pacth
进行 更新 即可* 此时 oldStartIdx
和 newStartIdx
都需要 +1
oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
跳出循环,双端 diff
结束/* diff 过程: diff 优化: 1. 四种假设:newStart === oldStartnewEnd === oldEndnewStart === oldEndnewEnd === oldStart 2. 假设新老节点开头结尾有相同节点的情况: - 一旦命中假设,就避免了一次循环,以提高执行效率- 如果没有命中假设,则执行遍历,从老节点中找到新开始节点找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置 如果老节点先于新节点遍历结束,则剩余的新节点执行新增节点操作 如果新节点先于老节点遍历结束,则剩余的老节点执行删除操作,移除这些老节点*/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 是一个特殊的标志,仅由 使用,// 以确保被移除的元素在离开转换期间保持在正确的相对位置const canMove = !removeOnlyif (process.env.NODE_ENV !== 'production') {// 检查新节点的 key 是否重复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)) {// 老开始节点和新开始节点是同一个节点,执行 patchpatchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)// patch 结束后老开始和新开始的索引分别加 1,开始下一个节点oldStartVnode = oldCh[++oldStartIdx]newStartVnode = newCh[++newStartIdx]} else if (sameVnode(oldEndVnode, newEndVnode)) {// 老结束和新结束是同一个节点,执行 patchpatchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)// patch 结束后老结束和新结束的索引分别减 1,开始下一个节点oldEndVnode = oldCh[--oldEndIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right// 老开始和新结束是同一个节点,执行 patchpatchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)// 处理被 transtion-group 包裹的组件时使用canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))// patch 结束后老开始索引加 1,新结束索引减 1,开始下一个节点oldStartVnode = oldCh[++oldStartIdx]newEndVnode = newCh[--newEndIdx]} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left// 老结束和新开始是同一个节点,执行 patchpatchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)// patch 结束后,老结束的索引减 1,新开始的索引加 1,开始下一个节点oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]} else {// 如果上面的四种假设都不成立,则通过遍历找到新开始节点在老节点中的位置索引// 找到老节点中每个节点 key 和 索引之间的关系映射:// 如 oldKeyToIdx = { key1: idx1, ... }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)) {// 如果这两个节点是同一个,则执行 patchpatchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)// patch 结束后将该老节点置为 undefinedoldCh[idxInOld] = undefinedcanMove && 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].elmaddVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)} else if (newStartIdx > newEndIdx) {// 新节点被遍历完了,老节点有剩余,说明这部分的节点被删掉了,则移除这些节点removeVnodes(oldCh, oldStartIdx, oldEndIdx)}}
Vue.js 3
借鉴了 ivi
和 inferno
这两个框架中使用的算法:快速 Diff
算法,这个算法的性能优于 Vue.js 2
中所采用的 双端 Diff
算法.
以下涉及的源码位置均在:
vue-core-3.2.31-main\packages\runtime-core\src\renderer.ts
中的patchKeyedChildren
函数中
对于 相同位置 的 前置节点 和 后置节点,由于它们在新旧两组子节点中的相对位置不变,因此并 不需要 进行 移动 操作,只需 进行 patch
更新 即可.
通过开启一个 while
循环,从前往后 依次遍历新旧两组子节点:
patch
进行 更新Vue.js 3
中对应源码如下:// 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++
}
通过开启一个 while
循环,从后往前 依次遍历新旧两组子节点:
patch
进行 更新Vue.js 3
中对应源码如下:// 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--
}
当完成 节点预处理 后,很可能出现以下两种情况,而这些剩余节点是很容易根据已处理过的前后节点推断出它们的具体位置的:
patch
将剩余新节点依次进行 挂载unmount
将剩余旧节点依次进行 卸载Vue.js 3
中对应源码如下:// 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 + 1const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchorwhile (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++}
}
直接来看 vue.js 3
在源码中举的例子:
旧节点:[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]
的部分是乱序的,针对这部分节点的处理是很关键的:
toBePatched
保存新节点的数量,即 toBePatched = e2 - s2 + 1
newChildren
的剩余节点,构造基一个形如 key: index
的 keyToNewIndexMap
索引映射,本质是一个 Map
对象patch
更新或 unmount
卸载* 若当前遍历的 老节点的 key 能在 keyToNewIndexMap
中获取到对应的索引值,则说明当前节点是可复用的节点,可通过 patch
进行 更新,并通过 patched
记录下当前已 被更新/被复用 的节点数* 若当前遍历的 老节点的 key 不能在 keyToNewIndexMap
中获取到对应的索引值,则说明当前的老节点通过 unmount
进行卸载* 若 patched >= toBePatched
,则说明所有剩余的新节点都已经在剩余旧节点中找到并更新完成,此时需要对旧节点中剩余老节点通过 unmount
进行卸载* 若当前老节点对应新节点中的索引 小于 上一次记录的索引值,则说明当前节点需要移动,将 moved
变量标识为 true
,便于后续基于 最长递增子序列 进行 移动 操作newIndexToOldIndexMap
数组,用于存储 当前新节点 在 老节点中 的索引值* 基于 newIndexToOldIndexMap
数组通过 getSequence(newIndexToOldIndexMap)
得到最长递增子序列,其中相关算法感兴趣的可自行研究* 从后往前 遍历,其中索引 i
指向新的一组子节点的最后一个节点,而索引 j
指向的是最长递增子序列中的最后一个元素* 若当前新节点对应老节点中的索引为 0
,则说明当前节点需要进行 挂载* 若 moved
为 true
则说明当前新节点需要进行 移动Vue.js 3
中对应源码如下:// 5.3 move and mount// generate longest stable subsequence only when nodes have movedconst increasingNewIndexSequence = moved? getSequence(newIndexToOldIndexMap): EMPTY_ARRj = increasingNewIndexSequence.length - 1// looping backwards so that we can use last patched node as anchorfor (i = toBePatched - 1; i >= 0; i--) {const nextIndex = s2 + iconst nextChild = c2[nextIndex] as VNodeconst anchor =nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchorif (newIndexToOldIndexMap[i] === 0) {// mount newpatch(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 sequenceif (j < 0 || i !== increasingNewIndexSequence[j]) {move(nextChild, container, anchor, MoveType.REORDER)} else {j--}}}