Vue3 Diff 算法简易版

背景

学习一下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);
		// 操作过的节点数量 + 1
		patched++;
	}
}

那么这里我们先得出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,如果不存在就是0
    const 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[相对坐标] = 旧列表的坐标 + 1
        newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;
        // 复用节点
        patch(node.key);
        // 操作过的节点数量 + 1
        patched++;
      }
    }

    // 获取最长子序列(可能很耗时,所以要进行优化,判断是否确定要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--;
        }
      }
    }
  }
}

参考链接

猜你喜欢

转载自blog.csdn.net/qq_34086980/article/details/131570544