欢迎光临
我们一直在努力

上传 20 张图片后页面越来越卡:文件没多大,浏览器却可能吃掉 1.8GB

一张 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 堆中。

    我更关注可重复的操作曲线:

  • 打开 Chrome 任务管理器,记录页面初始内存;
  • 上传同一组高分辨率图片;
  • 完成处理并删除全部图片;
  • 重复上传、处理、删除 5~10 轮;
  • 观察内存是否持续单向增长,还是会回落到相近区间;
  • 再通过 Memory 面板检查是否仍有组件、图片元素或闭包被引用。
  • 不要要求内存立刻回到初始值。浏览器会保留部分缓存,GC 时间也不固定。真正危险的信号是:执行完全相同的操作后,基线一轮比一轮高,而且空闲很久仍不回落。

    九、图片工具的内存检查清单

    上线前,我会逐项检查这些问题:

    • 每一次 createObjectURL 是否都有对应的 revokeObjectURL;
    • ImageBitmap 是否在 finally 中调用 close();
    • 删除、重置和组件卸载是否都能释放资源;
    • 大型 Canvas 是否被闭包、历史记录或 State 长期持有;
    • 批量任务是否设置了合理并发上限;
    • 旧设置产生的异步结果是否会被丢弃并清理;
    • 文件大小限制之外,是否还有像素数量限制;
    • 单个任务失败后,其他任务能否继续完成。

    结语

    图片处理页面越用越卡,未必是 React 渲染太多,也未必是某个库性能差。很多时候,真正的问题是我们创建了图片资源,却没有为它设计完整的退出路径。

    Object URL 要回收,ImageBitmap 要关闭,Canvas 要缩短生命周期,批量任务要限制并发,过期结果也要销毁。

    优化图片工具的关键,不是让每个任务跑得更猛,而是控制同一时刻有多少资源活着,并确保它们在正确的时间离场。

    以上实践来自我开发 Pixel Slim 图片工具时对批量压缩、尺寸调整和拼图流程的整理。项目体验地址:https://image.997728.xyz

    赞(0)
    未经允许不得转载:171主机测评 » 上传 20 张图片后页面越来越卡:文件没多大,浏览器却可能吃掉 1.8GB
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址