重温一下 Vue2 和 Vue3 Diff 算法的区别

1,055 阅读8分钟

什么是 Diff 算法?

Diff 算法是比较两棵虚拟 DOM 树的变化,并找出最小的更新路径的算法。在 Vue 中,diff 算法用于高效更新 DOM,以确保用户界面响应快速且流畅。

Vue2 的 Diff 算法

在 Vue2 中,diff 算法是基于 snabbdom 实现的。其核心思想是通过同层比较来找到最小更新路径。以下是 Vue2 diff 算法的几个关键步骤:

  1. 同层比较:比较两个虚拟 DOM 树的同一层节点。
  2. 判断节点类型:如果两个节点类型不同,则直接替换整个节点;否则,进一步比较节点的属性和子节点。
  3. 递归比较子节点:对子节点进行递归比较,直到找到最小的更新路径。
  4. 双端比较:使用双端比较策略,从新旧节点列表的两端同时开始比较和更新节点,以减少不必要的 DOM 操作。

Vue2 的 diff 算法主要包含在 src/core/vdom/patch.js 文件中。以下是关键的 patchVnode 函数及其解释:

function patchVnode(oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
  // 1. 如果新旧 vnode 相同,则直接返回
  if (oldVnode === vnode) return;

  const elm = vnode.elm = oldVnode.elm;

  // 2. 更新新 vnode 的数据到旧 vnode 上
  if (isDef(vnode.data)) {
    for (let i = 0; i < cbs.update.length; ++i) {
      cbs.update[i](oldVnode, vnode);
    }
  }

  const oldCh = oldVnode.children;
  const ch = vnode.children;
  // 3. 更新子节点
  if (isDef(ch) && isDef(oldCh)) {
    if (ch !== oldCh) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
  } else if (isDef(ch)) {
    // 4. 如果旧节点没有子节点而新节点有,则新增子节点
    if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '');
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
  } else if (isDef(oldCh)) {
    // 5. 如果旧节点有子节点而新节点没有,则移除子节点
    removeVnodes(elm, oldCh, 0, oldCh.length - 1);
  }
}

Vue2 的双端比较策略主要体现在对子节点的更新上,以下是 updateChildren 函数的关键部分:

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  let oldStartIdx = 0;
  let newStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let newEndIdx = newCh.length - 1;
  let oldStartVnode = oldCh[0];
  let oldEndVnode = oldCh[oldEndIdx];
  let newStartVnode = newCh[0];
  let newEndVnode = newCh[newEndIdx];

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
   //  因为暴力对比过程把移动的 vnode 置为 undefined,如果不存在 vnode 节点直接跳过 
    if (!oldStartVnode) {
      oldStartVnode = oldCh[++oldStartIdx]; // 跳过 undefined 节点
    } else if (!oldEndVnode) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
     // 头和头对比,依次向后追加
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
      // 递归比较儿子以及它们的子节点 
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 尾和尾对比,依次向前追加 
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
      
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      // 老的头和新的尾相同,把老的头部移动到尾部 
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
    // 老的尾和新的头相同,把老的尾部移动到头部 
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      // 都没有匹配的情况,需要通过 key 去查找或者插入新节点
      // ...
    }
  }

  // 处理剩余的节点
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      // 新增节点
      addVnodes(parentElm, null, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else {
      // 删除节点
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}

一些关键代码解释
  • 节点比较: if (oldVnode === vnode) return;:首先判断新旧 vnode 是否相同,如果相同则直接返回,不进行更新操作。

  • 数据更新if (isDef(vnode.data)) { ... }:如果新 vnode 有数据,依次调用更新钩子函数。

  • 子节点更新updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly):更新子节点,详细比较和更新子节点的变化。

  • 子节点添加addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue):如果旧节点没有子节点但新节点有,则新增子节点。

  • 子节点移除removeVnodes(elm, oldCh, 0, oldCh.length - 1):如果旧节点有子节点但新节点没有,则移除旧节点的子节点。

  • 双端比较 : 1.头和头对比,依次向后追加 2.尾和尾对比,依次向前追加 3.老的头和新的尾相同,把老的头部移动到尾部 4.老的尾和新的头相同,把老的尾部移动到头部

Vue2 执行 diff 算法简单的示例说明
// 假设旧节点
const oldVnode = h('div', null, [
  h('ul', null, [
    h('li', { key: 'a' }, 'Item A'),
    h('li', { key: 'b' }, 'Item B'),
    h('li', { key: 'c' }, 'Item C'),
  ]),
  h('MyComponent', { key: 'comp' }, [
    h('p', null, 'Hello'),
    h('span', null, 'World')
  ])
]);

// 假设新节点
const newVnode = h('div', null, [
  h('ul', null, [
    h('li', { key: 'a' }, 'Item A'),
    h('li', { key: 'c' }, 'Updated Item C'),
    h('li', { key: 'd' }, 'Item D'),
  ]),
  h('MyComponent', { key: 'comp' }, [
    h('p', null, 'Hello'),
    h('span', null, 'Vue')
  ])
]);

// 执行双端比较更新
patch(oldVnode, newVnode);


在这个例子中,Vue2 的 diff 算法会进行以下步骤:

  1. 比较根节点 ul:发现类型相同,继续比较其属性和子节点。

  2. 列表更新:在 ul 列表中,Vue2 会从两端开始比较旧节点和新节点的差异。例如,旧节点的 Item B 没有对应的新节点,需要删除;新节点的 Item D 是新增的,需要插入。

  3. 组件更新:对于自定义组件 MyComponent,Vue2 同样从两端开始比较。这里的 span 内容由 World 更新为 Vue,需要进行更新操作。

  4. 性能影响:虽然 Vue2 使用了双端比较策略优化了部分比较过程,但对于复杂的结构和频繁的数据更新,仍可能引起一些不必要的性能损耗,特别是在列表较长或嵌套层级深的情况下。

Vue3 Diff 算法

在 Vue3 中,diff 算法大致思路和vue2 差不多,细节方面做了一些优化。

关键改进
  • Fiber 架构:将整个 Diff 过程划分为多个阶段,每个阶段完成特定工作,这样可以实现更高效的异步更新。

  • 更新算法优化:Vue3 在比较算法和节点管理上进行了改进,例如采用了更快速的算法来查找匹配的节点,减少了不必要的比较操作。

  • 动态规划:在某些情况下,使用动态规划来找到最小的更新路径。

  • PatchFlag:引入 PatchFlag 标记,帮助快速确定节点的变化类型,从而减少不必要的比较。简单来说就是 对于不参与更新的元素,做静态标记并提示,只会被创建一次,在染时直接复用。

Vue3 的 diff 算法主要包含在 packages/runtime-core/src/renderer.ts 文件中。以下是 patchKeyedChildren 函数的关键部分及其解释:

const patchKeyedChildren = (
  c1: VNode[],
  c2: VNode[],
  container: RendererElement,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1;
  let e2 = l2 - 1;

  // 从头部同步
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
    } else {
      break;
    }
    i++;
  }

  // 从尾部同步
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
    } else {
      break;
    }
    e1--;
    e2--;
  }

  // 双端比较处理
  if (i > e1) {
    // 新节点多于旧节点
    while (i <= e2) {
      patch(null, c2[i], container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      i++;
    }
  } else if (i > e2) {
    // 旧节点多于新节点
    while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true);
      i++;
    }
  } else {
    // 复杂情况:新旧节点中间部分不匹配
    const oldKeyToIndexMap = new Map();
    for (let j = i; j <= e1; j++) {
      const key = c1[j].key;
      if (key != null) {
        oldKeyToIndexMap.set(key, j);
      }
    }
    let patched = 0;
    const toBePatched = e2 - i + 1;
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
    for (let j = i; j <= e2; j++) {
      const newVNode = c2[j];
      const oldIndex = oldKeyToIndexMap.get(newVNode.key);
      if (oldIndex != null) {
        patch(c1[oldIndex], newVNode, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
        newIndexToOldIndexMap[j - i] = oldIndex + 1;
        patched++;
      } else {
        patch(null, newVNode, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
      }
    }
    // 移除多余的旧节点
    for (let j = i; j <= e1; j++) {
      if (newIndexToOldIndexMap.indexOf(j + 1) === -1) {
        unmount(c1[j], parentComponent, parentSuspense, true);
      }
    }
  }
};


代码解析

  1. 从头部同步while (i <= e1 && i <= e2):从头部开始比较新旧节点,直到找到第一个不同节点。

  2. 从尾部同步while (i <= e1 && i <= e2):从尾部开始比较新旧节点,直到找到第一个不同节点。

  3. 处理新增节点if (i > e1):如果旧节点已经比较完,但新节点还有剩余,则将剩余的新节点添加到 DOM 中。

  4. 处理删除节点if (i > e2):如果新节点已经比较完,但旧节点还有剩余,则将剩余的旧节点从 DOM 中移除。

  5. 未知序列: 构建新节点的 key:index 映射表。 遍历旧节点,尝试找到

Vue3 执行 diff 算法简单的示例说明
// 假设旧节点
// 旧虚拟 DOM
const oldVNode = {
  tag: 'div',
  patchFlag: 1, // PatchFlag for TEXT
  children: [
    { tag: 'p', text: 'Hello' }
  ]
};

// 新虚拟 DOM
const newVNode = {
  tag: 'div',
  patchFlag: 1, // PatchFlag for TEXT
  children: [
    { tag: 'p', text: 'Hi' }
  ]
};


// 执行双端比较更新
patch(oldVnode, newVnode);

在这个例子中,Vue3 的 diff 算法将通过 PatchFlag 快速确定 p 节点的文本内容发生了变化,从而仅更新文本部分,提升更新效率。

总结

通过这些详细的示例和解释,们可以清楚地了解到 Vue2 和 Vue3 在 Virtual DOM 更新过程中的整体流程和优化点。Vue3 在继承了 Vue2 的基础上,通过优化算法和数据结构,使得在大规模数据更新和复杂视图场景下,能够提供更出色的性能和响应速度,从而改善用户体验。