Skip to content

VNode 如何成为真实 DOM

当我们编写 <div id="app"></div> 时,Vue 会把它解释并渲染成真实的界面。而这个过程就要从一个js对象VNode开始。

VNode 的结构与创建

VNode 是一个用于描述 DOM 结构的 JavaScript 对象。它包含了 Vue 在创建真实 DOM 节点时所需的所有信息,例如标签类型、属性和子节点。

VNode 的核心结构定义位于 @vue/runtime-core/src/vnode.ts

typescript
// core/packages/runtime-core/src/vnode.ts
export interface VNode {
  // 核心标识,用于 isVNode() 判断
  __v_isVNode: true

  // 节点的类型,如 'div', 'p',或一个组件对象
  type: VNodeTypes
  // 节点的属性,如 id, class, style, 事件监听器等
  props: (VNodeProps & ExtraProps) | null
  // 子节点,可以是文本,也可以是 VNode 数组
  children: VNodeNormalizedChildren
  
  // 渲染后会指向真实的 DOM 元素
  el: HostNode | null

  // --- 优化的关键 ---
  // 形状标记:用位运算快速判断 VNode 的类型
  shapeFlag: number
  // 补丁标记:编译器提供的优化提示
  patchFlag: number
  // 仅包含动态子节点的数组,用于 Block 优化
  dynamicChildren: VNode[] | null
  
  // ... 其他属性如 key, ref, component instance 等
}

优化标记:ShapeFlags 与 PatchFlags

初看之下,shapeFlagpatchFlag 可能有些晦涩,但shapeFlagpatchFlag 是 Vue 3 实现高性能渲染的关键。

  • shapeFlag (形状标记): Vue 需要频繁判断 VNode 的类型(是元素、组件、还是文本?)。shapeFlag 使用位运算来存储这些类型信息,这比使用 if/elsetypeof 进行多次判断效率更高。

    typescript
    export const enum ShapeFlags {
      ELEMENT = 1,                 // 00000001
      FUNCTIONAL_COMPONENT = 1 << 1, // 00000010
      STATEFUL_COMPONENT = 1 << 2,   // 00000100
      TEXT_CHILDREN = 1 << 3,        // 00001000
      ARRAY_CHILDREN = 1 << 4,       // 00010000
      // ...
    }

    判断时,if (vnode.shapeFlag & ShapeFlags.ELEMENT) 这样的位运算操作非常快。

  • patchFlag (补丁标记): 这是编译器在编译模板时,分析出动态内容后附加到 VNode 上的优化提示

    例如,对于 <div :class="cls" style="color: red"></div>

    • class 是动态的。
    • style 是静态的。
    • 文本 msg 是动态的。

    编译器会附加一个 patchFlag,如 PatchFlags.CLASS | PatchFlags.TEXT。在更新(patch)时,运行时(runtime)会读取这个标记,只对比 class 和文本内容,跳过对 style 等静态部分的检查,实现靶向更新。

VNode 的创建:createVNode 函数

createVNode 是创建 VNode 的入口函数。其核心逻辑(位于 @vue/runtime-core/src/vnode.ts)如下:

typescript
// core/packages/runtime-core/src/vnode.ts
function _createVNode(
  type: VNodeTypes,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  // ...
): VNode {
  // 1. 对 props 中的 class 和 style 进行归一化处理
  if (props) {
    props.class = normalizeClass(props.class)
    props.style = normalizeStyle(props.style)
  }

  // 2. 根据 type 计算出 shapeFlag,确定 VNode 的基本类型
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT // e.g., 'div'
    : isObject(type)
      ? ShapeFlags.STATEFUL_COMPONENT // e.g., { setup() ... }
      : isFunction(type)
        ? ShapeFlags.FUNCTIONAL_COMPONENT // e.g., (props) => {}
        : 0

  // 3. 调用 createBaseVNode 创建基础 VNode 对象
  return createBaseVNode(
    type,
    props,
    children,
    patchFlag,
    dynamicProps,
    shapeFlag,
    // ...
  )
}

createVNode 的职责是接收原始参数,进行预处理(如归一化),计算 shapeFlag,最后组装成一个结构统一的 VNode 对象,供后续的 patch 流程使用。

Patch 算法:VNode 的对比与更新

patch 函数是 Vue 渲染器的核心,位于 @vue/runtime-core/src/renderer.ts

patch 函数接收 n1 (旧 VNode) 和 n2 (新 VNode)。它的核心任务是对比新旧 VNode,并以最小的 DOM 操作将 n1(旧 DOM)更新为 n2(新 DOM)所描述的状态

typescript
// core/packages/runtime-core/src/renderer.ts
const patch: PatchFn = (
  n1, // old vnode
  n2, // new vnode
  container,
  // ...
) => {
  // 场景1: VNode 相同,无需操作
  if (n1 === n2) {
    return
  }

  // 场景2: VNode 类型不同,卸载旧节点,挂载新节点
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null // n1 置为 null,后续会执行挂载 n2
  }

  // 根据新 VNode (n2) 的类型进行分支处理
  const { type, ref, shapeFlag } = n2
  switch (type) {
    case Text:
      processText(n1, n2, container, anchor)
      break
    // ... 其他 case: Comment, Static, Fragment
    default:
      // 根据 shapeFlag 分发任务
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 处理普通 HTML 元素
        processElement(n1, n2, container, anchor, ...)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 处理 Vue 组件
        processComponent(n1, n2, container, anchor, ...)
      }
      // ...处理其他类型如 Teleport, Suspense
  }
}

patch 函数体现了 Diff 算法的核心思想:同层比较。它首先进行宏观判断(节点是否相同、类型是否改变),然后根据 shapeFlag,将具体任务分发给 processElementprocessComponent 等函数处理。

组件的挂载与更新

处理组件比处理普通元素更复杂,因为它涉及实例创建、生命周期和响应式系统的建立。

patch 将任务分派给 processComponent 时,它会根据 n1 是否为 null 来判断是挂载还是更新。挂载流程由 mountComponent 函数负责。

typescript
// core/packages/runtime-core/src/renderer.ts
const mountComponent: MountComponentFn = (
  initialVNode,
  container,
  // ...
) => {
  // 1. 创建组件实例
  const instance: ComponentInternalInstance =
    (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense,
    ))

  // 2. 初始化实例
  //    (解析 props, slots, 并执行 setup() 函数)
  setupComponent(instance)

  // 3. 设置渲染副作用
  setupRenderEffect(
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    namespace,
    optimized,
  )
}

mountComponent 的执行分为三个关键步骤:

  1. createComponentInstance:创建 instance 对象,它挂载了组件运行所需的所有上下文,如 props, slots, emit 和生命周期钩子。
  2. setupComponent:初始化实例,解析父组件传来的 propsslots,然后执行开发者编写的 setup() 函数,获取组件的状态和方法。
  3. setupRenderEffect这是连接响应式系统和渲染的核心
    • 它内部会创建一个 effect(响应式副作用)。
    • 在这个 effect 内部,会立即执行组件的 render() 函数,生成组件的 VNode(称为 subTree)。
    • 然后,再次调用 patch 函数,将这个 subTree 渲染成真实 DOM。
    • 因为 render()effect 中执行,它会自动收集所有访问到的响应式数据作为依赖。当这些数据变化时,这个 effect 会被重新触发,从而实现组件的自动更新。

nodeOps:跨平台 DOM 操作

Vue 的 patch 逻辑最终必须调用平台原生的 API(如 document.createElement)来操作 DOM。为了实现跨平台(例如渲染到 Canvas 或小程序),Vue 设计了 nodeOps 这一抽象层。

nodeOps 是一个包含所有底层平台操作方法的对象,它在创建渲染器时作为参数传入。

typescript
// core/packages/runtime-dom/src/nodeOps.ts
// (浏览器平台的 nodeOps 实现)
const doc = document;

export const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
  // 插入节点
  insert: (child, parent, anchor) => {
    parent.insertBefore(child, anchor || null)
  },
  // 移除节点
  remove: child => {
    const parent = child.parentNode
    if (parent) {
      parent.removeChild(child)
    }
  },
  // 创建元素
  createElement: (tag): Element => {
    return doc.createElement(tag)
  },
  // 创建文本节点
  createText: text => doc.createTextNode(text),
  
  // ... 其他所有 DOM API 的封装
}

patch 算法需要创建 div 时,它会调用 renderer.nodeOps.createElement('div'),而不是 document.createElement('div')

这种方式使 Vue 的核心渲染逻辑与具体平台完全解耦。如果想让 Vue 渲染到其他平台,只需提供一套符合 nodeOps 接口规范的实现即可。

渲染流程时序图

为了方便大家能够看懂从应用启动到渲染的完整调用链路,下面是一个简化的时序图:

总结:系统协同工作

Vue 3 将 VNode 渲染成真实 DOM 的过程,是编译器、运行时和响应式系统之间协同工作的结果:

  1. VNode 创建 (编译时):编译器将 <template> 转换为 render 函数,该函数运行时生成 VNode。编译器还会附带 patchFlag 等优化提示。
  2. Patch 调度 (运行时)patch 函数作为渲染核心,接收新旧 VNode,通过 shapeFlag 决策,将任务分派给不同的处理函数。
  3. 组件初始化 (运行时)mountComponentsetupComponent 创建实例、运行 setup,并通过 setupRenderEffect 建立“状态 -> 视图”的响应式关联。
  4. 递归渲染 (运行时)render 函数生成子树 VNode,再次进入 patch 流程,形成递归,直到所有节点都处理完毕。
  5. 平台抽象 (平台层):所有 DOM 操作都通过 nodeOps 这个抽象层执行,以支持跨平台。

理解这一过程,有助于深入掌握 Vue 的工作原理和进行性能优化。


微信公众号二维码

Last updated: