反复选图、切换预览、打开大图,再返回列表,页面里的图片换了一轮,旧资源却未必已经退出。排查这类问题时,我会先给每一次 createObjectURL 找到对应的使用者和结束条件,而不是看到内存上涨就直接认定泄漏。
这次以我开发的图片猫(PicCat)为例,梳理批量改尺寸预览和 AI 改图预处理的两段实现。项目出处:www.piccat.cn
先给结论:用于持续预览的 URL,应保留到相关视图不再使用它;仅供临时处理的 URL,应随任务结束清理。img.onload 只说明一次加载完成,不能自动代表整个业务已经用完。
1 为什么清空 src 还不够
URL.createObjectURL(file) 返回一个 blob: 地址。它不是把图片编码进字符串,也不是上传后得到的服务器地址。浏览器另外维护这个地址与 Blob 或 File 的关联;只把保存地址的变量设为空,不等于撤销关联。12

同一个 Blob 多次调用 createObjectURL,会得到不同地址,都需要分别管理。只要相关对象 URL 仍有效,底层对象就可能继续被保留。revokeObjectURL 撤销的是地址关联,不会替你删除业务数组中的 File,也不保证解码像素、Canvas 缓冲或浏览器进程内存立即回落。12
因此,不能把 createObjectURL 放进模板表达式或每次渲染都会调用的 getter。先在明确的文件变更事件里创建一次,再把同一个地址交给需要它的预览视图。
2 项目里已经怎样管理预览
在 webClientVue/src/components/batch/BatchResize.vue 中,父组件把当前选中的 File 传给 BatchResizePreview.vue。子组件用同一个 previewSrc 显示背景图、效果图和放大层。修改尺寸参数只触发重算,源文件变化才创建新的对象 URL。
项目代码摘录 BatchResizePreview.vue
以下保留实际生命周期逻辑;省略 props、响应式变量声明、模板、样式及 recalculate 的计算细节。这是关键片段,不能单独运行。
let currentObjectUrl = '';
function cleanup() {
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = '';
}
}
watch(() => props.sourceFile, (file) => {
cleanup();
if (!file) {
previewSrc.value = '';
return;
}
const url = URL.createObjectURL(file);
currentObjectUrl = url;
const img = new Image();
img.onload = () => {
sourceWidth.value = img.width;
sourceHeight.value = img.height;
previewSrc.value = url;
recalculate();
};
img.src = url;
}, { immediate: true });
onBeforeUnmount(cleanup);
这里已有两个明确的回收点:换图前调用 cleanup,组件卸载前再次调用 cleanup。清空文件也会先清理旧 URL。这避免了"每次选图只创建、不释放"的直接累积路径。
还有一个值得保留的设计:代码没有在 img.onload 里马上 revoke。因为这次 Image 加载只是读出尺寸,后面模板还要用同一个地址创建图片元素,用户也可能稍后才打开大图。过早撤销,会让后续使用失去有效入口。
3 有清理函数还需要检查哪些边界
读完这段代码,我不会把它描述成"已经彻底解决内存问题"。它完成了基础回收,但还有两个与生命周期直接相关的缺口。以下是源码层面的风险分析,不是已经复现的线上故障。
第一,缺少 img.onerror。新图解码失败后,这个 URL 会继续保留到下次切图或卸载;切入新文件时也没有同步清空旧 previewSrc,旧状态可能继续显示,但旧地址已经被撤销。这不等于必然无限泄漏,却说明失败态和资源状态没有一起收束。
第二,旧 onload 没有失效判断。假设 A 仍在加载,用户已经切到 B,不能仅靠 revoke A 就推断 A 的异步回调绝不再产生影响。代码需要明确拒绝旧任务提交宽高和预览地址,卸载时同样如此。

选择最小的所有权边界
这个组件只有一份当前预览,不必先建立全局 URL 池。我倾向于让每一轮 watch 拥有自己的 URL 和 Image:下一轮开始或监听停止时,上一轮清理;失败时主动清理;回调先检查是否失效。下一节给出这个建议的关键片段。
在 Vue 中,同步创建于 setup 的 watcher 随组件卸载停止;watch 回调第三个参数提供 onCleanup,可用于注册失效时的清理。3 接入时应替换原有 watcher 与 cleanup,避免两套代码同时创建资源。
本文选择"换图先清空,再显示新图"的简单交互。若要保留旧图直到新图准备好,应同时持有旧预览和候选预览:候选成功并完成 DOM 切换后回收旧图,候选失败只回收候选。这样体验更平滑,但峰值期间会同时保留两份资源。
4 建议把失效处理和地址回收放在一起
建议改进代码 尚未合入项目
以下用于客户端 Vue setup;需要导入 watch、nextTick,并沿用 props、previewSrc、sourceWidth、sourceHeight 和 recalculate,新增 loadError 字符串 ref。省略模板、加载提示和尺寸算法,不能直接当作完整组件使用。
watch(() => props.sourceFile, (file, _old, onCleanup) => {
previewSrc.value = '';
sourceWidth.value = sourceHeight.value = 0;
loadError.value = '';
if (!file || typeof window === 'undefined') return;
const img = new Image();
const url = URL.createObjectURL(file);
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
img.onload = img.onerror = null;
img.removeAttribute('src');
// 等本轮 DOM 更新撤下旧预览后,再撤销地址。
void nextTick(() => URL.revokeObjectURL(url));
};
onCleanup(dispose);
img.onerror = () => {
if (disposed) return;
loadError.value = '图片读取失败,请重新选择';
dispose();
};
img.onload = () => {
if (disposed) return;
if (!img.naturalWidth || !img.naturalHeight) {
loadError.value = '图片尺寸无效';
dispose();
return;
}
sourceWidth.value = img.naturalWidth;
sourceHeight.value = img.naturalHeight;
previewSrc.value = url;
recalculate();
};
img.src = url;
}, { immediate: true });
disposed 阻止过期结果写回,移除事件和 src 结束临时 Image 的使用。这里将 revoke 延后到 nextTick,是给当前组件一次撤下旧预览的 DOM 更新机会;它不是浏览器内存回收通知,也不是所有消费者都已结束的证明。
这个方案假设 URL 只供本组件使用。若有离场动画、独立大图窗口或其他组件仍引用旧地址,应把回收点延后到它们确实退出;KeepAlive 缓存的页面也不能把"离开路由"直接当成"已卸载"。
5 临时处理任务适合在 finally 中清理
再看 webClientVue/src/utils/aiEditImagePreparation.ts。直接可解码的图片会经过 loadImage、绘制 Canvas、编码和校验,最终返回新的 File。原始 URL 只服务这次处理,不需要继续交给页面预览。
项目代码摘录 任务出口与画布释放
下面是原函数结尾和 releaseCanvas 辅助函数。省略 try 中的缩放、编码及校验逻辑,保留真实清理代码;两段属于同一文件的不同位置。
return readyFile;
} finally {
canvases.forEach(releaseCanvas);
if (loadedImage) {
URL.revokeObjectURL(loadedImage.url);
loadedImage.image.src = '';
}
}
};
const releaseCanvas = (canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
canvas.width = 1;
canvas.height = 1;
};
finally 覆盖了成功返回、用户取消后返回 null,以及 try 内抛错的出口。它同时缩小临时画布,并解除图片元素对源地址的使用。这比只在成功分支的末尾补一行 revoke 更容易审查。缩小画布用于重置其位图存储,不应解释为内存已同步归还操作系统。
这里还有一处分工不能省略:如果 loadImage 自己失败,loadedImage 尚未赋值,外层 finally 拿不到地址。因此 loadImage 的 onerror 和无效尺寸分支已经自行 revoke,并清空图片事件及 src。资源还没交给调用者时,由创建它的函数承担清理。
|--------------|----------------|--------------------|
| 使用场景 | 何时结束使用 | 回收位置 |
| 可交互图片预览 | 换图、删除或相关视图真正退出 | 预览所有者的清理流程 |
| 临时解码与图片处理 | 成功、失败或取消并结束任务 | finally,加上交接前的失败清理 |
| 多个视图共享同一 URL | 最后一个使用者退出 | 共同所有者统一管理 |
这不代表整个图片处理链已经完成资源审计。该工具还有旧流程回退分支;取消提示返回 null 能进入 finally,也不等于存在可随时中止解码的 AbortSignal。下载、新窗口打开等用途也需要自己的交接策略,不能机械套用预览的释放时机。
6 如何验证而不把内存曲线当结论
源码核对与测试结果要分开说。本文核对了上述创建、回收和调用路径,也读到了 ai-edit-image-preparation.spec.cjs 中针对用户取消、比例限制拒绝的 URL 回收断言,以及缩放后画布重置检查;本文没有重跑这套测试,也没有进行线上内存压测。
建议片段在 Node 中使用 Image、watch 和 nextTick 替身做了受控回调测试,检查切图失效、失败清理、停止监听和重复清理等控制流。它不能替代真实浏览器解码、Vue DOM 更新、大图弹层和跨浏览器验收。
建议验收清单
-
先看资源是否成对。测试环境记录实际创建的 URL,回收时按地址从 Set 中删除;成功预览期间允许保留当前地址,彻底退出后应回到本模块的基线。不能仅比较两个 API 的总调用次数,因为重复 revoke 会掩盖漏回收。
-
覆盖状态切换。反复切换 A 与 B,加载中清空、卸载;选择损坏图片;加载成功后再打开大图。检查是否出现旧图回写、旧宽高、无法放大或失败后仍保留候选 URL。需要控制回调顺序时,用可延迟的 Image 替身测试,不能只依赖人工快点。
-
再观察内存趋势。用相同图片集重复"进入、选图、切图、清空、退出",在相同静置条件下比较多轮基线。Chrome 任务管理器、Performance 和 Memory 面板观察的层次不同;JS 堆快照适合找数组、闭包、DOM 等引用,不能单靠它证明全部图片像素和 GPU 资源已释放。4
-
让验证口径可重复。记录浏览器版本、图片像素与文件大小、循环次数、是否开启缓存及采样时间;不要在控制台长期保存 File 或 Image 对象。即便 URL 数量归零,也仍需检查 selectedFiles、结果 Blob、撤销历史和画布是否被业务继续持有。
兼容性与使用边界
对象 URL 在主流浏览器已广泛支持,但这不保证所有图片格式都可解码。Service Worker 不提供这两个静态方法;SSR 中也不要执行浏览器图片处理逻辑。2 本文片段用 window 检查避开服务端分支,API 可用性与实际格式支持仍应在目标环境验证。
我最终要确认的是:这个地址现在还有谁会用,谁负责在最后一次使用结束后回收。把这两个问题写进组件和任务的生命周期,才能区分正常保留、遗漏回收,以及"释放太早导致功能坏了"。