前置知识点
几个能触发浏览器自动创建文件并下载的 API
location.href;
window.open;
iframe.src;
a[download].click();
ServiceWorker
作用
用于浏览器代理,可以修改请求和响应,可以离线使用。
事件
发布事件
postMessage(message, transfer);
| 参数名 | 作用 | 类型 |
|---|---|---|
| message | 消息内容,会使用深复制来传递 | any |
| transfer | 可转移对象数组,e.g. [port2] | Array |
订阅事件
onmessage 或者 addEventListener('message')
注册
使用 navigator.serviceWorker.register 来注册。
e.g.
navigator.serviceWorker.register("sw.js", { scope: "./" });
拦截
在 serviceWorker 中监听 message 事件,事件回调中使用以下代码
event.respondWith(new Response(stream, { headers: responseHeaders }));
MessageChannel
用于在两个不同的浏览器上下文中进行通信。
创建:
const channel = new MessageChannel();
对象中有 port1 和 port2 两个 MessagePort,可以通过 addEventListener('message',()=>{}) 或者 onmessage 来订阅 message 事件
StreamSaver 的基本使用
使用 StreamSaver 创建一个 WritableStream,然后将 fetch 返回的 ReadableStream连接到 WritableStream
import streamSaver from "streamsaver";
const downloadStreamSingle = async () => {
const res = await fetch(`/rest/download/video`);
const fileStream = streamSaver.createWriteStream("video.mp4");
await res.body.pipeTo(fileStream);
};
StreamSaver 的实现方案
总体思路
- 前端传入文件名
- 前端打开
iframe/popup(https下使用iframe,http下使用popup) iframe/popup注册ServiceWorker,并绑定MessageChannel.port(用于前端和ServiceWorker之间的通信)iframe/popup加载完毕后,前端把文件名、随机数发送到iframe/popup,iframe/popup再发送到ServiceWorkerServiceWorker端根据文件名和随机数,动态生成一个 URL(URL 和ServiceWorker在同一个域下),并绑定 URL 和MessageChannel.port、ReadableStream之间的关联,供下次请求 URL 时取用ServiceWorker向前端返回 URL- 前端关闭
iframe/popup,并使用location.href请求 URL ServiceWorker接收到 URL 的请求,拦截该请求,返回上次保存的ReadableStream- 前端将接口返回的
readableStream, 用pipeTo连接到创建的writableStream,实现流式下载
模块
分为 3 部分
- StreamSaver.js:前端部分
- mitm.html:通过
iframe/popup打开的页面,用于注册ServiceWorker - sw.js:
ServiceWorker部分,用于拦截请求,并返回流式响应
画成顺序图,如下:
StreamSaver 的执行过程
StreamSaver.js
当引入了 StreamSaver 后,就会执行以下代码用于初始化全局变量
以下代码用于判断浏览器环境是否支持 ServiceWorker,如果不支持,后面会降级成非流式保存文件(当整个文件内容返回完毕后,才保存文件)
这段代码用于判断浏览器环境是否支持 TransformStream,为变量 supportsTransferable 赋值。supportsTransferable 的值会作为对象属性发送给ServiceWorker。
当需要保存文件时,就调用
createWriteStream 入口函数
函数在初始化了几个变量后,就通过调用 loadTransporter 函数来创建 iframe/popup
为了在前端和 iframe/popup 之间通信,还要创建 MessageChannel,MessageChannel 有 port1 和 port2,port1 指代浏览器端的 StreamSaver.js,port2 指代 iframe/popup 打开的 mitm.html
进入 loadTransporter 函数体,发现使用 isSecureContext 来判断使用 iframe 还是 popup
我们顺便进入 makePopup 函数体内
其实就是用 createDocumentFragment ,传入 mitm.html 的 URL 来打开一个 popup 形式的页面,并订阅 message 事件,在回调中向 popup 对象发送 load 事件
我们说回 createWriteStream 函数的主逻辑。接下来根据文件名和随机数,创建一个 pathname 路径,和 supportsTransferable 的值一起,打包到一个对象中,稍后会发送。
接下来会创建 TransformStream,并发送 transformStream.readable
port1 订阅 message 事件,事件回调的逻辑接下来会讲解。
判断 iframe/popup 是否已加载,如果已加上,就发送 message。
如果还没加载,就添加 load 事件回调,在回调中向 iframe/popup 发送 message。
message 事件的参数就是上面创建的 response 对象,包括 transferringReadable,pathname 和 headers
使用 popup/iframe 的方式打开 mimt.html,向主页面发送事件
整个 createWriteStream 函数的主要逻辑就到此为止了,接下来就返回 transformStream.writable 流。
如果浏览器不支持 MessageChannel 咋办呢?就返回原生的 WritableStream。
效果就是,等待整个文件的内容都返回了,才保存成文件。
mitm.html
刚才打开 popup 时,加载了 mitm.html 后会自动执行里面script标签中的 js
下面的代码会向原页面(打开 popup 的页面)发送 message 事件,表示 popup 已加载。
把 sw.js 注册成 ServiceWorker,然后绑定 message 回调
StreamSaver.js
StreamSaver.js 在收到 StreamSaver::loadedPopup 事件后,向 popup 对象发送 load 事件
注:popup 变量是创建 popup 时,使用 createDocumentFragment 返回的对象
Popup对象收到load事件后,向mitm.html发送message事件,参数是 pathname 等
Mitm.html
Mitm.html收到message事件后,向 ServiceWorker 发送 message 事件
sw.js
sw.js 收到 message 事件后,做 2 件事:
- 保存
downloadUrl和stream, - 向 port1(StreamSaver.js) 发送
message事件,参数是downloadUrl
如果 transferringReadable 为 true,就绑定 port 的message事件,在回调中设置readableStream
port 收到message事件时,设置 readableStream 的值,供下面返回
StreamSaver.js
port1 收到 downloadUrl 后,就使用 location.href 向 ServiceWorker 所在的域发出请求
downloadUrl 是在 sw.js 生成的,作用是关闭 popup 后再次请求(ServiceWorker 只能拦截注册后接收到的请求,所以需要在前端再次发送请求),触发 ServiceWorker 返回 readableStream
jimmywarting.github.io/StreamSaver…
请求发送到 ServiceWorker,sw.js 根据 url 找到上次保存的 readableStream
sw.js 返回stream
当响应内容返回到浏览器时,由于使用 location.href 来请求,所以自动触发浏览器的下载动作。会创建一个文件,然后流式下载。
当文件内容返回完毕后,文件就下载完成。
思考几个问题
为什么前端请求 URL,就能自动触发下载文件?
- URL 被
ServiceWorker拦截,并没有发送到服务端 ServiceWorker拦截到请求后,返回了ReadableStream- 由于采用
location.href来发起请求,所以可以触发浏览器默认的下载行为
为什么要创建 TransformStream?
个人认为,这是为了流式下载,可以一边读,一边写
为什么有了 TransformStream 后,还需要 ServiceWorker?
需要建立一个代理,请求 URL,返回服务端的流
需要通过 location.href 来触发浏览器的下载行为
为什么关闭了 iframe/popup 后,ServiceWorker 还能继续代理请求?
ServiceWorker 注册后,下次请求才能拦截,本次请求不会拦截
ServiceWorker 和页面状态无关,页面关闭不会影响到 ServiceWorker 的状态
能不能直接创建 TransformStream,然后用 pipeTo 连接到 fetch 的 readableStream(不用 ServiceWorker)?
本人还没尝试过这种办法,但是估计不会触发浏览器的下载行为
还有其他流式下载的方案吗?
头脑风暴想到的可能方案是:
使用 location.href 触发浏览器创建文件,拿到这个文件的 stream,然后使用 pipeTo 往 stream 中写入内容
由于还没找到获取浏览器创建的文件流的 API,所以不确定此方案是否可行。