跨平台渲染器:自定义渲染器的设计哲学与实现
第18章:跨平台渲染器——自定义渲染器的设计哲学与实现
@vue/runtime-core这个包里没有一行代码调用document.createElement——它甚至连document这个全局变量都不引用。整个组件系统、响应式绑定、生命周期、指令处理,都在完全不知道"DOM 是什么"的情况下运行。
本章核心问题:Vue 是如何把"渲染"这件事彻底抽象出来,让同一套组件逻辑能驱动 DOM、Canvas、小程序、服务端 HTML 字符串输出等完全不同的目标平台?
读完本章你将理解:
RendererOptions接口的完整定义及每个方法的职责createRenderer(options)如何将平台操作注入渲染器- 实现一个最小化 Canvas 渲染器的完整步骤
@vue/server-renderer的输出机制与普通渲染器的本质区别- 自定义渲染器与测试工具、小程序端的实践关系
Level 1 · 你需要知道的(1-3年经验)
渲染器的分层架构
Vue 3 把渲染相关的代码分成了三层:
层次结构:
┌─────────────────────────────────────────────────────────────┐
│ @vue/runtime-dom (DOM 平台层) │
│ ├── patchDOMProp / patchAttr / patchClass / patchStyle │
│ ├── patchEvent(addEventListener / removeEventListener) │
│ └── 实现 RendererOptions,调用 document.* │
│ │
│ 调用 createRenderer(domOptions) │
│ │
├─────────────────────────────────────────────────────────────┤
│ @vue/runtime-core (平台无关的核心层) │
│ ├── 组件挂载/更新/卸载逻辑 │
│ ├── diff 算法 │
│ ├── 指令处理 │
│ ├── 生命周期调用 │
│ └── 通过 RendererOptions 接口调用平台方法 │
│ │
├─────────────────────────────────────────────────────────────┤
│ @vue/reactivity (响应式系统,完全独立) │
│ └── ref / reactive / computed / watch │
└─────────────────────────────────────────────────────────────┘
@vue/runtime-dom 是 Vue 浏览器端的入口。如果你要做 Canvas 渲染,你不用 @vue/runtime-dom,而是直接用 @vue/runtime-core 提供的 createRenderer,传入你自己的 Canvas 操作方法。
RendererOptions 接口:渲染器的"驱动合同"
// packages/runtime-core/src/renderer.ts
export interface RendererOptions<HostNode = RendererNode, HostElement = RendererElement> {
// 核心 DOM 操作
createElement(type: string, isSVG?: boolean, isCustomizedBuiltIn?: string): HostElement
createText(text: string): HostNode
createComment(text: string): HostNode
setText(node: HostNode, text: string): void
setElementText(el: HostElement, text: string): void
// DOM 树操作
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
remove(el: HostNode): void
parentNode(node: HostNode): HostElement | null
nextSibling(node: HostNode): HostNode | null
// 属性/事件处理
patchProp(
el: HostElement,
key: string,
prevValue: any,
nextValue: any,
isSVG?: boolean,
prevChildren?: VNode[],
parentComponent?: ComponentInternalInstance | null,
unmountChildren?: UnmountChildrenFn
): void
// 可选:用于 SSR 的特殊操作
querySelector?: (selector: string) => HostElement | null
setScopeId?(el: HostElement, id: string): void
cloneNode?(node: HostNode): HostNode
insertStaticContent?(
content: string,
parent: HostElement,
anchor: HostNode | null,
isSVG: boolean
): [HostNode, HostNode]
}
只要你实现了这 10-12 个方法,@vue/runtime-core 的全部功能(组件、Suspense、KeepAlive、指令……)都可以在你的平台上运行。
createRenderer:渲染器工厂
import { createRenderer } from '@vue/runtime-core'
// 创建一个"什么都不做"的测试渲染器
const { render, createApp } = createRenderer({
createElement: (type) => ({ type, children: [], props: {} }),
createText: (text) => ({ type: '#text', text }),
createComment: (text) => ({ type: '#comment', text }),
setText: (node, text) => { node.text = text },
setElementText: (el, text) => { el.text = text },
insert: (el, parent, anchor) => {
const index = anchor ? parent.children.indexOf(anchor) : parent.children.length
parent.children.splice(index, 0, el)
},
remove: (el) => {
const parent = el.parentNode
if (parent) {
const index = parent.children.indexOf(el)
parent.children.splice(index, 1)
}
},
patchProp: (el, key, prevValue, nextValue) => {
el.props[key] = nextValue
},
parentNode: (node) => node.parentNode || null,
nextSibling: (node) => {
const parent = node.parentNode
if (!parent) return null
const index = parent.children.indexOf(node)
return parent.children[index + 1] || null
},
})
@vue/server-renderer 的使用
// 服务端(Node.js)
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
const app = createSSRApp({
data() { return { msg: 'Hello SSR' } },
template: '<div>{{ msg }}</div>'
})
// renderToString 不操作 DOM,而是产生 HTML 字符串
const html = await renderToString(app)
console.log(html) // '<div>Hello SSR</div>'
SSR 渲染器的核心差异:它不需要实现 insert/remove/parentNode 等 DOM 操作,因为它只是拼接字符串。
Level 2 · 它是怎么运行的(3-5年经验)
createRenderer 的内部结构
// packages/runtime-core/src/renderer.ts(简化)
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
return baseCreateRenderer<HostNode, HostElement>(options)
}
function baseCreateRenderer(options, createHydrationFns?) {
// 把 RendererOptions 解构,后续所有操作都通过这些局部变量调用
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
createComment: hostCreateComment,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
setScopeId: hostSetScopeId,
insertStaticContent: hostInsertStaticContent,
} = options
// 所有 patch / mount / unmount 函数都在这个闭包里定义
// 它们通过上面的 host* 变量调用平台方法
const patch = (n1, n2, container, anchor, ...) => {
// ... 使用 hostCreateElement 等,不直接用 document
}
const mountElement = (vnode, container, anchor, ...) => {
const el = (vnode.el = hostCreateElement(
vnode.type,
isSVG,
vnode.props && vnode.props.is
))
// ...
hostInsert(el, container, anchor)
}
// 最终返回 render 函数和 createApp 工厂
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate),
}
}
关键洞察:所有的渲染逻辑都在 baseCreateRenderer 这个函数的闭包内。hostCreateElement 等变量通过闭包被所有内部函数共享。换一套 options,就换了一套"底层驱动",上层逻辑完全不变。
DOM 渲染器的 patchProp 实现
DOM 平台的 patchProp 是最复杂的单个实现,它需要处理多种不同类型的属性:
// packages/runtime-dom/src/patchProp.ts(简化)
export const patchProp: DOMRendererOptions['patchProp'] = (
el, key, prevValue, nextValue, isSVG, ...
) => {
if (key === 'class') {
patchClass(el, nextValue, isSVG)
} else if (key === 'style') {
patchStyle(el, prevValue, nextValue)
} else if (isOn(key)) {
// 以 on 开头的属性(onClickt → click 事件)
if (!isModelListener(key)) {
patchEvent(el, key, prevValue, nextValue, ...)
}
} else if (
key[0] === '.'
? ((key = key.slice(1)), true)
: !isSVG && nativeOnlyTags.has(el.tagName) && domAttrConfig[key]
? (el[key] = nextValue) && false
: false
) {
// DOM property(.value, .checked 等)
patchDOMProp(el, key, nextValue, ...)
} else {
// HTML attribute(aria-*, data-*, 自定义属性)
patchAttr(el, key, nextValue, isSVG)
}
}
实战:构建一个最小化 Canvas 渲染器
// 节点类型定义
class CanvasNode {
constructor(type, props = {}) {
this.type = type // 'rect' | 'circle' | 'text' | '#text' | '#fragment'
this.props = props
this.children = []
this.parent = null
this._x = 0 // 计算后的绝对位置
this._y = 0
}
}
// Canvas 渲染器选项
import { createRenderer } from '@vue/runtime-core'
const { createApp } = createRenderer({
// 创建节点
createElement(type) {
return new CanvasNode(type)
},
createText(text) {
return new CanvasNode('#text', { text })
},
createComment(text) {
return new CanvasNode('#comment', { text })
},
// 文本操作
setText(node, text) {
node.props.text = text
scheduleRedraw()
},
setElementText(el, text) {
el.props.text = text
scheduleRedraw()
},
// 树操作
insert(el, parent, anchor) {
el.parent = parent
if (anchor) {
const index = parent.children.indexOf(anchor)
parent.children.splice(index, 0, el)
} else {
parent.children.push(el)
}
scheduleRedraw()
},
remove(el) {
if (el.parent) {
const index = el.parent.children.indexOf(el)
el.parent.children.splice(index, 1)
el.parent = null
}
scheduleRedraw()
},
parentNode(node) {
return node.parent
},
nextSibling(node) {
if (!node.parent) return null
const index = node.parent.children.indexOf(node)
return node.parent.children[index + 1] || null
},
// 属性更新
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue
scheduleRedraw()
},
})
// 实际 Canvas 渲染函数(将节点树转换为 Canvas 绘制调用)
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
let redrawScheduled = false
function scheduleRedraw() {
if (!redrawScheduled) {
redrawScheduled = true
requestAnimationFrame(redraw)
}
}
function redraw() {
redrawScheduled = false
ctx.clearRect(0, 0, canvas.width, canvas.height)
renderNode(rootNode, 0, 0)
}
function renderNode(node, x, y) {
if (!node) return
const { type, props, children } = node
const nodeX = x + (props.x || 0)
const nodeY = y + (props.y || 0)
switch (type) {
case 'rect':
ctx.fillStyle = props.fill || '#000'
ctx.fillRect(nodeX, nodeY, props.width || 100, props.height || 100)
break
case 'circle':
ctx.fillStyle = props.fill || '#000'
ctx.beginPath()
ctx.arc(nodeX, nodeY, props.radius || 50, 0, Math.PI * 2)
ctx.fill()
break
case 'text':
ctx.fillStyle = props.color || '#000'
ctx.font = props.font || '16px sans-serif'
ctx.fillText(props.text || '', nodeX, nodeY)
break
}
children.forEach(child => renderNode(child, nodeX, nodeY))
}
// 使用示例
const app = createApp({
setup() {
const color = ref('red')
const x = ref(50)
setTimeout(() => {
color.value = 'blue' // 响应式更新 → Canvas 重绘
x.value = 100
}, 1000)
return () => h('rect', { fill: color.value, x: x.value, y: 50, width: 80, height: 80 })
}
})
// 创建根节点
const rootNode = new CanvasNode('#fragment')
app.mount(rootNode)
渲染路径对比图
DOM 渲染器:
VNode tree
│
▼ patch()
DOM 操作调用序列
├── document.createElement('div')
├── el.setAttribute('class', 'box')
├── el.addEventListener('click', handler)
└── document.body.appendChild(el)
真实 DOM 树
│
▼ 浏览器渲染引擎
像素画面
────────────────────────────────────────────────────────
服务端渲染器(@vue/server-renderer):
VNode tree
│
▼ renderToString()
字符串拼接序列
├── '<div'
├── ' class="box"'
├── '>'
└── '</div>'
HTML 字符串
│
▼ HTTP 响应
客户端接收并水合(hydrate)
────────────────────────────────────────────────────────
Canvas 渲染器(自定义):
VNode tree
│
▼ patch()(使用自定义 RendererOptions)
CanvasNode 树操作
├── new CanvasNode('rect')
├── node.props.fill = 'red'
└── parent.children.push(node)
CanvasNode 树
│
▼ redraw()(requestAnimationFrame)
Canvas 2D 绘制调用
├── ctx.fillRect(...)
└── ctx.arc(...)
Canvas 像素
uni-app 和小程序端的渲染原理
uni-app(Vue 3 版本)使用自定义渲染器将 Vue 组件渲染到各平台的原生节点:
// uni-app 的渲染器选项(概念版,非实际代码)
const uniRenderer = createRenderer({
createElement(type) {
// 在小程序端,"创建节点"意味着创建一个描述对象
// 最终通过 setData() 传递给小程序框架
return { type, props: {}, children: [], nodeId: generateId() }
},
insert(el, parent, anchor) {
// 修改虚拟 DOM 树
// 然后通过 diff 计算出 setData 的最小更新
parent.children.splice(anchor ? ... : parent.children.length, 0, el)
batchSetData({ [el.nodeId]: el }) // 通知小程序框架
},
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue
// 只 setData 变化的属性,不是整棵树
setData({ [`${el.nodeId}.${key}`]: nextValue })
},
// ...
})
关键原理:小程序的渲染层和逻辑层是分离的(两个独立的 JS 运行环境)。所有的节点操作都通过 setData 消息传递给渲染层。自定义渲染器把 Vue 的 VNode 操作翻译成 setData 调用。
Level 3 · 设计文档与源码(资深开发者)
@vue/server-renderer 的工作原理
服务端渲染器与客户端渲染器的根本区别:
// packages/server-renderer/src/renderToString.ts(简化)
export async function renderToString(
input: App | VNode,
context: SSRContext = {}
): Promise<string> {
// 创建一个服务端应用实例(与客户端不同)
const vnode = createVNode(input._component, input._props)
vnode.appContext = input._context
// 初始化 Suspense 计数
const ctx: SSRContext = context
const buffer: SSRBuffer = []
await renderComponentVNode(vnode, null, ctx, buffer)
return unrollBuffer(await Promise.all(buffer))
}
// 服务端组件渲染:直接递归生成字符串,不创建真实 DOM
async function renderComponentVNode(vnode, parentComponent, ctx, buffer) {
const instance = createComponentInstance(vnode, parentComponent, null)
await setupServerComponent(instance) // 执行 setup,等待 async setup
// 执行 render 函数,得到 subTree VNode
const subTree = renderComponentRoot(instance)
// 递归渲染 subTree 为字符串
await renderVNode(subTree, instance, ctx, buffer)
}
async function renderVNode(vnode, parentComponent, ctx, buffer) {
const { type, shapeFlag } = vnode
if (type === Text) {
buffer.push(escapeHtml(vnode.children)) // 文本节点:转义 + 追加
} else if (type === Comment) {
buffer.push(`<!--${vnode.children}-->`)
} else if (type === Fragment) {
await renderVNodeChildren(vnode.children, parentComponent, ctx, buffer)
} else if (shapeFlag & ShapeFlags.ELEMENT) {
// 原生元素:生成 HTML 标签
await renderElementVNode(vnode, parentComponent, ctx, buffer)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 组件:递归渲染
await renderComponentVNode(vnode, parentComponent, ctx, buffer)
}
}
async function renderElementVNode(vnode, parentComponent, ctx, buffer) {
const { type: tag, props, children, shapeFlag } = vnode
// 开始标签
buffer.push(`<${tag}`)
// 属性
if (props) {
for (const key in props) {
buffer.push(` ${key}="${escapeHtmlAttr(props[key])}"`)
}
}
// 自闭合标签处理
if (isSelfClosingTag(tag)) {
buffer.push(' />')
return
}
buffer.push('>')
// 子节点
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
buffer.push(escapeHtml(children))
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
await renderVNodeChildren(children, parentComponent, ctx, buffer)
}
// 结束标签
buffer.push(`</${tag}>`)
}
SSR 渲染器不需要 RendererOptions,因为它不通过 createRenderer 创建——它直接操作 VNode 树,递归生成字符串。
水合(Hydration):SSR 到 CSR 的过渡
// packages/runtime-dom/src/index.ts
// DOM 渲染器通过 createHydrationRenderer 支持水合
export const { render, createApp } = ensureRenderer()
// 水合渲染器 = DOM 渲染器 + 额外的水合逻辑
export const { createApp: createSSRApp } = createHydrationRenderer({
...domRendererOptions,
// 额外方法:用于在已有 DOM 上"认领"节点
cloneNode: (el) => el.cloneNode(true),
insertStaticContent: (content, parent, anchor) => {
// SSR 产生的静态 HTML 内容直接插入,不需要逐节点创建
const temp = document.createElement('template')
temp.innerHTML = content
const first = temp.content.firstChild as Element
const last = temp.content.lastChild as Element
parent.insertBefore(temp.content, anchor)
return [first, last]
}
})
// 水合过程:
// 1. 客户端接收 SSR HTML
// 2. createSSRApp().mount('#app') → 执行水合(不创建新 DOM,而是"认领"已有 DOM)
// 3. patch(existingDOMNode, newVNode) → 复用 DOM,只绑定事件监听器
// 4. 水合完成后,应用变为可交互状态
@vue/test-utils 与自定义渲染器的关系
// @vue/test-utils 底层使用 jsdom(一个 Node.js 版的 DOM 实现)
// 它不是自定义渲染器,而是在 jsdom 环境中运行真实的 DOM 渲染器
// 这意味着:
// 1. 测试速度:jsdom 比真实浏览器慢,比自定义渲染器慢
// 2. 准确性:jsdom 的 DOM API 覆盖率约 90%,部分 CSS/布局相关不可用
// 3. 使用场景:测试组件行为(不是视觉效果)
// 如果你在构建自定义渲染器,可以为它创建专属的测试渲染器:
const { render, createApp } = createRenderer(myCanvasRendererOptions)
function mountCanvas(component, props?) {
const root = new CanvasNode('#fragment')
const app = createApp(component, props)
app.mount(root)
return { root, app }
}
// 然后直接断言 CanvasNode 树,而不需要 jsdom
test('renders a rect', () => {
const { root } = mountCanvas({ setup: () => () => h('rect', { fill: 'red' }) })
expect(root.children[0].type).toBe('rect')
expect(root.children[0].props.fill).toBe('red')
})
Vue 渲染器 vs React Reconciler:抽象层设计对比
Vue 3 的跨平台抽象:
@vue/runtime-core 定义 RendererOptions 接口(~12 个方法)
平台实现者实现这个接口
createRenderer(options) 将接口注入渲染器闭包
特点:
- 接口简单(12个方法)
- 同步调用:insert、remove 是同步的
- 不支持时间切片(没有 Concurrent Mode 等价物)
- diff 是同步的,长列表 diff 会阻塞主线程
────────────────────────────────────────────────────────
React 的跨平台抽象(Reconciler):
react-reconciler 定义 HostConfig 接口(~35 个方法)
平台实现者实现 HostConfig
创建:ReactReconciler(config)
特点:
- 接口复杂(35个方法,包括 prepareUpdate、commitMount 等阶段性方法)
- 异步调度:通过 Scheduler 支持可中断的渲染(Concurrent Mode)
- 更多控制点:渲染过程分 render 阶段(可中断)和 commit 阶段(同步)
- 代价:复杂度高,学习曲线陡峭
Vue 3 的选择是在简单性和灵活性之间取平衡:
12个方法足以覆盖 95% 的跨平台场景,
同时保持了渲染流程的可预测性和调试友好性。
Level 4 · 边界与陷阱(全体适用)
陷阱1:自定义渲染器中忘记处理 patchProp 的 null 值
// 当属性被移除时,nextValue 是 null
patchProp(el, 'fill', 'red', null)
// 错误写法:直接赋值
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue // el.props.fill = null → 下次渲染出现异常
}
// 正确写法:处理 null 值(删除属性)
patchProp(el, key, prevValue, nextValue) {
if (nextValue === null || nextValue === undefined) {
delete el.props[key]
} else {
el.props[key] = nextValue
}
scheduleRedraw()
}
陷阱2:scheduleRedraw 没有防重入导致性能问题
// 错误:每次 insert/remove/patchProp 都立即 redraw
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue
redraw() // 如果一次更新涉及 50 个 prop,就会 redraw 50 次!
}
// 正确:使用 requestAnimationFrame 批量合并
let rafId = null
function scheduleRedraw() {
if (rafId !== null) return // 已经有等待中的 redraw,不重复调度
rafId = requestAnimationFrame(() => {
rafId = null
redraw()
})
}
陷阱3:SSR 中的 context 对象泄漏
// SSR 的 renderToString 接受一个 context 对象
// 用户可以在组件中通过 useSSRContext() 访问它
// 问题:在并发请求场景下,同一个 context 对象被多个请求共享
// 错误:使用全局 context
const globalContext = { state: {} }
app.use(store)
// 多个并发请求的 renderToString 都在修改同一个 state!
// 正确:每个请求创建新的 context
app.get('/page', async (req, res) => {
const context = { req } // 每个请求独立的 context
const html = await renderToString(createApp(App), context)
res.send(html)
})
陷阱4:自定义渲染器忘记实现 parentNode 导致 Teleport 失效
// Teleport 内部需要 parentNode 来确定锚点的父节点
// 如果没有正确实现,Teleport 会静默失败
// 错误:返回 undefined
parentNode(node) {
return node.parent // 如果 parent 从未被赋值,返回 undefined
}
// 正确:始终返回 null(而不是 undefined)
parentNode(node) {
return node.parent || null // null 是 RendererOptions 规定的"无父节点"返回值
}
陷阱5:在自定义渲染器中处理事件时忘记清理旧监听器
// 错误:每次 patchProp 都追加新监听器
patchProp(el, key, prevValue, nextValue) {
if (key.startsWith('on')) {
const eventName = key.slice(2).toLowerCase()
el.canvas.addEventListener(eventName, nextValue) // 没有移除旧的!
// 每次更新都会追加一个新监听器,最终有数十个重复的监听器
}
}
// 正确:先移除旧的,再添加新的
patchProp(el, key, prevValue, nextValue) {
if (key.startsWith('on')) {
const eventName = key.slice(2).toLowerCase()
if (prevValue) {
el.canvas.removeEventListener(eventName, prevValue)
}
if (nextValue) {
el.canvas.addEventListener(eventName, nextValue)
}
}
}
陷阱6:SSR 中的数据获取时序
// 问题:组件在 onMounted 中获取数据,但 SSR 不执行 onMounted
const MyComp = defineComponent({
setup() {
const data = ref(null)
onMounted(async () => {
data.value = await fetch('/api/data').then(r => r.json())
})
// SSR 时 data.value 始终是 null
return { data }
}
})
// 正确:使用 async setup 或 onServerPrefetch
const MyComp = defineComponent({
async setup() {
// async setup 在 SSR 和客户端都会执行
const data = await fetch('/api/data').then(r => r.json())
return { data }
}
// 或:
// setup() {
// const data = ref(null)
// onServerPrefetch(async () => {
// data.value = await fetch('/api/data').then(r => r.json())
// })
// return { data }
// }
})
本章小结
-
分层设计是跨平台的基础:
@vue/runtime-core通过RendererOptions接口(12 个方法)将所有平台相关操作抽象成可替换的驱动,本身不引用任何浏览器 API;createRenderer(options)通过闭包把这 12 个方法注入到渲染器的所有内部函数中。 -
自定义渲染器的核心工作是树操作的映射:
insert/remove/parentNode/nextSibling这四个方法定义了你的"节点"如何组成树形结构;createElement/createText定义节点的创建方式;patchProp定义属性如何被应用。实现这 10 个方法,就能运行完整的 Vue 组件系统。 -
SSR 渲染器不通过 createRenderer 创建:
@vue/server-renderer直接操作 VNode 树,递归生成 HTML 字符串;它不需要insert/remove等 DOM 树操作,因为它的"DOM"就是一个字符串缓冲区。 -
水合是 SSR 后的必要步骤:水合渲染器在已有 DOM 上"认领"节点(复用 DOM 元素),只绑定事件监听器,跳过节点创建;
insertStaticContent和cloneNode是水合专用的两个额外 RendererOptions 方法。 -
小程序端渲染器的本质是 setData 翻译器:小程序的渲染层和逻辑层分离,自定义渲染器把 Vue 的 VNode 操作(insert/remove/patchProp)翻译成
setData调用,通过框架的消息传递机制更新渲染层——这是 uni-app 等跨端框架的核心技术。