跳到主要内容

OffscreenCanvas与引擎差异

1. 两条离屏路径,不要混

API谁持有 DOMcontext 在哪创建
new OffscreenCanvas(w, h)没有 DOM 节点任意线程,含 Worker
HTMLCanvasElement.transferControlToOffscreen()节点仍在,但控制权交给 OffscreenCanvas只能在 transfer 之后 在接收端 getContext

WHATWG:已经在 HTMLCanvasElementgetContext 过,再 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 与合成

  • roundRectfilterconic-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 仍有子集)。不要把本文的 Path2DOffscreenCanvascreateImageBitmap 默认能用。跨端产品:浏览器走这一章;小程序走各端文档,共享的只是 场景图与 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 transferredgetContext 再 transfer,或 transfer 两次
Worker 黑屏2d offscreen 不可用;或 resize 没同步 backing 尺寸
主线程仍掉帧每帧 postMessage 拷贝像素,bitmap 未 transfer
P3 图发闷colorSpace 未生效,资源按 sRGB 解
文字命中在 Safari 偏用了 Chrome 录的 metrics 硬编码

权威资料

核对日期:2026-08-26