vue3.5.18源码:图文结合,搞懂双端diff算法

vue3v-for时,必须要写key嘛?

答:如果无需对列表增删改,列表不会再变化,可以不写。否则,为了列表修改时的复用,需要写,且推荐用渲染数据的唯一标识作为key。如果实在不确定写不写,那就写,写总不会错的。

这里就涉及到了被津津乐道的优化策略之一diff算法,也是作为高级前端面试的选题之一

接下来以一个例子,为了说明问题,我们以Dom节点为例(组件key的渲染机制类似),接下来我们开始探索diff算法的底层实现原理:

ts 复制代码
<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的更新渲染,最后会执行到以下核心逻辑:

ts 复制代码
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的情况,这样,旧的索引值就变成了1 2 ... n。图中old index0就可以表示,当前新节点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中的实现为:

ts 复制代码
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即可;

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

遇到C,其索引值在increasingNewIndexSequence原地复用,索引-1即可;

遇到Ej < 0,需要通过move(nextChild, container, anchor, 2)原地复用,进行移动即可。

总结

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

相关推荐
SmalBox15 分钟前
【渲染流水线】[应用阶段]-[定制裁剪]以UnityURP为例
架构
上海大哥15 分钟前
Flutter 实现工程组件化(Windows电脑操作流程)
前端·flutter
用户849137175471621 分钟前
JustAuth实战系列(第5期):建造者模式进阶 - AuthRequestBuilder设计解析
java·设计模式·架构
刘语熙23 分钟前
vue3使用useVmode简化组件通信
前端·vue.js
XboxYan1 小时前
借助CSS实现一个花里胡哨的点赞粒子动效
前端·css
码侯烧酒1 小时前
前端视角下关于 WebSocket 的简单理解
前端·websocket·网络协议
OEC小胖胖2 小时前
第七章:数据持久化 —— `chrome.storage` 的记忆魔法
前端·chrome·浏览器·web·扩展
OEC小胖胖2 小时前
第六章:玩转浏览器 —— `chrome.tabs` API 精讲与实战
前端·chrome·浏览器·web·扩展
不老刘2 小时前
基于clodop和Chrome原生打印的标签实现方法与性能对比
前端·chrome·claude·标签打印·clodop
ALLSectorSorft2 小时前
定制客车系统票务管理系统功能设计
linux·服务器·前端·数据库·apache