当前位置: 首页 > news >正文

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

一、前言

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

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

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 中,视图通常是由数据驱动的。

比如我们修改一个数据:

this.message="新的内容";

页面会跟着变化。

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

数据变化 -> 生成新的虚拟 DOM -> 对比新旧虚拟 DOM -> 更新真实 DOM

那虚拟 DOM 到底是什么?

简单来说:

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

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

2. 虚拟 DOM 的结构

比如下面这段真实 DOM:

<divid="app"class="mainContainer"><ulclass="container-ul"style="color:#fff;"><li>第一项</li><li>第二项</li><li>第三项</li></ul></div>

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

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

{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 更接近下面这些字段:

sel、data、children、text、elm、key

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

三、为什么需要虚拟 DOM

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

ul.innerHTML=`<li>第一项</li> <li>第二项 - 已更新</li> <li>第三项</li>`;

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

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

旧的虚拟 DOM:

h("ul",[h("li","第一项"),h("li","第二项"),h("li","第三项")]);

新的虚拟 DOM:

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. 安装

npminstallsnabbdom

2. 创建 patch 函数

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

import{init,classModule,propsModule,styleModule,eventListenersModule,h}from"snabbdom";constpatch=init([classModule,propsModule,styleModule,eventListenersModule]);

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

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

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

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

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

constvnode=h("div#app.mainContainer",[h("ul.container-ul",{style:{color:"#fff"}},[h("li","第一项"),h("li","第二项"),h("li","第三项")])]);

这里的:

h("div#app.mainContainer")

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

<divid="app"class="mainContainer"></div>

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

六、第一次渲染

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

<divid="app"></div>

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

constapp=document.getElementById("app");letoldVnode=patch(app,vnode);

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

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

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

<divid="app"></div>

虚拟节点也写成:

h("div#app.mainContainer",[])

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

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

七、更新视图

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

constnewVnode=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来判断哪些节点可以复用,哪些需要移动、创建或删除。

举个简单例子:

constoldVnode=h("p","旧内容");constnewVnode=h("p","新内容");

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

但如果是这样:

constoldVnode=h("p","旧内容");constnewVnode=h("div","新内容");

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

九、为什么列表中需要 key

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

假设有这样一个列表:

constlist=[{id:1,text:"第一项"},{id:2,text:"第二项"},{id:3,text:"第三项"}];

渲染成虚拟 DOM:

constvnode=h("ul",[h("li",{key:1},"第一项"),h("li",{key:2},"第二项"),h("li",{key:3},"第三项")]);

如果后来列表顺序变成:

constlist=[{id:3,text:"第三项"},{id:1,text:"第一项"},{id:2,text:"第二项"}];

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

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

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

<liv-for="item in list":key="item.id">{{ item.text }}</li>

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

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

十、虚拟 DOM 一定更快吗?

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

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

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。

可以简单理解为:

模板 -> 渲染函数 -> 虚拟 DOM -> 真实 DOM

数据变化时:

数据变化 -> 新的虚拟 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 如何更新,则由框架内部完成。

参考资料

  • Snabbdom GitHub README
  • Vue 官方文档:Rendering Mechanism
http://www.jsqmd.com/news/1116752/

相关文章:

  • GEO 是什么?本地获客服务商怎么选?这些坑一定要避开
  • WebAssembly 与 Rust 字符串传递:跨边界之前先想清内存所有权
  • 企业级 Claude Code 的统一记忆层,如何部署组织级 CLAUDE.md
  • 浪漫风女装用户情绪标签抓取程序,用于品牌短视频详细页精准文案匹配。
  • Vue 从零配置与完整使用教程(零基础保姆级)
  • 无人机视角航拍森林树木健康状况检测数据集VOC+YOLO格式276张4类别
  • IEEE Transactions on Vehicular Technology (TVT)投稿时间线记录
  • 射阳燃气灶维修检查点火和风门
  • 商场洗地机的应用特点与商超保洁使用优势
  • 2026交稿周论文工具实测:通用AI能写,谁更适合把结构、文献、排版一次拉顺?
  • OAuth2 + JWT 企业单点登录(SSO)实战:多系统一次登录全打通(SpringBoot)
  • 基于STM32单片机WIFI 物联网 云平台 宠物自动喂食器 定时提醒1(设计源文件+万字报告+讲解)(支持资料、图片参考_相关定制)_
  • 锡盟公考课程深度测评:三大机构横向对比
  • 从±0.1mm到±0.02mm:点胶设备精度提升背后的技术逻辑
  • STM32L432KC与WSEN-ISDS传感器的运动跟踪系统实现
  • AI coding 不仅有代码,Copliot 拓展至报表开发
  • 三节串联锂电池充电管理芯片IC完整资料包,5套方案原理图BOM打包带走
  • DeepSeek大模型技术解析与行业应用实践
  • FOLDED LIGHT LINE代表什么意义
  • 2026年7月球场围网厂家推荐甄选指南,立足实体生产深耕体育场地防护工程
  • HarmonyOS APP《画伴梦工厂》开发第23篇:图片上传服务设计(存根模式)
  • 推理延迟诊断指南,利用 rocprof 追踪 GPU 内核执行
  • 写了9年代码,我靠这8道架构题拿下了P7 offer
  • PCF8591与PIC18F86J55的信号转换系统设计与实现
  • 终极指南:如何用PingFangSC字体包构建专业级中文Web排版系统
  • 2026年7月防火门厂家推荐攻略|防火门、工业提升门、堆积门、学校门、挡烟垂壁靠谱厂家甄选
  • sql语法- MyBatis 中 <association> 标签的作用 1对1的情况
  • HoRain云--C++命名空间:解决冲突的终极指南
  • 量子计算商业化进入关键阶段:2026年哪些行业已经率先实现应用落地?
  • 毫米波人体动作姿态分类数据集3057张12类别