JS实现流式打包下载说明
浏览器中的流式操作可以节省内存,扩大 JS
的应用边界,比如我们可以在浏览器里进行视频剪辑,而不用担心视频文件将内存撑爆。
浏览器虽然有流式处理数据的 API,并没有直接提供给 JS
进行流式下载的能力,也就是说即使我们可以流式的处理数据,但想将其下载到磁盘上时,依然会对内存提出挑战。
这也是我们讨论的前提:
- 流式的操作,必须整个链路都是流式的才有意义,一旦某个环节是非流式(阻塞)的,就无法起到节省内存的作用。
本篇文章分析了如何在 JS
中流式的处理数据 ,流式的进行下载,主要参考了 StreamSaver.js 的实现方案。
分为如下部分:
- 流在计算机中的作用
- 服务器流式响应
JS
下载文件的方式JS
持有数据并下载文件的场景- 非流式处理、下载的问题
- 浏览器流式
API
JS
流式的实现方案- 实现
JS
读取本地文件并打包下载
流在计算机中的作用
流这个概念在前端领域中提及的并不多,但是在计算机领域中,流式一个非常常见且重要的概念。
当流这个字出现在 IO 的上下文中,常指的得就是分段的读取和处理文件,这样在处理文件时(转换、传输),就不必把整个文件加载到内存中,大大的节省了内存空间的占用。
在实际点说就是,当你用着 4G
内存的 iPhone 13
看电影时,并不需要担心视频文件数据把你的手机搞爆掉。
服务器流式响应
在谈下载之前,先提一下流式响应。
如上可知,当我们从服务器下载一个文件时,服务器也不可能把整个文件读取到内存中再进行响应,而是会边读边响应。
那如何进行流式响应呢?
只需要设置一个响应头 Transfer-Encoding: chunked
,表明我们的响应体是分块传输的就可以了。
以下是一个 nodejs
的极简示例,这个服务每隔一秒就会向浏览器进行一次响应,永不停歇。
require('http').createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'text/html', 'Transfer-Encoding': 'chunked' }) setInterval(() => { response.write('chunked\r\n') }, 1000) }).listen(8000);
JS
下载文件的方式
在 js
中下载文件的方式,有如下两类:
// 第一类:页面跳转、打开 location.href window.open iframe.src a[download].click() // 第二类:Ajax fetch('/api/download') .then(res => res.blob()) .then(blob => { // FileReader.readAsDataURL() const url = URL.createObjectURL(blob) // 借助第一类方式:location.href、iframe.src、a[download].click() window.open(url) })
不难看出,使用 Ajax
下载文件,最终还是要借助第一类方法才可以实现下载。
而第一类的操作都会导致一个行为:页面级导航跳转
所以我们可以总结得出浏览器的下载行为:
- 在页面级的跳转请求中,检查响应头是否包含
Content-Disposition: attachment
。对于a[download]
和createObjectURL
的url
跳转,可以理解为浏览器帮忙加上了这个响应头。 Ajax
发出的请求并不是页面级跳转请求,所以即使拥有下载响应头也不会触发下载行为。
两类下载方式的区别
这两种下载文件的方式有何区别呢?
第一类请求的响应数据直接由下载线程接管,可以进行流式下载,一边接收数据一边往本地写文件。
第二类由 JS
线程接管响应数据,使用 API 将文件数据创建成 url
触发下载。
但是相应的 API createObjectURL
、readAsDataURL
必须传入整个文件数据才能进行下载,是不支持流的。也就是说一旦文件数据到了 JS
手中,想要下载,就必须把数据堆在内存中,直到拿到完整数据才能开始下载。
所以当我们从服务器下载文件时,应该尽量避免使用 Ajax
,直接使用 页面跳转类
的 API 让下载线程进行流式下载。
但是有些场景下,我们需要在 JS
中处理数据,此时数据在 JS
线程中,就不得不面对内存的问题。
JS
持有数据并下载文件的场景
以下场景,我们需要在 JS
中处理数据并进行文件下载。
- 纯前端处理文件流:在线格式转换、解压缩等
- 整个数据都在前端转换处理,压根没有服务端的事
- 文章所要讨论的情况
- 接口鉴权:鉴权方案导致请求必须由
JS
发起,如cookie + csrfToken
、JWT
- 使用
ajax
:简单但是数据都在内存中 - (推荐)使用
iframe + form
实现:麻烦但是可以由下载线程流式下载 - 服务端返回文件数据,前端转换处理后下载
- 如服务端返回多个文件,前端打包下载
- (推荐)去找后端 ~~聊一聊~~
可以看到第一种情况是必须用 JS
处理的,我们来看一下如果不使用流式处理的话,会有什么问题。
非流式处理、下载的问题
去网上搜索「前端打包」,99% 的内容都会告诉你使用 JSZip
,谈起文件下载也都会提起一个 file-saver
的库(JSZip
官网也推荐使用这个库下载文件)。
那我们就看一下这些流行库的的问题。
以上是一个用 JSZip
的官方实例构建的 Vue
应用,功能很简单,从本地上传一个文件,通过 JSZip
打包,然后使用 file-saver
将其下载到本地。
我们来直接试一下,上传一个 1G+
的文件会怎么样?
通过 Chrome
的任务管理器可以看到,当前的页面内存直接跳到了 1G+
。
当然不排除有人的电脑内存比我们硬盘的都大的情况,豪不在乎内存消耗。
OK,即使你的电脑足以支撑在内存中进行随意的数据转换,但浏览器对 Blob
对象是有大小限制的。
官网的第一句话就是
If you need to save really large files bigger than the blob's size limitation or don't have enough RAM, then have a look at the more advanced StreamSaver.js
如果您需要保存比blob的大小限制更大的文件,或者没有足够的内存,那么可以查看更高级的 StreamSaver.js
然后给出了不同浏览器所支持的 Max Blob Size
,可以看到 Chrome
是 2G
。
所以不管是出于内存考虑,还是 Max Blob Size
的限制,我们都有必要去探究一下流式的处理方案。
顺便说一下这个库并没有什么黑科技,它的下载方式和我们上面写的是一样的,只不过处理了一些兼容性问题。
浏览器流式 API
Streams API 是浏览器提供给 JS
的流式操作数据的接口。
其中包含有两个主要的接口:可读流、可写流
WritableStream
创建一个可写流对象,这个对象带有内置的背压和排队。
// 创建 const writableStream = new WritableStream({ write(chunk) { console.log(chunk) } }) // 使用 const writer = writableStream.getWriter() writer.write(1).then(() => { // 应当在 then 再写入下一个数据 writer.write(2) })
- 创建时传入
write
函数,在其中处理具体的写入逻辑(写入可读流)。 - 使用时调用
getWriter()
获取流的写入器,之后调用write
方法进行数据写入。 - 此时的
write
方法是被包装后的,其会返回Promise
用来控制背压,当允许写入数据时才会resolve
。 - 背压控制策略参考
CountQueuingStrategy
,这里不细说。
ReadableStream
创建一个可读的二进制操作,controller.enqueue
向流中放入数据,controller.close
表明数据发送完毕。
下面的流每隔一秒就会产生一次数据:
const readableStream = new ReadableStream({ start(controller) { setInterval(() => { // 向流中放入数据 controller.enqueue(value); // controller.close(); 表明数据已发完 }, 1000) } });
从可读流中读取数据:
const reader = readableStream.getReader() while (true) { const {value, done} = await reader.read() console.log(value) if (done) break }
调用 getReader()
可以获取流的读取器,之后调用 read()
便会开始读取数据,返回 Promise
- 如果流中没有数据,便会阻塞(
Promise penging
)。 - 当调用了
controller.enqueue
或controller.close
后,Promise
就会resolve
。 done
:数据发送完毕,表示调用了controller.close
。value
:数据本身,表示调用了controller.enqueue
。
while (true)
的写法在其他语言中是非常常见的,如果数据没有读完,我们就重复调用 read()
,直到 done
为true
。
fetch
请求的响应体和 Blob
都已经实现了 ReadableStream
。
Fetch ReadableStream
Fetch API 通过Response
的属性body
提供了一个具体的ReadableStream
对象。
流式的读取服务端响应数据:
const response = await fetch('/api/download') // response.body === ReadableStream const reader = response.body.getReader() while(true) { const {done, value} = await reader.read() console.log(value) if (done) break }
Blob ReadableStream
Blob
对象的 stream
方法,会返回一个 ReadableStream
。
当我们从本地上传文件时,文件对象 File
就是继承自Blob
流式的读取本地文件:
document.getElementById("file") .addEventListener("change", async (e) => { const file: File = e.target.files[0]; const reader = file.stream().getReader(); while (true) { const { done, value } = await reader.read(); console.log(value); if (done) break; } });
TransformStream
有了可读、可写流,我们就可以组合实现一个转换流,一端转换写入数据、一端读取数据。
我们利用 MessageChannel
在两方进行通信
const { port1, port2 } = new MessageChannel() const writableStream = new WritableStream({ write(chunk) { port1.postMessage(chunk) } }) const readableStream = new ReadableStream({ start(controller) { port2.onmessage = ({ data }) => { controller.enqueue(data) } } }); const writer = writableStream.getWriter() const reader = readableStream.getReader() writer.write(123) // 写入数据 reader.read() // 读出数据 123
在很多场景下我们都会这么去使用读写流,所以浏览器帮我们实现了一个标准的转换流:TransformStream
使用如下:
const {readable, writable} = new TransformStream() writable.getWriter().write(123) // 写入数据 readable.getReader().read() // 读出数据 123
以上就是我们需要知道的流式 API 的知识,接下来进入正题。
前端流式下载
ok,终于到了流式下载的部分。
这里我并不会推翻自己前面所说:
- 只有页面级跳转会触发下载。
- 这意味着响应数据直接被下载线程接管。
createObjectURL
、readAsDataURL
只能接收整个文件数据。- 这意味当数据在前端时,只能整体下载。
所以应该怎么做呢?
Service worker
是的,黑科技主角Service worker
,熟悉 PWA
的人对它一定不陌生,它可以拦截浏览器的请求并提供离线缓存。
Service Worker APIService workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。
—— MDN
这里有两个关键点:
- 拦截请求
- 构建响应
也就是说,通过 Service worker
前端完全可以自己充当服务器给下载线程传输数据。
让我们看看这是如何工作的。
拦截请求
请求的拦截非常简单,在Service worker
中注册 onfetch
事件,所有的请求发送都会触发其回调。
通过 event.request
对象拿到 Request
对象,进而检查 url
决定是否要拦截。
如果确定要拦截,就调用 event.respondWith
并传入 Response
对象,既可完成拦截。
self.onfetch = event => { const url = event.request.url if (url === '拦截') { event.respondWith(new Response()) } }
new Response
Response
就是 fetch()
返回的 response
的构造函数。
直接看函数签名:
interface Response: { new(body?: BodyInit | null, init?: ResponseInit): Response } type BodyInit = ReadableStream | Blob | BufferSource | FormData | URLSearchParams | string interface ResponseInit { headers?: HeadersInit status?: number statusText?: string }
可以看到,Response
接收两个参数
- 第一个是响应体
Body
,其类型可以是Blob
、string
等等,其中可以看到熟悉的ReadableStream
可读流 - 第二个是响应头、状态码等
这意味着:
- 在响应头中写入
Content-Disposition:attachment
,浏览器就会让下载线程接管响应。 - 将
Body
构建成ReadableStream
,就可以流式的向下载线程传输数据。
也意味着前端自己就可以进行流式下载!
极简实现
我们构建一个最简的例子来将所有知识点串起来:从本地上传文件,流式的读取,流式的下载到本地。
是的这看似毫无意义,但这可以跑通流程,对学习来说足够了。
关键点代码分析
- 通知
service worker
准备下载文件,等待worker
返回url
和writable
const createDownloadStrean = async(filename) = >{ // 通过 channel 接受数据 const { port1, port2 } = new MessageChannel(); // 传递 channel,这样 worker 就可以往回发送消息了 serviceworker.postMessage({ filename }, [port2]); return new Promise((resolve) = >{ port1.onmessage = ({ data }) = >{ // 拿到url, 发起请求 iframe.src = data.url; document.body.appendChild(iframe); // 返回可写流 resolve(data.writable) }; }); }
Service worker
接受到消息,创建url
、ReadableStream
、WritableStream
,将url
、WritableStream
通过channel
发送回去。
js self.onmessage = (event) = >{ const filename = event.data.filename // 拿到 channel const port2 = event.ports[0] // 随机一个 url const downloadUrl = self.registration.scope + Math.random() + '/' + filename // 创建转换流 const { readable, writable } = new TransformStream() // 记录 url 和可读流,用于后续拦截和响应构建 map.set(downloadUrl, readable) // 传回 url 和可写流 port2.postMessage({ download: downloadUrl, writable }, [writable]) }
- 主线程拿到
url
发起请求(第 1 步onmessage
中),Service worker
拦截请求 ,使用上一步的ReadableStream
创建Response
并响应。
self.onfetch = event => { const url = event.request.url // 从 map 中取出流,存在表示这个请求是需要拦截的 const readableStream = map.get(url) if (!readableStream) return null map.delete(url) const headers = new Headers({ 'Content-Type': 'application/octet-stream; charset=utf-8', 'Content-Disposition': 'attachment', 'Transfer-Encoding': 'chunked' }) // 构建返回响应 event.respondWith( new Response(readableStream, { headers }) ) }
- 下载线程拿到响应,开启流式下载(但是此时根本没有数据写入,所以在此就阻塞了)
- 主线程拿到上传的
File
对象,获取其ReadableStream
并读取,将读取到的数据通过WritableStream
(第 1 步中返回的)发送出去。
input.addEventListener("change", async(e: any) = >{ const file = e.target ! .files[0]; const reader = file.stream().getReader(); const writableStream = createDownloadStrean() const writable = writableStream.getWriter() const pump = async() = >{ const { done, value } = await reader.read(); if (done) return writable.close() await writable.write(value) // 递归调用,直到读取完成 return pump() }; pump(); })
- 当
WritableStream
写入数据时,下载线程中的ReadableStream
就会接收到数据,文件就会开始下载直到完成。
完整代码
// index.vue // service-worker.js self.addEventListener('install', () => { self.skipWaiting() }) self.addEventListener('activate', event => { event.waitUntil(self.clients.claim()) }) const map = new Map() self.onmessage = event => { const data = event.data const filename = encodeURIComponent(data.filename.replace(/\//g, ':')) .replace(/['()]/g, escape) .replace(/\*/g, '%2A') const downloadUrl = self.registration.scope + Math.random() + '/' + filename const port2 = event.ports[0] // [stream, data] const { readable, writable } = new TransformStream() const metadata = [readable, data] map.set(downloadUrl, metadata) port2.postMessage({ download: downloadUrl, writable }, [writable]) } self.onfetch = event => { const url = event.request.url const hijacke = map.get(url) if (!hijacke) return null map.delete(url) const [stream, data] = hijacke // Make filename RFC5987 compatible const fileName = encodeURIComponent(data.filename).replace(/['()]/g, escape).replace(/\*/g, '%2A') const headers = new Headers({ 'Content-Type': 'application/octet-stream; charset=utf-8', 'Transfer-Encoding': 'chunked', 'response-content-disposition': 'attachment', 'Content-Disposition': "attachment; filename*=UTF-8''" + fileName }) event.respondWith(new Response(stream, { headers })) }
流式压缩下载
跑通了流程之后,压缩也只不过是在传输流之前进行一层转换的事情。
首先我们寻找一个可以流式处理数据的压缩库(你肯定不会想自己写一遍压缩算法),fflate
就很符合我们的需求。
然后我们只需要在写入数据前,让 fflate
先处理一遍数据就可以了。
onMounted(async () => { const input = document.querySelector("#file")!; input.addEventListener("change", async (e: any) => { const stream = createDownloadStrean() const file = e.target!.files[0]; const reader = file.stream().getReader(); const zip = new fflate.Zip((err, dat, final) => { if (!err) { fileStream.write(dat); if (final) { fileStream.close(); } } else { fileStream.close(); } }); const helloTxt = new fflate.ZipDeflate("hello.txt", { level: 9 }); zip.add(helloTxt); while (true) { const { done, value } = await reader.read(); if (done) { zip.end(); break }; helloTxt.push(value) } }); });
是的,就是这么简单。
参考资料
- StreamSaver.js
- MDN
更多关于用js实现文件流式下载文件方法请查看下面的相关链接