真实 DOM 和虚拟 DOM

真实 DOM

真实 DOM 是浏览器能够直接解析和渲染的节点结构,当我们使用 JavaScript 操作 DOM 时,实际上是在与浏览器提供的 DOM API 进行交互。每次对真实 DOM 的修改(如创建、删除或更新节点)都可能触发浏览器的重排(reflow)和重绘(repaint),这些操作通常比较耗时。

例如,使用原生 JavaScript 创建并添加一个节点:

const div = document.createElement('div');
div.className = 'box';
div.textContent = 'Hello';
document.body.appendChild(div);

虚拟 DOM(VNode)

虚拟 DOM(VNode) 是用 JavaScript 对象来描述真实 DOM 结构的一种方式。它是对真实 DOM 的轻量级抽象,仅包含真实 DOM 的核心信息(如标签名、属性、子节点等)。

例如,一个 <div class="box">Hello</div> 对应的虚拟 DOM 对象可能如下:

const vnode = {
  type: 'div', // 节点类型
  props: { class: 'box' }, // 节点属性
  children: 'Hello' // 子节点
}

Vue 是如何生成虚拟 DOM 的?

注意:Vue 不会“把真实 DOM 转成虚拟 DOM”,而是用虚拟 DOM 来描述和生成真实的 DOM

Vue 通过 render 函数来创建虚拟 DOM。有以下几种方式:

方式一:手写 render 函数

render(h) {
  // h 函数参数:标签名/组件、属性对象、子节点
  return h('div', { 
    class: 'box'  // 设置 class 属性
  }, 'Hello')     // 文本内容作为子节点
}

方式二:使用模板(template)

<template>
	<div class="box">
		Hello
	</div>
</template>

在构建时,Vue 的 模板编译器(template compiler) 会自动把这段 HTML 模板编译成等价的 render 函数。

方式三:使用 JSX(在 Vue 3 中更常见)

render() {
  return (
	<div class="box">
		Hello
	</div>
  )
}

JSX 最终也会被 Babel 编译成 h() 调用。

渲染函数 render 是生成虚拟 DOM 的核心,其中的 h 函数是创建虚拟节点的关键方法。

知道了虚拟 DOM 是如何创建的,那么虚拟 DOM 如何变成真实 DOM?

Vue 组件在首次挂载时,会:

  1. 执行 render() 函数,生成 第一个虚拟 DOM(VNode)。

  2. 通过这个 VNode 创建真实 DOM 并插入页面。

  3. 此时没有“旧 VNode”用于对比,所以直接全量渲染。

后续更新时(例如响应式数据变化),会:

  1. Vue 触发组件重新渲染

  2. 再次调用 render() 函数,生成新的 VNode。

  3. 拿这个新的 VNode 和上一次保存的旧 VNode 做 Diff 对比。

  4. 根据差异更新真实 DOM。

  5. 更新完成后,新的 VNode 成为下一次的“旧 VNode”,被保存下来。

旧的 VNode 存在哪里?

Vue 会为每个组件实例维护一个内部属性,比如:

vm._vnode = VNode

这个 _vnode 就是上次渲染生成的虚拟 DOM 树。在下一次更新时,它就被当作“旧 VNode”用于 Diff。

旧的 VNode 会一直存在内存中吗?

是的,直到组件被销毁(beforeDestroy 时释放)。

一个简单的流程图:

组件状态变化
生成新的虚拟 DOM 树
对比新旧虚拟 DOM 树(Diff)
将差异应用到真实 DOM 上

Vue 使用虚拟 DOM 有哪些优缺点

优点

开发体验优势

Vue 结合虚拟 DOM 将传统的命令式编程变为声明式编程,声明式编程对比命令式编程有着更好的开发体验。开发者只需关注数据状态,无需手动操作 DOM,虚拟 DOM 会根据数据状态自动更新视图。

跨平台能力

虚拟 DOM 作为中间层,使得同一套代码可以渲染到不同平台,例如:

  • Web 平台:渲染为 <div>、<span> 等标准标签

  • 小程序平台:渲染为 <view>、<text> 等小程序组件

  • Native 平台:渲染为原生移动组件

性能优化

  • 批量更新:虚拟 DOM 可以将多次零散的 DOM 操作合并为一次,减少重排/重绘次数

    例如,向列表中添加 100 个元素:​

    • 直接操作真实 DOM:需执行 100 次 appendChild,触发 100 次重排。​

    • 虚拟 DOM:先在 JS 中构建完整的虚拟 DOM 列表,再一次性渲染为真实 DOM,仅触发 1 次重排。

  • 精准更新:通过 Diff 算法对比新旧虚拟 DOM,只更新变化的部分(而非全量替换)。

    例如:列表从 [A, B, C] 变为 [A, D, C]。真实 DOM 会删除 B、插入 D(2 次操作);虚拟 DOM 通过 Diff 发现只有第二个元素变化,直接修改 B 为 D(1 次操作)

缺点

内存占用

需要额外内存来存储虚拟 DOM 树

初始渲染成本

首次渲染需要先创建虚拟 DOM,再转换为真实 DOM

性能缺陷

对于简单的、少量的 DOM 操作,直接操作真实 DOM 可能更高效,因为虚拟 DOM 需要花费时间来计算找不同。

例如一个组件内有 100 个 li,我更新了其中的一个 li,这时候 Vue 框架会进行以下操作:

  1. Vue 的响应式系统触发该组件的重新渲染

  2. 生成新的 VNode 树:组件会执行 render 函数,生成一个由 100 个 li 组成的新的虚拟 DOM 树(VNode) 。

  3. 执行 Diff 比对:Vue 会将这个新的 VNode 与旧的 VNode 进行比对,最坏的情况要对比 100 次。

  4. 更新 DOM。

这个过程显然会比直接操作 DOM 更加耗时。

Vue 针对虚拟 DOM 的优化

编译时优化

Vue 模板编译器会分析模板中的节点,将静态节点(内容不会随数据变化的节点)标记为 static,并在虚拟 DOM 生成时进行复用,避免不必要的 Diff 计算。

<template>
  <div class="container">
    <!-- 静态节点:文本和class不会变化 -->
    <h1 class="title">Vue虚拟DOM优化</h1>
    <!-- 动态节点:文本随message变化 -->
    <p class="content">{{ message }}</p>
  </div>
</template>

编译后的虚拟 DOM 生成逻辑:

// 静态节点:仅在初始化时生成一次,后续复用
const staticVnode = {
  tag: 'h1',
  props: { class: 'title' },
  children: ['Vue虚拟DOM优化'],
  static: true  // 标记为静态节点
};

// 动态节点:数据变化时重新生成
function render() {
  return {
    tag: 'div',
    props: { class: 'container' },
    children: [
      staticVnode,  // 直接复用静态节点
      {
        tag: 'p',
        props: { class: 'content' },
        children: [this.message],  // 动态文本
        static: false
      }
    ]
  };
}

组件级更新

Vue 的响应式系统是基于组件级别的依赖追踪。

当一个组件依赖的数据发生变化时,这个组件会重新执行 render 函数(即重新渲染),生成新的虚拟 DOM 树。

当组件重新 render 时,会生成新的虚拟 DOM 树。

然后 Vue 会使用 Diff 算对比这个组件的新旧 VNode 树,逐层比较,找出需要更新的节点。

父组件中的数据发生了变化,子组件会进行 render 吗?

不一定。

  1. 父组件的变化与子组件无关(即:没有改变传给子组件的 props,且子组件的 tag、key 也未变):

    • 父组件重新 render,生成新的 VNode 树。

    • 在 patch 阶段,Vue 会对每个子节点进行 sameVnode 判断(检查类型、key、props 等核心特征)。

    • 由于子组件的 VNode 描述(类型、key、props 等)未变,Vue 判定可以复用该子组件实例,不会销毁重建子组件。

    • 因此:

      • 子组件 不会重新执行 render 函数

      • 子组件内部的 VNode 树由于新旧 VNode 完全一致,Diff 过程无实际差异对比(并非不执行 Diff 逻辑,而是 Diff 结果为 “无差异”,因此不会触发更新操作)

      • 子组件生命周期钩子(如 updated)也 不会触发

  2. 父组件的变化影响了子组件(如:传递的 props 改变,或 key 变化):

    • 父组件生成的新 VNode 中,在 patch 到该变化的子组件节点时,子组件节点的 props 或 key 发生了变化。

    • 若 key 变化:

      • sameVnode 判断为 false

      • 销毁旧子组件并创建新实例

      • 更新视图

    • 若只是 props 变化而 key 不变:

      • sameVnode 判断仍为 true(属于同一组件)

      • 重新执行 render() → 生成新的 VNode

      • 然后对子组件的内部 VNode 树进行 Diff

      • 更新视图

异步更新队列

在同一事件循环中的所有数据变更只会触发一次更新,减少了 Diff 计算和 DOM 操作的次数。(通过 nextTick 机制确保更新完成后执行回调)

精确的依赖追踪

只有当组件内部真正使用的数据发生变化时,才会触发该组件的重新渲染,无关数据变化不会产生影响。

除这些之外,还有【同级比较】、【使用 key 属性识别节点身份】、【双端比较算法】,这些属于 Vue Diff 的范畴。


相信在看完以上虚拟 DOM 的相关内容后,就会对 Diff 算法有一个初步的概念:

Diff 的对象是虚拟 DOM,更新真实 DOM 是 Diff 算法的结果。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐