vue3 diff核心源码剖析

1,305 阅读9分钟

导读

本文会一步一步的剖析vue3 vdom中的数组diff算法。

文中代码会尽可能还原vue3源码中的diff代码,变量名和函数名都会照搬源码。如有部分不一致的地方会在文中标注。

正文

本文主要内容是vue3中的patchKeyedChildren函数,该函数的作用是将有key的children列表进行diff比较,并在期间进行dom节点的增(mount)、删(unmout)、移动(move)、复用(patch)操作。

准确来说,不需要全部children都有key,部分有也会走patchKeyedChildren方法。

如将下列节点:

<div key="a">a</div>
<div key="b">b</div>
<div key="c">c</div>
<div key="d">d</div>

替换为:

<div key="b">b</div>
<div key="a">a</div>
<div key="d">d</div>

时会触发patchKeyedChildren函数。

如果所有节点都没有key,会怎么样?

在源码中,会简单粗暴卸载全部老节点,然后重新挂载所有新节点,不会有复用的节点,所以能加key就加key,加key会提高渲染性能。

概览

比较逻辑将数组的变化情况分成几种情况,情况由简单到复杂,主要逻辑分支如下:

image-20220324094511662

从两端开始比较

/**
 * vue diff 算法
 * @param {*} c1 老节点数组
 * @param {*} c2 新节点数据
 */
const patchKeyedChildren = (c1, c2, { patch, mount, unmount, move }) => {

}

首先创建一个patchKeyedChildren函数,c1表示老节点的数组,c2表示新节点的数组,后面的参数代表的是要执行的各种操作,这里是方便测试使用的。

注意:源码中是没有mount方法,这里是为了后面的代码好理解。源码中实现新增和复用都是使用patch方法,通过判断传入参数老节点的有无来区分是新增(mount)还是复用(patch)。

从头部比较

let i = 0; // 起始索引
const l2 = c2.length; // 新节点数组长度
let e1 = c1.length - 1; // 老节点数组结束索引
let e2 = l2 - 1; // 新节点数组结束索引

// 1. 从头部开始同步节点
while (i <= e1 && i <= e2) {
  const n1 = c1[i];
  const n2 = c2[i];
  // 如果是相同节点,证明可以复用节点,执行patch操作
  if (isSameVNodeType(n1, n2)) {
    patch(n1.key, n2.key);
  } else {
    // 否则,中断循环
    break;
  }
  // 每有一个相同的节点,起始索引加1
  i++;
}

补充一下isSameVNodeType函数:

/**
 * 判断两个节点是否相同
 * @param {*} n1
 * @param {*} n2
 * @returns
 */
function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key;
}

如果一个节点的类型和key都相同,则认为是同一个节点。

从尾部比较

// 2. 从尾部开始同步节点
while (i <= e1 && i <= e2) {
  const n1 = c1[e1];
  const n2 = c2[e2];
  // 判断是相同节点则复用
  if (isSameVNodeType(n1, n2)) {
    patch(n1.key, n2.key);
  } else {
    break;
  }
  e1--;
  e2--;
}

为什么要这么做?

我的猜想是,因为这种情况最简单,diff的时间复杂度低,如果满足这两种情况的节点,可以直接复用,无需进行其它判断,所以先把简单的情况处理掉,后面在进行复杂情况的比较。

如:

节点从[a, b, c]变成[a, b],那么从头部开始比较,一开始就能处理掉a、b两个节点,后面再处理c即可。

或者,节点从[c, a, b]变成[a, b],从尾部开始比较,也能处理掉a、b两个节点。

其它情况

其它情况分为三个分支,后面代码会拆开来写,防止代码篇幅过长,难以阅读。

if(i > e1){
  // todo
}else if(i > e2){
  // todo  
}else {
  // todo  
}

新节点还有,老节点没了

如果双端比较完后,新节点还有多,老节点没了,那么,多出来的执行mount操作即可:

// 3. 新节点还有,老节点没了
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
  if (i <= e2) {
    // 添加多出来的节点
    while (i <= e2) {
      mount(c2[i].key);
      i++;
    }
  }
}

老节点还有,新节点没了

如果双端比较完后,老节点还有多,新节点没了,那么,多出来的执行unmount操作即可:

// 4. 老节点还有,新节点没了
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
if (i > e2) {
  while (i <= e1) {
    unmount(c1[i].key);
    i++;
  }
}

处于中间部分的乱序节点

两端的节点比较完毕,剩下中间的节点是乱序的。如果两个乱序节点比较,如果直接暴力比较,在遍历一个数组的时候,再遍历另个数组,时间复杂度很高,为减少时间复杂度,这里使用空间换时间的做法,先将一个数组转为map,在遍历一个数组的时候,直接在map中查询,时间复杂度就能减低很多。

// 5. 处于中间的乱序节点
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
if {
  const s1 = i; // 记录剩下老节点的起始位置
  const s2 = i; // 记录剩下新节点的起始位置

  // 5.1 将剩下未执行比较的新节点数组转map
  // key就是节点的key,value是新节点数组的index
  const keyToNewIndexMap = new Map();
  for (i = s2; i <= e2; i++) {
    const nextChild = c2[i];
    if (nextChild.key != null) {
      keyToNewIndexMap.set(nextChild.key, i);
    }
  }
}

遍历老节点,进行patch和unmount操作

上面将新节点数组转成map,后面就需要遍历老节点,进行比较了:

// 变量剩下的老节点
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i];

  let newIndex; // 新节点数组的index
  if (prevChild.key != null) {
    // 如果有key,直接从map中获取index
    newIndex = keyToNewIndexMap.get(prevChild.key);
  }

  // 如果找不到新节点的索引,证明老节点没法复用,删除
  if (newIndex === undefined) {
    unmount(prevChild.key);
  } else {
    // 可以复用,执行patch
    patch(prevChild.key, c2[newIndex].key);
  }
}

比较逻辑很简单,在keyToNewIndexMap中找到证明新节点数组中有可以复用的节点,直接复用,否则进行删除操作。

这里有引申出几个问题?

  1. 是否真的需要遍历完所有的老节点,查询是否需要patch?
  2. 如何判断节点是否有移动?
  3. 如何记录哪些节点需要移动?
是否真的需要遍历完所有的老节点,查询是否需要patch?

答案是否定的,如这种情况:

a b (c d h g) e f // 老节点
a b (c d) e f // 新节点

剩下的老节点为c d h g,新节点为c d,c d如果都patch完了,那么其实就不需要进行后续的查找,直接删除当前节点即可。为此我们要在patch的时候记录一下patch的数量,和需要patch的数量做对比,代码如下:

+ let patched = 0; // 记录已经patch的节点数量
+ let toBePatched = e2 - s2 + 1; // 需要patch的数量,就是剩下所有的新节点的数量

// 变量剩下的老节点
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i];
  
+  // 如果已经没有需要patch的节点了,证明后面的节点都是要删除的,直接删除
+  if (patched >= toBePatched) {
+    unmount(prevChild.key);
+    continue;
+  }

  let newIndex; // 新节点数组的index
  if (prevChild.key != null) {
    // 如果有key,直接从map中获取index
    newIndex = keyToNewIndexMap.get(prevChild.key);
  }

  // 如果找不到新节点的索引,证明老节点没法复用,删除
  if (newIndex === undefined) {
    unmount(prevChild.key);
  } else {
    // 可以复用,执行patch
    patch(prevChild.key, c2[newIndex].key);
+   patched++; // 已patch的数量加1
  }
}
如何判断节点是否有移动?

这里使用两个变量来判断节点是否有移动,一个是moved,一个是maxNewIndexSoFar

每次newIndex变化的时候,如果大于等于maxNewIndexSoFar,则将maxNewIndexSoFar设为最新的newIndex,否则,则证明有移动。

如果没有移动,到遍历完成newIndex的值都会一直大于maxNewIndexSoFar

image-20220324145626743

如果有节点移动,newIndex就会跑到maxNewIndexSoFar前面去:

image-20220324145958742

代码如下:

let patched = 0; // 记录已经patch的节点数量
let toBePatched = e2 - s2 + 1; // 需要patch的数量,就是剩下所有的新节点的数量
+ let moved = false; // 记录是否有节点移动
+ let maxNewIndexSoFar = 0; // 记录最大的新节点索引,用于辅助判断是否有节点移动的

// 变量剩下的老节点
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i];
  
  // 如果已经没有需要patch的节点了,证明后面的节点都是要删除的,直接删除
  if (patched >= toBePatched) {
    unmount(prevChild.key);
    continue;
  }

  let newIndex; // 新节点数组的index
  if (prevChild.key != null) {
    // 如果有key,直接从map中获取index
    newIndex = keyToNewIndexMap.get(prevChild.key);
  }

  // 如果找不到新节点的索引,证明老节点没法复用,删除
  if (newIndex === undefined) {
    unmount(prevChild.key);
  } else {
+   // 记录新节点的索引,如果索引突然变小了,证明有节点的位置变了
+   if (newIndex >= maxNewIndexSoFar) {
+     maxNewIndexSoFar = newIndex; // 记录上一个移动的最大索引
+   } else {
+     moved = true;
+   }
    // 可以复用,执行patch
    patch(prevChild.key, c2[newIndex].key);
    patched++; // 已patch的数量加1
  }
}
如何记录哪些节点需要移动?

只要进行patch操作的节点,都有可能进行节点的移动,因为要移动的节点都是原来就存在的节点,只是放到不同的位置,剩下没有patch的节点,不是要新增就是要删除的。

这里使用一个数组来记录移动的节点,初始化为0,如果改变了就设置为对应老节点的索引加1。用于后续优化。

let patched = 0; // 记录已经patch的节点数量
let toBePatched = e2 - s2 + 1; // 需要patch的数量,就是剩下所有的新节点的数量
let moved = false; // 记录是否有节点移动
let maxNewIndexSoFar = 0; // 记录最大的新节点索引,用于辅助判断是否有节点移动的
+ // 用于记录剩下的节点是否有移动,索引是剩余新节点index(从0开始),值是老节点下标
+ const newIndexToOldIndexMap = new Array(toBePatched);
+ // 初始化为0
+ for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

// 变量剩下的老节点
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i];
  
  // 如果已经没有需要patch的节点了,证明后面的节点都是要删除的,直接删除
  if (patched >= toBePatched) {
    unmount(prevChild.key);
    continue;
  }

  let newIndex; // 新节点数组的index
  if (prevChild.key != null) {
    // 如果有key,直接从map中获取index
    newIndex = keyToNewIndexMap.get(prevChild.key);
  }

  // 如果找不到新节点的索引,证明老节点没法复用,删除
  if (newIndex === undefined) {
    unmount(prevChild.key);
  } else {
+   // 每次找到可以复用的节点,设置值为老节点索引加1
+   newIndexToOldIndexMap[newIndex - s2] = i + 1;
   // 记录新节点的索引,如果索引突然变小了,证明有节点的位置变了
   if (newIndex >= maxNewIndexSoFar) {
     maxNewIndexSoFar = newIndex; // 记录上一个移动的最大索引
   } else {
     moved = true;
   }
    // 可以复用,执行patch
    patch(prevChild.key, c2[newIndex].key);
    patched++; // 已patch的数量加1
  }
}

这里的newIndexToOldIndexMap可能不太好理解,在说明一下,这是一个数组,下标是剩余新节点数组乱序节点的部分的下标(从0开始),如果是没有进行过patch的下标对应的值为0,进行过patch的值为老节点的索引加1。

图解:

image-20220325150447124

image-20220325150506214

补充

之前说到,patchKeyedChildren不但能处理全部是key的节点,还可以处理部分不是key的节点,对于没有key的节点,就不能使用keyToNewIndexMap去找,需要遍历新节点数组去查找,所以这里补充一下找不到key的逻辑。

// 5.2 遍历剩余老节点,把能处理的处理了(patch, unmout)
+ let j;
let patched = 0; // 记录已经patch的节点数量
let toBePatched = e2 - s2 + 1; // 需要patch的数量,就是剩下所有的新节点的数量
let moved = false; // 记录是否有节点移动
let maxNewIndexSoFar = 0; // 记录最大的新节点索引,用于辅助判断是否有节点移动的
// 用于记录剩下的节点是否有移动,索引是剩余新节点index(从0开始),值是老节点下标
const newIndexToOldIndexMap = new Array(toBePatched);
// 初始化为0
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

// 变量剩下的老节点
for (i = s1; i <= e1; i++) {
  const prevChild = c1[i];
  
  // 如果已经没有需要patch的节点了,证明后面的节点都是要删除的,直接删除
  if (patched >= toBePatched) {
    unmount(prevChild.key);
    continue;
  }

  let newIndex; // 新节点数组的index
  if (prevChild.key != null) {
    // 如果有key,直接从map中获取index
    newIndex = keyToNewIndexMap.get(prevChild.key);
-  }
+  } else {
+    // 针对部分没有key的节点,遍历剩余新节点数组
+    for (j = s2; j <= e2; j++) {
+      if (
+        newIndexToOldIndexMap[j - s2] === 0 && // 没有进行过patch
+        isSameVNodeType(prevChild, c2[j]) // 是同一个节点
+      ) {
+        newIndex = j;
+        break; // 找到就不用再遍历
+      }
+    }
+  }

  // 如果找不到新节点的索引,证明老节点没法复用,删除
  if (newIndex === undefined) {
    unmount(prevChild.key);
  } else {
   // 每次找到可以复用的节点,设置值为老节点索引加1
   newIndexToOldIndexMap[newIndex - s2] = i + 1;
   // 记录新节点的索引,如果索引突然变小了,证明有节点的位置变了
   if (newIndex >= maxNewIndexSoFar) {
     maxNewIndexSoFar = newIndex; // 记录上一个移动的最大索引
   } else {
     moved = true;
   }
    // 可以复用,执行patch
    patch(prevChild.key, c2[newIndex].key);
    patched++; // 已patch的数量加1
  }
}

进行节点的移动和新增

乱序节点的复用和删除操作已经做完了,剩下的几点就是要移动或者新增的。

如何移动效率最高,这里涉及到一个算法,最长递增子序列

简单来讲,最长递增子序列就是,如果有一个数组:

[0, 7, 8, 9, 3, 4, 5]

那么它的最长递增子序列就是:

[0, 7, 8, 9]

有时候由于算法的具体实现不同,计算出来的解会不一样,如上面的数组还有一个解:

[0, 3, 4, 5]

这个数组也是递增的,而且长度也是4,所以也是最长递增子序列。

为什么要使用最长递增子序列?

因为只要找出最长的子序列,那么只要移动剩下的节点,就可以保证移动的次数最少。而且为了保证原来的顺序不变,所以元素要递增。聪明的人可能已经想到了,只要保证索引是递增的,这个子序列的相对位置就是不变的。即在原数组中7在0前面,子序列中,7还是在0前面。

最长递增子序列
/**
 * 获取最长递增子序列
 * 
 * @param {*} arr
 * @returns
 */
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];
    
    // 只看进行过patch的数据
    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) >> 1;
        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;
}

使用时,传入参数为newIndexToOldIndexMap,返回新节点的索引。

例如:

如果newIndexToOldIndexMap=[5, 4, 3, 0]

找到的最长递增子序列是[3],0代表没有patch的节点,不参数比较

但是返回的不是这个[3]

而是3[5, 4, 3, 0]中的索引,也就是返回[2],这里之所以返回新节点数组的索引,是因为后续要遍历新节点索引进行移动操作,有索引好比较。遍历新节点索引的时候,如果位置和最长递增子序列中的值一致,证明是不需要要移动的值。

算法的主要步骤:

  1. 找递增序列
  2. 在递增序列中查找比当前值大的值并替换
  3. 修正递增序列

图解

image-20220329102941311

  • 上述的图解为便于理解,使用的是值来显示,但vue中最长递增子序列算法返回的是数组的索引,即返回[0,2]
  • 在代码中,修正是通过p数组来处理的,一开始会存储替换过的值,后面根据索引遍历将值替换回来。

这个算法还有个小彩蛋,算法中定义了变量u、v、c,连起来不就是uvc(尤雨溪)吗,估计是尤大大故意这么写的,就是可读性差了点...

剩下的代码实现
// 5.3 移动和新建
// 获取最长递增子序列
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : [];

j = increasingNewIndexSequence.length - 1;

for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i; // i是相对坐标,要加上起始值,转为新节点真实坐标
  const nextChild = c2[nextIndex];
  
  // 如果没有进行patch操作,证明是新增节点
  if (newIndexToOldIndexMap[i] === 0) {
    mount(nextChild.key);
  } else if (moved) {
    // 如果没有稳定的子序列(e.g. 数组倒序)或者 当前节点不在稳定序列中,移动节点
    if (
      j < 0 || 
      i !== increasingNewIndexSequence[j]
    ) {
      move(nextChild.key);
    } else {
      j--;
    }
  }
}

图解:

这里的节点不是原数组的所有节点,是要进行当前遍历的节点,即[0,toBePatched]范围的节点

image-20220325112711023

其中a节点是 i !== increasingNewIndexSequence[j]的判断条件触发的,d e节点是j < 0 的节点触发的。

这里为什么要进行倒叙遍历?

因为dom插入元素的api是insertBeforeappendChild,所以倒叙遍历方便使用前一个节点作为锚点插入当前节点。

结语

以上就是vue3 diff核心源码全部内容,很多地方还是比较绕的,如有些地方不但数组的值有用,连索引也是有实际含义的,建议不懂的地方结合完整代码和测试用例再读多几遍。

完整代码和测试用例地址

vue源码地址