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

显示模式

登录
ARCHIVE DOCUMENTVUE

虚拟 DOM 与 DOM Diff 的原理

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/96-虚拟DOM与DOM Diff 的原理
本文目录9 个章节
  1. 一、虚拟 DOM 是什么
  2. 二、如何创建虚拟 DOM
  3. 三、VDOM 的优点与边界
  4. 四、DOM Diff 的基本逻辑
  5. 五、为什么需要 key
  6. 六、Vue 2 与 Vue 3 的 Diff 差异
  7. 七、把 Diff 过程串起来
  8. 总结
  9. 参考资料

虚拟 DOM 与 DOM Diff 的原理

原文从 Vue/React vnode 的结构、创建方式、VDOM 的优缺点、Tree Diff、组件/元素 Diff 和 key 讲起。本文保留原有的面试学习路线,修正损坏的对象字面量、key 默认值、性能绝对结论和“Vue 横向比较存在 bug”等表述,并补充 Vue 2 与 Vue 3 的真实更新策略。

一、虚拟 DOM 是什么

虚拟 DOM 是用 JavaScript 数据结构描述 UI 树的一种方式。一个简化 vnode 可以包含:

  • 节点类型或标签名;
  • props/attrs/class/style;
  • 事件处理器或事件描述;
  • 子节点;
  • key、组件类型和 renderer 内部信息。

Vue 2 的教学模型

Vue 2 的真实 VNode 字段比下面更多,但可以先这样理解:

const vue2VNode = {
  tag: 'div',
  data: {
    class: 'red',
    on: {
      click: () => console.log('clicked'),
    },
  },
  children: [
    { tag: 'span', text: 'hello' },
  ],
  key: undefined,
}

Vue 3 的教学模型

const vue3VNode = {
  type: 'div',
  props: {
    class: 'red',
    onClick: () => console.log('clicked'),
  },
  children: [
    'hello',
  ],
  key: null,
  patchFlag: 0,
}

Vue 2 和 Vue 3 的 vnode 字段不能混用。Vue 3 的 patchFlagshapeFlagdynamicChildren 等是编译器和运行时使用的内部信息,业务代码不应手动依赖这些字段。

Vue render 函数创建 vnode 的示意图(已本地化)

二、如何创建虚拟 DOM

1. Vue 模板

Vue 的模板不是浏览器原生 HTML 字符串。构建时,Vue 编译器会把模板转换为 render function:

<template>
  <button class="primary" @click="count++">
    {{ count }}
  </button>
</template>

概念上会生成类似:

import { createElementBlock, toDisplayString } from 'vue'

function render(_ctx) {
  return createElementBlock(
    'button',
    {
      class: 'primary',
      onClick: () => _ctx.count++,
    },
    toDisplayString(_ctx.count),
  )
}

真实生成代码会带有 block、patch flag、缓存和辅助函数,具体结果取决于 Vue 版本、编译模式和模板结构。

Vue 模板编译到 render function 的示意图(已本地化)

2. Vue render function 与 h

Vue 3 手写 render function 通常使用 h

import { h } from 'vue'

export default {
  props: {
    title: String,
  },
  setup(props) {
    return () => h('h1', { class: 'title' }, props.title)
  },
}

Vue 2 则常见:

export default {
  render(h) {
    return h('h1', { class: 'title' }, this.title)
  },
}

Vue 2 的 h 参数和 Vue 3 h 的 vnode 结构不同,迁移时不能只把 render(h) 中的字段照搬过去。

3. React 的 createElement 与 JSX

React 的 JSX 概念上会变成:

const element = React.createElement(
  'div',
  { className: 'red' },
  React.createElement('span', null, 'hello'),
)

JSX 是创建描述对象的语法方式,不等于 vnode 本身;Vue 同样可以使用 JSX 插件,但模板和 render function 已经足够生成 vnode。

React JSX 转换为 createElement 的示意图(已本地化)

JSX/模板与 vnode 关系示意图(已本地化)

三、VDOM 的优点与边界

1. 减少不必要的更新

如果更新前后只有一个文本节点发生变化,renderer 可以只修改对应文本,而不必重新创建整棵 DOM 树:

old vnode → <p>old</p>
new vnode → <p>new</p>

结果:修改 p 的文本节点

但“VDOM 把 1000 次 DOM 操作合并成一次”不是通用事实。框架可能批量调度组件更新,也可能最终提交多次属性、文本和节点操作;是否合并、合并到什么程度由 renderer 和具体更新决定。

2. 提供统一的渲染抽象

同一套 vnode/组件思想可以通过不同 renderer 映射到 DOM、服务端字符串、原生平台或自定义目标。跨平台不是 vnode 自动变成 iOS/Android,而是需要目标平台对应的 renderer 或编译适配层。

3. 不是天然更快

VDOM 增加了创建 vnode、比较和运行时调度成本。对简单且已知的局部变化,直接操作 DOM 可能更快;对复杂声明式 UI,VDOM 提供统一的组件和更新模型。Vue 3 还使用静态提升、patch flags、block tree 和响应式更新范围减少运行时工作。

因此,不能把“操作 DOM 必然慢”“VDOM 必然快”或固定的 Vue/React 秒数作为结论。大规模列表还要结合分页、虚拟列表和实际性能分析。

四、DOM Diff 的基本逻辑

Diff 发生在 vnode 或渲染树层面,最终由 patch/renderer 把必要变化提交到真实目标。可以把它抽象为:

const patches = diff(oldVNode, newVNode)
applyPatches(rootElement, patches)

现代 Vue/React 内部未必生成这样的公开补丁数组;它们可能边比较边调用 host operations。补丁模型适合解释原理。

一个简化补丁可能是:

[
  { type: 'INSERT', vnode: newVNode },
  { type: 'TEXT', text: 'new text' },
  { type: 'PROPS', props: { class: 'active' } },
]

1. Tree Diff

一般会从根开始按层级比较:

  • 同一位置的节点类型不同:卸载旧节点,挂载新节点;
  • 类型相同:继续比较 props、文本和 children;
  • 子节点通常只在同一父节点下比较,不会为任意两棵树寻找全局最优跨层级匹配;
  • 组件、Fragment、Teleport、Suspense 和文本节点会走不同 renderer 分支。

Tree Diff 同层级比较示意图(已本地化)

2. Component Diff

组件节点比较的重点是组件类型和 key:

旧:<Dialog :open="true" />
新:<Dialog :open="false" />

组件类型相同,通常复用组件实例并更新 props/slots;组件类型或 key 不同,则会创建新的组件实例并卸载旧实例。组件内部随后由自己的渲染 effect 产生子树更新。

3. Element Diff

原生元素节点比较标签和 key:

旧:<div class="old">text</div>
新:<div class="new">text2</div>

类型相同,更新 class 和文本;

旧:<div>...</div>
新:<p>...</p>

类型不同,通常直接替换根节点及其子树。真实 renderer 还会处理事件、DOM property、指令、transition 和 SVG namespace。

元素节点属性与文本更新示意图(已本地化)

五、为什么需要 key

1. 没有 key 时的按位置复用

旧列表:

[1, 2, 3]

新列表:

[1, 3]

对于没有 key 的列表,renderer 可以按位置复用:第一个位置继续复用,第二个位置把原来的 2 更新为 3,最后移除多余节点。这对纯文本列表可能没有问题,但如果每行有输入框、组件实例或本地状态,就可能让状态跟着位置而不是跟着业务项走。

这通常不是“Vue 横向比较存在 bug”,而是 unkeyed list 明确选择的按位置复用策略。开发者需要根据列表是否重排、是否保留子组件状态,选择是否提供 key。

2. 有稳定 key 时

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

旧数据:

[
  { id: 1, value: 'one' },
  { id: 2, value: 'two' },
  { id: 3, value: 'three' },
]

删除 id 为 2 的项后,renderer 可以识别 id 为 3 的 vnode 仍然是原来的节点,保留正确的组件实例和 DOM 状态。

没有 key 时按位置复用的示意图(已本地化)

有稳定 key 时识别节点身份的示意图(已本地化)

3. index 不是“绝对不可以”但通常不合适

原文说“默认 key 就是 index,绝对不可以用 index”,需要改得更准确:

  • Vue 的 vnode key 不会自动为所有 v-for 生成业务意义上的 index key;
  • 没有 key 时,列表会走 unkeyed/按位置的更新路径;
  • 使用 :key="index" 是显式把位置当作身份;
  • 如果列表只追加、只截取、不排序、不删除中间项,并且子项没有需要保留的状态,index 风险较低;
  • 可插入、删除、排序或包含表单/组件状态的列表,应使用稳定且唯一的业务 id。

列表删除元素时 index key 与稳定 key 的差异(已本地化)

列表插入元素时 key 帮助移动节点的示意图(已本地化)

六、Vue 2 与 Vue 3 的 Diff 差异

方面Vue 2Vue 3
子节点策略updateChildren 双端比较、key 映射patchKeyedChildren,头尾同步、key 映射和移动优化
编译信息运行时优化较少patch flags、静态提升、block tree
Fragment组件模板通常需要单根原生支持多根节点
vnode 字段tag/data/children/elmtype/props/shapeFlag/patchFlag
更新范围组件渲染 watcher 触发更新组件渲染 effect + scheduler

Vue 3 keyed children 在复杂移动场景会建立新旧索引映射,并使用最长递增子序列尽量减少移动。它仍然是启发式算法,不保证任何输入都得到理论上的最少 DOM 操作。

七、把 Diff 过程串起来

一次典型更新可以抽象成:

响应式数据变化
  ↓
组件更新任务进入 scheduler
  ↓
执行 render,产生 new vnode
  ↓
比较 old vnode 与 new vnode
  ↓
匹配同类型/同 key 节点
  ↓
更新 props、文本、子节点或移动列表项
  ↓
调用 renderer 提交真实 DOM 操作

这也解释了为什么“Diff 只在 VDOM 上发生”并不意味着真实 DOM 不重要:Diff 的结果最终仍要经过浏览器 DOM 的属性更新、节点插入、删除、移动、布局和绘制。

总结

  1. 虚拟 DOM 是描述 UI 的 JavaScript 数据结构,Vue 和 React 的内部字段并不相同;
  2. 模板、JSX 和 render function 都可以生成 vnode,但它们是创建方式,不是 vnode 本身;
  3. Diff 通常遵循同层比较、类型不同替换、类型相同继续 patch 的原则;
  4. 无 key 的列表常按位置复用,稳定 key 用于表达节点身份和保留组件/DOM 状态;
  5. index key 不是任何场景都错误,但在可重排、可删除和有内部状态的列表中风险较大;
  6. Vue 2 主要使用双端 Diff,Vue 3 还结合编译器优化、动态子树和 keyed children 移动优化;
  7. VDOM 没有固定的性能保证,应该用真实场景和性能工具验证。

参考资料

原文作者:凯小默。原文关于 vnode、模板/JSX、VDOM 优缺点、Tree Diff、组件/元素 Diff、key 和 patch 的内容予以保留;错误的 VNode 对象语法、空图片引用、固定性能数据、Vue 横向 Diff bug 和“index 绝对禁止”等绝对表述已修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS