说明
本文是霍春阳 [vue.js设计与实现] 中第9、10、11章的学习笔记。
文中是用dom和vnode来演示的,为了简化代码直接使用的字符串代替vnode来做对比,且不使用dom。
前言
- vue3 Diff 算法是对比新虚拟dom和旧虚拟dom的差别,对旧数据进行移动、新增、删除来得到新数据。因为创建dom、删除dom的开销较大,通过diff来改变旧数据,更多地利用已有数据,提高性能。新增和删除比较简单,重点是相同数据顺序不一致时如何移动。
- 代码中不考虑重复数据,因为v-key不重复。
- 代码不进行真实的数据操作,数组改变后,判断条件等也会改动。vue中通过对vnode的判断来更新dom,vnode数组不会发生变化
简单 Diff 算法
数据如下,新数组作为目标,顺序处理:
const news = ['a', 'c', 'b'];
const olds = ['a', 'b', 'c'];
- a 顺序相同,不处理
- c 在a之后,相对顺序相同,不处理
- b 新—在c之后,旧—在c之前,移动
实现
双重循环:新数组、旧数组,数组的循环是有序的,当前的索引也是数据在数组中的顺序。在遍历过程中遇到的索引值呈现递增趋势,则说明不需要移动,反之则需要。
function simpleDiff(olds, news) {
console.log('\n***简单diff***');
console.log('初始数组:', olds);
console.log('目标数组:', news, '\n');
// 上一个未移动的元素索引
// 用来存储过程中遇到的最大索引值
let lastIndex = 0;
for(let i = 0; i < news.length; i++) {
let find = false;
const value = news[i];
for(let j = 0; j < olds.length; j++) {
// 旧的元素存在则可能移动,也可能保持不变
if(olds[j] === value) {
find = true;
if(j >= lastIndex) {
// 新数据是对应的旧数据的相对顺序 相同
lastIndex = j;
console.log('元素[%s]: 不动', value);
}else {
// 如果i=0,则说明是第一个元素,不移动
const prevIndex = i - 1;
if(prevIndex > -1) {
console.log('元素[%s]: 移动到元素[%s]后', value, news[prevIndex]);
}
}
break;
}
}
if(!find) {
const prev = i - 1 > -1 ? news[i-1] : undefined;
console.log('元素[%s]: 新增在元素[%s]后', value, prev);
}
}
for(let i = olds.length - 1; i > -1; i--) {
const has = news.find(item => item === olds[i]);
if(!has) {
console.log('元素[%s]: 删除', olds[i]);
}
}
}
执行
const news = ['a', 'c', 'b', 'd'];
const olds = ['a', 'b', 'c', 'e'];
simpleDiff(olds, news);
***简单diff***
初始数组: [ 'a', 'b', 'c', 'e' ]
目标数组: [ 'a', 'c', 'b', 'd' ]
元素[a]: 不动
元素[c]: 不动
元素[b]: 移动到元素[c]后
元素[d]: 新增在元素[b]后
元素[e]: 删除
双端 Diff 算法
此算法是vue2采用的Diff算法
简单Diff算法的缺点在于,它的移动操作不是最优的
const olds = ['1', '2', '3', '4'];
const news = ['4', '1', '2', '3'];
simpleDiff(olds, news);
***简单diff***
初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '4', '1', '2', '3' ]
元素[4]: 不动
元素[1]: 移动到元素[4]后
元素[2]: 移动到元素[1]后
元素[3]: 移动到元素[2]后
此数据用简单 Diff 移动了3次,但最简单的是移动1次,将4移动到最前面,双端算法可以做到。
实现
双端算法是一种同时对新旧两组数据进行比较的算法,因此此算法需要四个索引值分别指向两组数据的首尾端点。
function doubleEndedDiff(paramOlds, paramNews) {
// 里边会对原数组有些改动
const olds = [...paramOlds];
const news = [...paramNews];
console.log('\n***双端diff***');
console.log('初始数组:', olds);
console.log('目标数组:', news, '\n');
let oldStartIdx = 0;
let oldEndIdx = olds.length - 1;
let newStartIdx = 0;
let newEndIdx = news.length - 1;
let oldStartVal = olds[oldStartIdx];
let oldEndVal = olds[oldEndIdx];
let newStartVal = news[newStartIdx];
let newEndVal = news[newEndIdx];
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if(oldStartVal === newStartVal) {
// 数据在新的顺序中仍然处于首部,不移动
oldStartVal = olds[++oldStartIdx];
newStartVal = news[++newStartIdx];
}else if(oldEndVal === newEndVal) {
// 数据在新的顺序中仍然处于尾部,不移动
oldEndVal = olds[--oldEndIdx];
newEndVal = news[--newEndIdx];
}else if(oldStartVal === newEndVal) {
// 将oldStart移动到oldEnd的后面
console.log('oldStart=newEnd)元素[%s]: 移动到元素[%s]后', oldStartVal, oldEndVal);
oldStartVal = olds[++oldStartIdx];
newEndVal = news[--newEndIdx];
}else if(oldEndVal === newStartVal) {
// 将oldEnd移动到oldStart的后面
console.log('oldEnd=newStart)元素[%s]: 移动到元素[%s]前', oldEndVal, oldStartVal);
oldEndVal = olds[--oldEndIdx];
newStartVal = news[++newStartIdx];
} else {
// 如果两端的值没有匹配的
}
}
console.log('结束循环');
console.log(`olds index: start=${oldStartIdx}, end=${oldEndIdx}`);
console.log(`news index: start=${newStartIdx}, end=${newEndIdx}`);
}
执行
const olds = ['1', '2', '3', '4'];
const news = ['4', '1', '2', '3'];
doubleEndedDiff(olds, news);
初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '4', '1', '2', '3' ]
oldEnd=newStart)元素[4]: 移动到元素[1]前
结束循环
olds index: start=3, end=2
news index: start=4, end=3
完善
两端不匹配
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 旧节点可能已经被处理了,跳到下一个位置继续处理
if(!oldStartVal) {
oldStartVal = olds[++oldStartIdx];
}else if(!oldEndVal) {
oldEndVal = olds[--oldEndIdx];
}else if(oldStartVal === newStartVal) {
}else if(oldEndVal === newEndVal) {
}else if(oldStartVal === newEndVal) {
}else if(oldEndVal === newStartVal) {
} else {
// 如果两端的值没有匹配的,就处理newStartVal
// newStartVal是否在初始数组中存在,存在则移动,不存在就新增
const idxInOld = olds.findIndex(val => val === newStartVal);
if(idxInOld > 0) {
// 将olds中的这个移动端头部节点oldStart之前
console.log('两端不匹配)元素[%s]: 移动到元素[%s]前', newStartVal, oldStartVal);
// 此数据已经移动了,不需要再处理了
olds[idxInOld] = undefined;
}else {
// 需要新增 新增到当前的头部节点位置
console.log('两端不匹配)元素[%s]: 新增在元素[%s]前', newStartVal, oldStartVal);
}
newStartVal = news[++newStartIdx];
}
}
执行
const olds = ['1', '2', '3', '4'];
const news = ['2', '4', '1', '3'];
doubleEndedDiff(olds, news);
***双端diff***
初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '2', '4', '1', '3' ]
两端不匹配)元素[2]: 移动到元素[1]前
oldEnd=newStart)元素[4]: 移动到元素[1]前
结束循环
olds index: start=3, end=2
news index: start=4, end=3
循环后检查
可能存在遗漏的数据
console.log(`news index: start=${newStartIdx}, end=${newEndIdx}`);
// 循环结束后检查索引的值
if(oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
// 如果满足条件 说明有新的节点遗留
for(let i = newStartIdx; i <= newEndIdx; i++) {
console.log('循环后检查)元素[%s]: 新增在元素[%s]前', news[i], oldStartVal);
}
}
const olds = ['1', '2', '3'];
const news = ['4', '5', '1', '2', '3'];
doubleEndedDiff(olds, news);
***双端diff***
初始数组: [ '1', '2', '3' ]
目标数组: [ '4', '5', '1', '2', '3' ]
结束循环
olds index: start=0, end=-1
news index: start=0, end=1
循环后检查)元素[4]: 新增在元素[1]前
循环后检查)元素[5]: 新增在元素[1]前
移除元素
// 循环结束后检查索引的值
if(oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
}else if(newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
// 旧数组中还有元素没处理
for(let i = oldStartIdx; i <= oldEndIdx; i++) {
if(olds[i]) {
console.log('元素[%s]: 删除', olds[i]);
}
}
}
const olds = ['1', '2', '3', '4'];
const news = ['1', '3'];
doubleEndedDiff(olds, news);
***双端diff***
初始数组: [ '1', '2', '3', '4' ]
目标数组: [ '1', '3' ]
两端不匹配)元素[3]: 移动到元素[2]前
结束循环
olds index: start=1, end=3
news index: start=2, end=1
元素[2]: 删除
元素[4]: 删除
快速 Diff 算法
此算法也是目前vue3采用的Diff算法
预处理
快速Diff借鉴了纯文本Diff算法,先对首尾相同的数据做预处理
function fastDiff(olds, news) {
console.log('\n***快速diff***');
console.log('初始数组:', olds);
console.log('目标数组:', news, '\n');
// 预处理:相同的前置和后置节点
let i = 0;
let oldVal = olds[i];
let newVal = news[i];
while(oldVal === newVal) {
i++;
oldVal = olds[i];
newVal = news[i];
}
let oldEnd = olds.length - 1;
let newEnd = news.length - 1;
oldVal = olds[oldEnd];
newVal = news[newEnd];
while(oldVal === newVal) {
oldEnd--;
newEnd--;
oldVal = olds[oldEnd];
newVal = news[newEnd];
}
console.log('预处理结束:i = %d, oldEnd = %d, newEnd = %d', i, oldEnd, newEnd);
console.log('相同的前置节点个数%d,后置节点的个数%d', i, olds.length - 1 - oldEnd);
// 预处理完毕后,如果满足条件,说明j newEnd之间存在新增节点
// i > oldEnd 说明旧的数组处理完了
// i <= newEnd 说明新数组还有节点没处理
if(i > oldEnd && i <= newEnd) {
const anchorIndex = newEnd + 1;
while(i <= newEnd) {
console.log('预处理后)元素[%s]: 新增在元素[%s]前', news[i], news[anchorIndex]);
i++;
}
}else if(i > newEnd && i<= oldEnd) {
// 说的新的已经处理完了,旧的需要卸载
while(i <= oldEnd) {
console.log('预处理后)元素[%s]: 删除', olds[i]);
i++;
}
}
}
新增
const olds = [1, 2, 3];
const news = [1, 4, 5, 2, 3];
fastDiff(olds, news);
***快速diff***
初始数组: [ 1, 2, 3 ]
目标数组: [ 1, 4, 5, 2, 3 ]
预处理结束:i = 1, oldEnd = 0, newEnd = 2
相同的前置节点个数1,后置节点的个数2
预处理后)元素[4]: 新增在元素[2]前
预处理后)元素[5]: 新增在元素[2]前
删除
const olds = [1, 2, 3];
const news = [1, 3];
fastDiff(olds, news);
***快速diff***
初始数组: [ 1, 2, 3 ]
目标数组: [ 1, 3 ]
预处理结束:i = 1, oldEnd = 1, newEnd = 0
相同的前置节点个数1,后置节点的个数1
预处理后)元素[2]: 删除
以上部分的用例都比较理想,或是olds全部处理完了,只需要新增,或是news处理完了,只需删除。 还可能出现更复杂的场景,此时索引的值会不满足上面两个分支条件。
继续处理
在预处理完后,还有部分节点需要进一步处理(此时索引i、newEnd、oldEnd不满足上面的两个分支条件),怎么处理勒?
- 判断是否有节点需要移动,以及如何移动
- 找出需要新增或删除的节点
计算索引表
- 索引表是新数组预处理后的剩余节点在旧数组中的索引,如果没有则是-1
- 如果索引表是递增的,说明元素不需要移动,反之则需要
if(i > oldEnd && i <= newEnd) {
}else if(i > newEnd && i<= oldEnd) {
}else {
console.log('\n继续处理');
// 新数组中的剩余处理节点数量
const newStart = i;
const count = newEnd - newStart + 1;
// 存储新子节点在olds中的索引
const source = new Array(count);
source.fill(-1);
// 子节点在新数组中的索引
const keyIndex = {};
for(let j = newStart; j <= newEnd; j++) {
const newVal = news[j];
keyIndex[newVal] = j;
}
// 判断剩余节点是否需要移动
let moved = false;
let pos = 0;
// 遍历旧数组中的剩余未处理节点
const oldStart = i;
for(let j = oldStart; j <= oldEnd; j++) {
const oldVal = olds[j];
// 元素在news中的索引
const k = keyIndex[oldVal];
if(k !== undefined){
// 元素在source中的索引
const index = k - newStart;
source[index] = j;
if(k < pos) {
moved = true;
}else {
pos = k;
}
}else{
console.log('索引表计算)元素[%s]: 删除', oldVal);
}
}
console.log('索引表:', source);
if(!moved) {
console.log('剩余元素不需要移动');
return;
}
}
const olds = [1, 2, 3, 4, 6, 5];
const news = [1, 3, 4, 2, 7, 5];
fastDiff(olds, news);
***快速diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]
预处理结束:i = 1, oldEnd = 4, newEnd = 4
相同的前置节点个数1,后置节点的个数1
继续处理
索引表计算)元素[6]: 删除
索引表: [ 2, 3, 1, -1 ]
元素移动方案
- 根据索引表是否递增来判断是否需要移动,如果不是递增则需要移动
- 如果不需要移动,则结束,否则进行下一步
- 计算出索引表中的最长递增子序列对应的索引表(seq)
- 对seq和剩余节点(source)开启双指针循环,来判断是元素新增、移动还是不变
if(!moved) {
console.log('剩余元素不需要移动');
return;
}
// 计算最长递增子序列的索引,方法见附录
const seq = getSequence(source);
const seqVals = seq.map(index => source[index]);
console.log('索引表的最长递增子序列为:' + seqVals, ',对应索引是' + seq);
// 开启循环,移动递增子序列和剩余节点
// 递增子序列的最后一个节点, seq的长度<=count
let s = seq.length - 1;
// 剩余节点的最后一个节点
let j = count - 1;
while(j >= 0) {
if(j === seq[s]) {
// 说明节点不需要移动,子序列指向下一个位置
s--;
j--;
continue;
}
// 元素在news中的索引
const pos = j + newStart;
// 倒序循环的,所以后面的节点一定处理过了
const nextVal = pos + 1 < news.length ? news[pos+1] : news[news.length-2];
if(source[j] === -1) {
// 此节点不在olds中,需要新增
console.log('递增子序列处理)元素[%s]: 新增在元素[%s]前', news[pos], nextVal);
}else{
// j !== seq[s],说明该节点需要移动
console.log('递增子序列处理)元素[%s]: 移动到元素[%s]前', news[pos], nextVal);
}
j--;
}
还是上面的例子
***快速diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]
预处理结束:i = 1, oldEnd = 4, newEnd = 4
相同的前置节点个数1,后置节点的个数1
继续处理
索引表计算)元素[6]: 删除
索引表: [ 2, 3, 1, -1 ]
索引表的最长递增子序列为:2,3 ,对应索引是0,1
递增子序列处理)元素[7]: 新增在元素[5]前
递增子序列处理)元素[2]: 移动到元素[7]前
运行对比
这里用一个案例来看不同方法的处理时间
const olds = [1, 2, 3, 4, 6, 5];
const news = [1, 3, 4, 2, 7, 5];
console.time('simpleDiff');
simpleDiff(olds, news);
console.timeEnd('simpleDiff');
console.log('\n');
console.time('doubleEndedDiff');
doubleEndedDiff(olds, news);
console.timeEnd('doubleEndedDiff');
console.log('\n');
console.time('fastDiff');
fastDiff(olds, news);
console.timeEnd('fastDiff');
在这个案例下
- 简单Diff和快速Diff的操作方案相同,都是一次新增、一次移动、一次删除,但快速Diff的计算时间更短
- 双端Diff的操作方案差一些,一次新增、两次移动、一次删除,计算时间最短
***简单diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]
元素[1]: 不动
元素[3]: 不动
元素[4]: 不动
元素[2]: 移动到元素[4]后
元素[7]: 新增在元素[2]后
元素[5]: 不动
元素[6]: 删除
simpleDiff: 13.122ms
***双端diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]
两端不匹配)元素[3]: 移动到元素[2]前
两端不匹配)元素[4]: 移动到元素[2]前
两端不匹配)元素[7]: 新增在元素[6]前
结束循环
olds index: start=4, end=4
news index: start=5, end=4
元素[6]: 删除
doubleEndedDiff: 2.690ms
***快速diff***
初始数组: [ 1, 2, 3, 4, 6, 5 ]
目标数组: [ 1, 3, 4, 2, 7, 5 ]
预处理结束:i = 1, oldEnd = 4, newEnd = 4
相同的前置节点个数1,后置节点的个数1
继续处理
索引表计算)元素[6]: 删除
索引表: [ 2, 3, 1, -1 ]
索引表的最长递增子序列为:2,3 ,对应索引是0,1
递增子序列处理)元素[7]: 新增在元素[5]前
递增子序列处理)元素[2]: 移动到元素[7]前
fastDiff: 5.084ms
附录
- 获取数组中最长子序列对应的索引
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for(i = 0; i < len; i++) {
const arrI = arr[i];
if(arrI !== 0) {
j = result[result.length - 1];
if(arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while(u < v) {
c = ((u + v) / 2) | 0;
if(arr[result[c]] < arrI) {
u = c + 1;
}else {
v = c;
}
}
if(arrI < arr[result[u]]) {
if(u > 0) {
p[i] = result[u-1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u-1];
while(u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}