1.双端diff算法
如下图,如果使用简单diff算法真实 DOM 节点会移动两次,但是实际上通过简单的观察可以发现只需要移动一次p-3就可以。
所以得出结论:简单diff算法的性能在某些场景下并不是最好的。对于上述的例子,使用双端diff算法的性能会更高。
2.双端diff算法比较原理
双端diff是一种对新旧两组子节点的端点进行比较的算法。(比较的是key)
vue对每个虚拟节点都会设置key,对比时是通过key来进行对比,
但是for循环这种情况比较特殊,vue不会给for循环自动添加key,而需要编码时手动添加key,因为for循环里面的顺序是随时变化的。
通过打印虚拟节点就可以看到,for循环是没有手动给key的
// oldChildren
[
{type: 'p', children: '1'},
{type: 'p', children: '2'},
{type: 'p', children: '3'},
{type: 'p', children: '4'}
]
// newChildren
[
{type: 'p', children: '4'},
{type: 'p', children: '2'},
{type: 'p', children: '1'},
{type: 'p', children: '3'}
]
在双端比较中,每一轮比较都分为四个步骤。
-
第一步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的第一个子节点 p-4,看它们是否相同。这里不相同,什么都不做。
-
第二步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的最后一个子节点 p-3,看它们是否相同。这里不相同,什么都不做。
-
第三步:比较旧的一组子节点中的第一个子节点 p-1 与新的一组子节点中的最后一个子节点 p-3,看它们是否相同。这里不相同,什么都不做。
-
第四步:比较旧的一组子节点中的最后一个子节点 p-4 与新的一组子节点中的第一个子节点 p-4。由于它们key相同,所以可以进行 DOM 复用。
思考下,p-4节点所对应的真实 DOM 此时应该如何移动?代码如何实现?
/*
* n1 旧的一组子节点
* n2 新的一组子节点
* container 真实 DOM 的锚点
*/
function patchKeyedChildren(n1, n2, container) {
const oldChildren = n1.children
const newChildren = n2.children
// 端点的索引
let oldStartIdx = 0
let oldEndIdx = oldChildren.length - 1
let newStartIdx = 0
let newEndIdx = newChildren.length - 1
// 四个端点索引对应的 vnode 节点
let oldStartVNode = oldChildren[oldStartIdx]
let oldEndVNode = oldChildren[oldEndIdx]
let newStartVNode = newChildren[newStartIdx]
let newEndVNode = newChildren[newEndIdx]
// 双端比较
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVNode) {
oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
newEndVNode = oldChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// 节点在新的顺序中都处于头部, DOM 不需要移动的
// 更新索引
oldStartVNode = oldChildren[++oldStartIdx]
newStartVNode = newChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
// 节点在新的顺序中都处于尾部,DOM 不需要移动的
// 更新索引尾部节点的变量
oldEndVNode = oldChildren[--oldEndIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
// 移动 DOM 操作
// oldStartVNode.el 移动到 oldEndVNode.el.nextSibling
insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
// DOM 移动完成后,更新索引值,然后指向下一个位置
oldStartVNode = oldChildren[++oldStartIdx]
newEndVNode = newChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
// 移动 DOM 操作
// oldEndVNode.el 移动到 oldStartVNode.el 前面
insert(oldEndVNode.el, container, oldStartVNode.el)
oldEndVNode = oldChildren[--oldEndIdx]
newStartVNode = newChildren[++newStartIdx]
} else {
// 遍历旧的一组子节点,找到newStartVNode拥有相同key的元素
const idxInOld = oldChildren.findIndex(node => node.key === newStartVNode.key)
// idxInOld 大于 0,说明找到了可复用的节点,并且需要将对应的真实 DOM 移动到头部
if (idxInOld > 0) {
// idxInOld 对应旧子节点中需要移动的节点
const vnodeToMove = oldChildren[idxInOld]
// 将 vnodeToMove.el 移动到头部节点 oldStartVNode 前面
insert(vnodeToMove.el, container, oldStartVNode.el)
// idxInOld 处的节点对应的真实 DOM 已经处理过,这里设置为undefined
oldChildren[idxInOld] = undefined
// 更新新子节点头部节点的指针
newStartVNode = newChildren[++newStartIdx]
}
}
}
}
3.非理想状况的处理方式
在进行双端比较中,可能会出现第一轮比较时,无法找到可以复用的节点。
在这种情况下,需要尝试非头部,尾部的节点能否复用。可以用新的一组子节点去旧的一组子节点去寻找。
新节点在旧节点中不存在时会直接忽略这个节点,更改索引进行下一轮的查找。
其实无论是哪种情况,数据或者节点变化都不会立即去更新页面。vue的渲染更新本身是异步的。
这里拿新的一组子节点的头部节点p-2去旧的一组子节点中查找时,会在索引为1的位置找到可复用的节点。说明p-2旧子节点对应的真实 DOM 需要移动到旧子节点 p-1 所对应的真实 DOM 之前。
已经处理过的旧子节点后续不用继续比较,这里设置为undefined。然后开始进行双端比较。在这一轮的比较第四步,找到了可复用的节点。所以将真实 DOM 中的p-4移动到p-1的前面,并且更新对应指针。
在这一轮比较的第一步就找到了可复用的节点,不需要移动 DOM 节点,并更新指针。
在这一轮比较中发现旧子节点中头部节点为undefined。不用比较,直接跳过。
继续进行比较,发现第一轮比较就可以找到复用节点。不需要移动 DOM,更新指针。这时满足循环停止条件,双端比较结束。
思考下,以上内容转变为代码将如何编写?
4..重点(总结双端diff算法查找过程):
1.双端diff算法即对新旧虚拟节点按照首首,尾尾,旧头新尾,新头旧尾的顺序进行查找。
2.查找到以后头尾的索引进行对应递增或者递减,如果是首首和尾尾直接更改索引值,如果是旧头新尾,新头旧尾查找到的会移动旧虚拟节点对应的真实的DOM顺序;
3.如果查找不到则通过newVnode节点去oldVnode中进行遍历查找,查找到以后需要移动oldVnode对应的真实DOM;再将newVnode索引加1,旧的虚拟节点设置标识为undefined,查找索引不变;
4.设置为undefined的oldVnode下次进行双端查找时将被忽略不进行查找直接更改对应索引值;
5.如果newVnode通过索引方式也查找不到,就证明这个节点属于新增节点,对这种节点不会进行特殊操作直接修改索引进行下轮查找。
6.进行索引方式查找后,只更改新的虚拟节点对应的索引值,然后继续按顺序进行双端diff算法查找
7.整个双端diff算法的比对过程是一个循环过程,通过新旧虚拟节点的头尾索引不断递增或递减进行查找,循环结束的标志即新头索引<=新尾索引 && 旧头索引 <= 旧尾索引
8.注意所有处理都是先移动DOM节点,再更新索引。
5.简单diff算法和双端diff算法对比
简单diff算法和双端diff算法都有各自的优缺点。
简单diff算法是针对虚拟DOM节点数据较少的情况,而双端diff算法针对节点数据较多的情况。
如果虚拟DOM节点数据较少时,使用简单diff算法的效率比使用双端diff算法的效率更高。
而数据量大时,因为双端diff算法是通过双端查找,查找的次数会较少,所以效率会更高
6.手写整个过程
/* 1.双端diff算法即对新旧虚拟节点按照首首,尾尾,旧头新尾,新头旧尾的顺序进行查找。
2.查找到以后头尾的索引进行对应递增或者递减,如果是首首和尾尾直接更改索引值,如果是旧头新尾,新头旧尾查找到的会移动旧虚拟节点对应的真实的DOM顺序;
3.如果查找不到则通过newVnode节点去oldVnode中进行遍历查找,查找到以后需要移动oldVnode对应的真实DOM;再将newVnode索引加1,旧的虚拟节点设置标识为undefined,查找索引不变;
4.设置为undefined的oldVnode下次进行双端查找时将被忽略不进行查找直接更改对应索引值;
5.如果newVnode通过索引方式也查找不到,就证明这个节点属于新增节点,对这种节点不会进行特殊操作直接修改索引进行下轮查找。
6.进行索引方式查找后,只更改新的虚拟节点对应的索引值,然后继续按顺序进行双端diff算法查找
7.整个双端diff算法的比对过程是一个循环过程,通过新旧虚拟节点的头尾索引不断递增或递减进行查找,循环结束的标志即新头索引<=新尾索引 && 旧头索引 <= 旧尾索引
*/
// 理想状态和非理想状态
function patchChildren(n1, n2, container) {
let newChildren = n1.children;
let oldChildren = n2.children;
// 设置双端的四个索引初始值
let newStartIdx = 0;
let oldStartIdx = 0;
let newEndIdx = newChildren.length - 1;
let oldEndIdx = oldChildren.length - 1;
// 获取双端初始的虚拟节点数据
let newStartVnode = newChildren[newStartIdx];
let oldStartVnode = oldChildren[oldStartIdx];
let newEndVnode = oldChildren[newEndIdx];
let oldEndVnode = oldChildren[oldEndIdx];
// 整个过程是循环执行的,结束标识为新头索引<=新尾索引 && 旧头索引 <= 旧尾索引
while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
// 对设置为undefined的虚拟节点进行处理(直接更改索引值)
if (!oldStartVNode) { //或者判断!oldStartVNode
oldStartVnode = oldChildren[++oldStartIdx]
} else if (!oldEndVnode) {
oldEndVnode = oldChildren[--oldEndIdx]
} else if (newStartVnode.key === oldStartVnode.key) {
// 进行双端diff算法查找(首首,尾尾,旧头新尾,新头旧尾),比对的是两个子节点的key
newStartVnode = newChildren[++newStartIdx]
oldStartVnode = oldChildren[++oldStartIdx]
} else if (newEndVnode.key === oldEndVnode.key) {
// 尾尾相等,都是处于末端,不需要移动虚拟DOM,只需要更改索引
newEndVnode = newChildren[--newEndIdx]; //先将索引+1再赋值给虚拟DOM
oldEndVnode = oldChildren[--oldEndIdx];
} else if (oldStartVnode.key === newEndVnode.key) {
// 旧尾新尾相等,需要移动虚拟DOM,且更改索引
insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling()); //oldStartIdx.el 移动到 newEndIdx.el 后面
oldStartVnode = oldChildren[++oldStartIdx]; //先将索引+1再赋值给虚拟DOM
newEndVnode = newChildren[--newEndIdx];
} else if (newStartVnode.key === oldEndVnode.key) {
// 新头旧尾相等,则移动旧虚拟节点对应的真实DOM,并且更改两端索引
// 移动DOM节点
insert(oldEndVNode.el, container, oldStartVNode.el); //此时是oldEndVNode.el 移动到 oldStartVNode.el 前面
// ++i后者是先自增,后赋值
newStartVnode = newChildren[++newStartIdx]; //先将索引+1再赋值给虚拟DOM
oldEndVnode = oldChildren[--oldEndIdx];
} else {
// 这种是通过首首,尾尾,旧头新尾,新头旧尾没有查找到时,通过新头去旧的虚拟节点中遍历查找
let idxInOld = oldChildren.findIndex(node => newStartVnode.key === node.key);
if (idxInOld) {
// 注意需要先移动当前index所在的虚拟节点
// idxInOld 对应旧子节点中需要移动的节点
const vnodeToMove = oldChildren[idxInOld]
// 将 vnodeToMove.el 移动到头部节点 oldStartVNode 前面
insert(vnodeToMove.el, container, oldStartVNode.el)
// 新头去旧的虚拟节点中遍历查找到了,需要先移动虚拟节点,再将当前虚拟节点标识为undefined即可,且修改索引
oldChildren[idxInOld] = undefined;
// 将新头的索引+1,旧头不变
newStartVnode = [++newStartIdx];
} else {
// 没有找到会忽略这个节点
newStartVnode = [++newStartIdx];
}
}
}
}