ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue虚拟DOM原理与diff算法全解析:从VNode到渲染性能优化

Vue虚拟DOM原理与diff算法全解析:从VNode到渲染性能优化 如果你用Vue写过稍微大一点的列表页面大概率踩过这种坑一个几百行的表格筛选条件一变页面卡顿明显输入框打字都跟手。以前我第一反应是“数据量太大得优化接口”后来真正去追查渲染链路才发现大部分开销并不在接口而在浏览器把一次次变更映射到真实DOM的过程。这个过程的中间层就是Vue的虚拟DOM。这篇笔记是Vue系列笔记的第三篇我就把虚拟DOM从数据结构到diff算法、再到Vue 3的编译优化整条链拆开过一遍争取让你既能应付面试也能在真实项目里知道性能问题该往哪查。系列前两篇分别写了Vue的响应式原理和模板编译这篇正好接上模板编译产出的是render函数render函数执行后得到的就是虚拟DOM树之后再经过patch这个环节落到真实DOM。所以理解虚拟DOM就等于理解了Vue从数据到页面的“最后一百米”。1. 虚拟DOM到底替我们省了什么1.1 为什么不能直接改真实DOM先说一个反直觉的结论虚拟DOM从来不是为了“比直接操作DOM更快”而存在的。如果你只需要更新一个节点上的文本手动document.getElementById(xxx).textContent new比任何框架都快。浏览器对DOM的操作从来不是问题问题在于操作之后那一连串的连锁反应。你在页面上改一个元素的样式浏览器可能要重新计算元素的位置和大小这叫reflow重排改颜色这种不影响布局的会触发repaint重绘。更麻烦的是这两者都可能波及整个页面而且高频触发时性能会急剧下降。我做个简单的估算一个渲染1000行的表格某一列数据变化你用一个循环去更新1000个节点的文本浏览器至少要触发一次布局计算、一次绘制。如果这个动作每秒发生好几次页面基本就肉眼可见地卡了。虚拟DOM的思路是不直接碰真实DOM先在JavaScript层面用对象把页面状态描述出来数据变化后生成一份新的对象树再和旧的对象树做对比找出最小差异最后只把这部分差异应用到真实DOM上。这个“找出最小差异”的过程就是diff算法。1.2 从命令式到声明式的架构转变虚拟DOM更大的价值是让前端开发从命令式变成声明式。命令式的意思是“你告诉我每一步怎么做”比如“先创建元素再设置属性再插入到某个地方”声明式的意思是“你告诉我最终长什么样中间怎么改我不管”。在没有框架的时代我维护过一个业务逻辑极其复杂的后台页面状态一多操作DOM的代码就失控了每个状态变化都要手动维护界面状态之间还有依赖一个地方忘了更新就会出现数据对不上的bug。后来用Vue只需要维护数据界面会自动跟着变。这份“自动”靠的就是虚拟DOMrender函数根据数据生成虚拟DOMVue负责把这个虚拟DOM同步到真实页面。所以虚拟DOM解决了两个核心问题一个是性能问题通过批量更新、最小化DOM操作降低浏览器开销另一个是开发体验问题让开发者可以专注数据逻辑不用手动管理复杂的DOM操作。这两点后面的章节会展开讲。2. 从VNode的数据结构看虚拟DOM2.1 一个VNode对象到底长什么样虚拟DOM里每一个节点在Vue里叫VNode。说白了它就是一个普通的JavaScript对象用来描述“这个位置应该渲染成一个什么样子的东西”。我们看一个最简单的例子// 模板中写的是div classtitle你好/div // 经过编译器处理render函数大概长这样 function render() { return createVNode( div, { class: title }, 你好 ) }你可以把createVNode理解成虚拟DOM的“工厂函数”它接收三个参数type元素类型、props属性、children子节点。上面这段代码返回的对象在Vue内部差不多长这样{ type: div, props: { class: title }, children: 你好, el: null, key: null, shapeFlag: 9 }最后一个shapeFlag值得单独解释。它是一个用二进制位运算算出来的数字用来标记这个节点是什么类型。Vue里定义了好几种类别元素、文本、组件、Fragment、Suspense等。为什么不用简单的字符串标记因为位运算可以同时表示多个维度比如一个节点既是一个组件又带有子节点二进制1001001这种形式可以在一次运算里同时判断出“这是组件”和“它有子节点”。这就像用一个开关面板上的几个拨杆来表达多种状态拨杆位置不同状态组合就不同框架内部判断时不需要逐字比较字符串直接在二进制位上做一次与运算就够了性能上开销极小。2.2 元素、文本、组件三种节点的处理差异VNode的type字段不同Vue后续的处理方式完全不同。我一开始学Vue时没注意这个以为虚拟DOM只是“用对象代替DOM元素”后来看源码才发现门道多了。type是字符串时比如div、span这是一个原生DOM元素节点。patch阶段会直接调用document.createElement创建真实元素然后递归处理子节点。type是Symbol或组件选项对象时这是一个组件节点不会直接创建DOM元素而是走到组件的实例化、setup、render流程。type是Text这个特殊Symbol时这是一个文本节点直接创建文本节点处理。还有一种情况容易被忽略type是Comment时是注释节点Vue内部很多占位逻辑会用到。组件节点和原生元素节点的最大区别在于原生元素节点的子节点直接就是DOM树的一部分而组件节点有自己的渲染边界子节点由组件自己的render函数产出。2.3 children也有自己的“形状”VNode的children字段同样不是随便存的。Vue 3里通过shapeFlag来区分children的类型常用的大概有四种文本子节点、单一个VNode子节点、多个VNode组成的数组、以及带key的Fragment。为什么要区分这个因为处理方式不同。文本子节点直接创建文本节点就行数组子节点需要遍历创建多个元素带key的数组子节点则要进入最复杂的diff流程。Vue会在创建VNode时就标记好children的形状patch时直接按标记走对应分支不用每次运行时再判断到底属于哪种情况。我在实际调接口渲染列表时遇到过一种情况同一块数据有时候返回的是数组有时候返回的是空。如果不注意children的处理数组为空时可能渲染出空文本占位页面布局就莫名其妙空了一块。后来我习惯在渲染前对数据做归一化处理保证要么是有效数组要么明确给个空态占位。这个习惯跟孩子们的处理方式一脉相承Vue框架内部都会对children做区分处理我们自己写业务代码时也应该对数据的“形状”有预期。3. patch与diff虚拟DOM怎么变成真实DOM3.1 从render到patch的完整链路render函数执行后得到的是新的VNode树但这棵新树不会直接替代旧树而是进入patch流程。patch是Vue内部“打补丁”的函数它接收旧VNode、新VNode、对应的容器真实DOM的挂载点三个参数目标是让容器内的真实DOM变成新VNode描述的状态。// 简化版的patch逻辑 function patch(oldVNode, newVNode, container) { // 如果新旧节点类型不同直接卸载老的挂载新的 if (oldVNode.type ! newVNode.type) { unmount(oldVNode) mount(newVNode, container) return } // 如果类型相同走更新流程 patchVNode(oldVNode, newVNode, container) }第一次渲染时容器内部是空的oldVNode其实是一个注释节点占位用来记住真实DOM的位置。后续更新时Vue会去对比新旧两棵VNode树尽量减少对真实DOM的操作。这个流程的核心是Vue不会因为数据变化就毁掉整个DOM重新建一遍而是在虚拟DOM层面找出差异精准更新。想明白这条链路你就知道为什么Vue的响应式系统在依赖变化时会通知组件重新执行render函数——render函数产出新的VNode后patch会根据需要最小化地更新真实DOM这就是从数据到页面更新的全部路径。3.2 key的真相VNode复用的关键diff算法里最重要的一环是确定“哪些节点可以复用”。假设页面渲染了一个列表内容从[A, B, C]变成[C, A, B]如果节点可以复用只需要把已有的DOM元素调整位置就行不用重建。但如果框架不知道A、B、C各自对应哪个DOM就只能靠顺序去猜一猜就容易出问题。所以Vue在diff时做个判断叫sameVNode如果两个VNode的type和key都相同就认为这是同一个节点的不同状态可以在原DOM元素上做更新如果key不同哪怕内容一样也认为不是同一个节点选择重建。// Vue 3源码里sameVNode简化判断 function isSameVNodeType(n1, n2) { return n1.type n2.type n1.key n2.key }这里就能解释为什么列表渲染时不能滥用index作为key。index是数组下标它跟数据本身没有稳定对应关系。你往数组头部插入一条数据后面所有数据的index都变了Vue会认为所有节点都是新节点整个列表都要重建。更严重的是如果用index做key节点复用时可能把A的DOM状态套到B身上产生状态错乱。比如列表项里有输入框用户在第一行输入了内容再往头部插一条数据输入框内容就跑到第二行去了。3.3 Vue 3的diff是怎么跑起来的Vue 3的diff算法一句话概括先从头尾两端向内逼近把能复用的节点先确定下来剩下的中间部分用最长递增子序列来算出最小移动次数。我拆开讲一下。假设旧列表是[A, B, C, D, E]新列表是[A, C, B, E, D]。diff过程先看头部头部的A和A相同直接复用指针后移。再看尾部尾部的E和E相同复用指针前移。此时头部指针指向B尾部指针指向D中间剩下[B, C, D]和[C, B, D]需要进一步处理。对于中间部分Vue会把新节点的key建一个索引映射然后遍历旧节点找到能复用的节点并打上标记同时记录各自的索引位置。最后要更新位置时用最长递增子序列算法找出“不需要移动”的节点集合剩下的节点只需要做插入或移动操作。这个算法的意义在于它能计算出怎么调整节点位置需要的移动次数最少。Vue 2的双端diff是四个指针同时移动比较四个方向上的节点Vue 3换成了“头尾单调递增更新中间求解LIS”的方式实际效果在大量列表重排场景下更稳定。你可能不需要手写diff算法但理解这个过程你就能明白key越稳定diff就能复用到越多节点性能就越好。3.4 patchVNode时到底更新了哪些东西当两个VNode确定是同一个节点时Vue会进入patchVNode流程把新VNode的变化同步到旧的真实DOM上。这里不是简单地整个替换而是分类讨论如果新节点没有子节点旧节点有子节点那就直接清空旧DOM的子节点设置新的文本。如果新节点有子节点且旧节点没有就创建所有子节点并插入。如果新旧都有子节点且children是数组就进入上面说的diff流程。如果新旧都有文本且文本不同直接更新DOM的textContent。还有一个细节容易被忽略属性更新也是在这里做的。Vue会遍历新旧props把新props里变化的属性更新到DOM元素上把旧props里有但新props里没有的属性移除。这个过程看似简单但里面隐藏着一个坑有些属性在DOM上的更新不能直接赋值比如innerHTML要用专门的接口处理value属性在输入框上要区分属性和值的更新。Vue内部针对这些特殊情况做了处理我们写代码时如果遇到自定义组件或自定义事件也要搞清楚哪些是作为attr传给元素的哪些是作为property绑定的。4. Vue 3的编译优化虚拟DOM不再需要全量diff4.1 静态提升和动态节点收集Vue 2的diff是对整棵VNode树做全量对比模板里大部分静态节点其实不会变但也要参与对比白白浪费性能。Vue 3在编译层面做了优化核心思路是编译器能静态分析的部分直接帮你跳过。静态提升hoistStatic就是典型优化。模板里如果有一段完全静态的节点比如divspan固定文字/span/div编译器会把这段节点提取到render函数外面只创建一次VNode每次渲染时复用同一个对象。因为它是常量diff时可以直接跳过不需要重复创建和对比。更关键的是动态节点收集。Vue 3的VNode会有一个dynamicChildren数组专门收集这个区块内“会变的节点”。编译时模板中的动态节点都会被标记出来放进这个数组。diff时框架不再层层递归整棵树而是直接对比这个数组里的节点静态部分彻底跳过。这就是block tree的思路把一颗大树裁剪成只包含动态节点的“精简版”。4.2 patchFlag给diff开的“小抄”Vue 3的模板编译语法里会出现patchFlag这是给diff算法提供的“提示”。它用数字表示这个节点哪些部分是需要更新的只更新文本、只更新class、只更新style、还是只更新props等。比如一个节点只是动态绑定了一个文本内容div{{ message }}/div编译后这个VNode的patchFlag会被标记为TEXTdiff时Vue看到这个标记就只去更新文本内容其他属性和子结构一律不看。这相当于diff前就拿到了一份“改动清单”不用全量排查。常见patchFlag值背后的含义我整理了一张表面试时也常被问到patchFlag值含义1只需要更新文本内容2只需要更新class4只需要更新style8只需要更新非class/style的props16有动态属性且key是动态的需要全量比较64Fragment是稳定的子节点顺序不变128Fragment的子节点带有key会走keyed diff每次数据更新Vue会先看patchFlag再决定怎么diff这种做法极大减少了无谓的比较。我在移动端项目里实测过一个长列表页面使用Vue 3之后页面滑动的帧率明显比Vue 2时代稳定背后的原因就在这里。4.3 事件缓存与Fragment带来的性能红利Vue 3还有一个不太容易被注意到的优化事件缓存。模板里写一个clickhandleClick编译时会生成一个内联函数但是这个函数会被缓存起来下一次渲染时直接复用同一个函数引用。因为函数引用没变diff时事件相关节点就会被标记为“不需要更新”省去了每次重新创建函数和绑定事件的成本。Fragment是另一个新特性让Vue 3的模板支持多个根节点。编译器会把多根节点包在一个Fragment里这个Fragment的children作为动态节点处理。相比Vue 2必须用一个根节点包裹整个模板Fragment减少了一层额外的DOM嵌套对页面结构和样式控制都更灵活。这些优化汇总到一起让Vue 3在渲染性能上比Vue 2有了明显提升。尤其对于大部分是静态结构、只有少量动态内容的典型页面后台管理、文档站动态节点收集和patchFlag带来的优势非常明显。反过来也提醒我们写模板时把动态绑定的部分尽量收拢减少散布的大范围动态区域能让这些编译优化发挥更彻底。5. 日常开发中关于虚拟DOM最常踩的坑5.1 虚拟DOM一定比操作真实DOM快吗一个常见误区这个问题我面试时经常被问到也是很多开发者对虚拟DOM最大的误解。虚拟DOM的收益在于它让你从手动操作DOM的泥潭里脱身并且通过批量更新和精准diff避免了大量不必要的DOM操作。但对于单次操作直接操作DOM一定更快因为虚拟DOM多了一层对象创建和对比的过程。虚拟DOM真正的优势在于复杂场景下的综合表现。如果你维护一个动态变化很多的页面手动操作DOM时你要为每一次变化写针对性代码很容易出现“某次更新漏了一个节点”的问题。Vue用虚拟DOM把这种复杂度封装起来框架层面统一处理正确性由框架保证。我们追求的不是单次操作的速度而是整个应用在复杂变化下的整体性能和可维护性。所以讨论虚拟DOM快不快要看前提是什么。如果只是一个静态页面上改一两处文本虚拟DOM反而是多余的开销如果是一个数据频繁变化、结构复杂的应用虚拟DOM加编译优化能让你在不牺牲性能的前提下愉快地写业务。5.2 key用index会怎样不只是性能问题更是正确性问题再说一遍key的重要性因为这是列表渲染里最经典的坑。key的作用是让Vue能识别“这个VNode是之前那个VNode”是VNode复用和位置移动的依据。如果用了index作为key列表数据变化后Vue可能把“旧的第2项”当成“新的第2项”来复用。表面上看起来没啥问题但一旦列表项内部有自己的状态就会错乱。我之前处理过一个商品列表每个商品卡片有一个展开/收起的按钮收起状态下展示摘要。一开始图省事用了index作为key结果用户操作时发现展开第一个商品再排序后展开状态跑到了另一行上。后来我把key改成商品的唯一id问题立刻消失。key的选择原则很简单必须是这条数据里稳定且唯一的值。后端返回的id、业务编号都可以实在没有唯一的字段可以在创建数据时生成一个全局唯一标识。5.3 组件更新时我该怎么判断是否走了虚拟DOM的优化我们在业务代码里一般不直接接触虚拟DOM但分析性能问题时需要一些手段。Vue 3里组件有一个render函数每次响应式数据变化后组件会重新渲染。如果组件内部全是静态节点只有外围一个动态绑定理论上重新渲染的开销很小。想实际观察虚拟DOM更新行为可以在浏览器里用Vue DevTools看组件树的变化也可以手动在render函数里打console.log。更实用的做法是拆组件时注意粒度动态变化的部分尽量独立成组件这样变化时只有局部组件重新渲染不会让整个页面树都进diff。这其实是用虚拟DOM的组件边界来划分渲染范围优化效果非常明显。我有一个后台项目筛选条件变了之后整个页面包括侧边栏都跟着闪烁。排查后发现侧边栏虽然没有变化但它跟列表页是同一个组件列表页的数据变化导致整个组件树重新render了。把侧边栏和列表拆成两个组件挂到不同分支后问题就消失了。这个场景不是虚拟DOM本身的问题而是组件边界没划好让diff范围变得过大。5.4 从虚拟DOM视角排查性能问题的几个方法如果你发现页面卡顿按我下面的顺序排查一般能定位到问题所在先看是不是列表没有设置key或者key不稳定导致大量节点重建。此时控制台可能会有警告Vue DevTools的组件树里也能看到节点在频繁创建销毁。再看是不是数据量过大一次渲染的节点数量太多比如几千行表格这种情况下虚拟DOM再优化也有它的物理极限要考虑虚拟滚动或分页。然后看是不是每个变化都触发了整个页面更新如果是的话检查组件拆分粒度。还有一种情况容易被忽略在render函数或模板里绑定了过于复杂的计算表达式导致每次渲染都要重新计算。虚拟DOM优化的是“DOM操作”但不负责优化“渲染前的JavaScript计算”。遇到这种情况可以用computed缓存计算结果或者把复杂计算放到组件外部。排查工具上Vue DevTools的Performance标签页可以看到组件渲染的耗时分布Chrome的Performance面板能看到长任务和布局抖动。把这两者结合起来基本就能定位到卡顿的根因。一些写在最后的个人感受这篇笔记我写的时候其实也顺手翻了一遍Vue 3的源码每次看都会有新的体会。虚拟DOM不是黑魔法它本质上是用空间换时间的产物在JavaScript里维护一棵对象树通过合理的diff策略减少对真实DOM的触碰再配合编译优化让diff对象本身都尽可能少。实际开发里我对虚拟DOM的感触是框架已经帮我们处理了绝大多数性能问题真正让页面卡顿的往往是我们自己代码里的不合理写法比如不稳定的key、过大的组件边界、高频触发的数据更新。理解了虚拟DOM的工作方式你就知道这些写法背后的代价是什么也就能定位到该从哪个方向优化。如果你也在看Vue的源码建议从runtime-core/src/renderer.ts这个文件入手看你可能会被上千行代码吓到但先只看patch和processElement这两个函数再配合这篇笔记去理解会顺畅很多。下一篇笔记我打算展开写Vue的组件渲染机制把组件从初始化到更新的完整生命周期串一遍到时候咱们继续聊。
返回列表