Skip to content

题目

请详细说明Vue的diff算法原理,包括key的作用,并对比Vue2和Vue3的diff算法有什么区别和优化?

📝 标准答案

核心要点

  1. diff算法目的

    • 高效对比新旧虚拟DOM,最小化真实DOM操作
    • 将传统O(n³)复杂度优化到O(n)
    • 通过同层比较和key标识实现高效更新
  2. Vue2双端比较算法

    • 同时从头尾两端开始比较(头头、尾尾、头尾、尾头)
    • 时间复杂度O(n),适合简单的增删改操作
    • 在复杂移动场景下可能产生不必要的移动
  3. Vue3最长递增子序列算法

    • 基于最长递增子序列(LIS)优化,减少移动操作
    • 预处理头尾相同节点,只对中间部分使用LIS
    • 复杂场景下性能显著优于Vue2
  4. key的作用

    • 唯一标识节点,帮助Vue正确识别元素变化
    • 避免就地复用导致的状态错乱问题
    • 提升diff算法的准确性和性能

详细说明

什么是diff算法?

diff算法是虚拟DOM的核心,用于对比新旧虚拟DOM树的差异,然后高效地更新真实DOM。

为什么需要diff算法?

  • 直接操作DOM很慢,需要调用浏览器API
  • 重新创建整个DOM树代价太大
  • 需要找出最小的变化集合,减少DOM操作
  • 保持组件状态(如input焦点、滚动位置等)
javascript
// 虚拟DOM结构
const vnode = {
  tag: 'div',
  props: { class: 'container' },
  children: [
    { tag: 'p', children: 'Hello' },
    { tag: 'p', children: 'World' }
  ]
};

// diff算法比较新旧虚拟DOM
function patch(oldVNode, newVNode) {
  // 1. 如果节点类型不同,直接替换
  if (oldVNode.tag !== newVNode.tag) {
    return replaceNode(oldVNode, newVNode);
  }
  
  // 2. 如果节点类型相同,更新属性
  patchProps(oldVNode, newVNode);
  
  // 3. 比较子节点(核心diff算法)
  patchChildren(oldVNode.children, newVNode.children);
}

Vue2的diff算法(双端比较)

Vue2使用双端比较算法,同时从新旧节点列表的头尾四个位置开始比较:

javascript
// Vue2双端比较算法(简化版)
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 (!oldStartVNode) {
      oldStartVNode = oldCh[++oldStartIdx];
    } else if (!oldEndVNode) {
      oldEndVNode = oldCh[--oldEndIdx];
    }
    // 1. 头头比较
    else if (sameVNode(oldStartVNode, newStartVNode)) {
      patchVNode(oldStartVNode, newStartVNode);
      oldStartVNode = oldCh[++oldStartIdx];
      newStartVNode = newCh[++newStartIdx];
    }
    // 2. 尾尾比较
    else if (sameVNode(oldEndVNode, newEndVNode)) {
      patchVNode(oldEndVNode, newEndVNode);
      oldEndVNode = oldCh[--oldEndIdx];
      newEndVNode = newCh[--newEndIdx];
    }
    // 3. 头尾比较
    else if (sameVNode(oldStartVNode, newEndVNode)) {
      patchVNode(oldStartVNode, newEndVNode);
      // 移动节点
      nodeOps.insertBefore(parentElm, oldStartVNode.elm, nodeOps.nextSibling(oldEndVNode.elm));
      oldStartVNode = oldCh[++oldStartIdx];
      newEndVNode = newCh[--newEndIdx];
    }
    // 4. 尾头比较
    else if (sameVNode(oldEndVNode, newStartVNode)) {
      patchVNode(oldEndVNode, newStartVNode);
      // 移动节点
      nodeOps.insertBefore(parentElm, oldEndVNode.elm, oldStartVNode.elm);
      oldEndVNode = oldCh[--oldEndIdx];
      newStartVNode = newCh[++newStartIdx];
    }
    // 5. 使用key查找
    else {
      const idxInOld = findIdxInOld(newStartVNode, oldCh, oldStartIdx, oldEndIdx);
      
      if (!idxInOld) {
        // 新节点,创建
        createElm(newStartVNode);
      } else {
        // 找到相同key的节点,移动
        const vnodeToMove = oldCh[idxInOld];
        patchVNode(vnodeToMove, newStartVNode);
        oldCh[idxInOld] = undefined;
        nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVNode.elm);
      }
      
      newStartVNode = newCh[++newStartIdx];
    }
  }
  
  // 处理剩余节点
  if (oldStartIdx > oldEndIdx) {
    // 新增节点
    addVNodes(newCh, newStartIdx, newEndIdx);
  } else if (newStartIdx > newEndIdx) {
    // 删除节点
    removeVNodes(oldCh, oldStartIdx, oldEndIdx);
  }
}

function sameVNode(a, b) {
  return (
    a.key === b.key &&
    a.tag === b.tag &&
    a.isComment === b.isComment &&
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b)
  );
}

双端比较示例:

旧节点:A B C D
新节点:D A B C

第1轮:
  头头比较:A !== D ❌
  尾尾比较:D === D ✅ → 复用D
  
旧节点:A B C [D已处理]
新节点:[D已处理] A B C

第2轮:
  头头比较:A === A ✅ → 复用A
  
旧节点:[A已处理] B C
新节点:[A已处理] B C

第3轮:
  头头比较:B === B ✅ → 复用B
  
第4轮:
  头头比较:C === C ✅ → 复用C

结果:只移动了D,其他节点复用

Vue3的diff算法(最长递增子序列)

Vue3采用了更先进的算法,基于最长递增子序列(LIS)

javascript
// Vue3 patchKeyedChildren核心逻辑
function patchKeyedChildren(c1, c2, container) {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1; // 旧列表尾部索引
  let e2 = l2 - 1; // 新列表尾部索引

  // 1. 从头开始比较相同的节点
  while (i <= e1 && i <= e2) {
    const n1 = c1[i], n2 = c2[i];
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container);
    } else {
      break;
    }
    i++;
  }

  // 2. 从尾开始比较相同的节点
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1], n2 = c2[e2];
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container);
    } else {
      break;
    }
    e1--;
    e2--;
  }

  // 3. 处理新增节点
  if (i > e1) {
    if (i <= e2) {
      // 有新节点需要挂载
      while (i <= e2) {
        patch(null, c2[i], container);
        i++;
      }
    }
  }
  // 4. 处理删除节点
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i]);
      i++;
    }
  }
  // 5. 处理复杂情况:移动、新增、删除混合
  else {
    // 使用最长递增子序列算法
    const s1 = i, s2 = i;
    const keyToNewIndexMap = new Map();
    
    // 建立新节点的key -> index映射
    for (i = s2; i <= e2; i++) {
      keyToNewIndexMap.set(c2[i].key, i);
    }
    
    // 计算最长递增子序列
    const newIndexToOldIndexMap = new Array(e2 - s2 + 1).fill(0);
    let moved = false;
    let maxNewIndexSoFar = 0;
    
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i];
      const newIndex = keyToNewIndexMap.get(prevChild.key);
      
      if (newIndex === undefined) {
        // 旧节点在新列表中不存在,删除
        unmount(prevChild);
      } else {
        newIndexToOldIndexMap[newIndex - s2] = i + 1;
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex;
        } else {
          moved = true;
        }
        patch(prevChild, c2[newIndex], container);
      }
    }
    
    // 生成最长递增子序列
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : [];
    
    // 从后往前遍历,移动和挂载节点
    let j = increasingNewIndexSequence.length - 1;
    for (i = e2 - s2; i >= 0; i--) {
      const nextIndex = s2 + i;
      const nextChild = c2[nextIndex];
      
      if (newIndexToOldIndexMap[i] === 0) {
        // 新节点,挂载
        patch(null, nextChild, container);
      } else if (moved) {
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          // 需要移动
          move(nextChild, container);
        } else {
          j--;
        }
      }
    }
  }
}

// 最长递增子序列算法
function getSequence(arr) {
  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;
      }
      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[result.length - 1];
  // 回溯构建最长递增子序列
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

最长递增子序列示例:

旧节点:[A, B, C, D, E]  索引:[0, 1, 2, 3, 4]
新节点:[A, C, B, E, D]  索引:[0, 2, 1, 4, 3]

1. 预处理:A相同,跳过
2. 中间部分:[B, C, D, E] -> [C, B, E, D]
3. 索引映射:[2, 1, 4, 3]
4. 最长递增子序列:[1, 4] 对应 [B, E]
5. 结果:只需要移动C和D,B和E保持不动

Vue2可能移动:B, C, D
Vue3只移动:C, D
减少了33%的移动操作

Vue2 vs Vue3 diff算法对比

特性Vue2Vue3
算法策略双端比较最长递增子序列
时间复杂度O(n)O(n log n)
空间复杂度O(1)O(n)
移动次数可能较多最少移动
适用场景简单列表变化复杂列表重排
性能表现一般场景好复杂场景更优
编译优化静态标记、静态提升

具体优化点

1. 预处理优化

javascript
// Vue3会先处理头尾相同的节点,减少需要diff的节点数量

// 旧: [A, B, C, D, E]
// 新: [A, B, X, Y, E]
// 预处理后只需要diff: [C, D] vs [X, Y]

2. 静态标记优化

javascript
// Vue3编译时会标记静态节点
const _hoisted_1 = createVNode("p", null, "Static text");

function render() {
  return createVNode("div", null, [
    _hoisted_1, // 静态节点,不参与diff
    createVNode("p", null, _ctx.dynamic, 1 /* TEXT */) // 动态节点,参与diff
  ]);
}

3. 最长递增子序列优化

javascript
// 例子:旧 [A, B, C, D] -> 新 [A, C, B, D]
// Vue2: 可能移动B和C
// Vue3: 通过LIS算法,只移动B,C保持不动

🧠 深度理解

key的作用详解

1. 没有key的问题

vue
<template>
  <div>
    <div v-for="item in items">
      <input :value="item.name" />
      <button @click="remove(item)">Remove</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
        { id: 3, name: 'Charlie' }
      ]
    };
  },
  
  methods: {
    remove(item) {
      const index = this.items.indexOf(item);
      this.items.splice(index, 1);
    }
  }
};
</script>

问题:

  • 删除Bob后,Charlie的input会显示Bob的值
  • 因为Vue就地复用了DOM元素
  • input的值没有绑定到数据,导致错乱

2. 使用key解决

vue
<template>
  <div>
    <!-- ✅ 使用key -->
    <div v-for="item in items" :key="item.id">
      <input :value="item.name" />
      <button @click="remove(item)">Remove</button>
    </div>
  </div>
</template>

效果:

  • Vue根据key识别每个元素
  • 删除Bob后,Charlie的元素保持不变
  • 不会出现数据错乱

3. key的最佳实践

vue
<template>
  <div>
    <!-- ✅ 使用唯一ID -->
    <div v-for="item in items" :key="item.id">
      {{ item.name }}
    </div>
    
    <!-- ❌ 不要使用index -->
    <div v-for="(item, index) in items" :key="index">
      {{ item.name }}
    </div>
    <!-- 问题:删除/插入元素时,index会变化 -->
    
    <!-- ❌ 不要使用随机数 -->
    <div v-for="item in items" :key="Math.random()">
      {{ item.name }}
    </div>
    <!-- 问题:每次渲染都会重新创建元素 -->
  </div>
</template>

为什么Vue3要改进diff算法?

Vue2双端比较的局限性

javascript
// 场景:列表项大量重排
// 旧: [1, 2, 3, 4, 5, 6, 7, 8, 9]
// 新: [9, 1, 8, 2, 7, 3, 6, 4, 5]

// Vue2双端比较会产生很多不必要的移动操作
// 因为它无法识别出最优的移动策略

Vue3最长递增子序列的优势

javascript
// 同样的场景
// Vue3通过LIS算法找出: [1, 2, 3, 4, 5] 这个递增序列
// 只需要移动 [9, 8, 7, 6],而 [1, 2, 3, 4, 5] 保持不动
// 大大减少了DOM操作次数

实际性能对比

javascript
// 测试场景:1000个节点的复杂重排
const oldList = Array.from({length: 1000}, (_, i) => i);
const newList = oldList.slice().sort(() => Math.random() - 0.5);

// Vue2: 平均需要移动 ~500 次
// Vue3: 平均需要移动 ~200 次
// 性能提升约 60%

常见误区

  1. 误区:key可以提高性能

    vue
    <!-- ❌ 错误理解 -->
    <!-- key不是用来提高性能的,而是用来正确识别节点 -->
    
    <!-- 某些情况下,不使用key反而更快(就地复用) -->
    <div v-for="item in items">
      {{ item }}
    </div>
    
    <!-- 但如果有状态(如input),必须使用key -->
    <div v-for="item in items" :key="item.id">
      <input v-model="item.value" />
    </div>
  2. 误区:使用index作为key

    vue
    <!-- ❌ 错误:index会变化 -->
    <div v-for="(item, index) in items" :key="index">
      <input v-model="item.value" />
    </div>
    
    <!-- 删除第一个元素后:
         原来的index 1变成index 0
         导致input的值错乱 -->
    
    <!-- ✅ 正确:使用唯一ID -->
    <div v-for="item in items" :key="item.id">
      <input v-model="item.value" />
    </div>
  3. 误区:所有列表都需要key

    vue
    <!-- 静态列表,不需要key -->
    <div v-for="item in staticItems">
      {{ item }}
    </div>
    
    <!-- 动态列表,需要key -->
    <div v-for="item in dynamicItems" :key="item.id">
      {{ item }}
    </div>

💡 面试回答技巧

推荐回答顺序

  1. 先说diff算法的作用:对比虚拟DOM差异,最小化DOM操作
  2. 介绍Vue2双端比较:四种比较情况,适合简单场景
  3. 介绍Vue3 LIS算法:最长递增子序列,减少移动次数
  4. 说明key的作用:唯一标识节点,避免就地复用问题
  5. 对比性能差异:复杂场景下Vue3性能更优
  6. 补充编译时优化:静态标记等优化手段

🎯 一句话回答(快速版)

Vue的diff算法通过同层比较和key标识,将传统O(n³)复杂度优化到O(n)。Vue2用双端比较,Vue3用最长递增子序列算法,在复杂列表重排场景下移动次数更少,性能更好。

📣 口语化回答(推荐)

面试时可以这样回答:

"Vue的diff算法是虚拟DOM的核心,用来高效对比新旧虚拟DOM的差异。当数据变化时,Vue会生成新的虚拟DOM,然后和旧的虚拟DOM进行对比,找出需要更新的部分,最小化真实DOM操作。

传统的diff算法是O(n³)的复杂度,Vue做了优化,降到了O(n)。主要是三个策略:

第一,只比较同层节点,不跨层级比较。如果父节点不同,直接替换整个子树。

第二,不同类型的节点直接替换,不会去比较内容。比如div变成span,直接删掉div创建span。

第三,用key来标识节点。这样在列表更新时,Vue能知道哪些节点是移动了,哪些是新增或删除的。

Vue2使用双端比较算法,就是同时从新旧节点列表的头尾四个位置开始比较,包括头头比较、尾尾比较、头尾比较、尾头比较四种情况。这种算法对于简单的增删改操作效率很高。

Vue3改用了最长递增子序列算法,这是一个更先进的算法。它会先处理头尾相同的节点,然后对于中间复杂的部分,通过最长递增子序列找出哪些节点可以保持不动,只移动必须移动的节点。

主要区别是:Vue2可能会产生不必要的移动操作,而Vue3通过LIS算法能找到最优的移动策略,大大减少DOM操作次数。特别是在复杂的列表重排场景下,Vue3的性能提升非常明显。

说到key,它的作用是帮助Vue正确识别节点。如果不加key或者用index作为key,Vue会采用"就地复用"策略,可能导致一些奇怪的bug,比如输入框内容错乱。所以最佳实践是用唯一ID作为key。"

重点强调

  • diff算法是同层比较,时间复杂度O(n)
  • key用于正确识别节点,不是提高性能
  • Vue3的优化:静态标记、最长递增子序列
  • 不要使用index作为key
  • Vue3在复杂场景下性能显著优于Vue2

可能的追问

Q1: 为什么diff算法是O(n)而不是O(n³)?

A: 传统diff算法是O(n³)(树的编辑距离),Vue做了三个假设优化:

  1. 只比较同层节点,不跨层级
  2. 不同类型的节点直接替换
  3. 使用key识别节点 这样时间复杂度降到O(n)

Q2: 什么时候不需要key?

A:

  • 静态列表(不会改变)
  • 简单的文本列表(无状态)
  • 列表只用于展示(不涉及交互)

Q3: key相同但内容不同会怎样?

A: Vue会认为是同一个元素,只更新内容,复用DOM节点。

Q4: Vue3的diff算法有什么缺点吗?

A: 有一些权衡:

  • 空间复杂度增加:需要额外的数组存储映射关系
  • 算法复杂度:LIS算法本身比双端比较复杂
  • 小列表性能:对于很小的列表,Vue2可能更快

但总体来说,Vue3的优化是值得的,特别是在现代应用的复杂场景下。

Q5: 如何优化列表渲染性能?

A: 几个关键点:

  1. 正确使用key:使用稳定唯一的key
  2. 避免使用index作为key:特别是在会重排的列表中
  3. 合理使用v-show/v-if:根据场景选择
  4. 虚拟滚动:大列表使用虚拟滚动
  5. 分页或懒加载:避免一次渲染过多节点

加分项

  • 提到Vue3的编译时优化(静态提升、补丁标记)
  • 说明虚拟DOM的整体架构
  • 提到React的diff算法对比
  • 结合实际项目经验,说明性能优化的收益
  • 提到Vue3的其他性能优化(Proxy、Tree-shaking等)

💻 代码示例

完整的diff算法演示

查看完整代码(包含所有算法实现和测试用例):vue-diff-algorithm.js

简化的diff算法实现

javascript
// 简化的diff算法实现
class VNode {
  constructor(tag, props, children) {
    this.tag = tag;
    this.props = props;
    this.children = children;
    this.key = props?.key;
  }
}

function patch(oldVNode, newVNode, container) {
  // 1. 旧节点不存在,直接挂载
  if (!oldVNode) {
    mount(newVNode, container);
    return;
  }
  
  // 2. 新节点不存在,卸载旧节点
  if (!newVNode) {
    unmount(oldVNode);
    return;
  }
  
  // 3. 节点类型不同,替换
  if (oldVNode.tag !== newVNode.tag) {
    unmount(oldVNode);
    mount(newVNode, container);
    return;
  }
  
  // 4. 节点类型相同,更新
  const el = (newVNode.el = oldVNode.el);
  
  // 更新props
  patchProps(el, oldVNode.props, newVNode.props);
  
  // 更新children(核心diff算法)
  patchChildren(oldVNode, newVNode, el);
}

🔗 相关知识点

📚 参考资料

基于 MIT 许可发布 | 隐私政策