虚拟 DOM 是什么?从 Snabbdom 理解 Vue 的 DOM 更新机制
一、前言
在前端开发中,如果页面数据发生变化,最终还是要反映到真实 DOM 上。
最直接的做法当然是手动操作 DOM,比如找到某个节点,然后修改它的内容:
document.querySelector("#app").innerHTML="<p>新的内容</p>";这种方式简单粗暴,小页面里也确实能用。但如果页面结构复杂,频繁使用innerHTML整块替换 DOM,就容易带来一些问题:
- 原来的 DOM 节点会被销毁,新节点会重新创建。
- 绑定在旧 DOM 节点上的事件可能会丢失。
- 浏览器需要重新解析 HTML 字符串,重新生成节点,更新成本会变高。
- 开发者需要自己判断哪些地方该改,代码后期会越来越难维护。
所以在 Vue 2 之后,我们经常会接触到一个概念:虚拟 DOM。
它不是为了让我们完全不操作真实 DOM,而是框架在真实 DOM 之前加了一层“描述层”。我们只需要关心数据和视图结构,至于真实 DOM 应该怎么更新,则交给框架内部处理。
这篇文章主要从 Snabbdom 入手,理解一下虚拟 DOM 的基本思想。
二、虚拟 DOM
1. 什么是虚拟 DOM
在 Vue 中,视图通常是由数据驱动的。
比如我们修改一个数据:
this.message="新的内容";页面会跟着变化。
但页面变化的背后并不是“数据直接变成了 DOM”,中间还会经历一套渲染流程。简单理解就是:
数据变化 -> 生成新的虚拟 DOM -> 对比新旧虚拟 DOM -> 更新真实 DOM那虚拟 DOM 到底是什么?
简单来说:
虚拟 DOM 就是用 JavaScript 对象来描述真实 DOM 结构。
它本身并不是真实的 DOM 节点,不能直接调用appendChild、querySelector这类 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:当前节点的标签名,比如div、ul、li。props:当前节点的属性,比如id、className、style。children:当前节点的子节点,可以是文本,也可以是其他虚拟节点。
需要注意的是,不同框架或虚拟 DOM 库的字段命名不一定一样。
比如 Snabbdom 中的 VNode 更接近下面这些字段:
sel、data、children、text、elm、keyVue 里的虚拟节点结构也有自己的实现细节。所以我们上面的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 库。
它的核心思想比较清晰:
- 用
h函数创建虚拟节点。 - 用
patch函数把虚拟节点渲染成真实 DOM。 - 数据变化后生成新的虚拟节点。
- 再次调用
patch,对比新旧虚拟节点并更新真实 DOM。
Vue 2 的虚拟 DOM 实现和 Snabbdom 的思路比较接近,所以用 Snabbdom 来理解虚拟 DOM 是一个不错的切入点。
五、Snabbdom 的基本使用
1. 安装
npminstallsnabbdom2. 创建 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 会对比oldVnode和newVnode,发现只有第二个li的文本内容发生了变化,于是只更新对应的文本节点,而不是重新创建整棵 DOM 树。
这就是虚拟 DOM 更新的基本过程。
八、diff 算法
虚拟 DOM 中最关键的一步就是 diff。
所谓 diff,就是比较新旧两个虚拟 DOM,找出它们之间的差异,然后把这些差异应用到真实 DOM 上。
不过为了性能考虑,虚拟 DOM 的 diff 通常不会做非常复杂的跨层级比较,而是采用一些简化策略。
常见规则可以简单理解为:
- 如果两个节点类型不同,直接用新节点替换旧节点。
- 如果两个节点类型相同,就继续比较它们的属性和子节点。
- 比较子节点时,通常会结合
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 更重要的价值在于:
- 让我们用声明式的方式描述 UI。
- 让框架统一管理视图更新。
- 在复杂页面中减少不必要的 DOM 操作。
- 让组件化、跨平台渲染、服务端渲染等能力更容易实现。
也就是说,虚拟 DOM 解决的重点不是某一次 DOM 操作谁更快,而是复杂应用里视图更新如何变得更好维护。
十一、Vue 中的虚拟 DOM 更新流程
结合 Vue 来看,整体流程大致是这样的:
- 模板被编译成渲染函数。
- 渲染函数执行后生成虚拟 DOM。
- 数据发生变化。
- Vue 重新执行渲染函数,生成新的虚拟 DOM。
- 新旧虚拟 DOM 进行 diff。
- 找出需要更新的地方后,更新真实 DOM。
可以简单理解为:
模板 -> 渲染函数 -> 虚拟 DOM -> 真实 DOM数据变化时:
数据变化 -> 新的虚拟 DOM -> diff -> 更新真实 DOM这也是为什么我们在 Vue 中大多数时候只需要关心数据,而不需要手动操作 DOM。
十二、总结
虚拟 DOM 本质上就是一个普通的 JavaScript 对象,它用来描述真实 DOM 的结构。
它的大致更新流程是:
- 用 JS 对象描述页面结构。
- 初次渲染时,根据虚拟 DOM 创建真实 DOM。
- 数据变化后,生成新的虚拟 DOM。
- 通过 diff 比较新旧虚拟 DOM。
- 将变化应用到真实 DOM 上。
需要注意的是,虚拟 DOM 并不是性能万能药。它的意义更多在于让页面更新变得可预测、可维护,同时把复杂的 DOM 更新逻辑交给框架处理。
对于 Vue、React 这类现代前端框架来说,虚拟 DOM 是连接“数据状态”和“真实页面”的中间层。开发者主要关注数据和组件结构,而真实 DOM 如何更新,则由框架内部完成。
参考资料
- Snabbdom GitHub README
- Vue 官方文档:Rendering Mechanism
