尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Vue 2到Vue 3 diff算法进化:虚拟DOM如何实现最小更新?

发布时间:2026/9/29 17:50:29

资讯中心
01
ARTICLE

Vue 2到Vue 3 diff算法进化:虚拟DOM如何实现最小更新?

Vue 2到Vue 3 diff算法进化:虚拟DOM如何实现最小更新?
前阵子带一位刚转 Vue 3 的同事他抛了一个问题给我Vue 2 和 Vue 3 的 diff 算法到底差在哪我当时第一反应是甩一段源码链接但转念一想这题最友好的讲法不是从源码开始而是从“为什么 Vue 2 本来挺好的还要换 diff”开始。这篇就用大白话理一遍 Vue 的 diff 算法进化史——虚拟 DOM 是怎么对比新旧状态的、Vue 2 双端比较为什么经典、Vue 3 为什么改用静态标记和最长递增子序列以及这些变化落到日常开发和面试里是什么体感。想弄懂框架原理的初学者、正在准备 Vue 面试的人、以及维护着老项目在评估要不要升级的同学都能顺着这条线把核心脉络拎起来。1. Vue 2 的 diff四个指针在新旧列表之间“翻花绳”1.1 虚拟 DOM 为什么需要 diff装修别拆墙浏览器里改一次 DOM往往牵动重排、重绘、样式计算每一项都不便宜。如果每次数据变化都直接怼到真实 DOM 上一个列表的局部改动就可能引发整页的连锁反应。于是就有了虚拟 DOM用 JS 对象先模拟出一棵树等状态变了再生成一棵新树然后把两棵树做对比找出“真正该动的部分”最后只改这一小撮真实 DOM。这个找差异的过程就是 diff。为什么叫 diff而不是“全量重建”因为全量重建虽然简单但代价太高。diff 要回答的问题是从旧状态变到新状态最少需要做多少次插入、删除和移动。Vue 2 的 diff 有一个很明确的前提只做同层对比节点不跨层级比对。原因是跨层级移动对算法来说代价太大而且真实业务里节点跨层移动的比例极低。与其做得完美不如做得够用。这是 Vue 2 设计上的务实之处。还有个更底层的限制Vue 2 从诞生起就分离了模板编译和运行时用户既可以用模板也可以完全手写 render 函数。手写 render 函数时编译器对节点“以后会不会变”完全无感知。既然拿不到完整信息Vue 2 干脆选择在运行时对整棵虚拟 DOM 做全量 diff。这也为后来 Vue 3 的编译期优化埋下了伏笔。1.2 双端比较四个指针四轮试探Vue 2 真正让人眼前一亮的是 updateChildren 里的双端比较。它在新旧两个子节点数组上各放了两个指针oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。四个指针把两个数组分别夹在中间然后循环做四轮试探。第一轮比较 oldStartVnode 和 newStartVnode相同就直接 patch两边的起点指针都往后走。第二轮比较 oldEndVnode 和 newEndVnode相同就 patch两边的终点指针都往前走。第三轮比较 oldStartVnode 和 newEndVnode相同就把旧列表开头的节点移动到队尾因为它在原位置已经没了意义而新位置正好在队尾。第四轮比较 oldEndVnode 和 newStartVnode相同就把旧列表末尾的节点移动到队首。如果这四种最理想的情况都不命中Vue 2 才会走兜底逻辑用 key 在旧列表里查找 newStartVnode 对应的节点。找到了就 patch 并移动到当前新起点位置找不到就创建一个新节点。循环结束时如果旧列表还有剩余节点批量卸载如果新列表还有剩余节点批量挂载。用伪代码示意一下去掉空节点跳过等细节流程是准确的while (oldStartIdx oldEndIdx newStartIdx newEndIdx) { if (oldStartVnode 与 newStartVnode 相同) { patchVnode(...); oldStartIdx; newStartIdx; } else if (oldEndVnode 与 newEndVnode 相同) { patchVnode(...); oldEndIdx--; newEndIdx--; } else if (oldStartVnode 与 newEndVnode 相同) { patchVnode(...); 把 oldStartVnode 移到队尾; oldStartIdx; newEndIdx--; } else if (oldEndVnode 与 newStartVnode 相同) { patchVnode(...); 把 oldEndVnode 移到队首; oldEndIdx--; newStartIdx; } else { 用 key 在旧列表中找到 newStartVnode 对应的节点; 找不到 - 新建节点; 找得到 - patch 并移动到当前新起点前; newStartIdx; } } // 多余的旧节点批量卸载多余的新节点批量挂载这套设计为什么经典因为它把“头尾微调”这类最常见的列表改动压缩到了几乎没有遍历成本的程度。比如第一项挪到末尾双端比较基本一两轮就能识别出来只需要一次节点移动而不是把整张表从头到尾扫一遍。1.3 软肋全量比较、跨层无解、key 一坏全盘崩Vue 2 最大的软肋是它必须把整棵子树里所有节点都拉进 diff。原因前面提过Vue 2 编译模板时不给节点打“这个部分以后会不会变”的标记。一个从来没有变化的 class 绑定、一个永远不变的文本节点到了更新阶段也要老老实实比较属性、比较子节点。这不是算法笨是运行时拿到的信息根本不够。另一个软肋在不合理的 key 上。如果图省事写:keyindex当列表发生“删除中间某一项”这类变化时diff 会把 index 相同的节点当成同一个节点DOM 被复用了内容却张冠李戴组件里若有状态状态也会跟着串位。我在实际项目里见过因为 index 当 key删掉第一行之后 input 里的输入全跑到下一行的案例排查很费时间根因就是 diff 的身份判断彻底乱了。还有跨层级移动。某个节点从 A 容器挪到 B 容器Vue 2 会直接卸载再重新挂载而不是把它“搬过去”。因为同层对比默认不同层的节点不是同一个东西。这也算设计取舍但遇到复杂拖拽、动态布局时确实会带来性能损耗。研究 Vue 2 源码我最大的感受是它是一套经验驱动的高手设计把“相邻微调”优化到了极致但对“中间大段乱序”的场景依然要靠 key 映射逐个查找大量的插入和移动无法避免。Vue 3 等于是在这套认知上换了一台新引擎。2. Vue 3 先给模板“减负”PatchFlag、Block Tree 和事件缓存2.1 思路反转把工作从运行时挪到编译期Vue 3 最本质的变化不是“把双端比较换成最长递增子序列”这么简单而是把优化前置到了编译阶段。Vue 开发通常用 SFC 单文件组件模板是写死在 .vue 文件里的编译阶段能看到完整的结构。既然看得到为什么不顺便算出“哪些节点以后会变、哪些永远不变”于是 Vue 3 在编译模板时做了两件事静态提升hoistStatic和连续静态节点的预字符串化。一段模板里如果存在完全不依赖响应式变量的片段它的 vnode 只创建一次并被缓存之后每次更新直接复用同一个对象。运行时不再反复生成静态节点的虚拟对象。等真正走到 patch 阶段时不少子树甚至根本不会出现在比较链路里。这个思路和 Vue 2 完全相反。Vue 2 把所有比较压力都放在运行时所有节点都参与 diffVue 3 则是“能静态的都静态动态的收窄到一个尽量小的范围再 diff”。在我看来这是两代框架最分水岭的差异——不是哪一招更快而是性能来源变了。2.2 PatchFlag给动态节点贴一张“变化类型”标签编译动态节点时Vue 3 会给它算一个 patchFlag。这个标记本质是一组二进制位表示“这个节点的哪一类属性会变”。更新时patch 函数看到 flag 就知道该比较 text、class、style 还是全部 props完全不用把所有属性和子节点翻一遍。常见的标记如下数值用二进制位移表示patchFlag名称含义1TEXT文本内容会变2CLASSclass 会变4STYLEstyle 会变8PROPS其他 props 会变用 dynamicProps 数组列出具体属性16FULL_PROPS无法静态分析整个 props 都可能变64STABLE_FRAGMENT稳定顺序的 Fragment128KEYED_FRAGMENT带 key 的 Fragmentv-for256UNKEYED_FRAGMENT不带 key 的 Fragment512NEED_PATCH需要强制更新动态事件、动态 slot 等举个例子模板里写div :classcls{{ text }}/div编译后这个 div 的 patchFlag 会是 1 | 2 3。运行时更新时只需要检查文本是否变化、class 是否变化其余一概不管。这比 Vue 2“拿着 oldProps 和 newProps 逐个属性 diff 一遍”精细太多了尤其是大型表单和复杂卡片类组件能省下大量无脑的属性对比。2.3 Block Tree只巡逻装了动态装置的节点光有 patchFlag 还不够。如果根节点下挂着一棵巨大的静态子树中间只有一个动态子节点Vue 2 依旧要从根一路比较到那个节点。Vue 3 用 Block 解决这个“深层定位”问题。一个 Block 节点会把自己下面所有动态子节点收集到一个 dynamicChildren 数组里。更新时patch 函数拿到新旧两个 Block不需要遍历完整的子节点树只需要循环对比 dynamicChildren 数组里的动态节点。静态部分根本不会出现在动态数组里相当于每次更新都自动绕过一大片不用改的东西。需要特别注意的是v-if、v-for 这类会改变节点结构的指令会各自成为新的 Block。为什么因为结构一旦变化“父块收集到的动态子节点”顺序可能对不上之前的位置必须重新建立索引。所以 Vue 3 把普通动态节点交给父级 Block 统一管理把结构型指令所在的区域单独剥出来管理。这也是 Fragment 分成 STABLE、KEYED、UNKEYED 三种的根本原因运行时对不同形态的 v-for 走不同的快通道。用大白话类比Vue 2 像每次打扫都要把整套房子的每个角落检查一遍Vue 3 像搬家前给所有可能脏的地方贴标签平时只擦贴了标签的台面结构一变再重新规划标签区域。省下来的时间相当可观。2.4 事件缓存消灭无意义的函数重建模板里写button clickhandlerVue 3 会编译成类似这样onClick: _cache[0] || (_cache[0] $event handler($event))第一次渲染后这个事件函数被缓存起来之后每次更新直接复用同一个函数。而 Vue 2 每次 render 都会生成一个新的匿名函数diff 对比 props 时发现事件函数“变了”于是白白多触发一轮更新。事件缓存把这整整一类“更新了但其实什么都没变”的情况直接掐掉了。整体看下来Vue 3 多数场景的 patch 过程是这样的要么节点是静态的直接跳过要么一个 Block 里只有几个动态子节点逐个 patch 就行要么遇到带 key 的列表乱序才进入真正的复杂 diff。Vue 3 把“复杂 diff”挤到了最后一道防线普通页面的大部分更新根本到不了那一层。3. 最长递增子序列Vue 3 让“移动”这件事做到最省3.1 先夹头夹尾中间再进乱序区真正需要动用到最长递增子序列的只有一种场景带 key 的列表节点前后有大量节点没变但中间区域发生了插入、删除和位置调整。Vue 3 的 patchKeyedChildren 拿到新旧子节点数组后不是上来就找 LIS而是先做两个非常直白的循环。第一个循环从头部开始持续比较新旧节点。类型相同就 patch两边指针一起往后走遇到类型不同的节点就立刻停止。第二个循环从尾部开始同样持续 patch两边指针一起往前走遇到不同就停止。两步完成之后新旧列表的“公共前缀”和“公共后缀”都被消化掉了剩下中间的才是真正需要精细处理的范围。如果这时新列表还剩下节点说明都是新增统一挂载如果旧列表还剩下节点说明都是删除统一卸载如果两边都有剩余才有必要进入乱序处理。这种“先收缩边界再把复杂区留到中间”的写法比 Vue 2 一上来就四个指针互相比对清晰得多。实际业务里列表最常见的改动恰恰集中在头部和尾部这两步往往能把问题消解掉一大半。3.2 为什么要用 LIS让尽可能多的节点原地不动现在进入核心问题对中间乱序区域怎么移动最省先说一个反直觉的结论Vue 3 的目标不是“每个节点都移到正确位置”而是先找出它在旧列表和新列表里相对顺序一致的一批节点让这批节点不动再把其余节点插到对应位置。这条“不用动”的节点序列越长需要移动的节点就越少整体操作就越省。举个例子旧列表是 [A, B, C, D, E]新列表是 [C, D, E, A, B]。这个变化本质是 A、B 被挪到了末尾C、D、E 的相对顺序没有变。把“每个旧节点在新列表中的位置”列出来C 对应 0D 对应 1E 对应 2正好是一条连续递增的序列。把这条序列保留住只移动 A、B就完成了从旧到新的变化。结合更一般的数据Vue 3 的做法是先给新列表的乱序区域建一个“新位置到旧索引”的映射数组然后对这个映射数组求最长递增子序列。LIS 中记录的那些新位置不去动它们不在 LIS 中的节点按从后往前的顺序一个个插入到正确位置。注意LIS 算法找出的集合不一定在每个场景里都是数学意义上的全局最优但工程上用起来已经足够优秀时间效率也高。Vue 2 和 Vue 3 在这个场景下的对比可以整理成下面这张表场景Vue 2 的处理Vue 3 的处理头尾有小改动双端四轮比对成本很低先夹头部再夹尾部也是低成本中间大段乱序key 逐个查找插入和移动多key 映射 LIS找出最少移动集大量静态节点全部进入 diff静态提升 Block 收集根本不进 diff事件绑定每次 render 重造函数props 对比可能误判变化事件缓存复用函数直接跳过3.3 getSequence 的通俗版贪心加二分最后回溯Vue 3 源码里的 getSequence 并不神秘它没有用 O(n^2) 的动态规划而是“贪心 二分查找 前驱链表回溯”的组合。核心思路是维护一个“当前增长序列的下标数组” result遍历输入数组时如果当前值比 result 最后一个值还大就追加进去否则用二分法找到 result 中第一个大于等于当前值的位置替换那里的记录。替换本身可能破坏实际顺序所以再用一个 p 数组记录每个元素的前驱关系最后从最后一个下标倒着回溯还原出真正可用的递增序列。整体时间复杂度 O(n log n)。放到 diff 场景里讲Vue 3 先遍历旧列表中的乱序段用 key 找到每个旧节点在新乱序段中的位置把这个位置对应的“旧索引加一”写进 newIndexToOldIndexMap。加一的原因很直接默认 0 表示“这个新位置在旧列表里没有对应节点”如果不加一真实的旧索引 0 会被当成“不存在”导致误判。接着对这个映射数组求 LIS拿到“不需要移动的新位置集合”。最后倒序遍历新列表的乱序段遇到不在 LIS 里的节点就把它插入到当前基准点之前遇到在 LIS 里的直接跳过。因为是从后往前处理只需要维护一个稳定的锚点就能用 insertBefore 完成全部插入。我自己读这段源码时最容易绕晕的地方是LIS 求的是下标集合而这个下标集合对应的是“新列表中的位置”不是旧列表的位置。一旦把这个对应关系理清楚后面的移动逻辑就顺了。4. 从 diff 进化反推日常开发注意写法也聊聊面试怎么答4.1 key 的真正意义给 diff 一条身份识别捷径不管 Vue 2 还是 Vue 3key 都只有一个作用告诉 diff 这两个节点是同一个对象的两个状态。没有 keydiff 只能按位置猜有了 keydiff 才能精确判断“这节点我认识直接复用只是位置变了”。实际开发里最常见的反面案例还是 index 当 key。比如一个可增删的列表删除第一项之后原来第二项 input 里存着的用户输入会被按 index 逐个 patch 的流程错误地保留到新位置看起来就是输入内容跟着 index 移动了。换成 keyid 后删除第一项其余节点都被识别为“还是原来那个节点”原位不动输入内容自然留在各自 DOM 上视图表现就正常了。另外key 不要用随机值也不要用每次都重新生成的量。否则每次更新都把这个节点当成新节点DOM 重建、组件状态重置diff 优化等于全部失效。稳定且唯一是 key 的两条底线。顺带说一下无 key 列表Vue 3 对没有 key 的 v-for 会生成 UNKEYED_FRAGMENT运行时走简化版的 patchUnkeyedChildren不建 key 映射、不查 LIS速度相对快一点代价是无法精确复用节点。所以如果你的列表真的不会增删、排序、也不需要复用内部状态可以不加 key 走快通道但只要列表可能变化稳定 key 永远是最稳的选择。4.2 哪些写法会让 Vue 3 的优化失效Vue 3 的 diff 优化高度依赖编译期拿到的模板信息所以写法越静态优化越充分写法越动态越要小心。第一是 v-for 和 v-if 写到同一个节点上。Vue 3 里 v-if 的优先级反而比 v-for 高不会再像 Vue 2 一样先循环再过滤但它会让节点的结构判定变复杂而且这种写法本身语义就很绕。我的建议是保持两者分离需要过滤就在外层包一个 template。第二是超长列表不做虚拟滚动。diff 本身或许不是瓶颈但万级数据的 DOM 挂载、事件绑定、样式计算才是大头这样场景上虚拟滚动才有意义。第三是大量使用 v-html 注入外部内容且内容频繁变化。v-html 会让整块内容无法被静态标记优化diff 也会退化成整块替换。第四是现阶段仍大量手写 h 函数、组合动态 slot。这类写法会跳过编译优化patchFlag 常常变大Block 也难以建立。还有一个容易被忽略的点Vue 项目升级到 Vue 3 后如果全部沿用 Vue 2 时期“什么都动态”的写法新版本的性能收益会明显打折。这不代表 Vue 3 不强而是优化前提被绕过了。反过来看这也是代码评审时值得关注的信号——模板越静态、key 越稳运行时就越省。4.3 面试被问 diff五分钟左右讲清脉络面试官问 diff通常不是想听你把 getSequence 的源码背下来而是想确认你有没有把整条链路在脑子里串起来。我建议按顺序讲四块。第一块说必要性虚拟 DOM 是内存里的树直接改真实 DOM 代价高diff 负责找出新旧状态的最小变化集合再批量更新真实 DOM。第二块说 Vue 2同层对比、双端四指针先试头头、尾尾、头尾、尾头四种最优情况不行再靠 key 在旧列表里定位最后清理新增和删除。第三块说 Vue 3真正的变化在编译期PatchFlag 标注动态类型、Block Tree 收集动态子节点、静态提升和事件缓存把大量节点挡在 diff 之外真正进入复杂 diff 的只有带 key 列表的乱序场景先用头尾同步去掉公共前缀后缀再建 key 映射最后用最长递增子序列决定谁不动、谁插入。第四块说落点key 要稳定且唯一避免 index模板尽量静态大列表上虚拟滚动。按这个顺序讲下来面试官基本能判断你是真的理解了而不是在背八股。如果对方追问 getSequence 的实现再往下补贪心加二分、前驱回溯也很自然。真正把原理吃透后你会发现 Vue 3 的 diff 面试题其实是一道送分题。到这里我把 Vue 2 到 Vue 3 的 diff 算法进化史完整过了一遍。我自己读完全部相关源码后的感受是diff 算法不是一道难背的面试题而是一面镜子照出 Vue 设计团队在不同阶段对“性能从哪来”这个问题的理解转变。Vue 2 相信巧妙的运行时策略Vue 3 相信编译期信息比运行时技巧更重要。如果你想进一步吃透我建议直接打开 Vue 3 源码里的 renderer.ts先看 patch 函数再看 patchKeyedChildren最后对照 Vue 2 的 updateChildren 读一遍。亲手跟一遍数据流比刷十篇总结都管用。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。