Skip to content

第 11 章 虚拟 DOM 与 Diff 算法

本章要点

  • 虚拟 DOM 的本质:从 VNode 的数据结构到它如何成为 UI 的中间表示层
  • VNode 的类型系统:ShapeFlag 位掩码如何用一个整数编码所有节点类型信息
  • patch 函数的分发逻辑:如何根据 VNode 类型选择不同的处理路径
  • 子节点 Diff 的核心算法:双端比较、最长递增子序列、key 的关键作用
  • Block Tree 与 PatchFlag:编译时优化如何让 Diff 从 O(n) 降到 O(动态节点数)
  • Fragment、Teleport、Suspense 等特殊 VNode 的处理策略
  • Vue 3.6 Vapor Mode 对传统 VNode 体系的挑战与共存

在前面的章节中,我们已经深入了编译器和组件系统。编译器将模板转化为渲染函数,组件系统管理每个组件的生命和状态。但在这两者之间,还有一个至关重要的中间层——虚拟 DOM

当渲染函数执行时,它不会直接操作真实 DOM,而是生成一棵由 JavaScript 对象构成的虚拟节点树。当组件状态变化时,新的虚拟树与旧的虚拟树进行对比——这个过程就是 Diff。Diff 的结果是一组最小化的 DOM 操作指令,精确地把界面从旧状态更新到新状态。

这一章,我们要完全拆解这个过程。

11.1 VNode:虚拟 DOM 的原子

VNode 的数据结构

每一个虚拟节点都是一个 VNode 对象。它的结构远比"一个标签名加一堆属性"复杂得多:

typescript
// packages/runtime-core/src/vnode.ts
export interface VNode<
  HostNode = RendererNode,
  HostElement = RendererElement,
  ExtraProps = { [key: string]: any }
> {
  __v_isVNode: true               // VNode 标记
  type: VNodeTypes                 // 节点类型:string | Component | Fragment | ...
  props: (VNodeProps & ExtraProps) | null
  key: string | number | symbol | null  // Diff 的身份标识
  ref: VNodeNormalizedRef | null        // 模板 ref
  children: VNodeNormalizedChildren     // 子节点

  // ---- 运行时状态 ----
  el: HostNode | null              // 对应的真实 DOM 节点
  anchor: HostNode | null          // Fragment 的锚点
  component: ComponentInternalInstance | null  // 组件实例引用

  // ---- 优化标记 ----
  shapeFlag: number                // 节点形状位掩码
  patchFlag: number                // 编译器标记的动态类型
  dynamicProps: string[] | null    // 动态属性名列表
  dynamicChildren: VNode[] | null  // Block 收集的动态子节点

  // ---- 其他 ----
  dirs: DirectiveBinding[] | null  // 指令绑定
  transition: TransitionHooks | null
  suspense: SuspenseBoundary | null
  appContext: AppContext | null
}

一个 VNode 既是 UI 的描述,又携带了优化信息,还反向引用了真实 DOM。它是连接"声明式意图"和"命令式操作"的桥梁。

ShapeFlag:用位运算编码节点类型

Vue 使用一个整数的不同位来标记 VNode 的类型信息,这是一种经典的位掩码模式:

typescript
// packages/shared/src/shapeFlags.ts
export enum ShapeFlags {
  ELEMENT                = 1,        // 0000 0001 — 普通 HTML 元素
  FUNCTIONAL_COMPONENT   = 1 << 1,   // 0000 0010 — 函数式组件
  STATEFUL_COMPONENT     = 1 << 2,   // 0000 0100 — 有状态组件
  TEXT_CHILDREN          = 1 << 3,   // 0000 1000 — 子节点是文本
  ARRAY_CHILDREN         = 1 << 4,   // 0001 0000 — 子节点是数组
  SLOTS_CHILDREN         = 1 << 5,   // 0010 0000 — 子节点是插槽
  TELEPORT               = 1 << 6,   // 0100 0000 — Teleport 组件
  SUSPENSE               = 1 << 7,   // 1000 0000 — Suspense 组件
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  COMPONENT_KEPT_ALIVE   = 1 << 9,
  COMPONENT              = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

为什么用位掩码而不是字符串枚举?因为位运算的判断只需要一条 CPU 指令:

typescript
// 判断是否是元素
if (shapeFlag & ShapeFlags.ELEMENT) { /* ... */ }

// 判断是否是组件且有数组子节点
if (shapeFlag & ShapeFlags.COMPONENT && shapeFlag & ShapeFlags.ARRAY_CHILDREN) { /* ... */ }

// 创建时组合标记
const shapeFlag = ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN

在 Diff 的热路径上,每一个 if 判断都被执行成千上万次。位运算比字符串比较快一个数量级,这是框架级性能优化的典型手法。

createVNode:VNode 的工厂

渲染函数中的 h() 最终调用 createVNode 来创建 VNode:

typescript
// packages/runtime-core/src/vnode.ts(简化)
export function createVNode(
  type: VNodeTypes,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {
  // 1. 规范化 type
  if (isVNode(type)) {
    // 克隆已有 VNode
    return cloneVNode(type, props)
  }

  // 2. 类组件规范化
  if (isClassComponent(type)) {
    type = type.__vccOpts
  }

  // 3. 规范化 props(class、style 合并)
  if (props) {
    props = guardReactiveProps(props)
    let { class: klass, style } = props
    if (klass && !isString(klass)) {
      props.class = normalizeClass(klass)
    }
    if (isObject(style)) {
      props.style = normalizeStyle(style)
    }
  }

  // 4. 计算 shapeFlag
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT
    : isSuspense(type)
      ? ShapeFlags.SUSPENSE
      : isTeleport(type)
        ? ShapeFlags.TELEPORT
        : isObject(type)
          ? ShapeFlags.STATEFUL_COMPONENT
          : isFunction(type)
            ? ShapeFlags.FUNCTIONAL_COMPONENT
            : 0

  // 5. 创建 VNode 对象
  return createBaseVNode(type, props, children, patchFlag, dynamicProps, shapeFlag, isBlockNode)
}

注意 patchFlagdynamicProps 参数——它们由编译器注入,运行时不会自己去分析哪些属性是动态的。这就是 Vue 3 的编译-运行时协作模型。

11.2 patch:万物的入口

patch 函数的分发逻辑

patch 是整个渲染器的核心入口。无论是首次渲染还是更新,都从 patch 开始:

typescript
// packages/runtime-core/src/renderer.ts(简化)
const patch: PatchFn = (
  n1,        // 旧 VNode(null 表示首次挂载)
  n2,        // 新 VNode
  container, // DOM 容器
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  namespace = undefined,
  slotScopeIds = null,
  optimized = false
) => {
  // 1. 如果新旧节点类型完全不同,直接卸载旧的
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null  // 重置为 null,后续走挂载逻辑
  }

  // 2. 如果新节点标记为 BAIL,关闭优化
  if (n2.patchFlag === PatchFlags.BAIL) {
    optimized = false
    n2.dynamicChildren = null
  }

  // 3. 根据类型分发
  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break
    case Comment:
      processCommentNode(n1, n2, container, anchor)
      break
    case Static:
      if (n1 == null) {
        mountStaticNode(n2, container, anchor, namespace)
      }
      break
    case Fragment:
      processFragment(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
      break
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
      } else if (shapeFlag & ShapeFlags.TELEPORT) {
        ;(type as typeof TeleportImpl).process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals)
      } else if (shapeFlag & ShapeFlags.SUSPENSE) {
        ;(type as typeof SuspenseImpl).process(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, internals)
      }
  }

  // 4. 设置 ref
  if (ref != null && parentComponent) {
    setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
  }
}

基于 VitePress 构建