一文搞懂 Vue3 的 diff 算法(3.3.8 版本源码)

Vue3 已经发布很久很久很久了,今天就来一起了解一下 Vue3 最新的 diff 算法,Vue2 的 diff 算法可以看一下之前发的文章《Vue2源码系列-9张图搞懂diff算法》

今天没有段子

准备工作

版本

本文使用 Vue3 的 3.3.8 版本

示例

为了更直观理解与解读,使用以下示例代码配合讲解

html 复制代码
<div id="demo">
  <ul>
    <li v-for="item in items" :key="item">{{item}}</li>
  </ul>
  <button @click="changeOrder">塔塔开</button>
</div>
<script>
  Vue.createApp({
    data: () => ({
      items: ["a", "b", "c", "d"],
    }),
    methods: {
      changeOrder() {
        this.items = ["b", "d", "e", "c"];
      },
    },
  }).mount("#demo");
</script>

名词解释

虚拟 DOM

虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构"虚拟"地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。
这里所说的 vnode 即一个纯 JavaScript 的对象 (一个"虚拟节点"),它代表着一个 <div> 元素。

虚拟 DOM 树

顾名思义,也就是一个虚拟 DOM 作为根节点,包含有一个或多个的子虚拟 DOM。

diff

在 Vue 中,我们改变原来的数据,例如示例中的数组 items,那么 Vue 中会再生成一份虚拟 DOM 树,现在我们就有了两份虚拟 DOM 树。

这时候渲染器将会找出它们之间的区别,并将其中的变化应用到真实的 DOM 上。这个过程被称为 更新 (patch),又被称为 比对(diffing)协调(reconciliation)

流程概述

不带 key 的新虚拟 DOM 树

  1. 从前往后遍历新旧虚拟 DOM 树,将旧虚拟 DOM 更新为新虚拟 DOM
  2. 比较新旧虚拟 DOM 树的长度,处理新增与删除的节点

带 key 的新虚拟 DOM 树

  1. 从前往后遍历新旧虚拟 DOM 树,寻找可复用节点,遇到不可复用节点跳出循环
  2. 从后往前遍历新旧虚拟 DOM 树,寻找可复用节点,遇到不可复用节点跳出循环
  3. 对比前两次遍历的索引
    • 识别出两侧与中间的新增与删除的节点
    • 处理未识别出的节点
      1. 遍历新节点,生成新节点的 key 与 index 对应的 Map
      2. 创建将要 patch 的节点数组 newIndexToOldIndexMap(下标为新节点索引,值为旧节点索引+1)
      3. 遍历剩余节点,寻找新节点 key 值对应的旧节点索引
        • 旧节点有 key 值,直接在 Map 中寻找
        • 旧节点不带 key 值,遍历剩余新节点,判断是否可复用
        • 找到新节点索引,更新 newIndexToOldIndexMap;没有找到就是不存在,直接卸载
      4. 根据 newIndexToOldIndexMap 生成最长稳定序列
      5. 从后往前遍历需要 patch 的节点
        • 在 newIndexToOldIndexMap 中的值为 0 的,是新节点
        • 不为 0 的,进一步判断是否在最长稳定序列中,不在就移动

不喜欢看源码的,看到这里就可以了


源码详解

还往下翻?看来是觉得只看概述不过瘾?

那就一起来扒一下源码,加深一下印象吧

Patch

一切的一切还要从 patch 方法讲起。

ts 复制代码
const patch: PatchFn = (...) => {
    // patch 方法首先判断传入的两个虚拟 DOM 是否完全一致,完全一致直接跳出 patch
    if (n1 === n2) {
      return
    }
    // 进一步判断新旧虚拟节点的 type 与 key 值是否一致,不一致将直接卸载旧虚拟节点
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null
    }
    // 非编译器生成,不走 optimized 模式
    if (n2.patchFlag === PatchFlags.BAIL) {
      optimized = false
      n2.dynamicChildren = null
    }
    const { type, ref, shapeFlag } = n2
    // 根据 新虚拟节点 的不同类型(type)使用不同的处理函数
    switch (type) {
      // 1. 文本:处理方式简单粗暴,直接往容器追加文本节点
      case Text:
        processText(n1, n2, container, anchor)
        break
      // 2. 组件节点:处理方式是先创建该组件,再往容器内追加
      case Comment:
        processCommentNode(n1, n2, container, anchor)
        break
      // 3. 静态节点:处理方式直接挂载节点
      case Static:
        if (n1 == null) {
          mountStaticNode(n2, container, anchor, isSVG)
        } else if (__DEV__) {
          patchStaticNode(n1, n2, container, isSVG)
        }
        break
      // 4. Fragment:Vue3 新组件,走 `processFragment` 处理函数
      case Fragment:
        processFragment(...)
        break
      default:
        // 如果都不是上述的类型,那就需要再分,这一次不根据 type(Symbol 标记),而是 shapeFlag(位运算)继续细分类型:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // 对应真实 DOM 节点,处理方式挂载节点(初始化)或走 patchElement 函数(处理 hook,若存在子节点也将走到 `patchChildren`)
          processElement(...)
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // vue 组件,处理方式就是挂载或更新组件
          processComponent(...)
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          // vue3 新组件,可自行了解 ☞ https://cn.vuejs.org/guide/built-ins/teleport.html
          ;(type as typeof TeleportImpl).process(...)
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          // vue3 新组件,可自行了解 ☞ https://cn.vuejs.org/guide/built-ins/suspense.html
          ;(type as typeof SuspenseImpl).process(...)
        } else if (__DEV__) {
          warn('Invalid VNode type:', type, `(${typeof type})`)
        }
    }
    // 挂 ref
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
    }
  }

以我们的示例代码为例,在我们按下按钮后,发生变化的是节点 li;在 Vue3 中一个组件如果有多个子节点会为其创建一个包裹,也即 Fragment 组件

同样的,v-for 也会为其创建一个包裹;因此,这里的 patch 类型会是 Fragment,将走到 processFragment 函数。

还是以我们的示例代码为例,processFragment 函数在节点更新时会走到 patchChildren 函数,这里就不贴代码了。

patchChildren

处理新旧两份虚拟子节点

ts 复制代码
const patchChildren: PatchChildrenFn = (...) => {
    // 取一哈新旧虚拟节点的子节点
    const c1 = n1 && n1.children
    const prevShapeFlag = n1 ? n1.shapeFlag : 0
    const c2 = n2.children
    const { patchFlag, shapeFlag } = n2
    if (patchFlag > 0) {
      // patchFlag > 0 就表示子节点含有动态属性,如:动态style、动态class、动态文案等
      if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
        // 子节点带 key
        patchKeyedChildren(...)
        return
      } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
        // 子节点不带 key
        patchUnkeyedChildren(...)
        return
      }
    }
    // 子节点存在3种可能的情况:文本、数组、没有子节点
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 新虚拟节点的子节点是文本
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 对应的旧虚拟节点的子节点是数组
        // 卸载旧虚拟节点的数组子节点
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
      }
      // 再挂载新虚拟节点的文本子节点
      if (c2 !== c1) {
        hostSetElementText(container, c2 as string)
      }
    } else {
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 旧虚拟节点的子节点是数组
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 新虚拟节点的子节点也是数组,做全量diff
          patchKeyedChildren(...)
        } else {
          // 能走到这就说明新虚拟节点没有子节点,这里只需要卸载久虚拟节点的子节点
          unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
        }
      } else {
        // 走到这就说明
        // 旧虚拟节点的子节点要么是文本要么也没有子节点
        // 新虚拟节点的子节点要么是数组要么就没有子节点
        if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
          // 旧虚拟节点的子节点是文本,更新
          hostSetElementText(container, '')
        }
        if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
          // 新虚拟节点的子节点是数组,挂载
          mountChildren(...)
        }
      }
    }
  }

patchUnkeyedChildren

这里我们先看一下不带 key 是怎么处理的

ts 复制代码
const patchUnkeyedChildren = (...) => {
  c1 = c1 || EMPTY_ARR;
  c2 = c2 || EMPTY_ARR;
  const oldLength = c1.length;
  const newLength = c2.length;
  const commonLength = Math.min(oldLength, newLength);
  let i;
  // 依次从头遍历,将旧的虚拟节点更新为新节点
  for (i = 0; i < commonLength; i++) {
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]));
    patch(...);
  }
  if (oldLength > newLength) {
    // 旧虚拟节点的子节点长度更大,说明被删除了
    // 卸载多余的旧虚拟节点
    unmountChildren(...);
  } else {
    // 反之,存在新增的节点,挂载
    mountChildren(...);
  }
};

patchKeyedChildren

我们示例中的 li 都带了 key,因此下一步直接走到 patchKeyedChildren 函数,也是 diff 算法优化的重点

ts 复制代码
const patchKeyedChildren = (...) => {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1; // 旧虚拟 DOM 树的末尾节点索引
  let e2 = l2 - 1; // 新虚拟 DOM 树的末尾节点索引

  /**
   * 第一步
   * 从前往后遍历
   * 索引 i 递增
   */
  while (i <= e1 && i <= e2) {
    // 新旧虚拟节点同时遍历
    const n1 = c1[i];
    const n2 = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]));
    if (isSameVNodeType(n1, n2)) {
      // 存在类型相同,且key值相同的节点就 patch
      patch(...);
    } else {
      // 存在不一致的节点,立即退出循环
      break;
    }
    i++;
  }

  /**
   * 第二步
   * 从后往前遍历
   * 索引 e1、e2 递减少
   */
  while (i <= e1 && i <= e2) {
    // 新旧虚拟节点同时遍历
    const n1 = c1[e1];
    const n2 = (c2[e2] = optimized
      ? cloneIfMounted(c2[e2] as VNode)
      : normalizeVNode(c2[e2]));
    if (isSameVNodeType(n1, n2)) {
      // 存在类型相同,且key值相同的节点就patch
      patch(...);
    } else {
      // 存在不一致的节点,立即退出循环
      break;
    }
    e1--;
    e2--;
  }

  /**
   * 第三步
   * 对比索引大小
   * 处理新增或被删除节点
   */
  if (i > e1) {
    if (i <= e2) {
      // 存在新增节点
      /**
       * 1. 左侧新增
       * (a b)
       * c (a b)
       * i = 0, e1 = -1, e2 = 0
       */
      /**
       * 2. 中间新增
       * (a b)
       * (a) c d (b)
       * i = 1, e1 = 0, e2 = 2
       */
      /**
       * 3. 右侧新增
       * (a b)
       * (a b) c
       * i = 2, e1 = 1, e2 = 2
       */
      // nextPos 作为新节点的后一位节点的索引,当作插入锚点
      const nextPos = e2 + 1;
      const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor;
      while (i <= e2) {
        /**
         * i 到 e2 之间的节点即为新增节点
         * 直接挂载
         * oldvnode 置为 null,复用 path 函数进行挂载
         */
        patch(
          null,
          (c2[i] = optimized
            ? cloneIfMounted(c2[i] as VNode)
            : normalizeVNode(c2[i])),
          ...
        );
        i++;
      }
    }
  } else if (i > e2) {
    // 存在被删除节点
    /**
     * 1. 左侧被删除
     * a (b c)
     *   (b c)
     * i = 0, e1 = 0, e2 = -1
     */
    /**
     * 2. 中间被删除
     * (a) c d (b)
     * (a)     (b)
     * i = 1, e1 = 2, e2 = 0
     */
    /**
     * 3. 右侧被删除
     * (a b) c
     * (a b)
     * i = 2, e1 = 2, e2 = 1
     */
    while (i <= e1) {
      /**
       * i 到 e1 之间的节点即为被删除节点
       * 直接卸载
       */
      unmount(c1[i], parentComponent, parentSuspense, true);
      i++;
    }
  }
  /**
   * 第四步
   * 处理其它特殊情况
   * 我们的示例也将走到这里
   */
  else {
    // 新旧虚拟 DOM 树不一致的节点起始索引
    const s1 = i;
    const s2 = i;
    /**
     * 遍历新节点
     * 生成map,键值即新节点的key值,值为节点的索引
     * 伪代码:keyToNewIndexMap<key, index>
     */
    const keyToNewIndexMap: Map<string | number | symbol, number> = new Map();
    for (i = s2; i <= e2; i++) {
      const nextChild = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]));
      if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i);
      }
    }
    let j;
    // 记录已经 patch 的节点数
    let patched = 0;
    // 计算新节点中还有多少节点需要被 patch
    const toBePatched = e2 - s2 + 1;
    // 标记是否需要移动
    let moved = false;
    // 记录这次对比的旧节点到新节点最长可复用的索引
    let maxNewIndexSoFar = 0;

    /**
     * 创建将要patch的节点数组
     * 新节点对应旧节点的数组
     * 数组下标为新节点的索引,值为旧节点的索引+1
     */
    const newIndexToOldIndexMap = new Array(toBePatched);
    /**
     * 默认置为 0
     * 表示新增节点
     * 这也是为什么值是 旧节点索引+1 而不直接存索引的原因了
     * 后续判断是否存在可复用的旧节点再重新赋值
     */
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

    // 遍历 i 到 e2 之间需要处理的节点
    for (i = s1; i <= e1; i++) {
      // 获取一下旧虚拟节点
      const prevChild = c1[i];
      if (patched >= toBePatched) {
        // 已经patch的节点数量大于或等于需要被patch的节点数
        // 说明当前节点是需要被删除的
        unmount(prevChild, parentComponent, parentSuspense, true);
        continue;
      }
      // 获取当前旧节点对应的新节点索引
      let newIndex;
      if (prevChild.key != null) {
        /**
         * 旧节点存在key值
         * 直接在 keyToNewIndexMap 查找
         * 获取新节点的索引(newIndex)
         */
        newIndex = keyToNewIndexMap.get(prevChild.key);
      } else {
        // 旧节点不存在 key 值
        for (j = s2; j <= e2; j++) {
          // 遍历新节点剩余索引(s2 即新旧虚拟DOM树存在不同节点的位置)
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j] as VNode)
          ) {
            /**
             * 判断当前索引是不是还没 patch(newIndexToOldIndexMap[j - s2] === 0)
             * 同时判断当前新旧节点是否 key、type 都一致
             * 一致就获取当前新节点索引(newIndex = j)
             * 跳出当前循环
             */
            newIndex = j;
            break;
          }
        }
      }
      if (newIndex === undefined) {
        /**
         * newIndex 为 undefined
         * 说明当前旧节点在新的虚拟 DOM 树中被删了
         * 直接卸载
         */
        unmount(prevChild, parentComponent, parentSuspense, true);
      } else {
        /**
         * newIndex有值
         * 说明当前旧节点在新节点数组中还存在,可能只是挪了位置
         */

        /**
         * 记录一下 newIndexToOldIndexMap
         * 表明当前新旧节点需要 patch
         */
        newIndexToOldIndexMap[newIndex - s2] = i + 1;
        if (newIndex >= maxNewIndexSoFar) {
          // 新节点索引大于或等于最长可复用索引,重新赋值
          maxNewIndexSoFar = newIndex;
        } else {
          /**
           * 反之
           * 说明新节点在最长可复用节点的左侧
           * 需要移动(左移)
           */
          moved = true;
        }
        // 直接复用,patch(处理可能存在的孙子节点、更新一下属性等)
        patch(
          prevChild,
          c2[newIndex] as VNode,
          ...
        );
        patched++;
      }
    }
    /**
     * 根据 newIndexToOldIndexMap 数组
     * 生成最长稳定序列
     * 最长稳定序列在这里存的就是不需要移动的节点索引
     */
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : EMPTY_ARR;
    // 最长稳定序列末尾节点索引
    j = increasingNewIndexSequence.length - 1;
    // 从后往前遍历需要 patch 的节点数
    for (i = toBePatched - 1; i >= 0; i--) {
      // 新虚拟节点索引
      const nextIndex = s2 + i;
      // 新虚拟节点
      const nextChild = c2[nextIndex] as VNode;
      // 将新节点的真实 DOM 作为后续插入的锚点
      const anchor =
        nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor;
      if (newIndexToOldIndexMap[i] === 0) {
        /**
         * 为 0 的话就是新增的节点
         * 直接挂载新节点
         */
        patch(
          null,
          nextChild,
          container,
          anchor,
          ...
        );
      } else if (moved) {
        // 需要移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          /**
           * 当前索引不在最长递增序列中
           * 移动当前索引对应的新节点
           * 移动到锚点节点之前
           */
          move(nextChild, container, anchor, MoveType.REORDER);
        } else {
          j--;
        }
      }
    }
  }
};

再补充一点,在 Vue 中,节点移动通常是左移,也就是通过 insertBefore 这个 API 实现

到这里,这个 diff 流程就走完了;还有很多针对不同节点类型的特殊处理,因为与 diff 的主流程没有太大关系也就没有列出。

小试牛刀

你学费了吗?

在我们的示例中

  • 旧虚拟 DOM 树对应的就是:['a', 'b', 'c', 'd']
  • 新虚拟 DOM 树对应的就是:['b', 'd', 'e', 'c']

可以先自己试着梳理一下 diff 过程

可以直观地看出,无论是从前往后遍历,还是从后往前遍历,都找不到可复用的节点,也无法通过对比前两次索引得到新增或被删除的节点是谁;直接进入核心算法。

整棵树都是无法处理的节点,因此生成的 keyToNewIndexMap 长这样:

js 复制代码
{'b' => 0, 'd' => 1, 'e' => 2, 'c' => 3}

旧虚拟 DOM 树都是带 key 的,所以可以轻松地从 keyToNewIndexMap 中得到新节点的索引,得到的 newIndexToOldIndexMap 如下:

js 复制代码
[2, 4, 0, 3];

注意,数组内的值对应的旧节点的索引+1的值,数组的下标才是新节点的索引

从 newIndexToOldIndexMap 就可以看出,下标是 2 的新节点是新增的(值为 0)

这时候还有最后一不,判断旧节点的顺序是不是对的,这里就要生成最长稳定序列了,结果如下:

js 复制代码
[0, 3]

因此,稳定的是索引为 0 与索引为 3 的新节点;需要移动的便是索引为 1 的新节点,对应的就是索引为 3(4 - 1)的旧节点,直接将新节点插入到索引为 2 的新节点前面。

小结

来个思维导图解解闷儿

与 Vue2 对比

Vue2 的 diff 算法,在一个大循环中处理所有可复用情况,最后再判断是否还有新增或被删除的节点;相比之下,Vue3 将大循环拆开了,更加细化节点的类型,针对不同类型做优化。

Vue2 在一开始就直接在大循环中,对比新旧虚拟 DOM 树的首尾节点,发现可复用节点但位置不对,直接移动(操作真实节点);而在 Vue3 中,移动节点的操作放到了最后,同时借助最长稳定序列减少节点操作次数。

在 Vue2 同样也生成了个 Map,不过这个 Map 是旧节点的 key 与索引的对应关系;而 Vue3 中的 Map 则是新节点的 key 与索引的对应关系。

参考

相关推荐
王中阳Go2 小时前
字节跳动的微服务独家面经
微服务·面试·golang
Jiaberrr3 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy3 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白3 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、3 小时前
Web Worker 简单使用
前端
web_learning_3213 小时前
信息收集常用指令
前端·搜索引擎
tabzzz3 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
LvManBa3 小时前
Vue学习记录之六(组件实战及BEM框架了解)
vue.js·学习·rust
200不是二百4 小时前
Vuex详解
前端·javascript·vue.js
滔滔不绝tao4 小时前
自动化测试常用函数
前端·css·html5