Skip to content

实现虚拟 DOM - VNode

本节对标 Vue 3 源码 @vue/runtime-core 中的 vnode.ts

什么是 VNode?

VNode(Virtual Node)是真实 DOM 节点的 JavaScript 对象描述。Vue 3 通过 VNode 树来描述整个 UI 结构,然后通过渲染器将 VNode 转换为真实 DOM。

Template / Render Function → VNode Tree → Real DOM

为什么需要 VNode?

  • 跨平台:VNode 只是普通 JS 对象,不绑定任何平台 API
  • Diff 优化:新旧 VNode 对比,只更新变化的部分
  • 声明式编程:描述"UI 应该是什么样",而非"如何操作 DOM"

VNode 的类型

Vue 3 中 VNode 有以下几种类型:

ts
// 使用 const enum 定义 VNode 的形状标志,编译时会内联为数字,零运行时开销
// 每个标志占一个 bit 位,因此可以通过位运算组合和判断多种类型
export const enum ShapeFlags {
  ELEMENT = 1,                      // 0b00000001 — 普通 DOM 元素(如 div、span)
  FUNCTIONAL_COMPONENT = 1 << 1,    // 0b00000010 — 函数式组件
  STATEFUL_COMPONENT = 1 << 2,     // 0b00000100 — 有状态组件(包含 setup/data 等)
  TEXT_CHILDREN = 1 << 3,          // 0b00001000 — 子节点是纯文本
  ARRAY_CHILDREN = 1 << 4,        // 0b00010000 — 子节点是 VNode 数组
  SLOTS_CHILDREN = 1 << 5,        // 0b00100000 — 子节点是插槽函数
  TELEPORT = 1 << 6,              // 0b01000000 — Teleport 组件
  SUSPENSE = 1 << 7,              // 0b10000000 — Suspense 组件
  // 组合标志:组件 = 有状态组件 | 函数式组件,方便统一判断是否为组件
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT,
}

位运算的巧妙设计

Vue 3 使用位运算来判断 VNode 类型,这比字符串比较或多个布尔值高效得多:

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

// 判断是否是组件
if (vnode.shapeFlag & ShapeFlags.COMPONENT) { ... }

// 判断子节点类型
if (vnode.shapeFlag & ShapeFlags.TEXT_CHILDREN) { ... }

// 组合标记
vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN

一个 VNode 可以同时具有多个标记(比如"是元素"且"有数组子节点")。

VNode 接口定义

ts
// VNode 接口定义 —— 虚拟 DOM 节点的完整数据结构
export interface VNode {
  __v_isVNode: true                        // 标识位,用于快速判断一个对象是否为 VNode
  type: VNodeTypes                          // 节点类型:可以是标签名字符串、组件对象或内置 Symbol
  props: VNodeProps | null                  // 节点属性(class、style、事件等),null 表示无属性
  children: VNodeNormalizedChildren         // 标准化后的子节点
  key: string | number | symbol | null      // 用于 Diff 算法中唯一标识同级节点,优化复用
  shapeFlag: number                         // 位运算标志位,编码了节点类型和子节点类型
  el: any           // 对应的真实 DOM 节点,mount 后会赋值,用于后续更新和卸载
  component: any    // 如果是组件 VNode,指向组件实例,方便访问组件的状态和方法
  anchor: any       // Fragment 的结束锚点,用于定位 Fragment 子节点的插入位置
}

// VNode 的 type 字段可以是以下几种类型
type VNodeTypes =
  | string           // 'div', 'span' — 原生 HTML 元素
  | object           // 组件选项对象(包含 setup、render 等)
  | typeof Text      // Symbol('Text') — 纯文本节点
  | typeof Fragment  // Symbol('Fragment') — 无根节点的片段
  | typeof Comment   // Symbol('Comment') — HTML 注释节点

// 属性类型:键值对形式,key 为属性名,value 为属性值
type VNodeProps = Record<string, any>

// 子节点的标准化类型:只允许文本、VNode 数组或 null 三种形式
type VNodeNormalizedChildren =
  | string
  | VNode[]
  | null

实现 createVNode

ts
// 定义内置的特殊 VNode 类型,使用 Symbol 确保唯一性,不会与任何 HTML 标签冲突
export const Text = Symbol('Text')       // 纯文本节点类型
export const Fragment = Symbol('Fragment') // Fragment 类型,允许多根节点
export const Comment = Symbol('Comment')   // 注释节点类型

// 创建 VNode 的核心函数,所有虚拟节点都通过此函数生成
export function createVNode(
  type: VNodeTypes,                        // 节点类型
  props: VNodeProps | null = null,         // 节点属性,默认为 null
  children: unknown = null,                // 子节点,默认为 null
): VNode {
  // 根据 type 的 JS 类型推断 VNode 的 shapeFlag
  // 字符串 → 原生 DOM 元素;对象 → 有状态组件;函数 → 函数式组件
  const shapeFlag = typeof type === 'string'
    ? ShapeFlags.ELEMENT
    : typeof type === 'object'
      ? ShapeFlags.STATEFUL_COMPONENT
      : typeof type === 'function'
        ? ShapeFlags.FUNCTIONAL_COMPONENT
        : 0

  // 构造 VNode 对象,初始化所有字段
  const vnode: VNode = {
    __v_isVNode: true,        // 标识位,标记这是一个 VNode 对象
    type,                      // 保存节点类型
    props,                     // 保存属性
    children: null,            // 先设为 null,后续由 normalizeChildren 处理
    key: props?.key ?? null,   // 从 props 中提取 key,用于 Diff 时的节点复用
    shapeFlag,                 // 初始的形状标志,后续会叠加子节点类型标志
    el: null,                  // 真实 DOM 引用,mount 阶段赋值
    component: null,           // 组件实例引用,组件 mount 阶段赋值
    anchor: null,              // Fragment 锚点,Fragment mount 阶段赋值
  }

  // 标准化子节点,同时将子节点类型信息编码到 shapeFlag 中
  normalizeChildren(vnode, children)

  return vnode
}

// 标准化子节点:将 children 转为统一格式,并在 shapeFlag 中记录子节点类型
function normalizeChildren(vnode: VNode, children: unknown) {
  if (typeof children === 'string' || typeof children === 'number') {
    // 文本或数字子节点:统一转为字符串存储
    vnode.children = String(children)
    // 用位或运算叠加 TEXT_CHILDREN 标志到 shapeFlag
    vnode.shapeFlag |= ShapeFlags.TEXT_CHILDREN
  } else if (Array.isArray(children)) {
    // 数组子节点:直接存储 VNode 数组
    vnode.children = children as VNode[]
    // 叠加 ARRAY_CHILDREN 标志
    vnode.shapeFlag |= ShapeFlags.ARRAY_CHILDREN
  }
}

normalizeChildren 的作用

VNode 的 children 可能有多种形式,需要标准化:

ts
// 文本子节点
h('div', null, 'hello')

// 数组子节点
h('div', null, [h('span'), h('p')])

// 数字也当作文本
h('div', null, 42)

通过 normalizeChildren,将 children 的类型信息编码到 shapeFlag 中,后续 patch 时可以快速判断。

h 函数

hcreateVNode 的简写,提供更友好的 API:

ts
// h 函数是 createVNode 的简写封装,提供更灵活的参数形式
export function h(
  type: VNodeTypes,                          // 节点类型(标签名或组件对象)
  propsOrChildren?: VNodeProps | unknown,    // 第二个参数可能是 props 也可能是 children
  children?: unknown,                        // 第三个参数为明确的 children
): VNode {
  const l = arguments.length // 根据实际传入参数个数决定如何解析
  if (l === 2) {
    // 只传了两个参数,需要判断第二个参数是 props 还是 children
    if (isObject(propsOrChildren) && !Array.isArray(propsOrChildren)) {
      // 是对象且不是数组 → 当作 props 处理
      // h('div', { class: 'foo' })
      return createVNode(type, propsOrChildren as VNodeProps)
    } else {
      // 是字符串、数字或数组 → 当作 children 处理,props 设为 null
      // h('div', 'text') 或 h('div', [child1, child2])
      return createVNode(type, null, propsOrChildren)
    }
  } else {
    // 传了三个参数 → 明确的 type + props + children
    return createVNode(type, propsOrChildren as VNodeProps, children)
  }
}

h 函数的多态调用

ts
// 只有类型
h('div')

// 类型 + 属性
h('div', { id: 'foo', class: 'bar' })

// 类型 + 子节点(文本)
h('div', 'hello')

// 类型 + 子节点(数组)
h('div', [h('span', 'child1'), h('span', 'child2')])

// 类型 + 属性 + 子节点
h('div', { class: 'container' }, [h('p', 'paragraph')])

// 组件
h(MyComponent, { msg: 'hello' })

createTextVNode

ts
export function createTextVNode(text: string): VNode {
  return createVNode(Text, null, text)
}

isVNode

ts
export function isVNode(value: any): value is VNode {
  return value ? value.__v_isVNode === true : false
}

isSameVNodeType —— Diff 的基础

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

这是 Diff 算法的核心判断条件 —— 只有 type 和 key 都相同的 VNode 才能复用。

对比 React

维度Vue 3 VNodeReact Element / Fiber
数据结构普通对象 + shapeFlagElement → Fiber
类型标记位运算 ShapeFlagstag(枚举)
创建 APIh() / createVNode()jsx() / createElement()
复用判断type + keytype + key
真实节点引用vnode.elfiber.stateNode

测试用例

ts
describe('vnode', () => {
  // 测试创建元素类型的 VNode,验证各字段是否正确
  it('should create element vnode', () => {
    const vnode = createVNode('div', { id: 'foo' }, 'hello')
    expect(vnode.type).toBe('div')                       // type 应为传入的标签名
    expect(vnode.props).toEqual({ id: 'foo' })           // props 应保留原始属性
    expect(vnode.children).toBe('hello')                 // 文本子节点应被正确存储
    // shapeFlag 应同时包含 ELEMENT 和 TEXT_CHILDREN 两个标志(位或组合)
    expect(vnode.shapeFlag).toBe(
      ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN,
    )
  })

  // 测试数组子节点的 shapeFlag 是否正确叠加了 ARRAY_CHILDREN
  it('should create vnode with array children', () => {
    const vnode = createVNode('div', null, [createVNode('span')])
    expect(vnode.shapeFlag).toBe(
      ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN,    // 应为 ELEMENT + ARRAY_CHILDREN
    )
  })

  // 测试 h 函数传入文本子节点时的标准化行为
  it('should normalize text children', () => {
    const vnode = h('div', 'hello')
    expect(vnode.children).toBe('hello') // h 的第二个参数应被当作 children 处理
  })
})

本节小结

  1. VNode — 真实 DOM 的 JS 对象描述,是渲染器的核心数据结构
  2. ShapeFlags — 位运算标记 VNode 类型,高效且可组合
  3. h 函数 — createVNode 的简写,支持多态调用
  4. isSameVNodeType — Diff 算法的入口判断

下一节实现渲染器的 mount 流程。

用心学习,用代码说话 💻