004 前端文件下载

12/14/2021 Javascript

前端文件下载是一个较为常见的需求,通常为后端返回二进制流文件,但在前端方面来说浏览器的安全机制会导致下载操作会有不同的实现,且涉及到文件类型转换会有一些副作用的产生,前端下载主要分为两种:

  1. 基于 location.href 、 iframe 下载;
  2. 基于 a 标签下载

# 一、基于 location.href 、 iframe 下载

这两种都是在另一个窗口或者当前地址栏地址指向下载链接,返回值必须是二进制流,同时设置正确响应头;其中不同的是iframe可以实现无闪下载

function getFile(url, params) {
    for (let key in params) {
        if (params[key] != undefined) {
            str += key + '=' + params[key] + '&';
        }
    }
    window.open(url + str.substring(0, str.length - 1));
}
// 调用
getFile('api/v1/download', {id: 888})
1
2
3
4
5
6
7
8
9
10

后端返回二进制流,并且设置 对应响应头字段: Content-Typeapplication/octet-stream,浏览器会在主进程中自动调起文件下载器进行下载,但在 post 中即使设置对应响应头也无法自动调起下载

# 二、基于 a 标签下载

假如现在需要使用XHR,我们按部就班写好api,然后调用,发现没有任何反应,并没有下载对应文件,打开开发者工具,我们看到 foo
一切正常, 该有的都有,查看response面板 foo
同样正常返回二进制流,那为什么没有没有调起下载动作呢

# 安全沙箱

查看Network 面板,我们可以看到我们的请求类型是:

foo

由于 XMLHttpRequest 请求是使用Javascript实现, 所以这个请求发出去以及接收的Response必然是由JavaScript处理, 而JavaScript 又由于浏览器安全机制的问题运行在 渲染进程安全沙箱

安全沙箱是用于隔离正在运行程序的安全机制,通常用于执行未经测试或不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行, 所以 XHR 请求并不能直接调用下载, 试想如果 XHR 可以下载文件,那就意味着 JS脚本 可以直接与磁盘交互,这会存在严重的安全隐患。

XHR 请求流程简述:

  1. 渲染进程会将请求发送给网络进程
  2. 然后网络进程负责资源的下载
  3. 等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程
  4. 渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中
  5. 等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。

那为什么直接跳转URL可以,就是因为这种 URL 直接跳转是由浏览器主进程进行处理,response 也由浏览器主进程处理,浏览器主进程有内置下载事件,就可以下载文件了

# 分析

根据以上分析,要使用 XHR 下载文件我们也就有思路了,既然 a 标签(或 frame)可以触发URL跳转, 那也有意味着可以触发浏览器的下载功能,那我们在请求拿到 response 后通过Blob对象创建一个二进制流文件,再创建一个不可见的 a 标签(标签的 href 指向文件),并执行它的 click 事件,由此触发浏览器的内置下载事件,不过需要注意的是,创建的 a 标签中需要添加一个 download 属性,由于浏览器能打开得文件,例如 html, 图片 等,如果不加 download,点击 a 标签就是直接打开了

# 实现

function download (url, fileName) {
  const token = localStorage.getItem('PmsToken');
  return axios
    .get(url, {
      responseType: 'blob', 
      headers: {
        Authorization: 'Bearer ' + token,
        Accept: 'application/json',
      },
    })
    .then(res => {
      const content = res;
      const blob = new Blob([content.data], {
        type: 'application/octet-stream',
      });
      // return;
      if ('download' in document.createElement('a')) {
        // 非IE下载
        const elink = document.createElement('a');
        elink.download = fileName; // 文件名可自行协商
        elink.style.display = 'none';
        elink.target = '_blank';
        elink.href = URL.createObjectURL(blob); // 创建该文件的内存URL
        document.body.appendChild(elink);
        elink.click();
        URL.revokeObjectURL(elink.href); // 释放URL对象
        document.body.removeChild(elink);
        // window.location.reload();
      } else {
        // IE10+下载
        navigator.msSaveBlob(blob, fileName);
        window.location.reload();
      }
    });
}
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

# 坑一: 获取文件名

正常情况下,文件名应该由后端附在响应头的 content-disposition, 但由于编码问题,我取到的是乱码:

access-control-allow-headers: Origin, Content-Type, Accept, Authorization, X-Requested-With
access-control-allow-methods: GET, POST, OPTIONS
access-control-allow-origin: *
cache-control: cache, must-revalidate
connection: close
// 文件名未解码
content-disposition: attachment; filename="在库串�查询2021-08-06.xlsx" 
// 不要直接复制进行测试,这里编码显示还是有问题的,一定要正常取值打印到控制台测试
content-type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet; charset=UTF-8
date: Fri, 06 Aug 2021 02:39:45 GMT
expires: Mon, 26 Jul 1997 05:00:00 GMT
last-modified: Fri, 06 Aug 2021 10:39:44
pragma: public
server: nginx/1.18.0
transfer-encoding: chunked
x-powered-by: PHP/7.0.33
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

解析方法如下

// axios中
const FileName = extractFilenameFromHeader(res.headers['content-disposition'])
// extractFilenameFromHeader
function extractFilenameFromHeader(header) {
  const reg = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
  const tmp = reg.exec(header);
  if (tmp) {
    return decodeURI(escape(tmp[1])).replace(/\"/g, "");;
  }
  return '默认';
}
1
2
3
4
5
6
7
8
9
10
11

# 坑二: 报错处理

由于在 axiso 中我们设置了 responseType 参数为 blob

axios.get(url, {
      responseType: 'blob', 
      headers: {
        Authorization: 'Bearer ' + token,
        Accept: 'application/json',
      },
    })
1
2
3
4
5
6
7

由于这个参数的原因,无论后端返回什么,axios 都会自动转为blob对象,那就会出现后端抛出json错误信息,而axios照样处理伟blob,导致取不到错误信息,那么就需要分开处理, 使用 try catch, 我们使用JSON方法对返回值进行转换,如果抛错,说明后端返回的是二进制流,然后进入到catch中进行下载操作,反之我们在try中转换成功说明后端返回了 json , 返回 json 说明请求报错,而后进行错误信息提示或处理

# 最终改良版

const downloadFile = function ( url, fileName, params = {} ) {
message.loading( '导出中..', 0 )
const token = localStorage.getItem( 'PmsToken' );
return axios.get( url, {
    responseType: 'blob', // 表明返回服务器返回的数据类型,
    headers: {
      Authorization: 'Bearer ' + token,
      Accept: 'application/json',
    },
    params
  } )
  .then( res => {
    const data = res.data
    const reader = new FileReader() // 创建 reader 进行处理
    reader.onload = function () {
      try {
        const resData = JSON.parse( this.result ); // 解析成功: 后端返回的是json
        message.destroy()
        message.error( resData.msg ) // 报错信息抛出
      } catch ( err ) {
        message.destroy()
        const name = extractFilenameFromHeader( res.headers[ 'content-disposition' ] )
        downloadFn( res.data, name );
      }
    }
    reader.readAsText( data ); // 启动 reader
  } )
  .catch( err => {
    console.log( err )
  } );
}
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
Last Updated: 12/15/2021, 11:12:47 AM