Vue3 DIFF 算法解析

2,657 阅读13分钟

前言

本文的目标是讲一下diff算法实现方式,吸收内部优秀的代码设计、设计模式吸收到实际业务中。一起来get吧!

vue 作为业界优秀的开源框架,同时也是业内广泛采用的技术方案,其源码设计非常优秀。学习vue3源码有助于提高伙伴能力、拓展技术视野,也能在实际的业务中帮助伙伴排查问题。

diff算法是vue渲染器的一个重要组成部分,解决了操作dom成本过高的问题。通过diff算法可以高效地实现渲染的局部更新,避免dom全量更新带来的性能负担。在长列表、重渲染、重交互的场景,伙伴可以借鉴diff算法的思想来提升渲染性能,达到提升用户体验的目的。

本文会讲述以下内容:

  • 概述虚拟dom的概念
  • 概述vue3的patch的概念
  • 解析patchKeyedChildren的实现方式
  • 解析patchUnkeyedChildren的实现方式
  • 与 vue2 diff的对比

产生背景:

原生dom中有很多我们不需要的节点、属性,因此在浏览器环境中进行dom的操作开销是很大的, 如果此时修改了部分数据,直接渲染真实dom又会引起整个dom树的重绘和重排,造成性能负担。

而 diff 算法就是我们解决操作真实dom成本过高的解决方案,目的是计算出对于真实dom最小性能损耗的操作。

diff流程

diff算法通过同层树节点进行比较的高效算法,提过将前后两个模块的节点进行差异化比较并且不断修补、更新差异的过程。因为避免了对树进行逐层搜索遍历,所以使渲染的效率更高。

源码解析

虚拟dom

在传统的页面开发中,频繁变动dom会造成浏览器的回流或者重绘,这种方式消耗计算资源较大,为了解决这个问题,我们需要虚拟dom这一层抽象。用对象的方式来描述真实的dom,就能将性能消耗从真实 DOM 操作的级别降低至 JavaScript 层面,尽可能地一次性将差异更新到dom中,这样我们有更多的优化空间,相对而言性能更加优秀。

不仅如此,虚拟dom也能让 Vue 和 React 之类的框架支持除浏览器之外的其他平台( react native、uni-app、taro 等跨平台框架)。

虚拟dom是一种对象结构,它描述了每一层容器的特征,其中包含了 type 、props 、children 三个属性,用来解释真实的dom节点。

<div>
  <p id="vnode1">1</p>
  <p id="vnode2">2</p>
  <p id="vnode3">3</p>
  <p id="vnode4">4</p>
</div>

dom节点

上面的 HTML 转换为虚拟 DOM 如下:

const vnode = {
  type: 'div',
  children: [
    {
      type: 'p',
      props: { 
        id: 'vnode1' 
      },
      key: '1',
      children: '1'
    }, 
    {
      type: 'p',
      props: {
        id: 'vnode2'
      },
      key: '2',
      children: '2'
    },
    {
      type: 'p',
      props: {
        id: 'vnode3'
      },
      key: '3',
      children: '3'
    }, {
      type: 'p',
      props: {
        id: 'vnode4'
      },
      key: '4',
      children: '4'
    }]
}

vnode结构

如果需要把 HTML 的结构改变为如下结构:

<div>
  <p id="vnode4">4</p>
  <p id="vnode1">1</p>
  <p id="vnode2">2</p>
  <p id="vnode3">3</p>
</div>

变更后的dom节点

传统的方式会导致整个 DOM 树的重绘和重排,但分析结构可以得出结论:

把节点4移动到头部就是最小损耗的操作,这就是diff算法需要做的事。

最小损耗操作

Diff 过程

1. 节点判断:

在采取diff算法比较新旧节点的时候,比较只会在同层级进行, 因为复杂度过高,不会跨层级比较。

<div>
  <p>123</p>
</div>

<div>
  <span>456</span>
</div>

新旧节点树

上面的代码会分别比较同一层的两个div以及第二层的 p 和 span ,但是不会拿div和span作比较。

比较过程

如何判断是否是同一个节点呢?在Vue 中 isSameVNodeType 函数就是用来区分是相同节点的方式,通过节点的type和key值两个维度可以快速判断两个节点是否相同。

function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && // 比较类型
    		 n1.key === n2.key	// 比较标记
}

判断节点相同

2. patch 过程:

首先要从patch函数开始看起:

Vue 在通过VNode节点渲染 DOM 时,并不是通过当前的VNode节点去暴力的更新DOM节点,而是对新旧两个 VNode节点通过patch进行比较,然后通过对比结果找出差异的属性或节点进行按需更细。

patch的过程中主要完成以下几件事情:

  • 创建需要新增的节点
  • 移除已经废弃的节点
  • 移动修改需要更新的节点
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = isHmrUpdating ? false : !!n2.dynamicChildren) => {
  /**
    Vnode有不同的类型,可以分为:
    简单类型:文本、注释、Static。
    复杂类型:组件、Fragment、Component、Teleport、Suspense。
    patch思路,可以看作一个深度优先遍历。
    简单类型就相当于JS中的原始数据类型:字符串、数字、布尔。
    复杂类型就相当于JS中的引用类型:对象、数组、Map、Set。
    不同的节点类型,需要采取不同的patch方式。
    而patch函数的主要职责就是去判断Vnode的节点类型(打上patchFlag标志),然后调用对应类型的Vnode处理方式,进行更细致的patch(最后进行render渲染)。
*/
  if (n1 === n2) {
    return;
  }
  // patching & not same type, unmount old tree
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1);
    unmount(n1, parentComponent, parentSuspense, true);
    n1 = null;
  }
  if (n2.patchFlag === -2 /* BAIL */) {
    optimized = false;
    n2.dynamicChildren = null;
  }
  const { type, ref, shapeFlag } = n2;
  switch (type) {
    case Text:
      // 文本类型的处理...
      processText(n1, n2, container, anchor);
      break;
    case Comment:
      processCommentNode(n1, n2, container, anchor);
      break;
    case Static:
      // 静态类型的处理...
      if (n1 == null) {
        mountStaticNode(n2, container, anchor, isSVG);x
      }
      else {
        patchStaticNode(n1, n2, container, isSVG);
      }
      break;
    case Fragment:
      // Fragment 类型处理
      processFragment(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      break;
    default:
      if (shapeFlag & 1 /* ELEMENT */) {
        // ELEMENT 类型处理
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      }
      else if (shapeFlag & 6 /* COMPONENT */) {
        // 组件 类型处理
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      }
      else if (shapeFlag & 64 /* TELEPORT */) {
        // 节点移动 类型处理
        type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, internals);
      }
      else if (shapeFlag & 128 /* SUSPENSE */) {
        // 节点移动 类型处理
        type.process(n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, internals);
      }
      else {
        warn('Invalid VNode type:', type, `(${typeof type})`);
      }
  }
  // set ref
  if (ref != null && parentComponent) {
    setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2);
  }
};

patch函数源码

patch函数的核心处理逻辑如下图所示:

patch函数结构

patch函数的主要职责就是判断Vnode的节点类型(打上patchFlag标志),然后调用对应类型的Vnode处理方式,进行更细致的patch(最后进行render渲染)。

其中patchFlag用于标识编译优化信息,可以理解为对虚拟节点VNode的属性进行描述的标志。根据patchFlag判断出需要更新的内容,实现靶向更新。

patchFlag类型如下:

export const enum PatchFlags {
  // 表示vnode具有动态textContent的元素
  TEXT = 1,
  // 表示vnode具有动态的class
  CLASS = 1 << 1,
  // 表示具有动态的style
  STYLE = 1 << 2,
  // 表示具有动态的非class和style的props
  PROPS = 1 << 3,
  // 表示props具有动态的key,与CLASS、STYLE、PROPS冲突
  FULL_PROPS = 1 << 4,
  // 表示有监听事件(在同构期间需要添加)
  HYDRATE_EVENTS = 1 << 5,
  // 表示vnode是个children顺序不会改变的fragment
  STABLE_FRAGMENT = 1 << 6,
  // 表示children带有key的fragment
  KEYED_FRAGMENT = 1 << 7,
  // 表示children没有key的fragment
  UNKEYED_FRAGMENT = 1 << 8,
  // 表示vnode只需要非props的patch。例如只有标签中只有ref或指令
  NEED_PATCH = 1 << 9,
  // 表示vnode存在动态的插槽。例如动态的插槽名
  DYNAMIC_SLOTS = 1 << 10,
  // 表示用户在模板的根级别存在注释而创建的片段,这是一个仅用于开发的标志,因为注释在生产中被剥离
  DEV_ROOT_FRAGMENT = 1 << 11,
  // 以下都是一些特殊的flag,它们不能使用位运算进行匹配
  // 表示vnode经过静态提升
  HOISTED = -1,
  // diff算法应该退出优化模式
  BAIL = -2
}

patchFlag枚举

Vue3中通过patch函数生成出一个数字枚举来对虚拟节点进行标记,采用按位与 ( & ) 运算来确定节点的属性,比如文本节点、数组节点、组件节点等等,CPU 中有直接对应位操作的指令,提升判断效率更高。

到 patch 函数匹配类型为复杂类型时,正式进入我们的diff算法的详细处理流程。主要处理两种情况:

  1. 有 key 的处理, 进入 patchKeyedChildren流程
  2. 无 key 的处理, 进入 patchUnkeyedChildren流程
const patchChildren = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized = false) => {
  const c1 = n1 && n1.children;
  const prevShapeFlag = n1 ? n1.shapeFlag : 0;
  const c2 = n2.children;
  const { patchFlag, shapeFlag } = n2;
  // fast path
  // 这个 patchFlag,就是一个判断渲染的节点上是包含 key 的一个标识符
  if (patchFlag > 0) {
    if (patchFlag & 128 /* KEYED_FRAGMENT */) {
      // 当节点上面存在 key,执行的是 patchKeyedChildren 方法
      // this could be either fully-keyed or mixed (some keyed some not)
      // presence of patchFlag means children are guaranteed to be arrays
      patchKeyedChildren(c1, c2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      return;
    }
    else if (patchFlag & 256 /* UNKEYED_FRAGMENT */) {
      // 当前节点上不存在 key,执行的是 patchUnkeyedChildren 方法
      // unkeyed
      patchUnkeyedChildren(c1, c2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      return;
    }
  }
}

patchChlidren源码

有key的处理:

主要分为五个部分:

首遍历
 /* 从头对比找到有相同的节点 patch ,发现不同,立即跳出*/
while (i <= e1 && i <= e2) {
  const n1 = c1[i];
  const n2 = (c2[i] = optimized
              ? cloneIfMounted(c2[i])
              : normalizeVNode(c2[i]));
  // isSameVNodeType 根据type与key的维度判断同一节点
  if (isSameVNodeType(n1, n2)) {
    // 相同就打补丁
    patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
  }
  else {
    // 不相同就跳出
    break;
  }
  // 记录下标
  i++;
}

首遍历

这一步是diff的第一步,是从节点组头部向尾部遍历,如果遍历过程中遇到相同节点,就进行patch,否则就退出遍历,并记录当前遍历的最新下标。

首遍历流程

尾遍历
 /* 从尾部对比找到有相同的节点 patch ,发现不同,立即跳出*/
while (i <= e1 && i <= e2) {
  const n1 = c1[e1];
  const n2 = (c2[e2] = optimized
              ? cloneIfMounted(c2[e2])
              : normalizeVNode(c2[e2]));
  // isSameVNodeType 根据type与key的维度判断同一节点
  if (isSameVNodeType(n1, n2)) {
    // 相同就打补丁
    patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
  }
  else {
    // 不相同就跳出
    break;
  }
  // 记录下标
  e1--;
  e2--;
}

第二部和第一步类似,是从后向前遍历,如果遇到第一步记录的下标就停止,然后遍历过程中,如果遇到相似节点也是直接进行 patch 对比,如果不相同就是直接退出遍历,并且记录旧节点组和新节点组的尾指针。

尾遍历流程

旧节点挂载完毕
/* 如果新的节点大于老的节点数 ,对于剩下的节点全部以新的vnode处理( 这种情况说明已经patch完相同的vnode  ) */
if (i > e1) {
  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++;
    }
  }
}

尾遍历

旧节点挂载完毕代表只有新的节点剩余,执行旧节点挂载

新节点挂载完毕
// 只有旧的节点剩余
else if (i > e2) {
  while (i <= e1) {
    // 执行旧节点卸载
    unmount(c1[i], parentComponent, parentSuspense, true);
    i++;
  }
}

新旧节点挂载完毕

新节点挂载完毕代表只有旧的节点剩余,执行旧节点卸载

皆有剩余
// 5. unknown sequence
// [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
else {
  const s1 = i; // prev starting index
  const s2 = i; // next starting index
  // 5.1 build key:index map for newChildren
  // 生成一个 Map 把新的元素的 key(如果存在) 和它对应的下标保存起来。
  const keyToNewIndexMap = new Map();
  for (i = s2; i <= e2; i++) {
    const nextChild = (c2[i] = optimized
                       ? cloneIfMounted(c2[i])
                       : normalizeVNode(c2[i]));
    if (nextChild.key != null) {
      if (keyToNewIndexMap.has(nextChild.key)) {
        warn(`Duplicate keys found during update:`, JSON.stringify(nextChild.key), `Make sure keys are unique.`);
      }
      keyToNewIndexMap.set(nextChild.key, i);
    }
  }
  // 5.2 loop through old children left to be patched and try to patch
  // matching nodes & remove nodes that are no longer present
  let j;
  let patched = 0; // 已经patch完的数量
  const toBePatched = e2 - s2 + 1; // 需要patch的数量,即c2剩下的元素数量
  let moved = false; // 是否需要进行移动操作
  // used to track whether any node has moved
  let maxNewIndexSoFar = 0; // 用来辅助判定moved
  // 生成c2中剩下的元素,在c1中的原来的位置的对应数组,初始化都为0
  // 数组长度为 toBePatched,即剩下的 c2 中的元素数量
  // 比如 oldchildren = [a, b, (c, d, e, f,) g, h], 
  // newchildren = [a, b, (d, e, f, c,) g, h]
  // d 在 new 中 index 为 2,在 old 中为 3
  const newIndexToOldIndexMap = new Array(toBePatched);
  for (i = 0; i < toBePatched; i++)
    newIndexToOldIndexMap[i] = 0;
  for (i = s1; i <= e1; i++) {
    const prevChild = c1[i];
    if (patched >= toBePatched) {
      // all new children have been patched so this can only be a removal
      unmount(prevChild, parentComponent, parentSuspense, true);
      continue;
    }
    let newIndex;
    // 寻找 key 是否存在
    if (prevChild.key != null) {
      newIndex = keyToNewIndexMap.get(prevChild.key);
    }
    else {
      // 如果没有 key 就在 c2 剩下的元素中 寻找同样没有 key 且,还没有配对的元素
      for (j = s2; j <= e2; j++) {
        if (newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j])) {
          newIndex = j;
          break;
        }
      }
    }
    // 如果 newindex 不存在,说明 child 在 c2 中不存在,卸载就行了
    if (newIndex === undefined) {
      unmount(prevChild, parentComponent, parentSuspense, true);
    }
    else {
      // 如果存在,找到对应的位置
      newIndexToOldIndexMap[newIndex - s2] = i + 1;
      if (newIndex >= maxNewIndexSoFar) {
        maxNewIndexSoFar = newIndex;
      }
      else {
        moved = true;
      }
      patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      // 比较新老元素
      patched++;
    }
  }
  // 5.3 move and mount 最长递增子序列
  const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : shared.EMPTY_ARR;
  j = increasingNewIndexSequence.length - 1;
  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;
    if (newIndexToOldIndexMap[i] === 0) {
      // 新元素没有在 c1 中匹配到index 新建就行了
      patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
    }
    else if (moved) {
      // move if:
      // 最大子序列下标中 对应的元素不需要移动,其他的元素要进行move
      // 如果 i 对应的不是最长子序列中的下标 进行move移动元素
      if (j < 0 || i !== increasingNewIndexSequence[j]) {
        move(nextChild, container, anchor, 2 /* REORDER */);
      }
      else {
        j--;
      }
    }
  }
}

皆有剩余

如果新旧节点组都未遍历完,说明存在未知序列可能存在位移等情况。

这个节点就要引入vue3最核心的思想:最长递增子序列。

在vue3中最长递增序列是基于旧节点列表key值映射到新节点列表的最长递增子序列,求解出不需要移动的元素有哪些,以此为基准操作其他节点移动、新增、删除。

我们看一个例子:

首先我们需要知道老节点与新节点下标的排序,假如:

新旧节点列表

oldVNodes: [0,1,2,3,4,5]

newVNodes: [0,3,1,2,4,5]

得出最长递增子序列为 [1,2], 然后可以在移动老节点的时候,忽略掉 [1,2] 这些元素而不去移动他们,这就是损耗最小的移动方式。

最小损耗操作

核心实现:

function getSequence(arr) {
    const p = arr.slice();
    const result = [0]; // 保存最长递增子序列的索引  以默认第0个位基准
    let i, j, u, v, c;
    const len = arr.length;
    for (i = 0; i < len; i++) {
      	// 【1】 找到索引先放进去
        const arrI = arr[i];
        if (arrI !== 0) {
            j = result[result.length - 1]; // 找到序列中的最后一项
            if (arr[j] < arrI) { // 取出序列中的最后一项对应的值 与当前项的值相比较
                p[i] = j; // 当前一项
                result.push(i); // 记录索引
                continue;
            }
          	// 【2】 二分查找 在结果集中找到比当前值大的 用当前值的索引将其替换掉
            // result是递增序列 采用二分查找是最快的
            u = 0;
            v = result.length - 1; // 二分查找 前后索引
            while (u < v) { // 最终 start = end 就停止了
                c = (u + v) >> 1;
              // 拿result中间值 和 当前项 比较
                if (arr[result[c]] < arrI) { // 找比arrI大的值 或者等于arrI
                    u = c + 1;
                }
                else {
                    v = c;
                }
            }
            if (arrI < arr[result[u]]) { // 当前项小于中间值就替换掉大的那一项
                if (u > 0) { // end > 0 才需要替换 p[i]就是当前一项
                    p[i] = result[u - 1]; // 【3.3】替换并记录前驱节点,核心点:记录 “要替换的值的 前一个的索引”
                }
                result[u] = i;
            }
        }
    }
    u = result.length;
    v = result[u - 1];
  	// 倒序追溯 根据前驱节点一个个向前查找
    while (u-- > 0) {
        result[u] = v; // 最后一项肯定是正确的
        v = p[v]; // 重新赋值
    }
    return result;
}

getSequence源码实现

实现原理:

  1. 通过贪心+二分查找拿到数组索引列表
    1. 先创建一个空数组result保存索引。
    2. 遍历nums,将当前项current和result的最后一项对应的值进行比较。
    3. 如果当前项大于最后一项,直接往result中新增一项;
    4. 否则,针对result数组进行二分查找,找到并替换比当前项大的那项。

下图示意图中为了方便理解result存放的是nums中的值,实际代码存放的是数组索引。

拿到数组索引列表

  1. 通过前驱节点重建列表

再创建一个数组preIndexArr。在步骤1往result中新增或者替换新值的时候,同时preIndexArr新增一项,该项为当前项对应的前一项的索引。这样我们有了两个数组:

const result = [1, 3, 4, 6, 7, 9]
const preIndexArr = [undefined, 0, undefined, 1, 3, 4, 4, 6, 1]

返回与前驱节点列表

result的结果是不准确的,但是result的 最后一项是正确的,因为最后一项是最大的,最大的正确率是拉满的。于是我们可知最大一项是值9,索引是7。可查询preIndexArr[7]获得 9 的前一项的索引为6,值为7...依次类推能够重建新的result。

重建列表

通过这样我们就得到了最长递增子序列,此时只需要以此序列为基准,移动在序列外的元素,达到节省性能的目的。

因此,递增序列越长时 vue3 的性能优势越显著。

这样就完成了PatchKeyedChildren比较的过程。

无 key 处理:
源码实现:
const patchUnkeyedChildren = (c1, c2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
  c1 = c1 || shared.EMPTY_ARR;
  c2 = c2 || shared.EMPTY_ARR;
  const oldLength = c1.length;
  const newLength = c2.length;
  const commonLength = Math.min(oldLength, newLength);
  let i;
  // 每次比对新旧节点会调用patch方法
  // 该方法的执行流程是:
  // 1. 如果两个节点一样不做处理
  // 如果节点不同就更新
  for (i = 0; i < commonLength; i++) {
    const nextChild = (c2[i] = optimized
                       ? cloneIfMounted(c2[i])
                       : normalizeVNode(c2[i]));
    patch(c1[i], nextChild, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
  }
  if (oldLength > newLength) {
    // 卸载多余节点
    // remove old
    unmountChildren(c1, parentComponent, parentSuspense, true, false, commonLength);
  }
  else {
    // 创建多余节点
    // mount new
    mountChildren(c2, container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized, commonLength);
  }
};

patchUnkeyedChildren源码实现

diff流程

含有key属性主要用patchKeyedChildren策略进行新旧节点对比。

如果不使用key,Vue会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的策略。

diff流程:
  1. 获取旧的和新的VNode数组(列表)的长度。
  2. Math.min(c1.length,c2.length)获取新旧数组中长度最短的值,然后遍历短的VNode列表。
  3. 相同的就不更新,不同的就更新。
  4. 直到遍历完成:
    1. 旧的VNode数大于新的VNode数,卸载多余节点。
    2. 新的VNode数小于新的VNode数,创建多余节点。

这样就完成了patchUnkeyedChildren比较的过程。

与Vue2 diff的对比

vue2核心diff算法采用的是双端比较算法

  • 双端指针头尾比较
  • 剩余节点旧列表删除或新列表添加

vue3核心diff算法采用的是去头尾的最长递增子序列算法

  • 双端指针头尾比较
  • 最长递增序列

vue2、vue3 的 diff 算法实现差异主要体现在:处理完首尾节点后,对剩余节点的处理方式。

vue2 是通过对旧节点列表建立一个 { key, oldVnode } 的映射表,然后遍历新节点列表的剩余节点,根据newVnode.key在旧映射表中寻找可复用的节点,然后打补丁并且移动到正确的位置。

vue3 则是建立一个存储新节点数组中的剩余节点在旧节点数组上的索引的映射关系数组,建立完成这个数组后也即找到了可复用的节点,然后通过这个数组计算得到最长递增子序列,这个序列中的节点保持不动,然后将新节点数组中的剩余节点移动到正确的位置。

业务思考:

为什么不推荐 index 作为 key

报错警告

在开发中,经常出现警告不要用 index 作为 key ,经过前面源码对学习我们已经得到答案了:

  • 若对数据进行逆序添加、逆序删除等破坏顺序的操作,会出现数组下标变化的场景 index 的变化会导致 vue 认为是同一节点,导致更新异常。
  • 若不存在对数据逆序添加、逆序删除等破坏顺序的操作,则没有问题。
解决方法:

唯一的 ID 作为 key

patchflag 优化判断性能

vue3采用了位运算优化判断性能,因 CPU 中有直接对应位操作的指令,提升判断效率更高。我们在大量判断的场景可以借鉴patchflag的解决方案。

let skills = 0;
const SKILLS = {
 CSS: 1 ,
 JS: 1 << 1,
 HTML: 1 << 2,
 WEB_GL: 1 << 3
}
skills = skills | SKILLS.JS;
if (skills & SKILLS.JS) {
	// CODE
}

位运算枚举

如何理解这段代码?

在 JavaScript 中,整数存储在 4 个字节中,即 32 位。第一个代表正负,后面的31代表数字。当我们用二进制表示 1 , 1 << 2 时,它们看起来像这样:

1 << 2 字节位置

我们定义的枚举变量只有一个二进制格式的 1 ,并且占据不同的位置。当我们添加枚举选项时,我们使用 skills | skill。假设现在我们需要添加的枚举是SKILLS.JS,那么在执行过程中,就是:

skills | skill 时的字节位置

我们可以发现,在skills中,SKILLS.JS 对应的位置会变成 1。

这样我们可以通过查看 skills & SKILLS.JS 的结果是否为0来判断技能中是否存在 SKILLS.CSS 。在业务开发中参考这个技巧优化判断效率。

性能测试:

与传统判断方式的对比

最长递增序列的应用场景

举一个实际中用到的的例子,我之前做相册的排序功能的时候遇到的,每张照片都有一个 index 属性,假设有4张照片,排序时第一张照片挪到最后的情况:

排序前:1,2,3,4

排序后:2,3,4,1

(1) 最简单最笨的方式是要修改四张照片的index值:1->4 , 2->1 , 3->2, 4->3

(2) 最优解应该是只修改第一张照片index值:1->5

备注:假设修改一次 index 是有成本,修改越少次越好。

这个例子是肉眼可见的能找到最优解,但是当数据量大,排序后比较混乱的时候就不是那么容易找到最优解了。

这个时候如果你曾经接触过算法,你就很容易联想到这个问题跟最长公共子序列算法要解决的场景非常契合。

总结:

diff 算法是 vue 用于解决数据更新时操作dom成本过高的解决方案,当数据更新时,用于确定最优的更新视图的策略。diff 在有key时使用了双指针 + 最长递增子序列实现,复杂度为 O(nlogn) 。diff 在没有 key 时会找到短列表比较,再执行新增删除,复杂度为 O(n)

相比于 vue2 的 双端比较算法,两者的实现差异主要体现在:处理完首尾节点后,对剩余节点的处理方式,vue3 diff 相比 vue2 性能更好,在递增序列越长时越显著

what: diff 算法是一种对新旧虚拟 DOM 树进行比较,得出两者差异,确定最优的更新视图的策略算法。

why: 减少渲染真实dom的开销,提高性能。

When: 页面更新,重新渲染时用到。

where: Vue 中当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的 DOM 打补丁。

Who: Vue ( patch、patchKeyedChildren、patchUnkeyedChildren )。

how: 深度优先、同层比较。tag不同,生成树形结构不同。使用key来标识稳定节点。