文件下载

前端从后端下载或导出文件的方法

适用场景: 很多时候,前端存在需要从后端下载文件的情况,典型的就是导出excel表格。

一般存在两种方式:

  • 请求接口之后,后端返回文件路径,前端直接下载。
  • 请求接口之后,后端以文件流的形式返回给前端,前端再下载到本地。

第一种方式

很简单,请求完接口之后,打开该文件的地址:

window.location.href = res.request.responseURL

responseURL这个地址其实和接口地址是一样,直接打开它,就能默认下载到本地的下载路径了。

非常方便,但是存在不足,比如不能修改文件的名称

而且,有些场景使用这种方式是行不通的,比如,很多管理系统,都是需要登录的,既然要登录,那就一般都会需要验证每个请求是否安全,往往需要在header里带上token,后端才会给你这个请求放行。

所以,这种方式,你请求接口之后,转到这个链接,其实就是再请求了一次,这个时候你是不好在请求里带上token的,自然也就拿不到你要的文件。

所以,这个时候就要用第二种方式,以文件流的方式来下载。

第二种方式

第二种方式,就是正常的api请求,后端以文件流的形式发送给前端,前端获取到文件数据之后,在本地模拟一次点击按钮下载,不过这次下载不是再向后端请求一次api,而是把第一次请求api之后,后端返回的文件数据转换成合适的格式之后下载下来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
exportFile(this.queryParam).then(res => {
if (res.status === 200) {
const xlsx = 'application/vnd.ms-excel'
const blob = new Blob([res.data], { type: xlsx })
//转换数据类型
const a = document.createElement('a') // 转换完成,创建一个a标签用于下载
// const name = res.headers['content-disposition']
// a.download = name.split('=')[1]
a.download = `${this.$t('自定义文件名')}.xlsx`
a.href = window.URL.createObjectURL(blob)
a.click()
a.remove()
document.body.removeChild(a) //也可以这么移除
// 直接打开下载文件的链接
// window.location.href = res.request.responseURL
}
})

补充

  • 不管是第一种方式还是第二种方式,尽量让后端指定好文件的类型。当然,使用第二种方式,前端可以再次指定好文件类型。

  • 这里将获取到的文件内容转换成blob类型的数据,是最常见的下载文件数据的格式,当然还可以使用别的方式。

  • 这里创建a标签取下载文件,还可以用别的方式,或者如果碰到浏览器兼容性的问题,可能需要个性化处理。

  • .download这里可以拿后端返回的文件名,也可以自己定义文件名,看你自己哪个方便一些。如果后端拿到的文件名是乱码,建议直接在前端定义文件名。

  • 最关键的是,下载文件乱码的问题,很多人碰到,解决方法也很简单。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    export function exportFile (parameter) {

    return axios({
    url: `${api.file}/export`,
    method: 'get',
    data: parameter,
    header: {
    headers: { 'Content-Type': 'application/x-download' }
    },
    responseType: 'blob'
    })

    }

    在请求接口的header里一定要指定responseTypeblob,否则把返回的文件数据转换成blob对象,blob是不认识的,就会出现乱码。

前端下载文件的5种方法的对比

form表单提交

这是以前常使用的传统方式,毕竟那个年代,没那么多好用的新特性呀。

道理也很简单,为一个下载按钮添加click事件,点击时动态生成一个表单,利用表单提交的功能来实现文件的下载(实际上表单的提交就是发送一个请求)

来看下如何生成一个表单,生成怎么样的一个表单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 下载文件
* @param {String} path - 请求的地址
* @param {String} fileName - 文件名
*/
function downloadFile (downloadUrl, fileName) {
// 创建表单
const formObj = document.createElement('form');
formObj.action = downloadUrl;
formObj.method = 'get';
formObj.style.display = 'none';
// 创建input,主要是起传参作用
const formItem = document.createElement('input');
formItem.value = fileName; // 传参的值
formItem.name = 'fileName'; // 传参的字段名
// 插入到网页中
formObj.appendChild(formItem);
document.body.appendChild(formObj);
formObj.submit(); // 发送请求
document.body.removeChild(formObj); // 发送完清除掉
}

优点

  • 传统方式,兼容性好,不会出现URL长度限制问题

缺点

  • 无法知道下载的进度
  • 无法直接下载浏览器可直接预览的文件类型(如txt/png等)

open或location.href

最简单最直接的方式,实际上跟a标签访问下载链接一样

1
2
window.open('downloadFile.zip');
location.href = 'downloadFile.zip';

当然地址也可以是接口api的地址,而不单纯是个链接地址。

优点

  • 简单方便直接

缺点

  • 会出现URL长度限制问题
  • 需要注意url编码问题
  • 浏览器可直接浏览的文件类型是不提供下载的,如txt、png、jpg、gif等
  • 不能添加header,也就不能进行鉴权
  • 无法知道下载的进度

a标签的download

我们知道,a标签可以访问下载文件的地址,浏览器帮助进行下载。但是对于浏览器支持直接浏览的txt、png、jpg、gif等文件,是不提供直接下载(可右击从菜单里另存为)的。

为了解决这个直接浏览不下载的问题,可以利用download属性。

download属性是HTML5新增的属性,兼容性可以了解下 can i use download

总体兼容性算是很好了,基本可以区分为IE和其他浏览。但是需要注意一些信息:

  • Edge 13在尝试下载data url链接时会崩溃。
  • Chrome 65及以上版本只支持同源下载链接。
  • Firefox只支持同源下载链接。

基于上面描述,如果你尝试下载跨域链接,那么其实download的效果就会没了,跟不设置download表现一致。即浏览器能预览的还是会预览,而不是下载。

简单用法:

1
<a href="example.jpg" download>点击下载</a>

可以带上属性值,指定下载的文件名,即重命名下载文件。不设置的话默认是文件原本名。

1
<a href="example.jpg" download="test">点击下载</a>

如上,会下载了一个名叫test的图片。

监测是否支持download

要知道浏览器是否支持download属性,简单的一句代码即可区分

1
const isSupport = 'download' in document.createElement('a');

对于在跨域下不能下载可浏览的文件,其实可以跟后端协商好,在后端层做多一层转发,最终返回给前端的文件链接跟下载页同域就好了。

优点

  • 能解决不能直接下载浏览器可浏览的文件

缺点

  • 得已知下载文件地址
  • 不能下载跨域下的浏览器可浏览的文件
  • 有兼容性问题,特别是IE
  • 不能进行鉴权

利用Blob对象

该方法较上面的直接使用a标签download这种方法的优势在于,它除了能利用已知文件地址路径进行下载外,还能通过发送ajax请求api获取文件流进行下载。毕竟有些时候,后端不会直接提供一个下载地址给你直接访问,而是要调取api。

利用Blob对象可以将文件流转化成Blob二进制对象。该对象兼容性良好,需要注意的是

  • IE10以下不支持。
  • 在Safari浏览器上访问Blob UrlObject URL当前是有缺陷的,如下文中通过URL.createObjectURL生成的链接。caniuse官网有指出 Safari has a serious issue with blobs that are of the type application/octet-stream

进行下载的思路很简单:发请求获取二进制数据,转化为Blob对象,利用URL.createObjectUrl生成url地址,赋值在a标签的href属性上,结合download进行下载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 下载文件
* @param {String} path - 下载地址/下载请求地址。
* @param {String} name - 下载文件的名字/重命名(考虑到兼容性问题,最好加上后缀名)
*/
downloadFile (path, name) {
const xhr = new XMLHttpRequest();
xhr.open('get', path);
xhr.responseType = 'blob';
xhr.send();
xhr.onload = function () {
if (this.status === 200 || this.status === 304) {
// 如果是IE10及以上,不支持download属性,采用msSaveOrOpenBlob方法,但是IE10以下也不支持msSaveOrOpenBlob
if ('msSaveOrOpenBlob' in navigator) {
navigator.msSaveOrOpenBlob(this.response, name);
return;
}
// const blob = new Blob([this.response], { type: xhr.getResponseHeader('Content-Type') });
// const url = URL.createObjectURL(blob);
const url = URL.createObjectURL(this.response);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
}

该方法不能缺少a标签的download属性的设置。

因为发请求时已设置返回数据类型为Blob类型(xhr.responseType = 'blob'),所以target.response就是一个Blob对象,打印出来会看到两个属性sizetype

虽然type属性已指定了文件的类型,但是为了稳妥起见,还是在download属性值里指定后缀名,如Firefox不指定下载下来的文件就会不识别类型。

大家可能会注意到,上述代码有两处注释,其实除了上述的写法外,还有另一个写法,改动一丢丢。

如果发送请求时不设置xhr.responseType = 'blob',默认ajax请求会返回DOMString类型的数据,即字符串。

这时就需要两处注释的代码了,对返回的文本转化为Blob对象,然后创建blob url,此时需要注释掉原本的const url = URL.createObjectURL(target.response)

优点

  • 能解决不能直接下载浏览器可浏览的文件
  • 可设置header,也就可添加鉴权信息

缺点

  • 兼容性问题,IE10以下不可用;Safari浏览器可以留意下使用情况

利用base64

这里的用法跟上面用Blob大同小异,基本上思路是一样的,唯一不同的是,上面是利用Blob对象生成Blob URL,而这里则是生成Data URL,所谓Data URL,就是base64编码后的url形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 下载文件
* @param {String} path - 下载地址/下载请求地址。
* @param {String} name - 下载文件的名字(考虑到兼容性问题,最好加上后缀名)
*/
downloadFile (path, name) {
const xhr = new XMLHttpRequest();
xhr.open('get', path);
xhr.responseType = 'blob';
xhr.send();
xhr.onload = function () {
if (this.status === 200 || this.status === 304) {
const fileReader = new FileReader();
fileReader.readAsDataURL(this.response);
fileReader.onload = function () {
const a = document.createElement('a');
a.style.display = 'none';
a.href = this.result;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}
};
}

优点

  • 能解决不能直接下载浏览器可浏览的文件
  • 可设置header,也就可添加鉴权信息

缺点

  • 兼容性问题,IE10以下不可用

关于文件名

有时候我们在发送下载请求之前,并不知道文件名,或者文件名是后端提供的,我们就要想办法获取。

Content-Disposition

当返回文件流的时候,我们在浏览器上观察接口返回的信息,会看到有这么一个header:Content-Disposition

1
Content-Disposition: attachment; filename=CMCoWork__________20200323151823_190342.xlsx; filename*=UTF-8''CMCoWork_%E4

上面的值是例子。

其中包含了文件名,我们可以想办法获取其中的文件名。

我们看到,有filename=filename*=,后者不一定有,在旧版浏览器中或个别浏览器中,会不支持这种形式,filename*采用了RFC 5987中规定的编码方式。

所以你要获取文件名,就变成,截取这段字符串中的这两个字段值了。

看上面的例子大家可能发现,怎么值怪怪的。是的,如果名字是英文,那好办, 如果是有中文或者其他特殊符号,是需要处理好编码的

  • filename,需要后端处理好编码形式,但是就算后端处理好了,也会应每个浏览器的不同,解析的情况也不同。是个比较难处理好的家伙,所以才有后面的filename*
  • filename*,是个现代浏览器支持的,为了解决filename的不足,一般是UTF-8,我们用decodeURIComponent就能解码了,能还原成原本的样子。当然,解码前你要把值中的UTF-8''这种部分给去掉。

所以,在我们实现之前,我们就要明白,取Content-Disposition的内容,并不是百分百能符合你预期的,除非你的文件名全是英文数字。

我们提取文件名值:

1
2
3
4
5
6
7
8
// xhr是XMLHttpRequest对象
const content = xhr.getResponseHeader('content-disposition'); // 注意是全小写,自定义的header也是全小写
if (content) {
let name1 = content.match(/filename=(.*);/)[1]; // 获取filename的值
let name2 = content.match(/filename\*=(.*)/)[1]; // 获取filename*的值
name1 = decodeURIComponent(name1);
name2 = decodeURIComponent(name2.substring(6)); // 这个下标6就是UTF-8''
}

上面我们获得了两个文件名name1,name2,如果两个都存在,那么我们优先取name2的,因为这个更靠谱,name1如果包含中文或特殊符号,就有风险还原不了真正的文件名。

自定义header

本质上跟上述的Content-Disposition差不多,只是我们这里不使用默认的header,我们自己自定义一个response header,跟后端决定好编码方式返回,前端直接获取这个自定义header,然后使用对应的解码即可,如使用decodeURIComponent

但是我们都要知道,在跨域的情况下,前端获取到的header只有默认的6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma

所以你想要获取到别的header,需要后端配合,设置

1
Access-Control-Expose-Headers: Content-Disposition, custom-header

这样,前端就能获取到对应暴露的header字段,需要注意的是,Content-Disposition也是需要暴露的。

重命名

这里额外提供个方法,该方法作用是,当你知道文件的全名(含后缀名),想要重命名,但是得后缀名一样,来获取后缀名。

1
2
3
4
function findType (name) {
const index = name.lastIndexOf('.');
return name.substring(index + 1);
}

下载大文件

注意: 如果下载特别大的文件,则上面的就不行了,可能会造成网页崩溃,这里就需要用到下载的库。

FileSaver, 轻松实现下载文件500M

1.安装npm依赖

1
npm install file-saver --save

2.引入代码

1
2
3
+ import { saveAs } from 'file-saver';
...
+ saveAs(blob, fileName );

3.完整例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
+ import { saveAs } from 'file-saver';
axios({
method: 'post',
url: 'api/file',
responseType: 'blob'
}).then(res=> {
if (res.data){
fileName = this.fileName;
// 有文件名就用自定义的,没有就从header获取
if (!fileName) {
fileName = fileNameFromHeader(
res.headers["content-disposition"] || ""
);
}

let blob = new Blob([res.data],{
type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8"});
+ saveAs(blob, fileName );
}
}).catch((error) => {
console.log(error)
})

function fileNameFromHeader(disposition) {
let result = null;
disposition = disposition.split(";")[1];
if (disposition && /filename=.*/gi.test(disposition)) {
result = disposition.match(/filename=.*/gi);
return decodeURIComponent((result[0].split("=")[1]).replace(/\+/g, '%20'));
}
return "null";
}

4.其他问题

下载大文件过程中遇到的其他问题

  • axios请求超时,注意配置timeout
  • Nginx 响应超时报504 网关超时错误,注意配置Nginx
  • 控制台报error response,浏览器请求长时间得不到响应,本地调试代理转发超时造成的,参考
  • 文件超过2G的解决方案

前端对于文件流的下载

Stream概念

想象一下,你编写了一个文件,你可以将它保存在硬盘上,也可以将它保存到U盘上,这样简单的操作,其实就是数据的传输。

数据的传输,也就是数据的流动,既然出现了这个流动,那就会有出方向和入方向,就像大部分河流一样,在某个地方冒出了涓涓细流,到最后流入大海(这属于地理知识我们在这里不做过多讨论…),在很多时候,流(Stream)我们代指字节流(Byte Steram),也就是长长的一串字节,除了字节流,我们还可能有视频流、音频流、数据流等等……

流按照处理数据的单位,可以分为字节流和字符流。

字节流的处理单位是字节,通常用来处理二进制文件,例如音乐、图片文件等。而字符流的处理单位是字符,因为Java采用Unicode编码,Java字符流处理的即为Unicode字符,所以在操作汉字、国际化等方面,字符流具有优势。

MIME概念

媒体类型(通常称为 Multipurpose Internet Mail Extensions 或 MIME 类型 )是一种标准,用来表示文档、文件或字节流的性质和格式。它在IETF RFC 6838中进行了定义和标准化。
互联网号码分配机构(IANA)是负责跟踪所有官方MIME类型的官方机构,您可以在媒体类型页面中找到最新的完整列表。

MIME它的全名叫多用途互联网邮件扩展(Multipurpose Internet Mail Extensions),最初是为了将纯文本格式的电子邮件扩展到可以支持多种信息格式而定制的。后来被应用到多种协议里,包括我们常用的HTTP协议。

MIME的常见形式是一个主类型加一个子类型,用斜线分隔。比如text/htmlapplication/javascriptimage/png等。

Blob

概念-什么是Blob

Blob(Binary Large Object)对象表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作。
Blob 表示的不一定是JavaScript原生格式的数据。File 接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

MDN官方的定义来看,我们大概可以知道这个Blob是用来干啥的了,它是一个用来读取或操作二进制数据的玩意,这我们就可以来下载后端给我们的文件流了啊

用法

构造函数
1
var Blob = new Blob( array, options )
  • array 是一个由ArrayBuffer(二进制数据缓冲区)、ArrayBufferView(二进制数据缓冲区的array-like视图)、Blob、DOMString等对象构成的Array,或者其他类似对象的混合体,它将会被放进Blob。DOMStrings会被编码为UTF-8。
  • options 是可选的,它可能会指定如下两个属性:
  • type,默认值为 “”,它代表了将会被放入到blob中的数组内容的MIME类型。
    • endings,默认值为”transparent”,用于指定包含行结束符n的字符串如何被写入。 它是以下两个值中的一个:
    • “native”,代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 “transparent”,代表会保持blob中保存的结束符不变。 🌰 :
1
2
var say = {hello: "world"}
var blob = new Blob([JSON.stringify(say, null, 2)],{type : 'application/json'})

URl对象

我们想要下载这个文件需要通过创建URL对象指定文件的下载链接

创建构造函数

创建新的URL表示指定的File对象或者Blob对象。

1
objectURL = window.URL.createObjectURL(blob)

释放对象

这个方法不会像JavaScript的垃圾回收机制那样帮你处理一些事情,在每次调用createObjectURL方法时,都会创建一个新的 URL 对象,即使你已经用相同的对象作为参数创建过。当不再需要这些 URL 对象时,每个对象必须通过调用 URL.revokeObjectURL方法来释放。浏览器会在文档退出的时候自动释放它们,但是为了获得最佳性能和内存使用状况,你应该在安全的时机主动释放掉它们。

1
window.URL.revokeObjectURL(objectURL)

使用a标签下载

生成一个a标签

1
const link = document.createElement('a')

指定下载链接

1
link.href = window.URL.createObjectURL(blob)

设置文件名

1
link.download = fileName

触发事件

1
link.click()

下载

1
2
3
4
5
6
7
8
9
10
11
12
const foo = {
hello: "world"
};
const blob = new Blob([JSON.stringify(foo)], {
type: 'application/vnd.ms-excel;charset=utf-8'
});
const fileName = `${new Date().valueOf()}.xls`;
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = fileName;
link.click();
window.URL.revokeObjectURL(link.href);

兼容IE

在IE中要使用window.navigator.msSaveOrOpenBlob来处理Blob对象。

1
window.navigator.msSaveOrOpenBlob(blob, fileName);

整体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*res是请求后返回的文件流,name是传入的文件名*/
export const downloadExcel = (res,name) => {
const blob = new Blob([res],{type: 'application/vnd.ms-excel'})
const fileName = `${name}.xlsx`
if ('download' in document.createElement('a')) {
const elink = document.createElement('a')
elink.download = fileName
elink.style.display = 'none'
elink.href = URL.createObjectURL(blob)
document.body.appendChild(elink)
elink.click()
URL.revokeObjectURL(elink.href)
document.body.removeChild(elink)
} else {
navigator.msSaveBlob(blob, fileName)
}
}

下载进度

ProgressEvent 接口是测量如 HTTP 请求(一个XMLHttpRequest,或者一个 <img><audio><video><style><link>等底层资源的加载)等底层流程进度的事件。

  • ProgressEvent.lengthComputable只读

    是一个 Boolean标志,表示底层流程将需要完成的总工作量和已经完成的工作量是否可以计算。换句话说,它告诉我们进度是否可以被测量。

  • ProgressEvent.loaded 只读

    是一个 unsigned long long 类型数据,表示底层流程已经执行的工作总量。可以用这个属性和 ProgressEvent.total 计算工作完成比例。当使用 HTTP 下载资源,它只表示内容本身的部分,不包括首部和其它开销。

  • ProgressEvent.total 只读

    是一个 unsigned long long 类型数据,表示正在执行的底层流程的工作总量。当使用 HTTP 下载资源,它只表示内容本身的部分,不包括首部和其它开销。

下面的示例为一个新建的 XMLHTTPRequest 添加了一个 ProgressEvent,并使用它来显示请求状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
var progressBar = document.getElementById("p"),
client = new XMLHttpRequest()
client.open("GET", "magical-unicorns")
client.onprogress = function(pe) {
if(pe.lengthComputable) {
progressBar.max = pe.total
progressBar.value = pe.loaded
}
}
client.onloadend = function(pe) {
progressBar.value = pe.loaded
}
client.send()

axios onUploadProgress(post) onDownloadProgres(get)

1
2
3
4
5
6
7
8
9
// `onUploadProgress` 允许为上传处理进度事件
onUploadProgress: function (progressEvent) {
// Do whatever you want with the native progress event
},
// `onDownloadProgress` 允许为下载处理进度事件
onDownloadProgress: function (progressEvent) {
// 对原生进度事件的处理
console.log(Math.round(progressEvent.loaded / progressEvent.total * 100) + '%');
},

axios的onDownloadProgress返回参数的total为0的问题。

  • 原因一、很简单,可能只是后端没有返回content-length,让后端工程师加上就行。

  • 原因二、开启了gzip。开启gzip之后服务器默认就会开启文件分块编码【Transfer-Encoding: chunked】,分块编码把「报文」分割成若干个大小已知的块,块之间是紧挨着发送的。

    采用这种传输方式进行响应时,没必要带上Content-Length这个首部信息。因为即使带上了也是不准确的,所以content-length会没有。

    解决方式之一:

    后端把文件大小存储到其他字段,比如:header[‘x-content-length’];