Skip to content

Vue 3 Diff 算法深入

双端对比、最长递增子序列、key 的作用、性能优化

什么是 Diff 算法?

定义:对比新旧虚拟 DOM 树,找出最小变更并应用到真实 DOM 的算法。

核心目标

  • 最小化 DOM 操作:DOM 操作昂贵,尽量复用
  • 保持正确性:确保视图与数据一致
  • 高效性能:O(n) 时间复杂度

应用场景

  • 组件更新时对比新旧 VNode
  • 列表渲染(v-for)的优化
  • 条件渲染(v-if)的切换

一、Diff 算法基础

1.1 传统 Diff 的问题

完整树对比:时间复杂度 O(n³)

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

完整对比需要:
1. 遍历旧树每个节点 O(n)
2. 在新树中查找 O(n)
3. 计算编辑距离 O(n)
总计:O(n³)

1.2 Vue/React 的优化策略

三个假设

  1. 同层对比:只比较同一层级的节点
  2. 不同类型节点:直接替换,不再深入对比
  3. key 标识:通过 key 识别节点是否可复用

时间复杂度:O(n)

typescript
function patch(
  n1: VNode | null,  // 旧节点
  n2: VNode,         // 新节点
  container: RendererElement,
  anchor?: RendererNode | null
) {
  // 类型不同,直接替换
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1)
    n1 = null
  }
  
  const { type, shapeFlag } = n2
  
  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break
    case Comment:
      processCommentNode(n1, n2, container, anchor)
      break
    case Fragment:
      processFragment(n1, n2, container, anchor)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container, anchor)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(n1, n2, container, anchor)
      }
  }
}

function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

二、元素节点 Diff

2.1 processElement 流程

typescript
function processElement(
  n1: VNode | null,
  n2: VNode,
  container: RendererElement,
  anchor: RendererNode | null
) {
  if (n1 == null) {
    // 挂载新节点
    mountElement(n2, container, anchor)
  } else {
    // 更新节点
    patchElement(n1, n2)
  }
}

2.2 patchElement - 更新元素

typescript
function patchElement(
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  optimized: boolean
) {
  const el = (n2.el = n1.el!)
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  
  // 1. 更新 props
  patchProps(
    el,
    n2,
    oldProps,
    newProps,
    parentComponent,
    parentSuspense,
    isSVG
  )
  
  // 2. 更新 children
  const { patchFlag, dynamicChildren } = n2
  
  if (patchFlag > 0) {
    // 有优化标记
    if (patchFlag & PatchFlags.FULL_PROPS) {
      patchProps(el, n2, oldProps, newProps, ...)
    }
    
    if (dynamicChildren) {
      // Block Tree 优化:只更新动态子节点
      patchBlockChildren(
        n1.dynamicChildren!,
        dynamicChildren,
        el,
        ...
      )
      return
    }
  }
  
  // 完整 Diff 子节点
  patchChildren(n1, n2, el, null, ...)
}

2.3 patchChildren - 子节点 Diff

typescript
function patchChildren(
  n1: VNode,
  n2: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  optimized: boolean = false
) {
  const c1 = n1 && n1.children
  const prevShapeFlag = n1 ? n1.shapeFlag : 0
  const c2 = n2.children
  const { shapeFlag } = n2
  
  // 新节点是文本
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 旧节点是数组,卸载所有子节点
      unmountChildren(c1 as VNode[])
    }
    if (c2 !== c1) {
      // 设置文本内容
      hostSetElementText(container, c2 as string)
    }
  } else {
    // 新节点是数组或空
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 新旧都是数组 - 核心 Diff
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          ...
        )
      } else {
        // 新节点为空,卸载旧节点
        unmountChildren(c1 as VNode[])
      }
    } else {
      // 旧节点是文本或空
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        hostSetElementText(container, '')
      }
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 挂载新节点
        mountChildren(c2 as VNodeArrayChildren, container, anchor, ...)
      }
    }
  }
}

三、核心 Diff 算法 - patchKeyedChildren

3.1 算法概览

Vue 3 使用双端对比 + 最长递增子序列的混合算法:

五个步骤

  1. 头部对比:从前往后比较相同节点
  2. 尾部对比:从后往前比较相同节点
  3. 新增节点:旧节点遍历完,新节点还有剩余
  4. 删除节点:新节点遍历完,旧节点还有剩余
  5. 乱序对比:使用最长递增子序列优化移动

3.2 完整实现

typescript
function patchKeyedChildren(
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  optimized: boolean
) {
  let i = 0
  const l2 = c2.length
  let e1 = c1.length - 1  // 旧节点尾部索引
  let e2 = l2 - 1          // 新节点尾部索引
  
  // 1. 头部对比
  // (a b) c
  // (a b) d e
  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = c2[i]
    
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, null, ...)
    } else {
      break
    }
    i++
  }
  
  // 2. 尾部对比
  // a (b c)
  // d e (b c)
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1]
    const n2 = c2[e2]
    
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, null, ...)
    } else {
      break
    }
    e1--
    e2--
  }
  
  // 3. 新增节点
  // (a b)
  // (a b) c d
  // i = 2, e1 = 1, e2 = 3
  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], container, anchor, ...)
        i++
      }
    }
  }
  
  // 4. 删除节点
  // (a b) c d
  // (a b)
  // i = 2, e1 = 3, e2 = 1
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i], ...)
      i++
    }
  }
  
  // 5. 乱序对比
  // a b [c d e] f g
  // a b [e d c h] f g
  // i = 2, e1 = 4, e2 = 5
  else {
    const s1 = i  // 旧节点开始索引
    const s2 = i  // 新节点开始索引
    
    // 5.1 构建新节点的 key -> index 映射
    const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
    for (i = s2; i <= e2; i++) {
      const nextChild = c2[i]
      if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i)
      }
    }
    
    // 5.2 遍历旧节点,patch 或 unmount
    let j
    let patched = 0
    const toBePatched = e2 - s2 + 1
    let moved = false
    let maxNewIndexSoFar = 0
    
    // 用于存储新节点在旧节点中的位置
    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) {
        // 所有新节点都已 patch,剩余旧节点直接卸载
        unmount(prevChild, ...)
        continue
      }
      
      let newIndex
      if (prevChild.key != null) {
        // 通过 key 查找
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // 没有 key,遍历查找
        for (j = s2; j <= e2; j++) {
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j])
          ) {
            newIndex = j
            break
          }
        }
      }
      
      if (newIndex === undefined) {
        // 新节点中不存在,卸载
        unmount(prevChild, ...)
      } else {
        // 记录位置映射
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        
        // 判断是否需要移动
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex
        } else {
          moved = true
        }
        
        // patch 节点
        patch(prevChild, c2[newIndex], container, null, ...)
        patched++
      }
    }
    
    // 5.3 移动和挂载新节点
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : 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) {
        // 新节点,挂载
        patch(null, nextChild, container, anchor, ...)
      } else if (moved) {
        // 需要移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          move(nextChild, container, anchor)
        } else {
          j--
        }
      }
    }
  }
}

3.3 算法图解

示例 1:头尾对比

旧: a b c d
新: a b c d e

步骤 1:头部对比
i=0: a === a ✓
i=1: b === b ✓
i=2: c === c ✓
i=3: d === d ✓

步骤 2:尾部对比(已完成)

步骤 3:新增节点
i=4 > e1=3,新增 e

示例 2:删除节点

旧: a b c d e
新: a b c

步骤 1:头部对比
i=0: a === a ✓
i=1: b === b ✓
i=2: c === c ✓

步骤 2:尾部对比(已完成)

步骤 4:删除节点
i=3 <= e1=4,删除 d, e

示例 3:乱序对比

旧: a b c d e f g
新: a b e d c h f g

步骤 1:头部对比
i=0: a === a ✓
i=1: b === b ✓
i=2: c !== e ✗

步骤 2:尾部对比
e1=6: g === g ✓
e1=5: f === f ✓
e1=4: e !== h ✗

步骤 5:乱序对比 [c d e] vs [e d c h]

四、最长递增子序列(LIS)

4.1 为什么需要 LIS?

问题:在乱序对比中,如何最小化 DOM 移动?

示例

旧: a b c d e
新: a e b c d

朴素做法:移动 e 到前面(1 次移动)
优化做法:移动 a 到后面(1 次移动)

但如果:
旧: a b c d e
新: b c d e a

朴素做法:移动 b c d e(4 次移动)
优化做法:移动 a 到后面(1 次移动)

LIS 的作用:找出不需要移动的最长序列。

4.2 LIS 算法实现

typescript
function getSequence(arr: number[]): number[] {
  const p = arr.slice()  // 用于回溯路径
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    
    if (arrI !== 0) {
      j = result[result.length - 1]
      
      // 如果当前值大于结果序列的最后一个值
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      
      // 二分查找,找到第一个大于 arrI 的位置
      u = 0
      v = result.length - 1
      
      while (u < v) {
        c = (u + v) >> 1
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }
  
  // 回溯构建最终序列
  u = result.length
  v = result[u - 1]
  
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  
  return result
}

4.3 LIS 示例

typescript
// 输入:新节点在旧节点中的位置
const arr = [2, 3, 1, 5, 6, 4, 8, 9, 7]

// 输出:最长递增子序列的索引
const lis = getSequence(arr)
// [2, 3, 5, 6, 8, 9] 的索引 -> [0, 1, 3, 4, 6, 7]

// 含义:这些位置的节点不需要移动
// 其他节点需要移动

4.4 在 Diff 中的应用

typescript
// newIndexToOldIndexMap: [5, 3, 4, 0]
// 表示:
// 新节点[0] 在旧节点位置 5
// 新节点[1] 在旧节点位置 3
// 新节点[2] 在旧节点位置 4
// 新节点[3] 是新增的(0 表示不存在)

const lis = getSequence([5, 3, 4, 0])
// 返回 [1, 2](索引)
// 表示新节点[1]和[2]不需要移动

// 遍历时:
// i=3: 新增节点,挂载
// i=2: 在 lis 中,不移动
// i=1: 在 lis 中,不移动
// i=0: 不在 lis 中,移动

五、key 的作用

5.1 为什么需要 key?

没有 key

vue
<div v-for="item in list">{{ item }}</div>
typescript
// 旧: [A, B, C]
// 新: [B, C, D]

// 没有 key,按索引对比:
// 索引 0: A -> B (更新)
// 索引 1: B -> C (更新)
// 索引 2: C -> D (更新)
// 3 次更新

有 key

vue
<div v-for="item in list" :key="item.id">{{ item }}</div>
typescript
// 旧: [A, B, C]
// 新: [B, C, D]

// 有 key,按 key 对比:
// A 不在新列表,删除
// B 复用
// C 复用
// D 是新节点,挂载
// 1 次删除 + 1 次挂载

5.2 key 的最佳实践

✅ 推荐

vue
<!-- 使用唯一 ID -->
<div v-for="user in users" :key="user.id">
  {{ user.name }}
</div>

<!-- 使用唯一字符串 -->
<div v-for="item in items" :key="`item-${item.id}`">
  {{ item.text }}
</div>

❌ 不推荐

vue
<!-- 使用索引 -->
<div v-for="(item, index) in items" :key="index">
  {{ item }}
</div>

<!-- 使用随机数 -->
<div v-for="item in items" :key="Math.random()">
  {{ item }}
</div>

5.3 key 的陷阱

问题 1:使用索引作为 key

vue
<template>
  <div v-for="(item, index) in list" :key="index">
    <input :value="item.name" />
  </div>
</template>

<script setup>
const list = ref([
  { id: 1, name: 'A' },
  { id: 2, name: 'B' },
  { id: 3, name: 'C' }
])

// 删除第一项
list.value.shift()
</script>
删除前:
key=0: A
key=1: B
key=2: C

删除后:
key=0: B (复用了 A 的 DOM,但数据是 B)
key=1: C (复用了 B 的 DOM,但数据是 C)
key=2: 删除

问题:input 的值不会更新!

问题 2:key 不唯一

vue
<div v-for="item in list" :key="item.type">
  {{ item.name }}
</div>

<!-- 如果多个 item 的 type 相同,会导致错误的复用 -->

六、Vue 2 vs Vue 3 Diff 对比

6.1 核心区别

特性Vue 2Vue 3
算法双端对比双端对比 + LIS
优化无编译时优化Patch Flag + Block Tree
移动优化简单移动最长递增子序列
性能基准提升 1.3-2 倍

6.2 Vue 2 双端对比

typescript
// Vue 2 的简化实现
function updateChildren(oldCh, newCh) {
  let oldStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let newStartIdx = 0
  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) {
    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)
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      // 尾头对比
      patchVnode(oldEndVnode, newStartVnode)
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 乱序对比
      // ...
    }
  }
}

6.3 性能对比

场景 1:顺序不变

旧: [A, B, C, D, E]
新: [A, B, C, D, E, F]

Vue 2: 5 次头头对比 + 1 次插入
Vue 3: 5 次头部对比 + 1 次插入
性能相当

场景 2:逆序

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

Vue 2: 多次移动
Vue 3: 使用 LIS,最小化移动
Vue 3 更快

场景 3:大量静态节点

vue
<div>
  <p>Static 1</p>
  <p>Static 2</p>
  <span>{{ dynamic }}</span>
  <p>Static 3</p>
</div>

Vue 2: 对比所有节点
Vue 3: Block Tree,只对比 span
Vue 3 快 2 倍+

面试高频题

1: Vue 的 Diff 算法是什么?

答案

Vue 使用同层对比的 Diff 算法,时间复杂度 O(n)。

核心策略

  1. 只比较同一层级的节点
  2. 不同类型节点直接替换
  3. 通过 key 识别节点是否可复用

Vue 3 优化

  • 双端对比 + 最长递增子序列
  • Patch Flag 靶向更新
  • Block Tree 跳过静态节点

2: 为什么 v-for 需要 key?

答案

作用

  1. 帮助 Vue 识别节点,提高复用率
  2. 避免"就地更新"导致的状态错乱
  3. 提升 Diff 性能

示例

javascript
// 没有 key
: [A, B, C]
: [B, C, D]
// 按索引对比,3 次更新

// 有 key
: [A, B, C]
: [B, C, D]
// 按 key 对比,1 次删除 + 1 次挂载

3: 为什么不能用索引作为 key?

答案

问题:索引会随着数组变化而变化,导致错误的节点复用。

示例

vue
<input v-for="(item, index) in list" :key="index" />

// 删除第一项
list.shift()

// 结果:
// 原本 key=1 的 input 现在变成 key=0
// DOM 被复用,但数据不对应
// 用户输入的值会错位

正确做法:使用唯一且稳定的 ID。


4: Vue 3 的 Diff 算法相比 Vue 2 有什么优化?

答案

Vue 3 优化

  1. 最长递增子序列:最小化 DOM 移动
  2. Patch Flag:靶向更新,跳过静态属性
  3. Block Tree:只对比动态节点
  4. 静态提升:静态节点只创建一次

性能提升

  • 更新性能提升 1.3-2 倍
  • 内存使用减少 54%

5: 什么是最长递增子序列?在 Diff 中如何应用?

答案

定义:在一个序列中,找出最长的递增子序列。

在 Diff 中的应用

  • 找出不需要移动的节点
  • 只移动其他节点
  • 最小化 DOM 操作

示例

javascript
newIndexToOldIndexMap: [5, 3, 4, 0]
// 新节点在旧节点中的位置

LIS: [3, 4]
// 位置 3, 4 是递增的,不需要移动

// 只需要移动位置 5 和挂载位置 0

6: Diff 算法的时间复杂度是多少?

答案

Vue/React Diff:O(n)

传统树 Diff:O(n³)

优化策略

  1. 只比较同层节点(降到 O(n²))
  2. 不同类型直接替换(降到 O(n))
  3. 使用 key 快速定位(保持 O(n))

7: 什么情况下 Diff 算法性能最差?

答案

最差场景

  1. 大量节点逆序
javascript
: [1, 2, 3, 4, 5, ..., 1000]
: [1000, 999, ..., 2, 1]
// 需要大量移动
  1. 没有 key 的大列表
vue
<div v-for="item in largeList">{{ item }}</div>
// 无法复用,全部重新创建
  1. 频繁的增删改
javascript
// 每次操作都触发 Diff
setInterval(() => {
  list.value.splice(random(), 1)
  list.value.push(newItem)
}, 100)

优化方案

  • 使用 key
  • 虚拟滚动
  • 防抖/节流

8: Block Tree 如何优化 Diff?

答案

原理

  • 编译时收集所有动态节点
  • 运行时只对比动态节点
  • 跳过静态节点

示例

vue
<div>
  <p>Static 1</p>
  <span>{{ msg }}</span>
  <p>Static 2</p>
</div>

// Block Tree
Block {
  dynamicChildren: [span]  // 只收集动态节点
}

// Diff 时只对比 span,跳过两个 p

性能提升

  • 静态节点越多,提升越明显
  • 可提升 2-10 倍性能

9: 如何手动触发组件更新?

答案

方法 1:修改响应式数据

javascript
const count = ref(0)
count.value++  // 自动触发更新

方法 2:forceUpdate

javascript
import { getCurrentInstance } from 'vue'

const instance = getCurrentInstance()
instance?.proxy?.$forceUpdate()

方法 3:修改 key

vue
<Component :key="componentKey" />

<script setup>
const componentKey = ref(0)

function refresh() {
  componentKey.value++  // 强制重新创建组件
}
</script>

10: Diff 算法如何处理 Fragment?

答案

Fragment:多个根节点的容器。

vue
<template>
  <div>A</div>
  <div>B</div>
</template>

// 编译为
Fragment([div(A), div(B)])

Diff 处理

typescript
if (type === Fragment) {
  // 直接对比 children
  patchChildren(n1, n2, container, ...)
}

优化

  • STABLE_FRAGMENT:子节点顺序不变
  • KEYED_FRAGMENT:有 key 的 v-for
  • UNKEYED_FRAGMENT:无 key 的 v-for

性能影响

  • Fragment 本身不创建 DOM
  • 只对比子节点
  • 性能优于包裹 div