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)
  }
}

这个函数的设计体现了一个重要原则:类型驱动的分发。不同类型的 VNode 走完全不同的处理路径,各自优化,互不干扰。

isSameVNodeType:身份判定

判断两个 VNode 是否"同一个"的逻辑极简:

typescript
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

两个条件——相同的类型,相同的 key。如果类型变了(比如从 <div> 变成 <span>),没有任何 Diff 的意义,直接替换。如果 key 变了,即使类型相同,也视为不同节点。这就是为什么 key 在列表渲染中如此重要。

11.3 元素的 Patch

patch 分发到 processElement 时,会区分挂载和更新两条路径:

typescript
const processElement = (
  n1: VNode | null,
  n2: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  if (n1 == null) {
    mountElement(n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
  } else {
    patchElement(n1, n2, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
  }
}

patchElement:属性与子节点的更新

typescript
const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  const el = (n2.el = n1.el!)  // 复用真实 DOM 节点
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ
  let { patchFlag, dynamicChildren, dirs } = n2

  // ---- 属性更新 ----
  if (patchFlag > 0) {
    // 编译器告诉我们哪些属性是动态的
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 所有属性都可能变化,全量 diff
      patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, namespace)
    } else {
      // 按需更新
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, namespace)
        }
      }
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, namespace)
      }
      if (patchFlag & PatchFlags.PROPS) {
        // 只更新 dynamicProps 中列出的属性
        const propsToUpdate = n2.dynamicProps!
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i]
          const prev = oldProps[key]
          const next = newProps[key]
          if (next !== prev || key === 'value') {
            hostPatchProp(el, key, prev, next, namespace, parentComponent)
          }
        }
      }
    }
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    // 没有编译器优化标记,全量 diff 属性
    patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, namespace)
  }

  // ---- 子节点更新 ----
  if (dynamicChildren) {
    // Block 优化路径:只 patch 动态子节点
    patchBlockChildren(n1.dynamicChildren!, dynamicChildren, el, parentComponent, parentSuspense, resolveChildrenNamespace(n2, namespace), slotScopeIds)
  } else if (!optimized) {
    // 全量子节点 diff
    patchChildren(n1, n2, el, null, parentComponent, parentSuspense, resolveChildrenNamespace(n2, namespace), slotScopeIds, false)
  }
}

基于 VitePress 构建