在前端框架横行的今天,“虚拟DOM”早已不是陌生的词汇。Vue、React等主流2框架的核心特性之一,面试中高频追问的底层考点,甚至很多开发者会下意识认为虚拟DOM就是比真实DOM快的错误认知——但这些认知,大多停留在八股文层面。
本文会从“为什么需要虚拟DOM”出发,来讲虚拟DOM。
一、虚拟BOM的本质,从来不是“快”
我们先推翻一个最常见的误区:虚拟DOM的核心价值,不是比真实DOM快,而是让DOM操作更可控、更高效、更具有可扩展性。
很多初学者会陷入一个误区:认为虚拟DOM之所以被框架采用,是因为它比直接操作真实DOM更快。但事实是,在简单场景下(比如单个DOM节点的修改),直接操作真实DOM,反而比虚拟DOM的流程更快——因为虚拟DOM“要先生成VNode、Diff对比,然后在创建对应的DOM节点,再生成真实DOM”。
那为什么框架还要用虚拟DOM?答案很简单:真实DOM的痛点,从来不是“慢”,而是“操作不可控、开销不可预测、开发效率低”。
1.真实DOM的核心痛点:昂贵且不可控
真实DOM是浏览器提供的原生API,它每一次操作,都会触发浏览器的重排或重绘——这两个操作的开销极高,因为浏览器需要重新计算页面布局、绘制像素,尤其是在频繁更新、大规模DOM节点操作时,会出现明显的性能瓶颈。
现代浏览器虽然会自动合并同步代码中的 DOM 操作,但如果我们在业务中频繁、分散地触发状态更新, 或出现 DOM 读写交替,依然会导致浏览器强制多次重排,产生布局抖动。
除此之外,真实DOM还存在一个致命问题:与平台强耦合。它依赖浏览器的DOM API,无法在非浏览器环境中运行,这就限制了前端代码的跨平台能力。
2.虚拟DOM的本质:状态与视图之间的“可编程中间层”
基于真实DOM的痛点,虚拟DOM应运而生。它的本质,是一层抽象层——用纯JS对象(称为VNode,虚拟节点),对真实DOM的结构、属性、子节点进行“描述”,相当于给真实DOM做了一个“JS快照”
但这只是表面,更深层次的本质是:虚拟DOM是状态与真实DOM之间的可编程中间层,它将“频繁、不可控的DOM操作”,转化为“可计算、可优化、可批量更新的JS运算
这句话可以拆解为三个核心点,也是理解虚拟DOM的关键:
- 抽象性:虚拟DOM不依赖浏览器环境,纯粹是JS对象,可在任何支持JS的环境中运行(这是跨平台的基础);
- 延迟性:修改状态时,不会立即操作真实DOM,而是先修改虚拟DOM(JS运算),再通过Diff算法找到最小修改量,批量更新真实DOM;
- 可编程性:我们可以通过代码干预虚拟DOM的生成、Diff、更新过程,实现各种优化(比如Vue3的编译时优化、React的Fiber调度)。
举个通俗的例子:真实DOM就像一块直接暴露在外面的黑板,每一次修改都要直接在黑板上涂抹,不仅麻烦,还容易弄脏;虚拟DOM就像一块草稿纸,我们先在草稿纸上反复修改、定稿,最后只把“需要修改的部分”一次性抄写到黑板上——既高效,又不会造成多余的“污染”。
二、深入底层:虚拟DOM的核心组成与工作流程
理解了虚拟DOM的本质,我们再深入它的底层细节:虚拟DOM到底由什么组成?从状态更新到视图渲染,它的完整工作流程是怎样的?这部分是重点,也是面试中高频追问的内容,掌握后,你就能轻松应对“虚拟DOM如何工作”的问题。
1. 核心组成:VNode(虚拟节点)的结构
虚拟DOM的最小单元是VNode(Virtual Node),它是一个纯JS对象,用来描述一个真实DOM节点的所有信息。不同框架的VNode结构略有差异,但核心属性基本一致,我们以最简洁的结构为例,拆解它的组成:
// 简易VNode结构(模拟Vue/React的核心属性)
const VNode = {
type: 'div', // 节点类型(元素、文本、组件等)
props: { // 节点属性(class、style、事件等)
className: 'container',
onClick: () => console.log('click')
},
children: [ // 子节点(数组,每个元素也是VNode)
{ type: 'span', props: {}, children: 'Hello Virtual DOM' }
],
key: '1', // 用于Diff算法的唯一标识
el: null // 关联的真实DOM节点(渲染后赋值)
}
这里有几个关键属性,需要重点理解:
- type:决定节点的类型,可能是HTML标签(div、span)、文本节点(text)、组件(Vue/React组件),不同类型的节点,渲染逻辑不同;
- props:存储节点的属性,包括HTML属性(class、id)、样式(style)、事件(onClick)等,相当于真实DOM的attributes和properties;
- children:子节点列表,子节点也都是VNode,形成嵌套结构,对应真实DOM的树形结构;
- key:唯一标识,用于Diff算法中快速定位节点,避免不必要的节点销毁和重建,提升Diff效率;
- el:关联的真实DOM节点,当虚拟DOM渲染为真实DOM后,会将真实节点赋值给el,方便后续更新和操作。
需要注意的是:VNode只是一个“描述性对象”,它本身不具备任何DOM操作能力,也不依赖浏览器环境——哪怕在Node.js中,我们也能生成VNode,这就是虚拟DOM跨平台的基础。
2. 完整工作流程:从状态更新到视图渲染
虚拟DOM的工作流程,本质上是“状态更新 → 生成新VNode → Diff对比 → 批量更新真实DOM”的循环,我们拆解为4个核心步骤,结合代码示例,让你直观理解每一步的作用:
步骤1:初始化渲染——将VNode转化为真实DOM
当页面首次渲染时,框架会根据初始状态,生成一棵完整的VNode树(虚拟DOM树),然后通过“挂载(mount)”过程,将VNode树转化为真实DOM树,插入到页面中。
这个过程的核心是“递归遍历VNode树”,根据每个VNode的type,创建对应的真实DOM节点,设置属性、挂载子节点,最终完成渲染。
// 简易mount函数:将VNode转化为真实DOM
function mount(vnode, container) {
// 1. 根据type创建真实DOM节点
const el = document.createElement(vnode.type);
// 2. 设置节点属性(props)
for (const [key, value] of Object.entries(vnode.props)) {
if (key.startsWith('on')) {
// 处理事件(如onClick)
el.addEventListener(key.slice(2).toLowerCase(), value);
} else {
// 处理普通属性(如className、id)
el.setAttribute(key, value);
}
}
// 3. 递归挂载子节点
if (vnode.children) {
vnode.children.forEach(child => mount(child, el));
}
// 4. 将真实DOM插入容器,关联el属性
container.appendChild(el);
vnode.el = el;
}
步骤2:状态更新——生成新的VNode树
当页面状态发生变化(比如Vue的data修改、React的setState),框架不会立即修改真实DOM,而是根据新的状态,生成一棵新的VNode树。
这里的关键是:新VNode树与旧VNode树是相互独立的,它们都是对当前视图的“快照”,修改新VNode树,不会影响旧VNode树和真实DOM——这就是虚拟DOM“延迟更新”的核心。
比如,我们修改了文本内容,会生成一棵新的VNode树,其中只有文本节点的children发生了变化,其他节点保持不变。
步骤3:Diff对比——找到最小修改量(核心环节)
Diff算法(也叫协调算法,Reconciliation)是虚拟DOM的核心,它的目的是:对比旧VNode树和新VNode树的差异,找到“最小修改量” ,避免大面积重建真实DOM。
很多人会误以为Diff是“全量对比”,但实际上,为了提升效率,Diff算法遵循两个核心假设(这也是所有框架Diff的基础):
- 同层对比:只对比同一层级的VNode,不跨层级对比(因为跨层级的DOM操作极少,全量跨层级对比会增加不必要的开销);
- key唯一:拥有相同key的VNode,被认为是同一个节点,只需要更新其属性和子节点,不需要销毁重建。
Diff的核心流程可以概括为3步:
- 对比当前层级的VNode类型:如果类型不同(比如div变成span),直接销毁旧节点,创建新节点,无需深入对比子节点;
- 如果类型相同,对比props:只更新变化的属性(比如class变化、事件变化),不变的属性不做操作;
- 对比children:通过key匹配子节点,找到新增、删除、移动的节点,批量更新。
这里需要强调:Diff算法的核心不是“快”,而是“高效找到最小修改量”——它牺牲了少量JS运算时间,换取了真实DOM操作的最小化,从而避免了频繁重排/重绘,在复杂场景下提升整体性能。
步骤4:批量更新——将差异应用到真实DOM
Diff对比完成后,会得到一个“差异补丁集”(包含所有需要修改的节点和操作),然后框架会将这些补丁批量应用到真实DOM上——这一步才是真正的DOM操作,而且是“最小化”的操作。
比如,我们只修改了文本内容,Diff后只会找到文本节点的差异,然后只修改该节点的文本内容,不会触动其他节点,也不会触发多余的重排/重绘。
到这里,虚拟DOM的完整工作流程就结束了——整个过程,将“频繁的DOM操作”转化为“JS运算(生成VNode、Diff)”,再通过“批量更新”,实现了DOM操作的可控和高效。
三、框架实践:Vue3中的虚拟DOM优化(深度进阶)
理解了虚拟DOM的底层逻辑后,我们结合Vue3的实践,看看框架是如何优化虚拟DOM的——这部分能让你跳出“通用虚拟DOM”的理解,深入到实际框架的落地,也是面试中“高级前端”的区分点。
很多人说“Vue3的虚拟DOM变慢了”,但实际上,Vue3的虚拟DOM做了大量优化,核心是“编译时优化”,让虚拟DOM的Diff过程更高效,甚至在很多场景下可以“跳过Diff”。
1. 编译时优化:从“运行时Diff”到“编译时预判”
Vue2的虚拟DOM是“纯运行时”的:不管模板是什么结构,都会生成VNode树,然后进行全量Diff——哪怕模板中有些节点是固定不变的(比如静态文本、静态属性),也会参与Diff,造成不必要的开销。
Vue3则引入了“编译时优化”:在模板编译阶段,会对模板进行静态分析,标记出“静态节点”“静态属性”“动态节点”,生成优化后的代码,从而在运行时跳过不必要的Diff。
比如,模板中的静态文本节点,编译时会被标记为“静态节点”,运行时不会生成VNode,也不会参与Diff,直接复用之前的真实DOM节点——这就大大减少了JS运算和Diff的开销。
2. PatchFlag:精准定位动态节点
Vue3在生成VNode时,会给动态节点添加一个“PatchFlag”(补丁标记),用来标记该节点的“动态部分”(比如动态文本、动态属性、动态class等)。
在Diff过程中,只要看到VNode有PatchFlag,就会只对比标记的动态部分,跳过静态部分——比如一个节点只有文本是动态的,Diff时就只对比文本内容,不对比其他静态属性,进一步提升Diff效率。
// Vue3编译后生成的VNode(简化版)
const VNode = {
type: 'div',
props: { className: 'container' },
children: [
{ type: 'span', props: {}, children: '静态文本' },
{ type: 'span', props: {}, children: ctx.msg, patchFlag: 1 /* 标记文本动态 */ }
]
}
3. 静态提升:复用静态节点
Vue3会将模板中的静态节点(比如固定的文本、固定的元素)进行“静态提升”——将其提取到渲染函数之外,只生成一次VNode,后续每次渲染都复用这个VNode,不需要重新生成,也不需要参与Diff。
这种优化,在静态内容较多的页面(比如官网、文档)中,能显著提升渲染性能,因为大量静态节点不需要再进行JS运算和Diff对比。
看到这里,相信你已经彻底了解了虚拟DOM,再也不是停留在“JS对象模拟DOM”的浅层理解。
最后,我们用一句话总结虚拟DOM的核心价值,帮你牢牢记住:
虚拟DOM的本质,是一层状态与真实DOM之间的可编程中间层,它通过纯JS对象描述DOM、Diff算法找到最小修改量、批量更新真实DOM,实现了DOM操作的可控、高效与跨平台,是现代前端框架的核心基石之一。