marp |
---|
JS 流式下载 —— StreamSaver.js
分段的读取和处理文件,不必把整个文件加载到内存中,节省内存空间的占用。
从服务器下载一个文件时,服务器也不可能把整个文件读取到内存中再进行响应,而是会边读边响应。
require('http').createServer((request, response) => {
response.writeHead(200, {
'Content-Type': 'text/html',
'Transfer-Encoding': 'chunked'
})
setInterval(() => {
response.write('chunked\r\n')
}, 1000)
}).listen(9000);
访问 http://localhost:9000
时,如下所示
在 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)
})
第一类的操作都会导致一个行为:页面级导航跳转
-
在页面级的跳转请求中,检查响应头是否包含
Content-Disposition: attachment
。对于a[download]
和createObjectURL
的url
跳转,可以理解为浏览器帮忙加上了这个响应头。 -
Ajax
发出的请求并不是页面级跳转请求,所以即使拥有下载响应头也不会触发下载行为。
第一类请求的响应数据直接由下载线程接管,可以进行流式下载,一边接收数据一边往本地写文件。
第二类由 JS
线程接管响应数据,使用 API 将文件数据创建成 url
触发下载。createObjectURL
、readAsDataURL
必须传入整个文件数据才能创建 URL。
-
纯前端处理文件流:在线格式转换、解压缩等
- 整个数据都在前端转换处理,压根没有服务端的事(要讨论的情况)
-
接口鉴权:鉴权方案导致请求必须由
JS
发起,如cookie + csrfToken
、JWT
- 使用
ajax
:简单但是数据都在内存中 - (推荐)使用
iframe + form
实现:麻烦但是可以由下载线程流式下载
- 使用
-
服务端返回文件数据,前端转换处理后下载
- 如服务端返回多个文件,前端打包下载
- (推荐)去找后端聊
聊一聊
网上搜索「前端 zip」,得到的答案大概率是 JSZip
。
import JSZip from 'jszip'
import { saveAs } from 'file-saver'
inputRef.value?.addEventListener("change", async (e: any) => {
const file = e.target!.files[0]!
const zip = new JSZip();
zip.file(file.name, file);
const blob = await zip.generateAsync({type:"blob"})
saveAs(blob, "example.zip");
});
FileSaver
官网的第一句话就是
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
创建一个可写流对象,这个对象带有内置的背压和排队。
// 创建
const writableStream = new WritableStream({
write(chunk: Unit8Array) {
console.log(chunk)
}
})
// 使用
const writer = writableStream.getWriter()
writer.write(1).then(() => {
// 应当在 then 再写入下一个数据
writer.write(2)
})
创建一个可读的二进制操作。
const readableStream = new ReadableStream({
start(controller) {
setInterval(() => {
// 向流中放入数据
controller.enqueue(value);
// controller.close(); 表明数据已发完
}, 1000)
}
});
async function () {
const reader = readableStream.getReader()
while (true) {
const {value, done} = await reader.read()
console.log(value)
if (done) break
}
}
const response = await fetch('/api/download')
response.json = async () => {
const reader = response.body.getReader()
const chunks = []
while(true) {
const {done, value} = await reader.read()
if (done) break
chunks.push(value)
}
return JSON.parse(toString(chunks))
}
Blob
对象的 stream
方法,会返回一个 ReadableStream
。
当我们从本地上传文件时,文件对象 File
就是继承自Blob
fileInput.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;
}
});
一端转换写入数据、一端读取数据。
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()
writer.write(123) // 写入数据
const reader = readableStream.getReader()
reader.read() // 读出数据 123
const {readable, writable} = new TransformStream()
writable.getWriter().write(123) // 写入数据
readable.getReader().read() // 读出数据 123
前面的结论:
-
只有页面级跳转会触发下载。
- 这意味着发起请求后,响应数据直接被下载线程接管,
JS
没机会插手处理。
- 这意味着发起请求后,响应数据直接被下载线程接管,
-
Fetch
请求,前端可以处理数据,但createObjectURL
只能接收整个文件数据。- 这意味一旦数据到了
JS
手中,只能整体下载。
- 这意味一旦数据到了
熟悉 PWA
的人对它一定不陌生,它可以拦截浏览器的请求并提供离线缓存。
这里有两个关键点:
- 拦截请求
- 构建响应
通过 Service worker
前端完全可以自己充当服务器给下载线程传输数据。
self.onfetch = event => {
const { url } = event.request
if (url === '要拦截 url') {
event.respondWith(new Response())
}
}
fetch()
返回的 response
的构造函数。
interface Response: {
new(body?: BodyInit, init?: ResponseInit): Response
}
type BodyInit = ReadableStream | Blob | BufferSource | FormData | URLSearchParams | string
interface ResponseInit {
headers?: HeadersInit
// ...
}
这意味着:
- 在响应头中写入
Content-Disposition:attachment
,浏览器就会让下载线程接管响应。 - 将
Body
构建成ReadableStream
,就可以流式的向下载线程传输数据。
const header = new Headers({
'Content-Disposition': "attachment;"
})
const {readable, writable} = new TransformStream()
new Response(readable, { header })
也意味着前端可以进行流式下载!
我们构建一个最简的例子来将所有知识点串起来:从本地上传文件,流式的读取,流式的下载到本地。
- 通知
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, 发起请求
const iframe = document.createElement('iframe')
iframe.src = data.url;
document.body.appendChild(iframe);
// 返回可写流
resolve(data.writable)
};
});
}
Service worker
接受到消息,创建url
、ReadableStream
、WritableStream
,将url
、WritableStream
通过channel
发送回去。
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'
})
// 构建返回响应
event.respondWith(
new Response(readableStream, { headers })
)
}
- 主线程拿到上传的
File
对象,获取其ReadableStream
并读取,将读取到的数据通过WritableStream
(第 1 步中返回的)发送出去。
input.addEventListener("change", async (e: any) => {
const file = e.target!.files[0];
const writableStream = createDownloadStrean()
const reader = file.stream().getReader();
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();
})
const writableStream = createDownloadStrean()
const writable = writableStream.getWriter()
const file = e.target!.files[0];
const reader = file.stream().getReader();
const zip = new fflate.Zip();
const zipStream = new fflate.ZipDeflate(file.name, { level: 9 });
zip.add(zipStream);
zip.ondata = (err, data, final) => {
if (err || final) {
zip.end();
writable.close();
} else {
writable.write(data);
}
}
while (true) {
const { done, value = new Unit8Array } = await reader.read();
zipStream.push(value, done)
}