axios.js实现文件下载功能

3,465 阅读3分钟

原文链接

在开发中遇到了需要实现文件下载的功能,起初以为只用<a>标签就能搞定,<a>标签确实能够搞定常见的场景。但是像导出或者在header里面添加了特殊字段的时候,使用<a>标签就搞不定了,又不想去使用原生XMLHttpRequest,因为又一堆的兼容性需求(技术能力不够ε=ε=ε=┏(゜ロ゜;)┛,有现成的兼容方案为啥要自己造轮子呢,说不定还爆胎>逃666),所以萌生基于Axios封装。

Ajax无法下载文件的原因

浏览器的GET(frame、a)和POST(form)请求具有如下特点:

  • response会交由浏览器处理
  • response内容可以为二进制文件、字符串等

Ajax请求具有如下特点:

  • response会交由Javascript处理
  • response内容仅可以为字符串

Ajax本身设计的目标就是用来获取文本数据的,而不是用来搞二进制的。

最近看文档发现, xhr在老的浏览器里面也是可以发送二进制数据的,用到一个冷门的api XMLHttpRequest#overrideMimeType,可以看这里在老的浏览器中接受二进制数据注意兼容性

XMLHttpRequest 2.0新增的数据类型Blob

看张老师的文章 理解DOMString、Document、FormData、Blob、File、ArrayBuffer数据类型

有了Blob类型之后,JavaScript处理二进制进一步增强,可以说以后想怎样就怎样(废话)。

文件下载实现

response header前提条件

服务端返回的头部需要设置

Content-Disposition: "attachment; filename=xxxx.docx;"

<a>标签的直接下载

// Downloader.ts
import qs from 'qs';
/**
 * downloadByUrl
 * @param config - 配置参数
 * @param config.url - 地址
 * @param config.params - querystring参数.
 * @param filename 文件名称,包括扩展名部分(不一定生效)
 *
 * @description
 * 原理是使用<a>的href和download属性,所以filename不一定会生效, 浏览器机制问题.
 *
 * @see https://zhuanlan.zhihu.com/p/58888918
 * @see https://github.com/kennethjiang/js-file-download
 */
export function downloadByUrl(
config: {
    url: string;
    params: any;
},
  filename = ''
): void {
  var tempLink = document.createElement('a');
  tempLink.style.display = 'none';
  tempLink.href =
    config.url + qs.stringify(config.params, { addQueryPrefix: true });
  tempLink.setAttribute('download', filename);
  if (typeof tempLink.download === 'undefined') {
    tempLink.setAttribute('target', '_blank');
  }

  document.body.appendChild(tempLink);
  tempLink.click();
  document.body.removeChild(tempLink);
}

主要是使用了js-file-download的代码,进行了简单的封装,而且去除了对Blob的依赖,主要为了兼容低版本的浏览器。同时使用了qsquerystring参数进行了简单的处理。

基于axios的实现

// Downloader.ts
import axios, { AxiosResponse, AxiosRequestConfig } from 'axios';
import fileDownload from 'js-file-download';
import logger from 'js-logger';


/**
 * 提取文件名.
 * @param response axios的response
 * @description 从reponse header的content-disposition中提取文件名.
 */
const extractFilenameFromResponseHeader = (response: AxiosResponse): string => {
  // content-disposition: "attachment; filename=xxxx.docx;"
  const contentDisposition = response.headers['content-disposition'];
  const patt = new RegExp('filename=([^;]+\\.[^\\.;]+);*');
  const result = patt.exec(contentDisposition) as RegExpExecArray;
  let filename = '';

  if (result) {
    filename = result.length > 0 ? result[1] : '';
  }
  // 解码之前尝试去除空格和双引号
  // content-disposition: "attachment; filename=\"xxxx.docx\";"
  return decodeURIComponent(filename.trim().replace(new RegExp('"', 'g'), ''));
};


const axiosInstance = axios.create({/* 
	可以传递公共默认的axios配置,但是注意reponse interceptor中默认把reponse.data作为JSON解析的情况
*/});

// https://www.zhihu.com/question/263323250
// https://github.com/axios/axios/issues/815#issuecomment-340972365
const downloadByAxios = async function (
  config: AxiosRequestConfig,
  filename = ''
): Promise<any | AxiosResponse<any>> {
  let response = await axiosInstance({
    ...config,
    responseType: 'blob', // 指定类型
  });

  let resBlob = response.data; // <--- store the blob if it is
  let respData = null;

  // 如果确定接口response.data是二进制,所以请求失败时是JSON.
  // 这里只对response.data做JSON的尝试解析
  try {
    let respText = await new Promise((resolve, reject) => {
      let reader = new FileReader();
      reader.addEventListener('abort', reject);
      reader.addEventListener('error', reject);
      reader.addEventListener('loadend', () => {
        resolve(reader.result as string);
      });
      reader.readAsText(resBlob);
    });
    respData = JSON.parse(respText as string); // <--- try to parse as json evantually
  } catch (err) {
    // ignore
  }
  // 如果response.data能够确定是二进制,则respData = null说明请求成功
  // 否则 respData !== null说明请求失败
  if (respData as ResponseData) {
    logger.error(respData);
      
    // 方便调用者有进一步的 then().catch()处理
    return Promise.reject({
      ...respData,
    });
  } else {
    // 触发浏览器下载
    // 如果没有传递filename尝试从Content-Disposition提取
    fileDownload(resBlob, filename || extractFilenameFromResponseHeader(
      response
    ));
    // 方便调用者有进一步的 then().catch()处理
    return Promise.resolve({
      ...response,
    });
  }
};

代码大部分都是参考这个issue实现的,只有少部分的个人代码。

基于axios实现的功能:

  1. 可以使用axios的所有参数,不管请求是GET或者POST
  2. 解决了在header中添加额外的参数的需求
  3. 可以指定filename,如果服务端没有设置content-disposition的情况
  4. 返回Promise方便调用者进一步处理请求

缺点:

  1. 只能使用独立的axios实例,不能公用一个axios

    本来想把下载功能使用axios interceptor拦截器实现,但是返回的response.dataBlob二进制,但是其它的response interceptor默认前提都是把response.data当作JSON处理,导致全部出现异常,所以把下载功能独立出来,更方便维护。

  2. 使用独立的axios实例,所以项目中的axios默认配置需要重新配置一遍

参考链接

Content-Disposition

axios.js实现下载功能

axios.js #815实现

StreamSaver

FileSaver

js-file-download 理解DOMString、Document、FormData、Blob、File、ArrayBuffer数据类型