OffscreenCanvas与引擎差异
1. 两条离屏路径,不要混
| API | 谁持有 DOM | context 在哪创建 |
|---|---|---|
new OffscreenCanvas(w, h) | 没有 DOM 节点 | 任意线程,含 Worker |
HTMLCanvasElement.transferControlToOffscreen() | 节点仍在,但控制权交给 OffscreenCanvas | 只能在 transfer 之后 在接收端 getContext |
WHATWG:已经在 HTMLCanvasElement 上 getContext 过,再 transferControlToOffscreen() 抛 InvalidStateError。反过来,transfer 之后主线程再 getContext 同样非法。
export function transferViewToWorker(
canvas: HTMLCanvasElement,
worker: Worker,
): void {
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "bind-surface", canvas: offscreen }, [offscreen]);
}
getContext 探测会把表面绑死,之后 transfer 必炸。生产代码 按架构二选一:这张视图 canvas 要么主线程画,要么创建后立刻 transfer,没有「先试一下再决定」。能力探测只测构造函数是否存在,且必须开在 临时 canvas 上,见第 4 节。
Worker 侧用消息驱动,不要每帧把像素拷回主线程:
type WorkerIn =
| { type: "bind-surface"; canvas: OffscreenCanvas }
| { type: "resize"; width: number; height: number; dpr: number }
| { type: "scene"; nodes: readonly SceneDrawCmd[] };
interface SceneDrawCmd {
path: Path2D;
fill: string;
world: DOMMatrix;
}
let surface: OffscreenCanvas | undefined;
let ctx: OffscreenCanvasRenderingContext2D | undefined;
let dpr = 1;
let scene: readonly SceneDrawCmd[] = [];
self.onmessage = (event: MessageEvent<WorkerIn>) => {
const msg = event.data;
if (msg.type === "bind-surface") {
surface = msg.canvas;
const next = surface.getContext("2d", { alpha: true, desynchronized: true });
if (!next) throw new Error("offscreen 2d unavailable");
ctx = next;
return;
}
if (msg.type === "resize") {
if (!surface || !ctx) return;
surface.width = msg.width;
surface.height = msg.height;
dpr = msg.dpr;
return;
}
scene = msg.nodes;
};
const paint = (): void => {
if (ctx) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
for (const node of scene) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.transform(node.world.a, node.world.b, node.world.c, node.world.d, node.world.e, node.world.f);
ctx.fillStyle = node.fill;
ctx.fill(node.path);
}
}
requestAnimationFrame(paint);
};
requestAnimationFrame(paint);
Path2D 不能结构化克隆。真正过 Worker 边界时传 序列化命令(verb + 点列)或在 Worker 里持有完整场景,主线程只发输入事件。上面用 Path2D 只表示绘制形态;跨线程要另写 codec。
Worker 里的 requestAnimationFrame 跟谁的 vsync:跟 那张 transferred canvas 所在文档 的刷新,不是跟一个抽象 60Hz。文档隐藏时同样会降频。
主线程 postMessage 传绘制指令(scene diff),不要每帧传 ImageData。需要传位图时用 ImageBitmap + transfer list。
2. 尺寸同步
transfer 之后,主线程改 CSS 不会自动改 OffscreenCanvas.width。resize 必须发消息:
export function postSurfaceResize(
worker: Worker,
metrics: { backingWidth: number; backingHeight: number; dpr: number },
): void {
worker.postMessage({
type: "resize",
width: metrics.backingWidth,
height: metrics.backingHeight,
dpr: metrics.dpr,
});
}
Worker 里写 offscreen.width = … 同样清空状态。收到 resize 要重设 CTM,并重绘静态缓存层。
3. 引擎差异(机制,不是兼容表)
Hint
willReadFrequently:Chromium 会把 2D 表面倾向软件光栅。Gecko/WebKit 不一定有同等路径,但读回照样贵。desynchronized:延迟敏感绘制用。不保证;getContextAttributes().desynchronized为 false 时不要假设低延迟。colorSpace: "display-p3":未支持时静默落到 sRGB。宽色域资源在 sRGB 表面上会被裁。
文字
TextMetrics.actualBoundingBox*各引擎对 glyph ink 的计算有差,命中盒不要假设和 Chrome 截图像素级一致。letterSpacing/fontKerning/fontStretch进入 2D 的时间各异。用到就 feature detect:赋值后读回是否还是你写的值。- 系统字体 fallback 不同,
measureText(中文).width跨 OS 不可当布局真值存盘。
Path 与合成
roundRect、filter、conic-gradient属于「API 在但不一定快」。isPointInPath对超大 path 的性能差一个数量级都可能出现;粗测 AABB 不能省。
OffscreenCanvas 2D
- 长期是 Chromium 先、Safari/WebKit 后。Worker + 2d 不可用时,降级为主线程
OffscreenCanvas或普通 canvas,不要让 Worker 里getContext("2d")返回 null 然后静默黑屏。 transferControlToOffscreen后用 WebGL 的 canvas 不能再切 2d,规则与主线程相同。
内存与移动端
- iOS 对 canvas backing store 很狠。多层 × 3x DPR 是杀进程套餐。见 05 封顶策略。
- WKWebView 里的 canvas 还受容器进程内存限制,和 Safari 同版本也不等于同预算。
嵌入 WebView / 小程序(附录)
微信/支付宝小程序的 canvas 是 另一套实现(旧版是同层组件 + 桥,新版接近 WebView 但 context API 仍有子集)。不要把本文的 Path2D、OffscreenCanvas、createImageBitmap 默认能用。跨端产品:浏览器走这一章;小程序走各端文档,共享的只是 场景图与 DPR 心智,不是代码。
4. 能力探测怎么写
export interface CanvasCaps {
offscreen: boolean;
transferControl: boolean;
workerRaf: boolean;
displayP3: boolean;
devicePixelContentBox: boolean;
}
export function detectCanvasCaps(): CanvasCaps {
const offscreen = typeof OffscreenCanvas !== "undefined";
let displayP3 = false;
if (typeof document !== "undefined") {
const probe = document.createElement("canvas").getContext("2d", {
colorSpace: "display-p3",
});
displayP3 = probe?.getContextAttributes?.().colorSpace === "display-p3";
}
return {
offscreen,
transferControl:
typeof HTMLCanvasElement !== "undefined"
&& typeof HTMLCanvasElement.prototype.transferControlToOffscreen === "function",
workerRaf: typeof requestAnimationFrame === "function",
displayP3,
devicePixelContentBox:
typeof ResizeObserver !== "undefined"
&& "devicePixelContentBoxSize" in ResizeObserverEntry.prototype,
};
}
探测用的 2d context 开在 临时 canvas 上,永远不要开在即将 transfer 的视图节点上。
5. 失败形态
| 症状 | 原因 |
|---|---|
InvalidStateError: The canvas has been transferred | 先 getContext 再 transfer,或 transfer 两次 |
| Worker 黑屏 | 2d offscreen 不可用;或 resize 没同步 backing 尺寸 |
| 主线程仍掉帧 | 每帧 postMessage 拷贝像素,bitmap 未 transfer |
| P3 图发闷 | colorSpace 未生效,资源按 sRGB 解 |
| 文字命中在 Safari 偏 | 用了 Chrome 录的 metrics 硬编码 |
权威资料
- OffscreenCanvas
- HTMLCanvasElement.transferControlToOffscreen()
- WHATWG — OffscreenCanvas
- CanvasRenderingContext2D.getContextAttributes()
核对日期:2026-08-26