尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue虚拟DOM深度解析:VNode、Diff算法演进与性能优化实战

Vue虚拟DOM深度解析:VNode、Diff算法演进与性能优化实战 最近又翻到不少人在问“Vue 的虚拟 DOM 是不是没必要存在”“它到底比真实 DOM 快在哪里”这类问题。作为一个用了 Vue 三四年、被各种诡异渲染 bug 折磨过的人我必须说虚拟 DOM 这套东西你光看概念觉得玄乎但真正理解它的内核之后排查起问题来完全就是两个手感。这篇笔记是我《VUE笔记》系列的第三篇专门把虚拟 DOM 拆开揉碎讲清楚。我会从它到底解决了什么真实问题开始一路讲到 template 编译成 render 函数、VNode 对象长什么样、patch 过程怎么跑、Diff 算法在 Vue 2 和 Vue 3 里分别怎么演进最后再给一些我在实际项目里基于虚拟 DOM 特性做的性能优化手段。适合两类人看一是刚接触 Vue、被“响应式、虚拟 DOM、渲染机制”这几个词绕晕的新手二是已经写过不少项目、想系统补全框架底层认知的初中级前端。如果你习惯“知其所以然”的学习方式这篇应该能给你省下不少翻源码的时间。1. 虚拟 DOM 解决的不是性能问题而是“状态驱动视图”的工程问题很多人提起虚拟 DOM第一反应都是“因为操作真实 DOM 慢所以搞了个虚拟 DOM 来提升性能”。这个说法不能说全错但它解释不了一个关键问题既然最终还是要操作真实 DOM那中间多一层 JavaScript 对象凭什么反而更快1.1 从命令式 DOM 操作到声明式框架虚拟 DOM 出现的历史背景在 Vue、React 这类框架流行之前我们写页面更新逻辑基本上是命令式的const list document.getElementById(list); list.innerHTML ; data.forEach(item { const li document.createElement(li); li.innerText item.name; list.appendChild(li); });这个写法本身没问题但它把“状态”和“操作”绑死在了一起。一旦页面复杂起来一个数据变化可能同时影响十几个地方的 DOM你必须自己记住每个状态对应改了哪些节点改漏了页面就错乱了。框架要解决的核心工程问题是让开发者能够只关心状态而不用关心浏览器里的 DOM 到底怎么变。虚拟 DOM 就是这一层“状态到界面”的映射中间层你先维护一份用 JavaScript 对象表达的“界面描述”状态变了就重新生成一份新的“界面描述”然后由框架负责对比新旧描述、算出差异、再最小化地去更新真实 DOM。所以虚拟 DOM 首先是一个工程方案而不是单纯的性能方案。它把“每次状态变化都重新渲染整棵界面树”这种朴素但可控的思路变成了可行方案——因为比较对象是 JS 对象怎么创建、怎么对比、怎么丢弃成本都远小于直接操作真实 DOM。1.2 一个反直觉的事实虚拟 DOM 并不是“比真实 DOM 快”才存在的这是我最想纠正的一个误区。虚拟 DOM 的性能优势不在“快”在“可控”。真实 DOM 节点本身是一个重量级对象浏览器给它挂了一堆内部属性、事件机制、样式计算逻辑。你往页面里 createElement 一百个节点和创建一百个纯 JS 对象开销完全不在一个量级。但要说“虚拟 DOM 操作比真实 DOM 快”这个比较前提就不成立因为虚拟 DOM 最终还是要走一遍真实 DOM 增删改查。它真正的价值有两点把更新粒度从“一次改一个点”变成“一次算一坨差异”。你不需要在业务代码里精准定位某个节点然后改它只需要重新描述整个界面框架帮你算出哪些地方变了。把 DOM 操作集中化、批量处理。框架会整理出一批必要的 DOM 修改指令按顺序执行避免了业务代码里零散、重复、甚至互相冲突的 DOM 操作。打个比方真实 DOM 操作就像每次要给货架补货就直接走到货架前一个商品一个商品地摆放虚拟 DOM 则是先在纸上画出整个货架的新布局对比老布局找出哪个位置少了哪瓶水再一次性带着水过去调整。画纸的过程多了一步但对“谁摆错位置、谁不需要动”的判断要准确得多。1.3 Vue 里虚拟 DOM 的具体形态一个 JavaScript 对象在 Vue 里虚拟 DOM 的载体叫 VNode它的本质就是一个普通的 JavaScript 对象。一个最简单的 VNode 长这样const vnode { type: div, props: { id: app }, children: [ { type: span, props: null, children: hello } ] };type可以是标签名、组件对象、或者 Fragment 等特殊标识props存放属性、指令、事件相关的描述children可以是文本、数组、或者另一个 vnode。Vue 3 里面还加了很多内部字段比如shapeFlag、patchFlag、dynamicProps但这些字段都是辅助 Diff 算法的元数据不影响理解主干。当你写.vue文件模板最终被编译成render函数render函数执行一次就返回一棵 VNode 树。虚拟 DOM 的“虚拟”两个字指的就是内存中这棵描述界面的树它并没有真实渲染到页面上。2. 从 template 到屏幕像素一条完整的虚拟 DOM 流转链路接下来我们把整条链路走一遍。你在.vue文件里写的模板到用户屏幕上实际渲染出来的画面中间要经过编译、执行 render、生成 VNode、patch、真实 DOM 操作这几个阶段。2.1 template 编译成 render 函数发生了什么Vue 的模板不是直接拿来用的它要先经过编译器处理。编译器的作用是把类似 HTML 的模板字符串解析成一份 AST抽象语法树再通过代码生成器把 AST 转成一个可执行的 render 函数。以 Vue 3 为例你写template div classcontainer p{{ msg }}/p span v-ifshow可见/span /div /template编译器会生成类似这样的 render 函数import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from vue; export function render(_ctx, _cache, $props, $setup, $data, $options) { return (_openBlock(), _createElementBlock(div, { class: container }, [ _createElementVNode(p, null, _toDisplayString(_ctx.msg), 1 /* TEXT */), _ctx.show ? (_createElementVNode(span, null, 可见)) : (_createElementComment(v-if, true)) ])) }如果你打开 Vue DevTools切到组件 - 渲染函数就能直接看到这个编译结果。我第一次看的时候还挺震撼的原来模板上的每一个插值、每一条指令最后都变成了函数调用。这里要注意:class、:style、事件绑定、指令之类全部是在编译阶段做了静态分析能提取的静态结构会被缓存动态部分会打上 patchFlag 标记这些信息在后面 Diff 阶段会起到关键的作用。2.2 render 函数如何产出 VNodeVNode 节点上都有什么当组件实例创建时会执行这个 render 函数产出当前状态对应的 VNode 树。Vue 3 的 VNode 对象比刚才那个最简示例要丰富得多源码里大概长这样const vnode { type: div, // 类型标签名 | 组件对象 | Fragment | Text | Comment props: { class: a }, // 属性、监听器、指令等 children: null, // 子节点 shapeFlag: 9, // 节点类型标记数字表示 patchFlag: 1, // 动态内容标记仅在动态节点上非 0 dynamicProps: null, // 动态属性列表 key: null, // diff 用的 key el: null // 指向真实 DOM 节点的引用 };shapeFlag是一个位运算的组合标记用来快速判断节点是元素、组件、Fragment 还是文本patchFlag则标明了这个节点具体哪部分需要更新。比如 1 代表文本动态2 代表 class 动态4 代表 style 动态64 代表整块需要替换等等。这些标记在我看来才是 Vue 3 和 Vue 2 在虚拟 DOM 设计上最大的分水岭。Vue 2 的 VNode 没有 patchFlagDiff 的时候就得靠遍历比对各种情况Vue 3 的编译器提前告诉 Diff 函数“你只需要处理这一小块”省掉大量无意义的遍历。2.3 patch 过程到底在做什么创建、更新、删除三件事render 函数产出 VNode 树之后下一步是 patch。patch 这个名字有点抽象我理解它就是“把新旧 VNode 树之间的差异同步到真实 DOM 上”的过程。整个过程可以拆解成三个核心动作创建如果旧的 VNode 树中某个节点不存在新的 VNode 树中有就创建对应的真实 DOM 元素并插入。更新如果新旧 VNode 的类型相同就进入递归 diff 过程比较属性、事件、子节点只更新变化的部分。删除如果旧的 VNode 树中存在新的 VNode 树中已消失就移除对应的真实 DOM 元素。举一个我在实际开发里经常遇到的场景列表排序。老写法是把整个列表容器清空再重新渲染用 Vue 后只需要在 patch 时对比同一层级的子节点通过 key 判断哪些节点可以移动复用哪些需要新增或删除。听起来不复杂但它背后牵扯到一个细节patch 是一个逐层递归、深度优先的过程。根节点比较完进入它的 children 数组对每个子节点再递归做同样的比较。这个“同层比较 深度优先”的策略是 Diff 算法性能的关键。3. Diff 算法的演进Vue 2 的双端比较与 Vue 3 的最长递增子序列Diff 是虚拟 DOM 中最绕、也最见功底的部分。很多前端面试官喜欢问“Vue 2 和 Vue 3 的 diff 有什么区别”本质上就是在考察你对虚拟 DOM 更新机制的观察深度。3.1 同层比较与深度优先diff 的前提约束原始思路下两棵树的 diff 是 O(n^3) 的时间复杂度这个量级对前端框架来说是不可接受的。为了让复杂度降到 O(n)Vue 和 React 都做了同样的约定只做同层比较不同层级的节点不跨界比较。在同一个层级内通过key来识别节点是否可复用。节点类型不同直接替换整棵子树不深入对比。这些约定牺牲了一部分理论上的“完美最小更新”换来了实践中足够好的性能和极简的算法模型。我很喜欢打一个比方diff 不是在做全局最优匹配而是在做“足够好的局部决策”。它不追求改动次数绝对最少只追求别做无效劳动。3.2 key 到底怎么工作没有 key 和错误 key 的真实后果key 的作用是给同一层级中的每个节点一个“身份证”。有了这个身份证diff 才能判断两个 vnode 是不是同一个东西。我举一个具体例子。你有一个数组渲染成列表li v-foritem in list :keyitem.id{{ item.name }}/li假设 list 从[{id: 1, name: A}, {id: 2, name: B}]变成[{id: 2, name: B}, {id: 1, name: A}]。有 key 的情况下diff 能识别出 id1 和 id2 的 li 都还在只是顺序变了那就可以通过移动真实 DOM 节点来完成更新。没有 key 或者用 index 当 keydiff 会认为数组第 0 项原来是 A、现在是 B第 1 项原来是 B、现在是 A于是直接把两个 li 的文本都替换掉。数据量小时感觉不出来但你会平白多做两次文本替换。更糟的是用 index 当 key 时配合非纯列表的场景。比如列表前面插入了新数据index 会被整体挤一位此时 key 完全错位Vue 会把旧节点当成新节点复用结果就是状态错乱。我遇到过一次很经典的 bug列表项内有 input 输入框用户往第一行输入了内容然后在头部插了新数据输入框内容全部错位。排查到最后原因就是 key 用了 index。把 key 换成后端下发的唯一 id 后问题立刻消失。提示不写 key 的 v-forVue 会给出编译警告。但你写了 key 不等于用对了 key稳定性比什么都重要——不要使用随机数当 key不要在渲染过程中不断变化 key。3.3 Vue 2 为什么用双端比较Vue 3 又为什么改成长度递增子序列Vue 2 的 diff 比较同一层级的 children 数组时用的是头尾双指针法新前、新后、旧前、旧后四个索引位置互相比较一种一种地尝试每命中一个场景就移动对应的指针直到新数组或旧数组遍历完。这种算法优化了“把尾部节点移动到头部”这类常见操作不需要全量遍历去找移动目标。Vue 3 换用了另一种思路。编译器给动态节点打上了 patchFlag所以 Diff 的主干逻辑变了先对比新旧 children 数组的前缀、后缀相同节点遇到不同的就停下。对于中间不同的“混沌区间”用 key 建一个旧节点索引表然后扫描新节点能复用的节点标记为更新新增的节点标记为挂载消失的节点标记为卸载。对同时存在于新旧两侧且符号顺序变化的移动节点计算出它们在新数组中的位置序列再求一遍最长递增子序列最长递增子序列里的节点就是不需要移动的部分其他节点基于它们做插入或移动。这样就避开了 Vue 2 双端比较里大量的四角查找和暴力扫描尤其在“头部/尾部插入删除多、中间交叉乱序少”的真实业务场景中性能提升非常明显。有意思的是我在读 Vue 3 源码时发现它在“纯新增”和“纯删除”的场景下表现非常直接。如果你在列表前追加了大批数据并带上正确的 keydiff 的复杂度几乎接近 O(1)前缀对比直接跳到最后后续打上 patchFlag 的节点也只需要原地更新。4. 虚拟 DOM 在实际项目中的性能优化手段理解了原理就该聊点实操了。虚拟 DOM 不是写好了就自动最优它在真实项目中很容易变成性能瓶颈或 bug 温床。下面是我在几个实际项目里采用的优化手段都基于虚拟 DOM 的机制来推的。4.1 key 的正确选择从 “随便一个 id” 到 “稳定且业务唯一”我见过不少团队对 key 的态度从“不写”到“乱写”都有。正确选择 key 有几个标准能用数据库主键的就用主键不要自己临时生成。没有主键时优先选择业务上永不变化的字段组合。千万不要用随机数、Math.random、时间戳这类每次渲染都会变的字段。同一父节点下的子节点 key 要唯一父节点不同则允许重复。在表格编辑、列表拖拽、树形组件这类高交互场景里key 选得对不对直接影响输入框焦点、展开状态、滚动位置会不会错乱。很多深层 bug 本质上都是 key 不稳定带来的 vnode 误复用。4.2 从虚拟 DOM 层面减少无效渲染静态提升、动态指令、shallowRefVue 3 编译模板时会把纯静态的内容“提升”到 render 函数外部这样组件更新时静态 VNode 不会重新创建直接从缓存取用。这个优化对长列表、复杂布局的组件收益非常显著。我自己还做了一件很重要的事尽量用shallowRef而不是ref包装体积大、层级深但不常整体替换的对象。shallowRef只有对.value的整体替换才会触发依赖更新深层属性变化不会触发。如果业务里确实有深层对象需要局部更新配合triggerRef手动触发能省掉大量无意义的响应式 track 和重新渲染。模板编译工具上v-once、v-memo也是和虚拟 DOM 密切相关的指令。v-memo会在 VNode 层面缓存一段模板的渲染结果只要传入的依赖数组没变化Vue 就直接复用整块 virtual tree完全不进入 diff 流程。这种指令适合用在大型下拉选项、动态表单字段这类“频繁切换但不常变化”的场景。4.3 什么时候应该完全避开虚拟 DOM有些场景虚拟 DOM 反而是累赘。第一种是运行时不包含编译器、模板用 render 手写、但不断触发巨型列表重渲染的情况。一旦某个动态字段被大范围引用diff 还是要对整棵 VNode 树做标记和比较这时不如直接用v-memo大幅缩小比较范围或者干脆用函数式组件配合外部状态管理。第二种是需要极低延迟的复杂自定义交互。比如图编辑器的拖拽画布、大型表格的单元格拖拽填充这类场景每一帧都要做精确到像素的 DOM 操作虚拟 DOM 的批量更新策略反而会成为阻碍。我做过一个画布类的白板工具拖拽时就是直接操作真实 DOM 的位置属性绕开了 Vue 的响应式渲染性能提升立竿见影。注意这不是说虚拟 DOM 没用而是要在正确的抽象层级选择工具。常规业务页面虚拟 DOM 是省心省力的好方案高频图形交互场景直接操作真实 DOM 更合适。5. 关于虚拟 DOM 的常见认知误区与面试题底层逻辑最后整理几个高频误区这些也是我在带新人时反复纠正的点。5.1 误区一虚拟 DOM 一定比操作真实 DOM 快不对。首次渲染时虚拟 DOM 反而要多执行一次 VNode 创建和 patch 过程比直接 innerHTML 还要慢一点。它的优势在于多次状态变更时可以合并和减少真实 DOM 操作。而真实 DOM 操作如果足够精准其实比虚拟 DOM 更快——只是业务代码很难做到足够精准。所以在面试里如果被问到“虚拟 DOM 是 Vue 性能好的原因吗”最稳的回答方向是“虚拟 DOM 保证我们在写声明式代码的前提下性能开销可控工程维护成本低而不是绝对意义上的快。Vue 3 又通过编译优化让这个可控性进一步提升。”5.2 误区二虚拟 DOM 和响应式是同一件事不是。响应式系统解决的是“数据变了组件要重新执行 render”虚拟 DOM 解决的是“render 执行后怎么高效地更新真实 DOM”。两套机制是独立设计又相互协作的。响应式追踪到组件粒度虚拟 DOM 负责组件内部的更新细节。换句话说数据变化不会直接导致真实 DOM 更新。它先触发组件 render 生成新的 VNode再通过 diff 算出差异最后才操作真实 DOM。理解了这个流程就不会再问“为什么我的响应式数据变了但页面没刷新”——那就是在 patch 阶段被某个 bug 挡住了。5.3 一个小实验在控制台直观感受 VNode 到 DOM 的转换纸上得来终觉浅我建议你在一个 Vue 项目里做个小实验。在任意组件里通过getCurrentInstance()拿到组件实例打印instance.subTree你会看到当前组件对应的 VNode 树。然后修改一个响应式数据再打印一次subTree你会发现type、props相同的节点被复用el属性指向的仍是同一个真实 DOM 元素。这个实验会让你对“节点复用”产生非常直观的体感。再配合renderer.js源码里的patchElement函数就能清楚看到属性更新是怎么基于 patchFlag 精确收窄范围的。我到现在还记得第一次追 Vue 3 源码看到patchElement里的处理逻辑时的感受那种“原来模板上的每个细节最终都落到了这样一行行判断里”的恍然大悟比看十篇概念文章都管用。6. 写在 VUE笔记三之后给继续往下看源码的人一点私藏技巧如果你读完这篇打算自己去看 Vue 源码给你几个我当初踩过坑换来的方向性建议。先从packages/runtime-core/src/renderer.ts里的createRenderer入手顺着patch-processElement-patchElement-patchChildren这条线读。这条线是虚拟 DOM 的主干读通它你就理解了 Vue 运行时的一半。遇到具体函数看不懂不要急着深挖每个分支。先把patchFlag、shapeFlag两个枚举定义搞清楚再回头看 diff你会发现很多分支判断都是在查位运算标记。Vue 源码里位运算用得非常多它在设计上就是故意用二进制标志位来压缩分支判断成本的。还有一个很值得做的小练习自己写一个极简版 diff 函数只处理“同层子节点有 key 时的移动和复用”。写完之后再对比 Vue 3 的实现差距会让你对“工程优化”产生非常真实的敬畏。一路写到这里我最大的体会是虚拟 DOM 并不是一道需要背诵的标准答案它更像是一套“如何用可计算的开销去换取可维护的声明式开发体验”的设计哲学。你在业务里写的每一行模板、选的每一个 key背后都是在和这套机制协作。搞懂它之后再回头看那些“页面卡顿”“渲染错乱”“状态丢失”的问题基本都能一眼锁定方向了。
返回列表