JavaScript网络请求(二):前端下载方案

前端下载方案

浏览器端的下载,广泛应用于文件下载、导出等操作,前端发起下载的方案有各种的方案,但是都与后端的协议息息相关,比如最简单的甚至可以直接使用链接即可发起下载。

a 标签下载

1
<a href="https://url.com/file" download="logo-image">下载</a>

这样既可在高版本浏览器中,指定一次下载,download属性便是指定该链接为下载的属性,属性值有三种形式:

  • 省缺:不填写的情况下会根据返回头中的 “Content-Disposition” 来生成名称。
  • 文件名:优先使用该值作为文件名,后缀名根据Content-Disposition来生成。
  • 文件名.后缀: 使用该文件名与后缀来下载文件。

以下为浏览器的支持情况:

浏览器支持情况

在实际开发中,更多的情况可能会是使用JavaScript来发起下载,而不是直接写好一个连接。因此我们可以通过动态的创建a标签,并触发点击事件,来完成下载。一个标准的下载工具函数如下:

1
2
3
4
5
6
7
8
9
const download = (url: string, fileName: string) => {
const elementLink = document.createElement("a");
elementLink.style.display = "none";
elementLink.download = fileName;
elementLink.href = url;
document.body.appendChild(elementLink);
elementLink.click();
document.body.removeChild(elementLink);
};

直接使用a标签来发起下载,在不同的浏览器端,会有一些不符合预期的表现形式,比如在测试中遇到的情况就是如果返回的文件是PDF格式,并且指定了’Content-Type’ Header为 ‘application/pdf’,则在不同的浏览器上有不同的表现。

  • FireFox download指定的文件名失效
  • Chrome 会直接跳转到PDF阅读页面,而不是发起下载

以下为不同浏览器表现的视频效果:

Chrome 中点击下载,则会跳转到阅读页面
firefox中则是指定的文件名失效

经测试,在使用JavaScript动态创建的标签下载时,不会出现该问题。和使用a标签链接对应的文件类似,我们同样可以使用其他的方式打开链接,让浏览器决定下载内容。

location.href / window.open

两种实现方法比较类似,都是使用浏览器直接打开对应的链接,然后浏览器根据Header中的content-disposition 的信息来决定是展示还是下载。

对于普通的二进制文件正常都是直接触发下载,但是对于图片、video等浏览器可以直接解析的内容,就可能会出现直接在浏览器展示,而不是下载。这其中的主要原因依旧是content-disposition Header的缘故。

content-disposition在作为response header时候,可以有两种类型,分别是:

  • inline (默认值)—— 如果浏览器可以解析,则使用浏览器展示内容
  • attachment —— 作为附件下载,可以指定文件名

对于这两种文件类型的效果区别,可以分别点击以下链接查看Header信息里的区别:

inline https://blog-demos.vercel.app/api/download/inline-image
attachement https://blog-demos.vercel.app/api/download/attachment-image

因此,如果希望浏览器直接打开的方式下载文件,需要服务端配置好对应的content-disposition为attachment值。

XMLHttpRequest + a标签下载

使用XHR下载主要可以处理以下几种情况,其中前两项为比较常见的场景:

  • 认证信息存在于Header中,而不是cookie中
  • 错误信息回调
  • JS端获取最新的下载进度,展示下载效果
  • 控制下载取消

使用XHR下载的思路是,请求的responseType为blob格式,在获取到文件之后,使用createObjectURL转换为对象地址,赋值给a标签的href属性,完成下载。

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
33
34
35
36
37
38
39
// 触发下载blob文件
const triggerDownload = useCallback((data: any, fileName: string) => {
let url = window.URL.createObjectURL(new Blob([data]));
let link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.setAttribute("download", fileName);
document.body.appendChild(link);
link.click();
}, []);

// XHR请求文件内容
const onDownload = useCallback(
async (url: string) => {
try {
const response = await Axios.get(url, {
// 文件类型设置
responseType: "blob",
headers: {
Authorization: `Bearer Token`, // 授权信息
},
});
if (response.status === 200) {
let fileName = "";
if ("content-disposition" in response.headers) {
// 从Header中获取文件名
const dispositionParams = contentDisposition.parse(
response.headers["content-disposition"]
).parameters;
fileName = dispositionParams["filename"];
}
triggerDownload(response.data, fileName);
}
} catch (error) {
message.error(error.message);
}
},
[triggerDownload]
);

错误信息回调

以上便是一个较为完整的使用XHR下载的方法,基于此,我们为其增加错误信息回调。

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
const onDownload = useCallback(
(url: string) => {
return new Promise(async (resolve, reject) => {
try {
//...code...
if (response.status === 200) {
//...code...
triggerDownload(response.data, fileName);
resolve("success");
} else {
reject("response status" + response.status);
}
} catch (error) {
// Axios的错误类型,读取返回数据中的JSON内容
if (error.isAxiosError) {
// 因为数据默认设置为Blob文件类型,因此需要使用fileReader将数据从Blob中读取出来
const fr = new FileReader();
fr.onload = function () {
const result = JSON.parse((this.result as string) ?? "{}");
reject(result.message);
};
fr.readAsText(error.response.data);
} else {
// 其他错误类型,直接返回消息体
reject(error.message);
}
}
});
},
[triggerDownload]
);

使用fileReader将后端返回的错误JSON解析出来,这样在使用该函数的过程中,就可以获取到成功或者错误的回调信息了。

进度和取消

取消网络请求可以使用xhr.abort,对应Axios的实现为cancelToken,取消网络请求相关的介绍内容可以参照之前内容:「JavaScript网络请求(一):处理race condition竞态问题#abort上一次网络请求」

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
const onDownload = useCallback(
(url: string) => {
return new Promise(async (resolve, reject) => {
const CancelToken = Axios.CancelToken;
cancelSource.current = CancelToken.source();

try {
const response = await Axios.get(url, {
// 文件类型设置
responseType: "blob",
headers: {
Authorization: `Bearer Token`, // 授权信息
},
cancelToken: cancelSource.current.token,
onDownloadProgress(evt) {
setPercentage(Math.ceil((evt.loaded / evt.total) * 100));
},
});
if (response.status === 200) {
// ...code...
resolve("success");
} else {
reject("response status" + response.status);
}
} catch (error) {
// ...code...
});
},
[triggerDownload]
);

在线测试效果地址:https://blog-demos.vercel.app/admin/blog-demos/user-download

完整代码地址:https://gist.github.com/mrxf/14091139d710a7e4d9d79b71e1472243

FileSaver.js

filesaver.js是非常强大的前端文件保存方案,不仅限于下载文件,包括canvas等内容都可以直接保存,是非常成熟的方案。

下载文件也非常的方便,如果有其他的保存需求,可以使用该库。

1
FileSaver.saveAs("https://httpbin.org/image", "image.jpg");

附录:

🤔 Q: 在一些返回中,并不能看到content-disposition Header信息,导致无法获取文件名,应该怎么办呢?

✅ 解决思路:检查Access-Control-Expose-Headers 的配置信息,该Header控制响应Header暴露那些Header到外部,默认不展示content-disposition。

🤔 Q: XHR的responseType还有哪些格式?

🙋‍♂️ Answer:text(省缺值)、json、document、arraybuffer、blob、ms-stream(IE支持的下载类型)

🤓 Q:content-disposition的attachment类型的规范是怎么样的呢?

🙋‍♂️ Answer:除了可以直接设置attachment值之外,还可以指明文件名,格式如下:

  • attachment; filename=”filename.jpg”
  • attachment; filename=”???.png”; filename*=UTF-8’’%E4%B8%AD%E6%96%87%E5%90%8D.png
  • filename“采用了 RFC 5987 中规定的编码方式,和filename同时出现时,应该优先使用filename,主要在非英文名时会用到

因此在获取content-disposition的文件名的时候,直接用正则匹配可能效果并不好,尤其是在出现了 filename*的时候,处理的条件就更加复杂了。建议直接使用第三方工具包 content-disposition

1
$ npm install content-disposition

参考