Skip to content

Vue 的虚拟 DOM、diff 与渲染更新

这篇不准备泛泛去讲“所有前端框架的虚拟 DOM”。

这一篇只聚焦 Vue 自己 的几件事:

  1. Vue 里的虚拟 DOM 到底是什么
  2. 为什么 Vue 不是每次都直接手改真实 DOM
  3. Vue2 和 Vue3 的 render -> vnode -> patch 链路是怎么跑的
  4. Vue3 的 Patch FlagBlock Tree、静态提升到底在优化什么

如果把主线压成一句话:Vue 的虚拟 DOM,本质上是运行时用来描述界面结构和更新差异的一层中间表示;响应式系统负责触发更新,虚拟 DOM 负责把更新更有组织地落到页面。

1. Vue 里的虚拟 DOM 到底是什么

很多人第一次听到“虚拟 DOM”,很容易把它理解成:浏览器 DOM 的完全复制品。

这不准确。

实际写项目时,更多是这样:Vue 的虚拟 DOM 是一棵由 JavaScript 对象组成的 vnode 树,用来描述当前界面应该长成什么样。

例如模板:

vue
<div class="card">
  <span>{{ title }}</span>
</div>

在 Vue 的运行时语境里,最终会对应成某种 vnode 结构。可以粗略理解成:

ts
const vnode = {
  type: 'div',
  props: { class: 'card' },
  children: [
    {
      type: 'span',
      children: 'hello'
    }
  ]
}

这不是 Vue 真实源码里的完整结构,但足够帮助我们建立第一层认识:

  1. type:节点类型
  2. props:属性
  3. children:子节点

所以把边界放回原位看:虚拟 DOM 不是浏览器提供的对象,而是 Vue 运行时里用来描述视图树的一种 JavaScript 数据结构。

2. 为什么 Vue 不直接每次都操作真实 DOM

这点不要讲成“真实 DOM 一定很慢”这种绝对化结论。

Vue 引入虚拟 DOM,主要是为了统一解决下面几件事:

  1. 组件更新时,先有一层稳定的视图描述
  2. 可以在 JavaScript 层比较新旧结构差异
  3. 最后只把必要的变化落到真实 DOM
  4. 让模板、渲染函数和运行时更新机制接到一条稳定主线上

也就是说,Vue 不是简单在追求:少改几次 DOM。

它真正想得到的是:一套可组合、可推导、可 patch 的 UI 描述和更新模型。

3. Vue 的渲染链路可以怎么记

不管是 Vue2 还是 Vue3,都可以记这条主线:

  1. 模板被编译成渲染函数
  2. 渲染函数执行,生成 vnode 树
  3. 运行时把 vnode 挂载成真实 DOM
  4. 状态变化后,再生成新的 vnode 树
  5. 新旧 vnode 做 diff
  6. 最后 patch 到真实 DOM

如果压缩成一行,就是:template -> render -> vnode -> patch

4. Vue2 里的虚拟 DOM 和更新流程

Vue2 的整体思路可以拆开来看:

  1. 模板编译成渲染函数
  2. 渲染 watcher 执行 _render()
  3. _render() 生成 vnode
  4. _update() 把 vnode 挂到页面
  5. 依赖变化后,watcher 再次执行
  6. 新旧 vnode 进入 patch 流程

4.1 一段源码风格的简化示例

js
/**
 * Vue2 渲染更新的简化示意
 * 功能:
 * - 把组件渲染包装成一个 watcher
 * - 每次依赖变化后重新执行 _render 和 _update
 * 参数:
 * - vm: 当前组件实例
 * 返回值:
 * - 真实场景里会得到一个渲染 watcher 实例
 */
new Watcher(vm, () => {
  // 根据当前状态生成最新 vnode 树
  const vnode = vm._render()
  // 把 vnode patch 到真实 DOM
  vm._update(vnode)
})

这段代码最重要的意思是:

  1. Vue2 的组件渲染本身是被 watcher 驱动的
  2. _render() 负责生成虚拟 DOM
  3. _update() 负责把虚拟 DOM 更新到页面

4.2 Vue2 的 diff 在做什么

当状态变化时,Vue2 不会直接整页重建 DOM。

更常见的流程是:

  1. 先拿到新的 vnode 树
  2. 和旧 vnode 树对比
  3. 找出哪些节点真的变了
  4. 只 patch 这些变化

所以 Vue2 的关键不是:有了虚拟 DOM 就完全不需要真实 DOM 操作。

而是:真实 DOM 操作被延后到了 patch 阶段,并且尽量只做必要修改。

5. Vue3 里的虚拟 DOM 和更新流程

Vue3 的主线和 Vue2 没有变成另一种完全不同的世界,它仍然遵循:render -> vnode -> patch

但 Vue3 做了两类重要升级:

  1. 响应式系统升级
  2. 编译器和虚拟 DOM 更新策略升级

5.1 一段源码风格的简化示例

ts
/**
 * Vue3 组件更新的简化示意
 * 功能:
 * - 重新执行组件渲染逻辑
 * - 对比前后 vnode 树
 * - 把差异 patch 到页面
 * 参数:
 * - instance: 当前组件实例
 * 返回值:
 * - 无显式返回值,副作用是更新 instance.subTree 和真实 DOM
 */
function componentUpdateFn(instance) {
  // 生成当前最新的 vnode 树
  const nextTree = renderComponentRoot(instance)
  const prevTree = instance.subTree
  instance.subTree = nextTree
  // 只把差异 patch 到页面
  patch(prevTree, nextTree)
}

这段代码最重要的意思是:

  1. Vue3 仍然是 vnode diff + patch 这条主线
  2. 组件更新时会拿新旧两棵树做对比
  3. 运行时最终还是要把差异落到真实 DOM

6. Vue3 比 Vue2 在虚拟 DOM 层到底强在哪

最容易讲偏的一点,是把答案只说成:Vue3 更快。

这不够。

Vue3 在虚拟 DOM 相关层面的升级,主要不是“diff 算法换了个魔法版本”,而是:

编译器提前告诉运行时哪些节点会变、哪些节点不会变,从而让 patch 更有针对性。

这就是为什么 Vue3 的虚拟 DOM 优化,必须连着编译器一起理解。

7. Patch Flag 是什么

Patch Flag 可以看成 编译阶段给 vnode 打上的“更新提示标签”

它在回答:这个节点下一次更新时,真正可能变化的部分是什么?

例如有的节点:

  1. 只有文本会变
  2. 只有 class 会变
  3. 只有 style 会变
  4. 某些 props 会变

那运行时 patch 时,就不必“把整块再仔细检查一遍”,而是可以更有针对性。

7.1 一个很粗略的理解示意

ts
const vnode = createVNode('div', { class: cls }, text, PatchFlags.TEXT)

这段不是要你背 API,而是想表达:

  1. 编译器已经提前分析出“这个节点主要是文本在变”
  2. 运行时 patch 就能少做一些无意义检查

8. Block Tree 是什么

Block Tree 这个词看起来有点抽象。

可以把它理解成:Vue3 会把模板里真正可能发生动态变化的节点尽量圈出来,形成一棵更关注动态部分的结构。

它想解决的问题是:更新时,不要每次都把整棵树从头到尾按同样力度检查。

所以把话收回来:

  1. 普通 vnode 树描述的是整体结构
  2. Block Tree 更强调“动态节点集合”

它和 Patch Flag 一起,构成了 Vue3 虚拟 DOM 更新更细粒度的基础。

9. 静态提升在优化什么

静态提升 可以看成 把那些完全不会变的节点,在编译阶段就提出来复用

这样做的价值是:

  1. 不必在每次渲染时都重新创建这部分 vnode
  2. patch 时也能更快跳过稳定部分

例如:

vue
<div>
  <h1>固定标题</h1>
  <span>{{ count }}</span>
</div>

这里的 h1 如果内容完全静态,就不必每次更新 count 时都重新按动态节点处理。

10. Vue2 和 Vue3 在虚拟 DOM 层的核心差异

可以用一张表压缩理解:

维度Vue2Vue3
基本主线render -> vnode -> patchrender -> vnode -> patch
更新触发watchereffect + scheduler
编译提示相对较弱更积极
动态节点识别运行时承担更多编译阶段承担更多
patch 粒度更偏经典模式更细粒度

所以实际写项目时,更多是这样:Vue2 和 Vue3 都用虚拟 DOM,但 Vue3 更擅长把“哪些节点真的会变”这件事前移到编译阶段。

11. 一张总图看清 Vue 的虚拟 DOM 主线

mermaid
flowchart TD
    A[模板 template] --> B[编译成 render 函数]
    B --> C[执行 render 生成 vnode 树]
    C --> D[首次 patch 挂载成真实 DOM]
    E[响应式状态变化] --> F[重新执行 render]
    F --> G[生成新 vnode 树]
    G --> H[与旧 vnode diff]
    H --> I[patch 到真实 DOM]

    J[Vue3 编译优化] --> K[Patch Flag]
    J --> L[Block Tree]
    J --> M[静态提升]
    K --> H
    L --> H
    M --> H

12. 一段更贴近 Vue3 编译优化思路的简化示意

ts
/**
 * 这是“思路示意”,不是完整源码
 * 功能:
 * - 编译阶段把动态节点信息提前挂到 vnode 上
 * 参数:
 * - type: 节点类型
 * - props: 节点属性
 * - children: 子节点
 * - patchFlag: 当前节点的动态提示信息
 * 返回值:
 * - 一个带有动态更新提示的 vnode
 */
function createVNode(type, props, children, patchFlag) {
  return {
    type,
    props,
    children,
    patchFlag
  }
}

这段代码想表达的不是具体实现细节,而是:Vue3 的 vnode 不只是“结构描述”,还可能带着编译阶段提前分析出来的更新提示。

13. 工程里最容易讲偏的地方

最常见的问题包括:

  1. 把虚拟 DOM 说成“真实 DOM 的完整替身”
  2. 只会说“虚拟 DOM 快”
  3. 只讲响应式,不讲 render -> vnode -> patch
  4. 只讲运行时,不讲 Vue3 编译优化
  5. 把 Vue 的虚拟 DOM 和别的框架混着讲,最后丢掉 Vue 自己的重点

14. 一份检查清单

当你回答 Vue 的虚拟 DOM 时,至少可以问自己:

  1. 我有没有讲清 vnode 是什么
  2. 我有没有讲清 render -> vnode -> patch
  3. 我有没有说明 Vue2 和 Vue3 都在走这条主线
  4. 我有没有讲出 Vue3 的优势来自编译器 + 运行时配合
  5. 我有没有把答案收口在 Vue 本身,而不是泛讲所有框架

15. 小结

如果把这篇压缩成一句话,可以记住:

Vue 的虚拟 DOM,是 Vue 运行时用来描述界面和组织更新的一层 vnode 结构;Vue2 和 Vue3 都走 render -> vnode -> patch 这条主线,而 Vue3 的优势在于编译器会提前分析动态节点,让 patch 更有针对性。

基于 VitePress 构建的个人技术笔记。