前端如何区分已发起下载和已保存文件
做图片工具时,最容易被误报表述的是“用户下载了多少张”。前端点击了下载按钮,只能说明页面发起了一次保存流程;浏览器接手后,用户可能取消、改名、保存到云盘、转发到其他应用,网页通常无法知道文件是否真的落到了磁盘或相册。本文结合我在图片猫项目中整理下载统计时遇到的实际问题,说明如何把“保存意图”“保存操作”“浏览器交接”和“文件已保存”拆成不同状态。
文章中的项目代码摘录来自当前仓库,教学简化示例会单独标注。文中所说的“已完成验证”仅指仓库记录的本地测试和模拟后台检查,不等于所有真实用户设备的保存结果都可被网页确认。

图 1 下载链路的四个状态:最后一步通常不在网页可观测范围内(合成示意图)
一、先定义问题:下载不是一个布尔值
1. 保存意图:用户点击下载、长按图片或打开保存菜单,表示“想保存”,不表示已经生成文件。
2. 保存开始:前端已经创建 Blob、请求下载地址或准备系统分享,这是页面内部可以确认的动作。
3. 交接成功:浏览器、系统分享面板或新预览页已经接手,但交接成功仍不等于用户完成了保存。
4. 文件已保存:只有系统或客户端明确回调,网页才有条件把状态提升到“已保存”;普通桌面浏览器下载通常没有这样的回调。
这四层如果被压成一个 downloaded=true,后台会出现两个问题:一是把取消和失败算成成功,二是把一个 ZIP 包错误地当成一张图片。统计口径应该先承认未知,再谈转化率。
二、浏览器为什么不能告诉你文件是否落盘
传统的 a.download、Blob URL 和浏览器下载管理器之间存在安全边界。页面可以请求下载并提供建议文件名,但用户选择了哪个目录、是否覆盖同名文件、是否被安全策略拦截,通常由浏览器和操作系统控制。MDN 对 download 属性的说明也明确提示:最终行为会因浏览器、用户设置以及资源处理方式而变化。
| 网页能确认 | 网页不能可靠确认 | 建议记录 |
| 按钮事件发生、保存上下文已创建 | 用户是否点击了浏览器下载栏的完成按钮 | result_save_started |
| Blob / URL 请求成功、保存方法已选择 | 文件是否写入磁盘、相册或第三方应用 | result_save_handoff + method |
| Web Share API Promise 返回,表示已交给分享目标 | 分享目标是否最终保存、转发或取消后续操作 | share 单列,不算下载文件数 |
| 部分客户端提供相册保存成功回调 | 普通 Web 页面是否能读取本地下载目录 | result_save_succeeded 仅用于有明确回调的客户端 |
因此“下载文件数”只能在证据足够时累计。例如当前项目只把 method=download 的 handoff 作为可计数下载,并把分享、预览、取消、失败和数量未知单独统计。这个定义比追求一个看起来完整的数字更可靠。
三、图片猫公共保存工具怎样实现分层状态
当前 webClientVue/src/utils/download.ts 统一处理单文件、对象 URL、ZIP 和移动端分享。它先创建保存尝试 ID,再根据设备能力选择 Web Share、iOS 图片预览或普通 a.download;方法返回的是 download、share、preview 或 cancelled,而不是“文件已落盘”。
项目代码摘录:webClientVue/src/utils/download.ts。省略了链接节点清理和异常上下文。
export type DownloadMethod = 'download' | 'share' | 'preview' | 'cancelled';
const triggerAnchorDownload = (blob: Blob, filename: string): DownloadMethod => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.dataset.piccatActivityDownload = 'true';
document.body.appendChild(link);
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
return 'download';
};
这里的返回值命名很重要:download 表示页面触发了浏览器下载通道,不应被翻译成 saved。iOS 图片预览返回 preview,用户还需要在预览页长按并选择系统保存动作。
项目代码摘录:保存尝试先生成 attemptId,并记录 started;这不是可直接运行的完整函数。
const beginSave = (options: DownloadBlobOptions, blob?: Blob) => {
const context = {
...options,
attemptId: options.attemptId || createSaveAttemptId(),
saveContainer: options.saveContainer || 'single',
};
if (!options.attemptId) reportResultSave('started', context);
return context;
};
四、一次 ZIP 为什么要同时记录操作次数和文件数
批量工具的用户动作是“一次打包下载”,但包内可能有 2 张、45 个甚至更多文件。当前公共 downloadArchive 会按最终 ZIP 目录统计非目录项,排除同名覆盖后的重复项;无法解析的成品 ZIP 不猜测数量,空包直接拒绝下载。
项目代码摘录:webClientVue/src/utils/download.ts。数量统计只针对可枚举的最终目录。
const savedFileCount = archive.files
? Object.values(archive.files).filter(file => !file.dir).length
: undefined;
const context = beginSave({
...options,
saveContainer: 'archive',
savedFileCount
});
| 指标 | 单位 | 什么时候增加 | 不能推出什么 |
| 保存意图 | 次 | 点击或长按等保存相关动作 | 不代表文件已经生成或保存 |
| 下载操作 | 次 | 一次有效的保存交接,按 attemptId 去重 | 不等于包内文件数量 |
| 下载文件数 | 文件 | method=download 且 savedFileCount 可确认 | 不代表用户打开或使用了每个文件 |
| 数量未知 | 次 | 有交接但缺少可靠文件数量或方法不唯一 | 不能默认填 1 |
服务端聚合还会把分片和网络重发按 attemptId 合并,跨功能 ZIP 按明确的 savedFeatureFileCounts 分配;缺少归属信息时进入未归属或未知,而不是按时间猜测。这个设计解决的是统计诚实性,不是把浏览器变成文件系统监控器。
五、保存事件如何把“可观测”和“不可观测”写清楚
项目代码摘录:resultSaveTracking.ts 的事件阶段示意,succeeded 只适用于明确回调的客户端。
reportResultSave('started', context);
// ... navigator.share / a.download / 图片预览
reportResultSave('handoff', context, { method: resolvedMethod });
// 只有有明确客户端回调时,才可以上报 succeeded
reportResultSave('succeeded', context, { method: 'album' });
在普通桌面浏览器里,handoff 是比较稳的上限;在微信小程序等能返回相册结果的客户端,才可能把客户端回调单列为 succeeded。后台页面也因此明确提示“下载为 0,不一定代表用户没有保存”,历史漏采和数量未知不能被补造成未下载。
建议把事件名称和值设计成状态机,而不是一个布尔字段。这样用户取消分享时可以记录 cancelled,分享面板打开时记录 share,图片预览时记录 preview,后续分析不会把这些动作混进下载文件数。
六、案例素材:批量结果展示不等于文件已落盘
下图使用项目现有批量压缩案例素材,展示的是批量结果前后对比,不是浏览器下载完成截图。它适合说明“结果列表可以展示处理结果”,但不能证明用户已经保存了文件;真正的保存状态仍要看公共下载链路的事件。

图 2 项目批量压缩案例素材:结果展示与本地文件落盘是两件事
七、当前实现、尚未完善与建议方案
| 状态 | 当前内容 | 边界或下一步 |
| 当前已经实现 | 统一 downloadBlob、downloadObjectUrl、downloadArchive;保存尝试 ID;单文件按 1 计数;ZIP 最终目录计数;分享、预览、取消、失败分开;后台展示下载操作、下载文件数、数量未知。 | 这是代码和本地聚合逻辑中的实现,不等于每台设备都能返回落盘结果。 |
| 尚未完善 | 普通 Web 页面不能确认用户是否在下载栏完成保存;部分本地工具仍缺少完整 localRunId 关联;历史没有采集到的数量无法可靠补造。 | 不能用下载文件数除处理次数当作下载率,也不能把 0 当成用户没有保存。 |
| 建议方案 | 需要强确认时,使用客户端原生保存回调;网页侧保留“已发起保存”和“请到浏览器下载记录确认”的提示。对关键交付记录文件名、哈希和用户确认动作。 | showSaveFilePicker 等能力存在兼容性与安全上下文限制,不应直接当成全端默认方案。 |
八、发布前验收清单
1. 单图下载:检查 started、handoff、failed 是否按一次 attemptId 关联,重复点击是否按产品口径另计。
2. ZIP 下载:准备含目录、同名覆盖、空包和无法解析包的样例,确认文件数、未知数和错误状态分别落表。
3. 移动端:分别测试分享成功、用户取消、图片预览长按保存;不要把分享 Promise 返回直接标成相册保存成功。
4. 页面离线或刷新:确认采集失败不会阻止文件保存,补发事件不会重复累计;页面关闭导致的事件缺失应显示覆盖不完整。
5. 后台统计:核对“保存操作”和“下载文件数”两列,确认历史旧记录、OCR 复制和未归属包没有被强行推定。
仓库记录的本地验证包括下载聚合、分片去重、ZIP 数量、取消分享和模拟后台展示;发布后仍需要用测试账号在目标浏览器和移动端执行真实交接。浏览器能力边界决定了这类验收不能只看前端按钮有没有触发。
参考资料
MDN:download 属性:download - SVG | MDN
MDN:Navigator.share():Navigator: share() method - Web APIs | MDN
本文案例来自我正在维护的图片猫图片处理项目,演示入口:图片猫(PicCat)
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)