污染CORS与像素导出
1. origin-clean 怎么被弄脏
一张 canvas 默认干净。下列绘制会把它标脏(不完全清单,以 WHATWG 为准):
- 跨源
HTMLImageElement/SVGImageElement且请求不是 CORS 成功 - 跨源
HTMLVideoElement当前帧 - 另一张 已经脏了的 canvas /
ImageBitmap - 某些跨源
createImageBitmap失败路径下的占位
同源 blob URL、data URL、已 CORS 成功的图,保持干净。
脏了之后:
- 仍然可以继续
drawImage/fill给用户看 - 不能读像素、不能导出、不能把这张表面当干净源再画到另一张还指望另一张干净
export function assertClean(canvas: HTMLCanvasElement): void {
try {
canvas.getContext("2d")?.getImageData(0, 0, 1, 1);
} catch (error) {
if (error instanceof DOMException && error.name === "SecurityError") {
throw new Error("canvas is tainted; export and readback are blocked");
}
throw error;
}
}
不要在热路径用 1×1 getImageData 当「是否脏」探针——它会触发读回。架构上 跟踪你画过的源:所有位图必须经过自己的 loader,loader 只产出干净的 ImageBitmap。
2. 加载路径:先 CORS,再 bitmap
export async function loadCleanBitmap(url: string): Promise<ImageBitmap> {
const response = await fetch(url, {
mode: "cors",
credentials: "omit",
});
if (!response.ok) {
throw new Error(`fetch ${url} -> ${response.status}`);
}
const blob = await response.blob();
return createImageBitmap(blob);
}
fetch + createImageBitmap 比 new Image(); img.crossOrigin = "anonymous" 更好控:
- 失败是 HTTP 状态,不是「onload 了但 canvas 脏了」
- 不依赖解码完成时机
credentials: "omit"避免把 cookie 带到跨源图床上(否则 CORS 头必须是精确 Origin 而不能是*)
如果必须走 HTMLImageElement:
export function loadImageElement(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = "anonymous";
image.decoding = "async";
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`image failed: ${url}`));
image.src = url;
});
}
crossOrigin 必须在设 src 之前 设。设晚了,这次请求按无 CORS 走,画上去就脏。CDN 没回 Access-Control-Allow-Origin 时,onload 仍可能触发——元素能显示在 <img> 里,但 不能 画进要导出的 canvas。产品表现是「预览正常,点下载才报 SecurityError」。
3. 导出:toBlob 优先,编码在主线程外
export function exportPng(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("toBlob returned null"));
return;
}
resolve(blob);
}, "image/png");
});
}
export async function exportJpeg(
canvas: HTMLCanvasElement,
quality: number,
): Promise<Blob> {
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/jpeg", quality);
});
if (!blob) {
throw new Error("jpeg encode failed");
}
return blob;
}
| API | 问题 |
|---|---|
toDataURL | 同步编码 + 巨大 base64 字符串,主线程卡死,内存翻倍 |
toBlob | 异步,给 Blob;下载用 URL.createObjectURL |
OffscreenCanvas.convertToBlob | Worker 里导出,主线程只收 Blob |
JPEG 丢掉 alpha。透明区域变黑或变白取决于引擎填的底。需要透明就 PNG。需要高压缩且无透明,先在不透明表面上画,再 JPEG。
质量参数:JPEG 的 quality 是 0–1。WebP 同理,但编码器支持度用探测,不要假设所有 WebView 都能 image/webp。
下载:
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
4. 隐私与指纹
干净 canvas 的像素也可以做指纹(字体、光栅差异、GPU)。若产品有隐私预算:
- 不要在登录前的第三方脚本里
toDataURL - 用户导出是正当读回;静默采样整张表面不是
desynchronized/ 不同colorSpace会改变像素,不要用像素 hash 当「场景是否相等」的测试金标——用场景图序列化
5. 失败形态
| 症状 | 原因 |
|---|---|
预览正常,导出 SecurityError | 跨源图无 CORS,或 crossOrigin 设晚了 |
| 导出卡 2 秒 | toDataURL 同步编码 4K × DPR |
| PNG 很大、JPEG 黑底 | alpha + JPEG |
| 另一张离屏也被脏 | drawImage 了已污染源 |
fetch CORS 失败 | 图床 * + 带 cookie,或根本没有 ACAO |
权威资料
- WHATWG — Security with canvas elements
- CORS enabled image
- HTMLCanvasElement.toBlob()
- createImageBitmap()
核对日期:2026-08-26