前端压缩包下载遇到解压失败问题

最近在跟后端对接压缩包下载遇到了下载OK,解压报错inappropriate file type or format,尝试使用postman却可以正常下载解压,所以很是奇怪。毕竟postman可以,后端不改的基础上前端一定是有解的,于是继续摸索,最终因为进度所赶,改用base64编码传输,解决了这个问题,但没有根本搞清root cause。

借着周末,研究输出下。

报错

报错如上

代码

这里贴出问题相关前后端代码

后端

1
2
3
4
5
6
7
router.get('/download-binary', function (req, res) {
const currentPath = process.cwd();
const file = fs.readFileSync(`${currentPath}/static/file/test.zip`, 'binary');
res.setHeader('Content-Length', file.length);
res.setHeader('Content-Type', 'application/zip');
res.write(file, 'binary');
});

前端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

function downloadFileClick(addResponseType = false) {
const xhr = new XMLHttpRequest();
xhr.open('get', '/test/download-binary');
xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
xhr.onload = () => {
if (xhr.status === 200) {
saveFile(xhr.response);
}
}
xhr.send();
}

function saveFile(content) {
const a = document.createElement("a");
const file = new Blob([content], {type: 'application/zip'});
a.href = URL.createObjectURL(file);
a.download = 'test.zip';
a.click();
}

解决

一开始以为是blob转存文件问题,尝试改写blob构造时type指定及增加编码等,发现还是不行。

于是翻看MDN,发现,XHR还是可以明确指定responseType于是加入了下

1
xhr.responseType = 'blob';

发现,下载解压经常正常了,原来问题在这里。

于是,结论就是后端返回二进制数据的前提下,前端需要明确responseType即可,当然如果是新开标签同步方式下载不存在该问题,因为浏览器自己会指定。

问题虽然解决,但为什么这样就行?这里整体分析下。

分析

xhr.responseType

MDN文档上关于responseType这样描述

XMLHttpRequest.responseType 属性是一个枚举类型的属性,返回响应数据的类型。它允许我们手动的设置返回数据的类型。如果我们将它设置为一个空字符串,它将使用默认的”text”类型。

断点查看下xhr.responseType设定对于返回response体的影响

  • 默认文本

  • blob

可以看出response类型有区别

因此可以说后端返回的response[content-type]在xhr中并没有用,仍然需要手动指定。但response[content-type]一点用都没有?错。

Response Headers[Content-Type]

尝试修改response[content-type]来对比测试

chrome下预览返回结果,

  1. 对于二进制还是文本,预览是chrome都会文本形式进行预览,所以直观来看内容一样
  2. 但是在JS中仍然会有影响,如果后端返回文本形式的内容,最终下载解压仍然失败。
  3. Content-Length在文本形式返回及二进制返回大小并不相同,比如这里如果是Text,长度是350,而Blob是318

所以结论就是前端xhr要明确responseType同时后端需要response[content-type]设置二进制返回,缺一不可。

Postman

使用postman测试,确保后端代码返回二进制数据,前端代码不指定responseType下解压报错。而postman却解压正常。

原因就容易解释了,浏览器异步请求需要指定XHR来确保返回数据正确进行格式解析,而postman请求的内部实现无论是利用的浏览器异步还是别的程序编程,总之正确的解析了返回的二进制数据,因此没问题。

DataURL

当然除了上述办法外还有一个办法即DataURL,这也是在一开始我还没解决找到解压根本原因前的解决方案。

后端

1
2
3
4
5
router.get('/download-base64', function (req, res) {
const currentPath = process.cwd();
const content = fs.readFileSync(`${currentPath}/static/file/test.zip`, 'base64');
res.json({content});
});

前端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 function downloadFileBase64Click() {
const xhr = new XMLHttpRequest();
xhr.open('get', '/test/download-base64');
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = () => {
if (xhr.status === 200) {
saveBase64File(JSON.parse(xhr.response).content);
}
}
xhr.send();
}
function saveBase64File(content) {
const a = document.createElement("a");
a.href = 'data:application/zip;base64,' + content;
a.download = 'test.zip';
a.click();
}

如上即可解决。

需要注意的是,采用base64,后端返回的内容长度是438,相较二进制的318,长度会变大。

查资料了解:Base64编码的数据体积通常是原数据的体积4/3

Binary vs DataURL

搞清楚了问题所在,且了解到有两种方案,那哪种方案更好呢。首先了解下利弊

  1. Base64编码后数据体积变大
  2. 对于文件,采用Base64编码一般是用于降低HTTP会话请求数量

个人觉得如果是异步下载,优先二进制流即可,没必要造成后端编码成本及请求体积增大。

DataURL适用于比如网页加载部分图片等资源,不希望占用HTTP会话,且有些简单的图片资源需要动态生成,那么可以使用DataURL

延伸

utf8 vs utf-8

利用Node来编写下载支持的后端代码时注意到fs.readFileSync的编码有两个类似的值utf8,utf-8。这样傻傻分不清。查了下资料,这里两个值使用哪个均可,准确来说utf8是utf-8的别名

WHY?因为有些框架下,对于编码常量上,不支持中线,因此才有了utf8这个值,node是跨平台运行环境,对此做了兼容处理。

Wireshark抓包

Chrome下看到的请求无法以16进制/二进制形式查看数据,肉眼看到的只是文本形式,因此觉得类似,但毕竟内容不同,那么具体怎么不同,这里可以使用抓包工具Wireshark来真正查看HTTP响应报文数据。

  1. 截图仔细对比16进制的数据,可以看出HTTP请求响应包-文本/二进制数据的内容是不同的。当然如果看右侧的文本形式还是会觉得相同。这点也与chrome中看到的一致
  2. 留意红框选中的length值,并不是前面讲到的内容长度350,这是因为Wireshark在包列表页展示的length为以太网帧的长度,这其中当然包含了在chrome看到的请求响应中的content-length即应用层HTTP-请求响应体的内容长度
  3. 虽然有返回不同的文本/二进制,但实际的数据传输最终都会是二进制01,这里只是显示为16进制格式而已,在Wireshark下也可以切换为二进制展示

写在最后

关于文中提到的代码demo,可以在这里找到

问题到此为止,结论也清晰了,Mark。

相关文档