Canvas OffscreenCanvas 与性能优化 — 离屏渲染 + Worker,告别卡顿
昨天的卷积滤镜在 1080P 上能卡住主线程 30ms+——今天学两把"性能手术刀"。第一把:离屏预渲染(OffscreenCanvas),复杂静态内容(大屏背景、网格、装饰)画一次存起来,每帧只做一次 drawImage 贴图,绘制开销直降一个数量级;第二把:Worker 像素处理,把 Day 40 的滤镜算法原封不动搬进后台线程,主线程照常跑 60fps 动画,两边互不干扰。这两把刀是"工业大屏顺滑"与"大屏卡成 PPT"的分水岭。
目录
- 一、性能问题的三大来源
- 二、离屏画布:预渲染思想
- 三、OffscreenCanvas:正规的离屏姿势
- 四、createImageBitmap:图片加载的现代方案
- 五、Worker 像素处理:后台跑滤镜
- 六、实战:离屏缓存优化的动画
- 七、性能测量:先测量再优化
- 八、常见坑点与最佳实践
- 九、自测挑战
- 十、总结与知识图谱
一、性能问题的三大来源
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() |
九、自测挑战
- 概念题:说明"离屏缓存"和"缓存失效"在本篇的完整流程(从创建到 resize 重建)。
- 计算题:1920×1080 的画布每帧重画背景耗时 12ms,改离屏贴图 0.8ms。60fps 预算 16.7ms——省下的时间够多画多少个"每个耗时 0.05ms"的粒子?
- 实现题:把 Day 38 的
drawGrid(viewport 网格)改造成离屏图案平铺:生成一个 40×40 的网格块 OffscreenCanvas,用createPattern填充。写出关键代码(提示:注意 viewport 缩放时 pattern 的变换)。 - 改错题:下面的 Worker 通信代码有两处性能问题,找出来:
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
worker.postMessage({ img });
// Worker 端处理完:
self.postMessage({ img });
- 设计题:设计"大屏项目渲染分层方案":一个 1920×1080 的组态画面包含——静态背景(变)、200 个设备图元(数据驱动,约 1 秒变一次)、实时曲线(每帧变)、报警闪烁(高频变)。给出你的画布分层与缓存策略,并说明每层的更新频率。
- 进阶题:给第 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 像素级操控"的理解就毕业了。