有一个「导出」按钮,用户点击之后后端返回文档流,前端自动唤起浏览器下载。如此简单的功能要如何完美的去实现它呢?
比较简单的做法是把请求的 URL 直接当作 a 标签的 attr,类似这样 <a href={url}>导出</a> 。
但由于在设计之初考虑到这个导出接口附带大量的筛选项,所以使用了 post 请求方式,那么只能使用 ajax 去做这件事。
然后看起来是一个简单的功能,却触及到了我的知识盲区(逐渐弱智),因此做个简单的记录。
ajax 侧
能实现这个功能的前提是我们需要能够「正确」的拿到后端传递的数据,所以我们需要在 ajax 请求侧做一些额外的工作。
responseType
前端常用的 ajax 请求库是使用的 axios,而 axios 的浏览器端的核心请求库是 XMLHttpRequest。
最初我天真的以为 XHR 可以根据响应头智能的识别响应体的内容(事实上从应用层的角度上确实可以)但上升到 JavaScript 层面的数据格式化方式则需要手动指定。
从目标上来看,我们希望得到正确的数据,即
const blob = response.data
我们查看 axios adapters 部分的源码,来寻找这个 response.data 到底是怎么被得到的

也就是如果 responseType 没有被指定或者是 'text' 或 'json' 就会读取 xhr.responseText,否则读取 xhr.response。
我们进一步从 XHR 的文档确定我们所需要的应当为 xhr.response

当然从文档对 XMLHttpRequest.responseType 字段的说明,结合 axios 对 config.responseType 的操作,我们不难判断出,只有当我们手动设置 responseType='blob' 时,XHR 会将 http 请求体格式化为 Blob 对象且 axios 会正确的将 xhr.response 赋值给 responseData

经过这一系列连锁反应,我们就可以得到正确的数据。因此我们写下第一行代码
request.post(url, data, { responseType: 'blob' })
middle wares
一般请求库会做一些中间层用于打点或者错误拦截,常见的操作会对 data 中的某些属性进行操作。
而当 data 是 Blob 时可以会出现一些意外导致报错,所以我们需要把他排除在外(如果有的话)
await next()
const { response } = ctx
if (!error && response?.data instanceof Blob) {
return
}
交互侧
前端在需要下载文件的场景一般通过
而这两种方式都需要得到下载文件的 URL。
因此我们需要通过 URL.createObjectURL() 这个方法实现 Blob 向 url 的转化
const fileDownload = (url: string) => {
const $a = document.createElement('a')
$a.target = '_blank'
$a.style.display = 'none'
$a.href = url
document.body.appendChild($a)
$a.click()
document.body.removeChild($a)
}
const { data } = await exportFile()
if (data instanceof Blob) {
fileDownload(URL.createObjectURL(data))
}
文件名
下载功能是实现了,但还没来得及松口气打开下载文件夹一看,好家伙!文件名显得非常的抽象很明显不符合预期。

因此我们还需要增加一个能力,通过后端获得正确的文件名并且重命名到下载文件上。
当 http 请求内容为附件时通常会具备 content-disposition 这个响应头,我们可以在有关他的说明文档中找到该头的意义。
而他的语法是这样的:

因此我们可以写个简单的正则拿到后端指定的文件名
const { headers } = await exportFile()
const fileName = headers['content-disposition']?.replace(/.+?filename=(.+)/, '$1')
需要注意的是 content-disposition 的字符是经过转义的,所以如果文件名出现中文会乱码,因此在下载之前要做一次转义
decodeURIComponent(fileName)
然后我们可以借助 a 标签的 download 属性来指定下载文件名

对 fileDownload 方法做一些优化
const fileDownload = (url: string, fileName?: string) => {
// ...
$a.download = fileName || url
// ...
}
done.