简历描述模板
深入研究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,动画更加流畅稳定。
技术亮点
- 智能降级系统:实时监控FPS,自动调整渲染质量保证流畅度。
- 增量渲染引擎:只更新变化部分,大幅减少重绘开销。
- 并行计算架构:充分利用多核CPU,Worker池管理复杂计算任务。
- 内存优化方案:对象池、纹理缓存、及时释放避免内存泄漏。
完整技术实现
1. 离屏Canvas优化
useOffscreenCanvas.js - 离屏渲染Hook
// 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
}
}
示例:静态背景优化
<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架构
<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 - 局部重绘优化
// 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
}
}
示例:脏矩形优化
<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 - 帧率控制
// 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
}
}
示例:自适应帧率
<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封装
// 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
// 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图像处理
<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性能优化有了更深的理解。性能优化不是单一的技术,而是要根据实际场景综合运用各种手段。分层、局部重绘、降级、多线程,这些技术都很有用,关键是要找对场景。
使用说明
- 所有优化技术都可以独立使用或组合使用
- 离屏Canvas适合静态内容或复杂图形的缓存
- 分层渲染适合不同更新频率的内容分离
- 脏矩形适合大量对象但只有少量变化的场景
- requestAnimationFrame配合帧率控制保证流畅度
- Web Worker适合耗时的像素操作和复杂计算
- 根据实际场景选择合适的优化策略