【Canvas 2D】day41-offscreen-performance

作者:mario 发布时间: 2026-09-01 阅读量:5 评论数:0

Canvas OffscreenCanvas 与性能优化 — 离屏渲染 + Worker,告别卡顿

昨天的卷积滤镜在 1080P 上能卡住主线程 30ms+——今天学两把"性能手术刀"。第一把:离屏预渲染(OffscreenCanvas),复杂静态内容(大屏背景、网格、装饰)画一次存起来,每帧只做一次 drawImage 贴图,绘制开销直降一个数量级;第二把:Worker 像素处理,把 Day 40 的滤镜算法原封不动搬进后台线程,主线程照常跑 60fps 动画,两边互不干扰。这两把刀是"工业大屏顺滑"与"大屏卡成 PPT"的分水岭。


目录


一、性能问题的三大来源

1.1 Canvas 动画卡顿的病灶诊断

每帧 16.7ms 预算,花在哪里了?

病灶 1:重复绘制静态内容(最常见!)
  大屏背景(渐变 + 网格 + 装饰边框)每帧重画一遍
  → 而它根本不变!100% 浪费

病灶 2:主线程被重计算阻塞
  卷积滤镜 30ms、复杂布局计算、大量 JSON 解析
  → 计算期间 rAF 根本没机会跑 → 掉帧

病灶 3:每帧创建对象(GC 抖动)
  循环里 new 数组/对象 → 频繁触发垃圾回收 → 周期性卡顿

今天的两把刀分别对应病灶 1 和病灶 2(病灶 3 用对象池,Day 34 已提过思路)。

1.2 治疗方案总览

病灶 药方 今天章节
重复绘制静态内容 离屏画布预渲染 第二、三节
图片解码慢 createImageBitmap 异步解码 第四节
重计算阻塞主线程 Worker 后台处理 第五节

二、离屏画布:预渲染思想

2.1 核心思想:“画一次,贴万次”

// 朴素做法(每帧重画背景):
function frame(): void {
  drawExpensiveBackground();   // 渐变+网格+100个装饰元素 = 8ms!
  drawDynamicParticles();      // 动态内容 3ms
  // 每帧 11ms,帧率摇摇欲坠
}

// 预渲染做法(背景只画一次):
const bgCanvas = document.createElement("canvas");   // 离屏(不在 DOM 里,不显示)
bgCanvas.width = canvas.width;
bgCanvas.height = canvas.height;
drawExpensiveBackgroundTo(bgCanvas);                 // 启动时画一次,8ms 只花这一次

function frame(): void {
  ctx.drawImage(bgCanvas, 0, 0);   // 贴图 ≈ 0.5ms
  drawDynamicParticles();          // 3ms
  // 每帧 3.5ms,稳 60fps ✅
}

2.2 为什么 drawImage 比重画快得多?

重画背景:
  CPU/GPU 执行几百条绘制指令(fillRect × 100、渐变计算、描边...)
  → 每条指令都有开销

贴图:
  一条指令:"把这块现成的像素缓冲区复制过去"
  → 现代 GPU 做位块传输(BitBLT)快如闪电

2.3 适用判断:什么内容适合离屏缓存?

特征 适合离屏 不适合
变化频率 静态/极少变(背景、网格、底图) 每帧都变(粒子、指针)
绘制开销 高(元素多、渐变多、滤镜) 低(一两个矩形)
尺寸 越大收益越明显 小到贴图本身也有开销

反模式警告:把每帧都变的内容离屏 = 每帧"画一遍 + 贴一遍",比直接画还慢!

2.4 已见过的两个伏笔

// ① Day 39 取色器:overlay 污染原画面 → 存了 sceneCanvas 备份
const sceneCanvas = document.createElement("canvas");
sceneCanvas.getContext("2d")!.drawImage(canvas, 0, 0);
// 这就是离屏缓存(当时没点名而已)

// ② Day 38 viewport:drawGrid 每帧画几十条线 → 可离屏成"平铺图案"
// 进阶做法:用 createPattern 把小网格块平铺(Day 31 的 pattern 知识)

三、OffscreenCanvas:正规的离屏姿势

3.1 document.createElement(“canvas”) vs new OffscreenCanvas()

// 方式一:DOM canvas 藏起来用(传统,兼容性最好)
const c1 = document.createElement("canvas");   // 有 DOM 属性(clientWidth 等),但不在文档中
c1.width = 800; c1.height = 600;
const ctx1 = c1.getContext("2d")!;

// 方式二:OffscreenCanvas(现代标准,2019+ 全绿)
const c2 = new OffscreenCanvas(800, 600);      // 纯渲染目标,无 DOM 包袱
const ctx2 = c2.getContext("2d")!;
// 优点:更轻量;且【能在 Worker 里用】← 关键差异!

3.2 OffscreenCanvas 在主线程的用法

// 生成 800×600 的静态背景,然后贴到主画布:
function buildBackground(w: number, h: number): OffscreenCanvas {
  const off = new OffscreenCanvas(w, h);
  const octx = off.getContext("2d")!;

  // 所有昂贵绘制(渐变、网格、装饰)写在这里……
  const grad = octx.createLinearGradient(0, 0, w, h);
  grad.addColorStop(0, "#0a1526");
  grad.addColorStop(1, "#12263f");
  octx.fillStyle = grad;
  octx.fillRect(0, 0, w, h);

  // 网格
  octx.strokeStyle = "rgba(58,74,106,0.35)";
  octx.beginPath();
  for (let x = 0; x <= w; x += 40) { octx.moveTo(x, 0); octx.lineTo(x, h); }
  for (let y = 0; y <= h; y += 40) { octx.moveTo(0, y); octx.lineTo(w, y); }
  octx.stroke();

  return off;
}

// drawImage 直接吃 OffscreenCanvas ✅
const bg = buildBackground(canvas.width, canvas.height);
ctx.drawImage(bg, 0, 0);

3.3 resize 时的缓存失效

// 离屏缓存最经典的 bug:窗口 resize 后缓存尺寸不匹配 → 拉伸模糊
// 解法:resize 事件里重建缓存

let bgCache: OffscreenCanvas | null = null;

function rebuildCache(): void {
  bgCache = buildBackground(canvas.width, canvas.height);   // 按新尺寸重建
}

window.addEventListener("resize", () => {
  // bootCanvas 重新适配 DPR(Day 29 的逻辑)……
  rebuildCache();   // ⭐ 缓存跟随重建,别忘
});

四、createImageBitmap:图片加载的现代方案

4.1 对比传统 img.onload

// 传统方式(Day 33 用的):
const img = new Image();
img.crossOrigin = "anonymous";
img.src = "device.png";
img.onload = () => {
  ctx.drawImage(img, 0, 0);     // 解码可能发生在"第一次绘制"时 → 首次 drawImage 卡一下
};

// 现代方式:createImageBitmap(解码在后台完成,返回"已就绪"的位图)
const response = await fetch("device.png");
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);   // ← 异步解码,主线程零卡顿
ctx.drawImage(bitmap, 0, 0);                    // 立即可用,无卡顿

// 还能顺便裁剪/缩放(一步到位):
const region = await createImageBitmap(blob, 0, 0, 100, 100);          // 裁出左上 100×100
const scaled = await createImageBitmap(blob, { resizeWidth: 64, resizeHeight: 64 });  // 缩成 64×64

// 用完释放(大位图别攒着)
bitmap.close();

4.2 ImageBitmap 的独特优势

// ① 可传输给 Worker(零拷贝):
worker.postMessage({ bitmap }, [bitmap]);   // Transferable,主线程立刻失去引用

// ② 可作为 ImageBitmapRenderingContext 的源(超快贴图模式):
const glCanvas = document.querySelector("#fast") as HTMLCanvasElement;
const bctx = glCanvas.getContext("bitmaprenderer")!;
bctx.transferFromImageBitmap(bitmap);   // "所有权转移"式贴图,比 drawImage 还快

// ③ drawImage 它时没有 img 元素的 DOM 开销

五、Worker 像素处理:后台跑滤镜

5.1 为什么 Worker 是像素处理的绝配

主线程(唯一)                      Worker 线程(可多个)
┌─────────────────┐               ┌─────────────────┐
│ rAF 动画循环      │               │ (闲置)          │
│ 鼠标/键盘事件     │    postMessage │                 │
│ UI 渲染          │  ──────────→  │ 跑卷积滤镜 30ms   │
│                  │               │ (随便跑,不影响   │
│ (必须丝滑)      │  ←──────────  │   任何人)        │
└─────────────────┘   postMessage  └─────────────────┘

关键:JS 单线程,但 Worker 是独立线程。
主线程"卡"的概念对 Worker 不存在——各卡各的。

5.2 数据传输:Transferable 零拷贝

// postMessage 传大数组的两种姿势:

// 姿势一:结构化克隆(拷贝一份传过去)
worker.postMessage(imageData);
// 800×600 的 data = 192 万元素 → 克隆耗时 ~10ms,内存翻倍 ❌

// 姿势二:Transferable(转移所有权,零拷贝)
worker.postMessage(imageData, [imageData.data.buffer]);
// ⭐ 微秒级完成,内存不翻倍
// ⚠ 代价:主线程立刻失去这块 buffer(TypedArray 长度变 0)
// → Worker 处理完再 transfer 回来(一来一回都是零拷贝)

5.3 完整的 Worker 滤镜管线

// ===== 主线程:filter-worker-client.ts =====

/** 创建 Worker 并封装"发送-处理-收回"的 Promise 化接口 */
class FilterWorker {
  private worker = new Worker("./filter-worker.js", { type: "module" });
  private pending: ((img: ImageData) => void) | null = null;

  constructor() {
    this.worker.onmessage = (e: MessageEvent) => {
      const { imageData } = e.data as { imageData: ImageData };
      this.pending?.(imageData);       // 收到处理结果,resolve Promise
      this.pending = null;
    };
  }

  /** 处理一块像素数据(异步,不阻塞主线程) */
  apply(imageData: ImageData): Promise<ImageData> {
    return new Promise((resolve) => {
      this.pending = resolve;
      // ⭐ buffer 转移过去(主线程失去 imageData,Worker 零拷贝获得)
      this.worker.postMessage({ imageData }, [imageData.data.buffer]);
    });
  }
}

// 使用:在事件回调里(不在 rAF 里!)
const fw = new FilterWorker();

async function onFilterButtonClick(): Promise<void> {
  const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
  const result = await fw.apply(img);     // 后台处理,主线程自由
  ctx.putImageData(result, 0, 0);         // 结果写回
}
// ===== Worker 文件:filter-worker.js =====
// 这段代码运行在独立线程 —— 里面跑再重的循环也不卡主线程

// 把 Day 40 的卷积函数原样搬进来(纯计算代码天然可移植):
function convolve3x3(imageData: ImageData, kernel: number[]): void {
  // …… Day 40 第 3.3 节的完整实现 ……
}

self.onmessage = (e: MessageEvent) => {
  const { imageData } = e.data as { imageData: ImageData };

  // 重活:高斯模糊跑 3 遍(在主线程会卡 50ms+,在这里无所谓)
  for (let i = 0; i < 3; i++) {
    convolve3x3(imageData, [1/16,2/16,1/16, 2/16,4/16,2/16, 1/16,2/16,1/16]);
  }

  // ⭐ 处理完,buffer 转移回主线程(依然零拷贝)
  (self as unknown as Worker).postMessage({ imageData }, [imageData.data.buffer]);
};

5.4 Worker 的限制清单

Worker 里【没有】的东西:
  ❌ document / window / DOM           (不能操作页面元素)
  ❌ canvas.getContext('2d')(DOM canvas)❌
  ✅ 但 OffscreenCanvas 可以!new OffscreenCanvas() 纯计算无 DOM 依赖
  ❌ 大多数全局对象(location、localStorage……)

Worker 里【有】的东西:
  ✅ self(全局对象)      ✅ fetch / XMLHttpRequest
  ✅ setTimeout 家族        ✅ WebSocket(第 8 周预告:数据订阅可以放 Worker!)
  ✅ OffscreenCanvas / ImageBitmap

结论:像素处理 = 纯数学运算 + TypedArray → Worker 的完美战场

六、实战:离屏缓存优化的动画

今天的实战目标:对比实验——同一幅"复杂静态背景 + 动态粒子"动画,先每帧重画背景(记录帧耗时),再改用离屏缓存(再次记录),亲眼看数字变化。

6.1 完整代码

// day41-offscreen-benchmark.ts —— 离屏缓存前后性能对比
import { bootCanvas } from "../day29/canvas-boot.js";

const canvas = document.querySelector("#board") as HTMLCanvasElement;
const { ctx, cssWidth, cssHeight } = bootCanvas(canvas);
const dpr = window.devicePixelRatio || 1;

// ===== 1. 昂贵的静态背景(渐变 + 双层网格 + 放射装饰,故意画复杂点)=====
function drawBackgroundTo(c: CanvasRenderingContext2D, w: number, h: number): void {
  const grad = c.createLinearGradient(0, 0, w, h);
  grad.addColorStop(0, "#0a1526");
  grad.addColorStop(1, "#12263f");
  c.fillStyle = grad;
  c.fillRect(0, 0, w, h);

  c.strokeStyle = "rgba(58,74,106,0.3)";
  c.lineWidth = 1;
  c.beginPath();
  for (let x = 0; x <= w; x += 40) { c.moveTo(x, 0); c.lineTo(x, h); }
  for (let y = 0; y <= h; y += 40) { c.moveTo(0, y); c.lineTo(w, y); }
  c.stroke();

  c.strokeStyle = "rgba(0,198,255,0.12)";
  c.beginPath();
  for (let x = 0; x <= w; x += 200) { c.moveTo(x, 0); c.lineTo(x, h); }
  for (let y = 0; y <= h; y += 200) { c.moveTo(0, y); c.lineTo(w, y); }
  c.stroke();

  // 放射状装饰(径向渐变 × 4,故意增加开销)
  for (let i = 0; i < 4; i++) {
    const rg = c.createRadialGradient(
      (w / 4) * (i % 2 + 0.5), (h / 4) * (Math.floor(i / 2) + 0.5), 0,
      (w / 4) * (i % 2 + 0.5), (h / 4) * (Math.floor(i / 2) + 0.5), 150,
    );
    rg.addColorStop(0, "rgba(0,255,136,0.06)");
    rg.addColorStop(1, "transparent");
    c.fillStyle = rg;
    c.fillRect(0, 0, w, h);
  }
}

// ===== 2. 动态粒子(Day 34 的简化版,随机漂浮)=====
interface P { x: number; y: number; vx: number; vy: number; r: number }
const particles: P[] = Array.from({ length: 60 }, () => ({
  x: Math.random() * cssWidth, y: Math.random() * cssHeight,
  vx: (Math.random() - 0.5) * 40, vy: (Math.random() - 0.5) * 40,
  r: 1.5 + Math.random() * 2.5,
}));

// ===== 3. 离屏缓存(物理分辨率)=====
let bgCache: OffscreenCanvas;

function buildCache(): void {
  bgCache = new OffscreenCanvas(canvas.width, canvas.height);   // 物理分辨率
  drawBackgroundTo(bgCache.getContext("2d")!, canvas.width, canvas.height);
}
buildCache();

// ===== 4. 两种渲染模式 + 帧耗时统计 =====
let useCache = true;   // 按 C 键切换模式
let frameCost = 0;     // 最近一帧耗时(ms)

function frame(now: number): void {
  const t0 = performance.now();

  ctx.clearRect(0, 0, cssWidth, cssHeight);

  if (useCache) {
    ctx.drawImage(bgCache, 0, 0);    // 物理分辨率贴图:注意坐标系!
  } else {
    // 每帧重画背景(要切换回 CSS 像素坐标系语义)
    drawBackgroundTo(ctx, cssWidth, cssHeight);
  }

  // 粒子(CSS 坐标系)
  const dt = 1 / 60;
  for (const p of particles) {
    p.x += p.vx * dt; p.y += p.vy * dt;
    if (p.x < 0 || p.x > cssWidth) p.vx *= -1;
    if (p.y < 0 || p.y > cssHeight) p.vy *= -1;
  }
  ctx.fillStyle = "#00ff88";
  ctx.beginPath();
  for (const p of particles) {
    ctx.moveTo(p.x + p.r, p.y);
    ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
  }
  ctx.fill();

  frameCost = performance.now() - t0;

  // HUD:模式 + 耗时
  ctx.fillStyle = "#ffffff";
  ctx.font = "14px monospace";
  ctx.fillText(
    `模式: ${useCache ? "离屏缓存" : "每帧重画"}  帧耗时: ${frameCost.toFixed(2)}ms  (按 C 切换)`,
    12, 24,
  );

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

window.addEventListener("keydown", (e) => {
  if (e.key.toLowerCase() === "c") useCache = !useCache;
});

6.3 预期结果与解读

典型测量值(800×600 画布,普通笔记本):

每帧重画模式:  帧耗时 6~9ms   (背景占 5~8ms)
离屏缓存模式:  帧耗时 1~2ms   (贴图 < 0.5ms)

画布越大差距越大:1920×1080 时重画模式可能直接掉到 30fps。

解读:省下的 5~8ms 可以拿去画更多动态内容(更多粒子/更复杂动画)
     ——这就是大屏"内容又多又流畅"的秘密。

七、性能测量:先测量再优化

7.1 工具箱

// 工具 1:performance.now() 手动打点(上面的实战用的)
const t0 = performance.now();
doWork();
console.log(`耗时 ${performance.now() - t0}ms`);

// 工具 2:requestAnimationFrame 帧率监控(HUD 常驻)
let frames = 0, lastStat = performance.now();
function fpsMonitor(now: number): void {
  frames++;
  if (now - lastStat >= 1000) {     // 每秒统计一次
    console.log(`FPS: ${frames}`);
    frames = 0;
    lastStat = now;
  }
  requestAnimationFrame(fpsMonitor);
}

// 工具 3:DevTools Performance 面板(不可替代)
// F12 → Performance → Record → 操作页面 → Stop
// 看:帧率曲线、Long Task(>50ms 红条)、函数级火焰图

7.2 性能优化的纪律

黄金法则:先测量,再优化,优化后再测量验证。

❌ "我觉得这里慢,改一下"  → 80% 的优化是白费(或负优化)
✅ Profile 找到热点 → 只优化热点 → 数字对比确认

第二法则:过早优化是万恶之源。
  300×200 的小画布 + 3 个滤镜 → 主线程毫无压力,别上 Worker
  1920×1080 + 实时高斯 → 才是 Worker 的战场

八、常见坑点与最佳实践

# 症状 解法
1 每帧重画不变的静态背景 帧率无故偏低 离屏缓存:画一次,贴万次
2 把每帧都变的内容离屏 反而更慢(画+贴双开销) 只缓存"静态/低频变化"内容
3 resize 后缓存不重建 背景拉伸模糊 resize 事件里重建缓存
4 缓存尺寸用 CSS 像素 高分屏上背景糊 缓存用物理分辨率(canvas.width)
5 drawImage 离屏时坐标系混乱 贴图位置/大小不对 统一物理分辨率直贴(setTransform(1,0,0,1,0,0))
6 postMessage 传 ImageData 没用 Transferable 传输本身卡 10ms+ [buffer] 转移所有权
7 Transfer 后还读原 buffer 长度变 0,报错/空白 转移后主线程立刻放弃引用
8 Worker 里用 DOM canvas 直接报错 Worker 只能用 OffscreenCanvas
9 每帧 new Worker 创建开销巨大 Worker 常驻,消息复用
10 bitmap 不 close 大图内存泄漏堆积 用完 bitmap.close()

九、自测挑战

  1. 概念题:说明"离屏缓存"和"缓存失效"在本篇的完整流程(从创建到 resize 重建)。
  2. 计算题:1920×1080 的画布每帧重画背景耗时 12ms,改离屏贴图 0.8ms。60fps 预算 16.7ms——省下的时间够多画多少个"每个耗时 0.05ms"的粒子?
  3. 实现题:把 Day 38 的 drawGrid(viewport 网格)改造成离屏图案平铺:生成一个 40×40 的网格块 OffscreenCanvas,用 createPattern 填充。写出关键代码(提示:注意 viewport 缩放时 pattern 的变换)。
  4. 改错题:下面的 Worker 通信代码有两处性能问题,找出来:
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
worker.postMessage({ img });
// Worker 端处理完:
self.postMessage({ img });
  1. 设计题:设计"大屏项目渲染分层方案":一个 1920×1080 的组态画面包含——静态背景(变)、200 个设备图元(数据驱动,约 1 秒变一次)、实时曲线(每帧变)、报警闪烁(高频变)。给出你的画布分层与缓存策略,并说明每层的更新频率。
  2. 进阶题:给第 6 节实战加"粒子对象池":粒子数固定 60,消失的粒子回收复用(提示:维护 freeList)。

十、总结与知识图谱

Day 41 OffscreenCanvas 与性能
├── 三大病灶
│   ├── 重复绘制静态内容 → 离屏缓存
│   ├── 主线程重计算    → Worker
│   └── 每帧创建对象    → 对象池
├── 离屏渲染
│   ├── 原理:画一次贴万次(BitBLT)
│   ├── DOM canvas 隐藏 vs OffscreenCanvas
│   ├── resize → 缓存重建
│   └── 判断:静态 + 昂贵 = 值得缓存
├── createImageBitmap
│   ├── 异步解码(fetch+blob+bitmap)
│   ├── 裁剪/缩放一步到位
│   └── Transferable / close 释放
├── Worker 像素处理
│   ├── 独立线程,主线程丝滑
│   ├── Transferable 零拷贝(来回双向)
│   └── 限制:无 DOM,可用 OffscreenCanvas
├── 实战:缓存前后帧耗时对比
└── 测量纪律
    └── 先测量 → 优化热点 → 数字验证

明天预告(Day 42 · BOSS 战):本周毕业考——图片滤镜编辑器。把 Day 39 的像素读写、Day 40 的滤镜算法(灰度/反色/亮度/对比度/模糊/锐化)、今天的 Worker 后台处理串成完整产品:滤镜链叠加、滑杆实时调参、处理中不卡 UI、前后对比、导出 PNG。做完它,你对"Canvas 像素级操控"的理解就毕业了。

评论