虚拟 DOM 是什么?从 Snabbdom 理解 Vue 的 DOM 更新机制

一、前言

在前端开发中,如果页面数据发生变化,最终还是要反映到真实 DOM 上。

最直接的做法当然是手动操作 DOM,比如找到某个节点,然后修改它的内容:

javascript 复制代码
document.querySelector("#app").innerHTML = "<p>新的内容</p>";

这种方式简单粗暴,小页面里也确实能用。但如果页面结构复杂,频繁使用 innerHTML 整块替换 DOM,就容易带来一些问题:

  1. 原来的 DOM 节点会被销毁,新节点会重新创建。
  2. 绑定在旧 DOM 节点上的事件可能会丢失。
  3. 浏览器需要重新解析 HTML 字符串,重新生成节点,更新成本会变高。
  4. 开发者需要自己判断哪些地方该改,代码后期会越来越难维护。

所以在 Vue 2 之后,我们经常会接触到一个概念:虚拟 DOM。

它不是为了让我们完全不操作真实 DOM,而是框架在真实 DOM 之前加了一层"描述层"。我们只需要关心数据和视图结构,至于真实 DOM 应该怎么更新,则交给框架内部处理。

这篇文章主要从 Snabbdom 入手,理解一下虚拟 DOM 的基本思想。

二、虚拟 DOM

1. 什么是虚拟 DOM

在 Vue 中,视图通常是由数据驱动的。

比如我们修改一个数据:

javascript 复制代码
this.message = "新的内容";

页面会跟着变化。

但页面变化的背后并不是"数据直接变成了 DOM",中间还会经历一套渲染流程。简单理解就是:

text 复制代码
数据变化 -> 生成新的虚拟 DOM -> 对比新旧虚拟 DOM -> 更新真实 DOM

那虚拟 DOM 到底是什么?

简单来说:

虚拟 DOM 就是用 JavaScript 对象来描述真实 DOM 结构。

它本身并不是真实的 DOM 节点,不能直接调用 appendChildquerySelector 这类 DOM API。它只是一个普通的 JS 对象,用来告诉框架:我想要的页面结构长什么样。

2. 虚拟 DOM 的结构

比如下面这段真实 DOM:

html 复制代码
<div id="app" class="mainContainer">
  <ul class="container-ul" style="color: #fff;">
    <li>第一项</li>
    <li>第二项</li>
    <li>第三项</li>
  </ul>
</div>

我们可以用一个 JS 对象来描述它。

这里先用一个简化版结构表示,方便理解:

javascript 复制代码
{
  tag: "div",
  props: {
    id: "app",
    className: "mainContainer"
  },
  children: [
    {
      tag: "ul",
      props: {
        className: "container-ul",
        style: {
          color: "#fff"
        }
      },
      children: [
        {
          tag: "li",
          children: ["第一项"]
        },
        {
          tag: "li",
          children: ["第二项"]
        },
        {
          tag: "li",
          children: ["第三项"]
        }
      ]
    }
  ]
}

这个对象大致包含三个核心信息:

  • tag:当前节点的标签名,比如 divulli
  • props:当前节点的属性,比如 idclassNamestyle
  • children:当前节点的子节点,可以是文本,也可以是其他虚拟节点。

需要注意的是,不同框架或虚拟 DOM 库的字段命名不一定一样。

比如 Snabbdom 中的 VNode 更接近下面这些字段:

text 复制代码
sel、data、children、text、elm、key

Vue 里的虚拟节点结构也有自己的实现细节。所以我们上面的 tag / props / children 只是为了方便理解,并不是某个库的完整源码结构。

三、为什么需要虚拟 DOM

如果没有虚拟 DOM,我们更新页面时,很容易写出这种代码:

javascript 复制代码
ul.innerHTML = `
  <li>第一项</li>
  <li>第二项 - 已更新</li>
  <li>第三项</li>
`;

这样确实能更新页面,但问题是:即使只有第二项发生变化,整个 ul 里面的内容也可能被重新生成。

而有了虚拟 DOM 之后,框架可以先在 JS 层面比较新旧结构:

旧的虚拟 DOM:

javascript 复制代码
h("ul", [
  h("li", "第一项"),
  h("li", "第二项"),
  h("li", "第三项")
]);

新的虚拟 DOM:

javascript 复制代码
h("ul", [
  h("li", "第一项"),
  h("li", "第二项 - 已更新"),
  h("li", "第三项")
]);

通过对比,框架可以发现:

  • ul 没有变。
  • 第一个 li 没有变。
  • 第二个 li 的文本变了。
  • 第三个 li 没有变。

最后只需要把第二个 li 的文本更新掉就可以了。

这里要注意一个说法:虚拟 DOM 的 diff 算法并不是每次都能算出"全局最小修改方案"。更准确地说,它是通过一些规则和策略,尽量减少不必要的 DOM 操作,让更新过程更加可控。

四、Snabbdom 简介

Snabbdom 是一个轻量级虚拟 DOM 库。

它的核心思想比较清晰:

  1. h 函数创建虚拟节点。
  2. patch 函数把虚拟节点渲染成真实 DOM。
  3. 数据变化后生成新的虚拟节点。
  4. 再次调用 patch,对比新旧虚拟节点并更新真实 DOM。

Vue 2 的虚拟 DOM 实现和 Snabbdom 的思路比较接近,所以用 Snabbdom 来理解虚拟 DOM 是一个不错的切入点。

五、Snabbdom 的基本使用

1. 安装

bash 复制代码
npm install snabbdom

2. 创建 patch 函数

Snabbdom 中需要先通过 init 创建一个 patch 函数。

javascript 复制代码
import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h
} from "snabbdom";

const patch = init([
  classModule,
  propsModule,
  styleModule,
  eventListenersModule
]);

这些模块负责处理不同类型的 DOM 更新:

  • classModule:处理 class。
  • propsModule:处理 DOM property。
  • styleModule:处理样式。
  • eventListenersModule:处理事件监听。

Snabbdom 的核心非常小,很多能力都是通过模块扩展进去的。这也是它比较适合拿来学习虚拟 DOM 的原因,结构不会太绕。

3. 使用 h 函数创建虚拟 DOM

Snabbdom 通常使用 h 函数创建虚拟节点:

javascript 复制代码
const vnode = h("div#app.mainContainer", [
  h("ul.container-ul", { style: { color: "#fff" } }, [
    h("li", "第一项"),
    h("li", "第二项"),
    h("li", "第三项")
  ])
]);

这里的:

javascript 复制代码
h("div#app.mainContainer")

表示创建一个类似这样的节点:

html 复制代码
<div id="app" class="mainContainer"></div>

也就是说,Snabbdom 的 h 函数支持类似 CSS 选择器的写法。

六、第一次渲染

假设页面中一开始有这样一个节点:

html 复制代码
<div id="app"></div>

我们可以这样把虚拟 DOM 渲染出来:

javascript 复制代码
const app = document.getElementById("app");

let oldVnode = patch(app, vnode);

这里有一个容易误解的地方。

patch(app, vnode) 并不是简单地把 vnode 生成的 DOM 插入到 app 里面,而是会用 vnode 生成的真实 DOM 去替换原来的 app 节点。

所以在示例中,最好让真实 DOM 和虚拟节点的选择器保持一致,比如真实节点是:

html 复制代码
<div id="app"></div>

虚拟节点也写成:

javascript 复制代码
h("div#app.mainContainer", [])

这样替换之后,页面结构仍然是我们预期的 div#app,只是它已经变成了 Snabbdom 管理的节点。

第一次 patch 执行之后,我们把返回值保存到 oldVnode 中。后面再次更新时,就需要拿它和新的 vnode 做对比。

七、更新视图

当数据变化后,我们重新创建一个新的虚拟 DOM:

javascript 复制代码
const newVnode = h("div#app.mainContainer", [
  h("ul.container-ul", { style: { color: "#fff" } }, [
    h("li", "第一项"),
    h("li", "第二项 - 已更新"),
    h("li", "第三项")
  ])
]);

oldVnode = patch(oldVnode, newVnode);

这一次 patch 的第一个参数不再是真实 DOM,而是上一次返回的旧 vnode。

Snabbdom 会对比 oldVnodenewVnode,发现只有第二个 li 的文本内容发生了变化,于是只更新对应的文本节点,而不是重新创建整棵 DOM 树。

这就是虚拟 DOM 更新的基本过程。

八、diff 算法

虚拟 DOM 中最关键的一步就是 diff。

所谓 diff,就是比较新旧两个虚拟 DOM,找出它们之间的差异,然后把这些差异应用到真实 DOM 上。

不过为了性能考虑,虚拟 DOM 的 diff 通常不会做非常复杂的跨层级比较,而是采用一些简化策略。

常见规则可以简单理解为:

  1. 如果两个节点类型不同,直接用新节点替换旧节点。
  2. 如果两个节点类型相同,就继续比较它们的属性和子节点。
  3. 比较子节点时,通常会结合 key 来判断哪些节点可以复用,哪些需要移动、创建或删除。

举个简单例子:

javascript 复制代码
const oldVnode = h("p", "旧内容");

const newVnode = h("p", "新内容");

这两个节点都是 p,所以不需要重新创建 p 标签,只需要更新里面的文本。

但如果是这样:

javascript 复制代码
const oldVnode = h("p", "旧内容");

const newVnode = h("div", "新内容");

节点类型从 p 变成了 div,这时通常就会直接替换节点。

九、为什么列表中需要 key

在列表渲染中,key 是一个非常重要的概念。

假设有这样一个列表:

javascript 复制代码
const list = [
  { id: 1, text: "第一项" },
  { id: 2, text: "第二项" },
  { id: 3, text: "第三项" }
];

渲染成虚拟 DOM:

javascript 复制代码
const vnode = h("ul", [
  h("li", { key: 1 }, "第一项"),
  h("li", { key: 2 }, "第二项"),
  h("li", { key: 3 }, "第三项")
]);

如果后来列表顺序变成:

javascript 复制代码
const list = [
  { id: 3, text: "第三项" },
  { id: 1, text: "第一项" },
  { id: 2, text: "第二项" }
];

有了 key 之后,diff 算法就能知道:这些节点不是全都变成了新节点,而是原来的节点位置发生了变化。

这样框架就可以尽量复用已有 DOM,而不是盲目删除再创建。

这也是为什么在 Vue 中使用 v-for 时,通常建议给每一项加上稳定且唯一的 key

html 复制代码
<li v-for="item in list" :key="item.id">
  {{ item.text }}
</li>

这里的 key 最好使用业务上稳定的唯一值,比如 id

不太建议使用数组下标作为 key,尤其是列表会新增、删除、排序的时候。因为下标会随着位置变化而变化,可能导致节点复用不符合预期。

十、虚拟 DOM 一定更快吗?

虚拟 DOM 不一定在任何情况下都比直接操作真实 DOM 快。

比如只是改一个明确的文本:

javascript 复制代码
document.querySelector("#title").textContent = "新标题";

这种写法肯定很直接,也没有创建虚拟 DOM 和 diff 的过程。

所以不能简单地说"虚拟 DOM 一定比真实 DOM 快"。

虚拟 DOM 更重要的价值在于:

  1. 让我们用声明式的方式描述 UI。
  2. 让框架统一管理视图更新。
  3. 在复杂页面中减少不必要的 DOM 操作。
  4. 让组件化、跨平台渲染、服务端渲染等能力更容易实现。

也就是说,虚拟 DOM 解决的重点不是某一次 DOM 操作谁更快,而是复杂应用里视图更新如何变得更好维护。

十一、Vue 中的虚拟 DOM 更新流程

结合 Vue 来看,整体流程大致是这样的:

  1. 模板被编译成渲染函数。
  2. 渲染函数执行后生成虚拟 DOM。
  3. 数据发生变化。
  4. Vue 重新执行渲染函数,生成新的虚拟 DOM。
  5. 新旧虚拟 DOM 进行 diff。
  6. 找出需要更新的地方后,更新真实 DOM。

可以简单理解为:

text 复制代码
模板 -> 渲染函数 -> 虚拟 DOM -> 真实 DOM

数据变化时:

text 复制代码
数据变化 -> 新的虚拟 DOM -> diff -> 更新真实 DOM

这也是为什么我们在 Vue 中大多数时候只需要关心数据,而不需要手动操作 DOM。

十二、总结

虚拟 DOM 本质上就是一个普通的 JavaScript 对象,它用来描述真实 DOM 的结构。

它的大致更新流程是:

  1. 用 JS 对象描述页面结构。
  2. 初次渲染时,根据虚拟 DOM 创建真实 DOM。
  3. 数据变化后,生成新的虚拟 DOM。
  4. 通过 diff 比较新旧虚拟 DOM。
  5. 将变化应用到真实 DOM 上。

需要注意的是,虚拟 DOM 并不是性能万能药。它的意义更多在于让页面更新变得可预测、可维护,同时把复杂的 DOM 更新逻辑交给框架处理。

对于 Vue、React 这类现代前端框架来说,虚拟 DOM 是连接"数据状态"和"真实页面"的中间层。开发者主要关注数据和组件结构,而真实 DOM 如何更新,则由框架内部完成。

参考资料

相关推荐
user62229864925811 小时前
Vue 常用技术知识全景:从响应式到组件通信的系统理解
前端
feiyu_gao1 小时前
一个人 + AI:246 commits 做出设计系统 CLI 的故事
前端·ai编程·交互设计
奶油mm1 小时前
从 0 到 1 搭建高可用 Redis Cluster:踩坑、优化与生产实践
前端
掘金安东尼2 小时前
Agent Loop 深度调研:把决定权交给模型的一次换代,为什么发生在现在
前端
亿元程序员2 小时前
Cocos视频拼图,终于支持微信小游戏了!
前端
JarvanMo2 小时前
Flutter 的默认颜色
前端
IT_陈寒2 小时前
Vite打包时踩的坑:静态资源为啥突然404了?
前端·人工智能·后端
神奇的程序员11 小时前
我的软件冲进苹果商店下载榜前 50 了
前端
阳光是sunny12 小时前
别再被 worktree 绕晕了!AI 编程时代你必须掌握的 Git 隔离神器
前端·人工智能·后端