通常我们使用AJAX向服务器请求数据,但是这些数据都是文本,而对于请求文件(二进制流),我们需要花点功夫进行处理。
本文从传统的下载实现方案到全新的HTML5 Blob下载方案进行比较全面的介绍。
一、 <a>
标签发起下载
一般情况下,定义一个<a>
标签,href
指向一个浏览器无法处理的文件就会发起下载。既然文件下载如此简单,那为什么还要写文章讨论它呢?
它只能只能使用GET
请求,直接使用的话,会出现页面跳转或弹窗,甚至可能被浏览器拦截。
二、<form>
表单发起下载
提交<form>
表单,若action
指向的是文件,亦可以发起下载,定义属性method="POST"
即可发起POST
请求,显然是比上面更优的方案。同样会出现页面跳转或弹窗。这里讨论的是以一种异步的方式发起下载,在不主张简单问题复杂化的情况下,第三点(重点)对这两种简单的方式进行特殊处理。
三、 利用<iframe>
标签实现异步下载
页面跳转或弹窗严重影响用户体验,它打断用户正在处理的事情,我们希望下载请求是被静默处理的。<a>
标签和<form>
都有target
属性,可在指定iframe
打开链接。既然资源是用来下载的,打开的页面对用户毫意义,那么可以把用来打开文件链接的iframe
隐藏,完成下载后销毁,即可实现异步下载。
更进一步,我们希望我们的下载逻辑是可复用的,不需要写特定的HTML结构,只需要指定URL和对象参数,为文件下载服务的<a>
、<form>
、iframe
等标签在需要时被静默创建不需要时自动销毁,并有一个错误处理机制。
下面是一个开箱即用的实现,为了方便操作DOM,使用了jQuery。
/**
* 异步文件下载(不刷新不弹窗)
* @function download
* @param {String} url 文件下载地址
* @param {Object} options 配置项
* @param {Object|String} [options.data] 请求参数,可以是类似jQuery的serializeArray()方法获取的数组形式;也可以直接是每个值都是字符串的对象。| 如果直接不带请求数据,该参数等于method
* @param {String} [options.method="GET"] 请求方式,可选:POST、GET
* @param {Number} [options.timeout=2min] 请求超时时间
* @param {Function} [options.onError] 错误回调函数
*/
function download(url, options) {
var opt = {
method: "GET",
data: null,
timeout: 1000 * 60 * 2,
onError: function (text) {
return text || "没有找到匹配的文件";
},
};
$.extend(opt, options);
var iframeName = "download-" + Date.now(),
// 为了避免页面跳转,采用创建临时iframe的方式
$iframe = $('<iframe name="' + iframeName + '" style="display:none;"></iframe>').appendTo(document.body);
// 给iframe传递一个错误处理回调函数等遇到错误时调用
$iframe[0].errorCallback = opt.onError;
if (opt.method.toUpperCase() === "POST") { // POST 方式
var iframeDoc = $iframe.prop("contentDocument") || $iframe.prop("contentWindow").document, // 获取iframe的document对象
$form = null,
data = opt.data;
// 为iframe写入一个form元素,利用该form元素发起文件下载请求
iframeDoc.write('<form method="POST" action="' + url + '"></form>');
$form = $(iframeDoc).find("form"); // 获取该form元素
// 带请求参数的情况
if (data instanceof Object) {
if (Array.isArray(data)) { // data是数组形式
data.forEach(function (o) {
if (o.value) $("<input>").prop(o).appendTo($form);
});
} else { // data是对象形式
for (var n in data) {
$("<input>").prop({ name: n, value: data[n] }).appendTo($form);
}
}
}
// 提交表单
$form.submit();
} else { // 默认 GET 方式
url.indexOf("?") < 0 ? url += "?" : url += "&";
window.open(url + $.param(opt.data), iframeName);
}
// 移除临时iframe
setTimeout(function () {
$iframe.remove();
}, opt.timeout);
return this;
}
这里遇到文件下载的一个经典问题:错误处理。你有没有发现,很多网站在我们点击下载文件时,会弹出类似提示:”如果浏览器没有发起下载请再点击……“,这是因为通过表单的方式发起下载我们并没有相关接口能够获得请求状态。不过我们并非完全措手无策,上面方法,在遇到服务器主动报错请求时可以得到错误反馈,例如服务器找不到文件或者业务逻辑不符合(非程序错误)拒绝了请求,通过补充下面函数进行处理:
/**
* @function download.error
* @param {Object} [frameWindow] iframe 的 window 对象
* @param {String} [text] 错误提示文本
*/
download.error = function (frameWindow, text) {
var frameElement = frameWindow.frameElement;
text = frameElement.errorCallback(text);
document.body.removeChild(frameElement);
text && alert(text);
}
此时,有点类似JSONP
请求,服务器必须返回指定HTML字符串,并且指定HTML响应头Content-Type: text/html
,例如配合上面的代码可以这样返回:
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>下载失败</title></head>
<body><script>
download.error(self, "文件不存在"); // 方法名要跟上面定义的错误处理方法名一样
</script></body>
</html>
这样我们已经拥有了一个类似发起$.ajax
这样的方法发起下载请求。值得注意的是,如果文件是浏览器能够识别的,很有可能是被浏览器打开而不是下载,例如txt
、jpg
等文件,对于用于下载的文件,服务器应该明确指定一个下载请求头:Content-Type: application/force-download
。
那么问题来了,为什么我们不直接使用$.ajax
这种普通AJAX请求来实现文件下载而非得这般折腾?如果你对兼容性要求不高,下面介绍一个更加优雅的方式。
四、 HTML5 XHR2与Blob接口实现文件下载
直接使用类似$.ajax
等 AJAX 下载文件是不行的。<a>
、<form>
、<iframe>
标签发起的请求,响应是直接由浏览器处理,浏览器识别为下载内容时会触发下载功能,而AJAX(XHR)请求是交给 JavaScript 处理,响应只能是字符串,即使是二进制流,依然会转换成字符串传递给回调函数,因此不会触发下载功能,这就是我使用大篇幅介绍如何封装<a>
、<form>
、<iframe>
标签进行下载的原因。
得益于 HTML5 的 XHR2 与 Blob 接口,现在可以处理 Blob 响应,除此之外还需要一些技术支持,当然最终还是通过<a>
标签触发下载。
首先介绍下什么是 Blob。
Blob 是对大数据块的不透明引用或者句柄,在 JavaScript 中,Blob 通常表示二进制数据。在使用 Blob 之前,首先要知道怎么获取 Blob。获取 Blob 有很多途径,这里讨论的是下载,因此只分析从WEB中下载 Blob,而从 WEB 中下载 Blob,需要 XHR2 的支持。
那么什么是XHR2?
上面提到的 AJAX 特指使用 XHR。相对 XHR2,老 XHR 存在一些缺点,具体可见阮一峰老师的XMLHttpRequest Level 2 使用指南,这里要说的是老XHR不支持上传和下载二进制数据,喜闻乐见,XHR2 支持了,可以通过设置 XHR 实例responseType = 'blob'
接收二进制数据。
其他条件
有了上面条件还不够,拿到了 Blob,上面说了最终还是通过<a>
标签触发下载,那么必须把有一个把 Blob 转换成”Blob URL“的方法才能供<a>
标签打开,它就是 HTML5 新方法window.URL.createObjectURL
。注意这不是普通 URL,直接打开 Blob URL 跳转的是乱码而不是触发下载,正确使用 Blob URL 的方式就是<a>
标签得支持 HTML5 的新属性download
。
假设上面通通被支持,那么我们开始写代码,由于这里讨论的是应用级别的方案,就不使用原生 XHR2 写代码了,原生实现可以移步到上面阮老师的文章。jQuery 的 AJAX 不支持 Blob 响应,这里使用了axios做演示。
axios.get('api.xlsx', { responseType: 'blob' }) // 参数指定响应类型是blob
.then(function (res) {
// 取得Blob
var blob = res.data;
// 创建一个URL指向Blob,也就是Blob URL
var objectURL = window.URL.createObjectURL(blob);
// 创建<a>标签
var el = document.createElement('a');
el.href = objectURL;
// <a>标签的 download 属性,指定下载的文件名,最后带上后缀,文件名可通过请求头获取。
el.download = 'file.txt';
el.hidden = true;
document.body.appendChild(el);
el.click();
document.body.removeChild(el);
// 移除文件的URL引用有利于内存回收
window.URL.revokeObjectURL(objectURL);
});
如果是IE,比较特殊,也比较简单:
window.navigator.msSaveOrOpenBlob(blob, fileName);
可见使用 HTML5 接口实现起来非常简单,甚至都不需要复杂的封装。更重要的是,你能监听到完整的请求和响应状态,进而可以对错误处理。
除了兼容性之外的坏消息是,前面3种浏览器帮你处理文件会比较省心,而这是手动处理文件,当文件很小时上面示例完全满足需求,当文件很大时,尽管 Blob 接口是异步的,如果怕页面失去响应,你可能需要在 Worker 线程中进行处理。