4、Canvas 2D性能:离屏渲染技术,脏矩形刷新策略,对象池与内存管理

说到 Canvas 2D 的性能优化,我其实踩过不少坑。早些年做一款数据可视化大屏,画布上要同时渲染上千个节点,结果帧率直接掉到个位数。用户拖拽缩放时,画面就像幻灯片一样。嗯,那会儿我才真正意识到——Canvas 不是随便画画就行的。

今天咱们就聊三个核心优化手段:离屏渲染脏矩形刷新对象池与内存管理。这三板斧用好了,你的 Canvas 应用性能能翻好几倍。

4.1 离屏渲染技术

离屏渲染,说白了就是「先画好,再贴上去」。你想想看,如果每次更新画面都要重新绘制所有元素,那 CPU 和 GPU 得多累?

我个人习惯的做法是:把那些不变或变化很少的图形,提前绘制到一个隐藏的 Canvas 上。需要显示时,直接用 drawImage() 把这个离屏 Canvas 的内容「拍」到主画布上。

核心思路:用空间换时间。多开一个 Canvas 对象,但省掉了大量重复的绘制指令。

来看一段代码:

// 创建离屏 Canvas
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');
offscreen.width = 800;
offscreen.height = 600;

// 一次性绘制静态背景
function drawBackground() {
  offCtx.fillStyle = '#f0f0f0';
  offCtx.fillRect(0, 0, 800, 600);
  // 绘制网格、坐标轴等静态元素
  for (let i = 0; i < 20; i++) {
    offCtx.strokeStyle = '#ddd';
    offCtx.strokeRect(i * 40, 0, 40, 600);
  }
}

// 主循环中只需绘制动态内容
function render() {
  ctx.clearRect(0, 0, 800, 600);
  ctx.drawImage(offscreen, 0, 0); // 贴背景
  // 然后只画动态元素
  drawDynamicElements(ctx);
}

我在项目中遇到过一种情况:一个地图应用,底图有几百条道路和建筑物轮廓。每次缩放都要重绘全部,卡得不行。用了离屏渲染后,把底图缓存起来,缩放时只重新计算变换矩阵,帧率从 12fps 直接飙到 55fps。

小技巧:离屏 Canvas 的尺寸不要超过实际需要。我见过有人把离屏 Canvas 设成 4096x4096,结果内存直接爆了。够用就好,别贪大。

4.2 脏矩形刷新策略

全屏重绘是最简单粗暴的方式,但也是性能杀手。脏矩形策略的核心思想是:只重绘发生变化的那一小块区域

为什么会这样?因为 Canvas 的 clearRect()drawImage() 都是光栅化操作,操作区域越小,GPU 负担越轻。

具体做法分三步:

  1. 标记脏区域——当某个元素位置或状态变化时,记录它的包围盒(bounding box)
  2. 合并脏区域——多个脏区域如果有重叠,合并成一个更大的矩形
  3. 局部重绘——只清除并重绘脏区域内的内容

代码示例:

// 脏矩形列表
let dirtyRects = [];

// 标记某个元素为脏
function markDirty(element) {
  dirtyRects.push(element.getBounds());
}

// 合并并重绘
function renderDirty() {
  if (dirtyRects.length === 0) return;

  // 合并所有脏矩形
  const merged = dirtyRects.reduce((acc, rect) => {
    return {
      x: Math.min(acc.x, rect.x),
      y: Math.min(acc.y, rect.y),
      width: Math.max(acc.x + acc.width, rect.x + rect.width) - Math.min(acc.x, rect.x),
      height: Math.max(acc.y + acc.height, rect.y + rect.height) - Math.min(acc.y, rect.y)
    };
  });

  // 只清除并重绘合并后的区域
  ctx.clearRect(merged.x, merged.y, merged.width, merged.height);
  // 这里需要重新绘制该区域内的所有元素
  drawElementsInRect(merged);

  dirtyRects = []; // 清空列表
}

注意:脏矩形策略有一个前提——你能精确知道哪些元素在某个矩形区域内。如果元素数量巨大,遍历查找反而可能比全屏重绘还慢。我曾经在一个粒子系统里试过脏矩形,结果因为粒子太多,每次查找开销太大,最后又换回了全屏重绘。

所以我的建议是:元素少于 500 个时,脏矩形效果明显;超过 2000 个时,考虑其他方案

4.3 对象池与内存管理

JavaScript 的垃圾回收(GC)是 Canvas 性能的隐形杀手。你想想看,如果每帧都创建几百个新对象,GC 一触发,画面就会「卡一下」。

对象池的思路很简单:用完了别扔,洗干净再用

我做一个弹幕游戏时,子弹对象频繁创建和销毁,GC 导致每 3 秒就掉一次帧。后来改用对象池,帧率曲线变得非常平滑。

来看一个通用的对象池实现:

class ObjectPool {
  constructor(factory, reset) {
    this.factory = factory;   // 创建新对象的函数
    this.reset = reset;       // 重置对象的函数
    this.pool = [];
  }

  acquire() {
    if (this.pool.length > 0) {
      return this.pool.pop(); // 从池中取
    }
    return this.factory();    // 池空了就新建
  }

  release(obj) {
    this.reset(obj);          // 重置状态
    this.pool.push(obj);      // 放回池中
  }
}

// 使用示例
const bulletPool = new ObjectPool(
  () => ({ x: 0, y: 0, speed: 0, active: false }),
  (b) => { b.active = false; }
);

// 发射子弹时
function fireBullet(x, y) {
  const bullet = bulletPool.acquire();
  bullet.x = x;
  bullet.y = y;
  bullet.speed = 5;
  bullet.active = true;
  bullets.push(bullet);
}

// 子弹消失时
function removeBullet(bullet) {
  bulletPool.release(bullet);
  // 从活跃列表中移除
  const idx = bullets.indexOf(bullet);
  if (idx > -1) bullets.splice(idx, 1);
}

关键点:对象池不是万能的。如果对象状态复杂,重置成本很高,那还不如直接新建。我一般只在「创建频繁、状态简单、数量大」的场景下用对象池。

除了对象池,还有几个内存管理的小习惯:

  • 避免在动画循环中创建字符串——字符串拼接会生成新对象,能用模板字符串就用模板字符串
  • 复用数组和对象——用 array.length = 0 清空数组,而不是重新 []
  • 小心闭包——闭包会持有外部变量的引用,容易造成内存泄漏

我曾经接手过一个项目,Canvas 动画跑着跑着内存占用就飙到 200MB。一查发现,每帧都在用 new Image() 加载图片,而且从来没释放过。嗯,这种问题用 Chrome 的 Performance 面板一抓一个准。

4.4 三者如何配合使用

这三个技术不是孤立的,实际项目中我通常这样搭配:

场景 离屏渲染 脏矩形 对象池
静态背景 + 少量动态元素 ✅ 缓存背景 ✅ 局部更新 ❌ 没必要
大量粒子/子弹 ❌ 不适合 ⚠️ 慎用 ✅ 强烈推荐
地图/图表缩放平移 ✅ 缓存瓦片 ✅ 增量更新 ⚠️ 看情况
游戏角色动画 ✅ 缓存帧 ✅ 局部刷新 ✅ 对象复用

说白了,性能优化没有银弹。你得根据实际场景,选最合适的组合拳。我个人习惯是:先做离屏渲染,再考虑脏矩形,最后上对象池。因为离屏渲染改动最小,收益最明显。

避坑指南:我曾经在一个项目里同时用了离屏渲染和脏矩形,结果因为离屏 Canvas 的坐标变换没处理好,脏矩形的位置总是对不上。调试了整整一下午才发现是 setTransform 的累积误差。所以,用离屏渲染时,一定要确保坐标系统一致

好了,这一章的内容就到这里。下一章咱们聊聊 WebGL 的优化思路,那又是另一个世界了。