你真的会用标签下载文件吗?


bd587019f86bf447917519eec507029f.png

最近和后端联调下载时忽然发现屡试不爽的 标签下载失灵了?这才感觉自己对文件下载一直处在一知半解的模糊状态中,趁端午前夕有空赶紧总结了一下,和大家一起讨论讨论。

标签 download

这应该是最常见,最受广大人民群众喜闻乐见的一种下载方式了,搭配上

download

属性, 就能让浏览器将链接的 URL 视为下载资源,而不是导航到该资源。

如果

download

再指定个

filename

,那么就可以在下载文件时,将其作为预填充的文件名。不过名字中的

/

和 “ 会被转化为下划线

_

,而且文件系统可能会阻止文件名中的一些字符,因此浏览器会在必要时适当调整文件名。

封装下载方法

贴份儿我常用的下载方法:

ts
复制代码
const downloadByUrl = (url: string, filename: string) => {
    if (!url) throw new Error('当前没有下载链接');

    const a = document.createElement("a");
    a.style.display = "none";
    a.href = url;
    a.download = filename;
    // 使用target="_blank"时,添加rel="noopener noreferrer" 堵住钓鱼安全漏洞 防止新页面window指向之前的页面
    a.rel = "noopener noreferrer";
    document.body.append(a);
    a.click();

    setTimeout(() => {
        a.remove();
    }, 1000);
};

Firefox 不能一次点击多次下载

这里有个兼容性问题:在火狐浏览器中,当一个按钮同时下载多个文件(调用多次)时,只能下载第一个文件。所以,我们可以利用 标签的

target

属性,将其设置成

_blank

让火狐在一个新标签页中继续下载。

ts
复制代码
// 检查浏览器型号和版本
const useBrowser = () => {
    const ua = navigator.userAgent.toLowerCase();
    const re = /(msie|firefox|chrome|opera|version).*?([\d.]+)/;
    const m = ua.match(re);
    const Sys = {
        browser: m[1].replace(/version/, "'safari"),
        version: m[2]
    };

    return Sys;
};

添加一个浏览器判断:

ts
复制代码
const downloadByUrl = (url: string, filename: string) => {
    // 略......

    //  火狐兼容
    if (useBrowser().browser === "firefox") {
        a.target = "_blank";
    }

    document.body.append(a);
}

download 使用注意点

标签虽好,但还有一些值得注意的点:

1. 同源 URL 的限制

download 只在同源 URL 或

blob:



data:

协议起作用

也就是说跨域是下载不了的……

首先,非同源 URL 会进行导航操作。其次,如果非要下载,那么正如上面的文档所说,可以先将其转换为

blob:



data:

再进行下载,至于如何转换会在

Blob

章节中详细介绍。

2. 无法鉴权

使用 标签下载是带不了

Header

的,因此也不能携带登录态,所以无法进行鉴权。这里我们给出一个解决方案:

  1. 先发送请求获取

    blob

    文件流,这样就能在请求时进行鉴权;

  2. 鉴权通过后再执行下载操作。

这样是不是就能很好的同时解决问题1和问题2带来的两个痛点了呢😃

顺便提一下,

location.href



window.open

也存在同样的问题。

3. download 与 Content-Disposition 的优先级

这里需要关注一个响应标头

Content-Disposition

,它会影响 的

download

从而可能产生不同的下载行为,先看一个真实下载链接的

Response Headers

8b4788a8a34ea9e9427910b17e5d6246.jpeg

Snipaste_2023-06-20_18-19-21.png

如图所示,

Content-Disposition

的 value 值为

attachment;filename=aaaa.bb

。请记住,此时

Content-Disposition 的 filename 优先级会大于


download 的优先级

。也就是说,当两者都指定了

filename

时,会优先使用

Content-Disposition

中的文件名。

接下来我们看看这个响应标头到底是什么。

Content-Disposition

在常规的 HTTP 应答中,Content-Disposition 响应标头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地。



Content-Type

不同,后者用来指示资源的 MIME 类型,比如资源是图片(

image/png

)还是一段 JSON(

application/json

),而

Content-Disposition

则是用来指明该资源是直接展示在页面上的,还是应该当成附件下载保存到本地的。

当它作为 HTTP 消息主题的标头时,有以下三种写法:

txt
复制代码
Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"

inline

默认值,即指明资源是直接展示在页面上的。但是在同源 URL 情况下, 元素的

download

属性优先级比

inline

大,浏览器优先使用

download

属性来处理下载(Firefox 早期版本除外)。

attachment

即指明资源应该被下载到本地,大多数浏览器会呈现一个 “保存为” 的对话框,如果此时有

filename

,那么它将其优于

download

属性成为下载的预填充文件名。

标签 VS Content-Disposition

介绍完

Content-Disposition

,我们做一个横向比对的归纳一下:

  • download VS inline/attachment

    优先级:attachment > download > inline

  • download 的值 VS filename

    优先级:filename > download 的值

Blob 转换

前文介绍到,在非同源请情况下可以将资源当成二进制的 blob 先拿到手,再进行 的下载处理。接下来,我们介绍两种 blob 的操作:

方法1. 用作 URL(blob:)

URL.createObjectURL[1] 可以给

File



Blob

生成一个URL,形式为

blob:<origin>/<uuid>

,此时浏览器内部就会为每个这样的 URL 存储一个 URL → Blob 的映射。因此,此类 URL 很短,但可以访问 Blob。

那这就好办多了,写成代码就三行:

js
复制代码
import downloadByUrl from "@/utils/download";

const download = async () => {
  const blob = await fetchFile();

  // 生成访问 blob 的 URL
  const url = URL.createObjectURL(blob);

  // 调用刚刚封装的 a 标签下载方法
  downloadByUrl(url, "表格文件.xlsx");
  
  // 删除映射,释放内存
  URL.revokeObjectURL(url);
};

不过它有个副作用。虽然这里有

Blob

的映射,但

Blob

本身只保存在内存中的。浏览器无法释放它。

在文档退出时(unload),该映射会被自动清除,因此

Blob

也相应被释放了。但是,如果应用程序寿命很长,那这个释放就不会很快发生。


因此,如果我们创建一个 URL,那么即使我们不再需要该

Blob

了,它也会被挂在内存中。

不过,URL.revokeObjectURL[2] 可以从内部映射中移除引用,允许

Blob

被删除并释放内存。所以,在即时下载完资源后,不要忘记立即调用 URL.revokeObjectURL。

方法2. 转换为 base64(data:)

作为

URL.createObjectURL

的一个替代方法,我们也可以将 Blob 转换为 base64-编码的字符串。这种编码将二进制数据表示为一个由 0 到 64 的 ASCII 码组成的字符串,非常安全且“可读”。

更重要的是 —— 我们可以在 “data-url” 中使用此编码。“data-url”[3] 的形式为

data:[<mediatype>][;base64],<data>

。我们可以在任何地方使用这种 url,和使用“常规” url 一样。

FileReader[4] 是一个对象,其

唯一目的

就是从 Blob 对象中读取数据,我们可以使用它的 readAsDataURL[5] 方法将 Blob 读取为 base64。请看以下示例:

js
复制代码
import downloadByUrl from "@/utils/download";

const download = async () => {
  const blob = await fetchFile();

  // 声明一个 fileReader
  const fileReader = new FileReader();
  
  // 将 blob 读取成 base64
  fileReader.readAsDataURL(blob);
  
  // 读取成功后 下载资源
  fileReader.onload = function () {
      downloadByUrl(fileReader.result);
  };
};

在上述例子中,我们先实例化了一个

fileReader

,用它来读取 blob。

一旦读取完成,就可以从 fileReader 的

result

属性中拿到一个

data: URL

格式的 Base64 字符串。

最后,我们给 fileReader 注册了一个

onload

事件,在读取操作完成后开始下载。

两种方法总结与对比


URL.createObjectURL(blob)

可以直接访问,无需“编码/解码”,但需要记得撤销(revoke);



Data URL

无需撤销(revoke)任何操作,但对大的

Blob

进行编码时,性能和内存会有损耗。

总而言之,这两种从

Blob

创建 URL 的方法都可以用。但通常

URL.createObjectURL(blob)

更简单快捷。

responseType

最后,我们回头说一下请求的注意点:如果你的项目使用的是

XHR

(比如 axios)而不是

fetch

, 那么请记得在请求时添加上 responseType[6] 为 ‘blob’。

js
复制代码
export const fetchFile = async (params) => {
  return axios.get(api, {
    params,
    responseType: "blob"
  });
};


responseType

不是 axios 中的属性,而是

XMLHttpRequest

中的属性,它用于指定响应中包含的数据类型,当为 “blob” 时,表明

Response

是一个包含二进制数据的

Blob

对象。

除了

blob

之外,responseType 还有

arraybuffer



json



text

等其他枚举字符串值。

总结

一言以蔽之,同源就直接使用

<a> download

下载,跨域就先获取

blob

,用

createObjectURL



readAsDataURL

读取链接,再用

<a> download

下载。

作者:蓝屏的钙

链接:https://juejin.cn/post/7246747232997720120

来源:稀土掘金