前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

前端文件下载完全指南 12 种方案对比与实战代码

首页2023-06-08 14:40:12Front-End
JavaScript文件下载前端开发BlobAxios浏览器

前端做下载,最容易翻车的往往不是「下不下来」,而是那些细节:接口要带 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]) : '下载文件'
@前端进阶之旅: 代码已经复制到剪贴板
fe
  • 一、先搞清楚是谁决定了「下载」
  • 二、基础方案
    • 1. a 标签下载
    • 2. location.href 跳转
    • 3. window.open 打开
  • 三、接口返回二进制流时怎么办
    • 4. fetch + Blob
    • 5. axios + Blob
  • 四、要进度条就得换 API
    • 6. XMLHttpRequest 下载
    • 7. axios + onDownloadProgress
  • 五、前端自己造文件
    • 8. Base64 与 Canvas 图片下载
    • 9. 纯文本 JSON 与 CSV 下载
  • 六、iframe 与表单提交
    • 10. 隐藏 iframe 下载
  • 七、超大文件的两种做法
    • 11. 大文件分片下载
    • 12. 流式下载
  • 八、方案对比总结
  • 九、选型建议与几个通用的坑
  • 总结
  • 参考

← Webpack 5 构建性能优化实战,持久化缓存 Tree-shaking 与分包配置拯救磁盘空间 为什么 pnpm 是更好的包管理工具 →