canvas大图内存怎么算:宽×高×4只是第一份

0 阅读35分钟

摘要

需求评审上有人问了一句:图片上传限单张 30 MB 并且前端先压再传真的安全吗?我当时答不上来。上传组件卡的是文件大小;浏览器吃的却是像素。一张 13.4 MB 的 1 亿像素 JPEG 走完「解码、画进原尺寸画布、导出」这条流程,Chromium 149 开源构建的各进程峰值加起来是 1289 MiB。文件按 MB 算,内存要按 GB 算。

这篇把大图处理的内存账拆成三份来算。第一份是画布本身:第一次绘制之后正好是 宽×高×4 字节。第二份是整幅 getImageData 读回来的那一份,大小与第一份相同。第三份是解码和导出过程里的峰值:它远超文件大小而且三个内核之间差得很远。算完之后我把预算写进了组件,上传前先预检、超了就给出缩放建议,导出后再核一遍,不信任 toBlob 的返回。四段代码都在本机三个内核上跑过并原样贴出输出。文末单独一章讲量内存时踩到的测量方法问题。量内存这件事本身就很容易骗到自己,我建议先看那一章。

版本声明

实测跑在 2026-09-24,补充的四段代码跑在 2026-09-28。内核是三个构建:Chromium 149.0.7827.55 的开源构建、WebKit 26.5 构建、Firefox 151.0 构建。机器是 16 GB 内存的 Apple M4 Mac,系统是 macOS 26.5.2。Chromium 这个构建不是日常装的 Chrome;WebKit 26.5 构建不等于 Safari 26.5;Firefox 151 构建也不是正式版 Firefox。文中写「Chromium」「WebKit」「Firefox」的地方都指这三个构建。

画布上限、导出格式的边长限制、解码器能解多大,这些都是浏览器的实现细节,换一个大版本就可能变。内存数字更容易变,它还受这台机器当时状态的影响。本文给的是三个构建在这台机器上的实测,算不上规范。组件里的预检因此写成了配置表加运行时核对,任何一个数都没写死成常量。

适用边界

这篇讲什么。讲浏览器端用 canvas 处理用户上传大图时的内存预算:一张图进来要占多少内存、由哪几份构成、怎么在上传前估出来、超了之后组件怎么知道。落点是组件里的预检和导出核对。

这篇不讲什么。WebGL 和 WebGPU 的纹理上限、HEIC 与 AVIF 这类格式的大图输入、分块处理的具体写法都不讲,这些这一轮都没测。分块和渐进式降采样我只在第六章讲原理,不给实测数字。Worker 里的内存也没有单独量过。

样本全部是程序生成的合成图。它们以渐变打底,上面叠了几十个半透明矩形和圆再加高斯噪声。它们既不是照片也不是任何用户上传的图。最大的一张是 25000×25000 的 JPEG。补充实测只用了纯色画布,没有加载大图样本。


文章目录

一、单张限 30 MB 安全吗:一个需求把问题逼出来

  • 1.1 为什么要在浏览器里先压
  • 1.2 文件大小不是内存大小
  • 1.3 样本、内核和一组对照

二、一张 canvas 占多少内存

  • 2.1 只建不画几乎不占
  • 2.2 第一次绘制之后的一份
  • 2.3 getImageData 再加一份
  • 2.4 代码一:把实测换算成系数

三、大图解码的内存峰值为什么远超文件大小

  • 3.1 13.4 MB 的 JPEG 与 1289 MiB
  • 3.2 峰值低也可能是失败
  • 3.3 系数怎么取

四、canvas 最大尺寸:预算之外的硬上限

  • 4.1 面积上限与单边上限
  • 4.2 OffscreenCanvas 和 Worker 换不来更大的画布
  • 4.3 img 能解不等于画布能画
  • 4.4 代码二:边长探得起,面积探不起

五、canvas 超限不报错怎么办:失败信号与导出核对

  • 5.1 getContext 和 fillRect 都不报错
  • 5.2 toBlob 回调 null 是三家唯一一致的信号
  • 5.3 img.decode() 的 reject 两头都靠不住
  • 5.4 导出格式有自己的边长上限
  • 5.5 代码三:导出之后再核一遍

六、把内存预算写进上传组件

  • 6.1 代码四:上传前预检
  • 6.2 批量上传按预算排队
  • 6.3 为什么给建议而不是自动缩小

七、适用边界与风险提示

八、测量方法自身的坑

九、还没解决的

参考资料


一、单张限 30 MB 安全吗:一个需求把问题逼出来

1.1 为什么要在浏览器里先压

我们组维护商家后台的商品图链路。最近要加一个批量上传:商家一次选一批详情图,前端先压缩、转格式再传到我们自己的存储。先在浏览器里压有两个原因。一是省带宽;二是合规,这是我更在意的那个。按我们的规定上架前的商品图不能过第三方压缩服务。前两年那次数据合规整改里法务盯「图片去了哪台服务器」盯了三个月。浏览器里处理完再传的数据流是最好讲清楚的。代价是内存从我们的服务器搬到了用户的电脑上。服务端压图出了问题有监控可看;浏览器里一张图把标签页拖白了我们什么都看不到,能收到的只有一句「上传没反应」。

1.2 文件大小不是内存大小

评审时的方案写的是「单张限 30 MB」。有人问这个数怎么来的,答案是沿用了旧组件。我之前脑子里也只有一个公式:画布占 宽×高×4 字节。这句话没错但它只算了一份。一张图从上传到导出的过程中内存里可能同时有好几份像素:解码出来的位图、画布的后备存储、getImageData 读回来的数组,编码器也许还有自己的缓冲。有几份、各多大、峰值落在哪一步,要量了才知道。

JPEG 和 PNG 都是压缩格式,文件大小和像素数之间没有固定比例可言。一张纯色的大图可以压得很小;一张细节多的小图反而可能更大。文件大小这道闸挡不住内存问题。真正该卡的是像素数,而像素数在读完文件头时就能知道、不需要先解码。

1.3 样本、内核和一组对照

我这轮用的是一份现成的三内核实测。它逐档加大画布找上限,另外量了单张画布的内存、连续新建画布的累积内存与十张大样本的解码峰值。每张样本都单独启动一次浏览器去跑,峰值因此不会带着上一张的残留。补充的四段代码是我这周为组件写的,用的是同一台机器和同样三个内核。

对照组我想看的是:现成的端侧工具在大图上会不会偷偷把图传走。我用的是图映 ImgIng(imging.cn/)的「压缩转换」,机器… 亿像素级的样本是 10000×10000 的合成 JPEG,分别转 WebP 和 JPG。这张图在三个内核上都处理完了。我一共跑了 12 个用例并全程抓网络请求,非 GET 请求是 0 次,大图下它依然没有上传。上传超大图后界面会给一条可选的「缩到 2048px」建议,不点的话就按原尺寸处理。这个做法后来影响了我组件里的一个决定,这件事放在第六章讲。


二、一张 canvas 占多少内存

2.1 只建不画几乎不占

先交代量内存的口径。每一步之后把这次启动的全部浏览器进程的 phys_footprint 加起来,再减去页面刚加载完空闲时的值得到增量。工具是 macOS 自带的 footprint 命令;页面里的 API 量不到画布的原因放在第八章讲。第一个结论有点反直觉:只新建一张画布、设好宽高、调一次 getContext('2d'),三个内核的增量都不到 1 MiB,连 16384×16384 也是这样。内存是在第一次绘制时才分配的。这意味着「建画布」这一步什么都证明不了:它既不占内存也不会因为尺寸太大而失败。

2.2 第一次绘制之后的一份

整幅 fillRect 一次之后 Chromium 与 Firefox 的增量正好是 宽×高×4 字节。16384×16384 是 1025 MiB,理论值是 1024 MiB;8192×8192 是 256.6 MiB;两家的线几乎重合。WebKit 在小尺寸上多出几十 MiB 的固定开销:1024×1024 填充后它就有 44.6 MiB,理论值只有 4 MiB。尺寸大了之后这部分被摊薄,16384×16384 填充后 WebKit 是 1206.4 MiB。「宽×高×4」这个公式对画布本身是成立的。它算的是一份每像素 4 字节的 RGBA 数据。

三个内核在不同画布尺寸下的内存增量:填充后与整幅 getImageData 读回后两组线

这张图的横轴是画布像素数、纵轴是浏览器进程的内存增量,要看的是两组线的斜率关系。每个内核各有一条「填充后」和一条「再整幅 getImageData 后」。Chromium 与 Firefox 的读回线正好是填充线的两倍高:16384×16384 这一档是 1025 MiB 对 2050 MiB。WebKit 的读回线最陡,同一档到了 3265 MiB 之多。Firefox 的线一直延伸到 537 百万像素是因为它的画布上限更大,23168×23168 读回后是 4090.9 MiB。

2.3 getImageData 再加一份

很多图片处理代码会整幅 getImageData 一次并拿像素数组去做滤镜、算直方图、找边界。这一步会再分配一份同样大小的内存。Chromium 在 16384×16384 上从 1025 MiB 涨到 2050 MiB;Firefox 从 1025.3 MiB 涨到 2035.6 MiB;WebKit 从 1206.4 MiB 涨到 3265.4 MiB 而比两份像素还多出一截。三个内核在各自能画的最大方形上都能整幅读回:16384×16384 返回的数组是 1 073 741 824 字节,Firefox 在 23168×23168 上返回 2 147 024 896 字节。这一轮没有遇到「能画但读不回」的情况。读得回来不等于该读。只要整幅读一次内存就翻倍。

释放的时候还有一件事。把画布宽高设成 0 并丢掉引用后稍等一会儿再量的话 Chromium 在 16384×16384 这一档仍然比空闲时多 1025 MiB。这里没有强制 GC。我推测是那份 ImageData 还没被回收。这个数不能拿来说泄漏,但它提醒我:组件连续处理几张大图时上一张的内存未必已经还回去。

2.4 代码一:把实测换算成系数

组件要的是一个能乘的系数,数据表派不上用场。我把解码实验里的峰值换算成「几份像素数据」去估一张图走完处理流程要多少内存。

// budget.js:峰值是各进程 phys_footprint 峰值之和(MiB),idle 是空闲基线
const MiB = 1024 * 1024;
const frameMiB = (width, height) => (width * height * 4) / MiB;

const peaks = {
  chromium: { idle: 49,  '10000x10000': 1289, '16384x16384': 2992, '20000x20000': 3724 },
  firefox:  { idle: 308, '10000x10000': 1550, '16384x16384': 3552, '20000x20000': 5026 },
  webkit:   { idle: 63,  '10000x10000': 3525, '16384x16384': 7757, '20000x20000': 8291 },
};
const fullCanvasDrawn = { chromium: ['10000x10000', '16384x16384'], webkit: ['10000x10000', '16384x16384'],
  firefox: ['10000x10000', '16384x16384', '20000x20000'] };

for (const size of ['10000x10000', '16384x16384', '20000x20000']) {
  const [width, height] = size.split('x').map(Number);
  const frame = frameMiB(width, height);
  const cells = Object.entries(peaks).map(([engine, p]) => {
    const k = (p[size] - p.idle) / frame;
    const mark = fullCanvasDrawn[engine].includes(size) ? '' : '*';
    return `${engine} ${k.toFixed(2)}${mark}`;
  });
  console.log(`${size.padEnd(12)} 一份 ${frame.toFixed(1).padStart(7)} MiB | ${cells.join(' | ')}`);
}
10000x10000  一份   381.5 MiB | chromium 3.25 | firefox 3.26 | webkit 9.08
16384x16384  一份  1024.0 MiB | chromium 2.87 | firefox 3.17 | webkit 7.51
20000x20000  一份  1525.9 MiB | chromium 2.41* | firefox 3.09 | webkit 5.39*

代码说明。这段是纯算术,本身不是测量。输入是实测的峰值和空闲基线;输出是「峰值减去基线之后等于几份像素数据」;一份像素数据就是 宽×高×4。带星号的两格要单独看:20000×20000 在 Chromium 和 WebKit 上画进原尺寸画布时没画上,画布那一份内存根本没有分配。这两格的系数因此偏低,不能拿来用。其余格子里 Chromium 与 Firefox 大致在 3 份上下,WebKit 在 5 到 9 份之间。这些峰值来自同一条处理流程:<img> 解码、画进原尺寸画布再导出 JPEG、两次 createImageBitmap。换一种写法的话系数就得重算。


三、大图解码的内存峰值为什么远超文件大小

3.1 13.4 MB 的 JPEG 与 1289 MiB

回到评审上那个问题。10000×10000 的合成 JPEG 文件是 13 434 287 字节即 13.4 MB,放在「单张限 30 MB」的闸前面是个小个子。每个内核都用新启动的浏览器跑一遍处理流程后各进程峰值加起来是 Chromium 1289 MiB、Firefox 1550 MiB、WebKit 3525 MiB。这三个数都没扣空闲基线:Firefox 的基线本来就有 308 MiB 左右;Chromium 约 49 MiB;WebKit 约 63 MiB。

更大的档也一样。26.6 MB 的 16384×16384 JPEG 峰值是 Chromium 2992 MiB、Firefox 3552 MiB、WebKit 7757 MiB;19.6 MB 的 20000×20000 JPEG 在 Firefox 上是 5026 MiB 而在 WebKit 上是 8291 MiB。文件大小和峰值之间没有稳定的比例。决定峰值的是像素数以及流程里同时存在几份像素。这一节我最想让评审会上那位同事看到:30 MB 的闸放进来的图在最坏的内核上能把峰值推到好几 GB。闸该卡在像素数上。

3.2 峰值低也可能是失败

25000×25000 那张图的峰值很有意思:Chromium 只有 90 MiB;Firefox 只有 449 MiB;WebKit 是 9175 MiB。前两个低得离谱的原因是它们根本没解出来。Chromium 的 <img> 在这一档直接 onerror;Firefox 的 <img> 触发了 load 但画出来是全透明的。这件事对监控有影响。组件如果只盯内存并把「峰值低」当成「处理得好」就会把解码失败误判成健康。内存数字要和结果核对放在一起看,结果怎么核对放在第五章讲。

3.3 系数怎么取

拿代码一的输出去掉两个带星号的格子后每个内核取最大值再往上取到 0.5:Chromium 取 3.5、Firefox 取 3.5、WebKit 取 9.5。预估内存等于 宽×高×4 乘这个系数。

这个取法我犹豫过。另一个选项是按像素数分段取系数,因为 WebKit 的系数随尺寸变大在下降,从 9.08 降到了 7.51。分段会更贴近实测,可我手里每个内核只有两到三个有效点。拿三个点拟合一条曲线比取最大值更容易给人错觉。组件里我宁可估高并让一部分本来能处理的图被建议缩小。估低的后果是标签页白掉;估高的后果只是多弹一次提示,两种代价摆在一起我选后者。还有一点要说在前面:这三个系数来自三张合成样本和一条固定的处理流程。你的流程如果多一步整幅 getImageData 的话,按第二章的结论至少要再加 1。


四、canvas 最大尺寸:预算之外的硬上限

4.1 面积上限与单边上限

内存预算管的是「会不会把机器拖慢」。画布还有一道管「能不能画上」的硬上限。这道线和内存大小无关,机器再空闲也跨不过去。Chromium 149 与 WebKit 26.5 构建的面积上限都是 268 435 456 像素。16384×16384 还能画、能导出,多一行的 16384×16385 就不行了。宽 8192 时最大高是 32 768。Firefox 151 构建能画到约 5.37 亿像素的 23168×23168。它没有一条固定的面积线。实测符合的规律是:每行字节对齐到 16 字节后乘以高度的结果小于 2 GiB。这条规律是从几个宽度二分出来的,没有读过源码。单边上限另算。Chromium 与 Firefox 构建都是 65 535。WebKit 构建能画到 4 194 303 但能导出 PNG 的只到 1 000 000。同一个内核里的「能画」和「能导出」也是两条线。

我在 WebKit 26.5 构建里把同一段探针分别跑在 <canvas>、主线程 OffscreenCanvas 和 Worker 里的 OffscreenCanvas 上,画布的高固定 8 px。1000001×8 时三处右下角都读回 0,0,255,255,说明画上了,导出却是 null 或 EncodingError;1000000×8 还能导出 139934 字节的 PNG。4194304×8 时三处右下角都读回 0,0,0,0,4194303×8 还读得对。能画的线在 4 194 303 而能导出的线在 1 000 000,三种画布一模一样。下一节说 Worker 换不来更大的画布,这组读数是证据之一。

4.2 OffscreenCanvas 和 Worker 换不来更大的画布

我原本打算把大图处理挪进 Worker 用 OffscreenCanvas 做。一个理由是不卡主线程;另一个理由是我隐约以为 Worker 里的画布限制会宽一些。第二个理由是错的。三个内核里的 <canvas>、主线程 OffscreenCanvas 与 Worker 里的 OffscreenCanvas 全部阈值完全相同。这一轮没有单独量 Worker 里的内存。Worker 能不能缓解内存问题我不下结论。挪进 Worker 的理由只剩「不卡主线程」这一条,它成立但和预算无关。

4.3 img 能解不等于画布能画

这是我在组件里差点写错的地方。旧组件的流程是先用 <img> 读图,拿到 naturalWidth 就当作「这张图能处理」。实测里 20000×20000 的 JPEG 在三个内核的 <img> 都能解而且缩略图也对。但 Chromium 与 WebKit 构建把它画进原尺寸画布之后是空白图,toBlob 回调 null。只有 Firefox 构建这一步能成功。

Chromium 149 里 16384×16400 的 JPEG 预览和缩小画布都正常,原尺寸导出只得到 4 字节的破图

这是我写的复现小页面在 Chromium 149 里打开一张 16384×16400 的 JPEG,只比 16384×16384 多了 16 行。左边预览下方写着 16384 × 16400 · 25.3 MB。这就是旧组件会看的东西:<img> load 了而宽高也读得出来。中间把它画进 380 × 380 的小画布,图也是对的。红框是点「Export JPEG」按原尺寸导出的结果:export.jpg 只有 4 bytes。它显示成一个破图图标。背后发生的事是这样的。画进原尺寸画布时 drawImage 不抛错。中心像素读回 [0, 0, 0, 0]。isContextLost() 是 true。toBlob 回调 null。另外走 createImageBitmap(blob) 和带 resize 的 createImageBitmap 出的缩略图也都正常。WebKit 构建在这一档也是空白,Firefox 构建的原尺寸画布一直能画到 20000×20000。缩略图能出不代表原尺寸能画。组件里的预检得按「实际要走的那一步」来判。

4.4 代码二:边长探得起,面积探不起

上限表按内核写死不是好办法,版本一升级表就过期了。我想过在页面加载时探一次上限。单边上限探起来很便宜:高度固定 8 px 的话宽度到 1 000 001 也才 800 万像素,一份约 30 MiB。面积上限却探不起。要确认 Firefox 能画 16384×16385 就得真的分配 1 GiB 以上的内存,探测本身就成了一次压测。组件里的做法是单边用探测,面积用配置表。

// side_probe.js:只探单边,另一边固定 8 px
const probeSides = async (widths) => {
  const results = [];
  for (const width of widths) {
    const canvas = document.createElement('canvas');
    canvas.width = width; canvas.height = 8;
    const context = canvas.getContext('2d');
    context.fillStyle = 'rgb(0,160,0)';
    context.fillRect(0, 0, width, 8);
    let tail;
    try { tail = context.getImageData(width - 1, 7, 1, 1).data[1]; }  // 读最右下角那一个像素
    catch (e) { tail = e.name || String(e); }
    const blob = await new Promise(done => canvas.toBlob(done, 'image/png'));
    results.push({ width, tail, png: blob ? blob.size : null });
    canvas.width = canvas.height = 0;
  }
  return results;
};
// 在三个内核的页面里各跑一次:await probeSides([65535, 65536, 1000000, 1000001]),下面是每档结果
chromium  65535: 右下=160 png=10084 | 65536: 右下=0 png=null | 1000000: 右下=0 png=null | 1000001: 右下=0 png=null
webkit    65535: 右下=160 png=9337 | 65536: 右下=160 png=9340 | 1000000: 右下=160 png=139929 | 1000001: 右下=160 png=null
firefox   65535: 右下=160 png=2152 | 65536: 右下=NS_ERROR_FAILURE png=null | 1000000: 右下=NS_ERROR_FAILURE png=null | 1000001: 右下=NS_ERROR_FAILURE png=null

代码说明。判「能画」只读最右下角一个像素的绿色分量,写进去的值是 160。只读中心点会漏掉一种情况:WebKit 在宽 4 194 304 时左上和中心都画上了,只有右下是 0。读回要包在 try 里,因为 Firefox 超限时 getImageData 直接抛 NS_ERROR_FAILURE,Chromium 则安静地返回 0。判「能导出」看 toBlob 回调是不是 null。输出和 4.1 节的上限对得上。Chromium 与 Firefox 在 65 536 失败;WebKit 能画到 1 000 001 但导出到 1 000 001 时回调 null。每一档用完立刻把宽高设成 0,几档探测的内存就不会叠在一起。


五、canvas 超限不报错怎么办:失败信号与导出核对

5.1 getContext 和 fillRect 都不报错

超了上限之后浏览器并不会大声告诉你。超面积的画布上三个内核的 getContext('2d') 照样返回对象,宽高也读回原值。整幅 fillRect 三家都不抛错。区别出在读回这一步:Chromium 与 WebKit 读回全 0 且不抛错,Firefox 在 getImageData 这一步抛 NS_ERROR_FAILURE。控制台也靠不住。Chromium 149 与 Firefox 151 构建超限时控制台一个字都没有。只有 WebKit 26.5 构建在超面积时打一条「Canvas area exceeds the maximum limit」警告,而它在 Worker 里 104 次超面积探针只收到 1 次。

5.2 toBlob 回调 null 是三家唯一一致的信号

三个内核里能一致判断的信号只有一个:toBlob 回调 null,对应的 toDataURL 返回 6 个字符的 "data:,"。另一个办法是画完读回一个已知像素,Firefox 在这一步会抛错也算能捕获。Chromium 另外会让 isContextLost() 返回 true 并在 <canvas> 上触发 contextlost 事件。这个信号只有 Chromium 有,只能当补充。我在组件里两道都做。画完先读右下角一个像素,对不上就判失败;导出后再看 toBlob 的结果。前一道拦住「根本没画上」,后一道拦住「画上了但导不出」。

WebKit 26.5 构建里同一张小图画进 4096 见方和 16384×16385 两张画布,后者空白且导出只有 4 字节

这张图说明「看 toBlob 的结果」为什么要显式判 null。复现页在 WebKit 26.5 构建里把同一张 sample.jpg 画进两张画布,导出时把 toBlob 的回调值直接塞进 new File([blob]),很多上传组件都这么写。左边 4,096 × 4,096 画上了。导出的 export.png 有 15.0 MB。右边 16,384 × 16,385 只多一行,红框里只有棋盘格。下面那行照样显示生成了 export.png,大小 4 bytes。blob 是 null 而 File 却建出来了。类型仍是 "image/png"。内容是 "null" 这四个字符。Chromium 在同一尺寸上也是这样。上传链路要是只看文件在不在,这 4 个字节会一路传到服务端。

5.3 img.decode() 的 reject 两头都靠不住

旧组件里有一行 await image.decode(),它 reject 时组件就提示「图片已损坏」。这一行在两个方向上都会出错。

一个方向是误报。Chromium 149 开源构建在 6000×6000 及以上的 JPEG 上 6 次全部 reject;5000×5000 是 6 次里 3 次;4000×4000 以下是 0 次。可同一张图 onload 正常而且画进小画布也没有透明像素。后来同一台机器上又补过两次实测,那两次里 6000×6000 没有 reject,8000×8000 仍然 reject。这个门槛本身会漂。这个现象只在这个开源构建上见过,正式版 Chrome 这一轮没验证,原因也没查。能确定的只有一点:reject 不代表图坏了。

另一个方向是漏报。Firefox 151 构建遇到解不了的大图时 <img> 照样触发 load 并给出真实的 naturalWidth,画出来却是全透明的。23170×23170、25000×25000 和 70000×1500 的 PNG 这三个样本都是这样。这时唯一能捕获的就是 img.decode() reject。同一个信号在 Chromium 上是噪声,在 Firefox 上却是唯一的线索。我最后把它和读回像素放在一起用:decode reject 并且画出来是透明的才判解码失败。

同一张 25000×25000 的 JPEG 在三个内核里画成缩略图:Chromium 加载报错、WebKit 正常、Firefox 全透明

这张是同一张 25000×25000 的 JPEG 在三个内核里画成缩略图后的产物并排。左边 Chromium 149 的 <img> 直接 load error。红框里什么都没有。它至少报了错。中间 WebKit 26.5 构建画出来是完整的。右边 Firefox 151 构建的 <img> 触发了 load。画进画布后红框里只剩棋盘格,也就是全透明。三个内核三种结果。最危险的是右边这种:事件说成功了而像素一个没有。

5.4 导出格式有自己的边长上限

画布能画并且能导出 PNG 还不等于能导出你要的格式。Chromium 149 把宽或高超过 16383 的画布导出 WebP 时不报错,直接裁掉。20000×64 导出成 16383×64,右边 3617 px 没了。65535×64 导出后只剩左边 16383 px,整整丢了 75%。JPEG 在边长超过 65500 时同样被悄悄裁成 65500。另外两个内核各有各的处理。Firefox 151 构建在这两种情况下 toBlob 回调 null。WebKit 26.5 构建不能编 WebP,会回落成 PNG 并按原尺寸输出;它的 JPEG 能按 65535 原尺寸写出来。问题是宽 65501 以上的 JPEG 在 Chromium 149、Firefox 151 构建和 Pillow 11.3 里都打不开。

5.5 代码三:导出之后再核一遍

既有静默裁切又有格式回落,导出之后的核对就不能只看 blob 在不在。我在组件里加了一步:把导出的 blob 再解一次并比对类型与宽高。

// verify_export.js:blob 在不在、类型对不对、解开后宽高是不是画布的宽高
const exportAndVerify = async ([width, height, type]) => {
  const canvas = document.createElement('canvas');
  canvas.width = width; canvas.height = height;
  const context = canvas.getContext('2d');
  context.fillStyle = 'rgb(200,120,0)';
  context.fillRect(0, 0, width, height);
  const blob = await new Promise(done => canvas.toBlob(done, type, 0.8));
  canvas.width = canvas.height = 0;
  if (!blob) return `${width}x${height} ${type}: blob=null`;
  let image;
  try { image = await createImageBitmap(blob); }
  catch (e) { return `${width}x${height} ${type}: 类型=${blob.type} 解不开(${e.name})`; }
  const same = image.width === width && image.height === height;
  const verdict = blob.type !== type ? '格式被换' : same ? '通过' : '尺寸被改';
  return `${width}x${height} ${type}: 类型=${blob.type} 解开=${image.width}x${image.height} ${verdict}`;
};
// 在三个内核的页面里依次跑:await exportAndVerify([16383, 64, 'image/webp']),再换 20000x64 webp、65500x64 jpeg、65535x64 jpeg
chromium  16383x64 image/webp: 类型=image/webp 解开=16383x64 通过
chromium  20000x64 image/webp: 类型=image/webp 解开=16383x64 尺寸被改
chromium  65500x64 image/jpeg: 类型=image/jpeg 解开=65500x64 通过
chromium  65535x64 image/jpeg: 类型=image/jpeg 解开=65500x64 尺寸被改
webkit    16383x64 image/webp: 类型=image/png 解开=16383x64 格式被换
webkit    20000x64 image/webp: 类型=image/png 解开=20000x64 格式被换
webkit    65500x64 image/jpeg: 类型=image/jpeg 解开=65500x64 通过
webkit    65535x64 image/jpeg: 类型=image/jpeg 解开=65535x64 通过
firefox   16383x64 image/webp: 类型=image/webp 解开=16383x64 通过
firefox   20000x64 image/webp: blob=null
firefox   65500x64 image/jpeg: 类型=image/jpeg 解开=65500x64 通过
firefox   65535x64 image/jpeg: blob=null

代码说明。核对分三层。第一层看 blob 是不是 null。第二层看 blob.type 是不是要的格式。第三层看解开后的宽高是不是画布的宽高。第三层是专门为 Chromium 的静默裁切加的,前两层都拦不住它。输出里最该注意的是 WebKit 65535×64 那一行。它在 WebKit 自己这里判了「通过」,因为 WebKit 能解自己写的文件。核对用的解码器和写文件的是同一个内核时这种情况就漏掉了。预检里 JPEG 的边长我因此在三个内核上一律按 65500 算,不给 WebKit 开口子。这段核对要多解一次图并多占一份内存。对大图来说这笔账也得算进预算,而第六章的系数没有包含它。


六、把内存预算写进上传组件

6.1 代码四:上传前预检

前面五章的结论最后落成一个函数。输入是图的宽高、当前内核、输出格式和一个内存预算。输出是能不能处理;处理不了的话还要给出原因和缩到多少能过。

// plan.js:上限表与系数只对这三个构建成立,当配置用
const MiB = 1024 * 1024;
const LIMITS = {
  chromium: { side: 65535, fits: (w, h) => w * h <= 268435456, webpSide: 16383, jpegSide: 65500, k: 3.5 },
  webkit:   { side: 1000000, fits: (w, h) => w * h <= 268435456, webpSide: null, jpegSide: 65500, k: 9.5 },
  firefox:  { side: 65535, fits: (w, h) => Math.ceil(w * 4 / 16) * 16 * h < 2 ** 31, webpSide: 16383, jpegSide: 65500, k: 3.5 },
};

function planImage(width, height, engine, output, budgetMiB) {
  const lim = LIMITS[engine];
  const frame = width * height * 4 / MiB;               // 一份像素数据
  const reasons = [];
  if (Math.max(width, height) > lim.side) reasons.push('单边超画布上限');
  if (!lim.fits(width, height)) reasons.push('面积超画布上限');
  const outSide = output === 'webp' ? lim.webpSide : lim.jpegSide;
  if (outSide && Math.max(width, height) > outSide) reasons.push(`${output} 边长超 ${outSide}`);
  if (frame * lim.k > budgetMiB) reasons.push(`预估 ${Math.round(frame * lim.k)} MiB 超预算`);
  if (!reasons.length) return { ok: true, estimateMiB: Math.round(frame * lim.k) };
  // 给出一个能过全部检查的缩放比例,交给界面去问用户,不自己偷偷缩
  let scale = 1;
  for (const s of [0.9, 0.75, 0.5, 0.35, 0.25, 0.2]) {
    const w = Math.floor(width * s), h = Math.floor(height * s);
    const side = Math.min(lim.side, outSide || Infinity);
    if (Math.max(w, h) <= side && lim.fits(w, h) && w * h * 4 / MiB * lim.k <= budgetMiB) { scale = s; break; }
    scale = null;
  }
  return { ok: false, reasons, scale };
}
// 样本里的五种尺寸 × 三个内核,输出 JPEG,预算 2048 MiB;下面的输出是 15 行里的 9 行
chromium 10000x10000 通过,预估 1335 MiB
chromium 20000x20000 面积超画布上限;预估 5341 MiB 超预算 → 建议缩放 0.5
chromium 70000x1500  单边超画布上限;jpeg 边长超 65500 → 建议缩放 0.9
webkit   10000x10000 预估 3624 MiB 超预算 → 建议缩放 0.75
webkit   20000x20000 面积超画布上限;预估 14496 MiB 超预算 → 建议缩放 0.35
webkit   70000x1500  jpeg 边长超 65500;预估 3805 MiB 超预算 → 建议缩放 0.5
firefox  10000x10000 通过,预估 1335 MiB
firefox  20000x20000 预估 5341 MiB 超预算 → 建议缩放 0.5
firefox  70000x1500  单边超画布上限;jpeg 边长超 65500 → 建议缩放 0.9

代码说明。这是按阈值和系数推算的结果,没有逐张实测。检查分四项:单边、面积、输出格式的边长、内存预算。四项都过才放行。任何一项不过就往下找一个缩放比例直到四项全过。Firefox 的面积规则写成行字节对齐 16 字节再乘高度,因为实测对得上的是这条规律,一个像素总数对不上。WebKit 的单边写的是它能导出 PNG 的上限 1 000 000,比能画的上限小。2048 MiB 这个预算是我按我们商家常用的办公电脑配置拍的,没有实测支撑,第九章会再提。结果里值得停一下的是 WebKit 那几行。10000×10000 在 Chromium 和 Firefox 上都能过,到了 WebKit 就被系数 9.5 顶到 3624 MiB。这是整个预检里最有争议的一处。系数取得保守的代价就是 WebKit 上会有更多图被建议缩小。

6.2 批量上传按预算排队

批量上传最容易出事的地方是并发。旧组件一次把选中的图全部丢进处理队列并给每张都起一个画布。按系数算一下就知道问题在哪:一张 10000×10000 在 Chromium 上预估 1335 MiB,两张并行就超过了 2048 MiB 的预算。组件现在按预估内存排队,不按张数。正在处理的图预估内存之和不超过预算。大图一张一张来;小图可以几张并行。拿系数去估小图是外推,因为我的系数只来自 1 亿像素以上的三张样本。小图的固定开销占比可能更高,WebKit 1024×1024 填充后就是 44.6 MiB。小图因此另外加了一个张数上限,不完全靠内存去算。

还有一组数据让我对「没崩」保持警惕。那组实验连续新建 4096×4096 的画布并全部保留引用,纯色填充一直加到 7 GiB,噪声填充加到 4 GiB。三个内核都没崩也没有一张画布失效。这是测试设的安全阈值,到了就主动停,崩溃点这一轮没测到。纯色画布的数字还可能被系统内存压缩美化过,这一点第八章会讲。「一直没崩」不是可以放心并发的依据。

6.3 为什么给建议而不是自动缩小

预检不通过时有两种做法:组件直接把图缩到能过的尺寸,或者把建议交给用户。我选了后者。商品详情图是商家自己的东西。悄悄改了尺寸的话商家发现后的第一反应是「你们把我的图弄糊了」,这类问题解释成本很高。第一章那组对照在这一点上也给了我参考。它面对超大图时给的也是一条可选建议,不点就按原尺寸处理。它背后的全部考虑我没法判断,只能说这种做法在用户侧最少意外。我在组件里多做了一步:预检判定会失败的图默认勾上缩放,只是超预算而不会失败的图才只给建议。前者不缩一定出错,后者不缩可能只是慢。

缩放本身怎么做这一轮没测。常见思路有两种。一种是每次缩一半的渐进式降采样,内存峰值比一次缩到位低。另一种是按条带读、按条带处理的分块,同一时刻只有一块在内存里。两种在原理上都能压住峰值。不过先用 <img> 把整张图解出来再缩的话,完整尺寸的那份位图在缩之前就已经进了内存。createImageBitmap 带上 resizeWidth 参数理论上可以在解码时就缩小,但我没有量过它的内存。这一段所有说法都停在原理层面。


七、适用边界与风险提示

7.1 适用场景

浏览器端单张大图的压缩、转格式、裁边。这是本文数据覆盖的链路:<img> 解码、画进画布、toBlob 导出。上传前的像素数预检。宽高在读文件头时就能拿到,预检不需要先解码。批量上传的并发控制。按预估内存排队,比按张数排队更贴近真实开销。

7.2 不适用场景

Safari、iOS、安卓 Chrome、正式版 Chrome 与 Firefox。本文三个内核都是开源构建,正式版浏览器和真机一台都没测。网上流传的各种移动端上限数字本文既不采用也不反驳。GPU 加速的画布。这一轮没查画布是否走 GPU,上限和内存都可能不同。WebGL、WebGPU 纹理。完全没测。HEIC、AVIF、WebP 大图输入。只测了 JPEG 和 PNG。Worker 里的内存。阈值实测相同,内存没有单独量。

7.3 风险提示

系数会过期。它们来自三张样本、一条流程、一台机器。内核升级、流程多一步读回、换一台内存更小的机器都要重新量。预算是拍的。2048 MiB 没有实测依据。给低配电脑用的组件应该把这个数再往下调。静默裁切不会有任何提示。Chromium 导出 WebP 时超过 16383 的边会被裁掉。Blob 一切正常,只有解开比宽高才发现得了这个问题。WebKit 写出的 65535 宽 JPEG 别处打不开。只在 WebKit 上回归会一路通过。控制台安静不代表没问题。Chromium 与 Firefox 超限时一个字都没有。isContextLost() 只在 Chromium 上有意义。另外两个内核一个没有这个方法,另一个返回 false。


八、测量方法自身的坑

量内存这件事在这一轮踩的坑比量上限还多,下面挑和预算最相关的五条。

峰值是各进程峰值之和,会高估。浏览器是多进程的,解码峰值的口径是把每个进程的峰值加起来,而这些峰值不一定发生在同一时刻。表里的 1289 MiB 更像上界,不是某一瞬间的真实占用。它还有一个前提:每张样本都新启动一次浏览器。进程复用的话峰值会带着上一张图的残留。我在系数里用这个口径是有意往高估的方向偏。

画布里画了什么会影响内存读数。连续新建 4096×4096 画布的实验里纯色填充加到 7 GiB 时系统可用内存几乎不动。换成不可压缩的噪声之后同样量级的张数下可用内存明显下降,最低到 2.7 GiB。推测是 macOS 的内存压缩器把纯色页压得很小。压缩器的数值这一轮漏存了,这条只能算推测。它的后果很具体:拿纯色画布去测「能开多少张」会严重高估。测内存预算要用不可压缩的内容。

本机不是干净环境。这台 16 GB 的 Mac 在实测期间还跑着别的任务,系统可用内存在 2.7 到 5.9 GiB 之间浮动。安全阈值因此设得保守。换一台空闲的大内存机器的话停止点会不同。我补跑代码时也守着同样的约束:单张画布面积不超过 16384×16384 且累计不超过 4 GiB。实际最大只用到 1000001×8。

页面里的 API 看不到画布内存。在 Chromium 149 开源构建里调用 performance.measureUserAgentSpecificMemory 会被拒并报 SecurityError,页面已经跨源隔离也一样。WebKit 与 Firefox 构建里没有这个 API。开发者工具里的 JSHeapUsedSize 在 1024×1024 到 16384×16384 各档填充后都是同一个数,整幅读回 1 GB 的 ImageData 之后也只多了一点点。画布和 ImageData 的内存在 JS 堆之外,想量它们只能从操作系统层面看进程。这也解释了组件为什么没法在运行时「量一下再决定」,只能先估。补跑时我在 Chromium 149 里把两种读数并排记过一次,画布 4096×4096。进程内存从 +0.0 涨到 +128.6 MiB,JSHeapUsedSize 只从 949 212 涨到 1 115 836 字节,堆只动了约 0.16 MB。这时 ImageData.data.byteLength 是 67 108 864 字节,正好 64 MiB。读回来的这一份在 JS 堆指标里几乎看不见。

释放之后内存不降不等于泄漏。宽高设成 0 并丢掉引用之后稍等一会儿再量,Chromium 16384×16384 那一档仍然多出 1025 MiB。这里没有强制 GC,回收时机也不由脚本控制,拿这个数去说泄漏是错的。对批量队列来说它意味着「上一张处理完」和「上一张的内存还回来了」之间有一段空档。排队时我把这段空档也算进去了:上一张的预估内存在它结束后延迟一拍才从占用里扣掉。


九、还没解决的

预算该是多少我没有好办法在页面里知道。API 层面量不到画布内存。用户机器当前还剩多少可用内存我也没找到可靠的页面读法,这一轮没试。现在的 2048 MiB 是按办公电脑拍的,对一台 8 GB 内存并且开着一堆标签页的机器来说它可能太高。

系数只来自三张样本。1 亿、2.68 亿、4 亿像素三个点里还有两格因为没画上而作废。小图的系数是外推的。Worker 里的内存没量。我打算把处理挪进 Worker,但它会不会多出一份拷贝这一轮给不出答案。缩放的内存效果没量。渐进式降采样、分块、createImageBitmap 带 resize 参数这三条路都只讲了原理。崩溃点没测。安全阈值以上一概没跑。真机没测。

写完这段已经过了晚上九点。组件的预检先合进了灰度分支。下一轮打算先做两件事。先在灰度版本里按像素数分档记录处理成败,看看真实用户的图落在哪几档。再挑一台 8 GB 的办公电脑用不可压缩的噪声样本按同样的口径重量一遍系数。


参考资料

  • HTML Standard(WHATWG):canvas 元素、toBlob、toDataURL、getImageData 与 contextlost 事件。
  • HTML Standard(WHATWG):decode() 与 createImageBitmap() 及其 resize 选项。
  • HTML Standard(WHATWG):OffscreenCanvas 与 convertToBlob()。
  • Performance Measure Memory API(WICG 草案):调用条件与跨源隔离要求。
  • Chrome DevTools Protocol 文档:Performance.getMetrics 的指标说明。
  • macOS footprint 命令手册:phys_footprint 与峰值字段。
  • libjpeg 的 JPEG_MAX_DIMENSION 常量(与本文实测的 65500 一致;未核对各内核源码)。

本文的原始数据、四段补充代码和它们的输出都留在本机证据目录里并可以逐项复核。