Vue 的 diff 算法,很多人面试前背得滚瓜烂熟,真到项目里出了问题,还是不知道怎么排查。我自己带过不少前端新人,发现一个很普遍的规律:凡是能把 diff 算法理解透的人,遇到“列表更新后界面没变”“一渲染大量数据页面就卡”这类问题,定位思路都清晰得多。这篇文章就从设计思路到代码行为,把 vue2 和 vue3 的 diff 算法拆开讲清楚,顺便聊聊它们在实际项目里到底影响了什么。适合准备面试的同学,也适合写了几年业务代码、想回头补一补底层原理的开发者。
1. 先搞懂一个基本问题:diff 算法到底在“diff”什么
1.1 虚拟 DOM 是 diff 的地基
谈 diff 之前,必须先把虚拟 DOM 这件事摆清楚。Vue 的响应式系统在数据发生变化时,并不会直接去操作真实 DOM,而是先生成一份新的虚拟 DOM 树,也就是用普通的 JavaScript 对象去描述界面结构,形如{ tag: 'div', props: {}, children: [...] }。之所以要绕这么一圈,是因为真实 DOM 的增删改查代价太高,频繁触发会导致浏览器不断重排重绘,性能很容易垮掉。
虚拟 DOM 相当于把“操作界面”这件事降级成了“操作对象”。JavaScript 对象之间的比较和计算,比来回触碰真实 DOM 要快好几个量级。但光生成新虚拟 DOM 没用,还得知道新旧两棵树到底哪里变了、哪里没变,然后只去更新真正有变化的部分。这个“找差异”的过程,就是 diff 算法在做的事。
1.2 新旧子节点的“盘货”模型
理解 diff 有个很直观的类比:仓库二次盘点。旧节点列表是上一次的库存清单,新节点列表是这次刚到的货单,diff 算法的目标就是在尽量少的搬动次教下,把旧货架整理成新货架的样子。
如果直接暴力比较,新旧两个列表任意两个节点之间都对比一次,时间复杂度是 O(n * m),节点一多直接爆炸。所以 Vue 2 和 Vue 3 都在做同一件事:用各种策略把节点对烘焙过程缩小,能快速判断相等的就快速判断,能不动就不动,实在要动再动。区别只在于,Vue 2 不知道哪些节点是动态的,只能对整棵树的子节点做通用比较;而 Vue 3 在编译阶段拿到了额外情报,可以精准跳过大量静态内容。
1.3 为什么每次都从根节点递归
很多人问过我一个问题:Vue 更新时是不是整棵树重新 diff 一遍?答案是:会,但不是从头到尾每个节点都对比。Vue 的视图是组件树,数据变化触发的是组件级更新,也就是说某个组件内部状态变了,就从这个组件的根节点开始走 patch 流程,向下递归对比子节点。这个范围已经比“整棵应用树”小很多了。
真正决定性能差异的,是每一层子节点的对比策略。比如一个div里有两个子节点,一个是永不变化的静态文本,一个是绑定了动态 class 的节点,Vue 2 会老老实实把每个子节点的 tag、key、props 都比一遍,Vue 3 则可以预先知道只需要关注第二个动态节点。这就是后面要展开的核心区别。
2. Vue 2 的 diff 算法:双端比较与 key
2.1 双端比较的四个指针怎么走
Vue 2 的 diff 算法实现,核心是一个updateChildren函数,维护了四个指针:oldStartIdx、oldEndIdx、newStartIdx、newEndIdx,分别指向旧子节点数组和新子节点数组的头尾。整个比较过程像是一根绳子从两头往中间收,每一轮都尝试用四种“廉价匹配”来命中:
- 新前节点等于旧前节点(头对头)
- 新尾节点等于旧尾节点(尾对尾)
- 新尾节点等于旧前节点(尾对头)
- 新前节点等于旧尾节点(头对尾)
每命中一种情况,就复用节点、移动指针、继续下一轮。这四种判断都指向一个基本原则:尽量做“原地复用”和“最小移动”。因为实际业务里,最常见的变化是列表头部插入、尾部追加、倒序翻转这类局部调整,双端比较能很快锁定哪些部分没变,直接跳过。
这里有一个关键细节:判断两个节点是否“相同”用的是sameVnode函数,它只比较key和tag,不会去比较 props。也就是说,只要 key 和标签一致,就认为可以复用这个 DOM 节点,剩下的属性差异交给后续 patch 阶段去逐个更新。这个设定决定了 key 的重要性,后面单独讲。
2.2 命中不到再找 key:暴力查找流程
双端比较不是万能的。如果四轮都没命中,Vue 2 会启动一条“兜底”路径:拿新前节点去旧节点数组里做一次遍历查找,看能不能找到相同 key 的节点。找到就把它移动到最前面,同时把这个位置在旧数组里标记为空,避免后续重复匹配;找不到就说明这是一个全新节点,直接创建真实 DOM 并插入。
这段逻辑中真正耗时的地方,就是这轮线性查找。对每个新前节点,最坏情况要遍历整个旧数组,再加上后续节点的移动操作,整体开销会明显上升。Vue 2 的 diff 在数据量小的时候体感不明显,但列表一长,频繁做头部插入或乱序操作时,性能就会暴露问题。
2.3 key 不要省略:就地复用带来的坑
Vue 2 里如果不写 key,会走一个“就地复用”策略,也就是相同 tag 的节点直接 patch,不关心它们的内容是否对得上。这个策略对纯静态列表没问题,但对含有状态的列表会出大问题。
最典型的案例:一个可拖拽排序的列表,每项是一个输入框,用户在第一项里输入了文字,拖拽排序后,输入框里的内容也跟着“跑”到了别的项上。原因就是 Vue 复用了 DOM 节点,输入框还是那个输入框,但绑定数据已经变了。这种情况在 Vue 2 中几乎只能靠给每项设置稳定的 key 来规避,而且 key 不能是数组下标,必须是和业务数据强关联的唯一标识,比如 id。
2.4 Vue 2 双端比较的短板
双端比较的聪明之处在于,它用四个指针覆盖了绝大多数“局部移动”场景,实现简单、理解成本低。但它的局限也很明显:第一,它无法预知哪些节点是静态的,所有节点都一视同仁地按 key 和 tag 去比较;第二,线性查找和大量移动操作在极端情况下会退化得很厉害;第三,编译阶段不做优化,运行时就要扛更多压力。
我见过不少 Vue 2 项目,列表数据一上千条,翻页、排序、筛选操作就明显掉帧。当然这不全是 diff 的锅,真实 DOM 创建和事件绑定也占大头,但 diff 范围过大,必然会放大整体开销。这也为 Vue 3 重写 diff 算法提供了最直接的动机。
3. Vue 3 的 diff 算法:静态标记与最长递增子序列
3.1 编译阶段的 patchFlag:给节点“贴标签”
Vue 3 的 diff 效率提升,很大一部分功劳要在编译阶段。模板编译器在解析指令和绑定表达式时,会给动态节点打上patchFlag标记,比如TEXT(文本动态)、CLASS(class 动态)、STYLE(style 动态)、PROPS(属性动态)等。这些标记相当于给每个节点贴了一张“这里有变化”的标签,patch 阶段看到标签就知道要从哪个字段入手,不需要再全量对比 props。
这里有个很有意思的细节:patchFlag 是二进制掩码,多个标记可以合并成一个数字。比如一个节点同时绑定了动态 class 和动态属性,它的 patchFlag 就是CLASS | PROPS。diff 阶段做一次位运算就能判断出需要处理哪些维度,非常省事。这种设计在 Vue 2 中完全不存在,Vue 2 的 patch 只能老老实实走完整流程。
3.2 block tree:动态节点的一次性收集
静态标记只解决了“单个节点知道哪里变了”,还没解决“我到底要 diff 哪些节点”的问题。Vue 3 的答案是 block tree。编译阶段会把模板结构组织成一棵 block 树,每个 block 节点内部只记录动态子节点,并把这些动态子节点收集到一个扁平数组里。
这个设计的直接效果就是:更新时只需要遍历这个扁平的动态节点数组,静态子树一次都不碰。比如一个很大的页面,几百个静态标签里混了三个动态文本,Vue 2 会把几百个节点全部走进 diff 流程,Vue 3 只需比对三个动态节点。对比之下差异相当明显。当然,遇到结构不稳定的情况,比如用了 v-if 造成了节点升降级,Vue 3 会通过动态节点锚点记录来应对,这里不展开,但原理上还是用更精确的追踪换更少的无用功。
3.3 最长递增子序列到底解决了什么问题
Vue 3 里经常被提到的“最长递增子序列”,属于 diff 流程的中后段优化,专门处理数组子节点无法通过双端指针快速命中的情况。当新旧子节点都需要调整顺序时,Vue 3 会先找出“无需移动的节点索引序列”,也就是最长递增子序列,把其他节点按这个序列作为参考点去移动。
举个例子,旧节点顺序是 A B C D E,新节点顺序是 A C D B E。最长递增子序列会命中 A、C、D、E,只有 B 需要移动,这样就把移动次数降到最低。如果用 Vue 2 的双端比较处理这类乱序,可能要频繁移动多个节点,操作次数多不少。
注意:最长递增子序列优化只影响“移动次数”,不影响节点的创建和删除。新节点该建还得建,多余节点该删还得删。面试时很多人误以为 Vue 3 用这个算法省掉了所有开销,其实它只优化了“顺序调整”这一步。
3.4 移动与挂载的判定逻辑
Vue 3 在数组子节点的 diff 中,会先通过新旧 key 建立起映射关系,然后判断每个节点属于“新增”“删除”“移动”“复用”四种类型之一。中间最核心的循环是:从新数组末尾往前遍历,维护一个“当前最大索引”,遇到旧索引小于最大索引的节点就标记为需要移动,否则更新最大索引。这个逻辑的目的,正是为后面的最长递增子序列计算提供基础数据。
这个设计相比 Vue 2 的兜底线性查找,最大的好处是:查找节点时用了 Map(旧节点 key 到索引的映射),查找代价从线性降到常数级。节点一多,性能差距会被放大得很明显。所以 Vue 3 的 diff 本质上是一次“信息更充分”的 diff,编译期的标记、数据结构的升级、以及算法层面的优化,三者一起拉高了更新效率。
4. 一张表看懂 vue2 和 vue3 的 diff 核心区别
| 对比维度 | Vue 2 | Vue 3 |
|---|---|---|
| 比较策略 | 双端比较,逐层 patch 全部子节点 | block tree + patchFlag,只遍历动态节点 |
| 编译阶段 | 不关注模板静态结构 | 编译期标记动态节点,生成优化信息 |
| 节点查找方式 | 兜底时线性遍历,O(n) | 用 key 映射表,Map 查找,接近 O(1) |
| 顺序调整优化 | 双端指针尽力减少移动 | 最长递增子序列计算最低移动次数 |
| 对静态节点 | 也会走进 diff 流程 | 完全跳过 |
| 子节点 patch | 全量比对 props | 按 patchFlag 分维度 patch |
| 性能瓶颈 | 大列表、乱序场景明显吃力 | 大列表、乱序场景显著优于 vue2 |
| 代码理解成本 | 相对简单,四指针逻辑直白 | 编译器 + 运行时两层配合,理解门槛更高 |
这张表只列了 diff 层面的差异,实际 Vue 3 性能提升还叠加了静态提升、事件缓存、响应式系统重写等因素,但仅就 diff 算法而言,上面的每一项都是实打实的改动。
5. 这些差异在实际项目里的影响
5.1 key 的使用建议变了
Vue 2 时代强调 key 不要用 index,这个问题在 Vue 3 依然存在,而且需要说得更细。Vue 3 同样要求列表项 key 尽量稳定,但这个“稳定”指的不是“每次渲染结果一样”,而是“在数据更新过程中,这个 key 能代表同一个业务实体”。
我踩过最典型的坑是这样的:列表数据来自接口,接口返回的数据没有 id 字段,前后端约定用每条数据的name作为唯一标识。后来业务允许重名,重名一出现,列表更新就出错,界面上部分行数据错乱,排查了很长时间才想到是 key 冲突。给 key 加个后缀,或者让后端补唯一 id,问题立刻消失。这个教训可以泛化成一条经验:key 必须能唯一标识列表项,任何可能出现重复的字段都不能当 key 用。
5.2 列表渲染性能优化的重心转移了
Vue 2 中优化长列表,大家习惯性用 Object.freeze 冻结静态数据、用v-once标记静态节点、减少深层嵌套组件。Vue 3 里这些手段的优先级下降了,因为编译优化已经过滤掉大量无意义 diff。现在优化长列表,重点应该在“减少动态节点的数量”,而不是“纠结静态节点会不会被 diff”。
举个例子,一个表格组件里有 50 列、500 行,每行只有第一列绑定了动态 class,其他列都是静态文本。Vue 3 的编译优化会自动让 diff 只在 500 个动态节点上做判断,静态文本列几乎零开销,你不需要手动做任何事。但如果你在每一列的模板里都写了绑定表达式,哪怕是:title="'row_' + index"这种看起来很轻的绑定,也会让它们全部变成动态节点,diff 范围立刻扩大。所以写模板时有个原则:没变化的属性就别加绑定,宁可写死。
5.3 排查列表更新异常的新思路
Vue 2 时代定位列表问题,常见套路是看 key 是否重复、是否用了 index、数据是否被冻结合并。Vue 3 排查问题,要多一步:检查节点是不是被编译器判定为静态了。听起来很抽象,其实有一种可复现的场景:列表项里用了自定义组件,但组件没有被正确识别为动态,或者组件内部依赖了外部数据却没有触发更新,结果列表项显示的就是旧数据。
这种问题在 Vue DevTools 里有一定表现形式:组件树中某层节点没有重新渲染,但父组件确实更新了。排查时先在模板里临时加一个动态属性看有没有反应,再逐步缩小范围,基本都能定位到“节点被跳过”还是“组件缓存了实例”。Vue 3 的 diff 更新粒度更细,反而要求开发者对“何时会触发组件更新”有更准确的理解。
5.4 从 Vue 2 迁移到 Vue 3 需要注意的差异点
迁移项目时,diff 算法的变化不会直接引起报错,但会改变一部分性能特征。比如 Vue 2 的项目里有人喜欢在模板里写大量内联事件绑定@click="() => fn(i)",这在 Vue 2 里不会产生额外 diff 负担,因为事件在 props 里都会被比对。Vue 3 编译阶段会缓存内联事件处理函数,这类写法反而比以前更安全。
真正需要留意的是组件封装层面。Vue 3 的 diff 优化依赖模板编译标记,如果你大量使用手写 render 函数或 JSX,就享受不到 block tree 的自动收集能力,需要手动通过事件缓存、静态提升等机制表达“哪些部分不会变”。这也是为什么我建议能用模板就用模板,不要因为想追求灵活而把整个页面都写成 render 函数,灵活性提升的同时,也把优化空间丢掉了。
6. 我个人在实际踩坑中的一点体会
我参与过一个数据大屏项目,从 Vue 2 迁到 Vue 3 的时候,表格组件的数据量没变,但明显感觉到筛选操作变流畅了。当时我们还以为是响应式代理重写的功劳,后来用性能面板分析,才发现大量时间都省在了 patch 阶段,diff 的静态节点被跳过是关键原因之一。
后来维护内部后台系统时,我又刻意做了一次对比实验:同样一份 800 行数据的可编辑表格,Vue 2 里每次修改单元格后,从触发更新到 DOM 完成变化大约需要几十毫秒;Vue 3 里同样操作只需几毫秒,中间省掉的就是重复 diff 静态单元格的那部分开销。
所以我的一点点建议是:学习 diff 算法时,不要只盯着那些抽象的词汇。最有效的方法是把新旧两棵节点树画在纸上,自己手动走一遍对比过程,然后再去看源码里的循环条件。真正理解 diff 之后,你写起模板、设计列表组件、排查性能问题的时候,会明显比从前心里更有底。如果还想往下挖,可以顺手研究一下 patchFlag 的所有类型和 block tree 的降级处理逻辑,这些都是 Vue 3 性能优化里最有含金量的部分。