返回笔记首页

11.1 Canvas 高级应用

主题配置

简历描述模板

主导开发了基于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%。

技术亮点

  1. 分层渲染架构:静态层、动态层、UI层分离,减少不必要的重绘。
  2. 操作历史管理:命令模式实现撤销重做,支持100步历史记录。
  3. 智能吸附算法:拖拽时自动对齐网格、参考线、其他对象。
  4. 实时协作引擎:OT算法保证多人编辑的一致性。

完整技术实现

1. 流程图编辑器

FlowEditor.vue - 可拖拽的流程图编辑器

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 - 多人协作白板

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 - 图片滤镜和裁剪

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 - 手写签名组件

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

javascript
// 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,要做出好用的应用,需要自己实现很多东西,比如事件系统、对象管理、性能优化等。但好处是完全可控,想实现什么效果都可以。

使用说明

  1. 所有组件都可以直接在Vue3项目中使用
  2. 代码使用Composition API和setup语法糖
  3. 流程图编辑器支持拖拽、连线、撤销重做
  4. 白板支持多种绘图工具和实时协作
  5. 图片编辑器支持多种滤镜效果
  6. 签名板支持鼠标和触摸操作
  7. 动画引擎可用于游戏和动画开发