背景
学习一下Vue3中的diff算法~
逻辑概述
这个算法的过程不算太复杂:
- 同时从新旧列表的头部遍历,找出相同的节点,则patch,否则就停止遍历;
- 同时从新旧列表的尾部遍历,找出相同的节点,则patch,否则就停止遍历;
- 如果旧列表的节点都遍历过了,新列表还有节点没有被遍历,那么说明新列表增加了节点,则将这些节点全部新增;
- 如果此时旧列表还有节点没有被遍历过、新列表已经都遍历完,那么说明新列表删除了旧列表的某些节点,则将旧列表中未被遍历过的节点删除;
- 如果新旧列表中都存在未遍历过的节点,则需要操作这些节点,这里的操作指的是新增、移动、删除,这一步骤是最复杂的,涉及到了二分查找和最长升上子序列。
Diff过程
函数传参
/*** Vue3 Diff简易版* @param {*} oldList 旧列表* @param {*} newList 新列表* @param {*} param2 4个函数分别代表 挂载、更新、移除、移动节点操作*/
function diffArray(oldList, newList, { mountElement, patch, unmount, move }) {// 节点是否可以复用,这里只是简单的判断key值,事实上判断元素是否可以复用,还要判断tag等值function isSameVnodeType(node1, node2) {return node1.key === node2.key;}// ...
}
从头遍历新旧列表,找出相同的节点
在这张图中,我们同时从两个列表的头部遍历,相同的节点有A B,然后停止了循环:
function diffArray(oldList, newList, { mountElement, patch, unmount, move }) {// 节点是否可以复用function isSameVnodeType(node1, node2) {return node1.key === node2.key;}// 头部遍历的起始变量let i = 0;// 旧列表的长度const oldLen = oldList.lenght;// 新列表的长度const newLen = newList.lenght;// 1. 从头遍历新旧列表,找出相同的节点while(i < oldLen && i < newLen) {const oldNode = oldList[i];const newNode = newList[i];if(isSameVnodeType(oldNode, newNode)) {patch(oldNode.key);i++;}else {break;}}
}
从尾遍历新旧列表,找出相同的节点
由于新旧列表的长度不一定相同,所以两者遍历的尾巴坐标也不一样,所以需要单独声明,找到相同的节点就继续往前走,否则就退出循环:
// 旧列表尾巴下标
const oldEndIndex = oldLen - 1;
// 新列表尾巴下标
const newEndIndex = newLen - 1; // 2.从尾遍历新旧列表,找出相同的节点
while(i <= oldEndIndex && i <= newEndIndex){const oldNode = oldList[oldEndIndex];const newNode = newList[newEndIndex];if(isSameVnodeType(oldNode, newNode)) {patch(oldNode.key);oldEndIndex--;newEndIndex--}else {break;}
}
新增节点
之前的i在最后循环的时候,最后的值为2
,而oldEndIndex = 1
, newEndIndex = 2
,则说明新列表比旧列表多出了一个节点,即新增节点。
// 3.新增节点
if( i > oldEndIndex && i <= newEndIndex ){while(i <= newEndIndex) {const newNode = newList[i];mountElement(newNode.key);i++;}}
删除节点
如前面所说,此时旧列表还有节点没有被遍历过、新列表已经遍历完,那么说明新列表删除了旧列表的某些节点,则将旧列表中未被遍历过的节点删除,由图可知,C节点在新列表中被删除了。
// 4. 删除节点
else if(i <= oldEndIndex && i > newEndIndex) {while(i <= oldEndIndex) {const delNode = oldList[i];unmount(delNode.key);i++;}
}
到这里的代码整体如下:
/*** Vue3 Diff简易版* @param {*} oldList 旧列表* @param {*} newList 新列表* @param {*} param2 4个函数分别代表 挂载、更新、移除、移动节点操作*/
function diffArray(oldList, newList, { mountElement, patch, unmount, move }) {// 节点是否可以复用function isSameVnodeType(node1, node2) {return node1.key === node2.key;}let i = 0;// 旧列表的长度const oldLen = oldList.lenght;// 新列表的长度const newLen = newList.lenght;// 旧列表尾巴下标const oldEndIndex = oldLen - 1;// 新列表尾巴下标const newEndIndex = newLen - 1; // 1. 从头遍历新旧列表,找出相同的节点while(i < oldLen && i < newLen) {const oldNode = oldList[i];const newNode = newList[i];if(isSameVnodeType(oldNode, newNode)) {patch(oldNode.key);i++;}else {break;}}// 2.从尾遍历新旧列表,找出相同的节点while(i <= oldEndIndex && i <= newEndIndex){const oldNode = oldList[oldEndIndex];const newNode = newList[newEndIndex];if(isSameVnodeType(oldNode, newNode)) {patch(oldNode.key);oldEndIndex--;newEndIndex--}else {break;}}// 3.新增节点if( i > oldEndIndex && i <= newEndIndex ){while(i <= newEndIndex) {const newNode = newList[i];mountElement(newNode.key);i++;}}// 4.删除节点else if(i <= oldEndIndex && i > newEndIndex) {while(i <= oldEndIndex) {const delNode = oldList[i];unmount(delNode.key);i++;}}else {// ...}
}
最后的处理
好了接下来进入最烧脑的环节,先给个例子先:
到这里我们其实就是要将新列表中的[M, H,.C, D, E, J]和旧列表中的[C, E, H]进行对比从而做出操作。源码肯定不会那么傻先遍历新列表的节点,再内嵌循环旧列表节点一个个做对比,他们根据数组的特点,转换了数据结构,减少了循环的耗时。接下来跟着源码学习:
// 新旧节点不同的起点坐标
const oldStartIndex = i;
const newStartIndex = i;// 5.1 根据数组的特点,将新列表中的key和index做成映射关系
const keyToNewIndexMap = new Map();
for(let i = newStartIndex; i < newEndIndex; i++){const node = newList[i];keyToNewIndexMap.set(node.key, i);
}
得出来的结果如下:
这里先提一下相对坐标!!!
Vue源码中为了方便,直接将循环的范围变成了我们要操作的新列表的这些节点中(即下面会用到相对坐标),比如M的相对要操作的节点列表的坐标是0,而不是原来列表中的2。
继续,接下来声明一些我们后面会用到的变量:
// 新列表中需要操作的节点数量(上述例子就是6个节点)
const toBePatched = newEndIndex - newStartIndex + 1;
// 已经操作过的节点数量
let patched = 0;
// 先将这个参数简单理解为,新列表的节点与旧列表节点的映射,如果存在就非0,如果不存在就是0
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
然后我们现在先遍历旧列表:
for(let i = oldStartIndex; i < oldEndIndex; i++){const node = oldList[i];// 如果需要操作的节点数量已经小于操作过的节点,说明旧列表中这些节点是木有用的,需要卸载if(patched >= toBePatched){unmount(node.key);continue;}// 接下来根据oldNode中的key,来看看keyToNewIndexMap是否有对应的下标,如果有,那说明旧节点在新列表中被复用啦,否则就得删除// Vue3源码中有一段是处理节点没有key的情况,这里就不写啦,这里默认我们的节点都是有key的const newIndex = keyToNewIndexMap.get(node.key);if(newIndex === undefined){// 旧节点在新列表中没有被复用,直接卸载unmount(node.key);}else {// 如果在旧列表中找到可以复用的节点,那么更新newIndexToOldIndexMap[相对坐标] = 旧列表的坐标 + 1// 这里加1的原因 后面再解释!newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;// 复用节点patch(node.key);// 操作过的节点数量 + 1patched++;}
}
那么这里我们先得出newIndexToOldIndexMap
的值:
newIndexToOldIndexMap[相对坐标] = 旧列表的坐标 + 1
这里加一的原因我理解的是,这个数组如果第一个元素在新旧列表都存在,那么它的坐标就是0,所以为了区分是否存在,需要+1,确保称为非0数。早期看技术文,也有初始化为-1的情况,这种就不用+1了。
那么这里就获取到了一个数组坐标,接下来的精华就是根据这个数组坐标,来对我们的节点进行操作,操作之前需要先获得最长升上子序列,好了这个算法我们先跳过实现过程(算法解说不适合我),先说一下为什么是最长升上子序列。我们在操作节点的时候,是不是希望有比较多的节点保持位置不变,尽量改变较少的节点。
我们得出的数组是[0, 5, 3, 0, 4, 0],忽略0(不是复用的节点)这里我们不难看出来连续的最长子序列是[3, 4]对应的是节点C、E,我们只要移动节点D即可。
不过这里最长子序列的结果应该返回的是[3, 4]的坐标,所以结果应该是[2, 4]
// 先假设获得最长升上子序列
function getSequence(arr){// 这里应该返回的是序列对应的下标,[3, 4] 转为下标变成[2, 4]return [2, 4]
}
然后继续我们的逻辑:
for(let i = oldStartIndex; i < oldEndIndex; i++){// ...
}// 最长升上子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
// 最长升上子序列的最后一个坐标
let lastIndex = increasingNewIndexSequence.length - 1;
/**
* 由于移动元素可能会用到inertBefore方法
* 该方法需要知道后一个元素,才能插入前面一个元素
* 所以这次遍历需要从后面开始
*/
for (i = toBePatched - 1; i >= 0; i--){if(newIndexToOldIndexMap[i] === 0) {// 说明是新增节点mountElement(node.key);}else {// 将相对坐标转为绝对(列表)坐标const index = newStartIndex + i;// 获得对应新列表中的节点const node = newList[index];// 如果没有最长上升子序列 或者 当前节点不在该子序列中,则需要移动if(lastIndex < 0 || i !== increasingNewIndexSequence[lastIndex]) {move(node.key)}else {lastIndex--;}}}
newIndexToOldIndexMap
从后往前遍历:
- 0代表新增节点,向前移动
- 当前i为4, increasingNewSequence[lastIndex] = 4,相等,向前移动,lastIndex–
- 0代表新增节点,向前移动
- 当前i为2, increasingNewSequence[lastIndex] = 2,相等,向前移动,lastIndex–
- 由于lastIndex小于0,所以移动节点5
- 0代表新增节点,向前移动,结束循环
这里有个优化的点,就是获得最长升上子序列这个函数其实挺耗时的,我们在某些情况下,其实并不一定要使用它:
// 是否需要获得最长升上子序列(是否需要移动节点)
let move = false;
// 子序列中最大的值
let maxNewIndexSoFar = 0;for(let i = oldStartIndex; i < oldEndIndex; i++){// ...if(newIndex === undefined) {// ...}else {// 判断是否有人插队(每个节点都按需递增的话)if(newIndex >= maxNewIndexSoFar) {maxNewIndexSoFar = newIndex;}else {// 说明被插队了,得去求最长递增子序列move = true;}// ...}
}
// 获取最长子序列(可能很耗时,所以要进行优化,判断是否确定要move)
const increasingNewIndexSequence = move ? getSequence(newIndexToOldIndexMap) : [];
所以最后的代码就是:
/*** Vue3 Diff简易版* @param {*} oldList 旧列表* @param {*} newList 新列表* @param {*} param2 4个函数分别代表 挂载、更新、移除、移动节点操作*/
function diffArray(oldList, newList, { mountElement, patch, unmount, move }) {// 节点是否可以复用function isSameVnodeType(node1, node2) {return node1.key === node2.key;}let i = 0;// 旧列表的长度const oldLen = oldList.lenght;// 新列表的长度const newLen = newList.lenght;// 旧列表尾巴下标const oldEndIndex = oldLen - 1;// 新列表尾巴下标const newEndIndex = newLen - 1; // 1. 从头遍历新旧列表,找出相同的节点while(i < oldLen && i < newLen) {const oldNode = oldList[i];const newNode = newList[i];if(isSameVnodeType(oldNode, newNode)) {patch(oldNode.key);i++;}else {break;}}// 2.从尾遍历新旧列表,找出相同的节点while(i <= oldEndIndex && i <= newEndIndex){const oldNode = oldList[oldEndIndex];const newNode = newList[newEndIndex];if(isSameVnodeType(oldNode, newNode)) {patch(oldNode.key);oldEndIndex--;newEndIndex--}else {break;}}// 3.新增节点if( i > oldEndIndex && i <= newEndIndex ){while(i <= newEndIndex) {const newNode = newList[i];mountElement(newNode.key);i++;}}// 4.删除节点else if(i <= oldEndIndex && i > newEndIndex) {while(i <= oldEndIndex) {const delNode = oldList[i];unmount(delNode.key);i++;}}else {// 新旧节点不同的起点坐标const oldStartIndex = i;const newStartIndex = i;// 5.1 根据数组的特点,将新列表中的key和index做成映射关系const keyToNewIndexMap = new Map();for(let i = newStartIndex; i < newEndIndex; i++) {const node = newList[i];keyToNewIndexMap.set(node.key, i);}// 新列表中需要操作的节点数量(上述例子就是6个节点)const toBePatched = newEndIndex - newStartIndex + 1;// 已经操作过的节点数量let patched = 0;// 先将这个参数简单理解为,新列表的节点与旧列表节点的映射,如果存在就非0,如果不存在就是0const newIndexToOldIndexMap = new Array(toBePatched).fill(0);// 是否需要获得最长升上子序列(是否需要移动节点)let moved = false;// 子序列中最大的值let maxNewIndexSoFar = 0;for(let i = oldStartIndex; i < oldEndIndex; i++) {const node = oldList[i];// 如果需要操作的节点数量已经小于操作过的节点,说明旧列表中这些节点是木有用的,需要卸载if(patched >= toBePatched) {unmount(node.key);continue;}// 接下来根据oldNode中的key,来看看keyToNewIndexMap是否有对应的下标,如果有,那说明旧节点在新列表中被复用啦,否则就得删除// Vue3源码中有一段是处理节点没有key的情况,这里就不写啦,这里默认我们的节点都是有key的const newIndex = keyToNewIndexMap.get(node.key);if(newIndex === undefined) {// 旧节点在新列表中没有被复用,直接卸载unmount(node.key);}else {// 判断是否有人插队(每个节点都按需递增的话)if(newIndex >= maxNewIndexSoFar) {maxNewIndexSoFar = newIndex;}else {// 说明被插队了,得去求最长递增子序列moved = true;}// 如果在旧列表中找到可以复用的节点,那么更新newIndexToOldIndexMap[相对坐标] = 旧列表的坐标 + 1newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;// 复用节点patch(node.key);// 操作过的节点数量 + 1patched++;}}// 获取最长子序列(可能很耗时,所以要进行优化,判断是否确定要move)const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];let lastIndex = increasingNewIndexSequence.length - 1;/*** 由于移动元素可能会用到inertBefore方法* 该方法需要知道后一个元素,才能插入前面一个元素* 所以这次遍历需要从后面开始*/for(let i = toBePatched - 1; i > 0; i--) {if(newIndexToOldIndexMap[i] === 0) {// 判断节点是不是新增的,不能被复用,即新增节点mountElement(node.key);}else {// 将相对坐标转为绝对(列表)坐标const index = newStartIndex + i;// 获得对应新列表中的节点const node = newList[index];// 如果没有最长上升子序列 或者 当前节点不在该子序列中,则需要移动if(lastIndex < 0 || i !== increasingNewIndexSequence[lastIndex]) {move(node.key)}else {lastIndex--;}}}}
}
参考链接
- 解锁vue3 diff算法
- React、Vue2、Vue3的三种Diff算法
- 300. 最长递增子序列