返回笔记首页

11.2 Canvas 性能优化技巧

主题配置

简历描述模板

深入研究Canvas性能优化技术,通过离屏渲染、分层绘制、脏矩形检测等多项优化手段,将复杂场景的渲染性能提升了300%。实现了基于Web Worker的多线程计算方案,将图像处理速度提升5倍。优化后的Canvas应用能够流畅处理10000+对象的实时渲染,帧率稳定在60fps。在大屏可视化项目中应用这些优化技术后,长时间运行的稳定性大幅提升,CPU占用率从80%降至35%。

SOP 标准回答

面试官:Canvas性能优化有哪些关键技术?你在项目中是怎么应用的?

Canvas性能优化是个很重要的话题,特别是在处理大量对象或者复杂动画的时候。我在项目中用过几个关键技术。

首先是离屏Canvas。这个技术的核心思想是用一个不可见的Canvas预先绘制内容,然后一次性绘制到主Canvas上。比如我们做大屏可视化的时候,背景有很多装饰元素,这些元素是静态的,没必要每帧都重绘。我们就用离屏Canvas把背景画好,每次只需要drawImage一下就行了。这样性能提升很明显,背景复杂度再高也不影响帧率。

第二个是分层渲染。把Canvas分成多个图层,不同图层画不同的内容。比如底层画背景,中间层画数据,顶层画UI交互元素。更新某一层时,其他层不用动。我们在项目里用了三个Canvas叠加,背景层基本不更新,数据层每秒更新一次,UI层实时响应用户操作。这样就避免了全局重绘,性能提升了3倍左右。

第三个是脏矩形检测。不是整个Canvas都需要重绘,只重绘变化的区域就够了。我们记录哪些对象变化了,计算出这些对象的包围盒,然后只清除和重绘这些区域。这个技术在对象数量多但变化少的场景下特别有效。

第四个是requestAnimationFrame优化。这个API会在浏览器重绘之前调用,能保证动画的流畅度。我们把所有的渲染逻辑都放在requestAnimationFrame里,而不是用setInterval。还做了帧率控制,如果检测到帧率低于30fps,会自动降低渲染质量,比如减少粒子数量,简化动画效果。

第五个是Web Worker多线程。Canvas的像素操作很耗时,比如图像滤镜、大数据量计算。我们把这些计算放到Worker里,主线程只负责渲染。比如处理一张10MB的图片,用Worker能快5倍。Worker计算完了把结果传回主线程,用postMessage通信。

还有一些细节优化,比如对象池避免频繁创建销毁对象,用TypedArray代替普通数组提升计算速度,批量操作减少API调用次数等等。

这些优化技术综合使用后,我们的Canvas应用能流畅处理上万个对象,帧率稳定在60fps,长时间运行也不会卡顿。

难点与亮点分析

难点1:大量对象的渲染优化

问题:Canvas中有10000+个对象时,每帧全量重绘会导致严重掉帧,帧率只有10fps左右。

解决方案:采用多层优化策略。一是空间索引(四叉树),只渲染视口内的对象。二是对象池复用,避免频繁GC。三是批量绘制,相同样式的对象合并绘制,减少状态切换。四是LOD(细节层次),根据对象大小和距离调整绘制精度,远处的对象简化绘制。五是按需更新,记录对象的dirty标记,只重绘变化的对象。

效果:10000个对象的场景,帧率从10fps提升到58fps,CPU占用从90%降到40%。

难点2:像素操作的性能瓶颈

问题:处理大图的像素操作(如滤镜)非常慢,会阻塞主线程导致界面卡死。

解决方案:Web Worker多线程方案。把图片分成多个区块,每个Worker处理一块。主线程负责分发任务和合并结果。使用Transferable Objects传输ImageData,避免数据复制。对于实时滤镜,做了降采样预览,先处理低分辨率版本给用户预览,确认后再处理完整图片。

效果:4000x3000的图片滤镜处理时间从8秒降到1.5秒,界面不再卡顿。

难点3:复杂动画的流畅度

问题:多个复杂动画同时运行时,帧率不稳定,有明显的卡顿和掉帧。

解决方案:实现了动画调度系统。根据设备性能动态调整动画复杂度,低端设备自动降低粒子数量、简化特效。使用requestIdleCallback处理非关键动画,在主线程空闲时执行。对于固定轨迹的动画,预计算关键帧,运行时插值即可。还做了分时渲染,把一帧的工作分散到多帧完成。

效果:复杂动画场景帧率从35fps提升到55fps,动画更加流畅稳定。

技术亮点

  1. 智能降级系统:实时监控FPS,自动调整渲染质量保证流畅度。
  2. 增量渲染引擎:只更新变化部分,大幅减少重绘开销。
  3. 并行计算架构:充分利用多核CPU,Worker池管理复杂计算任务。
  4. 内存优化方案:对象池、纹理缓存、及时释放避免内存泄漏。

完整技术实现

1. 离屏Canvas优化

useOffscreenCanvas.js - 离屏渲染Hook

javascript
// composables/useOffscreenCanvas.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useOffscreenCanvas(canvasRef) {
  const offscreenCanvas = ref(null)
  const offscreenCtx = ref(null)
  const mainCtx = ref(null)

  // 初始化
  const init = (width, height) => {
    if (!canvasRef.value) return

    mainCtx.value = canvasRef.value.getContext('2d')

    // 创建离屏Canvas
    offscreenCanvas.value = document.createElement('canvas')
    offscreenCanvas.value.width = width || canvasRef.value.width
    offscreenCanvas.value.height = height || canvasRef.value.height
    offscreenCtx.value = offscreenCanvas.value.getContext('2d')
  }

  // 在离屏Canvas上绘制
  const drawOffscreen = (drawFn) => {
    if (!offscreenCtx.value) return

    offscreenCtx.value.clearRect(
      0, 0,
      offscreenCanvas.value.width,
      offscreenCanvas.value.height
    )

    drawFn(offscreenCtx.value, offscreenCanvas.value)
  }

  // 提交到主Canvas
  const commit = (x = 0, y = 0, width, height) => {
    if (!mainCtx.value || !offscreenCanvas.value) return

    const w = width || offscreenCanvas.value.width
    const h = height || offscreenCanvas.value.height

    mainCtx.value.drawImage(offscreenCanvas.value, x, y, w, h)
  }

  // 完整渲染流程
  const render = (drawFn, x, y, width, height) => {
    drawOffscreen(drawFn)
    commit(x, y, width, height)
  }

  onMounted(() => {
    if (canvasRef.value) {
      init(canvasRef.value.width, canvasRef.value.height)
    }
  })

  onUnmounted(() => {
    offscreenCanvas.value = null
    offscreenCtx.value = null
    mainCtx.value = null
  })

  return {
    offscreenCanvas,
    offscreenCtx,
    mainCtx,
    init,
    drawOffscreen,
    commit,
    render
  }
}
示例:静态背景优化
vue
<template>
  <canvas ref="canvasRef" :width="800" :height="600"></canvas>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useOffscreenCanvas } from './useOffscreenCanvas'

const canvasRef = ref(null)
const { render, mainCtx } = useOffscreenCanvas(canvasRef)

// 绘制复杂背景(只需要绘制一次)
const drawBackground = (ctx, canvas) => {
  // 绘制渐变背景
  const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
  gradient.addColorStop(0, '#1a1a2e')
  gradient.addColorStop(1, '#16213e')
  ctx.fillStyle = gradient
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  // 绘制装饰网格
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
  ctx.lineWidth = 1
  for (let i = 0; i < canvas.width; i += 50) {
    ctx.beginPath()
    ctx.moveTo(i, 0)
    ctx.lineTo(i, canvas.height)
    ctx.stroke()
  }
  for (let i = 0; i < canvas.height; i += 50) {
    ctx.beginPath()
    ctx.moveTo(0, i)
    ctx.lineTo(canvas.width, i)
    ctx.stroke()
  }
}

// 绘制动态内容
const drawDynamic = () => {
  if (!mainCtx.value) return

  // 先绘制背景(从离屏Canvas)
  render(drawBackground)

  // 再绘制动态内容
  const ctx = mainCtx.value
  const time = Date.now() / 1000
  const x = 400 + Math.cos(time) * 200
  const y = 300 + Math.sin(time) * 150

  ctx.fillStyle = '#00d4ff'
  ctx.beginPath()
  ctx.arc(x, y, 30, 0, Math.PI * 2)
  ctx.fill()

  requestAnimationFrame(drawDynamic)
}

onMounted(() => {
  drawDynamic()
})
</script>

2. 分层渲染

LayeredCanvas.vue - 多层Canvas架构

vue
<template>
  <div class="layered-canvas" ref="containerRef">
    <canvas ref="backgroundRef" class="layer"></canvas>
    <canvas ref="contentRef" class="layer"></canvas>
    <canvas ref="uiRef" class="layer"></canvas>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const containerRef = ref(null)
const backgroundRef = ref(null)
const contentRef = ref(null)
const uiRef = ref(null)

const width = 1200
const height = 800

let bgCtx = null
let contentCtx = null
let uiCtx = null

// 初始化各层
const initLayers = () => {
  // 背景层
  backgroundRef.value.width = width
  backgroundRef.value.height = height
  bgCtx = backgroundRef.value.getContext('2d')

  // 内容层
  contentRef.value.width = width
  contentRef.value.height = height
  contentCtx = contentRef.value.getContext('2d')

  // UI层
  uiRef.value.width = width
  uiRef.value.height = height
  uiCtx = uiRef.value.getContext('2d')
}

// 绘制背景层(只绘制一次)
const drawBackground = () => {
  const gradient = bgCtx.createLinearGradient(0, 0, width, height)
  gradient.addColorStop(0, '#0f0f23')
  gradient.addColorStop(1, '#1a1a3e')
  bgCtx.fillStyle = gradient
  bgCtx.fillRect(0, 0, width, height)

  // 绘制装饰图案
  bgCtx.strokeStyle = 'rgba(0, 212, 255, 0.1)'
  bgCtx.lineWidth = 2
  for (let i = 0; i < 20; i++) {
    bgCtx.beginPath()
    bgCtx.arc(
      Math.random() * width,
      Math.random() * height,
      Math.random() * 100 + 50,
      0,
      Math.PI * 2
    )
    bgCtx.stroke()
  }
}

// 数据对象
const dataPoints = []
for (let i = 0; i < 100; i++) {
  dataPoints.push({
    x: Math.random() * width,
    y: Math.random() * height,
    vx: (Math.random() - 0.5) * 2,
    vy: (Math.random() - 0.5) * 2,
    radius: Math.random() * 5 + 2,
    color: `hsl(${Math.random() * 360}, 70%, 60%)`
  })
}

// 更新内容层(定期更新)
const updateContent = () => {
  contentCtx.clearRect(0, 0, width, height)

  // 更新和绘制数据点
  dataPoints.forEach(point => {
    point.x += point.vx
    point.y += point.vy

    if (point.x < 0 || point.x > width) point.vx *= -1
    if (point.y < 0 || point.y > height) point.vy *= -1

    contentCtx.fillStyle = point.color
    contentCtx.beginPath()
    contentCtx.arc(point.x, point.y, point.radius, 0, Math.PI * 2)
    contentCtx.fill()
  })
}

// 鼠标位置
let mouseX = 0
let mouseY = 0

// 更新UI层(实时更新)
const updateUI = () => {
  uiCtx.clearRect(0, 0, width, height)

  // 绘制鼠标光标效果
  const gradient = uiCtx.createRadialGradient(mouseX, mouseY, 0, mouseX, mouseY, 100)
  gradient.addColorStop(0, 'rgba(0, 212, 255, 0.3)')
  gradient.addColorStop(1, 'rgba(0, 212, 255, 0)')
  uiCtx.fillStyle = gradient
  uiCtx.fillRect(0, 0, width, height)

  // 绘制十字准线
  uiCtx.strokeStyle = 'rgba(0, 212, 255, 0.5)'
  uiCtx.lineWidth = 1
  uiCtx.beginPath()
  uiCtx.moveTo(mouseX, 0)
  uiCtx.lineTo(mouseX, height)
  uiCtx.moveTo(0, mouseY)
  uiCtx.lineTo(width, mouseY)
  uiCtx.stroke()
}

// 动画循环
const animate = () => {
  updateContent()
  updateUI()
  requestAnimationFrame(animate)
}

const handleMouseMove = (e) => {
  const rect = containerRef.value.getBoundingClientRect()
  mouseX = e.clientX - rect.left
  mouseY = e.clientY - rect.top
}

onMounted(() => {
  initLayers()
  drawBackground()
  animate()

  containerRef.value.addEventListener('mousemove', handleMouseMove)
})
</script>

<style scoped>
.layered-canvas {
  position: relative;
  width: 1200px;
  height: 800px;
}

.layer {
  position: absolute;
  top: 0;
  left: 0;
}
</style>

3. 脏矩形检测

useDirtyRect.js - 局部重绘优化

javascript
// composables/useDirtyRect.js
import { ref } from 'vue'

export function useDirtyRect() {
  const dirtyRects = ref([])

  // 添加脏区域
  const addDirtyRect = (x, y, width, height) => {
    dirtyRects.value.push({ x, y, width, height })
  }

  // 合并相邻的脏区域
  const mergeDirtyRects = () => {
    if (dirtyRects.value.length < 2) return

    const merged = []
    let current = dirtyRects.value[0]

    for (let i = 1; i < dirtyRects.value.length; i++) {
      const rect = dirtyRects.value[i]

      // 检查是否相邻或重叠
      if (
        current.x <= rect.x + rect.width &&
        current.x + current.width >= rect.x &&
        current.y <= rect.y + rect.height &&
        current.y + current.height >= rect.y
      ) {
        // 合并
        const minX = Math.min(current.x, rect.x)
        const minY = Math.min(current.y, rect.y)
        const maxX = Math.max(current.x + current.width, rect.x + rect.width)
        const maxY = Math.max(current.y + current.height, rect.y + rect.height)

        current = {
          x: minX,
          y: minY,
          width: maxX - minX,
          height: maxY - minY
        }
      } else {
        merged.push(current)
        current = rect
      }
    }

    merged.push(current)
    dirtyRects.value = merged
  }

  // 清除并重绘脏区域
  const redrawDirtyRects = (ctx, canvas, drawFn) => {
    if (dirtyRects.value.length === 0) return

    mergeDirtyRects()

    dirtyRects.value.forEach(rect => {
      // 扩展一点边界避免边缘问题
      const padding = 2
      const x = Math.max(0, rect.x - padding)
      const y = Math.max(0, rect.y - padding)
      const width = Math.min(canvas.width - x, rect.width + padding * 2)
      const height = Math.min(canvas.height - y, rect.height + padding * 2)

      // 清除脏区域
      ctx.clearRect(x, y, width, height)

      // 保存上下文
      ctx.save()
      ctx.beginPath()
      ctx.rect(x, y, width, height)
      ctx.clip()

      // 重绘该区域
      drawFn(ctx, { x, y, width, height })

      ctx.restore()
    })

    // 清空脏区域列表
    dirtyRects.value = []
  }

  // 清空所有脏区域
  const clearDirtyRects = () => {
    dirtyRects.value = []
  }

  return {
    dirtyRects,
    addDirtyRect,
    mergeDirtyRects,
    redrawDirtyRects,
    clearDirtyRects
  }
}
示例:脏矩形优化
vue
<template>
  <div>
    <canvas ref="canvasRef" :width="800" :height="600" @click="addBox"></canvas>
    <div>点击添加方块,只会重绘变化区域</div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useDirtyRect } from './useDirtyRect'

const canvasRef = ref(null)
const { addDirtyRect, redrawDirtyRects } = useDirtyRect()

let ctx = null
const boxes = []

class Box {
  constructor(x, y) {
    this.x = x
    this.y = y
    this.width = 50
    this.height = 50
    this.color = `hsl(${Math.random() * 360}, 70%, 60%)`
    this.vx = (Math.random() - 0.5) * 3
    this.vy = (Math.random() - 0.5) * 3
  }

  update() {
    // 记录旧位置(脏区域)
    addDirtyRect(this.x, this.y, this.width, this.height)

    this.x += this.vx
    this.y += this.vy

    if (this.x < 0 || this.x + this.width > 800) this.vx *= -1
    if (this.y < 0 || this.y + this.height > 600) this.vy *= -1

    // 记录新位置(脏区域)
    addDirtyRect(this.x, this.y, this.width, this.height)
  }

  draw(ctx) {
    ctx.fillStyle = this.color
    ctx.fillRect(this.x, this.y, this.width, this.height)
  }
}

const addBox = (e) => {
  const rect = canvasRef.value.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  boxes.push(new Box(x - 25, y - 25))
}

const drawAll = (ctx, dirtyRect) => {
  // 只绘制在脏区域内的方块
  boxes.forEach(box => {
    if (dirtyRect) {
      // 检查方块是否与脏区域相交
      if (
        box.x < dirtyRect.x + dirtyRect.width &&
        box.x + box.width > dirtyRect.x &&
        box.y < dirtyRect.y + dirtyRect.height &&
        box.y + box.height > dirtyRect.y
      ) {
        box.draw(ctx)
      }
    } else {
      box.draw(ctx)
    }
  })
}

const animate = () => {
  boxes.forEach(box => box.update())
  redrawDirtyRects(ctx, canvasRef.value, drawAll)
  requestAnimationFrame(animate)
}

onMounted(() => {
  ctx = canvasRef.value.getContext('2d')

  // 初始化背景
  ctx.fillStyle = '#f0f0f0'
  ctx.fillRect(0, 0, 800, 600)

  animate()
})
</script>

<style scoped>
canvas {
  border: 1px solid #ccc;
  cursor: pointer;
}
</style>

4. requestAnimationFrame优化

useAnimationFrame.js - 帧率控制

javascript
// composables/useAnimationFrame.js
import { ref, onUnmounted } from 'vue'

export function useAnimationFrame(callback, targetFps = 60) {
  const isRunning = ref(false)
  const currentFps = ref(0)

  let animationId = null
  let lastTime = 0
  let frameCount = 0
  let fpsTime = 0
  const frameInterval = 1000 / targetFps

  const animate = (timestamp) => {
    if (!isRunning.value) return

    // 帧率限制
    const elapsed = timestamp - lastTime
    if (elapsed < frameInterval) {
      animationId = requestAnimationFrame(animate)
      return
    }

    lastTime = timestamp - (elapsed % frameInterval)

    // 计算FPS
    frameCount++
    fpsTime += elapsed
    if (fpsTime >= 1000) {
      currentFps.value = Math.round((frameCount * 1000) / fpsTime)
      frameCount = 0
      fpsTime = 0
    }

    // 执行回调
    callback(timestamp, elapsed)

    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
    }
  }

  onUnmounted(() => {
    stop()
  })

  return {
    isRunning,
    currentFps,
    start,
    stop
  }
}
示例:自适应帧率
vue
<template>
  <div>
    <canvas ref="canvasRef" :width="800" :height="600"></canvas>
    <div class="stats">
      <div>FPS: {{ currentFps }}</div>
      <div>粒子数: {{ particleCount }}</div>
      <div>质量: {{ quality }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useAnimationFrame } from './useAnimationFrame'

const canvasRef = ref(null)
const particleCount = ref(1000)
const quality = ref('高')

let ctx = null
let particles = []

const { currentFps, start } = useAnimationFrame((timestamp, elapsed) => {
  // 根据FPS自动调整质量
  if (currentFps.value < 30 && particleCount.value > 100) {
    particleCount.value -= 100
    quality.value = '低'
    initParticles()
  } else if (currentFps.value > 55 && particleCount.value < 2000) {
    particleCount.value += 100
    quality.value = '高'
    initParticles()
  } else if (currentFps.value >= 30 && currentFps.value <= 55) {
    quality.value = '中'
  }

  update(elapsed)
  draw()
}, 60)

const initParticles = () => {
  particles = []
  for (let i = 0; i < particleCount.value; i++) {
    particles.push({
      x: Math.random() * 800,
      y: Math.random() * 600,
      vx: (Math.random() - 0.5) * 2,
      vy: (Math.random() - 0.5) * 2,
      radius: Math.random() * 3 + 1,
      color: `hsl(${Math.random() * 360}, 70%, 60%)`
    })
  }
}

const update = (elapsed) => {
  const speed = elapsed / 16
  particles.forEach(p => {
    p.x += p.vx * speed
    p.y += p.vy * speed
    if (p.x < 0 || p.x > 800) p.vx *= -1
    if (p.y < 0 || p.y > 600) p.vy *= -1
  })
}

const draw = () => {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'
  ctx.fillRect(0, 0, 800, 600)

  particles.forEach(p => {
    ctx.fillStyle = p.color
    ctx.beginPath()
    ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2)
    ctx.fill()
  })
}

onMounted(() => {
  ctx = canvasRef.value.getContext('2d')
  initParticles()
  start()
})
</script>

<style scoped>
.stats {
  margin-top: 10px;
  font-family: monospace;
}

canvas {
  border: 1px solid #ccc;
  background: #000;
}
</style>

5. Worker多线程

useWorker.js - Web Worker封装

javascript
// composables/useWorker.js
import { ref } from 'vue'

export function useWorker(workerScript) {
  const isProcessing = ref(false)
  const progress = ref(0)
  const result = ref(null)
  const error = ref(null)

  let worker = null

  // 创建Worker
  const createWorker = () => {
    // 将脚本转换为Blob URL
    const blob = new Blob([workerScript], { type: 'application/javascript' })
    const workerUrl = URL.createObjectURL(blob)
    worker = new Worker(workerUrl)

    worker.onmessage = (e) => {
      const { type, data } = e.data

      if (type === 'progress') {
        progress.value = data
      } else if (type === 'result') {
        result.value = data
        isProcessing.value = false
      } else if (type === 'error') {
        error.value = data
        isProcessing.value = false
      }
    }

    worker.onerror = (e) => {
      error.value = e.message
      isProcessing.value = false
    }
  }

  // 执行任务
  const execute = (data) => {
    if (!worker) createWorker()

    isProcessing.value = true
    progress.value = 0
    result.value = null
    error.value = null

    worker.postMessage(data)
  }

  // 终止Worker
  const terminate = () => {
    if (worker) {
      worker.terminate()
      worker = null
    }
  }

  return {
    isProcessing,
    progress,
    result,
    error,
    execute,
    terminate
  }
}
Worker脚本示例 - imageProcessor.worker.js
javascript
// workers/imageProcessor.worker.js
const workerScript = `
// 图像滤镜处理
self.onmessage = function(e) {
  const { imageData, filter } = e.data
  const data = imageData.data
  const length = data.length

  for (let i = 0; i < length; i += 4) {
    const r = data[i]
    const g = data[i + 1]
    const b = data[i + 2]

    switch (filter) {
      case 'grayscale':
        const gray = 0.299 * r + 0.587 * g + 0.114 * b
        data[i] = data[i + 1] = data[i + 2] = gray
        break

      case 'invert':
        data[i] = 255 - r
        data[i + 1] = 255 - g
        data[i + 2] = 255 - b
        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)
        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)
        break
    }

    // 发送进度
    if (i % 10000 === 0) {
      self.postMessage({
        type: 'progress',
        data: (i / length) * 100
      })
    }
  }

  // 发送结果
  self.postMessage({
    type: 'result',
    data: imageData
  }, [imageData.data.buffer])
}
`

export default workerScript
示例:Worker图像处理
vue
<template>
  <div class="image-processor">
    <input type="file" @change="handleFileSelect" accept="image/*" />
    <div class="filters">
      <button @click="applyFilter('grayscale')" :disabled="isProcessing">灰度</button>
      <button @click="applyFilter('invert')" :disabled="isProcessing">反色</button>
      <button @click="applyFilter('sepia')" :disabled="isProcessing">怀旧</button>
      <button @click="applyFilter('brightness')" :disabled="isProcessing">提亮</button>
    </div>
    <div v-if="isProcessing" class="progress">
      处理中: {{ progress.toFixed(1) }}%
    </div>
    <canvas ref="canvasRef"></canvas>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useWorker } from './useWorker'
import workerScript from './imageProcessor.worker'

const canvasRef = ref(null)
const { isProcessing, progress, result, execute } = useWorker(workerScript)

let ctx = null
let originalImageData = 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 = () => {
      canvasRef.value.width = img.width
      canvasRef.value.height = img.height
      ctx = canvasRef.value.getContext('2d')
      ctx.drawImage(img, 0, 0)
      originalImageData = ctx.getImageData(0, 0, img.width, img.height)
    }
    img.src = event.target.result
  }
  reader.readAsDataURL(file)
}

const applyFilter = (filter) => {
  if (!originalImageData) return

  // 复制ImageData,因为Worker会转移所有权
  const imageData = new ImageData(
    new Uint8ClampedArray(originalImageData.data),
    originalImageData.width,
    originalImageData.height
  )

  execute({ imageData, filter })
}

watch(result, (newResult) => {
  if (newResult && ctx) {
    ctx.putImageData(newResult, 0, 0)
  }
})
</script>

<style scoped>
.image-processor {
  padding: 20px;
}

.filters {
  margin: 20px 0;
  display: flex;
  gap: 10px;
}

button {
  padding: 8px 16px;
  background: #2196F3;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.progress {
  margin: 10px 0;
  color: #2196F3;
  font-weight: bold;
}

canvas {
  border: 1px solid #ccc;
  max-width: 100%;
}
</style>

真实项目经验

我印象很深的是优化一个数据大屏项目的性能。这个大屏要展示几千个实时数据点,还有粒子动画背景。刚开始运行的时候,帧率只有15fps左右,很卡。

我第一步做的是分层渲染。把Canvas分成三层:背景层、数据层、UI层。背景那些装饰性的东西是静态的,画一次就够了。数据层每秒更新一次,因为数据刷新频率就是1秒。UI层跟随鼠标实时更新。这样一改,帧率就上去了,到了40fps左右。

但还是不够流畅,我又做了脏矩形优化。因为大部分数据点其实没怎么变,只有少数在更新。我记录每个变化的数据点的位置,只重绘这些区域。这个优化效果很明显,帧率直接上了55fps。

粒子动画还是有点影响性能。我做了动态降级,实时监控FPS,如果低于30fps就自动减少粒子数量。高端设备能跑2000个粒子,低端设备自动降到500个。用户基本感觉不到差别,但流畅度有保证。

还有一个问题是图片处理。大屏上有个功能是上传图片添加滤镜。用户上传的图片都很大,处理起来很慢,会卡住界面。我改成了Worker方案,把像素计算放到后台线程。主线程只负责显示进度条和最终结果。这样界面就不卡了,用户体验好很多。

这次优化下来,我对Canvas性能优化有了更深的理解。性能优化不是单一的技术,而是要根据实际场景综合运用各种手段。分层、局部重绘、降级、多线程,这些技术都很有用,关键是要找对场景。

使用说明

  1. 所有优化技术都可以独立使用或组合使用
  2. 离屏Canvas适合静态内容或复杂图形的缓存
  3. 分层渲染适合不同更新频率的内容分离
  4. 脏矩形适合大量对象但只有少量变化的场景
  5. requestAnimationFrame配合帧率控制保证流畅度
  6. Web Worker适合耗时的像素操作和复杂计算
  7. 根据实际场景选择合适的优化策略