如何在浏览器中创建可下载文件
在浏览器中创建可下载文件需要综合运用四个浏览器 API:用于存储数据的 Blob、用于生成内存引用的 URL.createObjectURL()、带有 download 属性的锚元素(用于触发保存操作),以及事后释放引用的 URL.revokeObjectURL()。完整的实现模式不超过十行代码:
function downloadBlob(data, filename, type) {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
downloadBlob(JSON.stringify({ ok: true }), 'config.json', 'application/json');
这是标准实现模式,适用于保存生成的 CSV、JSON 配置文件或图片等常见场景。然而,这条”顺利路径”背后隐藏着真实的故障模式:download 属性在跨域 URL 上会静默失效、iOS Safari 会在新标签页中打开下载文件、对象 URL 会在组件重新渲染时发生泄漏,以及 Excel 会乱码显示 UTF-8 编码的 CSV 文件。本文将深入介绍可靠的实现模式、现代 File System Access API、针对超大文件的流式处理方案,以及在生产环境中导致下载功能失效的各类平台特性问题。
核心要点
- 客户端下载的标准流程为:
new Blob([data], { type })→URL.createObjectURL()→ 带有download属性的锚元素 →click()→URL.revokeObjectURL()。 - 对象 URL 的生命周期与创建它的文档绑定,因此应在框架的清理函数中(如 React 的
useEffect返回函数、Vue 的onUnmounted)执行撤销操作,而不是依赖与a.click()之间未经验证的时序关系。 showSaveFilePicker()是唯一允许用户选择保存位置的浏览器原生方式,但该 API 目前处于实验阶段且仅支持 Chromium 内核,因此必须进行特性检测并回退到锚元素模式。- 只有当文件携带 BOM 时,Excel 才能正确打开 UTF-8 编码的 CSV 文件,因此需要在字符串开头添加
\uFEFF。 - iOS Safari 对
download属性的支持历来不稳定,这也是为什么在该平台上下载操作经常会打开新标签页而非保存文件。
标准模式:使用 JavaScript 在浏览器中下载文件
Discover how at OpenReplay.com.
在浏览器中下载由 JavaScript 生成的文件,可靠的方式是:构造一个 Blob,使用 URL.createObjectURL() 创建对象 URL,将其赋值给锚元素的 href,将 download 属性设置为文件名,以编程方式点击锚元素,最后使用 URL.revokeObjectURL() 释放 URL。Blob 构造函数允许你独立于数据内容设置 MIME 类型,而对象 URL 是一个简短的引用(形如 blob:https://…),锚元素可以通过它进行导航。
不要将 data URI 作为默认方案。 Data URI 不适合作为客户端文件生成的默认方案:Base64 将每 3 个字节编码为 4 个字符,在填充之前就使数据量增加约三分之一(参见 RFC 4648),且整个编码后的字符串必须以 DOM 属性值的形式完整保存在内存中。目前 data: URL 的大小限制在 Chromium 和 Firefox 中为 512 MB,在 Safari/WebKit 中为 2048 MB(MDN data: URL 参考)——但由于编码开销和内存中字符串的代价,在远未触及这些上限之前,Blob 就已经是更优的默认选择。
在当前的浏览器中,锚元素无需附加到 DOM 即可执行 click(),这使得辅助函数可以保持自包含:
// 适用于 Chrome 14+、Firefox 20+(download 属性,HTML5)。
// caniuse: https://caniuse.com/download
function downloadBlob(data, filename, type = 'application/octet-stream') {
const blob = new Blob([data], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a); // 为获得最广泛的兼容性而附加到 DOM
a.click();
a.remove();
URL.revokeObjectURL(url);
}
const csv = 'name,role\nAda,Engineer\nGrace,Architect';
downloadBlob(csv, 'team.csv', 'text/csv;charset=utf-8;');
URL.revokeObjectURL(url) 的调用比大多数示例所暗示的更为重要。对象 URL 的生命周期与创建它的文档绑定,因此它会一直占用内存,直到你主动撤销或文档卸载。在一次性脚本中这无关紧要;但在基于组件的应用中,若辅助函数在每次按钮点击时都运行,未释放的 URL 就会不断累积。上述代码中的同步撤销在此处是安全的,因为函数中没有任何内容会在调用结束后继续存活——但正如框架部分所示,在组件内部这种写法是错误的。
HTML 原生 download 属性及其静默的跨域失效问题
对于已托管在同源服务器上的文件,完全不需要 JavaScript——只需在普通锚元素上添加 download 属性即可:
<a href="/reports/q3.pdf" download="q3-report.pdf">下载报告</a>
download 属性指示浏览器保存链接资源而非导航到该资源。该属性在 HTML5 中引入,支持 Chrome 14+ 和 Firefox 20+(caniuse: download 属性)。它同样接受 blob: 和 data: URL,这也是它与上述标准模式配合使用的原因。
失效场景:跨域 URL。 对于跨域下载,download 属性仅在响应同时携带 Content-Disposition: attachment 头时才会生效;若没有该响应头,浏览器会忽略此属性,链接将无法可靠地强制触发下载(参见 WHATWG HTML 标准,资源下载)。这是一个常见的混淆来源:同样的标记对同源文件能正常下载,但对于来自不同源 CDN 的文件则会导航到该文件或直接渲染。如果你控制服务器,应在服务器端设置该响应头。如果你无法控制服务器,则可以先获取文件,再以 blob 的形式从你自己的源重新提供:
// 将跨域文件重新作为同源 blob 提供,以确保 `download` 属性生效。
async function downloadCrossOrigin(remoteUrl, filename) {
const res = await fetch(remoteUrl); // 需要 CORS 允许该请求
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
File System Access API:让用户选择保存位置
File System Access API 的 showSaveFilePicker() 是唯一允许用户在写入前选择保存位置和文件名的浏览器原生方式。它会打开操作系统的保存对话框,返回一个文件句柄,并允许你通过 FileSystemWritableFileStream 进行写入。MDN 将其标记为”有限可用性”和”实验性”;该 API 从 Chromium 86 版本开始支持(Chrome Status),Firefox 和 Safari 均不支持,因此在生产环境中必须进行特性检测并回退到锚元素模式。
当用户关闭选择器时,showSaveFilePicker() 会抛出名称为 AbortError 的 DOMException,因此应明确处理该情况,而不是将其视为错误:
async function saveFile(data, filename, type = 'application/octet-stream') {
// 特性检测。注意:在某些沙箱化的 iframe 上下文中,
// 该属性可能存在但调用时会抛出异常,因此调用本身仍需加以保护。
if ('showSaveFilePicker' in window) {
try {
const handle = await window.showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'File', accept: { [type]: ['.' + filename.split('.').pop()] } }],
});
const writable = await handle.createWritable();
await writable.write(new Blob([data], { type }));
await writable.close();
return;
} catch (err) {
if (err.name === 'AbortError') return; // 用户取消,不视为错误
// 其他任何错误均回退到锚元素方案
}
}
// 回退方案:锚元素 + 对象 URL(无法选择保存位置,使用浏览器默认位置)。
downloadBlob(data, filename, type);
}
'showSaveFilePicker' in window 检查是正确的判断方式,但并非万无一失:在某些沙箱化的 iframe 上下文中,该属性存在但调用时会抛出异常,这也是为什么 try/catch 应包裹实际调用而非仅包裹检测逻辑。
流式处理超出内存限制的大文件
流式处理通过将数据块直接写入磁盘而非将完整内容缓冲到内存中,从而绕过 Blob 的内存上限。内存上限本身没有固定的字节阈值——它取决于设备、操作系统以及浏览器的可用堆内存。以下两种流式处理方案可以避免缓冲整个文件。
在 Chromium 中,可通过 showSaveFilePicker() 返回的 FileSystemWritableFileStream 进行增量写入。每次 write() 调用都会向可写流追加一个数据块,因此应用无需在内存中持有完整文件:
// 仅适用于 Chromium 86+。分块写入,无需在内存中持有完整文件。
async function streamToDisk(filename, chunks) {
const handle = await window.showSaveFilePicker({ suggestedName: filename });
const writable = await handle.createWritable();
for await (const chunk of chunks) {
await writable.write(chunk); // chunk 类型:string | ArrayBuffer | Blob
}
await writable.close();
}
对于不支持 File System Access API 的浏览器,StreamSaver.js 是跨浏览器的解决方案。根据其 README,StreamSaver.js 通过创建可写流并借助响应头与 Service Worker 模拟服务端驱动的下载,将客户端生成的大文件保存到磁盘,数据在流式传输过程中即被写入磁盘,而无需组装成一个完整的 Blob。它基于 WHATWG Streams 标准 构建:
import streamSaver from 'streamsaver';
// 通过 Service Worker 模拟服务端下载;数据流式写入磁盘。
function streamWithStreamSaver(filename, readableStream) {
const fileStream = streamSaver.createWriteStream(filename);
// readableStream:由 Uint8Array 数据块组成的 WHATWG ReadableStream
return readableStream.pipeTo(fileStream);
}
当输出文件确实较大时才考虑使用流式处理——例如数百 MB 的导出文件、生成的视频或长时间运行的数据流。对于仅有几千行的 CSV 文件,标准的 Blob 模式更简单,完全够用。
iOS Safari 的特殊问题及应对方案
iOS Safari 对通过 download 属性生成和下载文件的支持历来不稳定(WebKit bug 167341),生成的文件经常会在新标签页中打开或直接内联渲染,而非保存到本地——这使得 iOS 成为下载代码在生产环境中最容易出问题的平台。FileSaver.js 的 README 记录了这一实际影响:在 iOS 上,saveAs() 必须在用户交互(如 onClick)中执行,setTimeout 会阻止其触发;由于 iOS 的限制,saveAs() 可能会打开新窗口而非触发下载(FileSaver.js README)。
具体的应对方案如下:
- 保持下载操作与用户手势同步。 直接在事件处理函数中触发点击,而不是在
await或setTimeout之后触发(这会使其脱离手势上下文)。如果必须先执行异步操作,应在用户点击之前完成数据的获取和准备,然后在同步代码中触发保存。 - 为用户提供明确的备用方案。 当文件以内联方式在新标签页中打开时,提示用户”点击分享 → 存储到文件”,因为用户需要手动完成保存操作。
- 在 iOS 上对文本导出优先使用
text/plain或其他可预览类型,接受文件可能在查看器中打开后由用户手动保存的情况,而不是强行实现平台无法可靠支持的强制下载。
这类故障对服务器日志和分析工具是不可见的,因为没有任何请求到达后端,也不会抛出任何异常。在对下载流程进行生产环境会话回放时,经常可以看到用户因为没有收到任何可见反馈而反复点击触发按钮——在 iOS 上,这通常表现为新标签页打开后用户立即关闭,然后困惑地返回原页面。会话回放是为数不多能够发现这类静默的、与手势和平台相关的故障的技术手段之一。
// iOS 适配方案:先执行异步操作,然后在手势上下文中同步保存。
async function prepareThenDownload(button, getData, filename, type) {
const data = await getData(); // 网络/CPU 操作在点击路径之前完成
button.onclick = () => {
// 与用户手势同步——此处不使用 setTimeout,不使用 await
downloadBlob(data, filename, type);
};
}
MIME 类型的常见问题:内联显示、Excel CSV 与换行符
传递给 Blob 构造函数的 MIME 类型有助于浏览器判断如何解析文件,设置错误是导致下载内容意外打开或处理异常的常见原因。应设置与数据相匹配的类型,并配合 download 属性,使浏览器将结果视为附件处理。
最常见的 CSV 问题是在 Excel 中打开文件时,带重音符号的字符显示为乱码。解决方法是添加字节顺序标记(BOM)。当 CSV 文件以 BOM 保存时,Excel 才能正确打开 UTF-8 编码的文件,因此需要在字符串开头添加 BOM(Microsoft 支持:在 Excel 中正确打开 UTF-8 CSV 文件):
// 不带 BOM:Excel 可能对含重音符号的文本(如 é、ü、ñ)显示乱码。
// 带 BOM:Excel 能正确打开 UTF-8 文件。
function downloadCSV(csvString, filename = 'export.csv') {
downloadBlob('\uFEFF' + csvString, filename, 'text/csv;charset=utf-8;');
}
RFC 4180(将 text/csv 注册为 CSV 的 MIME 类型)中还有两点值得注意:该规范将 CRLF(\r\n)定义为记录分隔符,因此为获得最佳的电子表格兼容性,行间应优先使用 \r\n 而非 \n;包含逗号、引号或换行符的字段必须用双引号括起来,内部的引号需要重复转义。
不要将 application/octet-stream 作为万能的强制下载开关。它是 IANA 注册的任意二进制数据类型,但并不是可靠的强制下载机制——FileSaver.js 的 README 指出,使用 application/octet-stream 强制下载可能在 Safari 中引发问题。应使用正确的具体类型,并依靠 download 属性(或 Content-Disposition)来强制执行保存行为。
框架注意事项:SSR、对象 URL 生命周期与内存泄漏
在 React、Vue 和 Svelte 中,有两类组件生命周期问题会破坏标准下载模式:在服务端渲染(SSR)期间调用 DOM API(此时 document 未定义),以及在错误的组件生命周期节点撤销对象 URL。这两个问题的根本原因相同——组件在服务端渲染并在客户端重新渲染,而原生模式假设只有一个文档和一次执行。
防范 SSR 问题。 在 Next.js、Nuxt 或 SvelteKit 中,组件代码可能在 document 未定义的环境中运行;在这种情况下调用 document.createElement 会抛出异常。应在下载辅助函数中加入运行时检查:
function triggerDownload(data, filename, type) {
if (typeof document === 'undefined') return; // SSR 防护
downloadBlob(data, filename, type);
}
在清理函数中撤销对象 URL,而非立即撤销。 标准模式中的同步 URL.revokeObjectURL(url) 对于在其他操作执行前就已完成的一次性辅助函数是安全的。但如果你将 blob URL 存储在状态中用作 href 或 src,过早撤销会导致下载失败,而从不撤销则会在每次重新渲染时造成内存泄漏。经过验证的规则是:对象 URL 的生命周期与创建它的文档绑定,因此应在不再需要时撤销;在基于组件的框架中,应将 blob URL 存储在状态中,并在清理函数中执行撤销,而不是依赖与 a.click() 之间未经验证的时序关系。
import { useEffect, useState } from 'react';
function DownloadLink({ data, filename, type = 'application/json' }) {
const [url, setUrl] = useState(null);
useEffect(() => {
const blob = new Blob([data], { type });
const objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
// 清理函数在组件卸载时以及 effect 重新执行前运行:
return () => URL.revokeObjectURL(objectUrl);
}, [data, type]);
// 反模式——不要在此处撤销:
// <a href={url} download={filename} onClick={() => URL.revokeObjectURL(url)}>
// 在点击事件中撤销可能会在下载开始前就释放 URL。
return url ? <a href={url} download={filename}>下载</a> : null;
}
Vue 的等效写法是在 onMounted(或 watch)中创建 URL,并在 onUnmounted 中撤销;Svelte 使用 onDestroy。在这三个框架中,常见的内存泄漏问题是:每次重新渲染或每次按钮点击都创建新的对象 URL,却没有对应的撤销操作——内存会在文档的整个生命周期内持续增长。
对于完全不支持 download 属性的旧版浏览器,FileSaver.js 仍然是一行代码即可引入的 polyfill——但在现代浏览器目标上,上述平台原生模式已完全可以替代它。
总结
Blob → 对象 URL → 锚元素 → download → 撤销这一流程能够处理绝大多数客户端下载需求;当用户需要选择保存位置时,可使用仅限 Chromium 的 showSaveFilePicker() 作为升级方案;当文件大小超出内存限制时,流式处理则是最终的解决手段。在生产环境中出现的问题,很少出现在顺利路径上——它们往往是静默导航的跨域属性、需要 BOM 的 CSV 文件、打开新标签页而非保存文件的 iOS 行为,以及在错误时机撤销的对象 URL。请将清理逻辑接入组件生命周期,在调用实验性 API 前进行特性检测,并在真实 iOS 设备上测试下载流程后再上线发布。
常见问题
Blob 存储原始数据,并通过 URL.createObjectURL() 创建的简短对象 URL 进行引用;而 data URI 则将整个内容以 Base64 字符串的形式直接嵌入 URL 中。Base64 编码在填充之前会使数据量增加约三分之一,且完整的编码字符串必须以属性值的形式保存在内存中。Blob 允许独立于数据内容设置 MIME 类型,并避免了上述两项开销,因此是客户端文件生成的更优默认选择。
根据 WHATWG HTML 标准,download 属性对同源 URL 有效,但对跨域 URL 无效,除非响应同时携带 Content-Disposition: attachment 响应头。来自不同源 CDN 的文件会导航到该文件或直接内联渲染,而非触发下载。解决方法是在你控制的服务器上设置该响应头,或者使用 fetch 获取跨域文件后通过 URL.createObjectURL() 将其作为同源 blob 重新提供。
不可用。File System Access API 的 showSaveFilePicker() 仅在 Chromium 内核浏览器的第 86 版本起受支持,Firefox 和 Safari 均未实现该 API。MDN 将其标记为“有限可用性”和“实验性”。因此,生产代码必须通过 'showSaveFilePicker' in window 进行特性检测,并回退到锚元素和对象 URL 模式。还需将实际调用包裹在 try/catch 中,因为在某些沙箱化的 iframe 上下文中,该属性存在但调用时会抛出异常,且当用户关闭对话框时会抛出 AbortError。
在下载开始前撤销对象 URL 会导致下载失败,因为浏览器无法再解析锚元素指向的 blob 引用。在同步完成的一次性辅助函数中,在 click() 之后立即撤销是安全的。但在将 blob URL 存储在状态中用作 href 或 src 的组件中,应在清理函数中执行撤销,例如 useEffect 的返回函数、Vue 的 onUnmounted 或 Svelte 的 onDestroy。如果从不撤销,则会在文档的整个生命周期内因每次重新渲染而持续泄漏内存。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.