背景
在业务发展的过程中可能会遇到一些前后端下载的功能,当数据量特别大时,假设下载完全由前端来控制,常规的下载不能满足页面性能的要求,由于下载数据占用太多内存导致页面渲染变慢。那么我们接下来讨论此场景该如何解决,以及解决这背后的原理。
前端文件下载
前端的文件下载是一个老生常谈的话题,下载的方式大致是利用浏览器的特性使用 a 或者 form 标签结合后端请求产生文件(get),或者是利用 a 标签 download 属性结合 blob 或者 base64 做下载(post)。
前一种方式完全由后端和浏览器交互,第二种方式在后端和浏览器中间加了一层逻辑,先把后端的数据拿到后转换成 blob 或者 base64 等格式交给浏览器交互。
造成的问题
第二种方式相当于在页面做了一层内存,不管是 new blob 或 base64 都会在页面重新占用一倍的内存,假设文件特别大,假设有2G,会经历如下过程:
- 使用请求从后端拿到 2G 的数据
- 将 2G 数据转换成 blob
- 将 blob 传入 a 标签,交给浏览器产出下载文件
在这个过程中,有一个大前提,后端请求的返回是一个已经处理好的固定格式的 blob 文件。假设我们把这个 2G 的普通的数据请求,那在这中间还不能直接变换成 blob , 可能需要对此返回做一些定制化的处理,定制化的处理势必会造成成倍的内存压力,处理 2G 的数据却占用了 2G * N 的内存。
假设处理数据的操作都是同步执行(await/async + 其他同步代码),那此过程不紧占用了超大内存还会占用极大的cpu 。势必导致页面渲染卡顿等等问题。
如何解决
Stream API
Stream 其实在我们的开发中无处不在,我们所用的网络请求 Fetch respponse body 就是一个 stream ,那么我们看下 stream 有什么改变。如下图:
Streams API 赋予了网络请求以片段处理数据的能力,过去我们使用 XMLHttpRequest 获取一个文件时,我们必须等待浏览器下载完整的文件,等待浏览器处理成我们需要的格式,收到所有的数据后才能处理它。现在有了流,我们可以以 TypedArray 片段的形式接收一部分二进制数据,然后直接对数据进行处理,这就有点像是浏览器内部接收并处理数据的逻辑。甚至我们可以将一些操作以流的形式封装,再用管道把多个流连接起来,管道的另一端就是最终处理好的数据。
使用 stream 后相当于将数据切片处理,我们从 fetch 请求拿到 stream 对象可以针对这个对象做点文章。
针对此方案社区也提供了相关方案 streamsaver
const uInt8 = new TextEncoder().encode('StreamSaver is awesome')
// streamSaver.createWriteStream() returns a writable byte stream
// The WritableStream only accepts Uint8Array chunks
// (no other typed arrays, arrayBuffers or strings are allowed)
const fileStream = streamSaver.createWriteStream('filename.txt', {
size: uInt8.byteLength, // (optional filesize) Will show progress
writableStrategy: undefined, // (optional)
readableStrategy: undefined // (optional)
})
if (manual) {
const writer = fileStream.getWriter()
writer.write(uInt8)
writer.close()
} else {
// using Response can be a great tool to convert
// mostly anything (blob, string, buffers) into a byte stream
// that can be piped to StreamSaver
//
// You could also use a transform stream that would sit
// between and convert everything to Uint8Arrays
new Response('StreamSaver is awesome').body
.pipeTo(fileStream)
.then(success, error)
}
知识储备
在深入了解这个库的流程之前我们先要了解以下两个知识
stream
stream(流)是什么?
stream 是一种抽象 API。我们可以和 promise 做一下类比,如果说 promise 是异步标准 API,则 stream 希望成为 I/O 的标准 API。
stream 可以认为在形容资源持续流动的状态,我们需要把 I/O 场景看作一个持续的场景,就像把一条河的河水导流到另一条河。
做一个类比,我们在发送 http 请求、浏览网页、看视频时,可以看作一个南水北调的过程,把 A 河的水持续调到 B 河。
在发送 http 请求时,A 河就是后端服务器,B 河就是客户端;浏览网页时,A 河就是别人的网站,B 河就是你的手机;看视频时,A 河是网络上的视频资源(当然也可能是本地的),B 河是你的视频播放器。
所以流是一个持续的过程,而且可能有多个节点,不仅网络请求是流,资源加载到本地硬盘后,读取到内存,视频解码也是流,所以这个南水北调过程中还有许多中途蓄水池节点。
将这些事情都考虑到一起,最后形成了 web stream API。
一共有三种流,分别是:writable streams、readable streams、transform streams,它们的关系如下:
- readable streams 代表 A 河流,是数据的源头,因为是数据源头,所以只可读不可写。
- writable streams 代表 B 河流,是数据的目的地,因为要持续蓄水,所以是只可写不可读。
- transform streams 是中间对数据进行变换的节点,比如 A 与 B 河中间有一个大坝,这个大坝可以通过蓄水的方式控制水运输的速度,还可以安装滤网净化水源,所以它一头是 writable streams 输入 A 河流的水,另一头提供 readable streams 供 B 河流读取。
reabable streams
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue('h')
controller.enqueue('e')
controller.enqueue('l')
controller.enqueue('l')
controller.enqueue('o')
controller.close()
}
})
controller.enqueue() 可以填入任意值,相当于是将值加入队列,controller.close() 关闭后,就无法继续 enqueue 了,并且这里的关闭时机,会在 writable streams 的 close 回调响应。
读取流不一定一开始就充满数据,比如 response.body 就可能因为读的比较早而需要等待,就像接入的水管水流较慢,而源头水池的水很多一样。我们也可以手动模拟读取较慢的情况:
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue('h')
controller.enqueue('e')
setTimeout(() => {
controller.enqueue('l')
controller.enqueue('l')
controller.enqueue('o')
controller.close()
}, 1000)
}
})
上面例子中,如果我们一开始就用写入流对接,必然要等待 1s 才能得到完整的 'hello' 数据,但如果 1s 后再对接写入流,那么瞬间就能读取整个 'hello'。另外,写入流可能处理的速度也会慢,如果写入流处理每个单词的时间都是 1s,那么写入流无论何时执行,都比读取流更慢。
流的设计就是为了让整个数据处理过程最大程度的高效,无论读取流数据 ready 的多迟、开始对接写入流的时间有多晚、写入流处理的多慢,整个链路都是尽可能最高效的:
writable streams
write 回调需要返回一个 Promise,可以通过 write() 触发 writestream 写入:
const writableStream = new WritableStream({
write(chunk) {
return new Promise(resolve => {
// 消费的地方,可以执行插入 dom 等等操作
console.log(chunk)
resolve()
});
},
close() {
// 写入流 controller.close() 时,这里被调用
},
})
writableStream.getWriter().write('h')
transform streams
转换流内部是一个写入流 + 读取流,创建转换流的方式如下:
const decoder = new TextDecoder()
const decodeStream = new TransformStream({
transform(chunk, controller) {
controller.enqueue(decoder.decode(chunk, {stream: true}))
}
})
chunk 是 writableStream 拿到的包,controller.enqueue 是 readableStream 的入列方法,所以它其实底层实现就是两个流的叠加,API 上简化为 transform 了,可以一边写入读到的数据,一边转化为读取流,供后面的写入流消费。
serviece worker
- serviece worker 一个服务器与浏览器之间的中间人角色,它可以拦截当前网站所有的请求。
- 独立于 JavaScript 主线程的独立线程,在里面执行需要消耗大量资源的操作不会堵塞主线程。
Streams API 在 Service Worker 中同样可用,所以我们可以在 Service Worker 里监听 onfetch 事件,将写入文件这种大资源操作放进 worker 进程中,最后托管给浏览器处理。
self.onfetch = (event) => {
event.respondWith((async () => {
const stream = new ReadableStream({
start(controller) {
...
}
});
return new Response(stream, { headers: response.headers });
})());
}
streamsaver 流程
在初始化时客户端代码会创建一个 TransformStream 并将可写入的一端封装为 writer 暴露给外部使用,客户端会和 Service Worker 之间建立一个 MessageChannel,并将之前的 TransformStream 中可读取的一端通过 port1.postMessage() 传递给 Service Worker。Service Worker 里监听到通道的 onmessage 事件时会生成一个随机的 URL,并将 URL 和可读取的流存入一个 Map 中,然后将这个 URL 通过 port2.postMessage() 传递给客户端代码。
客户端接收到 URL 后会控制浏览器跳转到这个链接,此时 Service Worker 的 onfetch 事件接收到这个请求,将 URL 和之前的 Map 存储的 URL 比对,将对应的流取出来,再加上一些让浏览器认为可以下载的响应头(例如 Content-Disposition)封装成 Response 对象,最后通过 event.respondWith() 返回。这样在当客户端将数据写入 writer 时,经过 Service Worker 的流转,数据可以立刻下载到用户的设备上。这样就不需要分配巨大的内存来存放 Blob,数据块经过流的流转后直接被回收了,降低了内存的占用。
总结
所以对于大文件下载优化思路有两个:
- 尽量使用流式操作,天然的流式结构可以起到缓冲的作用,提高内存/cpu 利用率
- 高频计算或者消耗内存的操作可以使用 service worker / web work 等其他线程去处理,不占用主线程的内存。
问题
有一些问题留给你们去思考:
-
postMessage 传递对象会不会丢失对象的 function
-
new Reposone 为什么在 stream close 的时候自动完成下载