news 2026/9/29 17:50:24

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 上。如果图省事写:key="index",当列表发生“删除中间某一项”这类变化时,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 把所有比较压力都放在运行时,所有节点都参与 diff;Vue 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稳定顺序的 Fragment
128KEYED_FRAGMENT带 key 的 Fragment(v-for)
256UNKEYED_FRAGMENT不带 key 的 Fragment
512NEED_PATCH需要强制更新(动态事件、动态 slot 等)

举个例子,模板里写<div :class="cls">{{ 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 @click="handler">,Vue 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 对应 0,D 对应 1,E 对应 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 3,key 都只有一个作用:告诉 diff 这两个节点是同一个对象的两个状态。没有 key,diff 只能按位置猜;有了 key,diff 才能精确判断“这节点我认识,直接复用,只是位置变了”。

实际开发里最常见的反面案例还是 index 当 key。比如一个可增删的列表,删除第一项之后,原来第二项 input 里存着的用户输入,会被按 index 逐个 patch 的流程错误地保留到新位置,看起来就是输入内容跟着 index 移动了。换成 key=id 后,删除第一项,其余节点都被识别为“还是原来那个节点”,原位不动,输入内容自然留在各自 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 读一遍。亲手跟一遍数据流,比刷十篇总结都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 17:49:45

Starnet去星点工具详解:原理、实操与深空后期工作流整合指南

如果你搜“Starnet”这个词&#xff0c;可能会看到两种东西&#xff1a;一篇发表在计算机视觉顶会上的学术论文&#xff0c;或者一个在天文摄影圈被叫惯了的小工具。这篇文章要聊的是后者&#xff0c;也就是那个让无数深空摄影爱好者直呼“真香”的去星点软件。它的用途很简单&…

作者头像 李华
网站建设 2026/9/29 17:47:26

门诊服务聚合系统Java实战:接口编排、状态机与并发扣减设计

简介&#xff1a;这是一份基于 Java 语言开发的门诊服务聚合系统设计源码&#xff0c;面向医疗信息化方向的后端开发者和计算机相关专业学习者&#xff0c;定位在门诊服务场景的聚合管理与流程优化。该系统采用 Spring Boot、Spring MVC、MyBatis 等主流技术栈&#xff0c;包含…

作者头像 李华
网站建设 2026/9/29 17:46:19

Windows下MuJoCo与Qt集成实战:环境配置、仿真验证与界面开发指南

1. 为什么要在Windows上折腾MuJoCo加Qt这套组合如果你正在做机器人控制算法验证、强化学习训练环境搭建&#xff0c;或者需要给仿真系统做一个带界面的上位机&#xff0c;那MuJoCo加Qt这套组合大概率是你绕不开的方案。MuJoCo负责物理仿真&#xff0c;Qt负责界面呈现和交互&…

作者头像 李华
网站建设 2026/9/29 17:43:50

PHP手游平台部署实战:vlcms免费版从环境搭建到支付回调验证

简介&#xff1a;基于PHP的vlcms&#xff08;溪谷软件&#xff09;免费版手游平台程序源码&#xff0c;是一套面向手游运营商和开发者的后台管理系统。系统覆盖用户管理、游戏上下架、支付接口、数据统计、推广活动、在线客服及API接口等核心模块&#xff0c;既能快速搭建手游分…

作者头像 李华
网站建设 2026/9/29 17:43:34

Xilinx PCIe IP核BAR地址配置避坑指南:从原理到实战

调PCIe接口的开发者&#xff0c;十个里有八个都栽在BAR地址配置上&#xff0c;这话一点都不夸张。我自己第一次做Xilinx FPGA的PCIe板卡时&#xff0c;就在BAR空间大小对齐上卡了整整两天&#xff0c;枚举死活过不去&#xff0c;最后发现是IP核里BAR大小设成了1M&#xff0c;而…

作者头像 李华