前端做下载,最容易翻车的往往不是「下不下来」,而是那些细节:接口要带 token,一个 a 标签直接甩过去就丢了鉴权;后端返回的是二进制流,浏览器却直接把乱码渲染在页面上;导出的 CSV 用 Excel 打开中文全是问号;文件五百兆,用户点完之后页面卡死好几秒才开始下。
这些坑我基本都撞过一遍。这篇把前端下载的十二种做法整理成一份可以照抄的清单,但更重要的是先讲清楚浏览器凭什么决定「下载」还是「打开」,把这个搞明白了,选方案就不是背代码了。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 浏览器到底是根据什么决定下载还是预览的,
download属性和Content-Disposition谁说了算 a标签、location.href、window.open三种基础方案各自的边界在哪- 接口返回二进制流时,
fetch + Blob和axios + Blob怎么写才不出问题 - 想要进度条为什么只能用
XMLHttpRequest或axios的进度事件 - 前端自己生成文件(
Canvas图片、JSON、CSV)的完整写法和中文乱码的解法 - 分片下载和流式下载的真实适用边界,以及它们其实还没解决的问题
- 一张选型对比表,加上几个跨所有方案都会遇到的通用坑
# 一、先搞清楚是谁决定了「下载」
写代码之前先回答一个问题:同样是访问一个 PDF 链接,为什么有的直接在浏览器里打开预览,有的却弹出保存对话框?
决定权其实主要在服务端的响应头 Content-Disposition 上。它是 inline 的时候浏览器倾向于内联展示,是 attachment 的时候浏览器就走下载流程,后面还能跟一个 filename 指定文件名。
前端能插手的地方只有两个。一个是 a 标签的 download 属性,它相当于前端这边说「不管你返回什么,我要下载并且叫这个名字」。另一个是把数据先取到内存里变成 Blob,再自己造一个 blob: 的 URL 去触发下载,这时候内容已经在你手上了,服务端说什么都不重要。
但 download 属性有个硬限制必须记住:它只对同源 URL、blob: URL 和 data: URL 生效。跨域链接上写 download,浏览器会直接无视这个属性,既不下载也不改名,行为退化成普通跳转。这是浏览器出于安全考虑的规定,不是 bug,也没有前端侧的绕法。
搞清楚这一条,后面所有方案的取舍就有了统一的判断依据:下载链接是不是同源的,要不要带鉴权,要不要进度,文件有多大。
# 二、基础方案
# 1. a 标签下载
最简单直接的下载方式,适合静态资源或可直接访问的 URL。
// 基础下载 - 触发浏览器默认行为
const link = document.createElement('a')
link.href = 'https://example.com/file.pdf'
link.click()
// 带下载文件名 - 使用download属性
const link = document.createElement('a')
link.href = 'https://example.com/file.pdf'
link.download = '自定义文件名.pdf' // 告诉浏览器触发下载
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
// 注意事项:download属性有跨域限制
// 仅适用于同源URL或 blob:、data: URL
第二段里那个 appendChild 再 removeChild 的动作,很多人觉得是多余的。它其实是历史包袱:早期 Firefox 要求 a 元素必须在文档树里,click() 才会生效,游离节点点了没反应。现在主流浏览器都不需要这一步了,但加上也不亏,兼容成本几乎为零。
适用场景:同源静态文件下载、可控服务器资源。
# 2. location.href 跳转
最简单的下载方式,浏览器会直接打开或下载目标资源。
// 直接跳转下载
location.href = 'https://example.com/file.pdf'
// 优点:简单直接
// 缺点:会跳转当前页面,无法获取下载进度
这里有个很多人没注意的点:如果服务端返回的是 Content-Disposition: attachment,浏览器识别出这是个下载,当前页面并不会真的跳走,只是弹个下载条。但要是服务端没设这个头、返回的又是浏览器能渲染的类型,页面就真跳过去了,用户填了一半的表单全没。所以这个方案只在你能确定服务端行为的时候用。
适用场景:不关心下载状态、一次性下载。
# 3. window.open 打开
通过新窗口打开资源,适合浏览器直接支持预览的文件类型。
// 新窗口打开下载
window.open('https://example.com/file.pdf', '_blank')
// 适用于PDF、图片等浏览器可直接预览的文件
它避免了上一个方案「跳走」的风险,代价是容易被浏览器的弹窗拦截。规则大致是:在用户手势(点击事件)的同步调用栈里执行不会被拦,但如果你把它放在 await 之后或者 setTimeout 里,就大概率被拦。所以「请求接口拿到地址再 window.open」这种写法是高危写法,用户会一脸懵地看到什么都没发生。
适用场景:需要浏览器预览的文件、需要在新标签页查看。
# 三、接口返回二进制流时怎么办
上面三种方案有个共同前提:URL 能被浏览器直接访问。可后台系统里的下载接口通常要带 Authorization 请求头,浏览器发起的导航请求根本带不上自定义 header,这时候就必须先用 fetch 或 axios 把数据取到内存里。
# 4. fetch + Blob
处理接口返回的二进制数据,灵活控制下载行为。
// fetch下载文件(推荐方式)
async function downloadFile(url, filename) {
try {
const response = await fetch(url)
if (!response.ok) throw new Error('下载失败')
// 获取Blob对象
const blob = await response.blob()
// 创建下载链接
const blobUrl = URL.createObjectURL(blob)
// 创建a标签触发下载
const link = document.createElement('a')
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
// 清理
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl) // 释放内存
} catch (error) {
console.error('下载失败:', error)
}
}
// 使用
downloadFile('https://example.com/api/download', 'file.pdf')
这段是最通用的骨架,思路是「取回来变成 Blob,造一个 blob: URL,再用 a 标签点一下」。因为 blob: URL 天然同源,download 属性一定生效,跨域那个限制就绕过去了。
有个地方我要提醒一下:URL.revokeObjectURL 紧跟在 click() 后面同步调用,在部分浏览器上会把还没开始的下载给掐断。稳妥的写法是延迟一点再释放:
link.click()
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
}, 1000)
另一个坑更隐蔽。后端接口出错时,返回的可能是一个 JSON 错误对象而不是文件,但 response.ok 依然是 true(HTTP 200)。这时候上面的代码会老老实实把这段 JSON 存成一个 .pdf 文件给用户。稳妥做法是先看 Content-Type,是 application/json 就当错误处理:
const contentType = response.headers.get('Content-Type') || ''
if (contentType.includes('application/json')) {
const err = await response.json()
throw new Error(err.message || '下载失败')
}
# 5. axios + Blob
类似 fetch,适合已有 axios 项目的场景,好处是能直接复用项目里配好的拦截器、baseURL 和 token 注入。
import axios from 'axios'
async function downloadWithAxios(url, filename) {
const response = await axios.get(url, {
responseType: 'blob', // 关键:指定响应类型为blob
})
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
}
// 带请求头的下载
async function downloadWithToken(url, filename) {
const response = await axios.get(url, {
responseType: 'blob',
headers: {
Authorization: `Bearer ${token}`,
},
})
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
link.click()
}
responseType: 'blob' 这一行是命根子,忘了写的话 axios 会按文本解析二进制内容,存下来的文件必定损坏,而且过程中一声不吭。
用 axios 还有个额外收益,文件名可以从响应头里读,不用前端硬编码:
const disposition = response.headers['content-disposition'] || ''
const match = /filename\*?=(?:UTF-8'')?["']?([^;"']+)/i.exec(disposition)
const filename = match ? decodeURIComponent(match[1]) : '下载文件'