一张 8MB 的照片,放进浏览器后,可能不只占 8MB。
如果它的分辨率是 6000 × 4000,解码为 RGBA 像素后,仅一份画面就接近 92MiB。上传 20 张,理论上已经接近 1.8GiB——这还没算预览、Canvas、缩放结果和编码缓冲区。
最近在开发浏览器图片工具 Pixel Slim 时,我遇到了一个很典型的问题:单张图片处理很流畅,连续上传、调整尺寸、删除、再上传几轮以后,页面却越来越卡。
控制台没有报错,React 组件数量也没有明显增长。问题到底藏在哪里?
答案是:图片工具的内存大头,往往不在 JavaScript 堆,而在浏览器管理的解码像素、Canvas backing store 和 Blob 资源中。
这篇文章不讲“换个框架就好了”,而是从资源生命周期出发,拆解我最终整理出的 5 个治理细节。
一、文件只有 8MB,为什么浏览器可能占用 92MiB?
JPEG、WebP 和 AVIF 文件保存的是压缩数据。浏览器要显示或绘制图片,必须先把它解码为像素。
一张普通 RGBA 图片的基础内存可以近似计算为:
图片内存 ≈ 宽度 × 高度 × 4 字节
以 6000 × 4000 的照片为例:
6000 × 4000 × 4 = 96,000,000 字节 ≈ 91.6MiB
如果同时存在原图预览、ImageBitmap、目标 Canvas 和导出结果,峰值还会继续增加。文件体积是“存储成本”,像素数量才更接近“处理成本”。
因此,只限制文件不能超过 25MB 并不够,还应该限制像素数量、并发数和同时保留的处理结果。
二、先画清楚图片资源的生命周期
图片从用户选择到最终下载,会经过多个资源形态:
渲染错误: Mermaid 渲染失败: Lexical error on line 10. Unrecognized text. …下载] C -. bitmap.close .-> I[释放解码资源] ———————^
这张图里最容易遗漏的,不是创建资源,而是每一条虚线。
如果一个页面只打开一次,刷新后浏览器会帮你收尾,问题可能不明显。但在 SPA 中,用户可以反复上传、删除和切换工具,资源会在同一个页面会话里不断累积。
三、细节 1:把 Object URL 当成需要归还的资源
下面这行代码很方便:
const previewUrl = URL.createObjectURL(file);
但 Object URL 不是普通字符串。浏览器会维护它与 Blob 之间的映射,使用结束后应该显式回收:
function revokeJobUrls(job) {
URL.revokeObjectURL(job.previewUrl);
if (job.result?.url) {
URL.revokeObjectURL(job.result.url);
}
}
至少要覆盖三个时间点:
React 中有一个容易踩的闭包问题。下面的清理函数只会看到首次渲染时的空数组:
useEffect(() => {
return () => jobs.forEach(revokeJobUrls);
}, []);
更稳妥的做法是用 Ref 保存最新状态:
function useJobCleanup(jobs) {
const jobsRef = useRef(jobs);
jobsRef.current = jobs;
useEffect(() => {
return () => {
jobsRef.current.forEach(revokeJobUrls);
};
}, []);
}
需要注意:revokeObjectURL 释放的是 URL 映射,不等于立刻清空所有图片内存。如果 <img>、Canvas 或 JavaScript 变量仍然持有解码结果,资源仍可能继续存在。
四、细节 2:ImageBitmap 必须在 finally 中关闭
读取图片尺寸时,我优先使用 createImageBitmap:
async function loadImageSource(file) {
if (window.createImageBitmap) {
const bitmap = await createImageBitmap(file);
return {
source: bitmap,
width: bitmap.width,
height: bitmap.height,
close: () => bitmap.close()
};
}
return loadImageElement(file);
}
ImageBitmap 的优势之一是提供了明确的 close()。真正关键的是:不能只在成功路径关闭它。
async function resizeImage(job, settings) {
const image = await loadImageSource(job.file);
try {
return await renderResizeResult(image, settings);
} finally {
image.close();
}
}
无论 Canvas 创建失败、尺寸校验失败,还是编码中断,finally 都会执行。
如果降级使用 <img>,也可以让加载函数返回统一的 close(),内部回收临时 Object URL。上层只关心资源协议,不需要判断具体解码方式。
五、细节 3:Canvas 用完后,不要只等垃圾回收
Canvas 的像素缓冲区同样很大。一个 6000 × 4000 的 Canvas,基础像素空间也接近 92MiB。
如果一个任务结束后仍然持有 Canvas 引用,或者闭包、历史记录保存了它,内存就无法及时下降。
临时 Canvas 可以采用“创建、使用、释放”模式:
async function renderToBlob(image, plan, mimeType) {
const canvas = document.createElement('canvas');
canvas.width = plan.width;
canvas.height = plan.height;
try {
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0, plan.width, plan.height);
return await canvasToBlob(canvas, mimeType);
} finally {
canvas.width = 1;
canvas.height = 1;
}
}
将尺寸重置为极小值,可以提示浏览器释放较大的 backing store。最终何时归还内存仍由浏览器决定,但这比长期保留大画布更可控。
另外,不要把 Canvas、Context 或完整像素数组放进 React State。State 更适合保存宽高、缩放比例、裁剪坐标等可序列化状态,大型资源应放在短生命周期局部变量或 Ref 中。
六、细节 4:批量处理不能直接把 20 个任务全扔给 Promise.all
这段代码看起来并行度很高:
await Promise.all(files.map(processImage));
问题是 20 个任务会同时进入解码、绘制或编码阶段。CPU、内存和主线程都可能在短时间内出现尖峰。
我最终使用的是固定 Worker 数量的任务池:
async function runPool(items, worker, concurrency = 3) {
let nextIndex = 0;
async function runWorker() {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await worker(item);
}
}
const workerCount = Math.min(concurrency, items.length);
await Promise.all(Array.from({ length: workerCount }, runWorker));
}
它的运行逻辑如下:
#mermaid-svg-l1JkITLynvzYT29X{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-l1JkITLynvzYT29X .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l1JkITLynvzYT29X .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l1JkITLynvzYT29X .error-icon{fill:#552222;}#mermaid-svg-l1JkITLynvzYT29X .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l1JkITLynvzYT29X .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l1JkITLynvzYT29X .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l1JkITLynvzYT29X .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l1JkITLynvzYT29X .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l1JkITLynvzYT29X .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l1JkITLynvzYT29X .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l1JkITLynvzYT29X .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l1JkITLynvzYT29X .marker.cross{stroke:#333333;}#mermaid-svg-l1JkITLynvzYT29X svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l1JkITLynvzYT29X p{margin:0;}#mermaid-svg-l1JkITLynvzYT29X .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-l1JkITLynvzYT29X .cluster-label text{fill:#333;}#mermaid-svg-l1JkITLynvzYT29X .cluster-label span{color:#333;}#mermaid-svg-l1JkITLynvzYT29X .cluster-label span p{background-color:transparent;}#mermaid-svg-l1JkITLynvzYT29X .label text,#mermaid-svg-l1JkITLynvzYT29X span{fill:#333;color:#333;}#mermaid-svg-l1JkITLynvzYT29X .node rect,#mermaid-svg-l1JkITLynvzYT29X .node circle,#mermaid-svg-l1JkITLynvzYT29X .node ellipse,#mermaid-svg-l1JkITLynvzYT29X .node polygon,#mermaid-svg-l1JkITLynvzYT29X .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-l1JkITLynvzYT29X .rough-node .label text,#mermaid-svg-l1JkITLynvzYT29X .node .label text,#mermaid-svg-l1JkITLynvzYT29X .image-shape .label,#mermaid-svg-l1JkITLynvzYT29X .icon-shape .label{text-anchor:middle;}#mermaid-svg-l1JkITLynvzYT29X .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-l1JkITLynvzYT29X .rough-node .label,#mermaid-svg-l1JkITLynvzYT29X .node .label,#mermaid-svg-l1JkITLynvzYT29X .image-shape .label,#mermaid-svg-l1JkITLynvzYT29X .icon-shape .label{text-align:center;}#mermaid-svg-l1JkITLynvzYT29X .node.clickable{cursor:pointer;}#mermaid-svg-l1JkITLynvzYT29X .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-l1JkITLynvzYT29X .arrowheadPath{fill:#333333;}#mermaid-svg-l1JkITLynvzYT29X .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-l1JkITLynvzYT29X .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-l1JkITLynvzYT29X .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l1JkITLynvzYT29X .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-l1JkITLynvzYT29X .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l1JkITLynvzYT29X .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-l1JkITLynvzYT29X .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-l1JkITLynvzYT29X .cluster text{fill:#333;}#mermaid-svg-l1JkITLynvzYT29X .cluster span{color:#333;}#mermaid-svg-l1JkITLynvzYT29X div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-l1JkITLynvzYT29X .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-l1JkITLynvzYT29X rect.text{fill:none;stroke-width:0;}#mermaid-svg-l1JkITLynvzYT29X .icon-shape,#mermaid-svg-l1JkITLynvzYT29X .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l1JkITLynvzYT29X .icon-shape p,#mermaid-svg-l1JkITLynvzYT29X .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-l1JkITLynvzYT29X .icon-shape .label rect,#mermaid-svg-l1JkITLynvzYT29X .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l1JkITLynvzYT29X .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-l1JkITLynvzYT29X .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-l1JkITLynvzYT29X :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
20 个待处理任务
Worker 1 领取一个任务
Worker 2 领取一个任务
Worker 3 领取一个任务
完成或记录失败
队列是否为空
汇总全部结果
并发数不是越大越好。图片调整尺寸通常使用 2 个并发,网络压缩请求可以使用 3 个;最终数值应该结合图片尺寸、设备性能和服务端限制测试。
如果希望单个任务失败后其他任务继续执行,要在 worker 内部捕获错误并更新任务状态,不能让一次异常直接终止整条处理通道。
七、细节 5:过期的异步结果,也要主动销毁
还有一种资源泄漏很隐蔽:用户开始批量处理后,立刻修改了目标尺寸。
旧任务不会自动消失。它可能在几秒后返回一个 Blob URL,并覆盖新设置下的状态。即使界面没有采用这个结果,URL 也已经创建了。
我给设置增加了版本号:
async function processJob(job) {
const revision = settingsRevision.current;
const result = await resizeImage(job, settings);
if (revision !== settingsRevision.current) {
URL.revokeObjectURL(result.url);
return;
}
updateJob(job.id, { status: 'done', result });
}
这解决了两个问题:
- 旧任务不能覆盖新设置的结果;
- 已经产生但不再需要的资源可以立即回收。
如果处理函数支持取消,还可以进一步配合 AbortController。但取消请求不代表自动回收已经创建的 Blob、Canvas 和图片对象,资源清理仍然需要独立完成。
八、我是怎么验证内存真的降下来了
只盯着 Chrome DevTools 的 JavaScript Heap 不够,因为图片解码和 Canvas 内存不一定完整统计在 JS 堆中。
我更关注可重复的操作曲线:
不要要求内存立刻回到初始值。浏览器会保留部分缓存,GC 时间也不固定。真正危险的信号是:执行完全相同的操作后,基线一轮比一轮高,而且空闲很久仍不回落。
九、图片工具的内存检查清单
上线前,我会逐项检查这些问题:
- 每一次 createObjectURL 是否都有对应的 revokeObjectURL;
- ImageBitmap 是否在 finally 中调用 close();
- 删除、重置和组件卸载是否都能释放资源;
- 大型 Canvas 是否被闭包、历史记录或 State 长期持有;
- 批量任务是否设置了合理并发上限;
- 旧设置产生的异步结果是否会被丢弃并清理;
- 文件大小限制之外,是否还有像素数量限制;
- 单个任务失败后,其他任务能否继续完成。
结语
图片处理页面越用越卡,未必是 React 渲染太多,也未必是某个库性能差。很多时候,真正的问题是我们创建了图片资源,却没有为它设计完整的退出路径。
Object URL 要回收,ImageBitmap 要关闭,Canvas 要缩短生命周期,批量任务要限制并发,过期结果也要销毁。
优化图片工具的关键,不是让每个任务跑得更猛,而是控制同一时刻有多少资源活着,并确保它们在正确的时间离场。
以上实践来自我开发 Pixel Slim 图片工具时对批量压缩、尺寸调整和拼图流程的整理。项目体验地址:https://image.997728.xyz


