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,结果每次绘制都卡成幻灯片。

注意:离屏 Canvas 的宽高一定要跟显示 Canvas 匹配。不匹配会导致 drawImage 触发缩放,性能损耗可能达到 3-5 倍。

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 之间的中间状态都会被用户看到——这就是闪烁的根源。

我的经验:drawImage 的第三个参数是目标坐标,如果离屏 Canvas 和屏幕 Canvas 尺寸一致,直接用 (0, 0) 就行。我曾经为了做局部更新,只交换部分区域,结果逻辑复杂度翻倍,收益却微乎其微。除非你确定性能瓶颈在 drawImage 本身,否则全量交换是最稳的。

性能测试:别靠感觉,靠数据

双缓冲不是银弹。它本身也有开销——创建离屏 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 层面有瓶颈。

核心结论:双缓冲的收益在于消除闪烁,代价是额外的内存和一次 drawImage 调用。在大多数场景下,收益远大于代价。但如果你的页面已经跑在 60 FPS 且没有闪烁,强行上双缓冲反而可能降低性能。

双缓冲流程图

下面这张图展示了双缓冲的核心流程。我把它画成了 SVG,方便你理解数据流向。

Canvas 双缓冲流程图 离屏 Canvas (内存中预绘制) 复杂绘制操作 (clearRect / fill / draw) drawImage 交换 (一次性推送到屏幕) 屏幕 Canvas (用户可见) 用户感知 (无闪烁 / 完整画面) 关键点:所有绘制操作在离屏 Canvas 完成, 用户只看到 drawImage 交换后的完整画面,从而消除闪烁。

避坑指南

做双缓冲时,有几个坑我踩过,写出来给你参考:

  • 离屏 Canvas 不要频繁重建:创建 Canvas 对象本身有开销,最好在初始化时创建一次,后续复用。我曾经在每帧都新建离屏 Canvas,结果内存暴涨,页面直接崩了。
  • 注意 Canvas 的尺寸变化:如果页面窗口大小变了,离屏 Canvas 也要跟着变。我习惯在 resize 事件里同步更新两个 Canvas 的宽高。
  • 不要忽略 clearRect:每次绘制前一定要清空离屏 Canvas,否则上一帧的内容会残留。我见过有人忘了 clearRect,结果画面越来越花。
  • 测试环境要覆盖低端设备:双缓冲在高端设备上表现很好,但在低端设备上可能因为内存不足而卡顿。我一般会在测试列表里加上 3 年前的手机。
一个小技巧:如果你只需要局部更新,可以只清空和重绘离屏 Canvas 的脏区域,然后用 drawImage 的裁剪参数只交换变化部分。但说实话,这个优化只有在画面变化区域小于 10% 时才有明显效果,否则全量交换更简单。