前言
看完b站上和《vue.js设计与实现》中有关双端diff的讲解,这里做一个梳理记录。
双端diff的简单逻辑
diff的目的是在比较两组虚拟节点后尽可能的复用节点,减少dom操作,提升性能。如果只是拿出一个虚拟节点后与另一组的每一项对比那效率就太低了,所以vue的开发人员在vue的diff上采用了“双端diff”。
顾名思义,双端是两端比较逐渐向中间靠拢,那为啥不中间向两端走呢?
这就需要观察下新旧虚拟节点数组的特点,回想平常对数组的操作,我们多半都是往数组的尾部或者头部增、改、删元素,即使是中间的元素做增、改、删操作,也能保证头尾部的元素是尽可能的相似的。所以我们现在就能回答为什么是从数组的两端开始比较,而不是从中间开始?
答:由于操作数组的特点,头尾部出现相似节点的概率最大,这样能最大化的先处理相似节点,减少后续处理的难度,提高效率(万金油,哈哈)。
处理步骤
既然需要比较两个数组前后节点,那现在就需要四个下标,分别指向头部新虚拟节点、尾部新虚拟节点、头部旧虚拟节点,尾部旧虚拟节点。代码片段如下:
let oldStartIndex = 0
let oldEndIndex = oldChildren?.length ? oldChildren.length - 1 : 0
let newStartIndex = 0
let newEndIndex = newChildren?.length ? newChildren.length - 1 : 0
let oldStartVnode = oldChildren[oldStartIndex]
let oldEndVnode = oldChildren[oldEndIndex]
let newStartVnode = newChildren[newStartIndex]
let newEndVnode = newChildren[newEndIndex]
借用《vue.js设计与实现》表明现在的形势,如图-1.(ps:变量名没有一一对应,但应该是清楚的)
【图-1,来自《vue.js设计与实现》】
前四基本步骤
图-1也已经说明了“双端diff”的前四个基本步骤,即“前前对比,后后对比,旧前新后,旧后新前”,
序号 | 操作名 | 对比 | 如果相似后的处理 |
1 | 前前对比 | p-1与p-4对比节点的key以及标签名,结果key不同,开始“后后对比” | patch新旧VNode,为新VNode的elm赋值真实节点两个头部下标往后靠拢。 |
2 | 后后对比 | 新虚拟节点p-3和旧虚拟节点的p-4 对比,结果不相似,开始“旧前新后”对比 | patch新旧VNode,为新VNode的elm赋值真实节点两个尾部下标往前靠拢。 |
3 | 旧前新后 | 旧的p-1和新的p-3对比,不相似,开启“旧后新前”对比 | patch,elm赋值,旧前对应的真实节点移到旧后真实节点之后,旧前新后下标往中间靠拢。 |
4 | 旧后新前 | 新的p-4和旧的p-4相似 | patch,elm赋值,旧后对应的真实节点移到旧前真实节点之前,旧后新前下标往中间靠拢。 |
代码片段如下:
if (isSameVnode(oldStartVnode, newStartVnode)) {
// 旧前新前
patch(oldStartVnode, newStartVnode)
// 为新虚拟节点设置真实dom,目的是后续使用时需要用到真实dom
if (newStartVnode) { newStartVnode.elm = oldStartVnode?.elm }
// 移动
oldStartVnode = oldChildren[++oldStartIndex]
newStartVnode = newChildren[++newStartIndex]
console.log(1);
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
// 旧后新后
patch(oldEndVnode, newEndVnode)
if (newEndVnode) newEndVnode.elm = oldEndVnode?.elm
// 移动
oldEndVnode = oldChildren[--oldEndIndex]
newEndVnode = newChildren[--newEndIndex]
console.log(2);
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// 旧前新后
patch(oldStartVnode, newEndVnode)
if (newEndVnode) newEndVnode.elm = oldStartVnode?.elm
// 把旧前代表的节点移动到旧后代表节点的后面
parentNode.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
// 移动
oldStartVnode = oldChildren[++oldStartIndex]
newEndVnode = newChildren[--newEndIndex]
console.log(3, 'dom移动');
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// 旧后新前
patch(oldEndVnode, newStartVnode)
if (newStartVnode) newStartVnode.elm = oldEndVnode?.elm
// 把旧后代表的节点移动到旧前代表节点的前面
parentNode.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
// 移动
oldEndVnode = oldChildren[--oldEndIndex]
newStartVnode = newChildren[++newStartIndex]
console.log(4, 'dom移动');
}
查找
如果前面四个情况都不满足,就进入“查找”阶段。找?怎么找?
拿出一个剩下的未处理的新虚拟节点,去剩余的旧虚拟节点数组中做比对,如果没有查找到,那么这个新虚拟就需要创建真实节点并且插入,插入位置是旧前的真实节点之前(因为现在是在处理新前虚拟节点,要保证位置一一对应);如果能找到相似的旧虚拟节点,那么这个旧虚拟节点对应的真实节点需要移到旧前的真实节点之前,并且这个旧虚拟节点在旧虚拟数组中的位置需要置为undefined。
如图-2所示:
【图-2】
把新VNode“p-5”拿出来与剩余的旧VNode做对比,在第2步能找到。找到后并且做完常规操作(patch,elm复制等等)之后newStartIndex需要下移,旧虚拟节点数组中的p-5也需要置为undefined。
为什么需要置为undefined?主要原因有两点:
- 新旧VNode对比时“一一对应”原则,既然这次找到了旧虚拟节点,那下次对比时这个旧VNode就不能再用了,所以置为undefined。
- 如果出现越界的情况(越界后面会讲到),到时候会遍历旧虚拟节点数组,如果改旧VNode不为undefined就会移除其真实节点,从而产生错误。
p-5处理完后就处理p-7,这个新VNode也是前四步都不满足,来到查找,很明显也是找不到的,如图-3。
【图-3】
如果查找也没有找到就需要新增这个VNode,创建真实节点,并将真实节点插入到旧前VNode(oldStartIndex对应的那个虚拟节点)对应的真实节点之前,新虚拟节点的头部下标下移(即++newStartIndex)。
思考“查找”代码实现
我们可以构建一个索引表,存放剩余旧虚拟节点key与其下标的映射,然后用新VNode 的key去访问索引表,能访问到就代表新旧VNode有对应,只需要移动节点;访问结果是undefined 就说明需要新建节点。具体代码片段如下:
//前面代码省略
else {
// 以上都不满足,就查找,找到就在旧虚拟节点数组中设为undefined,没找到就新增
// 创建索引表,存放剩余未处理的旧虚拟节点的key与其下标的映射
const obj = {}
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
const key = oldChildren[i]?.key
if (key != undefined) {
obj[key] = i
}
}
// 利用映射查看新虚拟节点是否在旧虚拟节点中
let resultVnodeIndex = obj[newStartVnode.key]
if (resultVnodeIndex !== undefined) {
// 此时证明新虚拟节点能在旧的虚拟节点中能找到
const resultOldVnode = oldChildren[resultVnodeIndex]
patch(resultOldVnode, newStartVnode)
// 处理过的虚拟节点,需要在旧数组中设为undefined【在旧虚拟节点数组找到的位置设为undefined,后面如果有剩下来的就不会删除当前旧虚拟节点了。】
// 【目的就是证明这个虚拟节点用过了。】
oldChildren[resultVnodeIndex] = undefined
// 移动找到的节点
parentNode.insertBefore(resultOldVnode.elm, oldStartVnode.elm)
console.log(5, 'dom移动');
} else {
// 没有找到==>创建节点==>插入到旧前节点的前面
parentNode.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
console.log(6, 'dom增加');
}
// 新前虚拟节点+1
newStartVnode = newChildren[++newStartIndex]
}
越界
先看下把p-7处理完后的结果,如图-5。
【图-5】
这个时候新VNode都没了,而旧VNode还有没处理的,对于这种情况的旧VNode我们明显知道是需要删除其对应的真实节点。我们还能假设另外一种情况,那就是旧VNode没了,但是新VNode还有剩的,我们也能明显知道这些新VNode需要新增(所以将这两种情况称为“越界”)。
代码片段如下:
// 预处理后的剩余新旧虚拟节点数组有一组处理完了。
if (oldStartIndex > oldEndIndex) {
// 证明旧虚拟节点数组中能比较的都没有了,所以新虚拟节点数组中剩余的虚拟节点都需要新增
// 增加
const beforeNode = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1].elm : null
for (let i = newStartIndex; i <= newEndIndex; i++) {
parentNode.insertBefore(createElement(newChildren[i]), beforeNode)
console.log(7, 'dom增加');
}
} else {
// 证明比较后旧虚拟节点还有剩下来的,所以需要删除非undefined节点
// 删除
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldChildren[i] != undefined) {
parentNode.removeChild(oldChildren[i].elm)
console.log(8, 'dom删除');
}
}
}
来解决个bug
先有这样一个页面,两个radio,选中“长安”,之后点击按钮,在数组最前面加一个选项。
【图-6】
代码如下:
<div id="app">
<!-- key用index绑定会有bug -->
<label v-for="(item, index) in valArr" :key="index">
<!-- <label v-for="(item) in valArr" :key="item.value"> -->
<input type="radio" :value="item.value"> {{ item.label }}
</label>
<button @click="btn">点击</button>
</div>
methods: {
btn() {
this.valArr.unshift({ value: '4', label: '北京' })
// this.$set(this.valArr,1, { value: '4', label: '北京' })
}
},
点击后的结果:
【图-7】
我们发现点击按钮后,本来是“长安”选中,现在竟然是“西安”选中了。这是为什么呢?
原来我们模板中v-for绑定的key是用的index,新增后“西安”虚拟节点回去错误的复用“长安”的节点,导致“西安”是选中状态。如果把key用与节点特定的属性(即item.value)绑定就没问题。
具体可以看图-8和图-9
【图-8 key用index绑定】
【图-9 key用item.value绑定】
个人总结
有新的理解后再来更新。