讲 Vue 3 的 diff 算法,最怕两件事:一是堆源码,二是堆术语。前者让初学者直接放弃,后者让人看完依然不知道它到底“快在哪”。这篇文章我尽量用大白话,把 Vue 3 的 diff 算法核心逻辑拆开讲清楚。不管是刚接触虚拟 DOM,还是面试前想突击原理,或者工作中遇到列表渲染性能问题,都能从里面找到能直接用的东西。我会从“diff 到底在解决什么问题”讲起,再对比 Vue 2 和 Vue 3 的思路差异,然后把核心机制一步步拆开,最后用一个具体例子完整走一遍流程,再把面试里最常踩的几个坑一并说掉。
1. diff算法到底在解决什么问题
1.1 从“数据变了页面要更新”说起
先想一个最基础的场景:页面上有一个列表,你点击按钮往数组里插入了一项数据。Vue 需要让页面跟着更新,它面临一个选择——是把这个列表整个重新渲染一遍,还是找到变化的位置,只插入那一个新节点?
显然,整个重新渲染是最简单粗暴的方案,但对性能不友好。尤其当列表很大、组件树很复杂时,全部重建意味着大量 DOM 创建和销毁,浏览器会卡顿。diff 算法就是用来解决这个问题的:它拿新旧两棵虚拟 DOM 树做对比,找出最小的、必要的 DOM 更新操作,然后只去执行这些操作。
这里的“虚拟 DOM”大家可以理解成用 JavaScript 对象模拟出来的 DOM 结构描述。真实 DOM 的增删改查是昂贵的,但 JavaScript 对象的对比是廉价的。diff 的整个过程就是:先在 JS 对象层面做计算,得出“该改哪里”的结论,最后只把结论落到真实 DOM 上。
1.2 为什么不能整个替换
有人可能会问:既然虚拟 DOM 对比也要花时间,为什么不直接手动操作 DOM,精确更新?这个问题的答案是:手写精确 DOM 更新意味着你必须手动追踪每一个状态变化,这在复杂应用里几乎不可能维护。Vue 的响应式系统帮你追踪了数据变化,diff 则是把“数据变了”翻译成“DOM 该怎么变”的翻译官。
你只要改数据,框架负责搞定剩下的。diff 的价值在于:它不追求绝对最优解,而是在“可维护性”和“性能”之间找平衡。它不需要你操心每一次更新的具体 DOM 操作,但你得理解它的工作方式,才能写出高性能的代码——比如知道 key 怎么用、v-if 和 v-show 怎么选、列表到底该怎么拆组件。
整个 diff 的核心原则其实只有一句话:同层比较,深度优先。所谓“同层比较”,就是旧 VNode 树的第一层子节点,只跟新 VNode 树的第一层子节点对比,不会跳到第二层去比。这样做牺牲了一部分“复用跨层节点”的可能性,但换来了对比效率的极大提升。绝大多数实际场景里,跨层移动 DOM 本来就是少数情况,这个取舍非常划算。
2. Vue 3的diff与Vue 2有什么本质差别
2.1 Vue 2的双端diff
Vue 2 的 diff 算法叫“双端比较”,它会同时从新旧子节点的头部和尾部开始对比,处理四种情况:头头对比、尾尾对比、老头对新尾、老尾对新头。这样做的目的是尽量减少移动操作。Vue 2 还维护了四个索引,分别指向新旧数组的头部和尾部,每处理完一个节点就把对应索引往中间靠拢,直到两者相遇。
这个方案在大多数场景下表现正常,但它有一个明显的软肋:当数组只是做了一次“整体移位”时,双端比较能应对,但如果遇到中间大范围乱序,它的处理方式就开始变得啰嗦——需要不断查找 key 对应的节点位置,然后逐个插入和移动。移动次数多了,DOM 操作自然就多,性能就下来了。
此外,Vue 2 的 diff 是纯运行时的操作。也就是说,它在运行阶段才拿到新旧节点去做各种判断,编译器没有提前给它任何提示。哪怕某个节点的子节点是纯静态的、永远不会变化,它也照样要走一遍完整的对比逻辑。
2.2 Vue 3快速diff的思路转变
Vue 3 的逻辑从“双端同时夹逼”改成了“先头后尾、再处理中间乱序部分”。说得更直白一点:它先尽量复用那些位置没变、类型没变的节点,把明显不用动的部分先划掉,剩下的中间乱序区域,才用最大手段去“精算”。
这个“精算”的重点是——用 key 建立索引映射表,然后通过最长递增子序列算出哪些节点可以不动,哪些节点需要移动,最终把移动次数压到最低。这个思路跟 Vue 2 最大的不同在于:Vue 3 不再追求每次更新都老老实实从两端挨个比,而是先把能确定不动的拿掉,再集中精力处理真正乱序的部分。
我举个例子你就明白了。旧列表是 A B C D,新列表是 A C B D。Vue 2 会通过双端索引来回推进,调整位置;而 Vue 3 先发现头部 A 相同、尾部 D 相同,直接跳过,中间只剩 B C 和 C B 之间的小乱序,这时候建立映射表,发现 C 在新列表里排在 B 后面,于是只需要把 C 移到 B 前面,整个过程一步到位。
2.3 编译期的静态标记和优化
Vue 3 第二个很大的提升来自编译阶段。模板在编译成渲染函数时,编译器会做静态分析,给不同的节点打上不同的“补丁标记”(PatchFlags)。
什么意思呢?比如一个元素的子节点里有动态绑定:class,但它的文本内容全是写死的,那编译器就知道:这个节点更新时,只需要比对 class 即可,文本永远不会变,不用碰。它会给这个节点打上PATCH_CLASS这样的标记。运行时 diff 看到标记后,就能精准只做一类操作,而不是把所有可能性都判断一遍。
此外,Vue 3 还会做静态提升——把永远不变的子节点提升到渲染函数外部,每次渲染直接复用同一个对象,连创建都省了。这两个编译期优化是 Vue 2 完全不具备的。很多人看 Vue 3 的 diff 源码时只看运行时,忽略了编译期的这些标记,其实大错特错,因为正是编译期做的这一层“预标记”,才让运行时 diff 有了省力的资本。
3. diff核心机制逐层拆解
讲完了整体思路,下面来拆 Vue 3 diff 的几个核心环节。这些环节在源码里对应patchKeyedChildren函数,面试时被问到的概率极高,建议能用自己的话把每一步复述出来。
3.1 同类型节点的朴素对比
diff 的第一步,并不是直接比较 key,而是先看两个节点是否“同类型”。这个判断逻辑对应源码里的isSameVNodeType:新节点的类型(type)和 key,必须跟旧节点完全一样。如果类型都不一样,比如旧的是div、新的是span,那就没什么好比的了,直接替换整个节点。
这一步很重要,因为很多新手以为 diff 只靠 key 判断,其实类型是第一关。两个节点类型不同,即使 key 一样,也走的是“卸载旧节点、挂载新节点”的逻辑。
类型相同、key 相同的情况下,才会走patchElement之类的流程:复用 DOM 元素,只更新变化的 props 和子节点。这个“复用”就是虚拟 DOM 性能的来源之一——不是每次数据变化都重新创建 DOM,而是在已有节点上做精细修补。
3.2 从头尾两端逐步逼近
Vue 3 在处理子节点更新时,第一个动作就是从头开始对比,直到遇到一个 key 或类型不同的节点,然后从尾部开始对比,直到遇到不匹配的节点。这样做的目的很明确:把新旧列表中开头和结尾那部分“没变”的节点全部跳过。
假设旧列表是A B C D E,新列表是A B C X E。头头对比时,A、B、C 都能匹配上,直接复用;尾尾对比时,E 也能匹配上。中间就只剩下旧列表的 D 和新列表的 X 需要处理,这时候直接创建一个 X、删除一个 D 就完事了。
这个“双端夹逼”的做法效率非常高,因为实际开发中,列表更新最常见的就是末尾追加、头部删除、中间插入这类场景。两端的相同节点占了大多数,先把它们处理掉,后面要计算的区间就会变得很小。
3.3 中间乱序部分:用key建立映射表
当头部和尾部相同的节点都处理完之后,新旧列表中间剩下的部分,就是真正需要“精算”的乱序区域。Vue 3 的处理方式是:遍历新列表中间剩下的节点,用一个keyToNewIndexMap记录每个 key 对应的新索引;再遍历旧列表中间剩下的节点,通过这个映射表快速判断——旧节点在新列表里还存不存在。
这里有一个容易踩坑的细节:如果旧节点在新列表里找不到对应 key,说明这个节点该被卸载了,直接删除。如果找得到,再判断它的位置在新列表里是否发生了移动。
源码里还维护了一个newIndexToOldIndexMap数组,用来记录“新列表中的节点,在旧列表中的原始索引”。这个数组是后面算最长递增子序列的依据,也是 Vue 3 diff 最精华的一步。
3.4 最长递增子序列:让移动次数最少
为什么需要计算最长递增子序列?因为我们要决定:在乱序区域里,哪些节点可以保持不动,哪些节点需要移动。理想情况下,移动越少越好。
这里先解释一下什么叫最长递增子序列:在一个序列里,找到最长的、顺序递增的子序列(不需要连续)。比如[2, 3, 1, 5, 6],最长递增子序列是[2, 3, 5, 6],长度为 4。
放到 diff 里的应用过程是这样的:先根据newIndexToOldIndexMap得到“新列表节点在旧列表中的索引序列”,把这个序列里数值为 0 的项(表示旧列表里没有的节点)先标记为需要新建;剩下部分,用贪心加二分的方式求出最长递增子序列。这个子序列对应的节点在旧列表中的相对顺序跟新列表一致,所以它们不需要动;不在子序列里的节点,就需要通过插入或移动来到达正确位置。
我直接说结论:getSequence这个函数的目标,是找出“最长不需要移动的节点集合”,其余节点补位移动。整个过程计算量远小于暴力移动每个节点,而且代码写起来也很巧妙——它不直接存最终结果,而是用p数组回溯生成正确的序列。这块代码建议自己读一遍,是 Vue 3 源码里最有“算法题”味道的部分。
4. 手动跑一遍Vue 3的diff流程
光讲概念不够,我们手动走一遍完整流程。假设旧列表的 key 分别是A B C D E F,新列表的 key 是A C D B F E,感受一下 Vue 3 是怎么一步步算的。
4.1 准备一个具体的新旧列表
先画两张表。旧列表索引是 0 到 5,分别对应 A B C D E F;新列表索引是 0 到 5,分别对应 A C D B F E。
第一步,头头对比:旧 0 是新 0,都是 A,复用,索引后移。 第二步,旧 1 是 B,新 1 是 C,不同,头比较结束。 第三步,尾尾对比:旧 5 是 F,新 5 是 E,不同,尾比较也结束。
最终中间部分:旧列表剩下 B C D E(索引 1 到 4),新列表剩下 C D B F E(索引 1 到 4)。注意 F 虽然在旧列表尾部,但现在它被新列表挪到了中间位置,并没有被当成相同的尾节点直接复用。
4.2 四步走的完整过程
现在进入中间乱序处理。
第一步,遍历新列表中间部分,构建keyToNewIndexMap:C 对应新索引 1,D 对应 2,B 对应 3,F 对应 4,E 对应 5。
第二步,遍历旧列表中间部分,计算newIndexToOldIndexMap。旧 B 在新列表里的索引是 3,记录到新数组的位置 3 上,值为旧索引 1;旧 C 的新索引是 1,记录到位置 1,值为旧索引 2;旧 D 的新索引是 2,记录到位置 2,值为旧索引 3;旧 E 在新列表里找不到,标记为卸载。
第三步,处理需要新建的节点。这里判断标准是:新索引对应的newIndexToOldIndexMap值为 0。但刚才记录过程中,F 这个新节点在旧列表里也存在,所以它的值是旧索引 5,不是 0;新列表里的 E 虽然在旧列表里也有,但那个 E 不是我们刚才遍历的旧列表中间部分?这里要注意,我在例子里的 E 是新加的节点,它的 key 跟旧列表的 E 相同,所以其实能匹配上。为了更好说明,我把新列表改成 A C D B F G,G 是全新节点。这样处理时 G 的newIndexToOldIndexMap值为 0,创建新节点。
第四步,计算中间部分节点的最长递增子序列。新列表节点对应的旧索引序列是:旧 B 对应旧索引 1,旧 C 对应旧索引 2,旧 D 对应旧索引 3,旧 F 对应旧索引 5,新 G 对应 0。把 G 排除后,[1, 2, 3, 5]本身就是递增的,所以 B、C、D、F 全都不用动。但因为新列表的顺序是 C D B F G,B 位置偏前,实际上要看新旧顺序是否一致——这里的核心是:newIndexToOldIndexMap按新列表顺序存储旧索引,得到[2, 3, 1, 5](C 的旧索引 2、D 的旧索引 3、B 的旧索引 1、F 的旧索引 5)。这个序列的最长递增子序列是[2, 3, 5],对应新列表的 C、D、F,它们不动;B 不在子序列里,需要移动。
整个过程移动次数仅为一次:把 B 插入到正确位置。这比 Vue 2 那种挨个比较调整的方式要精简得多。
4.3 源码里你该看哪几个函数
如果想从源码层面验证上述逻辑,可以直接看packages/runtime-core/src/renderer.ts里的patchKeyedChildren。这是我个人建议新手最优先读的一段。核心函数就这么几个:isSameVNodeType判断节点是否可复用;patchElement做属性级更新;unmount卸载旧节点;mount挂载新节点;move做节点移动;再配合makeMap和getSequence做索引映射与最长递增子序列计算。
读的时候不要试图一次看懂整段代码,我的方法是:先圈出几个关键的 if 分支,看它们是处理“头部相同”“尾部相同”“乱序”哪种情况,再单步 debug 一个简单例子。配合浏览器断点打一遍,比看十篇文章都管用。
5. 高频面试题与线上排查经验
5.1 为什么不能用index作key
面试官最爱问的问题:v-for 里能不能用 index 作为 key?答案通常是不能,但你得能说清楚为什么。
假设有一个列表[A, B, C],三项都带输入框,你给每一项的 key 都绑定成下标 0、1、2。现在你往列表头部插入一个新项 X,数据变成[X, A, B, C]。由于之前第 0 项的 key 是 0,新的第 0 项 key 也是 0,Vue 就认为前后两项是同一个节点,直接把原第 0 项的 DOM 复用了——但原第 0 项里可能有用户的输入值、有内部组件状态,全被错误地保留在了新项上。这就会导致页面内容错乱,输入框的值对不上号。
如果 key 是每条数据独有的 id,插入 X 后,Vue 的 diff 会发现:旧列表里没有 X 这个 key,于是新建它的节点;其他节点因为 key 没变,原地复用,不会出现状态错乱。这就是为什么“每条数据要有稳定且唯一 key”几乎是一条铁律。
线上排查这种问题,最直接的现象就是:列表插入或删除一条数据后,其他行内容跟着变、输入框值错位、组件反复闪烁。看到这些现象,第一反应就去看 key 绑的是什么。
5.2 同样的内容为什么反复渲染
还有一个很常见的问题:数据明明没变,但组件却反复更新。这往往不是 diff 本身的问题,而是“渲染时生成了新对象”导致的。
举个例子,某个父组件里写了一个内联箭头函数,当作 prop 传给子组件。父组件每次更新,都会创建一个新的函数对象,这个对象的引用变化会触发子组件更新,哪怕它们逻辑上完全等价。diff 对比时发现 props 里函数的引用变了,只能往下走子组件的更新流程。
解决办法一般是:用computed缓存计算结果,避免每次渲染都重新生成对象;用useCallback或把不变的方法提取到外面;对于只依赖 props 的组件,可以结合memo避免不必要的渲染。实际项目里,列表越大,这种“无意义更新”的代价越明显。
5.3 如何判断diff是不是性能瓶颈
最后聊一个实操问题:怎么定位页面卡顿到底是不是 diff 引起的?
我最常用的办法是打开浏览器的 Performance 面板,录制一段交互过程,看渲染相关的耗时占比。如果主要耗时在脚本执行阶段(Scripting)而且能对应到组件的渲染函数,再结合 Vue DevTools 的组件更新高亮,基本能锁定是某个组件被频繁渲染。这时再检查它的父组件是否传入了不稳定 props、列表 key 是否合理、有没有不必要的响应式数据。
还要留意一种情况:v-if和v-show的选择也会影响 diff 压力。v-if为假时,节点不在虚拟 DOM 树里,后续 diff 根本不会碰到它;v-show只是切换display样式,节点始终在树里,每次父组件更新都得跟着参与对比。高频切换用v-show没错,但如果那个节点子树特别庞大、又很少变化,用v-if可能才是更好的选择。
另外补充一个我踩过的坑:有些小伙伴为了让 diff 更高效,给每个列表项套一层特别深的组件嵌套,每层都有大量 props,结果 diff 每层都要跑一遍,反而比扁平结构的渲染更慢。组件拆分的度要拿捏好,不要为了“组件化”牺牲性能。
最后说一点个人体会
Vue 3 的 diff 算法看起来复杂,真正核心就几件事:先划掉不变的、再处理乱序的、最后用算法把移动次数降到最低。理解它之后,你再写列表渲染时会下意识关注 key、关注 props 稳定性、关注组件拆分粒度,这些习惯比背源码重要得多。
我自己最初学这段源码时也被getSequence绕晕过,后来把例子缩小到手算一遍,逻辑瞬间就清晰了。建议你也找一个小列表,用笔在纸上画出新旧索引映射,把最长递增子序列算出来,再对照源码打断点看结果,你会真正掌握它。这个算法以后在写拖拽排序、虚拟滚动列表时也能派上用场,多花这十分钟非常划算。