从「为什么需要diff」讲起,拆解Vue2双端diff、Vue3快速diff的完整执行逻辑,再落地到日常开发的避坑指南和最佳实践
一、先搞懂:我们到底为什么需要Diff算法?
在讲原理之前,我们必须先搞清楚diff算法的定位——它不是孤立存在的,而是Vue渲染流程里的核心环节,解决的是前端渲染的性能痛点。
1.1 真实DOM操作的性能灾难
前端开发的本质,是数据变化驱动视图更新。但浏览器的真实DOM是一个极其庞大的对象,哪怕只是修改一个属性,都要触发重排重绘,频繁全量更新DOM,会直接导致页面卡顿、性能雪崩。
举个最简单的例子:一个100条数据的列表,只修改了其中1条的文本。如果直接用innerHTML全量替换,相当于销毁100个DOM节点,再重建100个新节点;而如果我们能精准找到那1条变化的节点,只更新它的文本,性能差距能达到上千倍。
1.2 虚拟DOM(VNode)的核心作用
虚拟DOM,就是用一个轻量级的JS对象,来描述真实DOM的结构。它剥离了真实DOM的庞大属性,只保留了渲染必需的核心字段,示例如下:
// 极简VNode结构
const vnode = {
tag: 'div', // 标签名,组件则是组件对象
props: { id: 'app', class: 'container' }, // 属性
children: [], // 子节点,文本节点则对应children为字符串
key: 'unique_key', // 唯一标识,diff的核心依赖
el: null, // 对应的真实DOM节点
// 其他:type、shapeFlag、patchFlag等(Vue3扩展)
}
当数据变化时,Vue会重新执行渲染函数,生成一棵新的VNode树。而diff算法,就是对比新旧两棵VNode树的差异,计算出「最小更新补丁」,最终只把变化的部分更新到真实DOM上。
1.3 Diff算法的本质
一句话总结:diff算法的核心,是在同层VNode对比中,用最低的成本找到节点差异,最大化复用已有DOM节点,最小化DOM操作次数。
二、Diff算法的核心前提:同层比较策略
很多人对diff的第一误解,是认为它会全量对比两棵VNode树的所有节点。但实际上,Vue的diff有一个铁则:只做同层比较,绝对不跨层级对比。
2.1 为什么不做跨层级对比?
前端开发中,DOM节点跨层级移动的场景极其罕见(比如把一个子节点从父容器里移到页面根节点),几乎可以忽略不计。
如果要实现全量的跨层级树对比,算法的时间复杂度是O(n³)(n是节点总数)—— 一个1000个节点的树,就要进行10亿次计算,这在浏览器里完全是性能灾难。
而采用同层比较策略后,只需要遍历每一层的节点,时间复杂度直接被优化到O(n),实现了质的飞跃。
2.2 同层比较的规则
- 只对比同一父节点下的同级子节点
- 如果节点跨层级移动了,不会尝试复用,直接销毁旧节点、在新位置创建新节点
- 只有同类型的节点,才会进行深度对比;类型不同,直接销毁重建
举个例子:
<!– 旧结构 –>
<div>
<p>
<span>文本</span>
</p>
</div>
<!– 新结构 –>
<div>
<span>文本</span>
<p></p>
</div>
这里的span从p的子节点,变成了div的子节点,跨了层级。diff算法不会复用这个span节点,会直接销毁旧的span,在div下新建一个span。
三、Vue2的核心Diff:双端Diff算法
Vue2的diff核心,是双端对比算法,它的核心设计思路,是优先对比首尾节点,最大化减少循环查找的次数,提升节点复用效率。
3.1 前置准备:双指针定义
对比新旧两个children数组时,Vue2会定义4个指针,分别指向新旧数组的首尾:
- oldStartIdx:旧列表起始指针(初始0)
- oldEndIdx:旧列表结束指针(初始oldChildren.length – 1)
- newStartIdx:新列表起始指针(初始0)
- newEndIdx:新列表结束指针(初始newChildren.length – 1)
同时,会拿到4个指针对应的VNode节点:oldStartVNode、oldEndVNode、newStartVNode、newEndVNode。
3.2 双端Diff的核心执行流程
双端diff的核心,是4种优先命中的首尾对比逻辑,这4种逻辑会循环执行,直到首尾指针相遇。
我们用一个具体的例子来演示:
- 旧children:[A, B, C, D](key分别为a/b/c/d)
- 新children:[D, A, B, C](key分别为d/a/b/c)
第1步:旧前 vs 新前
对比oldStartVNode和newStartVNode,判断是否是同一个节点(sameVNode:key相同、标签/类型相同)。
- 本例中:A vs D,不匹配,进入下一个判断。
第2步:旧后 vs 新后
对比oldEndVNode和newEndVNode。
- 本例中:D vs C,不匹配,进入下一个判断。
第3步:旧前 vs 新后
对比oldStartVNode和newEndVNode。
- 本例中:A vs C,不匹配,进入下一个判断。
第4步:旧后 vs 新前
对比oldEndVNode和newStartVNode。
- 本例中:D vs D,匹配成功!
匹配成功后,做两件事:
此时,指针状态:
- 旧列表:oldStartIdx=0(A),oldEndIdx=2(C)
- 新列表:newStartIdx=1(A),newEndIdx=3(C)
循环执行,直到指针相遇
接下来继续循环4步对比:
3.3 非首尾匹配的处理:key与映射表
如果4种首尾对比都没有命中,就进入「乱序节点处理逻辑」,这也是key的核心作用场景。
举个例子:
- 旧children:[A, B, C, D]
- 新children:[B, E, C, A, D]
首尾4种对比都不匹配,此时Vue会做两件事:
- 如果找到了:复用这个节点,把对应的DOM移动到旧起始节点的前面,同时把旧列表里这个位置的节点置为undefined(避免重复复用),newStartIdx后移;
- 如果没找到:说明这是个新节点,直接创建新的DOM节点,插入到旧起始节点前面,newStartIdx后移。
3.4 循环结束后的收尾处理
当首尾指针相遇,循环终止,此时会有两种情况:
3.5 Vue2双端Diff的局限性
双端diff在当时已经是非常优秀的实现,但依然有可优化的空间:
四、Vue3的进阶Diff:快速Diff(Quick Diff)算法
Vue3的diff算法,在Vue2的基础上做了革命性的优化,核心是先处理首尾同序节点,再用最长递增子序列(LIS)解决乱序节点的最小移动问题,同时配合前置的编译优化,实现了性能的飞跃。
4.1 Vue3在Diff前的前置优化(性能飞跃的核心)
很多人不知道,Vue3的diff性能优势,一半来自于编译阶段的优化,在diff执行前,就已经砍掉了90%不必要的对比。
1. 静态提升
Vue3的编译器会把模板里完全静态的节点(没有绑定动态数据、没有v-if/v-for的节点),直接提升到渲染函数外部。每次重新渲染时,不会重新生成这些节点的VNode,直接复用,diff的时候会完全跳过这些节点,不做任何对比。
比如模板里的<div>我是静态文本</div>,只会在初始化时生成一次VNode,后续更新完全不参与diff。对于官网、文档这类静态内容多的页面,性能提升极其明显。
2. 补丁标记(PatchFlags)
Vue3会给每个动态节点,打上精准的「动态标记」,告诉diff算法:这个节点只有哪些部分会变化,只需要对比这些部分即可,不用全量对比props。
举个例子:
<div :class="cls" :id="container">{{ text }}</div>
这个节点会被打上TEXT | CLASS的标记,diff的时候,只会对比text文本和class属性,哪怕有id、style、自定义属性等,只要是静态的,完全不会对比,实现了真正的靶向更新。
3. 事件缓存
Vue2中,每次渲染函数执行,都会生成新的内联事件函数,导致节点被判定为变化,触发不必要的更新。Vue3会把事件处理函数缓存起来,每次渲染都复用同一个函数引用,彻底避免了这个问题。
4.2 快速Diff的核心执行流程
Vue3的快速diff,核心思路是「先处理确定的同序节点,再处理不确定的乱序节点」,整体分为3个核心步骤。
我们还是用具体例子演示:
- 旧children:[A, B, C, D, E](key: a/b/c/d/e)
- 新children:[A, B, D, C, E](key: a/b/d/c/e)
第一步:前置同序对比(头对头)
从前往后,逐个对比新旧节点,只要是sameVNode,就复用节点,指针同时后移,直到不匹配为止。
本例中:
- A vs A:匹配,指针后移
- B vs B:匹配,指针后移
- C vs D:不匹配,前置对比结束。
此时,指针状态:
- 旧列表起始索引:i=2(C)
- 新列表起始索引:i=2(D)
第二步:后置同序对比(尾对尾)
从后往前,逐个对比新旧节点,只要是sameVNode,就复用节点,指针同时前移,直到不匹配为止。
本例中:
- E vs E:匹配,指针前移
- D vs C:不匹配,后置对比结束。
此时,指针状态:
- 旧列表结束索引:oldEnd=3(D)
- 新列表结束索引:newEnd=3(C)
经过这两步,首尾两端所有同序的节点都已经处理完毕,剩下的就是中间的乱序部分,也就是本例中的[C,D](旧)和[D,C](新)。
第三步:剩余节点的分情况处理
此时会出现3种情况:
4.3 核心优化:最长递增子序列(LIS)的妙用
为什么要用LIS?因为我们的目标是最小化DOM移动次数。最长递增子序列,就是在乱序的节点中,找到最长的、索引保持递增的节点序列——这个序列里的节点,相对位置完全没有变化,完全不需要移动,我们只需要移动序列之外的节点即可。
举个最直观的例子:
- 旧列表索引对应的key:[a, b, c, d, e](索引0-4)
- 新列表key:[a, b, d, c, e]
第一步,我们先给新列表的剩余节点,建立「key到新索引」的映射表,然后生成一个source数组:source数组的索引,对应新列表剩余节点的位置,值对应该节点在旧列表中的索引,找不到则为-1(新增节点)。
本例中,剩余节点是D、C:
- D在旧列表的索引是3,C在旧列表的索引是2
- source数组为:[3, 2]
第二步,计算source数组的最长递增子序列。[3,2]的LIS是[3]或者[2],长度为1。
第三步,从后往前遍历剩余节点:
- 索引在LIS里的节点:不需要移动,直接跳过;
- 索引不在LIS里的节点:移动到对应位置;
- 值为-1的节点:新建DOM插入。
本例中,LIS是[3](对应D节点),所以D不需要移动,只需要把C节点移动到D的后面即可,只需要1次DOM操作。
如果用Vue2的双端diff,同样的场景,可能会触发2次DOM移动,对于长列表的乱序场景,LIS的优化效果会被无限放大。
4.4 Vue3 Diff对比Vue2的核心优势
| 时间复杂度 | O(n) | O(n)(常数级开销大幅降低) |
| 静态节点处理 | 全量参与对比 | 完全跳过,不参与diff |
| 动态属性对比 | 全量props对比 | 基于PatchFlags靶向对比 |
| 节点移动优化 | 无最优解保证 | 基于LIS实现最小DOM移动 |
| 事件处理 | 每次渲染生成新函数,易触发更新 | 事件缓存,复用函数引用 |
| Fragment支持 | 不支持多根节点 | 原生支持,diff处理Fragment子节点 |
五、90%开发者都会踩的Diff坑:key的正确使用
讲完了diff原理,我们必须落地到开发中最核心、最容易踩坑的点:key属性。可以说,80%的列表渲染bug,都来自于key的错误使用。
5.1 key的核心作用到底是什么?
一句话总结:key是Vue识别节点唯一身份的标识,它能让diff算法精准找到可复用的节点,最大化减少DOM操作,同时避免节点复用导致的状态错乱。
没有key的时候,Vue会采用「就地复用」策略:如果节点类型相同,就直接复用DOM节点,只更新节点的内容。这个策略在纯展示列表里没问题,但一旦节点有内部状态(比如输入框、多选框),就会出现严重的bug。
5.2 为什么绝对不能用index作为key?
这是开发中最常见的错误,我用一个最直观的例子,给大家讲清楚危害。
举个例子:一个列表,用index作为key,渲染3个输入框:
<template>
<div v-for="(item, index) in list" :key="index">
<input type="checkbox" /> {{ item.name }}
</div>
<button @click="list.shift()">删除第一项</button>
</template>
<script setup>
import { ref } from 'vue'
const list = ref([
{ id: 1, name: '选项1' },
{ id: 2, name: '选项2' },
{ id: 3, name: '选项3' }
])
</script>
操作步骤:
预期结果:选项1被删除,剩下的选项2、选项3的输入框都未勾选。
实际结果:选项1被删除了,但第一个输入框依然是勾选状态,对应变成了选项2,出现了状态错乱。
根本原因
用index作为key时,删除第一项后,列表的index会重新排序:
- 旧key:0、1、2 → 对应选项1、2、3
- 新key:0、1 → 对应选项2、3
diff算法看到key=0的节点依然存在,会直接复用这个DOM节点,只更新了后面的文本内容。但输入框的勾选状态,是DOM的内部状态,Vue不会更新它,就导致了状态错乱。
除此之外,用index作为key,在列表排序、逆序、新增/删除时,会导致大量节点的key发生变化,diff算法无法复用节点,只能销毁重建,造成严重的性能浪费。
5.3 为什么不能用随机数作为key?
每次渲染时,随机数都会重新生成,key每次都不一样。diff算法会认为所有节点都是新的,直接全量销毁旧节点,重建所有新节点,相当于每次更新都全量重绘,性能直接拉满到灾难级别。
5.4 key的最佳实践
六、Diff算法在Vue完整更新流程中的位置
最后,我们把diff算法放到Vue的完整更新链路里,让大家有一个全局的认知:


