Vue3 自定义渲染器:从零实现一个 Canvas 渲染器

3994 字
20 分钟
Vue3 自定义渲染器:从零实现一个 Canvas 渲染器

Vue3 自定义渲染器:从零实现一个 Canvas 渲染器#

Vue3 架构中最令人兴奋的设计之一就是将渲染器(Renderer)从核心运行时中解耦出来。通过 createRenderer API,你可以将 Vue 的响应式系统和组件模型应用到任何目标平台——不仅是 DOM,还可以是 Canvas、WebGL、终端、甚至原生移动端。

本文将从零开始,利用 createRenderer API 手写一个将 Vue 组件渲染到 Canvas 的自定义渲染器,带你深入理解 Vue3 渲染器的架构设计。

一、Vue3 渲染器架构概览#

1.1 渲染器的职责#

在 Vue3 中,渲染器负责将虚拟 DOM(VNode)转换为真实的目标平台节点。它的核心职责包括:

  • 创建节点(createElement、createText)
  • 修改节点(设置属性、更新文本)
  • 插入和移除节点
  • 父子关系管理

Vue3 将这些操作抽象为一组平台无关的接口,由具体的渲染器实现来提供平台相关的操作。

1.2 createRenderer API#

import { createRenderer } from '@vue/runtime-core'
const { render, createApp } = createRenderer({
// 平台相关的节点操作
createElement(type) { /* ... */ },
insert(child, parent, anchor) { /* ... */ },
remove(child) { /* ... */ },
patchProp(el, key, prevValue, nextValue) { /* ... */ },
// ... 更多操作
})

createRenderer 接收一个 RendererOptions 对象,返回 render 函数和 createApp 工厂函数。这就是 Vue3 跨平台能力的基础。

1.3 RendererOptions 完整接口#

interface RendererOptions<HostNode, HostElement> {
patchProp(
el: HostElement,
key: string,
prevValue: any,
nextValue: any,
isSVG?: boolean,
prevChildren?: VNode[],
parentComponent?: ComponentInternalInstance | null,
parentSuspense?: SuspenseBoundary | null,
unmountChildren?: UnmountChildrenFn
): void
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
remove(el: HostNode): void
createElement(type: string, isSVG?: boolean, isCustomizedBuiltIn?: string, vnodeProps?: any): HostElement
createText(text: string): HostNode
createComment(text: string): HostNode
setText(node: HostNode, text: string): void
setElementText(el: HostElement, text: string): void
parentNode(node: HostNode): HostElement | null
nextSibling(node: HostNode): HostNode | null
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,
start?: HostNode | null,
end?: HostNode | null
): [HostNode, HostNode]
}

二、设计 Canvas 节点模型#

在实现渲染器之前,我们需要先设计一套Canvas 节点系统来模拟 DOM 的树形结构。

2.1 基础节点类#

canvas-nodes.ts
export type CanvasNodeType = 'Rect' | 'Circle' | 'Text' | 'Group' | 'Image' | 'Line'
export interface CanvasNodeStyle {
x?: number
y?: number
width?: number
height?: number
radius?: number
color?: string
backgroundColor?: string
fontSize?: number
fontFamily?: string
strokeColor?: string
strokeWidth?: number
opacity?: number
visible?: boolean
}
let nodeId = 0
export class CanvasNode {
id: number
type: CanvasNodeType | 'Root' | 'Comment'
props: Record<string, any>
style: CanvasNodeStyle
text: string
children: CanvasNode[]
parent: CanvasNode | null
eventListeners: Map<string, Function>
constructor(type: CanvasNodeType | 'Root' | 'Comment') {
this.id = nodeId++
this.type = type
this.props = {}
this.style = {}
this.text = ''
this.children = []
this.parent = null
this.eventListeners = new Map()
}
appendChild(child: CanvasNode) {
child.parent = this
this.children.push(child)
}
insertBefore(child: CanvasNode, anchor: CanvasNode | null) {
child.parent = this
if (anchor) {
const index = this.children.indexOf(anchor)
if (index !== -1) {
this.children.splice(index, 0, child)
return
}
}
this.children.push(child)
}
removeChild(child: CanvasNode) {
const index = this.children.indexOf(child)
if (index !== -1) {
this.children.splice(index, 1)
child.parent = null
}
}
nextSibling(): CanvasNode | null {
if (!this.parent) return null
const index = this.parent.children.indexOf(this)
return this.parent.children[index + 1] || null
}
}

2.2 Canvas 绘制引擎#

canvas-bindbindbindpaint.ts
export class CanvasPainter {
private ctx: CanvasRenderingContext2D
private canvas: HTMLCanvasElement
private root: CanvasNode | null = null
private animationFrameId: number | null = null
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')!
}
setRoot(root: CanvasNode) {
this.root = root
}
// 请求重绘(合并多次更新)
requestPaint() {
if (this.animationFrameId !== null) return
this.animationFrameId = requestAnimationFrame(() => {
this.paint()
this.animationFrameId = null
})
}
paint() {
const { ctx, canvas } = this
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (this.root) {
this.paintNode(this.root, 0, 0)
}
}
private paintNode(node: CanvasNode, offsetX: number, offsetY: number) {
const { style } = node
if (style.visible === false) return
const x = (style.x || 0) + offsetX
const y = (style.y || 0) + offsetY
ctx.save()
if (style.opacity !== undefined) {
ctx.globalAlpha = style.opacity
}
switch (node.type) {
case 'Rect':
this.paintRect(node, x, y)
break
case 'Circle':
this.paintCircle(node, x, y)
break
case 'Text':
this.paintText(node, x, y)
break
case 'Line':
this.paintLine(node, x, y)
break
case 'Group':
case 'Root':
// Group 和 Root 只作为容器,不绘制自身
break
}
// 递归绘制子节点
for (const child of node.children) {
this.paintNode(child, x, y)
}
ctx.restore()
}
private paintRect(node: CanvasNode, x: number, y: number) {
const { ctx } = this
const { width = 100, height = 50, backgroundColor, strokeColor, strokeWidth } = node.style
if (backgroundColor) {
ctx.fillStyle = backgroundColor
ctx.fillRect(x, y, width, height)
}
if (strokeColor) {
ctx.strokeStyle = strokeColor
ctx.lineWidth = strokeWidth || 1
ctx.strokeRect(x, y, width, height)
}
}
private paintCircle(node: CanvasNode, x: number, y: number) {
const { ctx } = this
const { radius = 25, backgroundColor, strokeColor, strokeWidth } = node.style
ctx.beginPath()
ctx.arc(x + radius, y + radius, radius, 0, Math.PI * 2)
if (backgroundColor) {
ctx.fillStyle = backgroundColor
ctx.fill()
}
if (strokeColor) {
ctx.strokeStyle = strokeColor
ctx.lineWidth = strokeWidth || 1
ctx.stroke()
}
}
private paintText(node: CanvasNode, x: number, y: number) {
const { ctx } = this
const { color = '#000', fontSize = 14, fontFamily = 'sans-serif' } = node.style
ctx.fillStyle = color
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textBaseline = 'top'
ctx.fillText(node.text, x, y)
}
private paintLine(node: CanvasNode, x: number, y: number) {
const { ctx } = this
const { strokeColor = '#000', strokeWidth = 1 } = node.style
const { x2 = 0, y2 = 0 } = node.props
ctx.beginPath()
ctx.moveTo(x, y)
ctx.lineTo(x2, y2)
ctx.strokeStyle = strokeColor
ctx.lineWidth = strokeWidth
ctx.stroke()
}
}

三、实现自定义渲染器#

现在我们来实现渲染器的核心——RendererOptions

3.1 基础渲染器#

canvas-renderer.ts
import { createRenderer } from '@vue/runtime-core'
import { CanvasNode } from './canvas-nodes'
import { CanvasPainter } from './canvas-painter'
let painter: CanvasPainter | null = null
export function bindCanvas(canvas: HTMLCanvasElement) {
painter = new CanvasPainter(canvas)
}
function triggerRepaint() {
painter?.requestPaint()
}
const rendererOptions = {
createElement(type: string): CanvasNode {
// 将标签名映射为 Canvas 节点类型
const nodeType = type.charAt(0).toUpperCase() + type.slice(1)
return new CanvasNode(nodeType as any)
},
createText(text: string): CanvasNode {
const node = new CanvasNode('Text')
node.text = text
return node
},
createComment(text: string): CanvasNode {
return new CanvasNode('Comment')
},
setText(node: CanvasNode, text: string) {
node.text = text
triggerRepaint()
},
setElementText(el: CanvasNode, text: string) {
// 清空子节点,设置文本
el.children = []
if (text) {
const textNode = new CanvasNode('Text')
textNode.text = text
textNode.parent = el
el.children.push(textNode)
}
triggerRepaint()
},
insert(child: CanvasNode, parent: CanvasNode, anchor?: CanvasNode | null) {
if (anchor) {
parent.insertBefore(child, anchor)
} else {
parent.appendChild(child)
}
triggerRepaint()
},
remove(child: CanvasNode) {
if (child.parent) {
child.parent.removeChild(child)
triggerRepaint()
}
},
parentNode(node: CanvasNode): CanvasNode | null {
return node.parent
},
nextSibling(node: CanvasNode): CanvasNode | null {
return node.nextSibling()
},
patchProp(
el: CanvasNode,
key: string,
prevValue: any,
nextValue: any
) {
if (key.startsWith('on')) {
// 事件处理
const eventName = key.slice(2).toLowerCase()
if (prevValue) {
el.eventListeners.delete(eventName)
}
if (nextValue) {
el.eventListeners.set(eventName, nextValue)
}
} else if (key === 'style' && typeof nextValue === 'object') {
// 样式处理
Object.assign(el.style, nextValue)
triggerRepaint()
} else {
// 其他属性
el.props[key] = nextValue
// 某些属性直接映射到 style
if (['x', 'y', 'width', 'height', 'radius', 'color',
'backgroundColor', 'fontSize', 'opacity'].includes(key)) {
;(el.style as any)[key] = nextValue
}
triggerRepaint()
}
},
// 可选方法
querySelector() {
return null
},
setScopeId(el: CanvasNode, id: string) {
el.props[id] = ''
},
cloneNode(node: CanvasNode): CanvasNode {
const clone = new CanvasNode(node.type)
clone.props = { ...node.props }
clone.style = { ...node.style }
clone.text = node.text
return clone
},
insertStaticContent() {
return [new CanvasNode('Comment'), new CanvasNode('Comment')] as [CanvasNode, CanvasNode]
}
}
// 创建渲染器
const { render, createApp: baseCreateApp } = createRenderer<CanvasNode, CanvasNode>(rendererOptions)
// 封装 createApp
export function createCanvasApp(rootComponent: any) {
const app = baseCreateApp(rootComponent)
const originalMount = app.mount
app.mount = (canvas: HTMLCanvasElement) => {
bindCanvas(canvas)
const root = new CanvasNode('Root')
painter!.setRoot(root)
// 设置事件代理
setupEventProxy(canvas, root)
// 挂载
originalMount(root as any)
painter!.paint()
return app
}
return app
}
export { render }

3.2 事件系统#

Canvas 不像 DOM 那样每个元素都能独立接收事件,我们需要实现一个事件代理系统,通过命中测试(hit testing)来分发事件:

canvas-events.ts
import { CanvasNode } from './canvas-nodes'
function hitTest(node: CanvasNode, mouseX: number, mouseY: number, offsetX = 0, offsetY = 0): CanvasNode | null {
const x = (node.style.x || 0) + offsetX
const y = (node.style.y || 0) + offsetY
// 倒序遍历子节点(后绘制的在上层)
for (let i = node.children.length - 1; i >= 0; i--) {
const hit = hitTest(node.children[i], mouseX, mouseY, x, y)
if (hit) return hit
}
// 检查当前节点
if (node.type === 'Rect') {
const w = node.style.width || 100
const h = node.style.height || 50
if (mouseX >= x && mouseX <= x + w && mouseY >= y && mouseY <= y + h) {
return node
}
} else if (node.type === 'Circle') {
const r = node.style.radius || 25
const cx = x + r
const cy = y + r
const dist = Math.sqrt((mouseX - cx) ** 2 + (mouseY - cy) ** 2)
if (dist <= r) {
return node
}
} else if (node.type === 'Text') {
// 简化:文本节点用矩形区域近似
const fontSize = node.style.fontSize || 14
const textWidth = node.text.length * fontSize * 0.6 // 近似
if (mouseX >= x && mouseX <= x + textWidth && mouseY >= y && mouseY <= y + fontSize) {
return node
}
}
return null
}
export function setupEventProxy(canvas: HTMLCanvasElement, root: CanvasNode) {
const eventTypes = ['click', 'mousedown', 'mouseup', 'mousemove', 'dblclick']
for (const eventType of eventTypes) {
canvas.addEventListener(eventType, (e: MouseEvent) => {
const rect = canvas.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
const hitNode = hitTest(root, mouseX, mouseY)
if (hitNode) {
// 冒泡:从命中节点向上传播
let current: CanvasNode | null = hitNode
while (current) {
const handler = current.eventListeners.get(eventType)
if (handler) {
handler({
target: hitNode,
currentTarget: current,
x: mouseX,
y: mouseY,
originalEvent: e
})
}
current = current.parent
}
}
})
}
}

四、使用自定义渲染器编写 Vue 组件#

现在我们可以像写普通 Vue 组件一样编写 Canvas 应用了!

4.1 基础示例#

// App.vue(使用 Canvas 渲染器)
import { defineComponent, ref, reactive } from '@vue/runtime-core'
export default defineComponent({
setup() {
const count = ref(0)
const ballPosition = reactive({ x: 100, y: 100 })
const handleRectClick = () => {
count.value++
}
const handleCircleClick = () => {
ballPosition.x += 20
if (ballPosition.x > 400) ballPosition.x = 100
}
return { count, ballPosition, handleRectClick, handleCircleClick }
},
render() {
return h('group', {}, [
// 背景矩形
h('rect', {
x: 10,
y: 10,
width: 480,
height: 280,
backgroundColor: '#f0f0f0',
strokeColor: '#333',
strokeWidth: 2
}),
// 标题
h('text', {
x: 20,
y: 20,
color: '#333',
fontSize: 24,
style: { fontFamily: 'Arial' }
}, `Canvas Vue App - Clicks: ${this.count}`),
// 可点击的按钮(矩形)
h('rect', {
x: 20,
y: 60,
width: 150,
height: 40,
backgroundColor: '#4CAF50',
onClick: this.handleRectClick
}),
h('text', {
x: 45,
y: 70,
color: '#fff',
fontSize: 16
}, 'Click Me!'),
// 可交互的圆
h('circle', {
x: this.ballPosition.x,
y: this.ballPosition.y,
radius: 30,
backgroundColor: '#2196F3',
onClick: this.handleCircleClick
}),
h('text', {
x: this.ballPosition.x + 10,
y: this.ballPosition.y + 22,
color: '#fff',
fontSize: 12
}, 'Move →')
])
}
})

4.2 入口文件#

main.ts
import { createCanvasApp } from './canvas-renderer'
import App from './App'
const canvas = document.getElementById('canvas') as HTMLCanvasElement
canvas.width = 500
canvas.height = 300
const app = createCanvasApp(App)
app.mount(canvas)
index.html
<!DOCTYPE html>
<html>
<head>
<title>Vue3 Canvas Renderer</title>
</head>
<body>
<canvas id="canvas" style="border: 1px solid #ccc;"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4.3 组件化:实现可复用的 Canvas 组件#

components/Button.ts
import { defineComponent, h } from '@vue/runtime-core'
export const CanvasButton = defineComponent({
props: {
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
width: { type: Number, default: 120 },
height: { type: Number, default: 36 },
label: { type: String, default: 'Button' },
color: { type: String, default: '#1976D2' },
textColor: { type: String, default: '#ffffff' }
},
emits: ['click'],
setup(props, { emit }) {
const handleClick = (e: any) => {
emit('click', e)
}
return () => h('group', {}, [
h('rect', {
x: props.x,
y: props.y,
width: props.width,
height: props.height,
backgroundColor: props.color,
onClick: handleClick
}),
h('text', {
x: props.x + 10,
y: props.y + (props.height - 14) / 2,
color: props.textColor,
fontSize: 14
}, props.label)
])
}
})
components/ProgressBar.ts
import { defineComponent, h, computed } from '@vue/runtime-core'
export const ProgressBar = defineComponent({
props: {
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
width: { type: Number, default: 200 },
height: { type: Number, default: 20 },
value: { type: Number, default: 0 }, // 0-100
trackColor: { type: String, default: '#e0e0e0' },
fillColor: { type: String, default: '#4CAF50' }
},
setup(props) {
const fillWidth = computed(() => (props.value / 100) * props.width)
return () => h('group', {}, [
// 轨道
h('rect', {
x: props.x,
y: props.y,
width: props.width,
height: props.height,
backgroundColor: props.trackColor
}),
// 填充
h('rect', {
x: props.x,
y: props.y,
width: fillWidth.value,
height: props.height,
backgroundColor: props.fillColor
}),
// 文本
h('text', {
x: props.x + props.width / 2 - 10,
y: props.y + 3,
color: '#333',
fontSize: 12
}, `${Math.round(props.value)}%`)
])
}
})

4.4 使用 Composition API 的动画#

composables/useAnimation.ts
import { ref, onMounted, onUnmounted } from '@vue/runtime-core'
export function useAnimation(updateFn: (dt: number) => void) {
let lastTime = 0
let rafId: number | null = null
const isRunning = ref(false)
function loop(time: number) {
if (!isRunning.value) return
const dt = lastTime ? (time - lastTime) / 1000 : 0
lastTime = time
updateFn(dt)
rafId = requestAnimationFrame(loop)
}
function start() {
if (isRunning.value) return
isRunning.value = true
lastTime = 0
rafId = requestAnimationFrame(loop)
}
function stop() {
isRunning.value = false
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
}
onMounted(start)
onUnmounted(stop)
return { isRunning, start, stop }
}
// BouncingBall.ts — 一个有动画的弹跳球组件
import { defineComponent, h, reactive } from '@vue/runtime-core'
import { useAnimation } from './composables/useAnimation'
export default defineComponent({
setup() {
const ball = reactive({
x: 50,
y: 50,
vx: 120, // 像素/秒
vy: 80,
radius: 20
})
const bounds = { width: 500, height: 300 }
useAnimation((dt) => {
ball.x += ball.vx * dt
ball.y += ball.vy * dt
// 边界碰撞
if (ball.x <= 0 || ball.x + ball.radius * 2 >= bounds.width) {
ball.vx = -ball.vx
ball.x = Math.max(0, Math.min(ball.x, bounds.width - ball.radius * 2))
}
if (ball.y <= 0 || ball.y + ball.radius * 2 >= bounds.height) {
ball.vy = -ball.vy
ball.y = Math.max(0, Math.min(ball.y, bounds.height - ball.radius * 2))
}
})
return () => h('group', {}, [
h('rect', {
x: 0, y: 0,
width: bounds.width,
height: bounds.height,
backgroundColor: '#1a1a2e'
}),
h('circle', {
x: ball.x,
y: ball.y,
radius: ball.radius,
backgroundColor: '#e94560'
})
])
}
})

五、进阶:支持更多特性#

5.1 支持 v-for 渲染列表#

由于我们的渲染器完整实现了 RendererOptions 接口,v-for 等指令天然支持:

ParticleSystem.ts
import { defineComponent, h, reactive, onMounted } from '@vue/runtime-core'
interface Particle {
id: number
x: number
y: number
radius: number
color: string
vx: number
vy: number
}
export default defineComponent({
setup() {
const particles = reactive<Particle[]>([])
let nextId = 0
function addParticle(x: number, y: number) {
particles.push({
id: nextId++,
x,
y,
radius: 3 + Math.random() * 8,
color: `hsl(${Math.random() * 360}, 70%, 60%)`,
vx: (Math.random() - 0.5) * 100,
vy: (Math.random() - 0.5) * 100
})
// 限制粒子数量
if (particles.length > 200) {
particles.shift()
}
}
// 动画循环
let lastTime = 0
function animate(time: number) {
const dt = lastTime ? (time - lastTime) / 1000 : 0
lastTime = time
for (const p of particles) {
p.x += p.vx * dt
p.y += p.vy * dt
p.vy += 50 * dt // 重力
p.radius *= 0.998 // 缩小
}
// 移除太小的粒子
for (let i = particles.length - 1; i >= 0; i--) {
if (particles[i].radius < 0.5) particles.splice(i, 1)
}
requestAnimationFrame(animate)
}
onMounted(() => requestAnimationFrame(animate))
const handleClick = (e: any) => {
for (let i = 0; i < 20; i++) {
addParticle(e.x, e.y)
}
}
return () => h('group', { onClick: handleClick }, [
// 背景
h('rect', {
x: 0, y: 0, width: 500, height: 300,
backgroundColor: '#000'
}),
// 粒子列表 —— v-for 的 render 函数写法
...particles.map(p =>
h('circle', {
key: p.id,
x: p.x - p.radius,
y: p.y - p.radius,
radius: p.radius,
backgroundColor: p.color
})
),
h('text', {
x: 10, y: 10, color: '#fff', fontSize: 12
}, `Particles: ${particles.length} | Click to spawn`)
])
}
})

5.2 支持 Transition#

我们可以为 Canvas 渲染器添加 Transition 支持:

canvas-transition.ts
import { defineComponent, h, Transition } from '@vue/runtime-core'
// 自定义 Transition 钩子
export function useCanvasTransition() {
return {
onBeforeEnter(el: CanvasNode) {
el.style.opacity = 0
},
onEnter(el: CanvasNode, done: () => void) {
animateProperty(el, 'opacity', 0, 1, 300, done)
},
onLeave(el: CanvasNode, done: () => void) {
animateProperty(el, 'opacity', 1, 0, 300, done)
}
}
}
function animateProperty(
node: CanvasNode,
prop: string,
from: number,
to: number,
duration: number,
done: () => void
) {
const start = performance.now()
function step(time: number) {
const progress = Math.min((time - start) / duration, 1)
const eased = easeInOutCubic(progress)
;(node.style as any)[prop] = from + (to - from) * eased
if (progress < 1) {
requestAnimationFrame(step)
} else {
done()
}
}
requestAnimationFrame(step)
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
}

六、与 DOM 渲染器的架构对比#

6.1 Vue3 DOM 渲染器的实现#

Vue3 内置的 DOM 渲染器(@vue/runtime-dom)本质上也是通过 createRenderer 创建的:

// @vue/runtime-dom/src/index.ts(简化)
import { createRenderer } from '@vue/runtime-core'
import { nodeOps } from './nodeOps'
import { patchProp } from './patchProp'
const rendererOptions = {
patchProp,
...nodeOps
}
const renderer = createRenderer(rendererOptions)
export function createApp(rootComponent: any) {
const app = renderer.createApp(rootComponent)
const originalMount = app.mount
app.mount = (containerOrSelector: string | Element) => {
const container = typeof containerOrSelector === 'string'
? document.querySelector(containerOrSelector)
: containerOrSelector
container!.innerHTML = ''
originalMount(container as any)
}
return app
}
// @vue/runtime-dom/src/nodeOps.ts(简化)
export const nodeOps = {
insert: (child: Node, parent: Element, anchor?: Node | null) => {
parent.insertBefore(child, anchor || null)
},
remove: (child: Node) => {
const parent = child.parentNode
if (parent) parent.removeChild(child)
},
createElement: (tag: string) => document.createElement(tag),
createText: (text: string) => document.createTextNode(text),
createComment: (text: string) => document.createComment(text),
setText: (node: Node, text: string) => { node.nodeValue = text },
setElementText: (el: Element, text: string) => { el.textContent = text },
parentNode: (node: Node) => node.parentNode as Element | null,
nextSibling: (node: Node) => node.nextSibling
}

可以看到,DOM 渲染器和我们的 Canvas 渲染器在结构上完全一致,区别仅在于节点操作的具体实现。

6.2 架构优势#

这种设计带来了几个重要优势:

  1. 核心算法复用:diff 算法、组件生命周期、响应式系统等核心逻辑在所有渲染器之间共享
  2. 平台解耦:添加新平台只需实现 RendererOptions 接口
  3. 测试友好:可以创建一个纯内存的渲染器用于单元测试
  4. Tree-shaking:不使用 DOM 的场景可以完全移除 @vue/runtime-dom

七、实际应用场景#

自定义渲染器在以下场景中特别有用:

  • 数据可视化:使用 Canvas/WebGL 渲染大量图表元素,同时利用 Vue 的响应式系统管理状态
  • 游戏开发:Vue 管理 UI 和游戏状态,Canvas/WebGL 负责渲染
  • 终端 UI:将 Vue 组件渲染到终端(类似 ink 对 React 的作用)
  • PDF 生成:将 Vue 模板渲染为 PDF 文档
  • 原生应用:类似 UniApp、Weex 的跨平台方案

八、总结#

Vue3 的 createRenderer API 体现了框架设计的精妙之处——通过抽象渲染接口,将渲染逻辑平台操作完全分离。

我们在本文中实现了一个完整的 Canvas 渲染器,包括:

  1. 节点模型:用 CanvasNode 类模拟 DOM 的树形结构
  2. 绘制引擎:遍历节点树将内容绘制到 Canvas
  3. 渲染器接口:实现 RendererOptions 的所有必要方法
  4. 事件系统:通过命中测试实现事件代理和冒泡
  5. 组件化:利用 Vue 的组件系统构建可复用的 Canvas 组件
  6. 动画支持:通过 Composition API 实现声明式动画

这个渲染器虽然是教学目的的简化版本,但它展示了 Vue3 自定义渲染器的完整工作流程。在此基础上,你可以扩展更多的图形基元、添加布局系统、实现更复杂的事件处理,打造一个真正可用的 Canvas UI 框架。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Vue3 自定义渲染器:从零实现一个 Canvas 渲染器
https://boke.hackerdream.xyz/posts/vue3-custom-renderer/
作者
晴天
发布于
2026-03-07
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录