浏览器二进制文件下载:Blob、文件名与资源释放
本文目录
导出 Excel、下载 PDF 或保存服务端生成的压缩包时,响应体不是普通 JSON。下载链路要同时约定状态码、媒体类型、文件名、身份校验和浏览器资源释放。把响应强行当字符串或 JSON 解析,常见结果就是文件打不开;创建 Blob URL 后不回收,则可能让页面长期持有大对象。
先选下载路径
| 场景 | 优先方案 | 原因与限制 |
|---|---|---|
| 静态文件或无需自定义请求头 | 普通 <a href>,由服务端返回 Content-Disposition: attachment | 浏览器可以直接处理下载;大文件不必先完整放进 JavaScript 内存 |
| 需要 Bearer header、页面内错误提示或客户端解密 | fetch / XHR 读取 Blob,再用临时对象 URL 触发下载 | 适合受控体积;Response.blob() 会读完整响应体,内存占用随文件大小增加 |
| 大文件或长时间导出任务 | 创建导出任务,完成后使用受控下载端点/短时授权能力 | 需要服务端任务状态、过期策略、断点续传或流式下载设计;不要默认把整个文件缓存在前端 |
小文件也不等于可以无限缓冲。评估峰值时至少考虑并发下载数、浏览器内存、设备内存、文件复制和 Blob URL 的存活时间。
用 Blob URL 保存已有二进制
Blob 表示一段不可变的原始数据。URL.createObjectURL() 为该对象创建本地 URL,URL.revokeObjectURL() 负责释放引用。它不是永久地址,也不是上传到服务器的文件 URL。
function saveBlob(blob, filename) {
const safeName = String(filename || 'download.bin')
.replace(/[\\/\u0000-\u001f\u007f]/g, '_')
.trim()
.slice(0, 180) || 'download.bin';
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
let timer;
let released = false;
const release = () => {
if (released) return;
released = true;
window.clearTimeout(timer);
window.removeEventListener('pagehide', release);
URL.revokeObjectURL(objectUrl);
};
link.href = objectUrl;
link.download = safeName;
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
// Keep the URL available while the browser begins consuming the download.
timer = window.setTimeout(release, 60_000);
window.addEventListener('pagehide', release, { once: true });
return release;
}
清理时机要在目标浏览器实测:下载提示并没有一个适用于所有浏览器的通用“已保存完成”事件。示例用有界延迟和页面离开兜底,不是对每种 WebView 的保证;如果浏览器在延迟前还没读取对象 URL,就应调整策略。对可直接由服务端下载的文件,通常不需要绕 Blob URL 这一圈。
在 React/Vue 等组件里保存 release 返回值,并在组件卸载、路由切换或用户取消时调用;不要只创建 URL 而不管理它。URL.createObjectURL() 在 Service Worker 中不可用,且 Blob URL 的存活会占用资源。
Fetch 二进制响应
fetch() 遇到 HTTP 404/500 时仍会 resolve,需要自行检查 response.ok。确认响应成功后再读取 Blob;不要先调 response.json(),响应体只能消费一次。
async function downloadReport(url, signal) {
const response = await fetch(url, {
credentials: 'same-origin',
signal,
});
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
}
const blob = await response.blob();
const contentType = response.headers.get('content-type') || blob.type;
if (/\bjson\b|\+json\b/i.test(contentType)) {
throw new Error('服务端返回的不是文件内容,请检查导出任务状态。');
}
return saveBlob(blob, 'report.xlsx');
}
const controller = new AbortController();
downloadReport('/api/reports/current', controller.signal)
.catch(showDownloadError);
// 用户取消或组件卸载时:
// controller.abort();
这只是浏览器读取和保存的例子。实际接口应使用正确的响应媒体类型和业务错误格式;不要仅凭扩展名推断服务端确实返回了对应格式。也要确认错误响应不会被当作 .xlsx 保存。
文件名与 Content-Disposition
服务端可以返回:
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Disposition: attachment; filename="report.xlsx"; filename*=UTF-8''monthly%20report.xlsx
filename* 可表达编码后的国际化文件名;支持它的客户端优先使用该值。跨域 fetch 还必须由服务端通过 Access-Control-Expose-Headers 暴露 Content-Disposition,否则浏览器脚本无法读取该响应头。不要用简单的 split(';') 解析所有合法的 header 变体;优先让成熟 HTTP 客户端或服务端提供经验证的文件名字段。
展示或使用服务端给出的名称前,移除路径分隔符、控制字符和不受支持的长度;不要让文件名决定保存路径。前端 download 属性只是浏览器提示,浏览器仍可根据策略、来源和响应头采取自己的行为。
如果下载是用户上传内容的再分发,不要把文件扩展名当作安全校验。服务端仍要做类型/签名校验、授权、文件大小上限、恶意内容处理和 Content-Disposition 策略;客户端 MIME 类型可以被伪造。
常见故障定位
- 下载到的是 JSON 错误。 先看 HTTP 状态、
Content-Type和响应体错误结构,再创建 Blob。 - 文件大小为 0 或 MIME 为空。 检查请求是否跨域、响应是否为 opaque、代理是否返回了错误页。
- 中文文件名乱码。 检查
filename*、CORS 暴露头、客户端解析和服务端编码,不要靠对整个 header 盲目decodeURIComponent。 - 文件扩展名与内容不符。 使用与实际格式一致的媒体类型和扩展名;旧版
.xls与.xlsx是不同格式。 - Safari/WebView 没有触发保存。 验证真实设备、用户手势时序、链接是否需要挂到文档,以及服务端直下路径;不要用桌面 Chrome 通过推断移动端行为。
- 页面持续占用内存。 回收对象 URL、清理临时 Blob 引用,并避免把大型文件完整读入前端。
- 取消按钮只是隐藏进度。 将
AbortController传到请求;如果服务端生成任务不可取消,也要在任务层暴露明确的取消/过期行为。
验收清单
- 200 成功、401/403、404、500、空文件和网络中断都进入正确状态。
.xlsx、PDF、图片等至少用目标应用实际打开一次,校验文件签名和内容,不只检查文件名。- 中文、空格、较长名称和危险路径分隔符在各目标浏览器有合理结果。
- 跨域路径验证 CORS、凭据和响应头暴露;不把敏感下载令牌写入 URL 或日志。
- 大文件测试取消、并发、页面退出和低内存设备,确认没有无限增长的 Blob/对象 URL。
- H5、移动 Safari/Chrome 和应用 WebView 分别测试;不支持的路径保留服务端直接下载回退。
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。