Simple version of Vue3 Diff algorithm

background

Learn about the diff algorithm in Vue3~

logical overview

The process of this algorithm is not too complicated:

  • Traverse from the head of the old and new lists at the same time, find the same node, then patch, otherwise stop traversing;
  • At the same time, traverse from the end of the old and new lists to find the same node, then patch, otherwise stop traversing;
  • If all the nodes in the old list have been traversed, and there are still nodes in the new list that have not been traversed, it means that the new list has added nodes, and all these nodes will be added;
  • If there are still nodes in the old list that have not been traversed at this time, and the new list has been traversed, it means that some nodes in the old list have been deleted in the new list, and the nodes that have not been traversed in the old list will be deleted;
  • If there are untraversed nodes in the old and new lists, you need to operate these nodes. The operations here refer to adding, moving, and deleting. This step is the most complicated, involving binary search and the longest ascending subsequence.

Diff process

function parameter

/**
 * 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;
  	}
	// ...
}

Traverse the old and new lists from the beginning to find the same nodes

insert image description here
In this graph, we traverse from the head of both lists at the same time, the same node has AB, and then stop the loop:

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;
    }
  }
}

Traverse the old and new lists from the end to find the same nodes

insert image description here
Since the lengths of the old and new lists are not necessarily the same, the tail coordinates of the two traversals are also different, so it needs to be declared separately. If the same node is found, it will continue to move forward, otherwise it will exit the loop:

// 旧列表尾巴下标
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;
  }
}

new node

insert image description here
When the previous i is in the final cycle, the final value is 2, and oldEndIndex = 1, newEndIndex = 2, indicates that the new list has one more node than the old list, that is, a new node.

// 3.新增节点
if( i > oldEndIndex && i <= newEndIndex ){
    
    
    while(i <= newEndIndex) {
    
    
      const newNode = newList[i];
      mountElement(newNode.key);
      i++;
    }
 }

delete node

insert image description here
As mentioned above, at this time, there are still nodes in the old list that have not been traversed, and the new list has been traversed, so it means that some nodes in the old list have been deleted in the new list, and the nodes that have not been traversed in the old list will be deleted. As can be seen from the figure, node C is deleted in the new list.

// 4. 删除节点
else if(i <= oldEndIndex && i > newEndIndex) {
    
    
  while(i <= oldEndIndex) {
    
    
    const delNode = oldList[i];
    unmount(delNode.key);
    i++;
  }
}

The whole code up to here is as follows:

/**
 * 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 {
    
    
    // ...
   } 
}

final processing

Okay, let’s move on to the most brain-burning part, let’s give an example first:
insert image description here
here we actually want to compare [M, H,.C, D, E, J] in the new list with [C, E, H] in the old list to make an operation. The source code will definitely not be so stupid. First traverse the nodes of the new list, and then compare the nodes of the old list with the embedded loop. According to the characteristics of the array, they have converted the data structure and reduced the time-consuming of the loop. Next follow the source code to learn:

// 新旧节点不同的起点坐标
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);
}

The results obtained are as follows:

insert image description here
First mention the relative coordinates here ! ! !
For convenience, in the Vue source code, the scope of the loop is directly changed to the nodes of the new list we want to operate (that is, the relative coordinates will be used below). For example, the coordinate of M relative to the node list to be operated is 0, not 2 in the original list.
Moving on, let's declare some variables we'll use later:

// 新列表中需要操作的节点数量(上述例子就是6个节点)
const toBePatched = newEndIndex - newStartIndex + 1;
// 已经操作过的节点数量
let patched = 0;
// 先将这个参数简单理解为,新列表的节点与旧列表节点的映射,如果存在就非0,如果不存在就是0
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);

Then we now iterate over the old list first:

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++;
	}
}

So here we get newIndexToOldIndexMapthe value first:
insert image description here
newIndexToOldIndexMap[相对坐标] = 旧列表的坐标 + 1I understand the reason for adding one here is that if the first element of this array exists in both the old and new lists, then its coordinate is 0, so in order to distinguish whether it exists, +1 is needed to ensure that it is called a non-zero number. Looking at the technical text in the early days, there are also cases where it is initialized to -1, so there is no need to +1.
Then here we have obtained an array coordinate. The next essence is to operate our nodes according to this array coordinate. Before the operation, we need to obtain the longest ascending subsequence. Well, let’s skip the implementation process of this algorithm (algorithm explanation is not suitable for me). First, let’s talk about why it is the longest ascending subsequence. When we operate nodes, do we want more nodes to keep their positions unchanged, and try to change as few nodes as possible.
The array we get is [0, 5, 3, 0, 4, 0], ignoring 0 (not a multiplexed node). Here we can easily see that the longest continuous subsequence is [3, 4] corresponding to nodes C and E. We only need to move node D.
However, the result of the longest subsequence here should return the coordinates of [3, 4], so the result should be [2, 4]
insert image description here

// 先假设获得最长升上子序列
function getSequence(arr){
    
    
	// 这里应该返回的是序列对应的下标,[3, 4] 转为下标变成[2, 4]
	return [2, 4]
}

Then continue our logic:

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--;
		}
	}
	
}

insert image description here

newIndexToOldIndexMapTraverse from back to front:

  • 0 represents adding a new node and moving forward
  • Current i is 4, increasingNewSequence[lastIndex] = 4, equal, move forward, lastIndex–
  • 0 represents adding a new node and moving forward
  • Current i is 2, increasingNewSequence[lastIndex] = 2, equal, move forward, lastIndex–
  • Since lastIndex is less than 0, move node 5
  • 0 represents adding a new node, moving forward, and ending the loop

There is an optimization point here, that is, the function of obtaining the longest ascending subsequence is actually quite time-consuming. In some cases, we do not have to use it:

// 是否需要获得最长升上子序列(是否需要移动节点)
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) : [];

So the final code is:

/**
 * 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--;
        }
      }
    }
  }
}

reference link

Guess you like

Origin blog.csdn.net/qq_34086980/article/details/131570544