WEB下载文件的各种实现及错误处理

通常我们使用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这样的方法发起下载请求。值得注意的是,如果文件是浏览器能够识别的,很有可能是被浏览器打开而不是下载,例如txtjpg等文件,对于用于下载的文件,服务器应该明确指定一个下载请求头: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 线程中进行处理。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容