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

显示模式

登录
ARCHIVE DOCUMENTVUE

你对虚拟 DOM 原理的理解?

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/62-你对虚拟DOM原理的理解
本文目录10 个章节
  1. 一、什么是 Virtual DOM
  2. 二、为什么需要 Virtual DOM
  3. 三、VNode 的创建
  4. 四、从 VNode 到真实 DOM
  5. 五、Virtual DOM 的 Diff
  6. 六、Vue 3.5 的 keyed diff 与 LIS
  7. 七、Vue 3 的编译器优化
  8. 八、Virtual DOM 的优缺点
  9. 九、面试版回答
  10. 参考资料

你对虚拟 DOM 原理的理解?

原文以 Vue 2、Snabbdom 和早期 Vue 3 预告为背景。本文保留“VNode 创建—真实 DOM 挂载—Diff—优化”的学习路径,并补充 Vue 3.5 的编译器优化、Block Tree、Patch Flags 和 keyed diff。

一、什么是 Virtual DOM

Virtual DOM(虚拟 DOM)是对 UI 的一种 JavaScript 数据描述。一个 VNode 通常会描述:

  • 节点类型或组件类型;
  • props、class、style、事件等数据;
  • 子节点;
  • key 等身份信息;
  • 某些 renderer 使用的运行时字段。

它不是浏览器 DOM 的完整复制,也不是一个可以直接显示的 DOM。Vue 会先根据模板或 render function 生成 VNode,再由 renderer 把 VNode 挂载或 patch 到目标平台。

Virtual DOM 与真实 DOM 的历史示意图(已本地化)

一个极简的 VNode 可以写成:

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

const vnode: VNode = {
  type: 'div',
  props: { class: 'container' },
  children: [
    {
      type: 'span',
      props: null,
      children: 'Hello Vue',
    },
  ],
}

实际 Vue 3 的 VNode 还包含 shape flags、patch flags、组件实例、DOM 引用等内部字段;不能把上面的教学对象当作 Vue 源码结构。

VNode 数据结构历史示意图(已本地化)

二、为什么需要 Virtual DOM

1. 减少手写 DOM 的工作

框架让开发者描述状态和视图关系,状态变化后由 renderer 计算并提交必要的 DOM 更新:

响应式状态变化
      ↓
生成新的 VNode/渲染结果
      ↓
比较新旧结果
      ↓
提交必要的 DOM 操作

这可以降低手动维护 DOM、事件和状态同步的复杂度,提高开发效率。它不代表开发者永远不能使用 DOM API,也不代表框架会替开发者修复所有低效逻辑。

2. 批量更新和编译优化

Vue 会把同一轮同步代码触发的多次响应式变化进行调度和批量处理。Vue 3 模板编译器还会标记静态节点和动态属性,帮助运行时跳过不需要更新的部分。

因此,“Virtual DOM 的核心就是把所有 DOM 操作合并成一次”并不准确:实际提交可能包含多次属性更新、文本更新、插入、移动和删除;性能收益来自更新范围、编译信息、批处理和真实 DOM 操作数量的综合结果。

3. 跨平台的抽象

VNode 是平台无关的描述,可以被不同 renderer 消费。Vue 3 的 createRenderer() 允许为自定义平台提供节点创建、插入、删除、属性设置等宿主操作:

// 仅展示 renderer 的宿主操作形状,不是完整实现
const rendererOptions = {
  createElement(type: string) {
    return platformCreateElement(type)
  },
  insert(child: unknown, parent: unknown, anchor?: unknown) {
    platformInsert(child, parent, anchor)
  },
  setElementText(node: unknown, text: string) {
    platformSetText(node, text)
  },
  patchProp(node: unknown, key: string, prev: unknown, next: unknown) {
    platformPatchProp(node, key, prev, next)
  },
}

SSR 也使用 Vue 的渲染模型,但“VNode 是 JavaScript 对象”不等于它自动就能跨任何平台;服务端渲染、原生渲染和自定义 renderer 仍然需要各自的实现。

三、VNode 的创建

3.1 模板编译

Vue 模板会在构建阶段编译成 render function。Vue 3 的示意结果类似:

import { createElementVNode as _createElementVNode } from 'vue'

function render(_ctx, _cache) {
  return _createElementVNode('div', null, 'Hello Vue')
}

真实编译结果会根据静态节点、动态 class、动态 text 等内容增加 hoisted nodes 和 Patch Flags。不要在业务代码中依赖 _createElementVNode_hoisted_1 等编译器私有变量。

3.2 h() / render function

需要手动生成 VNode 时,可以使用公开的 h() API:

import { h } from 'vue'

const vnode = h(
  'div',
  { class: 'container', id: 'app' },
  [
    h('span', null, 'Hello Vue'),
    h('button', { onClick: () => console.log('click') }, '点击'),
  ],
)

早期 Snabbdom 教程会提供一个简化版 vnode()h(),大意是把 typekeydatachildrentext 组装成 JavaScript 对象:

const Text = Symbol('Text')

function createVNode(type, props = null, children = null, key = null) {
  return { type, props, children, key }
}

function h(type, props, ...children) {
  const normalizedChildren = children.flat(Infinity).map(child => {
    return typeof child === 'object'
      ? child
      : createVNode(Text, null, String(child))
  })

  return createVNode(type, props, normalizedChildren, props?.key ?? null)
}

这是教学代码,不包含 Vue 的组件、Fragment、Teleport、事件 patch、异步组件和 shape flags。

VNode 创建函数历史示意图(已本地化)

四、从 VNode 到真实 DOM

一个极简的 DOM renderer 可以这样理解:

function mount(vnode, container) {
  if (vnode.type === Text) {
    vnode.el = document.createTextNode(vnode.children)
    container.appendChild(vnode.el)
    return
  }

  const el = document.createElement(vnode.type)
  vnode.el = el

  if (typeof vnode.children === 'string') {
    el.textContent = vnode.children
    container.appendChild(el)
    return
  }

  for (const [key, value] of Object.entries(vnode.props || {})) {
    if (key === 'class') {
      el.className = value
    } else if (key.startsWith('on')) {
      el.addEventListener(key.slice(2).toLowerCase(), value)
    } else {
      el.setAttribute(key, value)
    }
  }

  for (const child of vnode.children || []) {
    mount(child, el)
  }

  container.appendChild(el)
}

真实 Vue 还要处理 SVG、属性与 DOM property 的区别、事件更新、组件、Fragment、Teleport、Transition、指令、注释和 hydration。原文中的 createElm() 是 Snabbdom/Vue 2 renderer 语境下的源码片段,依赖 apicbs、hooks 等上下文,直接复制出来并不能独立运行。

VNode 挂载真实 DOM 的历史示意图(已本地化)

五、Virtual DOM 的 Diff

Diff 的目标不是比较两个 JavaScript 对象的每个字段,而是根据新旧 VNode 结果计算需要提交到宿主环境的更新。

理想的通用树编辑距离算法可能达到 O(n³),但 Vue、React 等框架会基于 UI 的常见规律做假设:

  1. 不跨层级寻找任意节点,主要比较同一父节点下的 children;
  2. 不同类型的节点通常直接替换子树;
  3. 列表通过稳定 key 提供节点身份;
  4. 编译器和运行时尽量跳过静态内容。

在这些假设下,常见同层比较可以接近 O(n),但“O(n)”是算法模型或典型场景的概括,不能保证每次更新都严格是 O(n),也不能因此断言 Virtual DOM 必然比手写 DOM 快。

Diff 同层比较历史示意图(已本地化)

5.1 节点是否可以复用

教学版可以先用以下规则判断:

function isSameVNode(oldVNode, newVNode) {
  return oldVNode.type === newVNode.type &&
    oldVNode.key === newVNode.key
}

Vue 2 的 sameVnode 还会检查 tag、注释、data、输入元素类型等;Vue 3 使用 isSameVNodeType,也会结合 VNode 类型和 key。相同 key 并不意味着一定可以复用,不同 type 的 VNode 仍可能需要替换。

5.2 Vue 2.7 的双端比较

原文引用的是 Vue 2/早期 Snabbdom 常见的 updateChildren 双端算法。它维护旧 children 和新 children 的头尾指针,循环尝试:

  1. 旧头与新头;
  2. 旧尾与新尾;
  3. 旧头与新尾(节点向右移动);
  4. 旧尾与新头(节点向左移动);
  5. 如果都不匹配,再通过 key map 查找新头对应的旧节点。

双端 Diff 四种比较历史示意图(已本地化)

简化伪代码如下:

function updateChildren(oldChildren, newChildren) {
  let oldStart = 0
  let oldEnd = oldChildren.length - 1
  let newStart = 0
  let newEnd = newChildren.length - 1

  while (oldStart <= oldEnd && newStart <= newEnd) {
    const oldHead = oldChildren[oldStart]
    const oldTail = oldChildren[oldEnd]
    const newHead = newChildren[newStart]
    const newTail = newChildren[newEnd]

    if (isSameVNode(oldHead, newHead)) {
      patch(oldHead, newHead)
      oldStart++
      newStart++
    } else if (isSameVNode(oldTail, newTail)) {
      patch(oldTail, newTail)
      oldEnd--
      newEnd--
    } else if (isSameVNode(oldHead, newTail)) {
      patch(oldHead, newTail)
      moveAfter(oldHead.el, oldTail.el)
      oldStart++
      newEnd--
    } else if (isSameVNode(oldTail, newHead)) {
      patch(oldTail, newHead)
      moveBefore(oldTail.el, oldHead.el)
      oldEnd--
      newStart++
    } else {
      // 根据 key 建立旧节点索引,查找并移动或创建 newHead
      patchOrCreateByKey(newHead)
      newStart++
    }
  }

  if (newStart <= newEnd) insertRemaining(newChildren, newStart, newEnd)
  if (oldStart <= oldEnd) removeRemaining(oldChildren, oldStart, oldEnd)
}

这段代码用于理解指针和移动方向,不是 Vue 2.7 可直接替换的源码。真实实现还处理空 VNode、组件、插槽、生命周期和模块 hooks。

旧尾新头移动历史示意图(已本地化)

旧头新尾移动历史示意图(已本地化)

5.3 key 的作用

列表 key 应该是稳定、唯一的原始值,通常来自业务 ID:

<li v-for="item in items" :key="item.id">
  {{ item.name }}
</li>

没有 key 时,Vue 通常按位置进行就地 patch;有 key 时,renderer 可以把旧节点与新列表中的同一业务实体匹配起来,并在需要时移动真实 DOM。不要把 key 理解为“让所有节点都重新渲染”,它主要表达身份。

index 不是任何情况下都绝对错误,但当列表会插入、删除、排序,或子组件/input 有局部状态时,使用 index 会导致状态被错误复用:

<!-- 有重排风险时不推荐 -->
<Row
  v-for="(item, index) in items"
  :key="index"
  :item="item"
/>

key 查找旧节点历史示意图(已本地化)

原文数组示例可以这样理解:

旧列表:[1, 2, 3, 4, 5]
新列表:[1, 4, 6, 1000, 100, 5]

先匹配两端可以快速确认 15;中间的 4 如果有稳定 key,可以复用旧节点并移动;61000100 如果没有对应 key,就需要创建新节点;旧的 23 可能被删除。具体操作顺序取决于 renderer 的算法和 key。

列表头尾指针推进历史示意图(已本地化)

新增与删除节点历史示意图(已本地化)

六、Vue 3.5 的 keyed diff 与 LIS

Vue 3 的 patchKeyedChildren 不只是 Vue 2 双端算法的简单复制。对于 keyed children,运行时会先处理相同的前缀和后缀,再建立 key 到新索引的映射,处理移动/新增/删除,并在需要移动的区间计算最长递增子序列(LIS)。

LIS 的直觉是:已经按照新顺序递增的旧节点可以尽量保持原位置,只移动不在这条序列中的节点。例如:

旧顺序:[a, b, c, d]
新顺序:[d, a, b, c]

可以把 d 移到最前,而不是把 abc 全部移动一次。LIS 只是 keyed diff 的一个步骤,不能简化为“Vue 3 只移动一个节点”或“Vue 3 永远比 Vue 2 快”。

七、Vue 3 的编译器优化

Vue 3 的性能重点不仅在运行时 Diff,还在 compiler-informed VDOM:

  • 静态提升:静态 VNode 只创建一次;
  • Patch Flags:标记动态 text、class、style 或 props;
  • Block Tree:收集动态后代,更新时跳过稳定的静态树;
  • 内联事件缓存:在适合的场景避免不必要的事件函数创建;
  • SSR 优化:服务端字符串拼接和客户端 hydration 有专门路径。
<!-- 编译器可以识别 title 是动态的,静态结构仍可复用 -->
<div class="card">
  <h2>固定标题</h2>
  <p>{{ title }}</p>
</div>

因此,面试回答“Vue 3 借鉴 Inferno,所以只靠 LIS 变快”是不完整的。Vue 3 的编译器、响应式调度、Block Tree、Patch Flags、renderer 和 keyed diff 共同影响性能。

八、Virtual DOM 的优缺点

优点

  • 把视图描述和手动 DOM 操作分离,提高开发效率;
  • 可以进行响应式批量更新和统一 patch;
  • VNode 作为中间描述有利于 SSR、测试和自定义 renderer;
  • 配合稳定 key、编译器优化和合理组件边界,可以在复杂 UI 中取得较好平衡。

局限

  • 生成 VNode、比较和调度本身有 CPU 与内存成本;
  • 极简单的局部 DOM 更新,手写 DOM 可能更直接、更快;
  • 不合理的组件更新、庞大列表、错误 key 和过度响应式仍然会很慢;
  • Virtual DOM 不会自动带来网络优化、图片优化、数据分页或 Web Worker;
  • 跨平台需要对应 renderer,不能仅凭 JavaScript 对象自动完成。

九、面试版回答

Virtual DOM 是对 UI 的 JavaScript 描述,Vue 会根据模板或 render function 创建 VNode,并在响应式状态变化后生成新的 VNode,通过 renderer 比较新旧结果,把必要的属性、文本、插入、移动和删除提交到真实 DOM。Vue 2.7 常见的是同层比较和双端 Diff;Vue 3.5 还结合模板编译器的静态提升、Patch Flags、Block Tree,以及 keyed children 的移动和 LIS 优化。Virtual DOM 不是必然比手写 DOM 快,它的价值主要是声明式开发、批量更新和跨 renderer 抽象,实际性能仍要结合场景测量。

参考资料

原文作者:寻找海蓝。原文的 VNode、双端 Diff、LIS 和示意图内容予以保留,示例代码与 Vue 2.7/Vue 3.5 版本边界已重新整理。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS