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 基础节点类
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 绘制引擎
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 基础渲染器
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)
// 封装 createAppexport 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)来分发事件:
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 入口文件
import { createCanvasApp } from './canvas-renderer'import App from './App'
const canvas = document.getElementById('canvas') as HTMLCanvasElementcanvas.width = 500canvas.height = 300
const app = createCanvasApp(App)app.mount(canvas)<!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 组件
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) ]) }})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 的动画
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 等指令天然支持:
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 支持:
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 架构优势
这种设计带来了几个重要优势:
- 核心算法复用:diff 算法、组件生命周期、响应式系统等核心逻辑在所有渲染器之间共享
- 平台解耦:添加新平台只需实现
RendererOptions接口 - 测试友好:可以创建一个纯内存的渲染器用于单元测试
- 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 渲染器,包括:
- 节点模型:用
CanvasNode类模拟 DOM 的树形结构 - 绘制引擎:遍历节点树将内容绘制到 Canvas
- 渲染器接口:实现
RendererOptions的所有必要方法 - 事件系统:通过命中测试实现事件代理和冒泡
- 组件化:利用 Vue 的组件系统构建可复用的 Canvas 组件
- 动画支持:通过 Composition API 实现声明式动画
这个渲染器虽然是教学目的的简化版本,但它展示了 Vue3 自定义渲染器的完整工作流程。在此基础上,你可以扩展更多的图形基元、添加布局系统、实现更复杂的事件处理,打造一个真正可用的 Canvas UI 框架。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!