双端 Diff 算法详解

0 阅读5分钟

在上一篇文章中,我们学习了 Diff 算法的基础原理和 key 的重要性。今天,我们将深入 Vue2 中经典的双端比较算法——这个算法通过四个指针的巧妙移动,实现了高效的节点更新。理解这个算法,不仅有助于掌握Vue2的diff原理,也为理解 Vue3 的更优算法打下基础。

前言:为什么需要双端比较?

我们还是以积木为例,假如我们有这样一排积木:

A B C D

然后我们想把它变成这样:

D A B C

也就是仅仅把 D 提到 A 的前面,如果我们用上一篇文章学的简单 Diff 算法,会怎么做呢?

  1. 比较位置0:A vs D,节点不同,更新为 D
  2. 比较位置1:B vs A,节点不同,更新为 A
  3. 比较位置2:C vs B,节点不同,更新为 B
  4. 比较位置3:D vs C,节点不同,更新为 C

上述 4 次更新操作中,没有复用任何节点。但实际上,这些节点除了顺序变化外,内容根本没有变。我们其实只需要通过移动 DOM 就复用它们,而且只需要移动一次(把 D 移动到 A 前面),就可以达到我们想要的效果。

双端 Diff 的核心思想

四个指针的设计

双端 Diff 算法在旧子节点数组和新子节点数组的两端各设置两个指针:

// 四个指针
let oldStartIdx = 0;              // 旧节点起始索引
let oldEndIdx = oldChildren.length - 1;   // 旧节点结束索引
let newStartIdx = 0;              // 新节点起始索引
let newEndIdx = newChildren.length - 1;    // 新节点结束索引

// 对应的节点
let oldStartVNode = oldChildren[oldStartIdx];
let oldEndVNode = oldChildren[oldEndIdx];
let newStartVNode = newChildren[newStartIdx];
let newEndVNode = newChildren[newEndIdx];

这四个指针的布局如图所示: 四个指针布局图

四种比较情况

双端比较的核心是进行四种比较:

1. 旧开始 vs 新开始

if (isSameVNodeType(oldStartVNode, newStartVNode)) {
  // 节点相同,直接复用
  patch(oldStartVNode, newStartVNode);
  oldStartIdx++;
  newStartIdx++;
}

2. 旧结束 vs 新结束

if (isSameVNodeType(oldEndVNode, newEndVNode)) {
  // 节点相同,直接复用
  patch(oldEndVNode, newEndVNode);
  oldEndIdx--;
  newEndIdx--;
}

3. 旧开始 vs 新结束

if (isSameVNodeType(oldStartVNode, newEndVNode)) {
  // 节点相同,但位置不同,需要移动
  patch(oldStartVNode, newEndVNode);
  // 将旧开始节点移动到旧结束节点之后
  insertBefore(oldStartVNode.el, oldEndVNode.el.nextSibling);
  oldStartIdx++;
  newEndIdx--;
}

4. 旧结束 vs 新开始

if (isSameVNodeType(oldEndVNode, newStartVNode)) {
  // 节点相同,但位置不同,需要移动
  patch(oldEndVNode, newStartVNode);
  // 将旧结束节点移动到旧开始节点之前
  insertBefore(oldEndVNode.el, oldStartVNode.el);
  oldEndIdx--;
  newStartIdx++;
}

通过 key 查找复用

为什么需要key查找?

当四种指标的比较都不匹配时,即非理想状况下,说明节点位置发生了较大变化。这时就需要通过 key 在旧节点中查找可复用的节点,如以下示例:

旧: A - B - C - D
新: C - A - D - B

第1轮比较时,四种指针比较都不匹配。这时就需要通过 key 查找,查找新开始节点 C 在旧节点中的位置,找到位置 2,就移动旧节点的 C 到开始位置。

// 在循环开始前建立key索引表
const keyToOldIndexMap = new Map();
for (let i = 0; i < oldChildren.length; i++) {
  const child = oldChildren[i];
  if (child.key != null) {
    keyToOldIndexMap.set(child.key, i);
  }
}

// 在四种比较都不匹配时使用
const idxInNew = keyToOldIndexMap.get(oldStartVNode.key);
if (idxInNew !== undefined) {
  // 找到了可复用的节点
  const vnodeToMove = newChildren[idxInNew];
  patch(oldStartVNode, vnodeToMove, container);
  // 移动节点
  container.insertBefore(oldStartVNode.el, oldStartVNode.el);
  // 标记该位置已处理
  newChildren[idxInNew] = undefined;
}

key查找的性能影响

场景无key查找有key查找优势
头部插入全量比较直接定位O(n) vs O(1)
节点移动难以复用精确复用减少DOM操作
列表重排性能差性能优差距可达10倍

完整的双端 Diff 实现

class DoubleEndedDiff {
  constructor(options = {}) {
    this.options = options;
  }
  
  /**
   * 执行双端比较
   */
  patchChildren(oldChildren, newChildren, container) {
    
    // 初始化指针
    let oldStartIdx = 0;
    let oldEndIdx = oldChildren.length - 1;
    let newStartIdx = 0;
    let newEndIdx = newChildren.length - 1;
    
    let oldStartVNode = oldChildren[oldStartIdx];
    let oldEndVNode = oldChildren[oldEndIdx];
    let newStartVNode = newChildren[newStartIdx];
    let newEndVNode = newChildren[newEndIdx];
    
    // 创建key索引表
    const keyToOldIndexMap = this.createKeyMap(oldChildren);
    
    // 记录移动次数
    let moveCount = 0;
    let patchCount = 0;
    let mountCount = 0;
    let unmountCount = 0;
    
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 跳过已处理的节点
      if (!oldStartVNode) {
        oldStartVNode = oldChildren[++oldStartIdx];
      } else if (!oldEndVNode) {
        oldEndVNode = oldChildren[--oldEndIdx];
      }
      // 情况1: 旧开始 = 新开始
      else if (this.isSameNode(oldStartVNode, newStartVNode)) {
        this.patch(oldStartVNode, newStartVNode, container);
        oldStartVNode = oldChildren[++oldStartIdx];
        newStartVNode = newChildren[++newStartIdx];
        patchCount++;
      }
      // 情况2: 旧结束 = 新结束
      else if (this.isSameNode(oldEndVNode, newEndVNode)) {
        this.patch(oldEndVNode, newEndVNode, container);
        oldEndVNode = oldChildren[--oldEndIdx];
        newEndVNode = newChildren[--newEndIdx];
        patchCount++;
      }
      // 情况3: 旧开始 = 新结束
      else if (this.isSameNode(oldStartVNode, newEndVNode)) {
        this.patch(oldStartVNode, newEndVNode, container);
        container.insertBefore(
          oldStartVNode.el,
          oldEndVNode.el.nextSibling
        );
        oldStartVNode = oldChildren[++oldStartIdx];
        newEndVNode = newChildren[--newEndIdx];
        moveCount++;
        patchCount++;
      }
      // 情况4: 旧结束 = 新开始
      else if (this.isSameNode(oldEndVNode, newStartVNode)) {
        this.patch(oldEndVNode, newStartVNode, container);
        container.insertBefore(
          oldEndVNode.el,
          oldStartVNode.el
        );
        oldEndVNode = oldChildren[--oldEndIdx];
        newStartVNode = newChildren[++newStartIdx];
        moveCount++;
        patchCount++;
      }
      // 情况5: 都不匹配,通过key查找
      else {
        const idxInOld = keyToOldIndexMap.get(newStartVNode.key);
        
        if (idxInOld !== undefined) {
          const vnodeToMove = oldChildren[idxInOld];
          this.patch(vnodeToMove, newStartVNode, container);
          container.insertBefore(
            vnodeToMove.el,
            oldStartVNode.el
          );
          oldChildren[idxInOld] = undefined;
          moveCount++;
          patchCount++;
        } else {
          this.mount(newStartVNode, container, oldStartVNode.el);
          mountCount++;
        }
        newStartVNode = newChildren[++newStartIdx];
      }
    
    // 处理剩余节点
    if (oldStartIdx > oldEndIdx) {
      for (let i = newStartIdx; i <= newEndIdx; i++) {
        const newVNode = newChildren[i];
        if (newVNode) {
          this.mount(newVNode, container, newChildren[newEndIdx + 1]?.el);
          mountCount++;
        }
      }
    } else if (newStartIdx > newEndIdx) {
      for (let i = oldStartIdx; i <= oldEndIdx; i++) {
        const oldVNode = oldChildren[i];
        if (oldVNode) {
          this.unmount(oldVNode);
          unmountCount++;
        }
      }
    }
  }
  
  /**
   * 创建key索引表
   */
  createKeyMap(children) {
    const map = new Map();
    for (let i = 0; i < children.length; i++) {
      const child = children[i];
      if (child?.key != null) {
        map.set(child.key, i);
      }
    }
    return map;
  }
  
  /**
   * 判断两个节点是否相同
   */
  isSameNode(n1, n2) {
    return n1 && n2 && n1.type === n2.type && n1.key === n2.key;
  }
  
  /**
   * 更新节点
   */
  patch(oldVNode, newVNode, container) {
    if (oldVNode.el) {
      newVNode.el = oldVNode.el;
      if (newVNode.children !== oldVNode.children) {
        newVNode.el.textContent = newVNode.children;
      }
    }
  }
  
  /**
   * 挂载新节点
   */
  mount(vnode, container, anchor) {
    const el = document.createElement(vnode.type);
    vnode.el = el;
    el.textContent = vnode.children;
    if (anchor) {
      container.insertBefore(el, anchor);
    } else {
      container.appendChild(el);
    }
  }
  
  /**
   * 卸载节点
   */
  unmount(vnode) {
    if (vnode.el && vnode.el.parentNode) {
      vnode.el.parentNode.removeChild(vnode.el);
    }
  }
}

源码对标:Vue2的双端 Diff

Vue2 的双端 Diff 算法实现位于 src/core/vdom/patch.js 中:

// Vue2源码中的双端比较(简化版)
function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0;
  let oldEndIdx = oldCh.length - 1;
  let newStartIdx = 0;
  let newEndIdx = newCh.length - 1;
  
  let oldStartVnode = oldCh[oldStartIdx];
  let oldEndVnode = oldCh[oldEndIdx];
  let newStartVnode = newCh[newStartIdx];
  let newEndVnode = newCh[newEndIdx];
  
  let oldKeyToIdx, idxInOld, vnodeToMove;
  
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx];
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode);
      api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode);
      api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
      }
      idxInOld = oldKeyToIdx[newStartVnode.key];
      if (isUndef(idxInOld)) {
        api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
      } else {
        vnodeToMove = oldCh[idxInOld];
        if (sameVnode(vnodeToMove, newStartVnode)) {
          patchVnode(vnodeToMove, newStartVnode);
          oldCh[idxInOld] = undefined;
          api.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
        } else {
          api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm);
        }
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }
  
  if (oldStartIdx > oldEndIdx) {
    // 挂载剩余新节点
  } else if (newStartIdx > newEndIdx) {
    // 卸载剩余旧节点
  }
}

结语

双端比较算法是 Vue2 响应式系统的核心之一,理解它不仅能帮助我们写出更高效的代码,也为理解 Vue3 的更优算法打下基础。虽然 Vue3 采用了新的算法,但双端比较的思想仍然值得我们深入学习。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!