Skip to content

实现渲染器 - mount 流程

本节对标 Vue 3 源码 @vue/runtime-core 中的 renderer.ts —— mount 部分

渲染器的职责

渲染器(Renderer)负责将 VNode 树转换为特定平台的真实 UI。核心流程:

VNode Tree  →  patch()  →  Real DOM

patch 是渲染器的核心函数,负责"打补丁" —— 将 VNode 渲染为真实节点,或者更新已有节点。

render 入口

ts
// 创建渲染器的工厂函数,接收平台相关的操作接口
// 这种设计将渲染逻辑与平台 API 解耦,是 Vue 3 跨平台渲染的基础
export function createRenderer(options: RendererOptions) {
  // 解构出平台操作方法,后续在渲染过程中直接调用
  const {
    createElement,    // 创建元素节点
    setElementText,   // 设置元素的文本内容
    patchProp,        // 设置/更新单个属性(class、style、事件等)
    insert,           // 将节点插入父容器
    remove,           // 从 DOM 中移除节点
    createText,       // 创建文本节点
    setText,          // 更新文本节点内容
  } = options

  // render 是渲染器的入口函数,外部通过它将 VNode 渲染到容器中
  function render(vnode: VNode | null, container: any) {
    if (vnode) {
      // vnode 存在 → 调用 patch 进行挂载或更新
      // n1 传 null 表示这是首次渲染(与 container._vnode 的更新在后续完成)
      patch(null, vnode, container)
    } else {
      // vnode 为 null → 需要卸载之前渲染的内容
      if (container._vnode) {
        unmount(container._vnode)
      }
    }
    // 将本次渲染的 vnode 缓存到容器上,下次渲染时作为旧 VNode 进行 Diff
    container._vnode = vnode
  }

  // ... patch, mount, update 等内部函数

  return { render } // 对外只暴露 render 方法
}
  • vnode 存在 → 执行 patch(挂载或更新)
  • vnode 为 null → 执行 unmount(卸载)
  • container._vnode 保存上一次渲染的 VNode,用于后续 Diff

patch 分发

ts
// patch 是渲染器的核心调度函数,负责根据 VNode 类型分发到对应的处理逻辑
function patch(
  n1: VNode | null,  // 旧 VNode(null 表示首次挂载)
  n2: VNode,         // 新 VNode
  container: any,    // 父容器节点
  anchor: any = null, // 插入锚点,用于精确控制 DOM 插入位置
) {
  // 如果旧 VNode 存在但新旧类型不同,则无法复用,先卸载旧节点
  // 然后将 n1 置为 null,后续作为首次挂载处理
  if (n1 && !isSameVNodeType(n1, n2)) {
    unmount(n1)
    n1 = null
  }

  const { type, shapeFlag } = n2

  // 根据 VNode 的类型(type 或 shapeFlag)分发到不同的处理函数
  switch (type) {
    case Text:
      // 文本节点走专门的文本处理逻辑
      processText(n1, n2, container)
      break
    case Fragment:
      // Fragment 没有真实容器,直接处理子节点
      processFragment(n1, n2, container)
      break
    default:
      // 默认分支:通过 shapeFlag 位运算判断具体类型
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // 普通 DOM 元素
        processElement(n1, n2, container, anchor)
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 组件(有状态组件或函数式组件)
        processComponent(n1, n2, container, anchor)
      }
  }
}

patch 的逻辑

  1. 如果新旧 VNode 类型不同 → 卸载旧的,当作首次挂载
  2. 根据 VNode 类型分发到不同的 process 函数
  3. n1 === null 表示首次挂载(mount),否则是更新(update)

mountElement —— 挂载 DOM 元素

ts
// processElement 根据是否有旧节点来决定挂载还是更新
function processElement(
  n1: VNode | null,  // 旧 VNode
  n2: VNode,         // 新 VNode
  container: any,    // 父容器
  anchor: any,       // 插入锚点
) {
  if (!n1) {
    // n1 为 null → 首次挂载,走 mountElement 逻辑
    mountElement(n2, container, anchor)
  } else {
    // n1 存在 → 更新已有元素,走 patchElement 逻辑
    patchElement(n1, n2)
  }
}

// mountElement 完成元素节点的首次挂载,分四步进行
function mountElement(vnode: VNode, container: any, anchor: any) {
  // 第 1 步:调用平台 API 创建真实 DOM 元素,并将引用保存到 vnode.el
  // 这样后续更新时可以直接通过 vnode.el 访问真实 DOM
  const el = (vnode.el = createElement(vnode.type as string))

  // 第 2 步:处理子节点
  const { children, shapeFlag } = vnode
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 子节点是纯文本 → 直接设置元素的文本内容
    setElementText(el, children as string)
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 子节点是 VNode 数组 → 递归挂载每个子节点
    mountChildren(children as VNode[], el)
  }

  // 第 3 步:遍历 props 设置元素属性(class、style、事件监听器等)
  // prevValue 传 null 因为是首次挂载,不存在旧值
  if (vnode.props) {
    for (const key in vnode.props) {
      patchProp(el, key, null, vnode.props[key])
    }
  }

  // 第 4 步:将创建好的元素插入到父容器中
  // anchor 用于指定插入位置,为 null 时默认追加到末尾
  insert(el, container, anchor)
}

// 递归挂载子节点数组,对每个子 VNode 调用 patch
function mountChildren(children: VNode[], container: any) {
  children.forEach((child) => {
    // n1 传 null 表示每个子节点都是首次挂载
    patch(null, child, container)
  })
}

mount 的四步流程

1. createElement('div')        → 创建 <div> 元素
2. 处理 children               → 递归 patch 子节点
3. patchProp(el, key, null, v) → 设置 class/style/事件等
4. insert(el, container)       → 插入到父容器

注意 vnode.el = el —— 将真实 DOM 节点的引用保存到 VNode 上,后续更新时直接操作它。

processText —— 文本节点

ts
// processText 处理文本类型的 VNode
function processText(
  n1: VNode | null,
  n2: VNode,
  container: any,
) {
  if (!n1) {
    // 首次挂载:创建文本节点并插入容器
    const el = (n2.el = createText(n2.children as string))
    insert(el, container)
  } else {
    // 更新:复用旧的真实 DOM 节点(将 n1.el 赋给 n2.el)
    const el = (n2.el = n1.el!)
    // 只在文本内容发生变化时才更新,避免不必要的 DOM 操作
    if (n2.children !== n1.children) {
      setText(el, n2.children as string)
    }
  }
}

processFragment —— Fragment

Fragment 没有真实的 DOM 容器,直接挂载子节点:

ts
// processFragment 处理 Fragment 类型的 VNode
// Fragment 本身不产生真实 DOM 节点,只是作为子节点的逻辑容器
function processFragment(
  n1: VNode | null,
  n2: VNode,
  container: any,
) {
  if (!n1) {
    // 首次挂载:直接将子节点挂载到容器中(不会创建额外的包裹元素)
    mountChildren(n2.children as VNode[], container)
  } else {
    // 更新:对子节点进行 Diff 更新
    patchChildren(n1, n2, container)
  }
}

processComponent —— 组件(初步)

组件的 mount 流程将在第 13 节详细实现,这里先给出框架:

ts
// processComponent 处理组件类型的 VNode
// 组件也遵循 "首次挂载 vs 更新" 的分支逻辑
function processComponent(
  n1: VNode | null,
  n2: VNode,
  container: any,
  anchor: any,
) {
  if (!n1) {
    // 首次挂载组件:创建组件实例、执行 setup、渲染子树等
    mountComponent(n2, container, anchor)
  } else {
    // 组件更新:对比新旧 props,决定是否需要重新渲染
    updateComponent(n1, n2)
  }
}

unmount —— 卸载

ts
// unmount 卸载 VNode,将对应的真实 DOM 从文档中移除
function unmount(vnode: VNode) {
  const { type, children, shapeFlag } = vnode

  if (type === Fragment) {
    // Fragment 没有自身的 DOM 节点,需要递归卸载其所有子节点
    ;(children as VNode[]).forEach((child) => unmount(child))
    return
  }

  if (shapeFlag & ShapeFlags.COMPONENT) {
    // 组件类型:调用组件的卸载逻辑(清理副作用、生命周期钩子等)
    unmountComponent(vnode.component)
    return
  }

  // 普通元素:直接调用平台 API 移除真实 DOM 节点
  remove(vnode.el!)
}

RendererOptions —— 平台抽象

ts
// RendererOptions 定义了渲染器所需的平台操作接口
// 不同平台(DOM、Canvas、Native 等)只需实现这些方法即可
interface RendererOptions {
  createElement(type: string): any                                    // 创建元素节点
  setElementText(el: any, text: string): void                        // 设置元素的文本内容
  insert(el: any, parent: any, anchor?: any): void                   // 将节点插入到父节点中指定位置
  remove(el: any): void                                               // 从父节点移除指定节点
  createText(text: string): any                                       // 创建文本节点
  setText(node: any, text: string): void                              // 更新文本节点内容
  patchProp(el: any, key: string, prevValue: any, nextValue: any): void // 设置/更新节点属性
}

这是 Vue 3 跨平台渲染的关键设计 —— 渲染器不直接调用 DOM API,而是通过 options 注入。DOM 平台的实现由 runtime-dom 提供。

对比 React

维度Vue 3 RendererReact Reconciler
入口render() → patch()render() → reconcile()
节点类型分发switch (type) + shapeFlagswitch (fiber.tag)
挂载mountElement()completeWork()
平台抽象RendererOptionsHostConfig
真实节点引用vnode.elfiber.stateNode

测试用例

ts
describe('renderer: mount', () => {
  // 测试基本元素挂载:带属性和文本子节点的 div
  it('should mount element', () => {
    const vnode = h('div', { id: 'foo' }, 'hello')
    const root = document.createElement('div')
    render(vnode, root) // 将 VNode 渲染到 root 容器
    // 验证生成的 HTML 结构是否正确,id 属性和文本内容都应存在
    expect(root.innerHTML).toBe('<div id="foo">hello</div>')
  })

  // 测试嵌套元素挂载:验证子节点递归渲染是否正确
  it('should mount nested elements', () => {
    const vnode = h('div', null, [
      h('p', null, 'paragraph'),
      h('span', null, 'text'),
    ])
    const root = document.createElement('div')
    render(vnode, root)
    // 验证嵌套结构:div 内包含 p 和 span 两个子元素
    expect(root.innerHTML).toBe('<div><p>paragraph</p><span>text</span></div>')
  })

  // 测试文本节点挂载:使用 Text 类型创建纯文本 VNode
  it('should mount text node', () => {
    const vnode = createVNode(Text, null, 'hello text')
    const root = document.createElement('div')
    render(vnode, root)
    // 文本节点不会产生 HTML 标签,只有纯文本内容
    expect(root.textContent).toBe('hello text')
  })
})

本节小结

  1. render → patch → process → mount 四层调用链
  2. patch 分发 — 根据 VNode 类型分派到不同的处理函数
  3. mountElement 四步 — 创建元素、处理子节点、设置属性、插入容器
  4. RendererOptions — 平台抽象,实现跨平台渲染能力

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

用心学习,用代码说话 💻