Vue 如何比较新旧虚拟 DOM,并用尽量少的真实 DOM 操作完成更新?
组件的响应式数据发生变化后,会生成一棵新的虚拟 DOM 树。Vue 随后进入 patch 阶段,对比新旧 VNode,判断哪些节点需要:
Diff 的目的不是找出理论上的最小差异,而是在时间复杂度和 DOM 操作次数之间取得平衡。
真实 DOM 操作通常比 JavaScript 计算昂贵,因此 Vue 可以多做一些数组遍历和映射计算,以减少节点的创建和移动。
两个 VNode 能否复用,主要看:
div 或都是同一个组件;key 是否相同。可以简化理解为:
如果节点类型或 key 不同,旧节点会被卸载,新节点会重新挂载;如果相同,则复用已有 DOM,并继续比较属性和子节点。
通用树 Diff 如果允许节点在任意层级间匹配,计算成本很高。Vue 根据前端页面的实际更新特点,采用同层比较策略:
这里的“不跨层比较”不是说子节点不会被比较,而是说 Vue 不会尝试把当前层的某个节点匹配到另一层。
Vue 2 在比较一组带 key 的子节点时,分别为新旧列表设置头尾指针:
每轮优先尝试四种匹配:
处理逻辑如下:
key,到旧列表中寻找可复用节点;找到则移动,找不到则创建。循环结束后:
双端比较对首尾追加、删除和列表反转等场景比较友好。
Vue 3 不再使用 Vue 2 的四种交叉比较,而是先处理容易确定的节点,再集中处理无法直接判断的中间区间。
完整流程可以分为五步。
从新旧列表头部开始比较,连续复用类型和 key 相同的节点,直到遇到不同节点。
再从新旧列表尾部向前比较,连续处理相同节点。
经过前后预处理后,只需要关注中间未知区间。
如果旧列表已经遍历完,而新列表还有节点,剩余节点全部挂载。
如果新列表已经遍历完,而旧列表还有节点,剩余节点全部卸载。
这两种情况不需要执行后续的乱序比较。
当新旧列表的中间区间都还有节点时,Vue 会:
key → newIndex 映射;key 查找对应的新位置;patch,并记录新节点对应的旧下标;对于没有 key 的节点,Vue 只能在尚未匹配的新节点中查找类型相同的节点,查找成本和误复用风险都会增加。
如果中间节点发生了移动,Vue 会根据记录的新旧下标关系计算最长递增子序列(LIS)。
最长递增子序列代表一组相对顺序没有改变的节点:
Vue 最后从后向前处理节点,因为此时后一个节点已经就位,可以作为当前节点插入或移动时的锚点。
因此,LIS 只是 Vue 3 快速 Diff 中减少 DOM 移动次数的一步,并不等于整个 Diff 算法。只有检测到节点发生移动时,才需要计算 LIS。
| 对比项 | Vue 2 | Vue 3 |
|---|---|---|
| 核心策略 | 双端 Diff | 快速 Diff |
| 首尾处理 | 头头、尾尾、头尾、尾头 | 从头同步、从尾同步,不做四种交叉比较 |
| 中间乱序处理 | key 映射查找并移动 | key 映射、新旧下标关系、LIS |
| 移动优化 | 通过双端匹配减少移动 | 通过 LIS 保留相对顺序稳定的节点 |
| 编译器辅助 | 相对有限 | Patch Flag、Block Tree、静态提升 |
不能简单地说 Vue 2 是双端 Diff、Vue 3 是 LIS。更准确的表述是:Vue 3 使用快速 Diff,LIS 是它处理中间乱序节点时的移动优化。
key 表示节点在同级列表中的稳定身份。它可以帮助 Vue:
没有 key 时,Vue 会尽量按位置就地复用相同类型的节点。它仍然会更新属性、文本和子节点,也可能新增或删除节点,并不是“只更新文本”。
对于只展示简单文本、顺序固定的列表,就地复用未必有问题;但列表包含表单状态、组件状态,或者会插入、删除、排序时,按位置复用可能造成状态和数据错位。
key;key;key,否则节点会被反复销毁和创建。所以,key 不是所有列表在语法层面都必须添加,但对于动态且有状态的列表,应当提供唯一、稳定的 key。
Vue 3 的更新性能不仅来自运行时的快速 Diff,还来自模板编译器提供的信息。
Vue 同时控制编译器和运行时。编译器可以提前分析模板中哪些内容不会变化、哪些内容可能变化以及变化的类型,再把这些信息写入渲染函数,让运行时走更短的更新路径。这种模式称为带编译时信息的虚拟 DOM。
对于完全不依赖响应式数据的节点,编译器可以把它们提升到渲染函数之外。
其中 h1 和 p 完全静态,编译结果可以简化理解为:
组件重新渲染时:
如果节点本身包含动态内容,但某个属性对象是静态的,静态属性也可以被单独提升。
当模板中存在足够多的连续静态节点时,编译器还可以将它们字符串化为一个静态 VNode,首次挂载时批量创建 DOM,避免逐个创建 VNode 和元素。
静态提升解决完全不变的节点,Patch Flag 则用于标记一个动态节点中什么内容可能变化。
编译器知道:
id 是静态属性;class 可能变化;编译结果可以简化为:
Patch Flag 是一个位掩码,多个更新类型可以通过按位或合并。运行时再通过按位与判断需要执行的更新:
常见标记包括:
| 标记 | 含义 |
|---|---|
TEXT | 动态文本 |
CLASS | 动态 class |
STYLE | 动态 style |
PROPS | 属性名确定的动态属性 |
FULL_PROPS | 存在动态属性名,需要完整比较 props |
STABLE_FRAGMENT | 子节点顺序稳定的 Fragment |
KEYED_FRAGMENT | 带 key 的列表 |
UNKEYED_FRAGMENT | 不带 key 的列表 |
DYNAMIC_SLOTS | 包含动态插槽 |
NEED_PATCH | 仍需执行特殊 patch 逻辑 |
对于属性名确定的绑定:
编译器可以生成动态属性名称列表,运行时只比较 id 和 title。但如果属性名也是动态的:
编译阶段无法确定最终要更新哪个属性,因此只能使用 FULL_PROPS,让运行时执行更完整的 props 比较。
编译器掌握的信息越具体,运行时需要做的检查就越少。
Patch Flag 可以告诉运行时某个节点的什么内容会变化,但如果每次更新仍要递归遍历整棵 VNode 树,深层静态结构依然会带来成本。
整棵树中只有 span 的文本可能变化。Vue 3 会创建一个 Block,并把带 Patch Flag 的动态后代收集到 dynamicChildren 中:
组件更新时可以直接遍历 dynamicChildren,跳过 header、main、section 和 footer 等静态层级。这就是 Block Tree 的树结构打平。
这里打平的是一份额外维护的动态节点数组,不是改变实际的 DOM 层级。
考虑下面的条件渲染:
show 为 false 时,span 不存在;为 true 时,span 才存在。根 Block 不能假设 span 始终处于固定位置,因此 v-if 分支需要形成新的 Block:
父 Block 只跟踪分支的 Block 根节点,进入该分支后,再由子 Block 跟踪内部的动态节点。
v-if、v-for 等会改变节点结构的指令通常会形成新的 Block,从而在结构可能变化的情况下保持每个 Block 内部的动态节点结构稳定。
对于带 key 的列表:
编译器会为包裹列表的 Fragment 添加 KEYED_FRAGMENT 标记。运行时看到这个标记后,可以直接进入带 key 的子节点 Diff。
没有 key 的列表会被标记为 UNKEYED_FRAGMENT。如果编译器能够确定 Fragment 的子节点顺序不会变化,则可以使用 STABLE_FRAGMENT,跳过不必要的列表顺序比较。
因此,Patch Flag 不只描述属性更新类型,也可以告诉运行时该采用哪一种子节点更新策略。
下面的内联事件在每次执行渲染函数时,理论上都会创建一个新函数:
编译器可以使用渲染函数的 _cache 缓存它:
这样可以:
编译器只有在确认缓存安全时才会这样处理。例如依赖 v-for 局部变量的事件函数不能简单地在整个组件范围内只缓存一份。
v-once 和v-memo这两个指令可以看成开发者主动向编译器提供缓存信息。
v-once 对应的子树只渲染一次,后续组件更新时直接复用缓存的 VNode。
v-memo 只有在依赖数组发生变化时才重新生成和比较子树。依赖没有变化时,可以跳过整个子树更新。它适合明确存在性能瓶颈的大型列表或复杂子树,不需要在普通模板中普遍添加。
两者处于不同阶段,解决不同问题:
例如:
编译器可以提供以下信息:
h1 和 footer 是静态内容,可以缓存或提升;v-for 形成新的 Block;KEYED_FRAGMENT 标记;li 带有动态 class 和动态文本标记;运行时更新时,可以跳过静态内容,直接定位列表 Block,根据 KEYED_FRAGMENT 进入带 key 的快速 Diff;对于成功复用的 li,再根据 Patch Flag 只更新 class 和文本。
模板语法具有约束,编译器能够对它进行静态分析:
编译器可以确定根节点类型固定、class 是动态属性、子节点是动态文本,并且不存在其他动态结构。
如果使用高度动态的手写渲染函数,节点类型、属性和子节点都可能只能在运行时确定,编译器便无法安全生成同样精确的 Patch Flag 和稳定 Block。这也是 Vue 模板除了书写方便之外的一项性能优势。
一句话总结:静态提升避免重复创建,Patch Flag 避免完整属性比较,Block Tree 避免遍历整棵树,快速 Diff 则减少列表节点的查找和移动。
不要笼统地说“Vue Diff 从 O(n³) 优化到了 O(n)”。O(n³) 通常指允许跨层移动的通用树编辑距离问题,不是 Vue 2 Diff 的实际复杂度。
对于 Vue 3 的带 key 子节点比较:
key 节点通常是 O(1);O(n log n);O(n log n) 的上界理解。算法复杂度只是一个方面。Vue Diff 更重要的目标,是复用已有节点并减少昂贵的真实 DOM 操作。
可以按下面的顺序回答:
Vue Diff 发生在虚拟 DOM 的 patch 阶段,采用同层比较,通过节点类型和 key 判断节点能否复用。Vue 2 使用双端 Diff,通过新旧列表的头尾指针进行四种匹配;Vue 3 使用快速 Diff,先从头和尾同步相同节点,再处理纯新增、纯删除以及中间未知区间。处理中间乱序节点时,Vue 3 会建立 key 映射、记录新旧节点的下标关系,并在发生移动时通过最长递增子序列保留相对顺序稳定的节点,从而减少真实 DOM 移动。Vue 3 还通过 Patch Flag、Block Tree 和静态提升缩小需要比较的范围。key 的作用是标识节点身份,动态且有状态的列表应使用唯一、稳定的 key。
一句话总结:Vue 2 用双端比较尽快复用节点;Vue 3 先缩小未知区间,再用 key 映射和 LIS 减少节点查找与移动。