深度剖析 Vue2、Vue3、React 的 Diff 算法:从原理到实战

0 阅读22分钟

深度剖析 Vue2、Vue3、React 的 Diff 算法:从原理到实战

三大前端框架的 Diff 算法各有千秋,Vue2 的双端比较、Vue3 的最长递增子序列、React 的 Fiber 协调,本文通过完整代码示例,带你彻底搞懂它们的核心原理与 Map 的关键应用。

前言

在虚拟 DOM 的世界里,Diff 算法是性能的核心。当数据变化时,如何以最小的代价更新真实 DOM?三大框架给出了三种不同的答案:

  • Vue2:双端比较算法(Double-Ended Diff)
  • Vue3:最长递增子序列算法(LIS)+ 快速路径
  • React:Fiber 架构 + 单向遍历比较

本文将通过 手写代码示例,深入剖析三者的实现原理,并重点讲解 Map 数据结构 在 Diff 算法中的关键作用。

核心概念:为什么需要 Diff?

虚拟 DOM 的本质

虚拟 DOM 是真实 DOM 的 JavaScript 对象表示:

// 真实 DOM
<div id="app" class="container">
  <p>Hello World</p>
</div>

// 虚拟 DOM
const vnode = {
  type: 'div',
  props: { id: 'app', class: 'container' },
  children: [
    { type: 'p', props: {}, children: 'Hello World' }
  ]
}

Diff 的目标

当数据变化生成新的虚拟 DOM 树时,需要:

  1. 找出变化的部分:哪些节点新增、删除、移动、更新
  2. 最小化操作:用最少的 DOM 操作完成更新
  3. 高效比较:时间复杂度尽可能低

理想情况下,对比两棵树的差异需要 O(n³) 时间复杂度,但三大框架都做了优化,将其降至 O(n) 或接近 O(n)。


一、Vue2 的双端比较算法

核心思想

Vue2 采用 双端比较(Double-Ended Comparison),同时从新旧子节点数组的两端开始比较,通过四种比较方式减少移动操作。

旧节点列表:[A, B, C, D]
新节点列表:[A, C, B, E]

比较过程:
  旧头 ←→ 新头
  旧尾 ←→ 新尾
  旧头 ←→ 新尾
  旧尾 ←→ 新头

四种比较策略

function updateChildren(oldCh, newCh, parentElm) {
  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];

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isSameVnode(oldStartVnode, newStartVnode)) {
      // 情况1:旧头 == 新头 → 不移动,继续比较下一对
      patchVnode(oldStartVnode, newStartVnode);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      // 情况2:旧尾 == 新尾 → 不移动,继续比较前一对
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 情况3:旧头 == 新尾 → 移动到末尾
      patchVnode(oldStartVnode, newEndVnode);
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 情况4:旧尾 == 新头 → 移动到开头
      patchVnode(oldEndVnode, newStartVnode);
      parentElm.insertBefore(oldEndVnode.elm, oldCh[oldStartIdx].elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      // 情况5:以上都不匹配 → 使用 key 查找
      // 创建旧节点 key -> index 的映射
      const idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
      if (idxInOld) {
        // 找到了:移动到正确位置
        const vnodeToMove = oldCh[idxInOld];
        patchVnode(vnodeToMove, newStartVnode);
        oldCh[idxInOld] = undefined;
        parentElm.insertBefore(vnodeToMove.elm, oldStartVnode.elm);
      } else {
        // 没找到:创建新节点
        createElm(newStartVnode, parentElm, oldStartVnode.elm);
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }

  // 处理剩余节点
  if (oldStartIdx > oldEndIdx) {
    // 旧节点处理完了,新节点还有剩余 → 批量新增
    const refElm = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
  } else if (newStartIdx > newEndIdx) {
    // 新节点处理完了,旧节点还有剩余 → 批量删除
    removeVnodes(oldCh, oldStartIdx, oldEndIdx);
  }
}

// 判断是否为同一节点
function isSameVnode (a, b) {
  return (
    a.key === b.key &&
    a.asyncFactory === b.asyncFactory && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

图解双端比较

初始状态:
旧: [A, B, C, D]
    ↑           ↑
  oldStart   oldEnd

新: [A, C, B, E]
    ↑           ↑
  newStart   newEnd

步骤1: oldStart(A) == newStart(A) → 匹配,不移动
旧: [A, B, C, D]
       ↑        ↑
旧: [A, C, B, E]
       ↑        ↑

步骤2: oldStart(B) != newStart(C),检查其他三种情况
       oldEnd(D) != newEnd(E)
       oldStart(B) != newEnd(E)
       oldEnd(D) == newStart(D)? → 不匹配
       → 进入 key 查找

步骤3: C 在旧节点中存在(索引2),移动到 newStart 位置
旧: [A, B, C, D]
       ↑     ↑
新: [A, C, B, E]
          ↑     ↑

最终结果:
- A 位置不变
- C 从位置2移动到位置1
- B 从位置1移动到位置2
- D 被删除
- E 被新增

Vue2 Diff 算法的局限

  1. key 查找效率低:使用 findIdxInOld 线性查找,O(n) 复杂度
  2. 移动次数可能不是最少:双端比较不一定找到最优解
  3. 无法处理复杂场景:乱序移动时性能下降

二、Vue3 的 Diff 算法:LIS + 快速路径

核心改进

Vue3 在 Diff 算法上做了重大升级:

  1. 快速路径判断:首尾相同节点快速跳过
  2. Map 索引优化:使用 Map 存储 key → index 映射,O(1) 查找
  3. 最长递增子序列:计算最小移动次数

完整实现

function diffChildren(n1, n2, container) {
  const oldChildren = n1.children;
  const newChildren = n2.children;

  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];

  // ========== 第一阶段:快速路径 ==========
  // 从头部开始同步
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode.key === newStartVnode.key) {
      patch(oldStartVnode, newStartVnode, container);
      oldStartVnode = oldChildren[++oldStartIdx];
      newStartVnode = newChildren[++newStartIdx];
    } else {
      break;
    }
  }

  // 从尾部开始同步
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldEndVnode.key === newEndVnode.key) {
      patch(oldEndVnode, newEndVnode, container);
      oldEndVnode = oldChildren[--oldEndIdx];
      newEndVnode = newChildren[--newEndIdx];
    } else {
      break;
    }
  }

  // ========== 第二阶段:简单情况处理 ==========
  // 新增节点
  if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
    const refElm = newChildren[newEndIdx + 1]?.elm;
    while (newStartIdx <= newEndIdx) {
      mount(newChildren[newStartIdx++], container, refElm);
    }
  }
  // 删除节点
  else if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
    while (oldStartIdx <= oldEndIdx) {
      unmount(oldChildren[oldStartIdx++]);
    }
  }
  // ========== 第三阶段:复杂情况处理 ==========
  else {
    // 3.1 构建 key -> index 的 Map(核心优化!)
    const keyToNewIndexMap = new Map();
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      keyToNewIndexMap.set(newChildren[i].key, i);
    }

    // 3.2 遍历旧节点,更新可复用节点,标记待删除节点
    const toBePatched = newEndIdx - newStartIdx + 1;
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0);

    let moved = false;
    let maxNewIndexSoFar = 0;

    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      const oldChild = oldChildren[i];
      const newIndex = keyToNewIndexMap.get(oldChild.key);

      if (newIndex !== undefined) {
        // 找到可复用节点
        patch(oldChild, newChildren[newIndex], container);

        newIndexToOldIndexMap[newIndex - newStartIdx] = i + 1;

        // 检测是否有移动
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex;
        } else {
          moved = true;
        }
      } else {
        // 旧节点在新列表中不存在,删除
        unmount(oldChild);
      }
    }

    // 3.3 计算最长递增子序列,确定移动策略
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : [];

    let j = increasingNewIndexSequence.length - 1;

    // 3.4 从后向前遍历,移动和新增节点
    for (let i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = newStartIdx + i;
      const nextChild = newChildren[nextIndex];
      const refElm = newChildren[nextIndex + 1]?.elm;

      if (newIndexToOldIndexMap[i] === 0) {
        // 新节点,需要挂载
        mount(nextChild, container, refElm);
      } else if (moved) {
        // 需要移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          move(nextChild, container, refElm);
        } else {
          j--;
        }
      }
    }
  }
}

最长递增子序列算法

/**
 * 最长递增子序列(LIS)
 * 返回最长递增子序列的索引数组
 * 时间复杂度:O(n log n)
 */
function getSequence(arr) {
  const result = [0];
  const p = arr.slice(); // 存放前驱索引
  const len = arr.length;

  for (let i = 0; i < len; i++) {
    const arrI = arr[i];
    if (arrI === 0) continue;

    // 当前元素大于结果数组最后一个元素,直接追加
    const lastResultIdx = result[result.length - 1];
    if (arr[lastResultIdx] < arrI) {
      p[i] = lastResultIdx;
      result.push(i);
      continue;
    }

    // 二分查找,找到第一个大于等于 arrI 的位置
    let left = 0, right = result.length - 1;
    while (left < right) {
      const mid = (left + right) >> 1;
      if (arr[result[mid]] < arrI) {
        left = mid + 1;
      } else {
        right = mid;
      }
    }

    if (arrI < arr[result[left]]) {
      if (left > 0) {
        p[i] = result[left - 1];
      }
      result[left] = i;
    }
  }

  // 回溯找到最长递增子序列
  let resultLen = result.length;
  let lastIdx = result[resultLen - 1];
  while (resultLen-- > 0) {
    result[resultLen] = lastIdx;
    lastIdx = p[lastIdx];
  }

  return result;
}

LIS 图解

旧节点:[A, B, C, D, E]
新节点:[A, C, D, B, E]

经过前面的快速路径处理后:
old: [B, C, D]  (索引 1-3)
new: [C, D, B]  (索引 1-3)

构建 newIndexToOldIndexMapnewIndex:     0    1    2
new节点:      C    D    B
old索引+1:    2    3    1

newIndexToOldIndexMap = [2, 3, 1]

计算 LIS:
序列 [2, 3, 1] 的最长递增子序列是 [2, 3]
对应索引 [0, 1]

这意味着 CD 不需要移动,只需移动 B

最终操作:
- C 位置不变
- D 位置不变
- B 移动到末尾

Vue3 Diff 的优势

特性Vue2Vue3
key 查找线性查找 O(n)Map 查找 O(1)
移动策略双端比较,可能多余移动LIS 保证最少移动
预处理快速路径跳过首尾相同节点
时间复杂度O(n²) 最坏情况O(n log n)

三、React 的 Diff 算法:Fiber 协调

核心思想

React 采用 Fiber 架构,将 Diff 过程拆分成可中断的小任务:

  1. 单链表比较:新旧 Fiber 节点通过链表连接,单向遍历
  2. key + elementType 判断:使用 key 和元素类型判断可复用性
  3. 优先级调度:高优先级任务可打断低优先级任务

React Diff 的三个层级

┌─────────────────────────────────────────────────────┐
│                    Tree Diff                        │
│    比较树层级,同层比较,不跨层级移动节点           │
└─────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────┐
│                   Component Diff                     │
│    比较组件类型,相同类型复用,不同类型重建         │
└─────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────┐
│                    Element Diff                      │
│    比较元素节点,key 相同则复用,否则新建           │
└─────────────────────────────────────────────────────┘

Element Diff 实现

/**
 * React Reconciler 中的子节点 Diff
 * 简化版实现
 */
function reconcileChildrenArray(
  returnFiber,
  currentFirstChild,
  newChildren,
  lanes
) {
  // 第一轮遍历:处理更新的节点
  let oldFiber = currentFirstChild;
  let newIdx = 0;
  let resultingFirstChild = null;
  let previousNewFiber = null;

  // 用于记录被删除的 Fiber
  const existingChildren = new Map();

  // ========== 第一阶段:顺序比较 ==========
  for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
    const newChild = newChildren[newIdx];

    // key 或类型不匹配,跳出循环
    if (!isSameType(oldFiber, newChild)) {
      break;
    }

    // 复用 Fiber
    const newFiber = useFiber(oldFiber, newChild, returnFiber);
    newFiber.flags |= Update;

    // 构建新 Fiber 链表
    if (previousNewFiber === null) {
      resultingFirstChild = newFiber;
    } else {
      previousNewFiber.sibling = newFiber;
    }
    previousNewFiber = newFiber;

    oldFiber = oldFiber.sibling;
  }

  // ========== 第二阶段:处理新增节点 ==========
  if (oldFiber === null) {
    // 旧节点已处理完,剩余新节点全部新增
    for (; newIdx < newChildren.length; newIdx++) {
      const newChild = newChildren[newIdx];
      const newFiber = createFiberFromElement(newChild, returnFiber);

      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
    return resultingFirstChild;
  }

  // ========== 第三阶段:处理删除和移动 ==========
  // 构建 key -> Fiber 的 Map(核心优化!)
  let existingChild = oldFiber;
  while (existingChild !== null) {
    const key = existingChild.key !== null ? existingChild.key : existingChild.index;
    existingChildren.set(key, existingChild);
    existingChild = existingChild.sibling;
  }

  // 在 Map 中查找可复用节点
  for (; newIdx < newChildren.length; newIdx++) {
    const newChild = newChildren[newIdx];
    const key = newChild.key !== null ? newChild.key : newIdx;

    const matchedFiber = existingChildren.get(key);

    if (matchedFiber) {
      // 找到可复用节点
      const newFiber = useFiber(matchedFiber, newChild, returnFiber);
      newFiber.flags |= Placement; // 标记需要移动

      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;

      // 从 Map 中移除,表示已复用
      existingChildren.delete(key);
    } else {
      // 未找到,创建新节点
      const newFiber = createFiberFromElement(newChild, returnFiber);
      newFiber.flags |= Placement;

      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
    }
  }

  // ========== 第四阶段:删除未复用的节点 ==========
  existingChildren.forEach((child) => {
    child.flags |= Deletion;
  });

  return resultingFirstChild;
}

// 判断是否为相同类型节点
function isSameType(oldFiber, newElement) {
  return (
    oldFiber.key === newElement.key &&
    oldFiber.elementType === newElement.type
  );
}

React Diff 流程图

Fiber 链表:
A -> B -> C -> D -> E

新子节点数组:
[A, D, C, F]

步骤1: 顺序比较
  A 匹配 → 复用,继续
  B != D → 跳出循环

步骤2: 构建 Map
  Map { B -> B Fiber, C -> C Fiber, D -> D Fiber, E -> E Fiber }

步骤3: 继续处理新节点
  DMap 中 → 复用,标记 Placement
  CMap 中 → 复用,标记 Placement
  F 不在 Map 中 → 新建

步骤4: 删除未复用节点
  BE 未被复用 → 标记 Deletion

最终操作:
- A 位置不变
- D 移动
- C 移动
- F 新增
- BE 删除

React 的局限与优化

局限

  • 无法像 Vue3 的 LIS 那样保证最少移动次数
  • 所有复用节点都标记 Placement,即使位置未变

React 团队的考量

"React 的 Diff 算法不一定是最优的,但它足够快,而且足够简单。"

React 选择简单可预测的策略,而不是复杂的数学优化,这是性能与复杂度的权衡。


四、Map 在 Diff 算法中的核心作用

为什么需要 Map?

在 Diff 过程中,最耗时的操作是 查找旧节点在新列表中的位置

线性查找(Vue2)

// vue2 中使用的是普通对象,使用旧节点来创建,维护的是 oldKey: oldIndex
// 然后根据新节点的key查找旧节点
function createKeyToOldIdx (children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}


// 时间复杂度 O(n)
function findIdxInOld(node, oldCh, start, end) {
  for (let i = start; i <= end; i++) {
    if (oldCh[i].key === node.key) return i;
  }
  return undefined;
}

Map 查找(Vue3/React)

// 时间复杂度 O(1),vue3中使用新节点创建,同时改用 Map 创建
const keyToIndexMap = new Map();
for (let i = 0; i < newChildren.length; i++) {
  keyToIndexMap.set(newChildren[i].key, i);
}

const oldIndex = keyToIndexMap.get(oldNode.key); // O(1)

性能对比

假设有 1000 个子节点:

操作线性查找Map 查找
查找一次O(n) ≈ 500 次比较O(1) ≈ 1 次查找
查找 n 次O(n²) ≈ 500,000 次O(n) ≈ 1000 次
内存消耗无额外空间O(n) 额外空间

结论:Map 用空间换时间,将 O(n²) 降至 O(n),性能提升显著。

Vue3 中的 Map 使用

// Vue3 源码片段
const keyToNewIndexMap = new Map();

// 构建 Map:key -> newIndex,维护的是 newKey: newIndex
// 然后根据旧节点的key查找新节点
for (let i = newStartIdx; i <= newEndIdx; i++) {
  const nextChild = newChildren[i];
  if (nextChild.key != null) {
    keyToNewIndexMap.set(nextChild.key, i);
  }
}

// 使用 Map 快速查找
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
  const oldChild = oldChildren[i];
  const newIndex = keyToNewIndexMap.get(oldChild.key);
  // ...
}

React 中的 Map 使用

// react 中使用的是 Map ,同时使用的是旧节点创建map对象。维护的是 oldKey: oldFiber
// 然后根据新节点的key查找旧节点
function mapRemainingChildren(
    returnFiber: Fiber,
    currentFirstChild: Fiber,
  ): Map<string | number, Fiber> {
    const existingChildren: Map<string | number, Fiber> = new Map();

    let existingChild = currentFirstChild;
    while (existingChild !== null) {
      if (existingChild.key !== null) {
        existingChildren.set(existingChild.key, existingChild);
      } else {
        existingChildren.set(existingChild.index, existingChild);
      }
      existingChild = existingChild.sibling;
    }
    return existingChildren;
  }


// 构建 Map:key -> oldFiber
let existingChild = currentFirstChild;
while (existingChild !== null) {
  const key = existingChild.key;
  existingChildren.set(key, existingChild);
  existingChild = existingChild.sibling;
}

// 使用 Map 查找可复用节点
const matchedFiber = existingChildren.get(newChild.key);

Map 的最佳实践

// ✅ 正确:为每个子节点设置唯一 key
<ul>
  <li key="apple">Apple</li>
  <li key="banana">Banana</li>
  <li key="orange">Orange</li>
</ul>

// ❌ 错误:使用 index 作为 key
<ul>
  <li key={0}>Apple</li>  {/* 当列表顺序变化时,key 失去意义 */}
  <li key={1}>Banana</li>
  <li key={2}>Orange</li>
</ul>

// ❌ 错误:不设置 key(默认使用 index)
<ul>
  <li>Apple</li>
  <li>Banana</li>
  <li>Orange</li>
</ul>

为什么不能用 index 作为 key?

// 场景:在列表开头插入新元素
const oldList = ['B', 'C', 'D'];
const newList = ['A', 'B', 'C', 'D'];

// 使用 index 作为 key:
// oldList: B(key:0), C(key:1), D(key:2)
// newList: A(key:0), B(key:1), C(key:2), D(key:3)

// Diff 结果:
// key:0 → B 变成 A(更新)
// key:1 → C 变成 B(更新)
// key:2 → D 变成 C(更新)
// key:3 → 新增 D
// 总共:3 次更新 + 1 次新增

// 使用唯一 key:
// oldList: B(key:B), C(key:C), D(key:D)
// newList: A(key:A), B(key:B), C(key:C), D(key:D)

// Diff 结果:
// key:A → 新增
// key:B, C, D → 位置移动
// 总共:1 次新增 + 3 次移动(无更新)

五、三大框架 Diff 算法对比总结

核心差异一览表

特性Vue2Vue3React
算法名称双端比较LIS + 快速路径Fiber 单向遍历
比较方向四端同时比较首尾快速路径 + Map 遍历单向链表遍历
key 查找线性查找 O(n)Map O(1)Map O(1)
移动策略双端匹配移动最长递增子序列全部标记移动
时间复杂度O(n²) 最坏O(n log n)O(n)
可中断是(Fiber)
最优移动

算法流程对比

┌──────────────────────────────────────────────────────────────────┐
│                        Vue2 双端比较                              │
├──────────────────────────────────────────────────────────────────┤
│  旧头 ──→ 新头  匹配? → patch,指针前进                          │
│  旧尾 ──→ 新尾  匹配? → patch,指针后退                          │
│  旧头 ──→ 新尾  匹配? → patch + 移动到末尾                       │
│  旧尾 ──→ 新头  匹配? → patch + 移动到开头                       │
│  以上都不匹配 → 线性查找 key                                      │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│                        Vue3 LIS 算法                              │
├──────────────────────────────────────────────────────────────────┤
│  1. 头部同步:从头匹配相同节点                                    │
│  2. 尾部同步:从尾匹配相同节点                                    │
│  3. 构建 Map:key → newIndex                                     │
│  4. 遍历旧节点:patch 可复用节点,删除废弃节点                    │
│  5. 计算 LIS:找出不需要移动的最长序列                            │
│  6. 移动/新增:反向遍历,LIS 外的节点移动/新增                    │
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│                        React Fiber Diff                           │
├──────────────────────────────────────────────────────────────────┤
│  1. 顺序比较:从头遍历,匹配相同 key                              │
│  2. 构建 Map:key → oldFiber                                     │
│  3. 遍历新节点:从 Map 查找可复用 Fiber                           │
│  4. 标记操作:Placement(新增/移动)、Deletion(删除)            │
│  5. 批量更新:根据 flags 执行 DOM 操作                            │
└──────────────────────────────────────────────────────────────────┘

适用场景分析

Vue2 双端比较

  • 适合简单列表更新场景
  • 首尾移动操作较多时效率高
  • 乱序复杂场景性能下降

Vue3 LIS 算法

  • 大型列表性能最优
  • 复杂移动场景保证最少操作
  • 空间换时间,内存占用稍高

React Fiber

  • 支持可中断渲染
  • 高优先级任务可打断低优先级
  • 算法简单,但移动效率不如 LIS

六、实战 Demo:手写一个简化版 Diff

下面我们实现一个简化版的 Diff 算法,整合 Vue3 的核心思想。

完整代码示例

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Diff 算法可视化演示</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      padding: 40px 20px;
      color: #fff;
    }
    .container {
      max-width: 1200px;
      margin: 0 auto;
    }
    h1 {
      text-align: center;
      margin-bottom: 20px;
      font-size: 32px;
      background: linear-gradient(90deg, #4fc3f7, #f06292);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
    .subtitle {
      text-align: center;
      color: rgba(255,255,255,0.6);
      margin-bottom: 40px;
    }
    .demo-section {
      background: rgba(255,255,255,0.05);
      border-radius: 16px;
      padding: 30px;
      margin-bottom: 30px;
      border: 1px solid rgba(255,255,255,0.1);
    }
    .demo-title {
      font-size: 20px;
      margin-bottom: 20px;
      color: #4fc3f7;
    }
    .controls {
      display: flex;
      gap: 20px;
      margin-bottom: 30px;
      flex-wrap: wrap;
    }
    .control-group {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    .control-group label {
      font-size: 14px;
      color: rgba(255,255,255,0.7);
    }
    .control-group input, .control-group select {
      padding: 10px 15px;
      border-radius: 8px;
      border: 1px solid rgba(255,255,255,0.2);
      background: rgba(255,255,255,0.1);
      color: #fff;
      font-size: 14px;
      min-width: 200px;
    }
    .btn {
      padding: 12px 24px;
      border-radius: 8px;
      border: none;
      cursor: pointer;
      font-size: 14px;
      font-weight: 600;
      transition: all 0.3s;
    }
    .btn-primary {
      background: linear-gradient(135deg, #4fc3f7, #2196f3);
      color: #fff;
    }
    .btn-primary:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 20px rgba(79, 195, 247, 0.4);
    }
    .btn-danger {
      background: linear-gradient(135deg, #f44336, #e91e63);
      color: #fff;
    }
    .visualization {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 30px;
      margin-bottom: 30px;
    }
    .list-container {
      background: rgba(0,0,0,0.2);
      border-radius: 12px;
      padding: 20px;
    }
    .list-title {
      font-size: 16px;
      margin-bottom: 15px;
      color: rgba(255,255,255,0.8);
    }
    .list {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      min-height: 60px;
    }
    .item {
      padding: 12px 20px;
      border-radius: 8px;
      font-weight: 600;
      font-size: 14px;
      min-width: 60px;
      text-align: center;
      transition: all 0.3s;
      position: relative;
    }
    .item-old {
      background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
    }
    .item-new {
      background: linear-gradient(135deg, #4ecdc4, #44a08d);
    }
    .item-created {
      background: linear-gradient(135deg, #a8e063, #56ab2f);
      animation: pulse 0.5s ease;
    }
    .item-deleted {
      background: linear-gradient(135deg, #667, #445);
      opacity: 0.5;
      text-decoration: line-through;
    }
    .item-moved {
      border: 2px solid #f39c12;
      animation: shake 0.3s ease;
    }
    .item-key {
      position: absolute;
      top: -8px;
      right: -8px;
      background: #333;
      color: #fff;
      font-size: 10px;
      padding: 2px 6px;
      border-radius: 4px;
    }
    @keyframes pulse {
      0% { transform: scale(1); }
      50% { transform: scale(1.1); }
      100% { transform: scale(1); }
    }
    @keyframes shake {
      0%, 100% { transform: translateX(0); }
      25% { transform: translateX(-5px); }
      75% { transform: translateX(5px); }
    }
    .operations {
      background: rgba(0,0,0,0.3);
      border-radius: 12px;
      padding: 20px;
      margin-top: 20px;
    }
    .operations-title {
      font-size: 16px;
      margin-bottom: 15px;
      color: #f39c12;
    }
    .operation-list {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    .operation {
      padding: 10px 15px;
      border-radius: 6px;
      font-size: 13px;
      font-family: monospace;
    }
    .op-create { background: rgba(168, 224, 99, 0.2); border-left: 3px solid #a8e063; }
    .op-delete { background: rgba(244, 67, 54, 0.2); border-left: 3px solid #f44336; }
    .op-move { background: rgba(243, 156, 18, 0.2); border-left: 3px solid #f39c12; }
    .op-update { background: rgba(79, 195, 247, 0.2); border-left: 3px solid #4fc3f7; }
    .algorithm-comparison {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 20px;
    }
    .algorithm-card {
      background: rgba(255,255,255,0.05);
      border-radius: 12px;
      padding: 20px;
      border: 1px solid rgba(255,255,255,0.1);
    }
    .algorithm-card h3 {
      color: #4fc3f7;
      margin-bottom: 15px;
      font-size: 18px;
    }
    .algorithm-card .feature {
      display: flex;
      justify-content: space-between;
      padding: 8px 0;
      border-bottom: 1px solid rgba(255,255,255,0.1);
      font-size: 13px;
    }
    .algorithm-card .feature:last-child {
      border-bottom: none;
    }
    .feature-label { color: rgba(255,255,255,0.7); }
    .feature-value { color: #4fc3f7; font-weight: 600; }
    .code-block {
      background: rgba(0,0,0,0.4);
      border-radius: 8px;
      padding: 20px;
      margin-top: 20px;
      overflow-x: auto;
      font-family: 'Fira Code', monospace;
      font-size: 13px;
      line-height: 1.6;
    }
    .code-block pre {
      margin: 0;
      color: rgba(255,255,255,0.9);
    }
    .comment { color: #6a9955; }
    .keyword { color: #569cd6; }
    .function { color: #dcdcaa; }
    .string { color: #ce9178; }
    .number { color: #b5cea8; }
  </style>
</head>
<body>
  <div class="container">
    <h1>Diff 算法可视化演示</h1>
    <p class="subtitle">深入理解 Vue2、Vue3、React 的 Diff 算法原理</p>

    <!-- 可视化演示区 -->
    <div class="demo-section">
      <h2 class="demo-title">交互式 Diff 演示</h2>
      <div class="controls">
        <div class="control-group">
          <label>旧列表(逗号分隔)</label>
          <input type="text" id="oldList" value="A,B,C,D,E" placeholder="输入节点,如 A,B,C,D">
        </div>
        <div class="control-group">
          <label>新列表(逗号分隔)</label>
          <input type="text" id="newList" value="A,C,B,F,D" placeholder="输入节点,如 A,C,B,D">
        </div>
        <div class="control-group">
          <label>算法选择</label>
          <select id="algorithm">
            <option value="vue3">Vue3 (LIS + Map)</option>
            <option value="vue2">Vue2 (双端比较)</option>
            <option value="react">React (Fiber)</option>
          </select>
        </div>
        <div class="control-group" style="justify-content: flex-end;">
          <button class="btn btn-primary" onclick="runDiff()">运行 Diff</button>
        </div>
      </div>

      <div class="visualization">
        <div class="list-container">
          <div class="list-title">旧列表</div>
          <div class="list" id="oldListDisplay"></div>
        </div>
        <div class="list-container">
          <div class="list-title">新列表</div>
          <div class="list" id="newListDisplay"></div>
        </div>
      </div>

      <div class="operations">
        <div class="operations-title">Diff 操作序列</div>
        <div class="operation-list" id="operationList">
          <div class="operation" style="color: rgba(255,255,255,0.5);">点击"运行 Diff"查看操作序列</div>
        </div>
      </div>
    </div>

    <!-- 算法对比卡片 -->
    <div class="demo-section">
      <h2 class="demo-title">三大框架 Diff 算法对比</h2>
      <div class="algorithm-comparison">
        <div class="algorithm-card">
          <h3>Vue2 双端比较</h3>
          <div class="feature">
            <span class="feature-label">比较方向</span>
            <span class="feature-value">四端同时</span>
          </div>
          <div class="feature">
            <span class="feature-label">Key 查找</span>
            <span class="feature-value">O(n) 线性</span>
          </div>
          <div class="feature">
            <span class="feature-label">移动策略</span>
            <span class="feature-value">启发式</span>
          </div>
          <div class="feature">
            <span class="feature-label">最优移动</span>
            <span class="feature-value">不保证</span>
          </div>
          <div class="feature">
            <span class="feature-label">时间复杂度</span>
            <span class="feature-value">O(n²)</span>
          </div>
        </div>

        <div class="algorithm-card">
          <h3>Vue3 LIS 算法</h3>
          <div class="feature">
            <span class="feature-label">比较方向</span>
            <span class="feature-value">首尾快速+遍历</span>
          </div>
          <div class="feature">
            <span class="feature-label">Key 查找</span>
            <span class="feature-value">O(1) Map</span>
          </div>
          <div class="feature">
            <span class="feature-label">移动策略</span>
            <span class="feature-value">最长递增子序列</span>
          </div>
          <div class="feature">
            <span class="feature-label">最优移动</span>
            <span class="feature-value">保证</span>
          </div>
          <div class="feature">
            <span class="feature-label">时间复杂度</span>
            <span class="feature-value">O(n log n)</span>
          </div>
        </div>

        <div class="algorithm-card">
          <h3>React Fiber</h3>
          <div class="feature">
            <span class="feature-label">比较方向</span>
            <span class="feature-value">单向链表</span>
          </div>
          <div class="feature">
            <span class="feature-label">Key 查找</span>
            <span class="feature-value">O(1) Map</span>
          </div>
          <div class="feature">
            <span class="feature-label">移动策略</span>
            <span class="feature-value">全部标记</span>
          </div>
          <div class="feature">
            <span class="feature-label">最优移动</span>
            <span class="feature-value">不保证</span>
          </div>
          <div class="feature">
            <span class="feature-label">时间复杂度</span>
            <span class="feature-value">O(n)</span>
          </div>
        </div>
      </div>
    </div>

    <!-- 核心代码展示 -->
    <div class="demo-section">
      <h2 class="demo-title">Vue3 Diff 核心代码</h2>
      <div class="code-block">
        <pre><code><span class="comment">// 1. 构建 key -> index 的 Map(核心优化!)</span>
<span class="keyword">const</span> keyToNewIndexMap = <span class="keyword">new</span> <span class="function">Map</span>();
<span class="keyword">for</span> (<span class="keyword">let</span> i = newStartIdx; i <= newEndIdx; i++) {
  keyToNewIndexMap.<span class="function">set</span>(newChildren[i].key, i);
}

<span class="comment">// 2. 遍历旧节点,复用可匹配节点</span>
<span class="keyword">for</span> (<span class="keyword">let</span> i = oldStartIdx; i <= oldEndIdx; i++) {
  <span class="keyword">const</span> newIndex = keyToNewIndexMap.<span class="function">get</span>(oldChildren[i].key);
  <span class="keyword">if</span> (newIndex !== <span class="keyword">undefined</span>) {
    <span class="function">patch</span>(oldChildren[i], newChildren[newIndex]);  <span class="comment">// 复用</span>
  } <span class="keyword">else</span> {
    <span class="function">unmount</span>(oldChildren[i]);  <span class="comment">// 删除</span>
  }
}

<span class="comment">// 3. 计算最长递增子序列,确定移动策略</span>
<span class="keyword">const</span> lis = <span class="function">getLongestIncreasingSubsequence</span>(newIndexToOldIndexMap);

<span class="comment">// 4. 移动和新增节点</span>
<span class="keyword">for</span> (<span class="keyword">let</span> i = toBePatched - <span class="number">1</span>; i >= <span class="number">0</span>; i--) {
  <span class="keyword">if</span> (newIndexToOldIndexMap[i] === <span class="number">0</span>) {
    <span class="function">mount</span>(newChildren[i]);  <span class="comment">// 新增</span>
  } <span class="keyword">else if</span> (!lis.includes(i)) {
    <span class="function">move</span>(newChildren[i]);  <span class="comment">// 移动</span>
  }
}</code></pre>
      </div>
    </div>
  </div>

  <script>
    // ==================== Diff 算法实现 ====================

    /**
     * Vue3 风格的 Diff 算法
     */
    function diffVue3(oldList, newList) {
      const operations = [];
      const keyToNewIndex = new Map();

      // 构建 key -> index 的 Map
      newList.forEach((item, index) => {
        keyToNewIndex.set(item, index);
      });

      // 记录新节点在旧列表中的位置
      const newIndexToOldIndex = [];
      const usedOldIndices = new Set();

      // 遍历旧列表,找到可复用节点
      oldList.forEach((item, oldIndex) => {
        const newIndex = keyToNewIndex.get(item);
        if (newIndex !== undefined) {
          newIndexToOldIndex[newIndex] = oldIndex + 1; // +1 避免与 0 混淆
          usedOldIndices.add(oldIndex);
        } else {
          operations.push({ type: 'delete', item, index: oldIndex });
        }
      });

      // 记录未使用的旧节点(需要删除)
      oldList.forEach((item, index) => {
        if (!usedOldIndices.has(index)) {
          // 已在上面处理
        }
      });

      // 计算最长递增子序列
      const lis = getLongestIncreasingSubsequence(
        newIndexToOldIndex.map((v, i) => v || 0)
      );

      // 从后向前遍历,处理移动和新增
      let lisIndex = lis.length - 1;
      for (let i = newList.length - 1; i >= 0; i--) {
        if (!newIndexToOldIndex[i]) {
          // 新节点
          operations.push({ type: 'create', item: newList[i], index: i });
        } else if (lis[lisIndex] === i) {
          // 在 LIS 中,不需要移动
          lisIndex--;
        } else {
          // 需要移动
          operations.push({ type: 'move', item: newList[i], from: newIndexToOldIndex[i] - 1, to: i });
        }
      }

      return operations.reverse();
    }

    /**
     * Vue2 风格的双端比较
     */
    function diffVue2(oldList, newList) {
      const operations = [];
      const oldCh = [...oldList];
      const newCh = [...newList];

      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];

      while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode === newStartVnode) {
          operations.push({ type: 'update', item: oldStartVnode, index: newStartIdx, match: 'head-head' });
          oldStartVnode = oldCh[++oldStartIdx];
          newStartVnode = newCh[++newStartIdx];
        } else if (oldEndVnode === newEndVnode) {
          operations.push({ type: 'update', item: oldEndVnode, index: newEndIdx, match: 'tail-tail' });
          oldEndVnode = oldCh[--oldEndIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (oldStartVnode === newEndVnode) {
          operations.push({ type: 'move', item: oldStartVnode, from: oldStartIdx, to: newEndIdx, match: 'head-tail' });
          oldStartVnode = oldCh[++oldStartIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (oldEndVnode === newStartVnode) {
          operations.push({ type: 'move', item: oldEndVnode, from: oldEndIdx, to: newStartIdx, match: 'tail-head' });
          oldEndVnode = oldCh[--oldEndIdx];
          newStartVnode = newCh[++newStartIdx];
        } else {
          // 使用 Map 查找
          const idxInOld = oldCh.findIndex((item, idx) =>
            idx >= oldStartIdx && idx <= oldEndIdx && item === newStartVnode
          );

          if (idxInOld !== -1) {
            operations.push({ type: 'move', item: newStartVnode, from: idxInOld, to: newStartIdx, match: 'key-lookup' });
            oldCh[idxInOld] = undefined;
          } else {
            operations.push({ type: 'create', item: newStartVnode, index: newStartIdx });
          }
          newStartVnode = newCh[++newStartIdx];
        }
      }

      if (oldStartIdx > oldEndIdx) {
        for (let i = newStartIdx; i <= newEndIdx; i++) {
          operations.push({ type: 'create', item: newCh[i], index: i });
        }
      } else if (newStartIdx > newEndIdx) {
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
          if (oldCh[i]) {
            operations.push({ type: 'delete', item: oldCh[i], index: i });
          }
        }
      }

      return operations;
    }

    /**
     * React 风格的 Fiber Diff
     */
    function diffReact(oldList, newList) {
      const operations = [];
      const existingChildren = new Map();

      // 构建 key -> index 的 Map
      oldList.forEach((item, index) => {
        existingChildren.set(item, index);
      });

      // 第一轮:顺序比较
      let i = 0;
      for (; i < oldList.length && i < newList.length; i++) {
        if (oldList[i] === newList[i]) {
          operations.push({ type: 'update', item: newList[i], index: i });
          existingChildren.delete(newList[i]);
        } else {
          break;
        }
      }

      // 第二轮:处理剩余节点
      for (; i < newList.length; i++) {
        const item = newList[i];
        if (existingChildren.has(item)) {
          operations.push({ type: 'move', item, from: existingChildren.get(item), to: i });
          existingChildren.delete(item);
        } else {
          operations.push({ type: 'create', item, index: i });
        }
      }

      // 删除未复用的节点
      existingChildren.forEach((index, item) => {
        operations.push({ type: 'delete', item, index });
      });

      return operations;
    }

    /**
     * 最长递增子序列算法
     */
    function getLongestIncreasingSubsequence(arr) {
      if (arr.length === 0) return [];

      const result = [0];
      const p = arr.slice();

      for (let i = 1; i < arr.length; i++) {
        const arrI = arr[i];
        if (arrI === 0) continue;

        const lastIdx = result[result.length - 1];
        if (arr[lastIdx] < arrI) {
          p[i] = lastIdx;
          result.push(i);
          continue;
        }

        let left = 0, right = result.length - 1;
        while (left < right) {
          const mid = (left + right) >> 1;
          if (arr[result[mid]] < arrI) {
            left = mid + 1;
          } else {
            right = mid;
          }
        }

        if (arrI < arr[result[left]]) {
          if (left > 0) p[i] = result[left - 1];
          result[left] = i;
        }
      }

      let len = result.length;
      let idx = result[len - 1];
      while (len-- > 0) {
        result[len] = idx;
        idx = p[idx];
      }

      return result;
    }

    // ==================== UI 交互 ====================

    function renderList(containerId, items, type = 'old') {
      const container = document.getElementById(containerId);
      container.innerHTML = '';

      items.forEach((item, index) => {
        const div = document.createElement('div');
        div.className = `item item-${type}`;
        div.innerHTML = `${item}<span class="item-key">key:${item}</span>`;
        container.appendChild(div);
      });
    }

    function renderOperations(operations) {
      const container = document.getElementById('operationList');
      container.innerHTML = '';

      if (operations.length === 0) {
        container.innerHTML = '<div class="operation" style="color: rgba(255,255,255,0.5);">无操作</div>';
        return;
      }

      operations.forEach(op => {
        const div = document.createElement('div');
        div.className = `operation op-${op.type}`;

        let text = '';
        switch (op.type) {
          case 'create':
            text = `CREATE: 新增节点 "${op.item}" 在位置 ${op.index}`;
            break;
          case 'delete':
            text = `DELETE: 删除节点 "${op.item}" 在位置 ${op.index}`;
            break;
          case 'move':
            text = `MOVE: 移动节点 "${op.item}" 从 ${op.from}${op.to}${op.match ? ` (${op.match})` : ''}`;
            break;
          case 'update':
            text = `UPDATE: 更新节点 "${op.item}" 在位置 ${op.index}${op.match ? ` (${op.match})` : ''}`;
            break;
        }

        div.textContent = text;
        container.appendChild(div);
      });
    }

    function runDiff() {
      const oldListStr = document.getElementById('oldList').value;
      const newListStr = document.getElementById('newList').value;
      const algorithm = document.getElementById('algorithm').value;

      const oldList = oldListStr.split(',').map(s => s.trim()).filter(Boolean);
      const newList = newListStr.split(',').map(s => s.trim()).filter(Boolean);

      // 渲染列表
      renderList('oldListDisplay', oldList, 'old');
      renderList('newListDisplay', newList, 'new');

      // 执行 Diff
      let operations;
      switch (algorithm) {
        case 'vue2':
          operations = diffVue2(oldList, newList);
          break;
        case 'vue3':
          operations = diffVue3(oldList, newList);
          break;
        case 'react':
          operations = diffReact(oldList, newList);
          break;
      }

      renderOperations(operations);
    }

    // 初始化
    runDiff();
  </script>
</body>
</html>

七、总结

核心要点回顾

  1. Diff 算法的本质:最小化 DOM 操作,将 O(n³) 的树对比降至 O(n) 或 O(n log n)

  2. Map 的关键作用:将 key 查找从 O(n) 降至 O(1),是性能优化的核心手段

  3. Vue2 双端比较:四端同时比较,适合简单移动场景,复杂场景性能下降

  4. Vue3 LIS 算法:首尾快速路径 + Map 查找 + 最长递增子序列,保证最少移动次数

  5. React Fiber:单向链表遍历 + Map 查找,支持可中断渲染,但移动不一定最优

面试高频问题

Q1:为什么不能用 index 作为 key?

使用 index 作为 key 会导致:

  • 列表顺序变化时,key 失去唯一性标识作用
  • 所有节点都被认为是"更新"而非"移动"
  • 性能浪费,可能导致状态错乱

Q2:Vue3 的 Diff 比 Vue2 快多少?

在极端乱序场景下,Vue3 比 Vue2 快约 100%(性能翻倍),主要得益于:

  • Map 查找替代线性查找
  • LIS 减少不必要的移动
  • 快速路径跳过首尾相同节点

Q3:React 为什么不采用 LIS 算法?

React 团队的考量:

  • LIS 算法增加复杂度,边际收益递减
  • Fiber 架构的设计目标是可中断性,而非最优移动
  • 简单策略更容易维护和优化

Q4:Map 会占用多少内存?

假设有 1000 个子节点:

  • 每个 key(假设字符串 10 字节)+ index(数字 8 字节)≈ 18 字节
  • Map 总内存 ≈ 1000 × 18 = 18KB

用 18KB 内存换取 O(n²) → O(n) 的性能提升,是非常划算的。

进一步学习


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!