Vue Diff 算法

Vue 如何比较新旧虚拟 DOM,并用尽量少的真实 DOM 操作完成更新?

一、先理解 Diff 在做什么

组件的响应式数据发生变化后,会生成一棵新的虚拟 DOM 树。Vue 随后进入 patch 阶段,对比新旧 VNode,判断哪些节点需要:

  • 复用并更新
  • 新增
  • 删除
  • 移动

Diff 的目的不是找出理论上的最小差异,而是在时间复杂度和 DOM 操作次数之间取得平衡。

真实 DOM 操作通常比 JavaScript 计算昂贵,因此 Vue 可以多做一些数组遍历和映射计算,以减少节点的创建和移动。

二、Vue 如何判断两个节点能否复用

两个 VNode 能否复用,主要看:

  1. 节点类型是否相同,例如都是 div 或都是同一个组件;
  2. key 是否相同。

可以简化理解为:

function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key;
}

如果节点类型或 key 不同,旧节点会被卸载,新节点会重新挂载;如果相同,则复用已有 DOM,并继续比较属性和子节点。

三、为什么只比较同层节点

通用树 Diff 如果允许节点在任意层级间匹配,计算成本很高。Vue 根据前端页面的实际更新特点,采用同层比较策略:

  • 新旧节点类型不同:直接替换当前节点及其子树;
  • 新旧节点类型相同:复用节点,继续比较属性和子节点;
  • 比较一组子节点时:重点解决新增、删除、复用和移动问题。

这里的“不跨层比较”不是说子节点不会被比较,而是说 Vue 不会尝试把当前层的某个节点匹配到另一层。

四、Vue 2:双端 Diff

Vue 2 在比较一组带 key 的子节点时,分别为新旧列表设置头尾指针:

旧列表:oldStart → ... ← oldEnd
新列表:newStart → ... ← newEnd

每轮优先尝试四种匹配:

  1. 旧头和新头;
  2. 旧尾和新尾;
  3. 旧头和新尾;
  4. 旧尾和新头。

处理逻辑如下:

  • 旧头与新头相同:复用并更新,两个头指针后移;
  • 旧尾与新尾相同:复用并更新,两个尾指针前移;
  • 旧头与新尾相同:复用并将旧头移动到尾部;
  • 旧尾与新头相同:复用并将旧尾移动到头部;
  • 四种情况都未命中:根据新头节点的 key,到旧列表中寻找可复用节点;找到则移动,找不到则创建。

循环结束后:

  • 新列表还有剩余节点,说明需要新增;
  • 旧列表还有剩余节点,说明需要删除。

双端比较对首尾追加、删除和列表反转等场景比较友好。

五、Vue 3:快速 Diff

Vue 3 不再使用 Vue 2 的四种交叉比较,而是先处理容易确定的节点,再集中处理无法直接判断的中间区间。

完整流程可以分为五步。

1. 从头同步相同节点

从新旧列表头部开始比较,连续复用类型和 key 相同的节点,直到遇到不同节点。

旧:a b c d
新:a b e d
    └─┘
   直接复用

2. 从尾同步相同节点

再从新旧列表尾部向前比较,连续处理相同节点。

旧:a b c d
新:a b e d
          └ 尾部 d 直接复用

经过前后预处理后,只需要关注中间未知区间。

3. 处理纯新增或纯删除

如果旧列表已经遍历完,而新列表还有节点,剩余节点全部挂载。

如果新列表已经遍历完,而旧列表还有节点,剩余节点全部卸载。

这两种情况不需要执行后续的乱序比较。

4. 处理未知的中间区间

当新旧列表的中间区间都还有节点时,Vue 会:

  1. 为新列表中间区间建立 key → newIndex 映射;
  2. 遍历旧列表中间区间,根据 key 查找对应的新位置;
  3. 找不到对应位置的旧节点直接卸载;
  4. 找到的节点继续执行 patch,并记录新节点对应的旧下标;
  5. 根据下标是否保持递增,判断节点是否发生移动。

对于没有 key 的节点,Vue 只能在尚未匹配的新节点中查找类型相同的节点,查找成本和误复用风险都会增加。

5. 用最长递增子序列减少移动

如果中间节点发生了移动,Vue 会根据记录的新旧下标关系计算最长递增子序列(LIS)。

最长递增子序列代表一组相对顺序没有改变的节点:

  • 位于 LIS 中的节点不移动;
  • 不在 LIS 中的节点移动到正确位置;
  • 新节点直接挂载。

Vue 最后从后向前处理节点,因为此时后一个节点已经就位,可以作为当前节点插入或移动时的锚点。

因此,LIS 只是 Vue 3 快速 Diff 中减少 DOM 移动次数的一步,并不等于整个 Diff 算法。只有检测到节点发生移动时,才需要计算 LIS。

六、Vue 2 和 Vue 3 的区别

对比项Vue 2Vue 3
核心策略双端 Diff快速 Diff
首尾处理头头、尾尾、头尾、尾头从头同步、从尾同步,不做四种交叉比较
中间乱序处理key 映射查找并移动key 映射、新旧下标关系、LIS
移动优化通过双端匹配减少移动通过 LIS 保留相对顺序稳定的节点
编译器辅助相对有限Patch Flag、Block Tree、静态提升

不能简单地说 Vue 2 是双端 Diff、Vue 3 是 LIS。更准确的表述是:Vue 3 使用快速 Diff,LIS 是它处理中间乱序节点时的移动优化。

七、key 到底有什么作用

key 表示节点在同级列表中的稳定身份。它可以帮助 Vue:

  • 判断新旧节点是否是同一个节点;
  • 正确复用已有 DOM 和组件实例;
  • 快速找到节点的新位置;
  • 正确执行新增、删除和移动;
  • 保留输入框状态和组件内部状态;
  • 正确触发列表过渡动画。

不使用 key

没有 key 时,Vue 会尽量按位置就地复用相同类型的节点。它仍然会更新属性、文本和子节点,也可能新增或删除节点,并不是“只更新文本”。

对于只展示简单文本、顺序固定的列表,就地复用未必有问题;但列表包含表单状态、组件状态,或者会插入、删除、排序时,按位置复用可能造成状态和数据错位。

key 的使用原则

  • 使用唯一、稳定的业务 ID;
  • 同一层级的兄弟节点不能使用重复 key
  • 会插入、删除或排序的列表,不要使用数组下标作为 key
  • 不要每次渲染都生成随机 key,否则节点会被反复销毁和创建。

所以,key 不是所有列表在语法层面都必须添加,但对于动态且有状态的列表,应当提供唯一、稳定的 key

八、Vue 3 的编译时优化

Vue 3 的更新性能不仅来自运行时的快速 Diff,还来自模板编译器提供的信息。

Vue 同时控制编译器和运行时。编译器可以提前分析模板中哪些内容不会变化、哪些内容可能变化以及变化的类型,再把这些信息写入渲染函数,让运行时走更短的更新路径。这种模式称为带编译时信息的虚拟 DOM

模板
  ↓ 解析和转换
AST
  ↓ 代码生成
带优化信息的渲染函数
  ↓ 执行
VNode
  ↓ patch
真实 DOM

静态提升

对于完全不依赖响应式数据的节点,编译器可以把它们提升到渲染函数之外。

<div>
  <h1>用户列表</h1>
  <p class="description">以下是全部用户</p>
  <span>{{ count }}</span>
</div>

其中 h1p 完全静态,编译结果可以简化理解为:

const _hoisted_1 = createElementVNode('h1', null, '用户列表', -1);
const _hoisted_2 = createElementVNode(
  'p',
  { class: 'description' },
  '以下是全部用户',
  -1,
);

function render(_ctx) {
  return createElementBlock('div', null, [
    _hoisted_1,
    _hoisted_2,
    createElementVNode('span', null, _ctx.count, 1),
  ]);
}

组件重新渲染时:

  • 静态 VNode 不会重复创建;
  • 新旧虚拟 DOM 引用同一个静态对象;
  • patch 可以直接跳过静态内容。

如果节点本身包含动态内容,但某个属性对象是静态的,静态属性也可以被单独提升。

当模板中存在足够多的连续静态节点时,编译器还可以将它们字符串化为一个静态 VNode,首次挂载时批量创建 DOM,避免逐个创建 VNode 和元素。

Patch Flag

静态提升解决完全不变的节点,Patch Flag 则用于标记一个动态节点中什么内容可能变化

<div id="user" :class="{ active }">
  {{ name }}
</div>

编译器知道:

  • id 是静态属性;
  • class 可能变化;
  • 文本可能变化。

编译结果可以简化为:

createElementVNode(
  'div',
  {
    id: 'user',
    class: normalizeClass({ active: _ctx.active }),
  },
  toDisplayString(_ctx.name),
  3, // TEXT | CLASS
);

Patch Flag 是一个位掩码,多个更新类型可以通过按位或合并。运行时再通过按位与判断需要执行的更新:

if (patchFlag & PatchFlags.TEXT) {
  // 只比较和更新文本
}

if (patchFlag & PatchFlags.CLASS) {
  // 只比较和更新 class
}

常见标记包括:

标记含义
TEXT动态文本
CLASS动态 class
STYLE动态 style
PROPS属性名确定的动态属性
FULL_PROPS存在动态属性名,需要完整比较 props
STABLE_FRAGMENT子节点顺序稳定的 Fragment
KEYED_FRAGMENTkey 的列表
UNKEYED_FRAGMENT不带 key 的列表
DYNAMIC_SLOTS包含动态插槽
NEED_PATCH仍需执行特殊 patch 逻辑

对于属性名确定的绑定:

<div :id="id" :title="title"></div>

编译器可以生成动态属性名称列表,运行时只比较 idtitle。但如果属性名也是动态的:

<div :[propName]="value"></div>

编译阶段无法确定最终要更新哪个属性,因此只能使用 FULL_PROPS,让运行时执行更完整的 props 比较。

编译器掌握的信息越具体,运行时需要做的检查就越少。

Block Tree

Patch Flag 可以告诉运行时某个节点的什么内容会变化,但如果每次更新仍要递归遍历整棵 VNode 树,深层静态结构依然会带来成本。

<div>
  <header>
    <h1>个人中心</h1>
    <p>欢迎回来</p>
  </header>

  <main>
    <section>
      <span>{{ username }}</span>
    </section>
  </main>

  <footer>Copyright</footer>
</div>

整棵树中只有 span 的文本可能变化。Vue 3 会创建一个 Block,并把带 Patch Flag 的动态后代收集到 dynamicChildren 中:

根 Block
└── dynamicChildren
    └── span:动态文本

组件更新时可以直接遍历 dynamicChildren,跳过 headermainsectionfooter 等静态层级。这就是 Block Tree 的树结构打平

这里打平的是一份额外维护的动态节点数组,不是改变实际的 DOM 层级。

为什么结构性指令会创建新 Block

考虑下面的条件渲染:

<div>
  <section v-if="show">
    <span>{{ message }}</span>
  </section>
</div>

showfalse 时,span 不存在;为 true 时,span 才存在。根 Block 不能假设 span 始终处于固定位置,因此 v-if 分支需要形成新的 Block:

根 Block
└── v-if Block
    └── 动态 span

父 Block 只跟踪分支的 Block 根节点,进入该分支后,再由子 Block 跟踪内部的动态节点。

v-ifv-for 等会改变节点结构的指令通常会形成新的 Block,从而在结构可能变化的情况下保持每个 Block 内部的动态节点结构稳定。

列表的 Fragment 标记

对于带 key 的列表:

<li v-for="user in users" :key="user.id">
  {{ user.name }}
</li>

编译器会为包裹列表的 Fragment 添加 KEYED_FRAGMENT 标记。运行时看到这个标记后,可以直接进入带 key 的子节点 Diff。

没有 key 的列表会被标记为 UNKEYED_FRAGMENT。如果编译器能够确定 Fragment 的子节点顺序不会变化,则可以使用 STABLE_FRAGMENT,跳过不必要的列表顺序比较。

因此,Patch Flag 不只描述属性更新类型,也可以告诉运行时该采用哪一种子节点更新策略。

事件处理函数缓存

下面的内联事件在每次执行渲染函数时,理论上都会创建一个新函数:

<button @click="count++">增加</button>

编译器可以使用渲染函数的 _cache 缓存它:

onClick:
  _cache[0] ||
  (_cache[0] = ($event) => _ctx.count++);

这样可以:

  • 减少函数对象的重复创建;
  • 保持事件处理函数引用稳定;
  • 避免不必要的事件监听器更新;
  • 事件传给子组件时,减少因函数引用变化触发的更新。

编译器只有在确认缓存安全时才会这样处理。例如依赖 v-for 局部变量的事件函数不能简单地在整个组件范围内只缓存一份。

v-oncev-memo

这两个指令可以看成开发者主动向编译器提供缓存信息。

<div v-once>{{ initialValue }}</div>

v-once 对应的子树只渲染一次,后续组件更新时直接复用缓存的 VNode。

<div v-memo="[user.id, user.updatedAt]">
  {{ user.name }}
</div>

v-memo 只有在依赖数组发生变化时才重新生成和比较子树。依赖没有变化时,可以跳过整个子树更新。它适合明确存在性能瓶颈的大型列表或复杂子树,不需要在普通模板中普遍添加。

编译时优化和快速 Diff 的关系

两者处于不同阶段,解决不同问题:

静态提升
  └── 避免重复创建静态 VNode

Block Tree
  └── 决定需要检查哪些动态节点

Patch Flag
  └── 决定节点需要检查哪些内容

快速 Diff
  └── 决定一组子节点如何复用、增删和移动

例如:

<div>
  <h1>用户列表</h1>

  <ul>
    <li
      v-for="user in users"
      :key="user.id"
      :class="{ active: user.active }"
    >
      {{ user.name }}
    </li>
  </ul>

  <footer>固定内容</footer>
</div>

编译器可以提供以下信息:

  • h1footer 是静态内容,可以缓存或提升;
  • v-for 形成新的 Block;
  • 列表 Fragment 带有 KEYED_FRAGMENT 标记;
  • li 带有动态 class 和动态文本标记;
  • Block 记录需要更新的动态后代。

运行时更新时,可以跳过静态内容,直接定位列表 Block,根据 KEYED_FRAGMENT 进入带 key 的快速 Diff;对于成功复用的 li,再根据 Patch Flag 只更新 class 和文本。

为什么模板更容易被优化

模板语法具有约束,编译器能够对它进行静态分析:

<div :class="className">{{ message }}</div>

编译器可以确定根节点类型固定、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 减少节点查找与移动。