写Vue笔记到第三篇,终于轮到虚拟DOM了。这个知识点在Vue的学习路线里挺特别:你说它抽象,它其实不过是一层JS对象;你说它简单,Vue的diff算法、响应式更新、编译优化全都绕着它在转。网上讲虚拟DOM的文章不少,但大多要么停留在“什么是虚拟DOM”的科普层,要么直接甩一段Vue源码让人望而却步。这篇笔记我想换个思路,从“为什么需要它”讲起,把VNode的数据结构、挂载更新流程、diff算法的核心策略、Vue3编译阶段的优化逐一拆开,最后再把面试里高频问到的点和实际开发中容易踩的坑一起整理出来。如果你是刚接触Vue不久,正好想搞明白虚拟DOM到底是什么;或者你已经写过一阵子Vue,想在面试前把这块知识梳理清楚,这篇笔记应该都能帮到你。
1. 先搞清楚:虚拟DOM到底解决了什么问题
1.1 直接操作真实DOM的代价
要理解虚拟DOM为什么存在,先得看清楚真实DOM的“慢”是慢在哪里的。
浏览器在拿到HTML、CSS之后,要经过一个相当重的流程才能把页面画出来:先解析HTML生成DOM树,解析CSS生成CSSOM树,两棵树合并成渲染树,之后还要经历布局(Layout)和绘制(Paint)。这意味着,每当你用document.getElementById('xxx').innerHTML = ...改一次DOM,浏览器都至少需要重新走一遍布局和绘制流程。如果在一个循环里操作了500次DOM,哪怕每次只改一个字段,浏览器也会傻乎乎地重复计算500遍。
我做过一个不算严谨但很直观的测试:给一个表格动态渲染100行数据,每行5个字段,如果用纯jQuery的方式逐行appendChild,页面会有肉眼可见的卡顿;换成Vue之后,代码量少了一大截,而且交互流畅很多。这不完全是虚拟DOM的功劳,但虚拟DOM在其中起到了关键作用——它把几十次、几百次的真实DOM操作,收敛成了尽可能少的几次。
不过要说明一点:虚拟DOM并不是“永远比直接操作DOM快”。如果你真的非常精细地手动操作DOM,理论上可以做到比任何框架都快,因为框架拿不到足够的信息去预测你最精准的操作路径。虚拟DOM的价值,更多在于用可维护的代码换来了“足够好”的性能,同时把平台差异封装了起来。
1.2 虚拟DOM的本质:用JS对象描述UI
虚拟DOM说白了就是一棵普通的JavaScript对象树。每个节点(通常叫VNode)身上带着tag、data、children这些字段,用来描述“这里要渲染一个什么标签、它有什么属性、里面的子节点是什么”。
举个例子,如果你有一段模板:
<div id="app"> <span class="text">hello</span> </div>对应的虚拟DOM大致长这样:
const vnode = { tag: 'div', data: { id: 'app' }, children: [ { tag: 'span', data: { class: 'text' }, children: [ { tag: undefined, text: 'hello' } ] } ] }Vue的模板编译之后,会生成一个render函数,这个函数每次执行都会返回一棵全新的虚拟DOM树。注意是“全新”,也就是说:class、{{ message }}这些响应式数据一旦变化,重新执行render就会得到一棵和之前结构相同、但内容不同的新树。
1.3 虚拟DOM带来的两层价值
第一层价值是开发体验。有了虚拟DOM,开发者可以像写静态模板一样描述UI和状态之间的映射关系,不需要手写createElement、appendChild、removeChild这一整套命令式操作。框架拿到新旧两棵VNode树之后,自己去比对差异、执行更新,这就是“声明式UI”的核心。
第二层价值是跨平台。因为虚拟DOM只是JS对象,它不依赖浏览器环境,所以可以输出到真实DOM,也可以输出到小程序、原生渲染引擎。Vue的weex、uni-app这类方案,本质上都是借助虚拟DOM这层抽象实现多端渲染的。
2. Vue里的VNode:数据结构与核心字段
2.1 一个VNode身上到底挂着哪些字段
Vue新版源码里VNode是一个类,里面字段很多,但真正核心的就那几个。我按理解把它们分成了三组:
| 字段 | 作用 |
|---|---|
tag | 节点类型,比如div、span,组件节点里存的可能是组件对象 |
data | 节点上的属性、事件、指令等,对应模板里的id、class、@click、v-if这些 |
children | 子节点数组(元素节点用) |
text | 节点文本内容(文本节点用,和children互斥) |
elm | 该虚拟节点对应的真实DOM对象,patch完成后会赋值 |
key | 节点唯一标识,diff时用于复用节点 |
componentOptions | 组件节点的选项,实例化子组件时用到 |
componentInstance | 组件实例,挂载完成后赋值 |
打个比方,VNode有点像建筑行业里的施工图纸:图纸上标注了“这里要砌一堵墙、那里要开一扇窗、窗的尺寸是多少”,工人(渲染器)按图施工,建出真实的墙和窗。每次状态变了,设计师重新画一张图纸,工人对比新旧图纸,只改动有差异的位置。
2.2 常见的VNode类型
Vue里VNode按功能可以分成几种:
- 元素节点:
tag为字符串,比如'div',对应普通HTML标签。 - 文本节点:没有
tag,有text字段,表示一段纯文本。 - 注释节点:
isComment: true,渲染成注释。 - 组件节点:
tag为组件选项对象,patch时走创建组件实例的流程。 - Fragment节点:Vue3里用来表示多根节点,相当于一个不会渲染出DOM的容器。
- 静态节点:标记了
isStatic的节点,内容永不变化。
判断一个VNode是什么类型,通常就检查tag和text就够了。这也解释了为什么在render函数里写h('div')、h(MyComponent)的用法不同——第二个参数传字符串还是传组件对象,VNode的形态完全不同。
2.3 render函数与h函数的关系
平时写Vue基本用SFC单文件组件,模板由vue-loader在编译阶段转成render函数。但如果你写render函数,会直接接触到h这个函数:
import { h } from 'vue' export default { render() { return h('div', { id: 'app' }, [ h('span', { class: 'text' }, 'hello') ]) } }h是createVNode的语法糖,传入标签、属性、子节点,返回一个VNode对象。正是因为模板会被编译成render函数,所以Vue组件里“渲染什么”本质上是“执行一个JS函数,返回一棵JS对象树”。
在实际工作里,我很少手写render函数,大多数时候模板就够了。但理解h能帮你在写动态组件、封装通用组件时更从容,因为有些场景模板表达能力有限,比如需要完全由逻辑生成嵌套结构时。
3. 从虚拟DOM到真实DOM:挂载与更新全流程
3.1 首次挂载:render → VNode → patch → DOM
Vue实例创建后,会经历这么一条链路:初始化响应式系统 → 执行render函数拿到VNode树 → 调用patch函数,把VNode树递归转成真实DOM,插入容器。
patch里做的大概是这么几件事:
- 判断新旧节点是否值得比较(后面细说)。
- 如果新节点是元素节点,创建真实的DOM元素,然后递归处理子节点。
- 把创建好的DOM挂到VNode的
elm字段上。 - 最后通过
insert把DOM插入父容器。
这个阶段的关键在于:VNode和真实DOM是绑定的。VNode上的elm字段指向真实DOM节点,这样后续更新时,diff算法能找到“这棵树之前对应的DOM在哪”,从而精准替换或修改。你可以把VNode理解成一个“带记忆的施工图纸”,每张图纸都记录着自己对应的实体建筑。
3.2 响应式驱动更新:数据变了,谁去触发patch
Vue的响应式系统会和虚拟DOM紧密配合。当你修改data里的数据,会触发依赖收集时登记的Watcher(Vue3里是effect),这个Watcher通知组件执行更新。
组件更新时会重新执行render函数,拿到一棵新的VNode树,然后把这棵树和旧的VNode树一起传给patch:
patch(oldVNode, newVNode)这里有一个很容易忽略的优化点:Vue的更新是组件级的,不是整棵树级的。哪个组件的数据变了,只有那个组件会重新执行render、生成新的VNode树,然后从组件根节点开始对比更新。父组件数据变化导致子组件重新渲染,本质上是父组件的render执行时重新创建了子组件的VNode,从而触发子组件的更新逻辑。
3.3 patch的核心步骤:sameVNode与真实更新
patch函数拿到新旧两个节点后,第一件事是调用sameVNode判断它们“是不是同一个节点”。判断依据主要是tag是否相同、key是否相同:
function sameVNode(n1, n2) { return n1.key === n2.key && n1.tag === n2.tag }如果判断为不是同一个节点,就没必要精雕细琢了,直接把旧的干掉、换上新的;如果是同一个节点,才进入patchVNode,做属性和子节点的更新。
这个“先判断再处理”的思路,很像你在改代码时先看这个文件是不是你要改的那个:如果不是,直接删掉重写;如果是,就在原有基础上微调,避免大动干戈。框架层面通过这种策略,把“对比成本”限制在一个可控范围内。
4. diff算法核心:虚拟DOM更新为什么可以这么快
4.1 三个基本原则:同层、双端、key复用
diff算法是整个虚拟DOM最精彩的部分。Vue的diff建立在三个原则上:
- 同层比较:只在同一层级的节点之间做对比,不去跨层移动。如果发现某个节点从一层移动到了另一层,Vue的处理方式是删除旧节点、创建新节点,而不是真的“移动”它。这个策略牺牲了一些理想情况的性能,但换来了算法复杂度的急剧下降。
- 双端指针:对新旧两组子节点分别用头尾指针向中间靠拢,优先做四种比较。
- key复用:遍历时通过
key精确找到可复用的节点,避免大量不必要的创建和删除。
有了这三个原则,diff的时间复杂度从理论上最坏O(n³)降到了接近O(n),这也是为什么列表渲染时给节点加key如此重要的根本原因。
4.2 Vue2的双端对比过程
Vue2的updateChildren是整个diff最核心的函数。它维护了四个指针:oldStartIdx、oldEndIdx、newStartIdx、newEndIdx,然后循环做以下判断:
- 旧头 vs 新头,相同就patch,指针后移。
- 旧尾 vs 新尾,相同就patch,指针前移。
- 旧头 vs 新尾,相同就patch,并把旧头的DOM移动到尾部。
- 旧尾 vs 新头,相同就patch,并把旧尾的DOM移动到头部。
如果上面四种都不命中,就会用新节点的key去旧节点列表里找。找到了就把对应旧节点移动过来并patch;找不到就说明这是一个全新的节点,直接创建。
这个过程有点像整理两副扑克牌:你先看两副牌的第一张和最后一张是不是一样的,一样的就先归好;不行再用牌面数字去另一副里找。多数情况下,头尾比较就能覆盖大部分场景,性能非常好。
4.3 Vue3的diff优化:去掉头尾+最长递增子序列
Vue3在diff上做了进一步优化。它先做一次“从前往后”的比较,把新旧节点开头相同的那部分全部处理掉;再做一次“从后往前”的比较,把结尾相同的部分也处理掉。经过这两个阶段,中间剩下的就是一段“新老都有的、顺序可能打乱的”节点序列。
处理中间序列时,Vue3不局限在头尾比较,而是走一套更精细的算法:
- 用新节点的
key建立索引映射。 - 遍历旧节点序列,找出哪些节点在新序列中仍然存在,并记录它们在旧序列中的位置。
- 用这些位置索引计算最长递增子序列,这些节点就是“相对顺序不用变”的节点。
- 只需要移动那些不在最长递增子序列里的节点,其他节点原地不动。
这个优化的核心在于:最大程度减少DOM移动次数。DOM移动是昂贵的,Vue3通过数学手段算出“最少需要移动哪些节点”,比Vue2靠双端比较“尽量少移动”又进了一步。
我记得第一次追Vue3这个算法时,看到最长递增子序列那段代码还挺感慨的——一个前端框架,为了减少几次DOM移动,把动态规划的算法都用上了。这大概就是“性能抠到极致”的样子。
4.4 用index做key的坑:一个必踩的经典问题
key的选择直接决定diff能不能高效工作。最常见的问题就是拿数组的index当key。
看这个例子:
<ul> <li v-for="(item, index) in list" :key="index">{{ item }}</li> </ul>list = ['a', 'b', 'c'] // 用户在列表开头插入一个新项后 list = ['x', 'a', 'b', 'c']插入前,索引0对应'a'、索引1对应'b'、索引2对应'c'。插入后,索引0对应'x'、索引1对应'a'、索引2对应'b',还多出索引3对应'c'。因为key用的就是索引,Vue会认为原来的节点A(key=0)现在应该显示'x',于是把A节点里的内容改成'x';节点B(key=1)改成'a'……最终所有节点都被“更新”了一遍,而不是把新节点插到头部。
如果列表项里还带着输入框、图片、组件状态,问题会更严重——明明没变的节点,内容却被替换了,或者输入框里的内容串了行。
解决办法也很简单:优先使用业务数据里天然唯一的id,比如用户id、订单号。只有在纯静态展示、且列表不会重排的场景下,才勉强可以用index。
重要提示:如果你的列表会做增删、排序、过滤操作,一定要用稳定的唯一key。没有唯一id的话,可以考虑生成一个短hash,也比index强得多。
5. Vue3编译优化:让虚拟DOM更轻、更快
5.1 PatchFlag:动静态节点分家
Vue2的diff有一个“不够聪明”的地方:它在更新时会遍历整棵VNode树,哪怕某个子树完全没有变化,也要进去对比一圈。Vue3在编译阶段就动手脚,解决了这个问题。
Vue3的模板编译器会分析模板,给每个绑定动态内容的节点打上PatchFlag标记。比如:
<div> <span>{{ message }}</span> <span>静态内容</span> </div>编译结果是,第一个span被打上了TEXT标记,表示“这个节点只有文本是动态的”;第二个span没有任何标记,是纯静态节点。patch时看到带标记的节点才深入处理,没标记的直接跳过,更新的压力大大减轻。
常用的PatchFlag包括:
| Flag | 含义 |
|---|---|
TEXT | 文本内容动态 |
CLASS | class绑定动态 |
STYLE | style绑定动态 |
PROPS | 除class/style外的属性动态(如id、value) |
FULL_PROPS | 属性全部可能动态,需要全量对比 |
NEED_PATCH | 需要强制更新(如事件绑定) |
这意味着Vue3在更新时,不再需要“傻瓜式”地比对每个节点的所有属性,而是有点像一个体检医生拿着体检单,只检查“标了异常”的项目,没标记的直接跳过。这个优化让大规模列表更新的性能提升非常明显。
5.2 静态提升与静态节点预字符串化
Vue3还有一个很有意思的编译优化叫静态提升。模板里那些完全静态的节点,会被提升到render函数外面,创建一次、反复使用,而不是每次render都重新创建VNode。
const _hoisted_1 = h('span', null, '静态内容') render() { return h('div', null, [ _hoisted_1, h('span', null, this.message) ]) }如果一整块区域都是静态的,编译器甚至会直接把这段静态内容转成字符串,一次性创建DOM,比如<div>静态1<span>静态2</span></div>这样的区块。这让虚拟DOM在“该静的静、该动的动”这条路上走得更远了。
5.3 事件缓存:连动态都变“静态”
Vue3对事件绑定的处理也很有意思。比如:
<button @click="count++">点击</button>在Vue2里,这个节点的onClick是一个新生成的函数,每次更新都要重新绑定。Vue3的编译器会把事件处理函数缓存起来,第一次生成后存到cache数组里,后续render直接复用同一个函数。这样一来,事件绑定节点也能被当成“静态”处理,patch时完全不需要更新。
这提醒我们:写Vue3组件时,尽量把模板逻辑交给编译优化,不要什么都塞进render函数手写。手写render虽然灵活,但享受不到模板编译器这些优化红利。
5.4 Vue3相比Vue2的实战体感
在真实项目里,Vue3的编译优化带来的体验提升不是玄学。我之前把一个Vue2管理后台的表格页迁移到Vue3,列表有40多列、上百行,每次筛选数据刷新时,Vue2版本偶尔能感觉到轻微卡顿,Vue3版本几乎无感。这就是因为大规模列表更新时,Vue3把大量静态节点直接跳过了,实际参与diff的节点数少了一个数量级。
6. 面试常问与实战排坑:虚拟DOM相关的那些事
6.1 几个高频面试题的答题思路
面试问到虚拟DOM,高频问题基本是下面这几个,我整理一下我的回答思路:
Q1:什么是虚拟DOM?虚拟DOM是通过JS对象描述UI结构的树形数据结构,Vue用它作为真实DOM和组件状态之间的中间层,实现声明式渲染和跨平台渲染。
Q2:虚拟DOM一定比真实DOM快吗?不一定。如果只对比“一次更新的耗时时长”,精细手写DOM操作可能更快。虚拟DOM的价值在于降低开发复杂度、保证可维护性的同时,把更新成本控制在可接受范围,并且天然具备跨平台能力。
Q3:Vue的diff算法是怎么工作的?先给面试官讲三个基本原则(同层比较、双端指针、key复用),再讲Vue2的双端对比流程,最后提Vue3的去头尾+最长递增子序列优化。能讲到这个深度,基本就能体现出你对这块真的研究过。
Q4:为什么列表渲染一定要加key?key让diff算法能跨位置精确识别同一个节点,避免因节点复用导致的状态错乱,同时减少不必要的DOM创建和删除。
Q5:key用index会有什么问题?用上面插入列表的例子说明,即数组头部插入时,所有节点的key都变了,Vue会把每个节点都当成新节点重新处理,导致不必要的更新和状态丢失。
6.2 地图组件“首次打开正常,第二次空白”的排查思路
有一个和虚拟DOM强相关的经典坑:接入百度地图或高德地图时,第一次进入页面地图正常,离开页面再进来,地图容器一片空白。这个问题我排查过好几次,根因几乎都是同一个:地图实例没有在组件销毁时正确销毁,而组件第二次挂载时复用了同一个DOM容器,地图库在初始化时发现容器里已经有实例了,就直接跳过或挂载失败。
排查思路分几步:
- 确认组件卸载时有没有调用地图实例的
destroy()或clearMap()。没有的话就是这里的问题。 - 确认DOM容器是否被Vue复用。如果页面用了
v-if/v-show、或者路由切换,容器DOM可能被保留,地图实例的残留状态就会导致第二次初始化异常。 - 试试把地图容器的
v-if改成v-show配合手动刷新,或者给容器加一个动态key,强制Vue销毁重建DOM节点。
从这个坑能侧面看到:虚拟DOM虽然帮我们管理了大部分DOM增删,但第三方库的实例化对象并不在Vue的管理范围内,该手动销毁的必须手动处理。
6.3 日常开发里的几个实用建议
- 别滥用
v-if和:key来强制刷新组件。虽然给组件加key能强制销毁重建,但频繁重建会带来不小的性能开销,只在确实需要重置组件内部状态时使用。 - 大数据量列表别只靠diff。虚拟DOM能优化更新成本,但渲染上万条DOM节点本身的成本省不掉。该用虚拟滚动就上虚拟滚动,别指望diff算法解决一切性能问题。
- 合理利用
v-once和v-memo。v-once标记的节点只渲染一次,适合纯静态内容;v-memo可以按依赖条件缓存一部分节点的渲染结果,用得好能进一步减少不必要的虚拟DOM对比。 - 调试时用
Vue Devtools看VNode树。切到组件树面板,点开某个组件查看“渲染函数”返回的VNode结构,能很直观地看到每个节点的字段和叶子标记,比单纯看源码更能建立直觉。
最后再分享一个小技巧
基于我自己的调试经验,如果你想深入观察VNode到底长什么样,不用去翻庞大源码,直接在Vue3项目里写一个组件,在render或watchEffect里打印一下h()返回的对象,配合Vue Devtools的组件树面板,就能非常直观地看到VNode的字段、PatchFlag、dynamicProps这些信息。虚拟DOM这件事,理论学到一定程度后,最重要的是“亲眼看见”它——你能看到编译后的render结构、看到diff时命中哪个分支、看到key变化如何影响节点复用,对它的理解才算真正落地。写这篇笔记时我又重新梳理了一遍Vue2和Vue3的diff差异,最大的感受是:框架在性能上的每一次进化,背后都是对“尽量少做事”的极致追求。你在写业务代码时如果能带着这种思维去看待列表渲染、组件更新这些场景,很多性能问题在写第一行代码的时候就该被想到并规避掉了。