背景
diff算法是讨论vue源码始终绕不开的话题,也是面试官非常热衷提问的问题来源,通过对diff算法的考察,可以明显的看出候选人对vue原理的掌握程度。因此许多同学就会通过背八股文来强迫自己输入diff算法的知识,殊不知,只要面试官稍加深入,候选人的回答就捉襟见肘了。本文就通过对diff算法的一步步还原来帮助各位同学从根源上解决diff算法上的难题。
初见diff算法
首先我们得明白什么情况下会用到diff算法,以及为什么需要用到diff算法。
diff算法使用场景
在vue中,当我们修改响应式数据
时,vue内部会执行render函数,从而形成新的虚拟DOM ,然后会将新的VNode同旧的VNode进行比较,这个新|旧VNode比较的过程在patch函数中进行,被称为"打补丁"。在比较新旧VNode的时候,针对其子节点会执行patchChildren函数,如果新旧VNode的子节点均为数组时,此时就会使用diff算法,用来减少DOM操作,提升性能
为什么是diff算法
前面讲到diff算法的作用就是在响应式数据更新期间,通过减少DOM操作来提升性能。为什么diff算法能够减少DOM操作呢?在pacth过程中,如果没有diff算法,那就只能卸载旧节点,然后在挂载新节点,由于新的VNode此时并没有真实的el节点,因此每次更新都必须先通过获取标签名创建真实的新节点,然后再挂载,整个过程是非常消耗性能的。而diff算法就是通过比对新旧VDOM,从中复用旧的真实节点,仅更新其内容,从而减少对DOM节点的操作。
核心策略
快速Diff
diff算法首先是进行头尾对比。即,先比较头部节点和尾部节点,跳过相同的前缀和尾缀节点,缩小比较范围。例如,新旧VDOM中存在相同节点(如,A-B-C变为A-D-C),则直接复用头部和尾部节点,仅遍历中间节点。
最长递增子序列(LIS)
最长递增子序列是vue3较vue2diff算法性能优化的又一大提升。Vue3通过构造source
数组记录新节点在旧节点中的位置,并计算LIS确定最长无需移动的节点序列,仅对不在LIS中的节点进行移动,极大减少DOM操作次数 。例如,新旧节点顺序变化为A-B-C
→B-C-A
时,仅移动节点A
而非全部重新排列。
详细过程
假设旧节点为(ABCDEF),新节点为(ABECDHFG),如下图:
头尾比较
思路:
1、定义三个变量:i=0;e1=旧子节点个数;e2=新节点个数
2、从头部开始比较,i不断递增,直到新旧节点的不同或者一方被遍历完时停止
3、从尾部开始比较,e1和e2同时递减,直到新旧节点不同或者一方被遍历完时停止
js
function isSameVnode (vnode1, vnode2) {//判断两个虚拟节点是否相同
return (vnode1.type === vnode2.type) && (vnode1.key === vnode2.key)
}
const patchKeyedChildren = (oldChildren, newChildren, el) => {
// 定义三个变量
let i = 0
let e1 = oldChildren.length - 1
let e2 = newChildren.length - 1
//从头开始比,相同就进行patch,有一方停止循环直接跳出
while (i <= e1 && i <= e2) {
const n1 = oldChildren[i]
const n2 = newChildren[i]
if (isSameVnode(n1, n2)) {
//打补丁(上面只是类型和key相同,并不代表两个元素中属性或子级相同)
patch(n1, n2, el)
} else {
//当两个元素不相同时就停止比较,因为要更新当前的旧元素
break
}
i++
}
// 从尾部开始比较
while (i <= e1 && i <= e2) {
const n1 = oldChildren[e1]
const n2 = newChildren[e2]
if (isSameVnode(n1, n2)) {
//比较类型和key,如果相同就递归比较他们的子级
patch(n1, n2, el)
} else {
//当两个元素不相同时就停止比较,因为要更新当前的旧元素
break
}
e1--
e2--
}
//i要比e1大说明有新增,i和e2之间的元素就是要插入的
if (i > e1) {
if (i <= e2) {
while (i <= e2) {
//e2的下一个索引
const nextPos = e2 + 1
//如果nextPos小于newChildren长度,说明后面有元素,否则为null,从末尾插入元素
const anchor = nextPos < newChildren.length ? newChildren[nextPos] : null
patch(null, newChildren[i], el, anchor)//插入元素
}
}
} else if (i > e2) { //i比e2大的时候,i到e1之间的不在新子级中,说明是要卸载
if (i <= e1) {
while (i <= e1) {
unmount(oldChildren[i])
i++
}
}
}
}
乱序对比
经过上一轮的比较,已经完成了头尾相同的节点的更新,接下来就是比对中间乱序的节点了,这是diff算法的核心。
思路:
1、构建一个map--keyToNewIndexMap,其键为新节点的key,值为对应的元素索引
2、遍历旧节点数组中的每个元素,并查询是否可以在keyToNewIndexMap中查询到一样的节点,如果找不到,说新节点数组中没有该旧节点,直接卸载,否则进行打补丁更新
js
const patchKeyedChildren = (oldChildren, newChildren, el) => {
// ....省略上面代码
//-----乱序比对-----
// 从乱序的起点开始
let s1 = i
let s2 = i
const keyToNewIndexMap = new Map()//用于保存新乱序子级中的元素的下标
for (let i = s2; i <= e2; i++) {
keyToNewIndexMap.set(newChildren[i].key, i)
}
//console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]
//循环乱序旧子级,看看新子级存不存在该元素,存在就添加到列表中复用,否则删除
for (let i = s1; i <= e1; i++) {//注意i从s1开始,也是就是乱序的第一个元素开始,减少循环次数
const oldchild = oldChildren[i]//根据下标获取到旧乱序中的元素
//查找新元素的map中能否找到旧元素
let newIndex = keyToNewIndexMap.get(oldchild.key)
if (newIndex == undefined) {//不存在该元素
unmount(oldchild)//多余的删掉
} else {
patch(oldchild, newChildren[newIndex], el)//如果存在就比对子级差异
}
}
}
节点移动
经过乱序比对后,vue内部已经可以将多余的旧节点删除,并更新相同的节点。但是此时仍然存在两个问题:
1、新增的节点没有挂载,如H
2、对相同的节点虽然进行了复用更新,但只是在原位置上更新,没有移动。即,复用的节点的内容已经更新,但是位置还是原来旧节点的位置。
所以接下来的任务就是新增节点和移动节点
思路:
1、获取乱序的个数,以此为长度新建一个数组--newIndexToOladIndexMap,用于记录相同元素在旧节点数组中的位置。在循环旧节点数组时遇见相同元素就更新数组。0代表找不到相同元素,即,新增
2、倒序循环,从乱序中最后一个新元素开始,判断此时newIndexToOladIndexMap的值:为0,则新增;否则移动到对应的
js
const patchKeyedChildren = (oldChildren, newChildren, el) => {
//....省略上面代码
//-----乱序比对-----
let s1 = i
let s2 = i
const keyToNewIndexMap = new Map()
for (let i = s2; i <= e2; i++) {
keyToNewIndexMap.set(newChildren[i].key, i)
}
//console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]
//循环乱序旧子节点,看看新子节点存不存在该元素,存在就添加到列表中复用,否则删除
const toBePatched = e2 - s2 + 1 //新乱序总个数
//根据乱序个数创建数组并赋值为0,记录是否比对过映射表
const newIndexToOldIndexMap = new Array(toBePatched).fill(0)//[0,0,0,0]
for (let i = s1; i <= e1; i++) {//循环旧乱序子级
const oldchild = oldChildren[i]//根据下标获取到旧乱序中的元素
//查找新元素的map中能否找到旧元素
let newIndex = keyToNewIndexMap.get(oldchild.key)
if (newIndex == undefined) {
unmount(oldchild)//多余的删掉
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1 //标识
patch(oldchild, newChildren[newIndex], el)//如果存在就比对子级差异
}
}
//到这只是新旧比对,没有移动位置
//console.log(keyToNewIndexMap);//[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}]
//console.log(newIndexToOldIndexMap);//[5, 3, 4, 0]
//需要移动位置
for (let i = toBePatched - 1; i >= 0; i--) {//倒叙插入
let index = i + s2 //找到当前元素在newChildren中的下标
let current = newChildren[index] //找到newChildren元素
//找到元素的下个元素作为参照物
let anchor = index + 1 < newChildren.length ? newChildren[index + 1] : null
// newIndexToOldIndexMap[i]为0,表示旧节点中没有可复用的元素,即,新增,如H
if (newIndexToOldIndexMap[i] === 0) {
patch(null, current, el, anchor)//第一个元素传入null,表示要创建元素并根据参照物插入
} else {
hostInsert(current.el, el, anchor.el)//存在el直接根据参照物插入
}
}
}
1、进行前后对比完,s1=2,s2=2,i=2,keyToNewIndexMap[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}],newIndexToOldIndexMap[0,0,0,0]
2、for (let i = s1; i <= e1; i++)遍历旧乱序,i为2,oldchild = oldChildren[2],旧节点为C
3、从keyToNewIndexMap[C]映射表中查找key是否有C,所以找到返回下标:newIndex=3
4、开始标识newIndexToOldIndexMap[newIndex - s2]=i+1;那就是newIndexToOldIndexMap[1]=3
5、此时keyToNewIndexMap{e:2,c:3,d:4:h5},newIndexToOldIndexMap[0,3,0,0],两个映射表对比,是不是表明了C被标识了
6、为什么要i+1呢?如果第一个元素就已经乱序了,那么i就为0;为0表示没有patch过,这样就无法辨别了,这里为了防止,所以+1
7、最后newIndexToOldIndexMap[5,3,4,0]映射keyToNewIndexMap[{'E' : 2}, {'C': 3},{ 'D' : 4}, {'H' : 5}],这样我们是不是知道只有H需要创建真实节点了
快速diff的利器--最长递增子序列
按照上面的思路,其实我们已经可以将旧节点更新为新节点了。但是有一个问题,乱序中的每一个节点都会有一次操作,要么删除、要么新增、要么移动。删除和新增没得啥说的,必须得有一次操作,但是每个元素是否真的都需要一次移动呢?实际是没有必要的。比如上面我们只需要新增H,并将E移动到C的前面两步就可以完成乱序的操作。而要实现最简操作,就要引入我们的主角---最长递增子序列。最长递增子序列的作用就是记录不用移动的节点,从而减少DOM节点的操作。
思路:
1、初始化一个result数组存放结果索引,遍历数组:
- 如果当前元素大于
result
最后一个元素对应的值,直接加入result
- 否则,使用二分查找+贪心算法在
result
中找到第一个比当前元素大的元素并替换,并使用一个p数组记录前驱节点索引。
此时result得到的递增子序列虽然长度是最长的,但是贪心算法会替换元素导致索引顺序与原始数组不一致,即顺序是错误的。
2、前驱追溯。vue通过从后往前遍历,通过result和p两个数组,修正result的顺序,通过记录每个节点的前驱,从后向前回溯,可以恢复正确的递增顺序。
至此vue3的diff算法相关的内容就都解释清楚了。
总结
最后回顾下vue3的快速diff算法的整个过程:
1、头尾对比,缩小diff的乱序比较范围
2、循环新节点序列,构建key和索引的Map--keyToNewIndexMap
3、仅循环旧节点的乱序子序列,通过寻找是否在keyToNewIndexMap中,构建newIndexToOladIndexMap,记录新节点在旧节点中的索引,没有找到则设为0。
4、更新节点,包括删除、新增、打补丁。
4、构建最长递增子序列,确定需要移动的节点