3、Canvas 2D上下文优化:离屏Canvas、批量绘制、避免频繁状态切换
说到 Canvas 2D 的性能优化,我这些年踩过的坑可真不少。记得刚入行那会儿,我写了一个实时数据仪表盘,数据一多,页面直接卡成幻灯片。老板站在我身后,那眼神我现在还记得。
后来我才明白,Canvas 2D 的上下文(Context)其实挺脆弱的。你折腾它太频繁,它就会罢工。今天我就把几个最实用的优化手段分享给你。
3.1 离屏Canvas:把预渲染做到极致
什么叫离屏 Canvas?说白了,就是先在后台画好,再一次性贴到屏幕上。
我举个例子。假设你要画 1000 个圆点,每个点位置不同但样式一样。常规做法是循环 1000 次,每次设置颜色、画圆、填充。这 1000 次状态切换,性能损耗很大。
离屏 Canvas 的思路是:先创建一个不显示的 Canvas,在上面画好一个圆点,然后把这个 Canvas 当作图片,用 drawImage() 批量贴到主 Canvas 上。
// 创建离屏 Canvas
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');
offscreen.width = 20;
offscreen.height = 20;
// 在离屏 Canvas 上画一个圆点
offCtx.beginPath();
offCtx.arc(10, 10, 5, 0, Math.PI * 2);
offCtx.fillStyle = '#ff6600';
offCtx.fill();
// 主 Canvas 批量绘制
const ctx = mainCanvas.getContext('2d');
for (let i = 0; i < 1000; i++) {
ctx.drawImage(offscreen, positions[i].x, positions[i].y);
}
3.2 批量绘制:别让 GPU 等你
你有没有想过,为什么游戏引擎里都强调「批处理」?因为 GPU 喜欢一次性吃下大量数据,而不是一口一口喂。
Canvas 2D 也一样。每次调用 fill() 或 stroke(),都会触发一次绘制命令。如果你画一条线就调用一次,那性能肯定上不去。
正确的做法是:先把所有路径攒起来,最后一次性绘制。
// ❌ 低效做法:每条线单独绘制
for (let i = 0; i < 1000; i++) {
ctx.beginPath();
ctx.moveTo(x1[i], y1[i]);
ctx.lineTo(x2[i], y2[i]);
ctx.stroke(); // 每次调用都触发绘制
}
// ✅ 高效做法:批量绘制
ctx.beginPath();
for (let i = 0; i < 1000; i++) {
ctx.moveTo(x1[i], y1[i]);
ctx.lineTo(x2[i], y2[i]);
}
ctx.stroke(); // 只触发一次绘制
3.3 避免频繁状态切换:save/restore 不是免费的
很多新手喜欢在每个绘制操作前后都加上 save() 和 restore(),觉得这样安全。但你知道吗?这两个方法内部会复制和恢复整个上下文状态栈,开销不小。
我曾经接手过一个项目,里面密密麻麻全是 save/restore,光这两个操作就占了总耗时的 30%。
我的建议是:只在真正需要的时候才用。比如你要临时改变变换矩阵或裁剪区域,用完后恢复。如果只是改个颜色或线宽,直接赋值回去就好。
// ❌ 过度使用 save/restore
ctx.save();
ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 50, 50);
ctx.restore();
ctx.save();
ctx.fillStyle = 'blue';
ctx.fillRect(70, 10, 50, 50);
ctx.restore();
// ✅ 直接赋值,避免 save/restore
ctx.fillStyle = 'red';
ctx.fillRect(10, 10, 50, 50);
ctx.fillStyle = 'blue';
ctx.fillRect(70, 10, 50, 50);
3.4 用 requestAnimationFrame 代替 setInterval
这个知识点其实很多人都知道,但我还是要强调一下。setInterval 的问题在于:它不关心浏览器是否在「忙」。哪怕页面被切到后台,它还在拼命执行,白白浪费 CPU。
而 requestAnimationFrame 会跟随浏览器的刷新节奏。页面不可见时,它会自动暂停。而且它的回调时机和屏幕刷新率同步,不会出现撕裂或跳帧。
// ❌ 不推荐
setInterval(() => {
drawDashboard();
}, 16); // 约 60fps
// ✅ 推荐
function animate() {
drawDashboard();
requestAnimationFrame(animate);
}
animate();
3.5 综合优化示例
最后,我把上面几个技巧整合到一个完整的仪表盘绘制函数里。你可以看看它们是怎么协同工作的。
// 离屏 Canvas 预渲染背景
const bgCanvas = document.createElement('canvas');
const bgCtx = bgCanvas.getContext('2d');
// ... 绘制背景网格、刻度线等固定元素
// 主循环
function drawDashboard(timestamp) {
// 清屏
ctx.clearRect(0, 0, width, height);
// 贴背景(离屏 Canvas)
ctx.drawImage(bgCanvas, 0, 0);
// 批量绘制数据点
ctx.beginPath();
for (const point of dataPoints) {
ctx.moveTo(point.x, point.y);
ctx.lineTo(point.x + 1, point.y + 1);
}
ctx.strokeStyle = '#00ff00';
ctx.stroke(); // 一次性绘制所有点
// 下一帧
requestAnimationFrame(drawDashboard);
}
requestAnimationFrame(drawDashboard);
嗯,这套组合拳打下来,你的 Canvas 性能应该能上一个台阶。记住,优化的核心思路就四个字:减少开销。减少绘制命令、减少状态切换、减少不必要的计算。剩下的,交给浏览器去处理就好。