Vue 自定义渲染器:从入门到实战
一、什么是自定义渲染器
Vue 3 的核心渲染逻辑被设计为与平台无关的。createRenderer API 允许你创建自定义的渲染器,将 Vue 的虚拟 DOM 渲染到不同的目标环境,而不仅仅是浏览器 DOM。
二、基础概念
核心 API
import { createRenderer } from 'vue'
const { render, createApp } = createRenderer({
// 渲染器选项
patchProp,
insert,
remove,
createElement,
// ...
})
渲染器选项
必须实现的几个核心函数:
| 函数 |
说明 |
|---|
createElement |
创建元素 |
createText |
创建文本节点 |
patchProp |
更新属性/属性 |
insert |
插入节点 |
remove |
删除节点 |
setElementText |
设置元素文本 |
createComment |
创建注释 |
三、入门示例:控制台渲染器
让我们创建一个将 Vue 组件渲染到控制台的简单渲染器:
import { createRenderer, h } from 'vue'
// 定义节点类型
const NodeTypes = {
ELEMENT: 'ELEMENT',
TEXT: 'TEXT',
COMMENT: 'COMMENT'
}
// 创建自定义渲染器
const { createApp } = createRenderer({
// 创建元素
createElement(type, isSVG, isCustomizedBuiltIn) {
return {
type: NodeTypes.ELEMENT,
tag: type,
children: [],
props: {}
}
},
// 创建文本节点
createText(text) {
return {
type: NodeTypes.TEXT,
content: text
}
},
// 创建注释节点
createComment(text) {
return {
type: NodeTypes.COMMENT,
content: text
}
},
// 插入节点
insert(child, parent, anchor) {
if (!parent.children) parent.children = []
if (anchor) {
const index = parent.children.indexOf(anchor)
parent.children.splice(index, 0, child)
} else {
parent.children.push(child)
}
// 记录插入操作
console.log(`插入 ${child.type} 到 ${parent.tag || '根节点'}`)
},
// 删除节点
remove(child) {
const parent = child.parent
if (parent && parent.children) {
const index = parent.children.indexOf(child)
if (index > -1) {
parent.children.splice(index, 1)
}
}
console.log(`删除 ${child.type}`)
},
// 设置元素文本
setElementText(el, text) {
el.children = [{
type: NodeTypes.TEXT,
content: text
}]
console.log(`设置 ${el.tag} 的文本为: "${text}"`)
},
// 更新属性
patchProp(el, key, prevValue, nextValue) {
if (!el.props) el.props = {}
if (nextValue === null || nextValue === undefined) {
delete el.props[key]
console.log(`删除属性 ${key}`)
} else {
el.props[key] = nextValue
console.log(`设置属性 ${key} = ${JSON.stringify(nextValue)}`)
}
}
})
// 创建 Vue 组件
const App = {
setup() {
const count = ref(0)
const increment = () => {
count.value++
}
return () => h('div', { class: 'container' }, [
h('h1', `计数: ${count.value}`),
h('button', { onClick: increment }, '增加'),
h('p', '这是一个控制台渲染的Vue应用')
])
}
}
// 渲染到控制台
const app = createApp(App)
const container = { type: 'ROOT', children: [] }
app.mount(container)
console.log('渲染结果:', JSON.stringify(container, null, 2))
四、实战:Canvas 渲染器
让我们创建一个更实用的 Canvas 渲染器:
import { createRenderer, h, ref, onMounted } from 'vue'
class CanvasRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.elements = new Map()
}
drawRect(node) {
const { x, y, width, height, fill, stroke } = node.props
const ctx = this.ctx
if (fill) {
ctx.fillStyle = fill
ctx.fillRect(x, y, width, height)
}
if (stroke) {
ctx.strokeStyle = stroke.color || '#000'
ctx.lineWidth = stroke.width || 1
ctx.strokeRect(x, y, width, height)
}
}
drawText(node) {
const { x, y, text, fontSize, color } = node.props
const ctx = this.ctx
ctx.font = `${fontSize || 16}px Arial`
ctx.fillStyle = color || '#000'
ctx.fillText(text, x, y)
}
drawCircle(node) {
const { x, y, radius, fill, stroke } = node.props
const ctx = this.ctx
ctx.beginPath()
ctx.arc(x, y, radius, 0, Math.PI * 2)
if (fill) {
ctx.fillStyle = fill
ctx.fill()
}
if (stroke) {
ctx.strokeStyle = stroke.color || '#000'
ctx.lineWidth = stroke.width || 1
ctx.stroke()
}
}
}
const { createApp } = createRenderer({
createElement(type) {
return {
type,
children: [],
props: {},
canvasId: `canvas-${Date.now()}-${Math.random()}`
}
},
createText(text) {
return {
type: 'text',
props: { text }
}
},
insert(child, parent, anchor) {
if (!parent.children) parent.children = []
if (anchor) {
const index = parent.children.indexOf(anchor)
parent.children.splice(index, 0, child)
} else {
parent.children.push(child)
}
child.parent = parent
},
remove(child) {
const parent = child.parent
if (parent && parent.children) {
const index = parent.children.indexOf(child)
if (index > -1) {
parent.children.splice(index, 1)
}
}
},
setElementText(el, text) {
el.props.text = text
},
patchProp(el, key, prevValue, nextValue) {
if (nextValue === null || nextValue === undefined) {
delete el.props[key]
} else {
el.props[key] = nextValue
}
// 标记需要重绘
if (el.root && el.root.renderer) {
el.root.renderer.requestRender()
}
},
// 自定义的创建画布方法
createCanvas(width, height) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
return canvas
}
})
// 创建 Canvas 组件
const CanvasApp = {
setup() {
const count = ref(0)
const canvasRef = ref(null)
let renderer = null
onMounted(() => {
renderer = new CanvasRenderer(canvasRef.value)
// 初始渲染
render()
})
const increment = () => {
count.value++
}
const render = () => {
if (!renderer) return
const ctx = renderer.ctx
ctx.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
// 绘制背景
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, canvasRef.value.width, canvasRef.value.height)
// 绘制计数
ctx.font = '24px Arial'
ctx.fillStyle = '#333'
ctx.fillText(`计数: ${count.value}`, 50, 50)
// 绘制按钮
ctx.fillStyle = '#4CAF50'
ctx.fillRect(50, 80, 100, 40)
ctx.fillStyle = 'white'
ctx.font = '16px Arial'
ctx.fillText('增加', 85, 105)
// 绘制圆形
ctx.beginPath()
ctx.arc(150, 200, 30 + count.value * 2, 0, Math.PI * 2)
ctx.fillStyle = '#2196F3'
ctx.fill()
}
// 监听 count 变化
watch(count, () => {
if (renderer) render()
})
return () => h('div', [
h('canvas', {
ref: canvasRef,
width: 400,
height: 300,
style: 'border: 1px solid #ccc'
}),
h('button', {
onClick: increment,
style: 'margin-top: 10px; padding: 10px 20px;'
}, '增加计数')
])
}
}
五、更高级的 Canvas 渲染器集成
创建一个完整的 Canvas 渲染系统:
// canvas-renderer.js
export function createCanvasRenderer(options = {}) {
const {
createCanvas = (w, h) => {
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
return canvas
},
getContext = '2d'
} = options
const nodeOps = {
createElement(type) {
return {
type,
tag: type,
children: [],
props: {},
style: {},
canvasNode: true
}
},
createText(text) {
return {
type: 'TEXT',
content: text,
canvasNode: true
}
},
createComment(text) {
return {
type: 'COMMENT',
content: text,
canvasNode: true
}
},
insert(child, parent, anchor) {
if (!parent.children) parent.children = []
const index = anchor
? parent.children.indexOf(anchor)
: parent.children.length
if (index >= 0) {
parent.children.splice(index, 0, child)
} else {
parent.children.push(child)
}
child.parent = parent
// 触发重绘
if (parent.root && parent.root.ctx) {
requestAnimationFrame(() => this.render(parent.root))
}
},
remove(child) {
const parent = child.parent
if (parent && parent.children) {
const index = parent.children.indexOf(child)
if (index > -1) {
parent.children.splice(index, 1)
// 触发重绘
if (parent.root && parent.root.ctx) {
requestAnimationFrame(() => this.render(parent.root))
}
}
}
},
setElementText(el, text) {
el.textContent = text
if (el.parent && el.parent.root && el.parent.root.ctx) {
requestAnimationFrame(() => this.render(el.parent.root))
}
},
patchProp(el, key, prevValue, nextValue) {
if (key.startsWith('on')) {
// 处理事件
const eventName = key.slice(2).toLowerCase()
if (prevValue) {
el.removeEventListener(eventName, prevValue)
}
if (nextValue) {
el.addEventListener(eventName, nextValue)
}
} else if (key === 'style') {
Object.assign(el.style, nextValue)
} else {
el.props[key] = nextValue
}
// 触发重绘
if (el.root && el.root.ctx) {
requestAnimationFrame(() => this.render(el.root))
}
},
// 渲染方法
render(root) {
const ctx = root.ctx
const width = root.canvas.width
const height = root.canvas.height
// 清空画布
ctx.clearRect(0, 0, width, height)
// 绘制背景
if (root.props.background) {
ctx.fillStyle = root.props.background
ctx.fillRect(0, 0, width, height)
}
// 递归绘制所有子节点
this.drawNode(root, ctx)
},
drawNode(node, ctx) {
if (!node) return
// 保存上下文状态
ctx.save()
// 应用样式
if (node.style) {
Object.entries(node.style).forEach(([key, value]) => {
if (key in ctx) {
ctx[key] = value
}
})
}
// 根据类型绘制
switch (node.type) {
case 'rect':
this.drawRect(node, ctx)
break
case 'circle':
this.drawCircle(node, ctx)
break
case 'text':
this.drawText(node, ctx)
break
case 'path':
this.drawPath(node, ctx)
break
}
// 绘制子节点
if (node.children) {
node.children.forEach(child => this.drawNode(child, ctx))
}
// 恢复上下文状态
ctx.restore()
},
drawRect(node, ctx) {
const { x = 0, y = 0, width = 100, height = 100 } = node.props
const { fill, stroke } = node.props
if (fill) {
ctx.fillStyle = fill
ctx.fillRect(x, y, width, height)
}
if (stroke) {
ctx.strokeStyle = stroke.color || '#000'
ctx.lineWidth = stroke.width || 1
ctx.strokeRect(x, y, width, height)
}
},
drawCircle(node, ctx) {
const { x = 50, y = 50, radius = 30 } = node.props
const { fill, stroke } = node.props
ctx.beginPath()
ctx.arc(x, y, radius, 0, Math.PI * 2)
if (fill) {
ctx.fillStyle = fill
ctx.fill()
}
if (stroke) {
ctx.strokeStyle = stroke.color || '#000'
ctx.lineWidth = stroke.width || 1
ctx.stroke()
}
},
drawText(node, ctx) {
const { x = 0, y = 0, text = '' } = node.props
const { fontSize = 16, fontFamily = 'Arial', color = '#000' } = node.props
ctx.font = `${fontSize}px ${fontFamily}`
ctx.fillStyle = color
ctx.fillText(text, x, y)
}
}
const { createApp } = createRenderer(nodeOps)
return {
createApp,
nodeOps
}
}
// 使用示例
import { createCanvasRenderer } from './canvas-renderer'
import { h, defineComponent, ref } from 'vue'
const { createApp } = createCanvasRenderer()
const CanvasDemo = defineComponent({
setup() {
const x = ref(100)
const y = ref(100)
const colorIndex = ref(0)
const colors = ['#FF6B6B', '#4ECDC4', '#FFE66D', '#1A535C']
const moveCircle = (dx, dy) => {
x.value += dx
y.value += dy
}
const changeColor = () => {
colorIndex.value = (colorIndex.value + 1) % colors.length
}
return () => h('canvas-root', {
width: 800,
height: 600,
background: '#f8f9fa'
}, [
h('rect', {
x: 50,
y: 50,
width: 200,
height: 100,
fill: '#E9ECEF',
stroke: { color: '#495057', width: 2 }
}),
h('circle', {
x: x.value,
y: y.value,
radius: 40,
fill: colors[colorIndex.value],
stroke: { color: '#343A40', width: 3 }
}),
h('text', {
x: 300,
y: 100,
text: 'Vue Canvas 渲染器',
fontSize: 24,
color: '#212529'
}),
h('text', {
x: 300,
y: 140,
text: `位置: (${x.value}, ${y.value})`,
fontSize: 16,
color: '#495057'
})
])
}
})
// 挂载到实际的 canvas 元素
const canvas = document.getElementById('app-canvas')
const app = createApp(CanvasDemo)
// 自定义挂载方法
app.mount = function(container) {
container.width = 800
container.height = 600
container.root = {
canvas: container,
ctx: container.getContext('2d'),
type: 'canvas-root',
children: []
}
// 初始渲染
requestAnimationFrame(() => {
app._instance = this
app._container = container
nodeOps.render(container.root)
})
return app
}
app.mount(canvas)
六、实战:PDF 渲染器
import { createRenderer, h } from 'vue'
import PDFDocument from 'pdfkit'
import fs from 'fs'
function createPDFRenderer() {
const nodeOps = {
createElement(type) {
return { type, children: [], props: {} }
},
createText(text) {
return { type: 'text', content: text }
},
insert(child, parent) {
if (!parent.children) parent.children = []
parent.children.push(child)
},
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue
},
setElementText(el, text) {
el.content = text
},
createPDF() {
const doc = new PDFDocument()
const stream = fs.createWriteStream('output.pdf')
doc.pipe(stream)
return { doc, stream }
},
render(node, pdf) {
this.renderNode(node, pdf.doc)
pdf.doc.end()
},
renderNode(node, doc) {
switch (node.type) {
case 'document':
node.children.forEach(child => this.renderNode(child, doc))
break
case 'page':
doc.addPage()
node.children.forEach(child => this.renderNode(child, doc))
break
case 'text':
doc.text(node.content || '', node.props.x || 0, node.props.y || 0, {
width: node.props.width,
align: node.props.align
})
break
case 'rect':
doc.rect(node.props.x || 0, node.props.y || 0,
node.props.width || 100, node.props.height || 100)
if (node.props.fill) {
doc.fill(node.props.fill)
}
if (node.props.stroke) {
doc.stroke()
}
break
case 'image':
if (node.props.src) {
doc.image(node.props.src, node.props.x || 0, node.props.y || 0, {
width: node.props.width,
height: node.props.height
})
}
break
}
}
}
return createRenderer(nodeOps)
}
// 使用 PDF 渲染器
const { createApp } = createPDFRenderer()
const PDFApp = {
render() {
return h('document', [
h('page', { size: 'A4' }, [
h('text', {
x: 50,
y: 50,
content: 'Vue PDF 渲染器示例',
fontSize: 24
}),
h('text', {
x: 50,
y: 100,
content: '这是使用 Vue 自定义渲染器生成的 PDF 文档',
fontSize: 14
}),
h('rect', {
x: 50,
y: 150,
width: 500,
height: 300,
fill: '#f0f0f0',
stroke: '#333'
})
])
])
}
}
const app = createApp(PDFApp)
const pdf = app.mount()
// 生成 PDF 文件
fs.writeFileSync('output.pdf', pdf)
七、最佳实践和注意事项
1. 性能优化
// 使用 requestAnimationFrame 批量更新
let pendingRender = false
const queue = new Set()
function queueRender(node) {
queue.add(node)
if (!pendingRender) {
pendingRender = true
requestAnimationFrame(() => {
queue.forEach(n => renderNode(n))
queue.clear()
pendingRender = false
})
}
}
2. 事件处理
// 在 Canvas 中处理事件
function setupCanvasEvents(canvas, rootNode) {
canvas.addEventListener('click', (event) => {
const rect = canvas.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
// 查找点击的元素
const target = findNodeAt(rootNode, x, y)
if (target && target.props.onClick) {
target.props.onClick(event)
}
})
}
function findNodeAt(node, x, y) {
if (!node) return null
// 检查当前节点
if (node.type === 'button' &&
x >= node.props.x &&
x <= node.props.x + node.props.width &&
y >= node.props.y &&
y <= node.props.y + node.props.height) {
return node
}
// 检查子节点
if (node.children) {
for (const child of node.children) {
const found = findNodeAt(child, x, y)
if (found) return found
}
}
return null
}
3. 服务端渲染支持
// 添加 SSR 支持
function createSSRCanvasRenderer() {
const nodeOps = {
// ... 基础的节点操作
// SSR 特有的方法
hydrate() {
// 服务端渲染的水合逻辑
},
createStaticVNode() {
// 创建静态节点
}
}
return createRenderer({
...nodeOps,
hydrate: nodeOps.hydrate,
createStaticVNode: nodeOps.createStaticVNode
})
}
八、总结
Vue 的自定义渲染器功能非常强大,它允许你将 Vue 的响应式系统和组件模型应用到各种渲染目标:
入门简单:只需要实现几个核心的节点操作方法
灵活强大:可以渲染到 Canvas、PDF、终端、Native 应用等
性能可控:可以根据目标平台优化渲染性能
复用 Vue 生态:可以使用 Vue 的组件、响应式系统、生命周期等所有特性
通过学习自定义渲染器,你可以更深入地理解 Vue 的内部工作原理,并创建出独特而强大的渲染解决方案。