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