简历描述模板
主导开发了基于Canvas的多个高级应用,包括流程图编辑器、在线白板、图片编辑器等核心功能模块。实现了拖拽、缩放、旋转等复杂交互,支持多人实时协作和历史记录回退。通过自研的图层管理和事件系统,解决了大量图形对象的性能问题。项目上线后支撑了公司内部5个产品线的使用,日活用户达8000+,Canvas绘制性能优化后帧率稳定在60fps,复杂场景下响应时间控制在16ms以内。
SOP 标准回答
面试官:你做过哪些Canvas的高级应用?遇到过什么难点?
我做过几个比较复杂的Canvas应用,主要有流程图编辑器、在线白板和图片编辑器这几个。
先说流程图编辑器。这个项目是给公司内部的工作流系统做的可视化配置工具。用户可以拖拽节点、连线,配置流程逻辑。我们要实现的功能包括节点的拖拽、缩放、连线、自动对齐、撤销重做等。最大的难点是事件处理,因为Canvas本身不像DOM那样有事件系统,所以我们得自己实现。我封装了一套事件系统,通过坐标计算判断鼠标点击的是哪个图形,然后触发对应的事件。还做了碰撞检测和自动吸附,拖拽节点时会自动对齐网格或其他节点。
在线白板是另一个项目,主要用于远程会议时的协作。要支持多人同时绘画、添加文字、插入图片这些功能,还要实时同步。技术上用WebSocket做数据同步,每个操作都序列化成JSON发送给其他用户。难点是性能问题,因为白板上可能有几千个笔画,全量重绘会很卡。我们做了脏矩形检测,只重绘变化的区域。还做了图层分离,把静态内容和动态内容分开渲染。
图片编辑器相对简单一些,主要是裁剪、滤镜、贴纸这些功能。裁剪用到了Canvas的drawImage,可以指定源区域和目标区域。滤镜是通过getImageData获取像素数据,然后遍历每个像素点修改RGB值,最后用putImageData写回去。这个过程计算量很大,我们用了Web Worker在后台线程处理,避免阻塞主线程。
这些项目做下来,我对Canvas的理解更深了。Canvas的性能优化很重要,特别是在对象数量多的时候。离屏渲染、分层、局部重绘这些技巧都很有用。另外就是要有好的架构设计,不能直接在Canvas上一通乱画,要有清晰的对象模型和事件系统。
难点与亮点分析
难点1:复杂场景下的事件处理
问题:Canvas中有数百个图形对象,如何准确判断用户点击的是哪个对象?如何处理拖拽、缩放等复杂交互?
解决方案:自研事件系统。为每个图形对象定义包围盒,点击时遍历所有对象,通过坐标判断点击命中。优化方面用了空间索引(四叉树),把画布划分成网格,只检测当前区域的对象。对于重叠对象,按图层顺序(z-index)从上到下判断。拖拽时记录鼠标偏移量,实时更新对象位置。
效果:支持500+对象的流畅交互,点击响应时间<10ms,拖拽帧率60fps。
难点2:多人协作的冲突处理
问题:多个用户同时编辑同一个白板,如何避免操作冲突?如何保证数据一致性?
解决方案:采用OT(操作转换)算法。每个操作都有唯一ID和时间戳,服务端按时间顺序应用操作。客户端收到其他人的操作时,先计算与本地未提交操作的冲突,然后转换操作参数。比如A在位置10插入文字,B同时在位置5删除了内容,B的操作会导致A的位置变成5,需要转换。还实现了操作队列和重试机制,保证最终一致性。
效果:支持20人同时编辑,操作延迟<100ms,冲突解决准确率99.9%。
难点3:大图性能优化
问题:处理10MB+的高分辨率图片时,Canvas操作很卡,内存占用过高。
解决方案:分块加载和渲染。把大图切成256x256的瓦片,只加载可视区域的瓦片。缩略图用降采样生成,展示时用低分辨率版本。滤镜处理用Web Worker并行计算,把图片分成多份,每个Worker处理一部分,最后合并结果。对于像素操作,用TypedArray代替普通数组,提升计算速度。
效果:20MB图片加载时间从15s降到3s,滤镜处理速度提升5倍,内存占用降低60%。
技术亮点
- 分层渲染架构:静态层、动态层、UI层分离,减少不必要的重绘。
- 操作历史管理:命令模式实现撤销重做,支持100步历史记录。
- 智能吸附算法:拖拽时自动对齐网格、参考线、其他对象。
- 实时协作引擎:OT算法保证多人编辑的一致性。
完整技术实现
1. 流程图编辑器
FlowEditor.vue - 可拖拽的流程图编辑器
<template>
<div class="flow-editor">
<div class="toolbar">
<button @click="addNode('rect')">添加矩形</button>
<button @click="addNode('circle')">添加圆形</button>
<button @click="setMode('line')">连线模式</button>
<button @click="undo">撤销</button>
<button @click="redo">重做</button>
<button @click="clear">清空</button>
</div>
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
></canvas>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const canvasRef = ref(null)
const canvasWidth = ref(800)
const canvasHeight = ref(600)
let ctx = null
let nodes = []
let lines = []
let selectedNode = null
let dragNode = null
let dragOffset = { x: 0, y: 0 }
let mode = ref('select') // select, line, drag
let lineStart = null
let history = []
let historyIndex = -1
// 节点类
class Node {
constructor(type, x, y) {
this.id = Date.now() + Math.random()
this.type = type
this.x = x
this.y = y
this.width = 100
this.height = 60
this.text = '节点'
this.color = '#4CAF50'
}
draw(ctx) {
ctx.save()
ctx.fillStyle = this.color
ctx.strokeStyle = '#333'
ctx.lineWidth = 2
if (this.type === 'rect') {
ctx.fillRect(this.x, this.y, this.width, this.height)
ctx.strokeRect(this.x, this.y, this.width, this.height)
} else if (this.type === 'circle') {
const centerX = this.x + this.width / 2
const centerY = this.y + this.height / 2
const radius = Math.min(this.width, this.height) / 2
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
}
// 绘制文字
ctx.fillStyle = '#fff'
ctx.font = '14px Arial'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(this.text, this.x + this.width / 2, this.y + this.height / 2)
ctx.restore()
}
contains(x, y) {
if (this.type === 'rect') {
return x >= this.x && x <= this.x + this.width &&
y >= this.y && y <= this.y + this.height
} else if (this.type === 'circle') {
const centerX = this.x + this.width / 2
const centerY = this.y + this.height / 2
const radius = Math.min(this.width, this.height) / 2
const dx = x - centerX
const dy = y - centerY
return dx * dx + dy * dy <= radius * radius
}
return false
}
getCenter() {
return {
x: this.x + this.width / 2,
y: this.y + this.height / 2
}
}
}
// 连线类
class Line {
constructor(startNode, endNode) {
this.id = Date.now() + Math.random()
this.startNode = startNode
this.endNode = endNode
this.color = '#333'
}
draw(ctx) {
const start = this.startNode.getCenter()
const end = this.endNode.getCenter()
ctx.save()
ctx.strokeStyle = this.color
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(start.x, start.y)
ctx.lineTo(end.x, end.y)
ctx.stroke()
// 绘制箭头
const angle = Math.atan2(end.y - start.y, end.x - start.x)
const arrowLength = 10
ctx.beginPath()
ctx.moveTo(end.x, end.y)
ctx.lineTo(
end.x - arrowLength * Math.cos(angle - Math.PI / 6),
end.y - arrowLength * Math.sin(angle - Math.PI / 6)
)
ctx.moveTo(end.x, end.y)
ctx.lineTo(
end.x - arrowLength * Math.cos(angle + Math.PI / 6),
end.y - arrowLength * Math.sin(angle + Math.PI / 6)
)
ctx.stroke()
ctx.restore()
}
}
// 添加节点
const addNode = (type) => {
const node = new Node(type, Math.random() * 600 + 100, Math.random() * 400 + 100)
nodes.push(node)
saveHistory()
render()
}
// 设置模式
const setMode = (newMode) => {
mode.value = newMode
lineStart = null
}
// 查找点击的节点
const findNodeAt = (x, y) => {
for (let i = nodes.length - 1; i >= 0; i--) {
if (nodes[i].contains(x, y)) {
return nodes[i]
}
}
return null
}
// 鼠标按下
const handleMouseDown = (e) => {
const rect = canvasRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const node = findNodeAt(x, y)
if (mode.value === 'select') {
if (node) {
dragNode = node
dragOffset.x = x - node.x
dragOffset.y = y - node.y
selectedNode = node
} else {
selectedNode = null
}
} else if (mode.value === 'line') {
if (node) {
if (!lineStart) {
lineStart = node
} else {
if (lineStart !== node) {
const line = new Line(lineStart, node)
lines.push(line)
saveHistory()
}
lineStart = null
mode.value = 'select'
}
}
}
render()
}
// 鼠标移动
const handleMouseMove = (e) => {
if (!dragNode) return
const rect = canvasRef.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
dragNode.x = x - dragOffset.x
dragNode.y = y - dragOffset.y
render()
}
// 鼠标释放
const handleMouseUp = () => {
if (dragNode) {
saveHistory()
}
dragNode = null
}
// 渲染
const render = () => {
if (!ctx) return
// 清空画布
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
// 绘制网格
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
for (let i = 0; i < canvasWidth.value; i += 20) {
ctx.beginPath()
ctx.moveTo(i, 0)
ctx.lineTo(i, canvasHeight.value)
ctx.stroke()
}
for (let i = 0; i < canvasHeight.value; i += 20) {
ctx.beginPath()
ctx.moveTo(0, i)
ctx.lineTo(canvasWidth.value, i)
ctx.stroke()
}
// 绘制连线
lines.forEach(line => line.draw(ctx))
// 绘制节点
nodes.forEach(node => {
node.draw(ctx)
if (node === selectedNode) {
ctx.strokeStyle = '#2196F3'
ctx.lineWidth = 3
ctx.strokeRect(node.x - 2, node.y - 2, node.width + 4, node.height + 4)
}
})
}
// 保存历史
const saveHistory = () => {
const state = {
nodes: JSON.parse(JSON.stringify(nodes.map(n => ({
id: n.id,
type: n.type,
x: n.x,
y: n.y,
width: n.width,
height: n.height,
text: n.text,
color: n.color
})))),
lines: JSON.parse(JSON.stringify(lines.map(l => ({
id: l.id,
startId: l.startNode.id,
endId: l.endNode.id
}))))
}
historyIndex++
history = history.slice(0, historyIndex)
history.push(state)
}
// 恢复状态
const restoreState = (state) => {
nodes = state.nodes.map(n => {
const node = new Node(n.type, n.x, n.y)
Object.assign(node, n)
return node
})
lines = state.lines.map(l => {
const startNode = nodes.find(n => n.id === l.startId)
const endNode = nodes.find(n => n.id === l.endId)
const line = new Line(startNode, endNode)
line.id = l.id
return line
})
render()
}
// 撤销
const undo = () => {
if (historyIndex > 0) {
historyIndex--
restoreState(history[historyIndex])
}
}
// 重做
const redo = () => {
if (historyIndex < history.length - 1) {
historyIndex++
restoreState(history[historyIndex])
}
}
// 清空
const clear = () => {
nodes = []
lines = []
selectedNode = null
saveHistory()
render()
}
onMounted(() => {
ctx = canvasRef.value.getContext('2d')
saveHistory()
render()
})
</script>
<style scoped>
.flow-editor {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
}
.toolbar {
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #1976D2;
}
canvas {
border: 1px solid #ccc;
background: white;
cursor: crosshair;
}
</style>
2. 在线白板系统
Whiteboard.vue - 多人协作白板
<template>
<div class="whiteboard">
<div class="toolbar">
<button @click="setTool('pen')" :class="{ active: tool === 'pen' }">画笔</button>
<button @click="setTool('eraser')" :class="{ active: tool === 'eraser' }">橡皮</button>
<button @click="setTool('text')" :class="{ active: tool === 'text' }">文字</button>
<input type="color" v-model="color" />
<input type="range" v-model="lineWidth" min="1" max="20" />
<button @click="clear">清空</button>
<button @click="undo">撤销</button>
</div>
<canvas
ref="canvasRef"
:width="canvasWidth"
:height="canvasHeight"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
></canvas>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const canvasRef = ref(null)
const canvasWidth = ref(1200)
const canvasHeight = ref(800)
const tool = ref('pen')
const color = ref('#000000')
const lineWidth = ref(2)
let ctx = null
let isDrawing = false
let lastX = 0
let lastY = 0
let strokes = []
let currentStroke = null
// 笔画类
class Stroke {
constructor(tool, color, lineWidth) {
this.id = Date.now() + Math.random()
this.tool = tool
this.color = color
this.lineWidth = lineWidth
this.points = []
}
addPoint(x, y) {
this.points.push({ x, y })
}
draw(ctx) {
if (this.points.length < 2) return
ctx.save()
ctx.strokeStyle = this.tool === 'eraser' ? '#fff' : this.color
ctx.lineWidth = this.lineWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(this.points[0].x, this.points[0].y)
for (let i = 1; i < this.points.length; i++) {
ctx.lineTo(this.points[i].x, this.points[i].y)
}
ctx.stroke()
ctx.restore()
}
}
const setTool = (newTool) => {
tool.value = newTool
}
const getMousePos = (e) => {
const rect = canvasRef.value.getBoundingClientRect()
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
const handleMouseDown = (e) => {
isDrawing = true
const pos = getMousePos(e)
lastX = pos.x
lastY = pos.y
currentStroke = new Stroke(tool.value, color.value, lineWidth.value)
currentStroke.addPoint(pos.x, pos.y)
}
const handleMouseMove = (e) => {
if (!isDrawing) return
const pos = getMousePos(e)
// 绘制当前笔画
ctx.strokeStyle = tool.value === 'eraser' ? '#fff' : color.value
ctx.lineWidth = lineWidth.value
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.beginPath()
ctx.moveTo(lastX, lastY)
ctx.lineTo(pos.x, pos.y)
ctx.stroke()
currentStroke.addPoint(pos.x, pos.y)
lastX = pos.x
lastY = pos.y
}
const handleMouseUp = () => {
if (isDrawing && currentStroke && currentStroke.points.length > 0) {
strokes.push(currentStroke)
currentStroke = null
}
isDrawing = false
}
const clear = () => {
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
strokes = []
}
const undo = () => {
if (strokes.length > 0) {
strokes.pop()
redraw()
}
}
const redraw = () => {
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
strokes.forEach(stroke => stroke.draw(ctx))
}
onMounted(() => {
ctx = canvasRef.value.getContext('2d')
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value)
})
</script>
<style scoped>
.whiteboard {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
}
.toolbar {
display: flex;
gap: 10px;
align-items: center;
}
button {
padding: 8px 16px;
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
button.active {
background: #2196F3;
color: white;
}
button:hover {
background: #e0e0e0;
}
button.active:hover {
background: #1976D2;
}
input[type="color"] {
width: 50px;
height: 38px;
border: none;
cursor: pointer;
}
input[type="range"] {
width: 150px;
}
canvas {
border: 1px solid #ccc;
cursor: crosshair;
background: white;
}
</style>
3. 图片编辑器
ImageEditor.vue - 图片滤镜和裁剪
<template>
<div class="image-editor">
<div class="toolbar">
<input type="file" @change="handleFileSelect" accept="image/*" />
<button @click="applyFilter('grayscale')">灰度</button>
<button @click="applyFilter('sepia')">怀旧</button>
<button @click="applyFilter('invert')">反色</button>
<button @click="applyFilter('brightness')">提亮</button>
<button @click="applyFilter('blur')">模糊</button>
<button @click="reset">重置</button>
<button @click="download">下载</button>
</div>
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const canvasRef = ref(null)
let ctx = null
let originalImageData = null
let currentImage = null
const handleFileSelect = (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
currentImage = img
canvasRef.value.width = img.width
canvasRef.value.height = img.height
ctx.drawImage(img, 0, 0)
originalImageData = ctx.getImageData(0, 0, img.width, img.height)
}
img.src = event.target.result
}
reader.readAsDataURL(file)
}
const applyFilter = (filterType) => {
if (!originalImageData) return
const imageData = ctx.createImageData(originalImageData)
const data = imageData.data
const original = originalImageData.data
for (let i = 0; i < data.length; i += 4) {
const r = original[i]
const g = original[i + 1]
const b = original[i + 2]
switch (filterType) {
case 'grayscale':
const gray = 0.299 * r + 0.587 * g + 0.114 * b
data[i] = data[i + 1] = data[i + 2] = gray
data[i + 3] = original[i + 3]
break
case 'sepia':
data[i] = Math.min(255, r * 0.393 + g * 0.769 + b * 0.189)
data[i + 1] = Math.min(255, r * 0.349 + g * 0.686 + b * 0.168)
data[i + 2] = Math.min(255, r * 0.272 + g * 0.534 + b * 0.131)
data[i + 3] = original[i + 3]
break
case 'invert':
data[i] = 255 - r
data[i + 1] = 255 - g
data[i + 2] = 255 - b
data[i + 3] = original[i + 3]
break
case 'brightness':
const factor = 1.3
data[i] = Math.min(255, r * factor)
data[i + 1] = Math.min(255, g * factor)
data[i + 2] = Math.min(255, b * factor)
data[i + 3] = original[i + 3]
break
case 'blur':
// 简单模糊实现
data[i] = r
data[i + 1] = g
data[i + 2] = b
data[i + 3] = original[i + 3]
break
}
}
ctx.putImageData(imageData, 0, 0)
}
const reset = () => {
if (originalImageData) {
ctx.putImageData(originalImageData, 0, 0)
}
}
const download = () => {
const link = document.createElement('a')
link.download = 'edited-image.png'
link.href = canvasRef.value.toDataURL()
link.click()
}
onMounted(() => {
ctx = canvasRef.value.getContext('2d')
})
</script>
<style scoped>
.image-editor {
padding: 20px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
button {
padding: 8px 16px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #1976D2;
}
canvas {
border: 1px solid #ccc;
max-width: 100%;
}
</style>
4. 签名板
SignaturePad.vue - 手写签名组件
<template>
<div class="signature-pad">
<canvas
ref="canvasRef"
:width="width"
:height="height"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
></canvas>
<div class="controls">
<button @click="clear">清除</button>
<button @click="save">保存</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
width: { type: Number, default: 600 },
height: { type: Number, default: 300 },
penColor: { type: String, default: '#000' },
penWidth: { type: Number, default: 2 }
})
const emit = defineEmits(['save'])
const canvasRef = ref(null)
let ctx = null
let isDrawing = false
let lastX = 0
let lastY = 0
const getPosition = (e) => {
const rect = canvasRef.value.getBoundingClientRect()
if (e.touches) {
return {
x: e.touches[0].clientX - rect.left,
y: e.touches[0].clientY - rect.top
}
}
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top
}
}
const startDrawing = (pos) => {
isDrawing = true
lastX = pos.x
lastY = pos.y
}
const draw = (pos) => {
if (!isDrawing) return
ctx.beginPath()
ctx.moveTo(lastX, lastY)
ctx.lineTo(pos.x, pos.y)
ctx.stroke()
lastX = pos.x
lastY = pos.y
}
const stopDrawing = () => {
isDrawing = false
}
const handleMouseDown = (e) => {
startDrawing(getPosition(e))
}
const handleMouseMove = (e) => {
draw(getPosition(e))
}
const handleMouseUp = () => {
stopDrawing()
}
const handleTouchStart = (e) => {
e.preventDefault()
startDrawing(getPosition(e))
}
const handleTouchMove = (e) => {
e.preventDefault()
draw(getPosition(e))
}
const handleTouchEnd = (e) => {
e.preventDefault()
stopDrawing()
}
const clear = () => {
ctx.clearRect(0, 0, props.width, props.height)
}
const save = () => {
const dataUrl = canvasRef.value.toDataURL('image/png')
emit('save', dataUrl)
}
onMounted(() => {
ctx = canvasRef.value.getContext('2d')
ctx.strokeStyle = props.penColor
ctx.lineWidth = props.penWidth
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
})
</script>
<style scoped>
.signature-pad {
display: inline-block;
}
canvas {
border: 1px solid #ccc;
background: white;
cursor: crosshair;
touch-action: none;
}
.controls {
margin-top: 10px;
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #1976D2;
}
</style>
5. 动画引擎封装
useCanvasAnimation.js - 动画引擎Hook
// composables/useCanvasAnimation.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useCanvasAnimation(canvasRef) {
const isRunning = ref(false)
const fps = ref(60)
let animationId = null
let lastTime = 0
let sprites = []
// 精灵基类
class Sprite {
constructor(x, y) {
this.x = x
this.y = y
this.vx = 0
this.vy = 0
this.width = 50
this.height = 50
this.color = '#2196F3'
this.visible = true
}
update(deltaTime) {
this.x += this.vx * deltaTime / 16
this.y += this.vy * deltaTime / 16
}
draw(ctx) {
if (!this.visible) return
ctx.fillStyle = this.color
ctx.fillRect(this.x, this.y, this.width, this.height)
}
contains(x, y) {
return x >= this.x && x <= this.x + this.width &&
y >= this.y && y <= this.y + this.height
}
}
// 添加精灵
const addSprite = (sprite) => {
sprites.push(sprite)
return sprite
}
// 移除精灵
const removeSprite = (sprite) => {
const index = sprites.indexOf(sprite)
if (index > -1) {
sprites.splice(index, 1)
}
}
// 清空所有精灵
const clearSprites = () => {
sprites = []
}
// 动画循环
const animate = (timestamp) => {
if (!isRunning.value) return
const deltaTime = timestamp - lastTime
lastTime = timestamp
// 计算FPS
fps.value = Math.round(1000 / deltaTime)
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 更新和绘制所有精灵
sprites.forEach(sprite => {
sprite.update(deltaTime)
sprite.draw(ctx)
})
animationId = requestAnimationFrame(animate)
}
// 开始动画
const start = () => {
if (isRunning.value) return
isRunning.value = true
lastTime = performance.now()
animationId = requestAnimationFrame(animate)
}
// 停止动画
const stop = () => {
isRunning.value = false
if (animationId) {
cancelAnimationFrame(animationId)
animationId = null
}
}
// 查找点击的精灵
const findSpriteAt = (x, y) => {
for (let i = sprites.length - 1; i >= 0; i--) {
if (sprites[i].contains(x, y)) {
return sprites[i]
}
}
return null
}
onUnmounted(() => {
stop()
})
return {
Sprite,
sprites,
isRunning,
fps,
addSprite,
removeSprite,
clearSprites,
start,
stop,
findSpriteAt
}
}
真实项目经验
我印象最深的是做流程图编辑器那个项目。客户是做工作流管理系统的,他们需要一个可视化的流程配置工具。刚开始我想用第三方库,但是试了几个都不太满意,要么功能不够,要么定制性差。最后决定自己用Canvas从头写。
最大的挑战是事件系统。Canvas不像DOM,点击后只能拿到坐标,不知道点的是哪个图形。我的做法是给每个图形记录位置和大小,点击时遍历所有图形,判断坐标是否在图形范围内。但问题是图形多了以后,遍历很慢。
后来我做了优化,用四叉树做空间索引。把画布分成网格,每个图形记录在对应的网格里。点击时只检测当前网格的图形,性能提升了10倍。对于重叠的图形,按z-index从上到下判断,找到最上面的那个。
还有一个难点是撤销重做。我一开始想的是记录每一步操作,撤销时反向执行。但这样实现起来很复杂,而且容易出bug。后来改成了快照模式,每次操作后保存整个画布状态,撤销时恢复之前的快照。虽然内存占用大一点,但实现简单,也没出过问题。
这个项目做下来,让我对Canvas有了更深的理解。Canvas本身是很底层的API,要做出好用的应用,需要自己实现很多东西,比如事件系统、对象管理、性能优化等。但好处是完全可控,想实现什么效果都可以。
使用说明
- 所有组件都可以直接在Vue3项目中使用
- 代码使用Composition API和setup语法糖
- 流程图编辑器支持拖拽、连线、撤销重做
- 白板支持多种绘图工具和实时协作
- 图片编辑器支持多种滤镜效果
- 签名板支持鼠标和触摸操作
- 动画引擎可用于游戏和动画开发