vue3中的diff算法:图文结合

vue3底层做了很多优化,其中diff算法是被津津乐道的优化策略之一,也是作为前端面试的选题之一。接下来希望能够以简洁的方式介绍明白这件事儿:

代码如下:

xml 复制代码
<template>
 <ul @click="changeList">
   <li v-for="item in activeList" :key="item.keyId">
     {{ item.name }}:{{ item.keyId }}
   </li>
 </ul>
</template>

<script setup>
import { ref } from "vue";
// 原来的数组
let preList = [
 { name: "A", keyId: "a" },
 { name: "B", keyId: "b" },
 { name: "C", keyId: "c" },
 { name: "D", keyId: "d" },
 { name: "E", keyId: "e" },
 { name: "F", keyId: "f" },
];
// 新的数组
let curList = [
 { name: "A", keyId: "a" },
 { name: "B", keyId: "b" },
 { name: "E", keyId: "e" },
 { name: "C", keyId: "c" },
 { name: "G", keyId: "g" },
 { name: "D", keyId: "d" },
 { name: "F", keyId: "f" },
];

// 定义响应式数据
const activeList = ref(preList);
// 定义数组改变的函数
const changeList = () => {
 activeList.value = curList;
};
</script>

执行结果如下:

从代码到视图,中间所经过的核心代码如下,心里先有个印象,稍后会有图文解释。

一、源码概览

vue的更新渲染,最后会执行到以下核心逻辑:

ini 复制代码
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
    let i = 0;
    const l2 = c2.length;
    let e1 = c1.length - 1;
    let e2 = l2 - 1;
    // 1、从头部开始进行新旧vnode的比对,如果是type和key相同的新旧vnode,直接进行patch操作
    while (i <= e1 && i <= e2) {
      const n1 = c1[i];
      const n2 = c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]);
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      } else {
        break;
      }
      // patch结束后,进行索引的递增
      i++; 
    }
    // 2、从尾部进行新旧vnode的比对,如果是type和key相同的新旧vnode,直接进行patch操作
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1];
      const n2 = c2[e2] = optimized ? cloneIfMounted(c2[e2]) : normalizeVNode(c2[e2]);
      if (isSameVNodeType(n1, n2)) {
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        );
      } else {
        break;
      }
      // patch结束后,进行尾索引的递减
      e1--;
      e2--;
    }
    // 3、如果旧vnode列表已经扫描结束
    if (i > e1) {
      // i <= e2 指的是新vnode未扫描完,未扫描到的通过while循环进行patch的新增操作
      if (i <= e2) {
        const nextPos = e2 + 1;
        const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
        while (i <= e2) {
          patch(
            null,
            c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]),
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          );
          i++;
        }
      }
      // 4、如果新vnode列表已经扫描结束
    } else if (i > e2) { 
      // i <= e1 指的是旧vnode未扫描完,未扫描到的通过while循环进行unmount的移除操作
      while (i <= e1) {
        unmount(c1[i], parentComponent, parentSuspense, true);
        i++;
      }
    } else {
      // 5、这部分就是经过前4步掐头去尾后剩余的中间部分
      const s1 = i; // 新节点开始索引
      const s2 = i; // 旧节点开始索引
      
      // (1)新vnode列表中key值和index的映射关系
      const keyToNewIndexMap = /* @__PURE__ */ new Map();
      for (i = s2; i <= e2; i++) {
        const nextChild = c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]);
        if (nextChild.key != null) {
          // 如果编写的key有重复,会报如下的警告
          if (!!(process.env.NODE_ENV !== "production") && keyToNewIndexMap.has(nextChild.key)) {
            warn(
              `Duplicate keys found during update:`,
              JSON.stringify(nextChild.key),
              `Make sure keys are unique.`
            );
          }
          // 只有在当前节点有key值的时候才会进行映射关系
          keyToNewIndexMap.set(nextChild.key, i);
        }
      }
      let j;
      // 已经被patch的长度
      let patched = 0;
      // 需要被patch的长度
      const toBePatched = e2 - s2 + 1;
      let moved = false;
      let maxNewIndexSoFar = 0;
      // (2)去头掐尾后的中间部分vnode的索引,与其在旧节点中的映射关系
      const newIndexToOldIndexMap = new Array(toBePatched);
      for (i = 0; i < toBePatched; i++)
        // 为newIndexToOldIndexMap赋值,
        newIndexToOldIndexMap[i] = 0;
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i];
        if (patched >= toBePatched) {
          unmount(prevChild, parentComponent, parentSuspense, true);
          continue;
        }
        let newIndex;
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key);
        } else {
          for (j = s2; j <= e2; j++) {
            if (newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(prevChild, c2[j])) {
              newIndex = j;
              break;
            }
          }
        }
        if (newIndex === void 0) {
          // 如果newIndex不存在,说明其在新列表中不存在,直接卸载即可
          unmount(prevChild, parentComponent, parentSuspense, true);
        } else {
          // 这里的newIndex - s2表示从0开始重新映射关系;
          // 这里的i+1表示的次序是1、2、3、4...n,目的是为了表示newIndexToOldIndexMap中的0表示新vnode在旧列表中是不存在的。
          newIndexToOldIndexMap[newIndex - s2] = i + 1;
          if (newIndex >= maxNewIndexSoFar) {
            // 表示当前newIndex所到达的最大位置
            maxNewIndexSoFar = newIndex;
          } else {
            // 否则,maxNewIndexSoFar都已经大于newIndex,说明当前去头掐尾的数组需要移动
            moved = true;
          }
          patch(
            prevChild,
            c2[newIndex],
            container,
            null,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          );
          patched++;
        }
      }
      // (3)通过贪心+二分查找的算法,求得最长递增子序列,目的是为了确定最长的无需移动的旧的节点
      const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : EMPTY_ARR;
      // 无语移动数组increasingNewIndexSequence的最后一位,即将和当前去头掐尾的新数组,从末位向前扫描
      j = increasingNewIndexSequence.length - 1;
      // (4)以下逻辑是针对于去头掐尾后,处理新列表中vnode中的逻辑
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i;
        const nextChild = c2[nextIndex];
        const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor;
        // 如果新节点在旧节点中未找到,重新patch
        if (newIndexToOldIndexMap[i] === 0) {
          patch(
            null,
            nextChild,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          );
        } else if (moved) {
          // j < 0 表示无需移动节点扫描结束;或者当前i不是无需移动的节点时,进行移动
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor, 2);
          } else {
            // 否则,j--,无需移动的节点-1,扫描索引i也即将-1;进行下一次循环
            j--;
          }
        }
      }
    }
  };

二、各步骤详情

起初新旧vnode列表如下所示:

1、首首扫描

新旧vnode列表从头开始扫描,如果是isSameVNodeType(n1, n2)为true(type和key相同)的新旧vnode,通过patch(n1,n2)的方式进行更新,直到isSameVNodeType(n1, n2)为false时进行终止。此时,i移动到了2的位置。

2、尾尾扫描

新旧vnode列表从尾部开始扫描,如果是isSameVNodeType(n1, n2)为true的新旧vnode,通过patch(n1,n2)的方式进行更新,直到isSameVNodeType(n1, n2)为false时进行终止。此时,e1移动到了4的位置,e2移动了5的位置。

3、如果旧vnode列表已经扫描结束

假设:

旧vnode列表:A B F

新vnode列表:A B C D E F

旧节点vnode列表通过头头、尾尾的方式已经扫描完成,那么,未被扫描的C D E就是新增的vnode,直接通过pathch(null, vnode)的方式进行新增操作。

4、如果新vnode列表已经扫描结束

假设:

旧vnode列表:A B C D E F

新vnode列表:A B F

新节点vnode列表通过头头、尾尾的方式已经扫描完成,那么,旧vnode列表中未被扫描的C D E在新vnode列表中不存在,直接通过unmount(vnode)的方式进行移除。

5、经过前4步后的中间部分

以下这部分就是经过前4步掐头去尾后剩余的中间部分,旧vnode列表为C D E,新vnode列表为E D G C

(1)构建新vnode列表中key值和index的映射关系

keyToNewIndexMap:

构建keyToNewIndexMap的目的是,可以通过key值得到新vnode中的index值。

(2)中间部分新vnode列表的与旧节点中vnode的索引映射关系

newIndexToOldIndexMap:

结论1:这幅图表示了旧vnode列表和新vnode列表的索引对应关系,其中旧节点的每一个值都被加了1,目的是剔除首位0的情况,这样,旧的索引值就为0 1 2 ... n。这里的0就可以表示,当前新节点G在旧vnode列表不存在,需要通过创建得到。

结论2:其次在扫描过程中,还定义了maxNewIndexSoFar,每一次newIndex >= maxNewIndexSoFar时会进行更新,如果整个遍历中新旧vnode列表的索引对应关系时递增的,那么就无需移动。否则,moved = true,就是图中虚线关系。

结论3:图中例子简单,很明显5 3 0 4的递增子序列时3 4,对应新vnode列表中的索引就是1 3,即C D保持不变,G在旧vnode中对应的是0,需要递增,E的值为5,不在递增子序列3 4中,需要移动。这里有个疑问,复杂渲染场景中1 3这样的值是怎么得到的?这就涉及到了最长递增子序列:

(3)最长递增子序列

通过贪心+二分查找的算法,求得最长递增子序列,目的是为了确定最长的无需移动的旧的节点,vue3中的实现为:

ini 复制代码
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];
    if (arrI !== 0) {
      // 如果结果中的最后一个值小于当前索引值先通过p[i]记录i的前一个索引是j;再进行push;
      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;
        }
      }
      // 通过p[i] = result[u - 1]的方式记录当前值的前一个索引,并替换当前值
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1];
        }
        result[u] = i;
      }
    }
  }
  // 以结果的最后一个值开始,通过p的对应关系,刷新result值
  u = result.length;
  v = result[u - 1];
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

(4)根据最长递增子序列处理中间部分

此时从新节点的尾部开始,同时,获取到的递增子序列increasingNewIndexSequence也在从尾 --> 头进行扫描,如下图所示:

在扫描过程中,遇到D,其索引值在increasingNewIndexSequence中,索引-1即可;

遇到GewIndexToOldIndexMap[i] === 0,需要进行首次渲染挂载patch(null,nextChild)

遇到C,其索引值在increasingNewIndexSequence中,索引-1即可;

遇到Ej < 0,需要通过move(nextChild, container, anchor, 2)进行移动。

总结

vue3中的diff本着能复用就复用原则,先从新旧vnode首首、尾尾进行扫描,然后处理新旧vnode列表各自扫描完后的新增或卸载。最后,通过递增子序列找到最长无需移动的索引,让在索引在其中的vnode对应的dom保持不动,处理其他需要新增或移动的dom

相关推荐
胡西风_foxww6 分钟前
【ES6复习笔记】Spread 扩展运算符(8)
前端·笔记·es6·扩展·运算符·spread
小林爱31 分钟前
【Compose multiplatform教程08】【组件】Text组件
android·java·前端·ui·前端框架·kotlin·android studio
跨境商城搭建开发42 分钟前
一个服务器可以搭建几个网站?搭建一个网站的流程介绍
运维·服务器·前端·vue.js·mysql·npm·php
hhzz43 分钟前
vue前端项目中实现电子签名功能(附完整源码)
前端·javascript·vue.js
秋雨凉人心1 小时前
上传npm包加强
开发语言·前端·javascript·webpack·npm·node.js
JoeChen.1 小时前
PostCSS插件——postcss-pxtorem结合动态调整rem实现字体自适应
javascript·ecmascript·postcss
NoneCoder1 小时前
CSS系列(37)-- Overscroll Behavior详解
前端·css
Nejosi_念旧1 小时前
使用Webpack构建NPM Library
前端·webpack·npm
前端切圖仔1 小时前
失业,仲裁,都赶上了(二)
前端·javascript·程序员
冰红茶-Tea2 小时前
typescript数据类型(二)
前端·typescript