Canvas 性能优化
Canvas 应用的性能直接影响用户体验。在复杂场景下(如游戏、数据可视化、实时图表),不当的绘图方式可能导致帧率下降、卡顿。本节将介绍离屏 Canvas、分层渲染、状态管理优化、减少状态切换和硬件加速等关键技术,帮助你构建高性能的 Canvas 应用。
前置知识
阅读本节前,建议先了解:动画基础
基础概念
Canvas 2D 绑定的绘图操作本质上是 CPU 向 GPU 提交渲染指令的过程。性能瓶颈通常出现在:
- 过多的绘图调用(draw calls)
- 频繁的状态切换(fillStyle、transform 等)
- 大面积的像素操作(getImageData/putImageData)
- 重复绘制不变的静态内容
性能优化金字塔
┌──────────┐
│ 硬件加速 │ ← 最后考虑
├───────────┤
│ 算法优化 │ ← 减少计算量
├────────────┤
│ 缓存策略 │ ← 避免重复计算
├─────────────┤
│ 绘图优化 │ ← 减少 draw calls
├──────────────┤
│ 架构设计 │ ← 合理的分层与渲染策略
└───────────────┘离屏 Canvas
什么是离屏 Canvas
离屏 Canvas(Offscreen Canvas)是不在 DOM 中的 <canvas> 元素。它常用于预渲染静态内容、缓存复杂的绘制结果,然后在动画循环中通过 drawImage() 快速绘制。
基本用法
javascript
// 创建离屏 Canvas
const offscreen = document.createElement('canvas');
offscreen.width = 200;
offscreen.height = 200;
const offCtx = offscreen.getContext('2d');
// 在离屏 Canvas 上绘制复杂内容(只需执行一次)
offCtx.fillStyle = '#4285f4';
offCtx.beginPath();
// ... 复杂的路径绘制 ...
offCtx.fill();
// 在动画循环中直接使用 drawImage(非常快)
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(offscreen, 0, 0); // 快速绘制预渲染结果
requestAnimationFrame(animate);
}实战示例:预渲染复杂图形
javascript
/**
* 预渲染复杂星形图案
*/
function preRenderStar(ctx, radius, color) {
const offscreen = document.createElement('canvas');
const size = radius * 2 + 10;
offscreen.width = size;
offscreen.height = size;
const offCtx = offscreen.getContext('2d');
const cx = size / 2, cy = size / 2;
// 绘制复杂的星形(包含渐变、阴影等)
offCtx.save();
offCtx.shadowColor = 'rgba(0,0,0,0.3)';
offCtx.shadowBlur = 8;
const gradient = offCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
gradient.addColorStop(0, '#ffffff');
gradient.addColorStop(0.5, color);
gradient.addColorStop(1, '#000000');
offCtx.beginPath();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? radius : radius * 0.4;
const angle = (Math.PI / 5) * i - Math.PI / 2;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) offCtx.moveTo(x, y);
else offCtx.lineTo(x, y);
}
offCtx.closePath();
offCtx.fillStyle = gradient;
offCtx.fill();
offCtx.restore();
return offscreen;
}
// 预渲染(只执行一次)
const starImage = preRenderStar(ctx, 30, '#fbbc05');
// 动画中使用
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 直接 drawImage,无需每帧重新计算渐变和阴影
ctx.drawImage(starImage, 100, 100);
ctx.drawImage(starImage, 300, 200);
requestAnimationFrame(animate);
}分层渲染
什么是分层渲染
分层渲染将不同类型的图形绘制到不同的 Canvas 层上,通过 CSS 叠加显示。这样修改某一层时,不需要重新绘制其他层。
实现方式
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>分层渲染</title>
<style>
.canvas-container {
position: relative;
width: 400px;
height: 300px;
border: 1px solid #ccc;
}
.canvas-container canvas {
position: absolute;
top: 0;
left: 0;
}
/* 不同层的 z-index */
#bgLayer { z-index: 1; }
#staticLayer { z-index: 2; }
#dynamicLayer { z-index: 3; }
#uiLayer { z-index: 4; }
</style>
</head>
<body>
<h1>Canvas 分层渲染</h1>
<div class="canvas-container">
<canvas id="bgLayer" width="400" height="300"></canvas>
<canvas id="staticLayer" width="400" height="300"></canvas>
<canvas id="dynamicLayer" width="400" height="300"></canvas>
<canvas id="uiLayer" width="400" height="300"></canvas>
</div>
<script>
// 各层获取上下文
const bgCtx = document.getElementById('bgLayer').getContext('2d');
const staticCtx = document.getElementById('staticLayer').getContext('2d');
const dynamicCtx = document.getElementById('dynamicLayer').getContext('2d');
const uiCtx = document.getElementById('uiLayer').getContext('2d');
// 背景层:只绘制一次
const bgGrad = bgCtx.createLinearGradient(0, 0, 400, 300);
bgGrad.addColorStop(0, '#e3f2fd');
bgGrad.addColorStop(1, '#e8f5e9');
bgCtx.fillStyle = bgGrad;
bgCtx.fillRect(0, 0, 400, 300);
// 静态层:只绘制一次
staticCtx.fillStyle = '#4285f4';
staticCtx.fillRect(50, 200, 100, 60);
staticCtx.fillStyle = '#34a853';
staticCtx.fillRect(250, 200, 100, 60);
// 动态层:每帧更新
let ballX = 0;
function animateDynamic() {
dynamicCtx.clearRect(0, 0, 400, 300);
dynamicCtx.beginPath();
dynamicCtx.arc(ballX, 150, 15, 0, Math.PI * 2);
dynamicCtx.fillStyle = '#ea4335';
dynamicCtx.fill();
ballX = (ballX + 2) % 400;
requestAnimationFrame(animateDynamic);
}
animateDynamic();
// UI 层:只在需要时更新
function updateScore(score) {
uiCtx.clearRect(0, 0, 400, 300);
uiCtx.fillStyle = '#333';
uiCtx.font = 'bold 16px sans-serif';
uiCtx.fillText(`得分: ${score}`, 10, 25);
}
updateScore(0);
</script>
</body>
</html>分层策略
| 层级 | 内容 | 更新频率 |
|---|---|---|
| 背景层 | 天空、地面、背景纹理 | 从不更新 |
| 静态层 | 建筑物、固定元素 | 偶尔更新 |
| 动态层 | 角色、粒子、动态对象 | 每帧更新 |
| UI 层 | HUD、文字、菜单 | 按需更新 |
减少 save/restore
过度使用的问题
javascript
// 不好的做法:过度使用 save/restore
function drawParticles(particles) {
particles.forEach(p => {
ctx.save(); // 每个粒子都 save
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.fillStyle = p.color;
ctx.fillRect(-5, -5, 10, 10);
ctx.restore(); // 每个粒子都 restore
});
}优化方案
javascript
// 好的做法:手动管理状态
function drawParticles(particles) {
// 批量设置相同的属性
ctx.fillStyle = '#4285f4'; // 如果颜色相同
particles.forEach(p => {
// 只有需要 save/restore 时才使用
if (p.needsTransform) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.fillRect(-5, -5, 10, 10);
ctx.restore();
} else {
ctx.fillRect(p.x - 5, p.y - 5, 10, 10);
}
});
}减少状态切换
批量绘制
javascript
// 不好的做法:频繁切换颜色
function drawBad() {
ctx.fillStyle = 'red';
ctx.fillRect(0, 0, 50, 50);
ctx.fillStyle = 'blue';
ctx.fillRect(100, 0, 50, 50);
ctx.fillStyle = 'green';
ctx.fillRect(200, 0, 50, 50);
// ... 更多颜色切换
}
// 好的做法:按颜色分组绘制
function drawGood() {
// 先绘制所有红色
ctx.fillStyle = 'red';
redRects.forEach(r => ctx.fillRect(r.x, r.y, r.w, r.h));
// 再绘制所有蓝色
ctx.fillStyle = 'blue';
blueRects.forEach(r => ctx.fillRect(r.x, r.y, r.w, r.h));
// 再绘制所有绿色
ctx.fillStyle = 'green';
greenRects.forEach(r => ctx.fillRect(r.x, r.y, r.w, r.h));
}Path2D 批量绘制
javascript
// 使用 Path2D 预定义路径
const rectPaths = {
red: new Path2D(),
blue: new Path2D(),
green: new Path2D()
};
// 一次性构建路径
redRects.forEach(r => {
rectPaths.red.rect(r.x, r.y, r.w, r.h);
});
blueRects.forEach(r => {
rectPaths.blue.rect(r.x, r.y, r.w, r.h);
});
// 批量绘制
ctx.fillStyle = 'red';
ctx.fill(rectPaths.red);
ctx.fillStyle = 'blue';
ctx.fill(rectPaths.blue);硬件加速
CSS 硬件加速
css
/* 通过 CSS 触发 GPU 加速层 */
canvas {
will-change: contents;
/* 或 */
transform: translateZ(0);
}OffscreenCanvas(多线程)
OffscreenCanvas 允许在 Web Worker 中进行 Canvas 绘制,不阻塞主线程:
javascript
// 主线程
const canvas = document.getElementById('myCanvas');
const offscreen = canvas.transferControlToOffscreen();
// 传递给 Worker
const worker = new Worker('canvas-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);
// canvas-worker.js
self.onmessage = (e) => {
const canvas = e.data.canvas;
const ctx = canvas.getContext('2d');
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ... 绘制逻辑 ...
requestAnimationFrame(animate);
}
animate();
};OffscreenCanvas 兼容性
javascript
// 检测 OffscreenCanvas 支持
if (canvas.transferControlToOffscreen) {
// 支持,使用 Worker
} else {
// 不支持,回退到主线程渲染
}其他优化技巧
1. 避免频繁创建对象
javascript
// 不好的做法:每帧创建新对象
function animate() {
const point = { x: 0, y: 0 }; // 每帧创建
// ...
}
// 好的做法:复用对象
const reusablePoint = { x: 0, y: 0 };
function animate() {
reusablePoint.x = newX;
reusablePoint.y = newY;
// ...
}2. 使用整数坐标
javascript
// 不好的做法:浮点数坐标
ctx.fillRect(10.5, 20.3, 50.7, 50.1);
// 好的做法:使用整数坐标(避免亚像素渲染)
ctx.fillRect(10, 20, 50, 50);3. 避免在动画中使用阴影
javascript
// 不好的做法:动画中每帧使用阴影
function animate() {
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 10;
ctx.fillRect(x, y, 100, 100);
requestAnimationFrame(animate);
}
// 好的做法:预渲染阴影
// 在离屏 Canvas 上预渲染带阴影的图形
const shadowImage = preRenderWithShadow();
function animate() {
ctx.drawImage(shadowImage, x, y);
requestAnimationFrame(animate);
}4. 大画布处理策略
javascript
// 对于超大画布(如 4K),考虑分块渲染
const TILE_SIZE = 512;
function renderLargeCanvas() {
for (let y = 0; y < totalHeight; y += TILE_SIZE) {
for (let x = 0; x < totalWidth; x += TILE_SIZE) {
renderTile(x, y, TILE_SIZE, TILE_SIZE);
}
}
}性能检测工具
javascript
/**
* Canvas 性能分析器
*/
class CanvasProfiler {
constructor(ctx) {
this.ctx = ctx;
this.metrics = {
frameCount: 0,
drawCalls: 0,
totalDrawTime: 0,
avgDrawTime: 0,
fps: 0
};
this.frameStart = 0;
this.lastFPSUpdate = 0;
this.framesSinceFPSUpdate = 0;
}
beginFrame(timestamp) {
this.frameStart = performance.now();
this.drawCalls = 0;
}
recordDraw(callName, duration) {
this.drawCalls++;
this.totalDrawTime += duration;
}
endFrame(timestamp) {
const frameTime = performance.now() - this.frameStart;
this.metrics.frameCount++;
this.framesSinceFPSUpdate++;
// 每秒更新一次 FPS
if (timestamp - this.lastFPSUpdate >= 1000) {
this.metrics.fps = this.framesSinceFPSUpdate;
this.framesSinceFPSUpdate = 0;
this.lastFPSUpdate = timestamp;
}
this.metrics.avgDrawTime = this.totalDrawTime / Math.max(1, this.drawCalls);
}
getReport() {
return {
...this.metrics,
drawCalls: this.drawCalls
};
}
drawOverlay(x, y) {
const report = this.getReport();
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
this.ctx.fillRect(x, y, 200, 80);
this.ctx.fillStyle = '#0f0';
this.ctx.font = '12px monospace';
this.ctx.fillText(`FPS: ${report.fps}`, x + 10, y + 18);
this.ctx.fillText(`Draw Calls: ${report.drawCalls}`, x + 10, y + 36);
this.ctx.fillText(`Avg Draw: ${report.avgDrawTime.toFixed(2)}ms`, x + 10, y + 54);
this.ctx.fillText(`Total Frames: ${report.frameCount}`, x + 10, y + 72);
}
}性能优化检查清单
| 优化项 | 说明 | 影响 |
|---|---|---|
| 使用离屏 Canvas 预渲染 | 缓存静态或复杂图形 | 高 |
| 分层渲染 | 分离静态和动态内容 | 高 |
| 批量绘制 | 按属性分组,减少状态切换 | 中 |
| 减少 save/restore | 只在必要时使用 | 中 |
| 避免动画中使用阴影 | 预渲染到离屏 Canvas | 高 |
| 使用整数坐标 | 避免亚像素渲染 | 低 |
| 复用对象 | 避免每帧创建对象 | 低 |
| Path2D | 预定义路径对象 | 中 |
| OffscreenCanvas | 多线程渲染 | 高 |
| CSS will-change | 提示浏览器创建 GPU 层 | 低 |
| 限制像素操作 | 减少 getImageData/putImageData | 高 |
| requestAnimationFrame | 使用标准动画 API | 中 |
下一节
继续学习:SVG 基本图形