Skip to content

理解 Vue 的虚拟 DOM (Virtual DOM)

一、 什么是虚拟 DOM (VNode)?

虚拟 DOM (Virtual DOM) 的本质是一个普通的 JavaScript 对象,它用于描述视图的界面结构

  • 本质:一个 JS 对象,并非真实的 DOM 元素。它只是一个轻量级的对真实 DOM 的描述。

  • 作用:描述视图应该是什么样子,包括其标签、属性、子元素等。它本身不直接参与渲染。

  • 查看方式:在组件的 mounted生命周期钩子中,可以通过 this._vnode 访问到当前组件渲染出的虚拟 DOM 树。

    javascript
    // 在任意组件中
    mounted() {
      // this._vnode 就是当前组件生成的虚拟DOM
      console.log('当前组件的 VNode:', this._vnode);
    }
  • 结构:一个 VNode 对象大致包含以下关键信息:

    • tag: 元素的标签名,如 'div'
    • data: 一个对象,包含元素的属性、事件监听器、样式等,如 { attrs: { id: 'app' } }
    • children: 一个数组,包含了所有的子节点 VNode,构成了树形结构。
    • text: 节点的文本内容。

VNode 与组件的关系

  • 每个 Vue 组件都有一个 render 函数。
  • render 函数的作用就是创建并返回一个 VNode 树
  • 因此,每个组件实例都对应一棵自己的 VNode 树。当组件数据更新时,它会重新执行自己的 render 函数,生成一棵新的 VNode 树,用于后续的更新过程。

二、 为什么需要虚拟 DOM?

根本原因在于性能考量,这与 Vue 的渲染机制密切相关。

  1. render 函数的频繁执行

    • 首次渲染:组件创建时,会执行一次 render 函数。
    • 数据更新:当组件所依赖的响应式数据发生变化时,render 函数会重新执行
    • 由于执行非常频繁,如果 render 函数直接操作真实 DOM,性能开销会极其巨大。
  2. 直接操作真实 DOM 的性能瓶颈

    • 对象重量级:真实 DOM 元素的属性非常多,结构复杂,在 JS 中创建和操作它们比操作普通 JS 对象要慢得多。
    • 引发重排与重绘:对真实 DOM 的任何修改(即使是微小的属性变更),都可能导致浏览器进行重排 (Reflow)重绘 (Repaint),这些操作非常消耗性能。
  3. 性能对比: 通过简单的计时实验可以发现,创建大量(如 1000 万个)JS 对象的耗时,要比创建同等数量的真实 DOM 元素快几十倍甚至更多

    操作耗时 (示例)
    创建 1000 万个 JS 对象~130ms
    创建 1000 万个真实 DOM 元素~4000ms+

结论:为了解决 render 函数频繁执行和直接操作真实 DOM 的性能问题,Vue 引入了虚拟 DOM 作为中间层。它将所有 DOM 操作先在轻量级的 JS 对象 (VNode) 上进行,计算出最小的变更,最后才将这些变更一次性应用到真实 DOM 上。

三、 VNode 如何转换为真实 DOM?

这个过程分为两种情况:首次渲染更新

1. 首次渲染 (Initial Render)

当组件第一次被渲染时:

  1. 执行组件的 render 函数,生成一棵完整的 VNode 树
  2. Vue 会遍历这棵 VNode 树。
  3. 根据每个 VNode 的描述(如标签、属性、子节点),创建出对应的真实 DOM 元素
  4. 将创建好的真实 DOM 挂载到页面的指定位置。
  5. 重要:在此过程中,Vue 会在每个 VNode 上添加一个 elm 属性,指向其对应的真实 DOM 元素,建立起 VNode 和真实 DOM 的一一对应关系。

注意:在首次渲染时,Vue 的效率其实比直接操作 DOM 要低,因为它多了一个创建 VNode 的步骤。虚拟 DOM 的优势体现在后续的更新中。

2. 更新 (Update / Patching)

当组件因数据变化需要重新渲染时:

  1. 再次执行 render 函数,生成一棵新的 VNode 树
  2. 此时 Vue 不会立刻去操作真实 DOM。它会使用新的 VNode 树和上一次渲染时保存的旧 VNode 树进行对比 (Diff)。
  3. 这个对比过程被称为 Patch。Diff 算法会高效地找出两棵树之间的差异点(例如,哪个节点的文本变了,哪个节点的属性改了,哪个节点被添加或删除了)。
  4. Vue 只会针对找到的差异点去操作真实 DOM。由于旧 VNode 上保存着对应真实 DOM 的引用 (elm 属性),Vue 可以精确地、以最小量的方式更新真实 DOM。

核心优势:通过 Diff 和 Patch,Vue 保证了每次更新都只执行必要的 DOM 操作,从而避免了不必要的重排和重绘,极大地提高了渲染性能。

四、 模板 (Template) 和虚拟 DOM 的关系

开发者编写的模板(如 .vue 文件中的 <template> 部分)和 VNode 之间存在一个编译过程。

  • 最终目的:Vue 在运行时需要的是 render 函数,而不是模板字符串。render 函数才能生成 VNode。
  • 模板的作用:模板语法(如 v-if@click)是为了让开发者能够以更声明式、更直观的方式来描述界面结构,提升开发体验。直接手写 render 函数非常繁琐且不直观。

编译过程

Vue 的编译器模块 (Compiler) 负责将模板字符串转换为 render 函数。这个过程主要分两步:

  1. 解析 (Parse):将模板字符串解析成 AST (Abstract Syntax Tree, 抽象语法树)。AST 也是一个用 JS 对象来描述代码结构的树形结构,它与 VNode 类似但用途不同,专门用于编译阶段。
  2. 生成 (Generate):将 AST 转换为 render 函数的函数体字符串,最终生成 render 函数。

编译时机

  1. 预编译 (Ahead-of-Time, AOT)

    • 时机:在项目打包构建时(如执行 npm run build)。
    • 工具:由 Vue CLI 或 Vite 等构建工具完成。
    • 过程.vue 文件中的 <template> 会在打包时被直接编译成 render 函数,最终打包进产物中的是 render 函数,不包含模板字符串和编译器
    • 优点
      • 运行时性能更高:浏览器运行时无需再进行耗时的编译操作。
      • 打包体积更小:由于运行时不需要编译器,可以将其从最终的包中移除。
    • 这是 Vue CLI 默认且推荐的方式。
  2. 运行时编译 (Runtime Compilation)

    • 时机:在浏览器中,当组件第一次加载时。
    • 场景
      • 通过 <script> 标签直接引入完整版 vue.js
      • vue.config.js 中设置 runtimeCompiler: true
    • 过程:浏览器加载代码后,Vue 的编译器会获取组件的 template 字符串选项,并将其编译成 render 函数。
    • 缺点
      • 性能较低:编译是一个耗费性能的过程,会增加组件的加载和渲染时间。
      • 打包体积更大:最终的包里必须包含编译器代码。

语法转换

模板中的所有特定语法,在编译后都会转换成 VNode data 对象中的配置项,在 VNode 树中不存在模板语法本身。

示例

html
<div
  class="container"
  :id="dynamicId"
  @click="handleClick"
>
  {{ message }}
</div>

经过编译后,在 render 函数中会生成类似下面结构的 VNode:

javascript
// 编译后生成的 VNode 结构(示意)
{
  tag: 'div',
  data: {
    class: 'container', // 静态class
    attrs: {
      id: this.dynamicId // 动态绑定
    },
    on: {
      click: this.handleClick // 事件监听
    }
  },
  children: [
    {
      text: this.message // 插值文本
    }
  ]
}