站在巨人的肩膀上看vue,来自霍春阳的vue设计与实现。作者以问题的形式一步步解读vue3底层的实现。思路非常的巧妙,这里会将书中的主要逻辑进行串联,也是自己读后的记录,希望通过这种形式可以和大家一起交流学习。
开篇
双端Diff算法指的是,在新旧两组节点的四个端点之间分别进行比较,并试图找到可复用的节点,相比简单Diff算法,双端Diff算法的优势在于,对于同样的更新场景,执行的DOM移动操作数会更少。
双端算法主要需要比较四次,先将新头旧头、新尾旧尾进行对比,然后新尾和旧头、新头和旧尾对比。这里就出现几种场景:
当新旧节点一样时。新头旧头、新尾旧尾相同不需要移动,只需要修改节点;当新尾和旧头相同,则需要将旧头移到到尾部,然后更新新尾和旧头的位置。同理,新头和旧尾相同,只需要将旧尾移动到前面节点,再更新位置。
当然这是最理想的状态,当第一轮四次比较都没有找到相同的节点时,这时候取新节点第一个在旧节点中移动位置,如果没有找到表明这是一个新增的节点,添加到旧节点顶部。然后在重新定义四个端点的位置。
最后一种情况可能在比较的过程中,新旧节点依然还有节点没有处理,这就需要新增或者卸载了,如果新节点多余表明需要新增,反之旧节点多余表明需要卸载。
最后这就是一个双端Diff算法的整个过程。
10.1、双端比较的原理
简单的diff算法移动操作并不是最优的,比如
新节点 旧节点
n-3 o-1
n-1 ---> o-2
n-2 o-3
在简单diff算法中更新会发生两次DOM操作,遍历新节点寻找旧节点可复用的节点:首先把o-3当作头节点,移动o-1到节点o-3下面,最后遍历到n-2把o-2移动到之前的o-1下面。
显然这并不是最优的,我们只需要将o-3移动到第一个节点就行。
双端Diff算法:同时对新旧两组节点的两个端点进行比较需要四个索引值,分别指向新旧两组节点的端点。
function patchKeyedChildren(oldChildren, newChildren, container) {
// 四个索引
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
// 四个索引指向的vnode节点
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
}
比较过程分为四步:
- 新节点第一个和旧节点第一个比较
- 新节点最后一个和旧节点最后一个比较
- 新节点最后一个和旧节点第一个比较
- 新节点第一个和旧节点最后一个比较
节点相同的场景也因此分为四种:
第1种情况:新节点第一个和旧节点第一个相同
两者都是头部节点,不需要移动只需要打补丁更新内容
if (newStartVNode.key === oldStartVNode.key) {
// 第一步
patch(oldStartVNode, newStartVNode, container) // 补丁修改不同
newStartVNode = newChildren[++newStartIdx] // 更新索引值
oldStartVNode = oldChildren[++oldStartIdx]
} else if (newEndVNode.key === oldEndVNode.key) {
// 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
// 第三步
} else if (newStartVNode.key = oldEndVNode.key) {
// 第四步
}
第2种情况:新节点最后一个和旧节点最后一个相同
两者都处于尾部,因此不需要移动,只需要打补丁
if (newStartVNode.key === oldStartVNode.key) {
// 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
// 第二步
patch(oldEndVNode, newEndVNode, container) // 补丁修改不同
newEndVNode = newChildren[--newEndIdx] // 更新索引值
oldEndVNode = oldChildren[--oldEndIdx]
} else if (newEndVNode.key === oldStartVNode.key) {
// 第三步
} else if (newStartVNode.key = oldEndVNode.key) {
// 第四步
}
第3种情况:新节点最后一个和旧节点第一个相同
需要将旧的第一个节点移动到尾部,更新对应的索引位置
if (newStartVNode.key === oldStartVNode.key) {
// 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
// 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
// 第三步
patch(oldStartVNode, newEndVNode, container) // 补丁修改不同
insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling) // 插入到最后一个节点兄弟节点的前面
newStartVNode = newChildren[++newStartIdx] // // 更新索引值
oldEndVNode = oldChildren[--oldEndIdx]
} else if (newStartVNode.key = oldEndVNode.key) {
// 第四步
}
第4种情况:新节点第一个和旧节点最后一个相同
只需要通过DOM移动操作完成更新,将索引值为oldEndIdx 指向的虚拟节点所对应的真实的DOM移动到索引oldStartIdx 指向的虚拟节点所对应的真实的DOM前面(最后一个移动到最前面)
if (newStartVNode.key === oldStartVNode.key) {
// 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
// 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
// 第三步
} else if (newStartVNode.key = oldEndVNode.key) {
// 第四步
patch(oldEndVNode, newStartVNode, container) // 补丁修改不同
insert(oldEndVNode.el, container, oldStartVNode.el) // 移动dom到旧节点第一个前面
newStartVNode = newChildren[++newStartIdx] // 更新索引值,指向下一个节点
oldEndVNode = oldChildren[--oldEndIdx]
}
10.2-10.3、双端比较的优势和非理想状态处理方式
采用简单Diff算法移动元素位置如开头描述一样,会有很多不必要的操作,而使用双端diff算法可以对其进行优化。
非理想的状态1:在经过四次对比都找不到相同节点该怎么处理?
解决方法:
- 拿新节点的第一个去旧节点中寻找
- 将找到的旧节点的索引存储到变量idxInOld中
- 将该节点放到旧节点的第一个位置上
if (newStartVNode.key === oldStartVNode.key) {
// 第一步
} else if (newEndVNode.key === oldEndVNode.key) {
// 第二步
} else if (newEndVNode.key === oldStartVNode.key) {
// 第三步
} else if (newStartVNode.key = oldEndVNode.key) {
// 第四步
} else {
// 特殊情况
const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld]
patch(vnodeToMove, newStartVNode, container) // 补丁修改不同
insert(vnodeToMove.el, container, oldStartVNode.el) // 移动dom到旧节点第一个前面
oldChildren[idxInOld] = undefined // 将旧节点设置为undefined
newStartVNode = newChildren[++newStartIdx] // 更新索引值,指向下一个节点
}
}
当新节点第一个元素在旧节点中找到的话移动设置为undefined,这时下次遍历的时候也需要处理一下:
如果头尾部节点为undefined,说明该节点已经被处理过了,直接跳到下一个位置。
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = newChildren[--oldEndIdx]
}
10.4、添加新元素
非理想状态2: 新节点第一个元素在旧节点中找不到
说明这是一个新增的节点,只需要将它挂载到当前头部节点之前即可
// 特殊情况
const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)
if (idxInOld > 0) {
const vnodeToMove = oldChildren[idxInOld]
patch(vnodeToMove, newStartVNode, container) // 补丁修改不同
insert(vnodeToMove.el, container, oldStartVNode.el) // 移动dom到旧节点第一个前面
oldChildren[idxInOld] = undefined // 将旧节点设置为undefined
} else {
patch(null, newStartVNode, container, oldStartVNode.el)
}
newStartVNode = newChildren[++newStartIdx]
非理想状态3: 更新过程中,新节点比旧节点多,会遗漏节点
当旧节点所有的节点更新完之后,oldStartIdx 大于 oldEndIdx值,上面的while循环会停止更新,但是新节点还有数据则需要增加
if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
// 新的节点添加
for (let i = newStartIdx; i <= newEndIdx; i++) {
patch(null, newChildren[i], container, oldStartVNode.el)
}
}
10.5、移除不存在的元素
同理,当新节点所有的节点更新完之后,newStartIdx 大于 newEndIdx值,上面的while循环会停止更新,但是旧节点还有数据则需要移除
if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
// 新的节点添加
for (let i = newStartIdx; i <= newEndIdx; i++) {
patch(null, newChildren[i], container, oldStartVNode.el)
}
} else if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
// 移除操作
for (let i = oldStartIdx; i < oldEndIdx; i++) {
unmount(oldChildren[i])
}
}