前端资源下载作为前端常用的功能之一,我们的工具库该如何保证它的健壮性,以及通用性呢?以及面对不同下载的场景,比如:文件资源超过了 Blob
对象所能支撑的最大 size
, 或者超过了浏览器所能支撑的最大 RAM,该如何进行资源下载呢?可能你会说教给服务端处理,这完全没毛病,那万一刚好又没有自己的服务资源呢?那又该怎么做呢?本文将给你介绍下面这些知识。
一、文件大小 Blob 对象最大 Size
1. download
耳熟能详的资源文件下载,最先能想到的可能就是 a
标签了,借助 html5 的新增特性 download
。然后可能行云流水的写下如下通过代码:
/**
* @params url 下载资源路径
* @params name 文件名
*/
function saveAs(url,name){
var a = document.createElement('a');
a.download = name;
a.rel = 'noopener';
a.href = url;
a.click()
}
复制代码
在同源环境下,该代码能够正常的下载资源并且文件名称按照 name 的配置正常显示。但当下载的资源是跨域资源,文件名就不能按照 name 正常显示了。
了解下 download 的知识点了:
download
属性指示浏览器下载 URL 而不是导航到它,因此将提示用户将其保存为本地文件。
如果属性有一个值,那么此值将在下载保存过程中作为预填充的文件名。
此属性对允许的值没有限制,但是 / 和 \ 会被转换为下划线。
大多数文件系统限制了文件名中的标点符号,故此,浏览器将相应地调整建议的文件名。
此属性仅适用于同源 URL。
2. Blob
Blob
对象,已经不陌生,复习下它的知识点。
它表示一个不可变、原始数据的类文件对象。它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream
来用于数据操作。
File
接口基于Blob
,继承了blob
的功能并将其扩展使其支持用户系统上的文件。
非blob
对象转Blob
,请使用Blob()
构造函数;创建子集 blob,用slice()
方法,大文件切片就靠它了。
了解到这里,又可以洋洋洒洒的写下代码了:
function download (url, name) {
var xhr = new XMLHttpRequest()
xhr.open('GET', url)
xhr.responseType = 'blob'
xhr.onload = function () {
var url = URL.createObjectURL(xhr.response)
saveAs(url, name)
setTimeout(function () { URL.revokeObjectURL(url) }, 4E4) // 40s
}
xhr.onerror = function () {
console.error('could not download file')
}
xhr.send()
}
复制代码
同样的,该代码下载不了不允许 cors 的资源,因此需要在前面校验下该资源是否支持跨域。
function corsEnabled (url) {
var xhr = new XMLHttpRequest()
xhr.open('HEAD', url, false)
try {
xhr.send()
} catch (e) {}
return xhr.status >= 200 && xhr.status <= 299
}
复制代码
注意了,这里请求是否允许 cors 是同步的。
corsEnabled (url)?download(url,name):saveAs(url,name)
复制代码
3. msSaveOrOpenBlob
针对 IE10 浏览器,它提供了 msSaveBlob
和 msSaveOrOpenBlob
两方法允许用户在客户端上保存文件,就像从 Internet 下载文件,这也是为啥此类文件能保存到下载文件夹。这两方法存在一些区别:
msSaveBlob
只提供一个保存按钮
msSaveOrOpenBlob
提供保存和打开按钮
直接上代码:
/**
* @params blob :Blob对象
* @params name :文件名
*/
function saveAs(blob,name,opts){
if('msSaveOrOpenBlob' in navigator){
navigator.msSaveOrOpenBlob(bom(blob, opts), name)
}
}
function bom (blob, opts) {
if (typeof opts === 'undefined') opts = { autoBom: false }
else if (typeof opts !== 'object') {
opts = { autoBom: !opts }
}
// 为UTF-8 XML和text/*类型(包括HTML)预先准备BOM表
// 浏览器会自动将UTF-16 U+FEFF转换为EF BB BF
if (opts.autoBom && /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {
return new Blob([String.fromCharCode(0xFEFF), blob], { type: blob.type })
}
return blob
}
复制代码
4. FileReader
FileReader
对象允许 Web 应用程序异步读取存储在用户计算机上的文件(或原始数据缓冲区)的内容,使用 File
或 Blob
对象指定要读取的文件或数据。
File
对象来自 input
元素选择文件返回的 FileList 对象,也可以是拖拽生成的 DataTransfer
对象,或者是 Canvas
上执行 mozGetAsFile()
方法返回的结果.
FileReader 仅用于以安全的方式从用户(远程)系统读取文件内容 它不能用于从文件系统中按路径名简单地读取文件.
function saveAs(){
var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent);
var reader = new FileReader()
reader.onloadend = function () {
var url = reader.result
url = isChromeIOS ? url : url.replace(/^data:[^;]*;/, 'data:attachment/file;')
window.location.href = url
}
reader.readAsDataURL(blob)
}
复制代码
到此为止,已经写了 4 种下载的方式,那这 4 种方式存在的情况下如何排优先级呢?
'download' in HTMLAnchorElement.prototype
'msSaveOrOpenBlob' in navigator
FileReader
二、文件大小超过 Blob 最大 Size
上面这些实现最大资源文件下载的大小依靠浏览器 Blob
对象的 Max Size
,在 Chrome 上是 2GB,Firefox 是 800MiB,其他浏览器略有差异。那么当你去下载一个 10G 文件对时候,浏览器就崩溃了!那为啥会崩溃呢?通常发起一个 URL 请求到服务端,浏览器会根据返回的响应头 Content-Type
字段来区分返回的资源,如果返回的是 application/octet-stream
,显示数据是字节流,通常浏览器会按照下载类型来处理该请求,将它转发给下载管理器,由它执行 IO
操作去存储资源。这种情况下,浏览器基本不存在崩溃的情况。但是,如果将文件通过 js 直接读取到内存,一方面 Blob
存在大小限制,资源文件不能大于Blob Max Size
,另一方面,大对象读到堆空间,可能撑爆空间,导致页面崩溃。文件很大,超过 Blob
的最大限制 2GB,读到客户端存储或内存中,已然是不可取,那还有什么其他方式呢?
答案是:流,创建一个直接到文件系统的可写流。
说起流,下载远端资源,如果文件大,服务端考虑到内存的消耗,通常也是边读文件边返回流给前端。那前端可以这么保存流吗?
link = document.createElement('a')
link.href = URL.createObjectURL(stream) // DOES NOT WORK
link.download = 'filename'
link.click() // Save
复制代码
答案是:不能,不能通过 stream 去创建 Object URL!
2.1 Content-Disposition
在常规的 HTTP 应答中,Content-Disposition
响应头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地。
Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"
复制代码
2.2 方案概述
所以,解决方案只有一个:发送带有Content-Disposition
标头的流告诉浏览器保存文件。 如果下载保存的资源来自自有服务器,可以让服务端处理。那万一我们没有服务器或者内容不在服务器上呢?模拟服务器,通过创建一个可以拦截请求并使用responseWith()
并充当服务器的service worker
!
2.3 自有服务器资源
让服务器响应请求去读流写流,配置响应头 Content-Type/Content-Disposition 等,不多说。
2.4 无服务器或客户端资源
这个方案是:模拟服务器,创建一个可以拦截请求并使用 responseWith()
并充当服务器的 service worker
。但是 Service Worker
只允许在安全的上下文中使用,并且需要付出一些努力。大多数情况下,都在主线程中工作,而 Service Worker
在空闲前仅存活 < 5 分钟。
因此,可以在中间创建了个中间人,将 service worker
安装托管在 github
静态页面上的安全上下文中。如果你的页面不安全,则采用自 iframe
(在安全上下文中)或新弹出窗口。
使用 postMessage
将流(或 DataChannel
)传输到 Service Worker
。
然后创建一个下载链接,然后我们打开它。
如果“可传输的”可读流没有传递给 Service Worker
,那么 mitm
还将尝试通过每 x 秒 ping 服务工作者来保持服务工作者活着,以防止它闲置。以上便是整体的大体实践,下面看看如何实现。
2.4.1 中间人 mith.html
github 是 https 的站点,认为它的上下文是安全的。我们本质是创建 service worker
来冒充服务器拦截请求,但考虑没有服务器或者下载的是客户端资源,所以采用免费的 github。mith.html
文件作为 web 页面通过 iframe 加载的站点,它的功能主要有两个:
注册管理 service worker
,防重启。
作为 web 页面和 service worker
消息通信的中间人,加工处理 web 页面消息以及MessageChannel
给 service worker
。
1. github 建立托管文件
// service worker文件,充当服务器,用来拦截请求,制造假的响应,让浏览器去下载资源
sw.js
// web页面通过iframe加载的github静态资源文件,由它注册service worker
mitm.html
复制代码
2. mith.html 监听 web 页面发送的消息
// 消息队列
let messages = []
// iframe监听到消息,塞到消息队列
window.onmessage = evt => messages.push(evt)
复制代码
3. mitm.html 注册 service worker
// 注册的service worker实例
let sw = null
let scope = ''
// 注册service worker
function registerWorker() {
return navigator.serviceWorker.getRegistration('./').then(swReg => {
return swReg || navigator.serviceWorker.register('sw.js', { scope: './' })
}).then(swReg => {
const swRegTmp = swReg.installing || swReg.waiting
scope = swReg.scope
// 精简代码
return (sw = swReg.active)
})
}
if (navigator.serviceWorker) {
registerWorker()
}
复制代码
4. mitm.html 处理队列消息,并发送 service worker
// 队列内消息的处理,发生在sw注册成功之后
registerWorker().then(()=>{
window.onmessage = onMessage
// 依次执行消息,并post到sw
messages.forEach(window.onmessage)
})
// 队列消息处理逻辑
function onMessage(event){
let { data, ports, origin } = event
// 所以所有下载链接都需要加前缀以避免任何其他冲突
data.origin = origin
// 重定向到发起 http 请求的页面
data.referrer = data.referrer || document.referrer || origin
if (typeof data.filename === 'string') {
data.filename = data.filename.replace(/\//g, ':')
}
if (!data.pathname) {
data.pathname = Math.random().toString().slice(-6) + '/' + data.filename
}
// 删除所有前导斜杠
data.pathname = data.pathname.replace(/^\/+/g, '')
// 删除协议
let org = origin.replace(/(^\w+:|^)\/\//, '')
// 将绝对路径名设置为下载 url。
data.url = new URL(`${scope + org}/${data.pathname}`).toString()
// 将页面传递进来的messageChannel.port2传递给service worker,方便service worker与页面进行通信
const transferable = [ ports[0] ]
// 我们本文默认通过可传输流传递数据,所以keepAlive,实际上没有必要,但为了扩展,还是保留了
if (!data.transferringReadable){
keepAlive()
}
return sw.postMessage(data, transferable)
}
复制代码
5. mith.html 防止 sw 重启
当 service worker
完成后,可以关闭 mitm
,但最好别,这会停止 sw
。
let keepAlive = () => {
keepAlive = () => {}
var ping = location.href.substr(0, location.href.lastIndexOf('/')) + '/ping'
var interval = setInterval(() => {
if (sw) {
sw.postMessage('ping')
} else {
fetch(ping).then(res => res.text(!res.ok && clearInterval(interval)))
}
}, 10000)
}
复制代码
2.4.2 “服务器”service worker
service worker 首要任务自然是拦截请求,并伪造请求头,同时返回数据给浏览器,这里的数据是流。
1. service worker 监听生命周期和数据
const map = new Map()
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('activate', event => {
event.waitUntil(self.clients.claim())
})
// 每次下载都应该只被调用一次,每个事件都有一个数据通过,数据将通过管道传输
self.onmessage = event => {
// 发送心跳,响应心跳,保证sw在没有传输数据下仍然活跃
if (event.data === 'ping') {
return
}
const data = event.data
const downloadUrl = data.url || self.registration.scope + Math.random() + '/' + (typeof data === 'string' ? data : data.filename)
const port = event.ports[0]
const metadata = new Array(3) // [stream, data, port]
metadata[1] = data
metadata[2] = port
port.onmessage = evt => {
port.onmessage = null
metadata[0] = evt.data.readableStream
}
// 存储数据
map.set(downloadUrl, metadata)
// 往web页面发送消息,这个port是由web交由mith.html中间人传递进来
port.postMessage({ download: downloadUrl })
}
复制代码
2. 拦截请求,伪造响应
self.onfetch = event => {
const url = event.request.url
const hijacke = map.get(url)
if (!hijacke) return null
const [ stream, data, port ] = hijacke
map.delete(url)
// 只复制length和disposition
const responseHeaders = new Headers({
'Content-Type': 'application/octet-stream; charset=utf-8',
// 为了安全起见,链接可以在iframe中打开,但 octet-stream 应该停止它
'Content-Security-Policy': "default-src 'none'",
'X-Content-Security-Policy': "default-src 'none'",
'X-WebKit-CSP': "default-src 'none'",
'X-XSS-Protection': '1; mode=block'
})
let headers = new Headers(data.headers || {})
if (headers.has('Content-Length')) {
responseHeaders.set('Content-Length', headers.get('Content-Length'))
}
if (headers.has('Content-Disposition')) {
responseHeaders.set('Content-Disposition', headers.get('Content-Disposition'))
}
// data, data.filename and size should not be used anymore
if (data.size) {
responseHeaders.set('Content-Length', data.size)
}
let fileName = typeof data === 'string' ? data : data.filename
if (fileName) {
// 使文件名与 RFC5987 兼容
fileName = encodeURIComponent(fileName).replace(/['()]/g, escape).replace(/\*/g, '%2A')
responseHeaders.set('Content-Disposition', "attachment; filename*=UTF-8''" + fileName)
}
event.respondWith(new Response(stream, { headers: responseHeaders }))
port.postMessage({ debug: 'Download started' })
}
复制代码
2.4.3 页面流数据保存者
有了中间人传递数据,也有 service worker 拦截请求,伪造响应头和返回数据。那 web 页面就剩下创建对象写数据。
1. 构造 streamSaver 对象
const streamSaver = {
createWriteStream,
WritableStream: global.WritableStream,
mitm: 'https://***/mitm.html'
}
复制代码
2. 创建隐藏的 iframe 添加到 dom
function makeIframe (src) {
if (!src) throw new Error('meh')
const iframe = document.createElement('iframe')
iframe.hidden = true
iframe.src = src
iframe.loaded = false
iframe.name = 'iframe'
iframe.isIframe = true
iframe.postMessage = (...args) => iframe.contentWindow.postMessage(...args)
iframe.addEventListener('load', () => {
iframe.loaded = true
}, { once: true })
document.body.appendChild(iframe)
return iframe
}
复制代码
3. 写流函数
// 中间人
let mitmTransporter = null
function loadTransporter() {
if (!mitmTransporter) {
mitmTransporter = makeIframe(streamSaver.mitm);
}
}
function createWriteStream(filename, options, size) {
let opts = {
size: null,
pathname: null,
writableStrategy: undefined,
readableStrategy: undefined,
};
let bytesWritten = 0; // by StreamSaver.js (not the service worker)
let downloadUrl = null;
let channel = null;
let ts = null;
// 格式化参数,相关代码省略
...
// 加载中间人
loadTransporter();
// 创建消息通道
channel = new MessageChannel();
// 自定义响应参数
const response = {
transferringReadable: true,
pathname: opts.pathname || Math.random().toString().slice(-6) + '/' + filename,
headers: {
'Content-Type': 'application/octet-stream; charset=utf-8',
'Content-Disposition': "attachment; filename*=UTF-8''" + filename,
},
};
// 传递响应参数 给中间人进行格式化,并传递给service worker
// channel.port2传递给service worker方便与channel.port1相互通信
const args = [response, '*', [channel.port2]];
const transformer = undefined;
// 默认支持传输流,不支持浏览器可以使用pollfill进行处理
// transformer只有在非安全环境下需要转化,这里只为了展示流程,不展开
ts = new streamSaver.TransformStream(transformer, opts.writableStrategy, opts.readableStrategy);
const readableStream = ts.readable;
// 发送读取到流
channel.port1.postMessage({ readableStream }, [readableStream]);
// 监听service worker返回的消息
channel.port1.onmessage = evt => {
// download定义返回到可下载数据
if (evt.data.download) {
// 默认使用iframe加载
makeIframe(evt.data.download);
}
};
// 中间人加载完成,推送数据(iframe加载完成)
if (mitmTransporter.loaded) {
mitmTransporter.postMessage(...args);
} else {
mitmTransporter.addEventListener(
'load',
() => {
mitmTransporter.postMessage(...args);
},
{ once: true }
);
}
return new streamSaver.WritableStream(
{
write(chunk) {
// 只能写Unit8Array的数据
if (!(chunk instanceof Uint8Array)) {
throw new TypeError('Can only write Uint8Arrays');
}
// 将获取到chunk数据,推送给service worker
channel.port1.postMessage(chunk);
bytesWritten += chunk.length;
if (downloadUrl) {
location.href = downloadUrl;
downloadUrl = null;
}
},
close() {
channel.port1.postMessage('end');
},
abort() {
// 执行一些资源的置为空值
},
},
opts.writableStrategy
);
}
复制代码
4. 检测传输流是否可用
我们这里流程默认是浏览器支持传输流,实际上浏览器只有到 chrome 73 才支持,这里扩展下如何优雅检测。
// 检测函数
const test = fn => { try { fn() } catch (e) {} }
test(() => {
const { readable } = new TransformStream();
const mc = new MessageChannel();
mc.port1.postMessage(readable, [readable]);
mc.port1.close();
mc.port2.close();
supportsTransferable = true;
// 冻结TransformStream对象,只能以原生使用
Object.defineProperty(streamSaver, 'TransformStream', {
configurable: false,
writable: false,
value: TransformStream,
});
});
复制代码
2.4.4 用法
该模块主要是为了拓展下视野,知道前端原来还可以这么玩。最后你对这块感兴趣,可以详细去读 StreamSaver.js。
附用法:
const fileStream = streamSaver.createWriteStream('filename.txt', {
size: uInt8.byteLength,
writableStrategy: undefined,
readableStrategy: undefined
})
new Response('streamsaver真棒!').body
.pipeTo(fileStream)
.then(success, error)
复制代码
总结
这篇文章,基本已经涵盖了所有场景下的资源下载,希望对你有用。
评论