说说浏览器下载的那些事儿

最近突然在 chrome 85 版本上遇到下载后会出现”xxx 下载方式实属异常 因此它可能存在危险”,去看了下相关的谷歌博客,定位是谷歌的浏览器安全策略引起的。
实际上浏览器下载还有不少坑,这次就接这个机会总结一下。

前端如何实现文件下载?

window.open

在 download 属性出现之前,前端实现文件下载实际上依赖于浏览器的默认行为,也就是打开链接。

通常我们可以直接这样实现

1
2
const url = "http://xxxx";
window.open(url);

这会以指定的 url 打开一个新窗口,浏览器在根据 url 获得到服务器的响应,判断出这不是一个浏览器可以打开的文件类型,就自动会转为下载。

在段代码在正常情况下是能够工作正常的,然而实际上有些业务场景,下载链接是后台生成的,我们需要这样做

1
2
3
4
5
6
7
8
9
10
11
clickHandle() {
api.get('xxx', params).then(response => {
let data = response.data;
if(data.code === 0 ) {
let url = data.data.url || '';
if (url) {
window.open(url);
}
}
});
}

这在几乎所有的现代浏览器上都会被阻止,在 Chrome 上会在地址栏显示被拦截的标志,用户需要手动点击才能成功,Safari 会静默失效。
这是由于早期网页经常在页面插入自动打开广告页面,带来很糟糕的用户体验,因此浏览器做了限制,会禁止所有的异步回调中调用的 window.open()

当然了,浏览器限制也拦不过各种奇思妙想,既然无法在异步代码中调用 window.open(),那先在同步代码中调用,等待异步调用完成,改变新开的浏览器窗口的 url 就好了。
下面的代码就是一种实现:

1
2
3
4
5
6
7
8
9
10
11
12
clickHandle() {
const windowObjectReference = window.open('xxx')
api.get('xxx', params).then(response => {
let data = response.data;
if(data.code === 0 ) {
let url = data.data.url || '';
if (url) {
windowObjectReference.location.href = url;
}
}
});
}

因为上述的原因,使用了 window.open(), 无法打开页面是很容易发生的事,这时 window.open()会返回 null,需要做一些失败后的处理

1
2
3
if (!window.open("xxxx")) {
// do xxx
}

window.location.href

尽管可以以上面的方式绕过浏览器的安全限制,然而这种行为是不确定,永远无法知道浏览器会在哪一个版本改变这种做法,更安全的行为是使用 window.location.href

1
window.location.herf = "http://xxxx";

这种方式下载是手动让浏览器跳转到指定页面,浏览器发现是不支持预览的文件类型,自动转为下载,由于浏览器加载链接到发现这是一个应该下载的资源需要一定时间,这会造成页面白屏一段时间,表现的像是页面闪动一样

为了避免闪动,也有人想出了在 iframe 中调用 window.location.href 中的办法,由于链接是在 iframe 里加载的,也就不会出现闪动了

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
// 无闪现下载excel
function download(url) {
const iframe = document.createElement("iframe");
iframe.style.display = "none";
function iframeLoad() {
console.log("iframe onload");
const win = iframe.contentWindow;
const doc = win.document;
if (win.location.href === url) {
if (doc.body.childNodes.length > 0) {
// response is error
}
iframe.parentNode.removeChild(iframe);
}
}
if ("onload" in iframe) {
iframe.onload = iframeLoad;
} else if (iframe.attachEvent) {
iframe.attachEvent("onload", iframeLoad);
} else {
iframe.onreadystatechange = function onreadystatechange() {
if (iframe.readyState === "complete") {
iframeLoad;
}
};
}
iframe.src = "";
document.body.appendChild(iframe);

setTimeout(function loadUrl() {
iframe.contentWindow.location.href = url;
}, 50);
}

使用 iframe 的顾虑和使用 window.open 的顾虑一样,这依赖于浏览器的默认安全策略,我们无法知道浏览器会在哪天把创建 iframe 进行下载定义为危险的行为并进行拦截。

使用 <a> 标签

这实际上是目前大多数前端下载库的实现方式,会动态创建一个隐藏的 <a> 标签,通过 Blob 转换为 data 链接,点击这个 data 链接,浏览器会默认将这个文件 data 链接的内容进行保存。
15k start 的FileSaver.js就是这样实现的。

这样的方式相比直接打开或者直接使用链接的好处在于许多文件类型可以下载,而不是变为预览。

除此之外,<a> 还能支持 download 属性,这是真正的浏览器语义上的下载属性,以上除 data 链接的方式,浏览器都会抛出控制台警告

1
resource interpreted as Document but transferred with MIME type application xxx

浏览器的请求头会附带这样的 Accept 头:

1
2
Host: xxxx
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9

使用 download 属性后,浏览器发出的请求头会像这样

1
Host: xxxx

浏览器不会带上 Accept 头,甚至请求也不会出现在开发者工具中,会直接进行下载。

虽然 download 属性非常好用,但是还有一些限制:

此属性仅适用于同源 URL
尽管 HTTP URL 需要位于同一源中,但是可以使用 blob: URL 和 data: URL ,以方便用户下载使用 JavaScript 生成的内容(例如使用在线绘图 Web 应用程序创建的照片)。
如果 HTTP 头中的 Content-Disposition 属性赋予了一个不同于此属性的文件名,HTTP 头属性优先于此属性。

download 属性支持所有现代浏览器,而 IE 理所当然的不支持,需要进行一些特殊处理。

后端如何实现文件下载?

Content-Disposition

实际上,在 HTTP 响应中加上 Content-Disposition 是浏览器兼容性最好的下载方式,大多数的浏览器都支持这种方式.

Content-Disposition 可以指定浏览器是以附件的形式下载文件,还是以页面的一部分预览展示,同时也可以指定文件名。

1
2
3
Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"

还存在的一些问题

ios

IOS 由于系统是每个应用都是一个沙盒,没有为用户暴露文件系统,如果用户 IOS 手机上没有安装对应的打开软件是无法进行下载的。

安全限制

在最新的谷歌浏览器版本中(chrome 84+),会开始逐渐限制 https 网站下载 http 内容,其中可执行文件 exe、压缩文件都会警告下载方式十分危险,并且在未来可能所有通过 http 下载的可执行内容都会被阻止。尽管理解谷歌这种方式可能是为了防止中间人攻击替换下载内容,不过推进方式还是蛮激进的。