Skip to content

实现自定义渲染器

本节对标 Vue 3 源码 @vue/runtime-core 中的 createRenderer + @vue/runtime-dom

为什么需要自定义渲染器?

Vue 3 的一个重要设计目标是跨平台渲染。通过 createRenderer API,开发者可以将 Vue 的响应式和组件系统应用于任何渲染目标:

  • DOM(Web 平台,@vue/runtime-dom
  • Canvas(游戏、可视化)
  • Native(移动端,如 uni-app)
  • Terminal(终端 UI)
  • Three.js(3D 场景)
  • 测试环境(类似 React 的 test renderer)

createRenderer 架构

ts
// runtime-core: 平台无关的核心逻辑
// 使用泛型 HostNode 和 HostElement 来抽象平台节点类型
// 不同平台传入不同的类型:DOM 平台是 Node/Element,Canvas 平台可以是自定义对象
export function createRenderer<HostNode, HostElement>(
  options: RendererOptions<HostNode, HostElement>,
) {
  // 解构 options,将平台 API 函数提取为 host* 命名的局部变量
  // 这些函数在整个渲染器中被大量使用,通过 options 注入实现平台解耦
  const {
    createElement: hostCreateElement,     // 创建元素
    setElementText: hostSetElementText,   // 设置元素文本
    patchProp: hostPatchProp,             // 更新属性
    insert: hostInsert,                   // 插入节点
    remove: hostRemove,                   // 移除节点
    createText: hostCreateText,           // 创建文本节点
    setText: hostSetText,                 // 设置文本节点内容
    parentNode: hostParentNode,           // 获取父节点
    nextSibling: hostNextSibling,         // 获取下一个兄弟节点
  } = options

  // 内部函数都通过 host* 函数操作"DOM",从不直接调用任何平台 API
  function patch(...) { /* 使用 host* 函数 */ }
  function mountElement(...) { /* 使用 hostCreateElement, hostInsert */ }
  function patchElement(...) { /* 使用 hostPatchProp */ }
  // ...

  function render(vnode, container) { ... }

  // 对外暴露 render 和 createApp,createApp 由工厂函数创建
  return { render, createApp: createAppAPI(render) }
}

核心思想:渲染器内部不直接调用任何平台 API,而是通过 options 注入的函数间接操作。

RendererOptions 接口

ts
// RendererOptions 定义了渲染器所需的全部平台操作接口
// 泛型参数 HostNode 和 HostElement 允许不同平台使用不同的节点类型
interface RendererOptions<HostNode = any, HostElement = any> {
  createElement(type: string): HostElement                                     // 根据标签名创建元素
  createText(text: string): HostNode                                           // 创建文本节点
  setText(node: HostNode, text: string): void                                  // 更新文本节点的内容
  setElementText(node: HostElement, text: string): void                        // 设置元素的文本内容
  insert(child: HostNode, parent: HostElement, anchor?: HostNode | null): void // 将子节点插入父节点
  remove(child: HostNode): void                                                // 从父节点移除子节点
  patchProp(                                                                   // 设置或更新元素属性
    el: HostElement,
    key: string,
    prevValue: any,
    nextValue: any,
  ): void
  parentNode(node: HostNode): HostElement | null   // 获取节点的父节点(Diff 移动时需要)
  nextSibling(node: HostNode): HostNode | null     // 获取下一个兄弟节点(确定插入锚点时需要)
}

runtime-dom —— DOM 平台实现

ts
// packages/runtime-dom/src/nodeOps.ts
// 这是 DOM 平台对 RendererOptions 的具体实现
// 每个方法都直接调用 Web DOM API
const nodeOps = {
  // 通过 document.createElement 创建真实 DOM 元素
  createElement(type: string): Element {
    return document.createElement(type)
  },

  // 通过 document.createTextNode 创建文本节点
  createText(text: string): Text {
    return document.createTextNode(text)
  },

  // 通过 nodeValue 更新文本节点的内容
  setText(node: Text, text: string) {
    node.nodeValue = text
  },

  // 通过 textContent 设置元素的文本内容(会清空所有子节点)
  setElementText(el: Element, text: string) {
    el.textContent = text
  },

  // 使用 insertBefore 插入子节点;anchor 为 null 时等效于 appendChild
  insert(child: Node, parent: Element, anchor: Node | null = null) {
    parent.insertBefore(child, anchor)
  },

  // 从父节点中移除指定子节点
  remove(child: Node) {
    const parent = child.parentNode
    if (parent) {
      parent.removeChild(child)
    }
  },

  // 获取节点的父元素
  parentNode(node: Node): Element | null {
    return node.parentNode as Element | null
  },

  // 获取节点的下一个兄弟节点(用于确定插入位置的锚点)
  nextSibling(node: Node): Node | null {
    return node.nextSibling
  },
}
ts
// packages/runtime-dom/src/index.ts
// runtime-dom 是 DOM 平台的入口模块,组合 nodeOps 和 patchProp 创建渲染器
import { createRenderer } from '@mini-vue/runtime-core'
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'

// 将 DOM 操作和属性处理合并为完整的渲染器配置
const rendererOptions = {
  ...nodeOps,  // 展开 DOM 节点操作方法
  patchProp,   // DOM 属性处理(class、style、事件等)
}

// 缓存渲染器实例,避免重复创建(单例模式)
let renderer: any

// 懒初始化:首次调用时才创建渲染器
function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions))
}

// 对外暴露的 render 函数,直接委托给渲染器实例
export function render(vnode: any, container: any) {
  return ensureRenderer().render(vnode, container)
}

// 对外暴露的 createApp 函数,创建 Vue 应用实例
export function createApp(rootComponent: any) {
  const app = ensureRenderer().createApp(rootComponent)
  const { mount } = app // 保存原始的 mount 方法

  // 重写 mount,添加 DOM 平台特定的逻辑
  app.mount = (containerOrSelector: string | Element) => {
    // 支持传入 CSS 选择器字符串或直接传入 DOM 元素
    const container =
      typeof containerOrSelector === 'string'
        ? document.querySelector(containerOrSelector)
        : containerOrSelector
    if (!container) return

    container.innerHTML = '' // 清空容器,移除之前的内容
    mount(container)          // 调用原始 mount 执行真正的渲染
  }

  return app
}

示例:Canvas 自定义渲染器

ts
import { createRenderer, h } from '@mini-vue/runtime-core'

// 创建 Canvas 平台的自定义渲染器
// 将 Vue 的响应式和组件系统应用于 Canvas 绘图
const canvasRenderer = createRenderer({
  // 创建元素:返回一个描述节点的普通 JS 对象(而非 DOM 元素)
  createElement(type) {
    return { type, props: {}, children: [] }
  },
  // 创建文本节点:同样是普通对象
  createText(text) {
    return { type: 'text', text }
  },
  // 更新文本节点的 text 属性
  setText(node, text) {
    node.text = text
  },
  // 设置元素的文本内容:将文本作为子节点
  setElementText(node, text) {
    node.children = [{ type: 'text', text }]
  },
  // 插入子节点:将 child 添加到 parent 的 children 数组
  insert(child, parent) {
    parent.children.push(child)
    // 如果父节点绑定了 Canvas 上下文,触发重绘
    if (parent.bindCanvas) {
      drawCanvas(parent.bindCanvas, parent)
    }
  },
  // 移除子节点:从父节点的 children 数组中删除
  remove(child) {
    const parent = child.parent
    if (parent) {
      const i = parent.children.indexOf(child)
      if (i > -1) parent.children.splice(i, 1)
    }
  },
  // 设置属性:直接在 props 对象上存储
  patchProp(el, key, prevValue, nextValue) {
    el.props[key] = nextValue
  },
  // Canvas 渲染器中父节点直接存储在 node.parent 上
  parentNode(node) {
    return node.parent
  },
  // Canvas 场景下不需要 nextSibling(不使用 insertBefore 语义)
  nextSibling(node) {
    return null
  },
})

// 将节点树绘制到 Canvas 上
function drawCanvas(ctx, root) {
  // 每次重绘前先清空画布
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)

  // 递归绘制节点树
  function draw(node, x, y) {
    if (node.type === 'rect') {
      // 矩形节点:读取 props 中的颜色和尺寸属性进行绘制
      ctx.fillStyle = node.props.color || '#000'
      ctx.fillRect(x, y, node.props.width || 50, node.props.height || 50)
    } else if (node.type === 'text') {
      // 文本节点:在指定坐标绘制文字
      ctx.fillText(node.text, x, y)
    }
    // 递归绘制子节点,水平方向每个子节点偏移 60px
    node.children?.forEach((child, i) => draw(child, x + i * 60, y))
  }

  draw(root, 0, 0)
}

示例:测试用 noop-renderer

ts
import { createRenderer } from '@mini-vue/runtime-core'

// noop(no operation)渲染器:所有操作都是空函数
// 用于单元测试场景,测试组件逻辑时无需依赖真实 DOM 环境
// 类似于 React 的 test renderer 概念
const noopRenderer = createRenderer({
  createElement: () => ({}),     // 返回空对象作为"虚拟元素"
  createText: () => ({}),        // 返回空对象作为"虚拟文本"
  setText: () => {},              // 不做任何操作
  setElementText: () => {},      // 不做任何操作
  insert: () => {},               // 不做任何操作
  remove: () => {},               // 不做任何操作
  patchProp: () => {},            // 不做任何操作
  parentNode: () => null,         // 永远返回 null
  nextSibling: () => null,        // 永远返回 null
})

这个 noop renderer 不执行任何实际操作,用于单元测试时测试组件逻辑而不依赖 DOM 环境。

对比 React

维度Vue 3React
跨平台 APIcreateRenderer(options)react-reconciler
平台实现@vue/runtime-domreact-dom
配置方式RendererOptions 对象HostConfig 对象
实现复杂度约 10 个函数约 30+ 个函数

两者的设计理念完全一致:核心逻辑与平台实现分离

createApp API

ts
// runtime-core 中的 createAppAPI 工厂函数
// 接收 render 函数,返回 createApp 函数
// 这样每个渲染器(DOM、Canvas 等)都能创建自己的 createApp
function createAppAPI(render: any) {
  return function createApp(rootComponent: any) {
    const app = {
      _component: rootComponent, // 保存根组件的引用

      // mount 方法:将根组件渲染到指定容器中
      mount(rootContainer: any) {
        // 将根组件创建为 VNode,然后交给 render 函数处理
        const vnode = createVNode(rootComponent)
        render(vnode, rootContainer)
      },
    }
    return app
  }
}

这是 createApp(App).mount('#app') 的基础实现。

本节小结

  1. createRenderer — 平台无关的渲染器工厂,通过 options 注入平台操作
  2. runtime-dom — DOM 平台的具体实现(nodeOps + patchProp)
  3. 跨平台能力 — 同一套组件和响应式逻辑,可渲染到 DOM / Canvas / Terminal 等
  4. createApp — 应用入口 API,连接组件和渲染器

下一节开始实现组件系统。

用心学习,用代码说话 💻