「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」
目前我们保存Blob/文件的方式通常是借助Object URL和a[download]属性,但很不幸,每个浏览器中Blob对象存值大小是有限制的,在Chrome上是2GB,Firefox是800MiB,其他浏览器略有差异。当采用Blob的方式来操作,也就是将数据保存在客户端存储或内存中,这受到最大容纳RAM的影响,而移动设备上,RAM是非常有限的。
如果你下载保存的资源不大,可以直接翻看我的这篇文章:再看资源文件下载保存,如何健壮?。
一. 原理
文件很大,超过Blob的最大限制2GB,读到客户端存储或内存中,已然是不可取,那还有什么其他方式呢?
答案是:流,创建一个直接到文件系统的可写流。
我们知道打开一个页面,会存在浏览器进程、渲染进程、GPU进程、网络进程,还可能存在插件进程。浏览器进程主要负责用户交互、页面展示,同时还提供存储功能;渲染进程是将HTML/CSS/JS转化为用户可与之交互的网页,内部存在排版引擎Blink和JS引擎V8;网络进程是负责网络资源的加载;其他进程跟文章关系不大,这里不多介绍。
说起流,下载远端资源,如果文件大,服务端考虑到内存的消耗,通常也是边读文件边返回流给前端。那前端可以这么保存流吗?
link = document.createElement('a')
link.href = URL.createObjectURL(stream) // DOES NOT WORK
link.download = 'filename'
link.click() // Save
答案是:不能,不能通过stream去创建Object URL!
那完整的一个资源下载请求是什么样的呢?
graph TD
用户点击下载按钮 --> 发起资源请求 --> 服务端响应请求 --> 浏览器下载
用户点击下载,浏览器主进程将点击行为转发给渲染进程,添加到消息队列;渲染进程的事件循环系统依次从消息队列中取任务执行,当执行到该任务时,发现是个请求操作,便将其转发给网络进程,由网络进程去执行资源加载。重点来了,当网络进程接收到响应行和响应头之后,就开始解析响应头的内容。如果是301或302状态码,则重定向。如果是200,则继续处理该请求。浏览器是如何区分处理请求的数据类型呢?Content-Type,它决定了如何显示响应体的内容。 本文讲的是文件下载,那对应的Content-Type:application/octet-stream字节流类型,通常浏览器会按照下载类型来处理该请求,将该请求提交给浏览器的下载管理器,由它去执行读写操作。
前置知识:Content-Disposition
在常规的 HTTP 应答中,Content-Disposition 响应头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地。
- 语法:作为消息主体中的消息头
在 HTTP 场景中,第一个参数或者是
inline(默认值,表示回复中的消息体会以页面的一部分或者整个页面的形式展示),或者是attachment(意味着消息体应该被下载到本地;大多数浏览器会呈现一个“保存为”的对话框,将filename的值预填为下载后的文件名,假如它存在的话)
Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"
以上便是浏览器下载远程资源流的完整过程。
二、方案
所以,解决方案只有一个:发送带有Content-Disposition标头的流告诉浏览器保存文件。 如果下载保存的资源来自自有服务器,可以让服务端处理。那万一我们没有服务器或者内容不在服务器上呢?模拟服务器,通过创建一个可以拦截请求并使用responseWith()并充当服务器的service worker!
1. 自有服务器资源
让服务器响应请求去读流写流,配置响应头Content-Type/Content-Disposition等,不多说。
2. 无服务器或客户端资源
这个方案是:模拟服务器,创建一个可以拦截请求并使用responseWith()并充当服务器的service worker。但是 Service Worker 只允许在安全的上下文中使用,并且需要付出一些努力。大多数情况下,都在主线程中工作,而 Service Worker 在空闲前仅存活 < 5 分钟。
- 因此,可以在中间创建了个中间人,将service worker安装托管在 github 静态页面上的安全上下文中。如果你的页面不安全,则采用自 iframe(在安全上下文中)或新弹出窗口。
- 使用 postMessage 将流(或 DataChannel)传输到 Service Worker。
- 然后创建一个下载链接,然后我们打开它。
如果“可传输的”可读流没有传递给 Service Worker,那么 mitm 还将尝试通过每 x 秒 ping 服务工作者来保持服务工作者活着,以防止它闲置。以上便是整体的大体实践,下面看看如何实现。
2.1 中间人mith.html
githubs是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.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.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,
});
});
总结
文章主要是为了拓展下视野,知道前端原来还可以这么玩。整个远端资源下载保存问题的本质,还是追溯到浏览器进程之间的相互协调,服务端控制浏览器的行为。当然这里涉及的利用github托管资源,iframe加载静态资源作为web页面和service worker之间通信的中间人,还是挺有趣的。最后本文代码来源于对:StreamSaver.js的拆解分析,感兴趣的可以去详读。
附录:用法
const fileStream = streamSaver.createWriteStream('filename.txt', {
size: uInt8.byteLength,
writableStrategy: undefined,
readableStrategy: undefined
})
new Response('streamsaver真棒!').body
.pipeTo(fileStream)
.then(success, error)