导读
本文会一步一步的剖析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会提高渲染性能。
概览
比较逻辑将数组的变化情况分成几种情况,情况由简单到复杂,主要逻辑分支如下:
从两端开始比较
/**
* 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中找到证明新节点数组中有可以复用的节点,直接复用,否则进行删除操作。
这里有引申出几个问题?
- 是否真的需要遍历完所有的老节点,查询是否需要patch?
- 如何判断节点是否有移动?
- 如何记录哪些节点需要移动?
是否真的需要遍历完所有的老节点,查询是否需要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:
如果有节点移动,newIndex就会跑到maxNewIndexSoFar前面去:
代码如下:
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。
图解:
补充
之前说到,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],这里之所以返回新节点数组的索引,是因为后续要遍历新节点索引进行移动操作,有索引好比较。遍历新节点索引的时候,如果位置和最长递增子序列中的值一致,证明是不需要要移动的值。
算法的主要步骤:
- 找递增序列
- 在递增序列中查找比当前值大的值并替换
- 修正递增序列
图解:
- 上述的图解为便于理解,使用的是值来显示,但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]范围的节点
其中a节点是 i !== increasingNewIndexSequence[j]的判断条件触发的,d e节点是j < 0 的节点触发的。
这里为什么要进行倒叙遍历?
因为dom插入元素的api是insertBefore和appendChild,所以倒叙遍历方便使用前一个节点作为锚点插入当前节点。
结语
以上就是vue3 diff核心源码全部内容,很多地方还是比较绕的,如有些地方不但数组的值有用,连索引也是有实际含义的,建议不懂的地方结合完整代码和测试用例再读多几遍。