技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

【前端面试】虚拟 DOM、Diff 算法和 key,别再答错了

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/55-【前端面试】面试官常问的虚拟dom,dom算法,key,别再答不出来了。
本文目录10 个章节
  1. 一、操作真实 DOM 一定慢吗
  2. 二、虚拟 DOM 是什么
  3. 三、Diff 的核心目标
  4. 四、为什么列表需要 key
  5. 五、无 key 的位置复用示例
  6. 六、Vue 2.7 的双端 Diff
  7. 七、Vue 3.5 的 keyed children 与 LIS
  8. 八、历史图片与 Diff 示意
  9. 九、面试中如何回答
  10. 十、总结

【前端面试】虚拟 DOM、Diff 算法和 key,别再答错了

本文保留原文“虚拟 DOM、Diff、key”面试题的主线,并修正“DOM 一定慢”“默认 key 是 index”“Vue 的横向 Diff 有 bug”等容易误导的说法。

版本说明:Vue 2.7 使用 Vue 2 的 VNode renderer 和四指针双端 Diff;Vue 3.5 使用编译器提示、Block Tree、Patch Flags,并在 keyed children 的中段使用最长递增子序列(LIS)减少移动。下面会分别标注。

一、操作真实 DOM 一定慢吗

这个问题本身不够准确。DOM 操作、JavaScript 计算、样式计算、布局、绘制和合成的成本取决于节点规模、节点类型、更新方式、浏览器和设备,不能用“DOM 一定慢”概括。

虚拟 DOM 也不是比真实 DOM 更快的魔法。Vue/React 最终仍然需要把变化提交给真实 DOM,虚拟 DOM 还会产生创建 VNode、执行渲染函数和比较新旧树的成本。它的价值主要是:

  1. 用声明式数据描述 UI,避免业务代码手动维护大量 DOM 状态;
  2. 将更新集中在 renderer 中,根据新旧 UI 描述提交必要的插入、删除、移动和属性更新;
  3. 让编译器和运行时可以做静态提升、Patch Flags、批量调度等框架级优化;
  4. 同一套组件模型可以适配不同的渲染目标。

因此:在一个已知的小范围更新中,手写 DOM 可能更直接;在复杂组件树中,Vue 的编译器和 renderer 可以减少业务层的重复工作。最终仍应使用 Chrome Performance、Vue DevTools 和真实用户数据验证,不要引用“1000 个 DOM 一定是毫秒级”或“10 万个 DOM 一定需要 30 秒”这类没有测试环境的数字。

二、虚拟 DOM 是什么

虚拟 DOM(VNode tree)可以理解为内存中的 UI 描述。它通常包含节点类型、属性、子节点、key、组件信息等,但不同框架、不同版本的字段并不相同。

下面只是教学模型,不是 Vue 3 VNode 的完整结构:

type SimpleVNode = {
  type: string | object
  props?: Record<string, unknown>
  children?: SimpleVNode[] | string
  key?: string | number | null
}

const vnode: SimpleVNode = {
  type: 'div',
  props: {
    class: 'red',
    onClick: () => console.log('click'),
  },
  children: [
    { type: 'span', children: 'Hello' },
    { type: 'span', children: 'Vue' },
  ],
}

Vue 3 可以使用模板、h() 或 JSX 产生 VNode:

import { h } from 'vue'

export default {
  props: { message: String },
  setup(props) {
    return () => h('p', props.message)
  },
}

模板通常更容易被 Vue 编译器分析和优化,不应该为了“手写 VNode 性能更高”而把所有模板改成 render function 或 JSX。

三、Diff 的核心目标

可以把 Diff 抽象成:

旧 VNode 树 + 新 VNode 树
            ↓
比较节点身份和内容
            ↓
提交必要的 host 操作

这个模型中的“patches”只是教学抽象。Vue 并不要求先生成一个公开的:

INSERT / TEXT / PROPS

补丁数组,而是在 patch/diff 过程中直接调用插入、移动、删除、设置文本和更新属性等 host 操作。

3.1 普通节点比较

简化流程可以写成:

  1. 新旧节点类型不同:卸载旧节点并创建新节点;
  2. 都是文本:比较文本内容,必要时更新 textContent
  3. 都是同类型元素:更新可能变化的属性,再比较 children;
  4. 都是同一组件:更新 props/slots,继续执行组件更新;
  5. children 是数组:根据是否 keyed 选择相应的 children 算法。

真实 Vue 还需要处理组件、Fragment、Teleport、Suspense、异步组件、指令、Transition 和 SSR hydration,面试时应把下面的流程称为“简化模型”。

四、为什么列表需要 key

key 的作用是给同一父级下的 VNode 提供稳定身份。推荐使用唯一、稳定、原始类型的业务 ID:

<div v-for="item in items" :key="item.id">
  <input v-model="item.name" />
</div>

当列表插入、删除、排序或过滤时,稳定 key 能让 Vue 判断“这是原来的哪个实体”,从而尽可能复用并移动正确的 DOM 或组件实例。

4.1 没有 key 不等于默认 key 是 index

这是一个常见错误:

  • 不写 key 时,VNode 的 key 仍然是 undefined,Vue 会采用不带身份提示的就地 patch;
  • 写成 :key="index" 时,才是显式把当前位置当作身份;
  • 两者在很多简单列表中表现相近,但概念上不是同一件事。

如果列表只显示简单文本、顺序永远不变、不包含输入框或有状态组件,不写 key 可能没有问题。对于可能重排或包含本地状态的列表,不要使用 index 作为 key:

<!-- 推荐:业务实体的稳定身份 -->
<ListItem
  v-for="item in items"
  :key="item.id"
  :item="item"
/>

<!-- 有重排、插入、删除时容易发生身份错配 -->
<ListItem
  v-for="(item, index) in items"
  :key="index"
  :item="item"
/>

改变一个节点的 key 会被 Vue 当作身份改变,通常会导致旧节点卸载、新节点创建;这可以用于有意重置组件状态,但不应无意中频繁改变。

五、无 key 的位置复用示例

考虑列表从 [1, 2, 3] 变成 [1, 3]

<li v-for="value in values">{{ value }}</li>

没有 key 时,Vue 可以按位置进行 patch:

  1. 第一个位置仍然是 1,复用;
  2. 第二个位置由 2 更新成 3
  3. 第三个位置被移除。

对于纯文本,这个结果完全可以接受,而且很高效。问题在于如果每个位置包含输入框或有内部状态的子组件,第二个位置的状态可能跟着“位置”走,而不是跟着业务实体 3 走。

加上稳定 key 后:

<li v-for="value in values" :key="value">
  {{ value }}
</li>

Vue 可以识别旧的 3 仍然存在,并根据需要移动/复用它。这个现象不是 Vue 的“横向比较 bug”,而是没有提供实体身份时选择位置复用的 trade-off。

六、Vue 2.7 的双端 Diff

Vue 2.7 的 updateChildren 使用常见的四指针双端算法。它同时维护:

oldStart、oldEnd、newStart、newEnd

简化后的顺序是:

  1. 旧首与新首比较;
  2. 旧尾与新尾比较;
  3. 旧首与新尾比较,可能表示节点向后移动;
  4. 旧尾与新首比较,可能表示节点向前移动;
  5. 如果都不匹配,再根据 key 查找旧节点并移动、复用或创建;
  6. 最后删除旧列表中剩余的节点,或创建新列表中剩余的节点。

Vue 2 的 sameVnode 也不只是比较 key,还会综合 tag、注释状态、data 是否存在、input type 等条件。key 是身份信息的重要组成部分,但不能把整个算法简化成“只比较 key”。

原文中的 reverse 示例可以这样理解:

this.list.reverse()

当使用 id 作为 key 时,旧的 ab 会被识别为同一批组件,只需要移动 DOM;当使用 index 作为 key 时,位置上的 props 会改变,子组件可能重新渲染。这个示例说明的是身份建模的重要性,不表示 index 在所有列表中都必然错误。

七、Vue 3.5 的 keyed children 与 LIS

Vue 3 的模板编译器会给 VNode 添加 Patch Flags 和 Block 信息。运行时会根据 KEYED_FRAGMENTUNKEYED_FRAGMENT 等信息选择路径,但这不是“所有节点都自动只比较新增节点”。

对于 keyed children,Vue 3 的中段流程可以概括为:

  1. 从头同步相同节点;
  2. 从尾同步相同节点;
  3. 处理纯追加或纯删除;
  4. 对未知中段建立 key -> newIndex 映射;
  5. patch 能找到的旧节点,卸载找不到的旧节点;
  6. newIndexToOldIndexMap 记录旧节点对应的新位置;
  7. 检测到确实发生重排时计算最长递增子序列;
  8. 从后向前移动不在 LIS 中的旧节点,并挂载新节点。

LIS 表示可以保持相对顺序的那部分已有节点,其他节点才需要移动。它不是所有更新都必然执行,也不是“只对新增节点做比较”。

示意代码:

old: [a, b, c, d]
new: [b, a, d, e]

- b、a、d 可以复用
- e 是新节点,需要 mount
- 通过 LIS 尽量少移动已有节点
- c 在新列表不存在,需要 unmount

Vue 3 还会利用静态提升、静态节点缓存、Patch Flags 和 Block Tree 减少不必要的运行时工作。开发者不应手动复制 _misStatic 或内部 patch flag;这些属于编译器/runtime 实现细节。

八、历史图片与 Diff 示意

以下图片保留原文的历史示意,仅用于帮助理解,不代表某个 Vue 版本的完整源码流程:

VNode 属性变化示意图(原文历史资料,本地化)

VNode 删除子树示意图(原文历史资料,本地化)

无 key 列表位置复用示意图(原文历史资料,本地化)

原文另一张列表示意图存在身份连线与文字说明不一致的问题,因此不再作为结论依据。

图片中的 patchtree diffcomponent diff 是便于面试表达的概念模型;实际 Vue 2/3 renderer 的分支更多,不能据此推断所有节点都按图片中的步骤更新。

九、面试中如何回答

可以用下面的顺序回答:

虚拟 DOM 是对 UI 的内存描述。Vue 在响应式状态变化后重新得到新的 VNode,并通过 renderer 比较新旧 VNode,提交必要的 DOM 操作。它不保证任何场景都比手写 DOM 快,实际还会受编译器优化、布局绘制和更新范围影响。列表中的 key 用来描述同级节点的稳定身份,重排、插入、删除或包含有状态子节点时应使用稳定业务 ID;index 不是绝对禁止,但不适合身份会变化的列表。Vue 2 常见的是四指针双端 Diff,Vue 3 还结合 Patch Flags、Block Tree,并在 keyed children 的必要场景使用 LIS 减少移动。

十、总结

  • DOM 不等于慢,虚拟 DOM 也不是无成本;
  • VNode 是 UI 描述,Diff 的目标是提交必要更新;
  • 无 key 不是“默认 key=index”;
  • 稳定 key 主要解决列表实体身份和状态复用问题;
  • Vue 2.7 和 Vue 3.5 的 renderer 不能混成一套源码;
  • Vue 3 的 Patch Flags、Block Tree 和 LIS 是实现细节,不能简化为“只更新新增节点”;
  • 是否优化要结合 Vue DevTools、Chrome Performance 和真实用户指标验证。

原文主题和历史示意图予以保留;Diff、key 和 renderer 结论已按 Vue 2.7/Vue 3.5 官方资料重新整理。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS