虚拟 DOM 与 Diff 算法

文章目录

  • 前言
  • [一、什么是虚拟 DOM](#一、什么是虚拟 DOM)
    • [1.1 定义](#1.1 定义)
    • [1.2 为什么需要](#1.2 为什么需要)
    • [1.3 并非总是更快](#1.3 并非总是更快)
  • [二、VNode 结构](#二、VNode 结构)
    • [2.1 基本字段](#2.1 基本字段)
    • [2.2 常见类型](#2.2 常见类型)
  • 三、更新流程
  • [四、Vue 2 双端 Diff](#四、Vue 2 双端 Diff)
    • [4.1 算法思路](#4.1 算法思路)
    • [4.2 特点](#4.2 特点)
  • [五、Vue 3 快速 Diff](#五、Vue 3 快速 Diff)
    • [5.1 为什么放弃双端 Diff](#5.1 为什么放弃双端 Diff)
    • [5.2 快速 Diff 流程(列表)](#5.2 快速 Diff 流程(列表))
    • [5.3 最长递增子序列(LIS)](#5.3 最长递增子序列(LIS))
  • [六、Vue 3 编译时优化(概览)](#六、Vue 3 编译时优化(概览))
    • [6.1 PatchFlag:标记动态节点](#6.1 PatchFlag:标记动态节点)
    • [6.2 Block Tree:扁平化动态节点](#6.2 Block Tree:扁平化动态节点)
    • [6.3 静态提升](#6.3 静态提升)
  • [七、key 与 Diff 的关系(简述)](#七、key 与 Diff 的关系(简述))
  • [八、与 React 的对比](#八、与 React 的对比)
  • 九、面试聚焦
    • [9.1 虚拟 DOM 并非总是更快](#9.1 虚拟 DOM 并非总是更快)
    • [9.2 Vue 3 为什么改 Diff?](#9.2 Vue 3 为什么改 Diff?)
    • [9.3 PatchFlag 做什么?](#9.3 PatchFlag 做什么?)
    • [9.4 Block Tree 是什么?](#9.4 Block Tree 是什么?)
  • 十、易混淆点
  • 十一、思考与练习
  • 总结

前言

虚拟 DOM 是 Vue 渲染层的核心机制:用 JavaScript 对象描述 DOM,通过 Diff 算法找出最小变更并批量更新。本篇会讲清楚:

  • VNode 结构与更新流程
  • Vue 2 双端 Diff vs Vue 3 快速 Diff
  • 最长递增子序列(LIS)与编译时优化概览

一、什么是虚拟 DOM

1.1 定义

虚拟 DOM 是用 JavaScript 对象描述真实 DOM 结构的轻量级表示。状态变化时生成新的 VNode 树,与旧树 Diff 后,只把差异应用到真实 DOM。

javascript 复制代码
// 真实 DOM
<div class="box">
  <h1>Hello</h1>
</div>

// 虚拟 DOM(VNode 示意)
const vnode = {
  type: 'div',
  props: { class: 'box' },
  children: [
    { type: 'h1', children: 'Hello' }
  ]
}

1.2 为什么需要

问题 虚拟 DOM 的解决
直接操作 DOM 慢且难追踪 声明式描述 UI,框架算最优更新
多次状态变更多次 DOM 操作 合并为一次批量更新
跨平台(Web / SSR / 小程序) 同一套 VNode 可对接不同渲染器

1.3 并非总是更快

极少量的 DOM 更新时,手动改 DOM 可能更快。虚拟 DOM 的价值在于复杂场景下的声明式开发、批量更新和跨平台抽象,而不是「一定比原生 DOM 快」。


二、VNode 结构

2.1 基本字段

javascript 复制代码
const vnode = {
  type: 'div',           // 标签名、组件、Fragment 等
  props: { class: 'box' },
  children: [],
  key: 'unique-id',      // 列表 Diff 用
  el: null,              // 运行时关联的真实 DOM
  patchFlag: 0,          // Vue 3 编译时标记(见下文)
  dynamicChildren: null  // Vue 3 Block Tree 动态子节点
}

2.2 常见类型

javascript 复制代码
// 文本
{ type: Text, children: 'hello' }

// 元素
{ type: 'div', props: {}, children: [] }

// 组件
{ type: MyComponent, props: { msg: 'hi' } }

// Fragment(多根节点)
{ type: Fragment, children: [...] }

三、更新流程

复制代码
响应式数据变化
    ↓
触发组件 re-render,生成新 VNode 树
    ↓
新旧 VNode 树 Diff(patch)
    ↓
计算出最小变更集(增删改移)
    ↓
批量应用到真实 DOM
javascript 复制代码
// 简化 Diff 思路
function patch(oldVNode, newVNode) {
  if (oldVNode.type !== newVNode.type) {
    // 类型不同 → 替换节点
    replaceNode(oldVNode, newVNode)
    return
  }
  // 同类型 → 比 props、比 children
  patchProps(oldVNode, newVNode)
  patchChildren(oldVNode, newVNode)
}

四、Vue 2 双端 Diff

4.1 算法思路

Vue 2 对同级列表 使用双端比较:新旧数组各设头尾指针,从两端向中间同时比较,尽量复用 DOM。

复制代码
旧: A  B  C  D
    ↑        ↑
    oldStart oldEnd

新: D  A  B  C
    ↑        ↑
    newStart newEnd

比较顺序(共 4 种):
1. oldStart vs newStart
2. oldEnd vs newEnd
3. oldStart vs newEnd
4. oldEnd vs newStart
都不匹配 → 用 key 在旧列表中查找

4.2 特点

  • 适合列表头尾增删、反转等常见场景
  • 依赖 key 做节点身份识别
  • 最坏情况仍需较多比较,Vue 3 做了进一步优化

五、Vue 3 快速 Diff

5.1 为什么放弃双端 Diff

Vue 3 借鉴 Inferno 的快速 Diff,目标:

  • 减少不必要的节点比较次数
  • 最长递增子序列(LIS)优化列表移动操作,少做 DOM insert/move

5.2 快速 Diff 流程(列表)

复制代码
1. 从头同步:新旧节点 type + key 相同则 patch,不同则停
2. 从尾同步:同上,从尾部向前
3. 中间段:用 key → index 映射处理新增、删除、移动
4. 移动优化:对需要移动的节点求 LIS,LIS 内节点不移动,其余按需 insert

5.3 最长递增子序列(LIS)

复制代码
旧: [A, B, C, D, E]
新: [A, C, D, B, E]  (B 从 index 1 移到 index 3)

需要移动的: B
LIS 帮助找出「已经相对有序、不必动」的节点,减少 DOM 移动次数

LIS 让 Diff 在「乱序但可复用」的列表里,用最少 DOM 移动完成更新。


六、Vue 3 编译时优化(概览)

Diff 之外,Vue 3 在编译阶段减少需要 Diff 的节点量:

6.1 PatchFlag:标记动态节点

编译器分析模板,给 VNode 打上「哪里会变」的标记:

javascript 复制代码
// 编译结果示意
createElementVNode('div', { class: 'static' },
  createTextVNode('hello', PatchFlags.TEXT)  // 仅文本会变
)
PatchFlag 含义
TEXT (1) 动态文本
CLASS (2) 动态 class
STYLE (4) 动态 style
PROPS (8) 动态 props
... 组合标记

Diff 时若 patchFlag 为 0,可跳过该节点子树比较;有标记则只比较标记部分。

6.2 Block Tree:扁平化动态节点

将模板中的动态节点收集到 dynamicChildren 数组,Diff 时只遍历动态节点,静态子树整段跳过。

vue 复制代码
<div>
  <p>静态标题</p>           <!-- 静态,不参与 Diff -->
  <p>{{ msg }}</p>          <!-- 动态,进入 dynamicChildren -->
  <span>{{ count }}</span>  <!-- 动态,进入 dynamicChildren -->
</div>

6.3 静态提升

纯静态节点提升到 render 函数外,只创建一次,后续 render 直接复用,避免重复生成 VNode。

编译优化的细节(PatchFlag 类型、Block 收集规则等)在编译优化专题中展开。


七、key 与 Diff 的关系(简述)

列表 Diff 依赖 key 判断「同一节点」:

  • 相同 key + 相同 type → patch(复用 DOM)
  • 不同 key → 销毁旧节点,创建新节点

key 不稳定(如用 index 做增删)会导致错误复用。key 的完整原理见专题「Key 的作用与原理」。


八、与 React 的对比

对比项 Vue 3 React
Diff 粒度 组件级 + Block 内动态节点 Fiber 可中断的增量 Diff
列表算法 快速 Diff + LIS 单端 + key 映射
编译优化 PatchFlag、静态提升、Block Tree 部分优化,策略不同

两者都用虚拟 DOM,但 Diff 策略和编译优化路径不同。


九、面试聚焦

9.1 虚拟 DOM 并非总是更快

简单场景手动 DOM 可能更快;虚拟 DOM 的价值是声明式、批量更新、跨平台。

9.2 Vue 3 为什么改 Diff?

双端 Diff 在复杂列表下比较次数仍偏多;快速 Diff + LIS 减少移动,配合 PatchFlag / Block Tree 减少比较范围。

9.3 PatchFlag 做什么?

编译期标记 VNode 哪些部分动态,Diff 时跳过静态内容,只更新标记位。

9.4 Block Tree 是什么?

把动态节点扁平收集,Diff 只比 dynamicChildren,静态子树整段跳过。


十、易混淆点

  1. 虚拟 DOM ≠ 更快:是开发模型和批量更新策略,不是性能银弹。
  2. Diff 只做同级比较:不会跨层级移动节点(O(n) 层级比较)。
  3. Vue 2 / Vue 3 列表 Diff 不同:Vue 3 用快速 Diff + LIS。
  4. PatchFlag 是编译产物:手写 render 函数默认无此优化。
  5. Shadow DOM ≠ Virtual DOM:前者是 Web Components 浏览器封装,与框架 VNode 无关。

十一、思考与练习

1. 虚拟 DOM 的更新流程是什么?

解析:数据变 → 新 VNode → 与旧 VNode Diff → 最小变更 → 更新真实 DOM。

2. Vue 2 和 Vue 3 列表 Diff 有何不同?

解析:Vue 2 双端比较;Vue 3 快速 Diff,头尾同步 + 中间 key 映射 + LIS 优化移动。

3. LIS 在 Diff 中的作用?

解析:找出相对有序、不必移动的节点,减少 DOM insert/move 次数。

4. PatchFlag 和 Block Tree 解决什么问题?

解析:减少 Diff 范围------只比动态节点、只更新标记为动态的部分,静态内容跳过。

5. 为什么说虚拟 DOM 不总是更快?

解析:创建 VNode 和 Diff 本身有开销;极简单更新直接改 DOM 可能更省。


总结

  • 虚拟 DOM:JS 对象描述 DOM,声明式 + 批量更新 + 跨平台
  • 更新流程:新 VNode → Diff → 最小 patch → 真实 DOM
  • Vue 2:双端 Diff,依赖 key
  • Vue 3:快速 Diff + LIS;PatchFlag、Block Tree、静态提升减少 Diff 量
  • 本质:虚拟 DOM 是工程权衡,不是「一定比原生 DOM 快」