前阵子带一位刚转 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 | 名称 | 含义 |
|---|---|---|
| 1 | TEXT | 文本内容会变 |
| 2 | CLASS | class 会变 |
| 4 | STYLE | style 会变 |
| 8 | PROPS | 其他 props 会变,用 dynamicProps 数组列出具体属性 |
| 16 | FULL_PROPS | 无法静态分析,整个 props 都可能变 |
| 64 | STABLE_FRAGMENT | 稳定顺序的 Fragment |
| 128 | KEYED_FRAGMENT | 带 key 的 Fragment(v-for) |
| 256 | UNKEYED_FRAGMENT | 不带 key 的 Fragment |
| 512 | NEED_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 读一遍。亲手跟一遍数据流,比刷十篇总结都管用。