Vue3 — 快速diff

115 阅读9分钟

diff

Vue2的双端diff存在一些缺点,比如存在额外的移动操作

image.png

如上,在为新节点e创建完真实DOM操作后,newStart指针指向新节点b,于是进如下一轮比较

在进行头头比较、尾尾比较、头尾比较、尾头比较后,均没有发现可以复用的节点,于是进行暴力比较,在还未比较过的旧结点中寻找是否有与新节点b相同的节点,发现存在,于是复用旧节点b的真实DOM给新结点b,并更新,更新完成后,将真实DOM插入到oldStart节点的真实DOM之前

之后,新节点c和新结点d的处理也会和新节点b一样,它们虽都能够在旧节点列表中找到可以复用的真实DOM,但需要将其真实DOM进行移动,移动到oldStart的真实DOM的前面

其实,对于a、b、c、d这四个节点,其实没有必要将b、c、d逐个移动,而是只需要将a进行移动即可,但Vue2的diff算法却没有能力找出这种特征

在Vue3中,它采用了一种寻找最长递增子序列的方式来规避对大量相对位置没有变化的DOM进行移动

Vue3采用的diff算法为快速diff,快速diff的处理情况可以分为以下四种:

  • 头头对比

    与Vue2的双端diff相同,均是让旧头与新头进行对比,如果相同就更新,不同就进入尾尾对比

  • 尾尾对比

    如果头头对比发现不相同,则让旧尾和新尾进行对比,如果相同就更新,不同就进入复杂情况处理

  • 复杂情况处理

    复杂情况是指新旧节点列表均还存在未处理的节点,此时需要对这些节点进行更新或移动或创建或销毁

  • 非复杂情况处理

    非复杂情况是指新节点列表或旧节点列表中有任意一方结束,即其已经没有需要处理的节点

    若新节点列表结束而旧节点列表未结束,则将未处理的旧节点的真实DOM进行销毁

    若旧节点列表结束而新节点列表未结束,则为未处理的新节点创建真实DOM并插入到合适的位置

    非复杂情况处理与Vue2相同

快速diff与Vue2的双端diff的区别就在于对复杂情况的处理方式不同

对于双端diff,复杂情况处理是:新头旧尾比对、旧头新尾比对、暴力比对

而对于快速diff,复杂情况的大致处理方式为:

  1. 初始化keyToNewIndexMap
  2. 初始化newIndexToOldIndexMap
  3. 更新newIndexToOldIndexMap
  4. 计算最长递增子序列
  5. 移动和挂载节点

初始化keyToNewIndexMap

Vue会定义一个名为keyToNewIndexMap的map集合,用于存储未处理的新节点的key与其在整个新节点列表的下标的映射关系,键值对形式为:{ key: index }

const keyToNewIndexMap = new Map();

for(let i = newStartIdx; i <= newEndIdx; i++){
    const key = newChildren[i].key;
    keyToNewIndexMap.set(key, i);
}

image.png

newStartIdx是指第一个未处理的新节点在整个新节点列表中的下标,其值不一定是0;newEndIdx是指当前还未处理的最后一个新节点在整个新节点列表的下标,其值不一定是未处理的新节点的数量 - 1

对于本例,最原始的新节点列表与旧节点列表就不存在头头相同和尾尾相同的情况,因此会直接来到复杂情况处理,所以newStartIdx为0,newEndIdx为未处理的新节点的数量 - 1

初始化newIndexToOldIndexMap

定义一个名为newIndexToOldIndexMap的数组(不是map集合),数组的长度与还未处理的新节点的数量相同,其作用是记录这些新节点中是否有相同的未处理的旧节点

数组元素一开始都会被初始化为0,并会在之后的过程中更新,若全部更新完成后某个数组元素仍然为0,就表示该与数组元素的下标对应的未处理的新节点列表中的节点是不存在相同旧节点的,此时就需要为它创建真实DOM

const toBePatched = newEndIdx - newStartIdx + 1;		// 未处理的新节点的数量
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);

image.png

oldStartIdx是指第一个未处理的旧节点列表在整个旧节点列表中的下标,其值不一定是0;oldEndIdx是指当前未处理的最后一个旧节点在整个旧节点列表的下标,其值不一定是未处理的旧节点的数量 - 1

对于本例,最原始的新节点列表与旧节点列表就不存在头头相同和尾尾相同的情况,因此会直接来到复杂情况处理,所以oldStartIdx为0,oldEndIdx为未处理的旧节点的数量 - 1

更新newIndexToOldIndexMap

在本步骤中,Vue会遍历未处理的旧节点列表,查找旧节点在未处理的新节点列表中的相同节点的下标,其具体步骤如下:

  • 循环从oldStartIdx到oldEndIdx,找到每一个未处理的旧节点,并对旧节点进行如下操作:

    • 找到与该旧节点相同的新节点的索引newIndex:

      • 如果旧节点有key,则会先通过key查找keyToNewIndexMap

        若能够找到项目,说明未处理的新节点列表中存在与自己相同的节点,于是将项目的index值记录到newIndex之中

      • 如果旧节点没有key,则直接顺序查找未处理的新节点列表,寻找是否有与其相同的新节点,若能找到,则将查找结果所在下标记录到newIndex之中

      在Vue3中,若不给元素设置key,则默认元素的key为null,而非undefined

    • 经过上面的查找过程后,若newIndex无值,说明没能找到与旧节点相同的新节点,则说明该旧节点是需要被删除的,于是将其真实DOM或对组件实例进行销毁

      若newIndex有值,则进行下面的处理:

      • 根据新旧节点的差异更新真实DOM节点,并让真实DOM连接到新节点之中,然后递归处理新旧vnode的子节点

      • 记录【旧节点的下标 + 1】到newIndexToOldIndexMap[newIndex - newStartIdx]之中

        +1是为了与原始的0区分开,因为旧节点的下标可能为0,会产生歧义

      • 判断旧节点的真实DOM是否需要移动,如果需要,则标记moved变量为true

let moved = false;
let maxNewIndexSoFar = 0;
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    const oldNode = oldChildren[i];
    let newIndex;
    if (oldNode.key != null) {
        // 旧节点存在 key,根据 key 找到该节点在新节点列表里面的索引值
        newIndex = keyToNewIndexMap.get(oldNode.key);
    } else {
        // 遍历新节点列表匹配
    }
    if (newIndex === undefined) {
        // 旧节点在新节点中不存在,卸载
        unmount(oldNode);
    } else {
        // 更新节点,并递归处理其子节点
        patch(oldNode, newChildren[newIndex], container);
        // 记录映射关系,注意这里在记录的时候,旧节点的索引要加1
        newIndexToOldIndexMap[newIndex - newStartIdx] = i + 1;
        // 判断是否需要移动
        if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex;
        } else {
            moved = true;
        }
    }
}
if (newIndex >= maxNewIndexSoFar) {
       maxNewIndexSoFar = newIndex;
} else {
       moved = true;
}

上面的代码用于判断这些旧节点其对应的新节点在新节点列表中的相对位置是否是递增的,如果不是则会设置moved为true,后面会根据moved的值判断是否需要计算最长递增子序列

image.png

经过上面的一系列操作后,newIndexToOldIndexMap数组就完成了更新,此时,数组中值为0的元素,与其下标对应的未处理的新节点列表的节点就是在旧节点列表中找不到可复用节点得,在之后需要为它们创建真实DOM

计算最长递增子序列

在该步骤中,根据上一步得到的moved变量,如果moved变量为true,就表示需要计算出newIndexToOldIndexMap数组的最长递增子序列

计算最长递增子序列的目的在于,最长递增子序列反映了旧节点列表与新节点列表中最长的相对位置不变的节点序列,之后在移动时,通过避免对这些节点的真实DOM进行移动,进而做到尽可能少的移动操作

const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];

注意:Vue3中计算出的最长递增子序列的内容为元素的下标,而非元素本身

对于本例,根据[0, 2, 3, 4, 1, 0]计算出的最长递增子序列为[1, 2, 3]

移动和挂载节点

在这一步中,会从后往前遍历未处理的新节点列表,对每个新节点进行如下处理:

  • 计算出当前新节点在整个的新节点列表中的下标newIndex = newStartIdx + i

    i为循环变量,其最小值为0,最大值为未处理的新节点的数量 - 1

  • 获取锚点DOM,其存在的目的是作为被移动的DOM的参照物,被移动的DOM元素移动完成后,其下一个兄弟节点就是锚点DOM

    锚点DOM的计算方式如下:

    const anchor = (newIndex + 1 < newChildren.length) ? newChildren[newIndex + 1].el : null;			// newChildren为整个新节点列表
    
  • 如果newIndexToOldIndexMap[i]为0,说明新节点没有可以复用的旧节点,此时需要为新节点创建真实DOM并插入到锚点DOM之前

    如果newIndexToOldIndexMap[i]不为0,则还需要根据最长递增子序列判断其复用的旧节点的真实DOM是否需要移动

    • 如果新节点在最长递增子序列之中,则说明该节点的真实DOM是不需要进行移动的,于是直接进入下一轮循环

    • 如果新节点不在最长递增子序列之中,则说明该节点的真实DOM是需要进行移动的,就将其真实DOM移动到锚点DOM之前

    若上一步得到的锚点DOM为null,则说明当前创建出来或需要移动的真实DOM是最后一个DOM节点,于是就会将其插入到其父DOM节点的末尾

for(let i = toBePatched - 1; i >= 0; i--){
    const newIndex = newStartIdx + i;
    const anchor = (newIndex + 1 < newChildren.length) ? newChildren[newIndex + 1].el : null;
    if (newIndexToOldIndexMap[i] === 0) {
        // 创建新节点并插入到锚点DOM位置之前
        patch(/* 参数略 */);
    } else if (moved) {
        if (!increasingNewIndexSequence.includes(i)) {
            // 移动节点到锚点DOM之前
            move(/* 数略 */);
        }
    }
}

对于本例,每个新节点的操作如下:

  1. e:新建并且插入到 b 之前
  2. b: 位置不变,没有做移动操作
  3. c:位置不变,没有做移动操作
  4. d:位置不变,没有做移动操作
  5. a:移动到 m 之前
  6. m:新建并且插入到末尾