StreamSaver.js执行过程分析

599 阅读10分钟

前置知识点

几个能触发浏览器自动创建文件并下载的 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();

对象中有 port1port2 两个 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 的实现方案

总体思路

  1. 前端传入文件名
  2. 前端打开 iframe/popuphttps下使用iframehttp下使用popup
  3. iframe/popup 注册 ServiceWorker,并绑定 MessageChannel.port(用于前端和 ServiceWorker 之间的通信)
  4. iframe/popup 加载完毕后,前端把文件名、随机数发送到 iframe/popup, iframe/popup 再发送到 ServiceWorker
  5. ServiceWorker 端根据文件名和随机数,动态生成一个 URL(URL 和 ServiceWorker 在同一个域下),并绑定 URL 和 MessageChannel.portReadableStream 之间的关联,供下次请求 URL 时取用
  6. ServiceWorker 向前端返回 URL
  7. 前端关闭 iframe/popup,并使用 location.href 请求 URL
  8. ServiceWorker 接收到 URL 的请求,拦截该请求,返回上次保存的 ReadableStream
  9. 前端将接口返回的 readableStream, 用 pipeTo 连接到创建的 writableStream,实现流式下载

模块

分为 3 部分

  • StreamSaver.js:前端部分
  • mitm.html:通过 iframe/popup 打开的页面,用于注册 ServiceWorker
  • sw.jsServiceWorker 部分,用于拦截请求,并返回流式响应

画成顺序图,如下:

20240923_001017_image.png

StreamSaver 的执行过程

StreamSaver.js

当引入了 StreamSaver 后,就会执行以下代码用于初始化全局变量

以下代码用于判断浏览器环境是否支持 ServiceWorker,如果不支持,后面会降级成非流式保存文件(当整个文件内容返回完毕后,才保存文件)

20240922_214445_image.png

这段代码用于判断浏览器环境是否支持 TransformStream,为变量 supportsTransferable 赋值。supportsTransferable 的值会作为对象属性发送给ServiceWorker

20240922_214032_image.png 当需要保存文件时,就调用 createWriteStream 入口函数

20240922_211711_image.png

函数在初始化了几个变量后,就通过调用 loadTransporter 函数来创建 iframe/popup

为了在前端和 iframe/popup 之间通信,还要创建 MessageChannelMessageChannel 有 port1 和 port2,port1 指代浏览器端的 StreamSaver.js,port2 指代 iframe/popup 打开的 mitm.html

20240922_212023_image.png

进入 loadTransporter 函数体,发现使用 isSecureContext 来判断使用 iframe 还是 popup

20240922_211803_image.png

我们顺便进入 makePopup 函数体内

其实就是用 createDocumentFragment ,传入 mitm.html 的 URL 来打开一个 popup 形式的页面,并订阅 message 事件,在回调中向 popup 对象发送 load 事件

20240922_214348_image.png

我们说回 createWriteStream 函数的主逻辑。接下来根据文件名和随机数,创建一个 pathname 路径,和 supportsTransferable 的值一起,打包到一个对象中,稍后会发送。

20240922_230859_image.png

接下来会创建 TransformStream,并发送 transformStream.readable

20240922_212309_image.png

port1 订阅 message 事件,事件回调的逻辑接下来会讲解。

20240922_212815_image.png

判断 iframe/popup 是否已加载,如果已加上,就发送 message

如果还没加载,就添加 load 事件回调,在回调中向 iframe/popup 发送 message

message 事件的参数就是上面创建的 response 对象,包括 transferringReadablepathnameheaders

20240922_212951_image.png

使用 popup/iframe 的方式打开 mimt.html,向主页面发送事件

image.png

整个 createWriteStream 函数的主要逻辑就到此为止了,接下来就返回 transformStream.writable 流。

如果浏览器不支持 MessageChannel 咋办呢?就返回原生的 WritableStream。

效果就是,等待整个文件的内容都返回了,才保存成文件。

20240922_213228_image.png

mitm.html

刚才打开 popup 时,加载了 mitm.html 后会自动执行里面script标签中的 js

下面的代码会向原页面(打开 popup 的页面)发送 message 事件,表示 popup 已加载。

20240922_220044_image.png

把 sw.js 注册成 ServiceWorker,然后绑定 message 回调

20240922_220707_image.png

image.png

StreamSaver.js

StreamSaver.js 在收到 StreamSaver::loadedPopup 事件后,向 popup 对象发送 load 事件

注:popup 变量是创建 popup 时,使用 createDocumentFragment 返回的对象

image.png

image.png

Popup对象收到load事件后,向mitm.html发送message事件,参数是 pathname 等

image.png

Mitm.html

Mitm.html收到message事件后,向 ServiceWorker 发送 message 事件

image.png

sw.js

sw.js 收到 message 事件后,做 2 件事:

  1. 保存 downloadUrlstream,
  2. 向 port1(StreamSaver.js) 发送message事件,参数是 downloadUrl

image.png

image.png

如果 transferringReadabletrue,就绑定 portmessage事件,在回调中设置readableStream

image.png

port 收到message事件时,设置 readableStream 的值,供下面返回

image.png

image.png

StreamSaver.js

port1 收到 downloadUrl 后,就使用 location.hrefServiceWorker 所在的域发出请求

downloadUrl 是在 sw.js 生成的,作用是关闭 popup 后再次请求(ServiceWorker 只能拦截注册后接收到的请求,所以需要在前端再次发送请求),触发 ServiceWorker 返回 readableStream

image.png

jimmywarting.github.io/StreamSaver…

请求发送到 ServiceWorker,sw.js 根据 url 找到上次保存的 readableStream

image.png

sw.js 返回stream

image.png

当响应内容返回到浏览器时,由于使用 location.href 来请求,所以自动触发浏览器的下载动作。会创建一个文件,然后流式下载。

当文件内容返回完毕后,文件就下载完成。

思考几个问题

为什么前端请求 URL,就能自动触发下载文件?

  1. URL 被 ServiceWorker 拦截,并没有发送到服务端
  2. ServiceWorker 拦截到请求后,返回了ReadableStream
  3. 由于采用 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,所以不确定此方案是否可行。

参考资料

developer.mozilla.org/zh-CN/docs/…

github.com/xitu/gold-m…