基本概念
diff算法就是进行虚拟节点对比,并返回一个patch对象,用来存储两个节点不同的地方,最后用patch记录的消息去局部更新Dom。
简单来说Diff算法就是在虚拟DOM树从上至下进行同层比对,如果上层已经不同了,那么下面的DOM全部重新渲染。这样的好处是算法简单,减少比对次数,加快算法完成速度。
- 有两个特点:
- 比较只会在同层级进行, 不会跨层级比较
- 在diff比较的过程中,循环从两边向中间比较
背景
在vue中,diff算法是在列表数据发生变化时,新老数组进行比较差异(根据节点的key值),以最小的性能消耗使页面重新渲染某些节点,相比vue2,diff算法进行了很大程度的优化,其中复杂场景采用了最长递增子序列进行优化,下面先从简单的场景练练手,开始了解最长递增子序列主要做了什么事情。(在本篇文章中,均以key值代表节点)
举个栗子
场景一
新老树长度不一致,新树比老树长,有节点新增,循环新树
- 先从头部循环新树,节点一致则跳过,多余则直接push
[1, 2, 3]; // 已渲染的旧节点树
[1, 2, 3, 4]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略
- 从尾部循环新树,节点一致跳过,多余unshift
[1, 2, 3]; // 已渲染的旧节点树
[4, 5, 2, 3]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略
新老树长度不一致,新树比老树短,有节点删除,循环老树
- 从头部循环老树,节点一致跳过,没有则删除
[1, 2, 3, 4]; // 已渲染的旧节点树
[1, 2, 3]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略
场景二(较复杂)
无法满足场景一后,需要更加精细的比较。
新老树长度不一致,在中间有新增或删除元素。
- 在中间进行删除或者插入,就需要根据场景一的过程,标记出头尾可复用的节点,以下例子标记完后剩下
7未被标记,需要根据新树做一个映射表,再根据新的映射表去老树查找是否存在,不存在则是新增节点,再通过老的key,来查找新树里对应的索引,如果找不到则是删除节点。
[1, 2, 3, 4, 5, 6, 8, 9]; // 已渲染的旧节点树
[1, 2, 3, 4, 5, 6, 7, 8, 9]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略
场景三(更复杂)
新老树长度不一致,无规律可循的情况。
- 当然这种情况也可以使用场景一结合场景二去做树的删除、新增、替换操作,但其中很多元素可以复用,有些无需移动,所以并不是最优的。
[1, 2, 3, 4, 5, 7]; // 已渲染的旧节点树
[3, 4, 2, 5, 1, 6, 8]; // 待渲染的目标新节点树
// 将旧节点与旧节点进行比较,获取最优更新策略
最长递增子序列
在vue3中引入了一种新的解决方案最长递增子序列,当遇到场景三的情况时,使用二分查找和贪婪算法来得出,可以算出在一个序列中连续递增的个数和递增的具体值,有助于更加精准的获得最优解决方案。
// 现有如下树,求最长递增子序列
[2, 3, 1, 5, 6, 8, 7, 9, 4];
思路分析:
- 设一个结果集,长度为1且第一项为0的数组。
- 循环获取需要处理的数组,当循环的当前项比结果集数组最后一项大时,把当前项放到数组中,反之,采用二分查找和贪婪算法,算出结果集中比当前项第一大的项进行替换,可得出该数组的最长递增子序列个数。
- 设一个追溯索引的数组,在结果集添加子元素时记录该结果集中的前一项是索引值,在结果集替换子元素时,也需要替换其索引值。
直接开干
- 二分查找就是在结果集中取中间值,判断中间值和当前项的大小,直到获取到第一个大的值,结合下图进行理解。
- 循环需要处理的结果数组,生成结果数组,具体如下图所示(为了方便理解,图为值,代码实现结果集记录的是索引值)。
const arr = [2, 3, 1, 5, 6, 8, 7, 9, 4];
function getSequence(arr) {
const len = arr.length;
const result = [0]; // 最长递增子序列个数的结果索引
let resultLastItem; // 结果的最后一项
for (let i = 0; i < len; i++) {
const item = arr[i];
resultLastItem = result[result.length - 1];
// 判断如果当前项大于当前结果集里的最后一项,则push
if (arr[resultLastItem] < item) {
result.push(i);
continue;
}
// 如果当前项小于当前结果集的最后一项,则采用二分查找和贪婪算法进行替换第一个比当前项大的值
let startIndex = 0; // 二分查找结果集的开始索引
let endIndex = result.length - 1; // 二分查找结果集的结束索引
// 只要startIndex和endIndex重合则跳出循环
while (startIndex < endIndex) {
let middle = Math.floor((startIndex + endIndex) / 2) | 0;
// 判断中间值是否小于当前项
if (arr[result[middle]] < item) {
startIndex = middle + 1;
} else {
endIndex = middle;
}
}
// 找到比当前项大的第一个值后,进行替换
if (item < arr[result[endIndex]]) {
result[endIndex] = i;
}
}
console.log(result); // [2, 1, 8, 4, 6, 7];
}
- 最终获得结果集的个数为5个,此时该结果集并不是正确的值,需要通过回溯找到正确的值,如下图所示
- 第一次循环,前面没有值,因此不需要记录。
- 第二次循环,前面的值是2,记录其下标为0。
- 需要替换值时,则需要把溯源的值也替换掉,如下图
- 根据上述思路,完善代码,最后得出
最长递增子序列为[0, 1, 3, 4, 6, 7]
function getSequence(arr) {
const len = arr.length;
const result = [0]; // 最长递增子序列个数的结果索引
let resultLastItem; // 结果的最后一项
const p = arr.slice(0); // 用来追溯的索引数组
for (let i = 0; i < len; i++) {
const item = arr[i];
resultLastItem = result[result.length - 1];
// 判断如果当前项大于当前结果集里的最后一项,则push
if (arr[resultLastItem] < item) {
result.push(i);
p[i] = resultLastItem; // push进数组时,让其记住他的前一项是谁
continue;
}
// 如果当前项小于当前结果集的最后一项,则采用二分查找和贪婪算法进行替换第一个比当前项大的值
let startIndex = 0; // 二分查找结果集的开始索引
let endIndex = result.length - 1; // 二分查找结果集的结束索引
// 只要startIndex和endIndex重合则跳出循环
while (startIndex < endIndex) {
let middle = Math.floor((startIndex + endIndex) / 2) | 0;
// 判断中间值是否小于当前项
if (arr[result[middle]] < item) {
startIndex = middle + 1;
} else {
endIndex = middle;
}
}
// 找到比当前项大的第一个值后,进行替换
if (item < arr[result[endIndex]]) {
p[i] = result[endIndex - 1]; // 替换时,让替换掉的人也记住前面是谁
result[endIndex] = i;
}
}
// // 追溯
let i = result.length; // 获取结果集长度
let last = result[i - 1]; // 获取结果集最后一项
while (i-- > 0) {
console.log(i);
result[i] = last;
last = p[last];
}
console.log(result); // [0, 1, 3, 4, 6, 7]
}