主题
实现自定义渲染器
本节对标 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 3 | React |
|---|---|---|
| 跨平台 API | createRenderer(options) | react-reconciler 包 |
| 平台实现 | @vue/runtime-dom | react-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') 的基础实现。
本节小结
- createRenderer — 平台无关的渲染器工厂,通过 options 注入平台操作
- runtime-dom — DOM 平台的具体实现(nodeOps + patchProp)
- 跨平台能力 — 同一套组件和响应式逻辑,可渲染到 DOM / Canvas / Terminal 等
- createApp — 应用入口 API,连接组件和渲染器
下一节开始实现组件系统。