Canvas 双缓冲实现:离屏 Canvas、drawImage 交换与性能测试
双缓冲这个概念,说白了就是「别让用户看到你画画的过程」。你想想看,如果每次更新页面都直接在屏幕上擦擦画画,用户肯定会看到闪烁或者撕裂。我最早做 Canvas 动画时就被这个问题坑过——画面一闪一闪的,像老式电视机没信号一样。
那 Canvas 的双缓冲怎么做?核心就三个东西:离屏 Canvas、drawImage 交换、还有性能测试。咱们一个一个来拆。
离屏 Canvas:看不见的画布
离屏 Canvas,就是内存里的一块画布。它不在页面上显示,只用来「预绘制」。我习惯叫它「后台画布」或者「buffer」。它的创建方式跟普通 Canvas 一模一样,只是不挂到 DOM 上。
// 创建离屏 Canvas
const offscreen = document.createElement('canvas');
offscreen.width = 800;
offscreen.height = 600;
const offCtx = offscreen.getContext('2d');
// 在离屏画布上绘制内容
offCtx.fillStyle = '#3498db';
offCtx.fillRect(0, 0, 800, 600);
offCtx.font = '24px sans-serif';
offCtx.fillStyle = '#ffffff';
offCtx.fillText('双缓冲内容', 300, 300);
这里有个细节:离屏 Canvas 的宽高必须跟屏幕上展示的 Canvas 一致。不一致的话,drawImage 会做缩放,性能就下来了。我在项目中遇到过有人图省事,离屏画布设了个 100x100,屏幕画布是 1920x1080,结果每次绘制都卡成幻灯片。
drawImage 交换:把后台画布推到前台
绘制流程是这样的:所有复杂的绘图操作都在离屏 Canvas 上完成,等全部画好了,再用 drawImage 一次性把离屏画布的内容「拍」到屏幕 Canvas 上。这个过程就叫「交换」。
// 屏幕 Canvas
const screenCanvas = document.getElementById('screen');
const screenCtx = screenCanvas.getContext('2d');
// 动画循环
function render() {
// 1. 清空离屏画布
offCtx.clearRect(0, 0, offscreen.width, offscreen.height);
// 2. 在离屏画布上绘制复杂内容
drawComplexScene(offCtx);
// 3. 一次性交换到屏幕
screenCtx.clearRect(0, 0, screenCanvas.width, screenCanvas.height);
screenCtx.drawImage(offscreen, 0, 0);
requestAnimationFrame(render);
}
为什么这样能避免闪烁?因为屏幕 Canvas 只做一次更新,用户看到的永远是「完整的画面」。而直接在屏幕 Canvas 上绘制时,每次 clearRect 和 fill 之间的中间状态都会被用户看到——这就是闪烁的根源。
性能测试:别靠感觉,靠数据
双缓冲不是银弹。它本身也有开销——创建离屏 Canvas 需要内存,drawImage 需要时间。所以必须做性能测试。我常用的工具是 performance.now() 和 Chrome 的 Performance 面板。
帧率测试
let frameCount = 0;
let lastTime = performance.now();
function measureFPS() {
frameCount++;
const now = performance.now();
if (now - lastTime >= 1000) {
console.log(`FPS: ${frameCount}`);
frameCount = 0;
lastTime = now;
}
requestAnimationFrame(measureFPS);
}
这个代码每秒钟统计一次帧数。60 FPS 是目标,低于 30 FPS 就说明有问题。我一般会在双缓冲开启和关闭两种状态下各跑 10 秒,取平均值对比。
内存开销
离屏 Canvas 会占用 GPU 内存。一个 1920x1080 的 Canvas,每个像素 4 字节(RGBA),算下来就是 1920 * 1080 * 4 ≈ 8.3 MB。如果你创建了多个离屏 Canvas,内存会线性增长。
| Canvas 尺寸 | 像素数 | 内存占用(约) |
|---|---|---|
| 800 x 600 | 480,000 | 1.83 MB |
| 1920 x 1080 | 2,073,600 | 7.91 MB |
| 3840 x 2160 | 8,294,400 | 31.64 MB |
嗯,这里要注意:移动端设备的内存更紧张。我在做 H5 页面时,曾经因为离屏 Canvas 开得太多,导致低端手机直接白屏。后来改成按需创建、用完即销毁,才解决问题。
drawImage 耗时
drawImage 本身不是免费的。你可以用 performance.now() 精确测量它的耗时:
const t0 = performance.now();
screenCtx.drawImage(offscreen, 0, 0);
const t1 = performance.now();
console.log(`drawImage 耗时: ${t1 - t0} ms`);
一般来说,1920x1080 的 Canvas 全量 drawImage 耗时在 1-3 ms 之间。如果超过 5 ms,就要检查是不是触发了缩放,或者 GPU 层面有瓶颈。
双缓冲流程图
下面这张图展示了双缓冲的核心流程。我把它画成了 SVG,方便你理解数据流向。
避坑指南
做双缓冲时,有几个坑我踩过,写出来给你参考:
- 离屏 Canvas 不要频繁重建:创建 Canvas 对象本身有开销,最好在初始化时创建一次,后续复用。我曾经在每帧都新建离屏 Canvas,结果内存暴涨,页面直接崩了。
- 注意 Canvas 的尺寸变化:如果页面窗口大小变了,离屏 Canvas 也要跟着变。我习惯在 resize 事件里同步更新两个 Canvas 的宽高。
- 不要忽略 clearRect:每次绘制前一定要清空离屏 Canvas,否则上一帧的内容会残留。我见过有人忘了 clearRect,结果画面越来越花。
- 测试环境要覆盖低端设备:双缓冲在高端设备上表现很好,但在低端设备上可能因为内存不足而卡顿。我一般会在测试列表里加上 3 年前的手机。