从 Fetch 到 Streams —— 以流的角度处理网络请求

Title image of Streams API by Mozilla Contributors is licensed under CC-BY-SA 2.5.

Streams API 示意图,做者 Mozilla Contributors,基于 CC-BY-SA 2.5 协议使用。
本文篇幅较长,建议配合目录食用分次阅读。

本文做者:cclolihtml


自第一个实现的浏览器开始计算,Fetch API 已经快要五岁了。这五年 Chrome 和 Firefox 刷了很多版本号,IE 也不知死了多少年,而它的继任者更是上演了一出名为《Edge: Become Chromium》的好剧。再加上 ES6+ 的普及,咱们早已习惯了基于 Promise 和 async/await 的异步编程,因此估计很多同窗也转而使用 Fetch API 做异步请求。陪伴了咱们将近 20 年历史的 XMLHttpRequest 也被很多同窗「打入冷宫」,毕竟谁让 Fetch API 那么好用呢?可怜的 XHR 只能独守空房终日以泪洗面,看着你和 Fetch API 嬉戏的样子,口中喃喃说着「是我,是我先,明明都是我先来的」——呃,很差意思扯歪了。前端

Fetch API 不香吗?

不不不,没有这个意思。相比较于 XMLHttpRequest 来讲,fetch() 的写法简单又直观,只要在发起请求时将整个配置项传入就能够了。并且相较于 XHR 还提供了更多的控制参数,例如是否携带 Cookie、是否须要手动跳转等。此外 Fetch API 是基于 Promise 链式调用的,必定程度上能够避免一些回调地狱。举个例子,下面就是一个简单的 fetch 请求:ios

fetch('https://example.org/foo', {
    method: 'POST',
    mode: 'cors',
    headers: {
        'content-type': 'application/json'
    },
    credentials: 'include',
    redirect: 'follow',
    body: JSON.stringify({ foo: 'bar' })
}).then(res => res.json()).then(...)

若是你不喜欢 Promise 的链式调用的话,还能够用 async/awaitgit

const res = await fetch('https://example.org/foo', { ... });
const data = await res.json();

再回过头来看久经风霜的 XMLHttpRequest,若是你已经习惯使用诸如 jQuery 的 $.ajax() 或者 axios 这类更为现代的封装 XHR 的库的话,估计已经忘了裸写 XHR 是什么样子了。简单来讲,你须要调用 open() 方法开启一个请求,而后调用其余的方法或者设置参数来定义请求,最后调用 send() 方法发起请求,再在 onload 或者 onreadystatechange 事件里处理数据。看,这一通下来你已经乱了。github

课后习题 Q0:试试看将上面的 fetch 请求用原生 XMLHttpRequest 实现一遍,看看你还记得多少知识?

Fetch API 真香吗?

看起来 Fetch API 相比较于传统的 XHR 优点很多,不过在「真香」以前,咱们先来看三个在 XHR 上很容易实现的功能:web

  1. 如何中断一个请求?

    XMLHttpRequest 对象上有一个 abort() 方法,调用这个方法便可中断一个请求。此外 XHR 还有 onabort 事件,能够监听请求的中断并作出响应。ajax

  2. 如何超时中断一个请求?

    XMLHttpRequest 对象上有一个 timeout 属性,为其赋值后若在指定时间请求还未完成,请求就会自动中断。此外 XHR 还有 ontimeout 事件,能够监听请求的超时中断并作出响应。编程

  3. 如何获取请求的传输进度?json

    在异步请求一个比较大的文件时,因为可能比较耗时,展现文件的下载进度在 UI 上会更友好。XMLHttpRequest 提供了 onprogress 事件,因此使用 XHR 能够很方便地实现这个功能。canvas

    const xhr = new XMLHttpRequest();
    xhr.open('GET', '/foo');
    xhr.addEventListener('progress', (event) => {
        const { lengthComputable, loaded, total } = event;
        if (lengthComputable) {
            console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
        } else {
            console.log(`Downloaded ${loaded}`);
        }
    });
    xhr.send();

对于第一个问题其实已经有比较好的解决方案了,只是在浏览器上的实现距离 Fetch API 晚了近三年。随着 AbortControllerAbortSignal 在各大浏览器上完整实现,Fetch API 也能像 XHR 那样中断一个请求了,只是稍微绕了一点。经过建立一个 AbortController 实例,咱们获得了一个 Fetch API 原生支持的控制中断的控制器。这个实例的 signal 参数是一个 AbortSignal 实例,还提供了一个 abort() 方法发送中断信号。只须要将 signal 参数传递进 fetch() 的初始化参数中,就能够在 fetch 请求以外控制请求的中断了:

const controller = new AbortController();
const { signal } = controller;
fetch('/foo', { signal }).then(...);
signal.onabort = () => { ... };
controller.abort();

对于第二个问题,既然已经稍微绕路实现中断请求了,为什么再也不绕一下远路呢?只须要 AbortController 配合 setTimeout() 就能实现相似的效果了。

可是第三个获取请求进度的问题呢?你打开了 MDN,仔细地看了 fetch() 方法的全部参数,都没有找到相似 progress 这样的参数,毕竟 Fetch API 并无什么回调事件。难道 Fetch API 就不能实现这么简单的功能吗?固然能够,这里就要绕一条更远的路,提一提和它相关的 Streams API 了——不是 Web Socket,也不是 Media Stream,更不是只能在 Node.js 上使用的 Stream,不过和它很像。

Streams API 能作什么?

对于非 Web 前端的同窗来讲,流应该是个很常见的概念,它容许咱们一段一段地接收与处理数据。相比较于获取整个数据再处理,流不只不须要占用一大块内存空间来存放整个数据,节省内存占用空间,并且还能实时地对数据进行处理,不须要等待整个数据获取完毕,从而缩短整个操做的耗时。

此外流还有管道的概念,咱们能够封装一些相似中间件的中间流,用管道将各个流链接起来,在管道的末端就能拿处处理后的数据。例如,下面的这段 Node.js 代码片断实现了解压 zip 中的文件的功能,只须要从 zip 的中央文件记录表中读取出各个文件在 zip 文件内的起止偏移值,就能将对应的文件解压出来。

const input = fs.createReadStream(null, {
    fd, start, end, autoClose: false
});
const output = fs.createWriteStream(outputPath + name);
// 能够从流中直接读取数据
input.on('data', (chunk) => { ... });
// 或者直接将流引向另外一个流
input.pipe(zlib.createInflateRaw()).pipe(output);

其中的 input 是一个可读取的流,output 是一个可写入的流,而 zlib.createInflateRaw() 就是建立了一个既可读取又可写入的流,它在写入端以流的形式接受 Deflate 压缩的数据,在读取端以流的形式输出解压缩后的数据。咱们想象一下,若是输入的 zip 文件是一个上 GB 的大文件,使用流的方式就不须要占用一样大小的上 GB 的内存空间。并且从代码上看,使用流实现的代码逻辑一样简洁和清晰。

很惋惜,过去在客户端 JavaScript 上并无原生的流 API——固然你能够本身封装实现流,好比 JSZip 在 3.0 版本就封装了一个 StreamHelper,可是基本上除了使用这些 stream 库的库之外,没有其它地方能 产生 兼容这个库的流了。没有能产生流的数据源才是大问题,好比想要读取一个文件?过去 FileReader 只能在 onload 事件上拿到整个文件的数据,或者对文件使用 slice() 方法获得 Blob 文件片断。如今 Streams API 已经在浏览器上逐步实现(或者说,早在 2016 年 Chrome 就开始支持一部分功能了),能用上流处理的 API 想必也会愈来愈多,而 Streams API 最先的受益者之一就是 Fetch API。

Streams API 赋予了网络请求以片断处理数据的能力,过去咱们使用 XMLHttpRequest 获取一个文件时,咱们必须等待浏览器下载完整的文件,等待浏览器处理成咱们须要的格式,收到全部的数据后才能处理它。如今有了流,咱们能够以 TypedArray 片断的形式接收一部分二进制数据,而后直接对数据进行处理,这就有点像是浏览器内部接收并处理数据的逻辑。甚至咱们能够将一些操做以流的形式封装,再用管道把多个流链接起来,管道的另外一端就是最终处理好的数据。

Fetch API 会在发起请求后获得的 Promise 对象中返回一个 Response 对象,而 Response 对象除了提供 headersredirect() 等参数和方法外,还实现了 Body 这个 mixin 类,而在 Body 上咱们才看到咱们经常使用的那些 res.json()res.text()res.arrayBuffer() 等方法。在 Body 上还有一个 body 参数,这个 body 参数就是一个 ReadableStream

既然本文是从 Fetch API 的角度出发,而如前所述,能产生数据的数据源才是流处理中最重要的一个部分,那么下面咱们来重点了解下这个在 Body 中负责提供数据的 ReadableStream

这篇文章不会讨论流的排队策略(也就是下文即将提到的构造流时传入的 queuingStrategy 参数,它能够控制流的缓冲区大小,不过 Streams API 有一个开箱即用的默认配置,因此能够不指定),也不会讨论没有浏览器实现的 BYOR reader,感兴趣的同窗能够参考相关规范文档

ReadableStream

The image of ReadableStream Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.

ReadableStream 示意图,做者 Mozilla Contributors,基于 CC-BY-SA 2.5 协议使用。

下面是一个 ReadableStream 实例上的参数和可使用的方法,下文咱们将会详细介绍它们:

ReadableStream

  • locked
  • cancel()
  • pipeThrough()
  • pipeTo()
  • tee()
  • getReader()

其中直接调用 getReader() 方法会获得一个 ReadableStreamDefaultReader 实例,经过这个实例咱们就能读取 ReadableStream 上的数据。

ReadableStream 中读取数据

ReadableStreamDefaultReader 实例上提供了以下的方法:

ReadableStreamDefaultReader

  • closed
  • cancel()
  • read()
  • releaseLock()

假设咱们须要读取一个流中的的数据,能够循环调用 reader 的 read() 方法,它会返回一个 Promise 对象,在 Promise 中返回一个包含 value 参数和 done 参数的对象。

const reader = stream.getReader();
let bytesReceived = 0;
const processData = (result) => {
    if (result.done) {
        console.log(`complete, total size: ${bytesReceived}`);
        return;
    }
    const value = result.value; // Uint8Array
    const length = value.length;
    console.log(`got ${length} bytes data:`, value);
    bytesReceived += length;
    // 读取下一个文件片断,重复处理步骤
    return reader.read().then(processData);
};
reader.read().then(processData);

其中 result.value 参数为此次读取获得的片断,它是一个 Uint8Array,经过循环调用 reader.read() 方法就能一点点地获取流的整个数据;而 result.done 参数负责代表这个流是否已经读取完毕,当 result.donetrue 时代表流已经关闭,不会再有新的数据,此时 result.value 的值为 undefined

回到咱们以前的问题,咱们能够经过读取 Response 中的流获得正在接收的文件片断,累加各个片断的 length 就能获得相似 XHR onprogress 事件的 loaded,也就是已下载的字节数;经过从 Responseheaders 中取出 Content-Length 就能获得相似 XHR onprogress 事件的 total,也就是总字节数。因而咱们能够写出下面的代码,成功获得下载进度:

let total = null;
let loaded = 0;
const logProgress = (reader) => {
    return reader.read().then(({ value, done }) => {
        if (done) {
            console.log('Download completed');
            return;
        }
        loaded += value.length;
        if (total === null) {
            console.log(`Downloaded ${loaded}`);
        } else {
            console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
        }
        return logProgress(reader);
    });
};
fetch('/foo').then((res) => {
    total = res.headers.get('content-length');
    return res.body.getReader();
}).then(logProgress);

看着好像没问题是吧?问题来了,数据呢?我那么大一个返回数据呢?上面的代码只顾着输出进度了,结果并无把返回数据传回来。虽然咱们能够直接在上面的代码里处理二进制数据片断,但是有时咱们仍是会偷懒,直接获得完整的数据进行处理(好比一个巨大的 JSON 字符串)。

若是咱们但愿接收的数据是文本,一种解决方案是借助 TextDecoder 获得解析后的文本并拼接,最后将整个文本返回:

let text = '';
const logProcess = (res) => {
    const reader = res.body.getReader();
    const decoder = new TextDecoder('utf-8');
    const push = ({ value, done }) => {
        if (done) return JSON.parse(text);
        text += decoder.decode(value, { stream: true });
        // ...
        return reader.read().then(push);
    };
    return reader.read().then(push);
};
fetch('/foo').then(logProgress).then((res) => { ... });

不过若是你犯了强迫症,必定要像原来那样显示调用 res.json() 之类的方法获得数据,这该怎么办呢?既然 fetch() 方法返回一个 Response 对象,而这个对象的数据已经在 ReadableStream 中读取下载进度时被使用了,那我再构造一个 ReadableStream,外面再包一个 Response 对象并返回,问题不就解决了吗?

构造一个 ReadableStream

构造一个 ReadableStream 时能够定义如下方法和参数:

const stream = new ReadableStream({
    start(controller) {
        // start 方法会在实例建立时马上执行,并传入一个流控制器
        controller.desiredSize
            // 填满队列所需字节数
        controller.close()
            // 关闭当前流
        controller.enqueue(chunk)
            // 将片断传入流的队列
        controller.error(reason)
            // 对流触发一个错误
    },
    pull(controller) {
        // 将会在流的队列没有满载时重复调用,直至其达到高水位线
    },
    cancel(reason) {
        // 将会在流将被取消时调用
    }
}, queuingStrategy); // { highWaterMark: 1 }

而构造一个 Response 对象就简单了,Response 对象的第一个参数便是返回值,能够是字符串、BlobTypedArray,甚至是一个 Stream;而它的第二个参数则和 fetch() 方法很像,也是一些初始化参数。

const response = new Response(source, init);

了解以上的内容后,咱们只须要构造一个 ReadableStream,而后把「从 reader 中循环读取数据」的逻辑放在这个流的 start() 方法内,它会在流实例化后当即调用。当 reader 读取数据时能够输出下载进度,同时调用 controller.enqueue() 把获得的数据推动咱们构造出来的流,最后在读取完毕时调用 controller.close() 关闭这个流,问题就能轻松解决。

const logProgress = (res) => {
    const total = res.headers.get('content-length');
    let loaded = 0;
    const reader = res.body.getReader();
    const stream = new ReadableStream({
        start(controller) {
            const push = () => {
                reader.read().then(({ value, done }) => {
                    if (done) {
                        controller.close();
                        return;
                    }
                    loaded += value.length;
                    if (total === null) {
                        console.log(`Downloaded ${loaded}`);
                    } else {
                        console.log(`Downloaded ${loaded} of ${total} (${(loaded / total * 100).toFixed(2)}%)`);
                    }
                    controller.enqueue(value);
                    push();
                });
            };
            push();
        }
    });
    return new Response(stream, { headers: res.headers });
};
fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... });

分流一个 ReadableStream

感受是否是绕了一个远路?就为了这点功能咱们竟然构造了一个 ReadableStream 实例?有没有更简单的方法?实际上是有的,若是你稍有留意的话,应该会注意到 ReadableStream 实例上有一个名字看起来有点奇怪的 tee() 方法。这个方法能够将一个流分流成两个如出一辙的流,两个流能够读取彻底相同的数据。

The image of Teeing a ReadableStream by Mozilla Contributors is licensed under CC-BY-SA 2.5.

分流 ReadableStream 示意图,做者 Mozilla Contributors,基于 CC-BY-SA 2.5 协议使用。

因此咱们能够利用这个特性将一个流分红两个流,将其中一个流用于输出下载进度,而另外一个流直接返回:

const logProgress = (res) => {
    const total = res.headers.get('content-length');
    let loaded = 0;
    const [progressStream, returnStream] = res.body.tee();
    const reader = progressStream.getReader();
    const log = () => {
        reader.read().then(({ value, done }) => {
            if (done) return;
            // 省略输出进度
            log();
        });
    };
    log();
    return new Response(returnStream, { headers: res.headers });
};
fetch('/foo').then(logProgress).then(res => res.json()).then((data) => { ... });

另外其实 fetch 请求返回的 Response 实例上有一个一看就知道是什么意思的 clone() 方法,这个方法能够获得一个克隆的 Response 实例。因此咱们能够将其中一个实例用来获取流并获得下载进度,另外一个实例直接返回,这样就省去了构造 Response 的步骤,效果是同样的。其实这个方法通常用在 Service Worker 里,例如将请求获得的结果缓存起来等等。

课后习题 Q1:若是咱们调用了流的 tee() 方法获得了两个流,但咱们只读取了其中一个流,另外一个流在以后读取,会发生什么吗?

很好,下载进度的问题完美解决了,那么让咱们回到最先的问题。Fetch API 最先是没有 signal 这个参数的,因此早期的 fetch 请求很难中断——对,是「很难」,而不是「不可能」。若是浏览器实现了 ReadableStream 并在 Response 上提供了 body 的话,是能够经过流的中断实现这个功能的。

中断一个 ReadableStream

总结一下咱们如今已经知道的内容,fetch 请求返回一个 Response 对象,从中能够获得一个 ReadableStream,而后咱们还知道了如何本身构造 ReadableStreamResponse 对象。再回过头看看 ReadableStream 实例上还没提到的方法,想必你必定注意到了那个 cancel() 方法。

经过 ReadableStream 上的 cancel() 方法,咱们能够关闭这个流。此外你可能也注意到 reader 上也有一个 cancel() 方法,这个方法的做用是关闭与这个 reader 相关联的流,因此从结果上来看,二者是同样的。而对于 Fetch API 来讲,关闭返回的 Response 对象的流的结果就至关于中断了这个请求。

因此,咱们能够像以前那样构造一个 ReadableStream 用于传递从 res.body.getReader() 中获得的数据,并对外暴露一个 aborter() 方法。调用这个 aborter() 方法时会调用 reader.cancel() 关闭 fetch 请求返回的流,而后调用 controller.error() 抛出错误,中断构造出来的传递给后续操做的流:

let aborter = null;
const abortHandler = (res) => {
    const reader = res.body.getReader();
    const stream = new ReadableStream({
        start(controller) {
            let aborted = false;
            const push = () => {
                reader.read().then(({ value, done }) => {
                    if (done) {
                        if (!aborted) controller.close();
                        return;
                    }
                    controller.enqueue(value);
                    push();
                });
            };
            aborter = () => {
                reader.cancel();
                controller.error(new Error('Fetch aborted'));
                aborted = true;
            };
            push();
        }
    });
    return new Response(stream, { headers: res.headers });
};
fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... });
aborter();

课后习题 Q2:从上面的结果来看,当咱们调用 aborter() 方法时,请求被成功停止了。不过若是不调用 controller.error() 抛出错误强制中断流,而是继续以前的流程调用 controller.close() 关闭流,会发生什么事吗?

流的锁机制

或许你仍是很奇怪,既然流自己就有一个 cancel() 方法,为何咱们不直接暴露这个方法,反而要绕路构造一个新的 ReadableStream 呢?例如像下面这样:

let aborter = null;
const abortHandler = (res) => {
    aborter = () => res.body.cancel();
    return res;
};
fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... });
aborter();

惋惜这样执行会获得下面的错误:这个流被锁了。

TypeError: Failed to execute 'cancel' on 'ReadableStream': Cannot cancel a locked stream

你不信邪,既然流的 reader 被关闭时会关闭相关联的流,那么只要再获取一个 reader 并 cancel() 不就行了?

let aborter = null;
const abortHandler = (res) => {
    aborter = () => res.body.getReader().cancel();
    return res;
};
fetch('/foo').then(abortHandler).then(res => res.json()).then((data) => { ... });
aborter();

惋惜这样执行仍是会获得下面的错误:

TypeError: Failed to execute 'getReader' on 'ReadableStream': ReadableStreamReader constructor can only accept readable streams that are not yet locked to a reader

或许你还会想,像以前那样使用 tee() 克隆一个流,而后关闭克隆的流不就行了?惋惜即使成功调用了其中一个流的 cancel() 方法,请求仍是没有中断,由于另外一个流并无被中断,而且还在不断地接收数据。

因而咱们接触到了流的锁机制。一个流只能同时有一个处于活动状态的 reader,当一个流被一个 reader 使用时,这个流就被该 reader 锁定了,此时流的 locked 属性为 true。若是这个流须要被另外一个 reader 读取,那么当前处于活动状态的 reader 能够调用 reader.releaseLock() 方法释放锁。此外 reader 的 closed 属性是一个 Promise,当 reader 被关闭或者释放锁时,这个 Promise 会被 resolve,能够在这里编写关闭 reader 的处理逻辑:

reader.closed.then(() => {
  console.log('reader closed');
});
reader.releaseLock();

但是上面的代码彷佛没用上 reader 啊?再仔细思考下 res => res.json() 这段代码,是否是有什么启发?

让咱们翻一下 Fetch API 的规范文档,在 5.2. Body mixin 中有以下一段话:

Objects implementing the Body mixin also have an associated consume body algorithm, given a _type_, runs these steps:

  1. If this object is disturbed or locked, return a new promise rejected with a TypeError.
  2. Let stream be body’s stream if body is non-null, or an empty ReadableStream object otherwise.
  3. Let reader be the result of getting a reader from _stream_. If that threw an exception, return a new promise rejected with that exception.
  4. Let promise be the result of reading all bytes from stream with _reader_.
  5. Return the result of transforming promise by a fulfillment handler that returns the result of the package data algorithm with its first argument, type and this object’s MIME type.

简单来讲,当咱们调用 Body 上的方法时,浏览器隐式地建立了一个 reader 读取了返回数据的流,并建立了一个 Promise 实例,待全部数据被读取完后再 resolve 并返回格式化后的数据。因此,当咱们调用了 Body 上的方法时,其实就建立了一个咱们没法接触到的 reader,此时这个流就被锁住了,天然也没法从外部取消。

示例:断点续传

如今咱们能够随时中断一个请求,以及获取到请求过程当中的数据,甚至还能修改这些数据。或许咱们能够用来作些有趣的事情,好比各个下载器中很是流行的断点续传功能。

首先咱们先来了解下断点续传的原理,简述以下:

  1. 发起请求
  2. 从响应头中拿到 Content-Length 属性
  3. 在响应过程当中拿到正在下载的数据
  4. 终止下载
  5. 从新下载,可是此时根据已经拿到的数据设置 Range 请求头
  6. 重复步骤 3-5,直至下载完成
  7. 下载完成,将已拿到的数据拼接成完整的

在过去只能使用 XMLHttpRequest 或者尚未 Stream API 的时候,咱们只能在请求完成时拿到数据。若是期间请求中断了,那也不会获得已经下载的数据,也就是这部分请求的流量被浪费了。因此断点续传最大的问题是获取已拿到的数据,也就是上面的第 3 步,根据已拿到的数据就能算出还有哪些数据须要请求。

其实在 Streams API 诞生以前,你们已经有着各类各样奇怪的方式实现断点续传了。例如国外的 Mega 网盘在下载文件时不会直接通知浏览器下载,而是先把数据放在浏览器内,传输完成后再下载文件。此外它还能够暂停传输,在浏览器内实现了断点续传的功能。仔细观察网络请求就会发现,Mega 在下载时不是下载整个文件,而是下载文件的一个个小片断。因此 Mega 是经过创建多个小的请求获取文件的各个小片断,待下载完成后再拼接为一个大文件。即使用户中途暂停,已下载的块也不会丢失,继续下载时会从新请求未完成的片断。虽然暂停时正在下载的片断仍是会被丢弃(注意下面的视频中,暂停下载后从新请求的 URL 和以前的请求是同样的),不过相比较于丢弃整个文件来讲,如今的实现已是很大的优化了。

除了创建多个小请求获得零散文件块,变相实现断点续传外,其实 Firefox 浏览器上的私有特性容许开发者获取正在下载的文件片断,例如云音乐就使用了该特性优化了 Firefox 浏览器上的音频文件请求。Firefox 浏览器的 XMLHttpRequestresponseType 属性提供了私有的可用参数 moz-chunked-arraybuffer。请求还未完成时,能够在 onprogress 事件中请求 XHR 实例的 response 属性,它将会返回上一次触发事件后接收到的数据,而在 onprogress 事件外获取该属性将始终是 null

let chunks = [];
const xhr = new XMLHttpRequest();
xhr.open('GET', '/foo');
xhr.responseType = 'moz-chunked-arraybuffer';
xhr.addEventListener('progress', (event) => {
    chunks.push(xhr.response);
});
xhr.addEventListener('abort', () => {
    const blob = new Blob(chunks);
});
xhr.send();

看起来是个很不错的特性,只惋惜在 Bugzilla 上某个 和云音乐相关的 issue 里,有人发现这个特性已经在 Firefox 68 中移除了。缘由也能够理解,Firefox 如今已经在 fetch 上实现 Stream API 了,有标准定义固然仍是跟着标准走(虽然至今仍是 LS 阶段),因此也就再也不须要这些私有属性了。

从以前的示例咱们已经知道,咱们能够从 fetch 请求返回的 ReadableStream 里获得正在下载的数据片断,只要在请求的过程当中把它们放在一个相似缓冲区的地方就能够实现以前的第 3 步了,而这也是在浏览器上实现这个功能的难点。请求中断后再次请求时,只须要根据已下载片断的字节数就能够算出接下来要请求哪些片断了。简单来看,逻辑大概是下面这样:

const chunks = [];
let length = 0;
const chunkCache = (res) => {
    const reader = res.body.getReader();
    const stream = new ReadableStream({
        start(controller) {
            const push = () => {
                reader.read().then(({ value, done }) => {
                    if (done) {
                        let chunk;
                        while (chunk = chunks.shift()) {
                            controller.enqueue(chunk);
                        }
                        controller.close();
                        return;
                    }
                    chunks.push(value);
                    length += value.length;
                    push();
                });
            };
            push();
        }
    });
    return new Response(stream, { headers: res.headers });
};
const controller = new AbortController();
fetch('/foo', {
    headers: {
        'Range': `bytes=${length}-`
    },
    signal: controller.signal
}).then(chunkCache).then(...);
// 请求中断后再次执行上述 fetch() 方法

下面的例子对上述代码简单封装获得了 ResumableFetch,并使用它实现了图片下载的断点续传。示例完整代码可在 CodePen 上查看。

注意:该示例中的代码仅进行了简单封装,没有作诸如 If-RangeRangeContent-Length 等 header 的校验,也没有作特殊的错误处理,也没有包含以前提到的中断请求兼容代码,使用上可能也不够友好,仅供示例使用,请谨慎用于生产环境。

封装的 ResumableFetch 类会在请求过程当中建立一个 ReadableStream 实例并直接返回,同时已下载的片断将会放进一个数组 chunks 并记录已下载的文件大小 length。当请求中断并从新下载时会根据已下载的文件大小设置 Range 请求头,此时拿到的就是还未下载的片断。下载完成后再将片断从 chunks 中取出,此时不须要对片断进行处理,只须要逐一传递给 ReadableStream 便可获得完整的文件。

管道

到这里 ReadableStream 上的方法已经描述的差很少了,最后只剩下 pipeTo() 方法和 pipeThrough() 方法没有提到了。从字面意思上来看,这就是咱们以前提到的管道,能够将流直接指向另外一个流,最后拿处处理后的数据。Jake Archibald 在他的那篇《2016 — 属于 web streams 的一年》中提出了下面的例子,或许在(当时的)将来能够经过这样的形式以流的形式获得解析后的文本:

var reader = response.body
    .pipeThrough(new TextDecoder()).getReader();
reader.read().then(result => {
    // result.value will be a string
});

如今那个将来已经到了,为了避免破坏兼容性,TextEncoderTextDecoder 分别扩展出了新的 TextEncoderStreamTextDecoderStream,容许咱们以流的方式编码或者解码文本。例以下面的例子会在请求中检索 It works! 这段文字,当找到这段文字时返回 true 同时断开请求。此时咱们不须要再接收后续的数据,能够减小请求的流量:

fetch('/index.html').then((res) => {
    const decoder = new TextDecoderStream('gbk', { ignoreBOM: true });
    const textStream = res.body.pipeThrough(decoder);
    const reader = textStream.getReader();
    const findMatched = () => reader.read().then(({ value, done }) => {
        if (done) {
            return false;
        }
        if (value.indexOf('It works!') >= 0) {
            reader.cancel();
            return true;
        }
        return findMatched();
    });
    return findMatched();
}).then((isMatched) => { ... });

或者在将来,咱们甚至在流里实现实时转码视频并播放,或者将浏览器还不支持的图片以流的形式实时渲染出来:

const encoder = new VideoEncoder({
    input: 'gif', output: 'h264'
});
const media = new MediaStream();
const video = document.createElement('video');
fetch('/sample.gif').then((res) => {
    response.body.pipeThrough(encoder).pipeTo(media);
    video.srcObject = media;
});

从中应该能够看出来这两种方法的区别:pipeTo() 方法应该会接受一个能够写入的流,也就是 WritableStream;而 pipeThrough() 方法应该会接受一个既可写入又可读取的流,也就是 TransformStream

The image of Stream Pipe Chains Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.

Stream 管道链示意图,做者 Mozilla Contributors,基于 CC-BY-SA 2.5 协议使用。

接下来咱们将介绍这两种流,不过在继续以前,咱们先来看看 ReadableStream 在浏览器上的支持程度:

Image of Stream API Browser Compatibilty Table by Mozilla Contributors is licensed under CC-BY-SA 2.5.

ReadableStream 浏览器兼容表,做者 Mozilla Contributors,本图片为表格的截图,基于 CC-BY-SA 2.5 协议使用。

从表中咱们注意到,这两个方法支持的比较晚。而缘由估计你也能猜获得,当数据从一个可读取的流中流出时,管道的另外一端应该是一个可写入的流,问题就在于可写入的流实现的比较晚。

WritableStream

The image of WritableStream Concept by Mozilla Contributors is licensed under CC-BY-SA 2.5.

WritableStream 示意图,做者 Mozilla Contributors,基于 CC-BY-SA 2.5 协议使用。

咱们已经从 ReadableStream 中了解到不少关于流的知识了,因此下面咱们简单过一下 WritableStreamWritableStream 就是可写入的流,若是说 ReadableStream 是一个管道中流的起点,那么 WritableStream 能够理解为流的终点。下面是一个 WritableStream 实例上的参数和可使用的方法:

WritableStream

  • locked
  • abort()
  • getWriter()

可用的方法和参数不多,估计你们从名字就能知道它们是作什么的。其中直接调用 getWriter() 方法会获得一个 WritableStreamDefaultWriter 实例,经过这个实例咱们就能向 WritableStream 写入数据。一样的,当咱们激活了一个 writer 后,这个流就会被锁定(locked = true)。这个 writer 上有以下属性和方法:

WritableStreamDefaultWriter

  • closed
  • desiredSize
  • ready
  • abort()
  • close()
  • write()
  • releaseLock()

看起来和 ReadableStreamDefaultReader 没太大区别,多出的 abort() 方法至关于抛出了一个错误,使这个流不能再被写入。另外这里多出了一个 ready 属性,这个属性是一个 Promise,当它被 resolve 时,代表目前流的缓冲区队列再也不过载,能够安全地写入。因此若是须要循环向一个流写入数据的话,最好放在 ready 处理。

一样的,咱们能够本身构造一个 WritableStream,构造时能够定义如下方法和参数:

const stream = new WritableStream({
    start(controller) {
        // 将会在对象建立时马上执行,并传入一个流控制器
        controller.error(reason)
            // 对流抛出一个错误
    },
    write(chunk, controller) {
        // 将会在一个新的数据片断写入时调用,能够获取到写入的片断
    },
    close(controller) {
        // 将会在流写入完成时调用
    },
    abort(reason) {
        // 将会在流强制关闭时调用,此时流会进入一个错误状态,不能再写入
    }
}, queuingStrategy); // { highWaterMark: 1 }

下面的例子中,咱们经过循环调用 writer.write() 方法向一个 WritableStream 写入数据:

const stream = new WritableStream({
    write(chunk) {
        return new Promise((resolve) => {
            console.log('got chunk:', chunk);
            // 在这里对数据进行处理
            resolve();
        });
    },
    close() {
        console.log('stream closed');
    },
    abort() {
        console.log('stream aborted');
    }
});
const writer = stream.getWriter();
// 将数据逐一写入 stream
data.forEach((chunk) => {
    // 待前一个数据写入完成后再写入
    writer.ready.then(() => {
        writer.write(chunk);
    });
});
// 在关闭 writer 前先保证全部的数据已经被写入
writer.ready.then(() => {
    writer.close();
});

下面是 WritableStream 的浏览器支持状况,可见 WritableStream 在各个浏览器上的的实现时间和 pipeTo()pipeThrough() 方法的实现时间是吻合的,毕竟要有了可写入的流,管道才有存在的意义。

Image of Stream API Browser Compatibilty Table by Mozilla Contributors is licensed under CC-BY-SA 2.5.

WritableStream 浏览器兼容表,做者 Mozilla Contributors,本图片为表格的截图,基于 CC-BY-SA 2.5 协议使用。

TransformStream

从以前的介绍中咱们知道,TransformStream 是一个既可写入又可读取的流,正如它的名字同样,它做为一个中间流起着转换的做用。因此一个 TransformStream 实例只有以下参数:

TransformStream

  • readable: ReadableStream
  • writable: WritableStream

TransformStream 上没有其余的方法,它只暴露了自身的 ReadableStreamWritableStream。咱们只须要在数据源流上链式使用 pipeThrough() 方法就能实现流的数据传递,或者使用暴露出来的 readablewritable 直接操做数据便可使用它。

TransformStream 的处理逻辑主要在流内部实现,下面是构造一个 TransformStream 时能够定义的方法和参数:

const stream = new TransformStream({
    start(controller) {
        // 将会在对象建立时马上执行,并传入一个流控制器
        controller.desiredSize
            // 填满队列所需字节数
        controller.enqueue(chunk)
            // 向可读取的一端传入数据片断
        controller.error(reason)
            // 同时向可读取与可写入的两侧触发一个错误
        controller.terminate()
            // 关闭可读取的一侧,同时向可写入的一侧触发错误
    },
    transform(chunk, controller) {
        // 将会在一个新的数据片断传入可写入的一侧时调用
    },
    flush(controller) {
        // 当可写入的一端获得的全部的片断彻底传入 transform() 方法处理后,在可写入的一端即将关闭时调用
    }
}, queuingStrategy); // { highWaterMark: 1 }

有了 ReadableStreamWritableStream 做为前置知识,TransformStream 就不须要作太多介绍了。下面的示例代码摘自 MDN,是一段实现 TextEncoderStreamTextDecoderStream 的 polyfill,本质上只是对 TextEncoderTextDecoder 进行了一层封装:

const tes = {
    start() { this.encoder = new TextEncoder() },
    transform(chunk, controller) {
        controller.enqueue(this.encoder.encode(chunk))
    }
}
let _jstes_wm = new WeakMap(); /* info holder */
class JSTextEncoderStream extends TransformStream {
    constructor() {
        let t = { ...tes }
        super(t)
        _jstes_wm.set(this, t)
    }
    get encoding() { return _jstes_wm.get(this).encoder.encoding }
}
const tes = {
    start() {
        this.decoder = new TextDecoder(this.encoding, this.options)
    },
    transform(chunk, controller) {
        controller.enqueue(this.decoder.decode(chunk))
    }
}
let _jstds_wm = new WeakMap(); /* info holder */
class JSTextDecoderStream extends TransformStream {
    constructor(encoding = 'utf-8', { ...options } = {}) {
        let t = { ...tds, encoding, options }
        super(t)
        _jstes_wm.set(this, t)
    }
    get encoding() { return _jstds_wm.get(this).decoder.encoding }
    get fatal() { return _jstds_wm.get(this).decoder.fatal }
    get ignoreBOM() { return _jstds_wm.get(this).decoder.ignoreBOM }
}
Polyfilling TextEncoderStream and TextDecoderStream 源代码,做者 Mozilla Contributors,基于 CC-BY-SA 2.5CC0 协议使用。

到这里咱们已经把 Streams API 中所提供的流浏览了一遍,最后是 caniuse 上的浏览器支持数据,可见目前 Streams API 的支持度不算太差,至少主流浏览器都支持了 ReadableStream,读取流已经不是什么问题了,可写入的流使用场景也比较少。不过其实问题不是特别大,咱们已经简单知道了流的原理,作一些简单的 polyfill 或者额外写些兼容代码应该也是能够的,毕竟已经有很多第三方实现了。

Image of Streams Support Table by caniuse.com is licensed under CC-BY 4.0.

Streams 浏览器支持总览,做者 caniuse.com,本图片为图表的截图,基于 CC-BY 4.0 协议使用。

在 Service Worker 中使用 Streams API

控制请求的响应速度

首先让咱们来模拟体验一下龟速到只有大约 30B/s 的网页看起来是什么样子的:

你会注意到页面中的文字是一个个显示出来的(甚至标题栏也是这样的),其实这是借助 Service Worker 的 onfetch 事件配合 Streams API 实现的。熟悉 Service Worker 的同窗应该知道 Service Worker 里有一个 onfetch 事件,能够在事件内捕获到页面全部的请求,onfetch 事件的事件对象 FetchEvent 中包含以下参数和方法,排除客户端 id 之类的参数,咱们主要关注 request 属性以及事件对象提供的两个方法:

addEventListener('fetch', (fetchEvent) => {
    fetchEvent.clientId
    fetchEvent.preloadResponse
    fetchEvent.replacesClientId
    fetchEvent.resultingClientId
    fetchEvent.request
        // 浏览器本来须要发起请求的 Request 对象
    fetchEvent.respondWith()
        // 阻止浏览器默认的 fetch 请求处理,本身提供一个返回结果的 Promise
    fetchEvent.waitUntil()
        // 延长事件的生命周期,例如在返回数据后再作一些事情
});

使用 Service Worker 最多见的例子是借助 onfetch 事件实现中间缓存甚至离线缓存。咱们能够调用 caches.open() 打开或者建立一个缓存对象 cache,若是 cache.match(event.request) 有缓存的结果时,能够调用 event.respondWith() 方法直接返回缓存好的数据;若是没有缓存的数据,咱们再在 Service Worker 里调用 fetch(event.request) 发出真正的网络请求,请求结束后咱们再在 event.waitUntil() 里调用 cache.put(event.request, response.clone()) 缓存响应的副本。因而可知,Service Worker 在这之间充当了一个中间人的角色,能够捕获到页面发起的全部请求,而后根据状况返回缓存的请求,因此能够猜到咱们甚至能够改变预期的请求,返回另外一个请求的返回值。

Streams API 在 Service Worker 中一样可用,因此咱们能够在 Service Worker 里监听 onfetch 事件,而后用上咱们以前学习到的知识,改变 fetch 请求的返回结果为一个速度很缓慢的流。这里咱们让这个流每隔约 30 ms 才吐出 1 个字节,最后就能实现上面视频中的效果:

globalThis.addEventListener('fetch', (event) => {
    event.respondWith((async () => {
        const response = await fetch(event.request);
        const { body } = response;
        const reader = body.getReader();
        const stream = new ReadableStream({
            start(controller) {
                const sleep = time => new Promise(resolve => setTimeout(resolve, time));
                const pushSlowly = () => {
                    reader.read().then(async ({ value, done }) => {
                        if (done) {
                            controller.close();
                            return;
                        }
                        const length = value.length;
                        for (let i = 0; i < length; i++) {
                            await sleep(30);
                            controller.enqueue(value.slice(i, i + 1));
                        }
                        pushSlowly();
                    });
                };
                pushSlowly();
            }
        });
        return new Response(stream, { headers: response.headers });
    })());
});
在 Service Worker 里 Streams API 能够作出更多有趣的事情,感兴趣的同窗能够参考下以前提到的那篇 《2016 - the year of web streams》

下载一个前端生成的大文件

看着不是很实用?那么再举一个比较实用的例子吧。若是咱们须要让用户在浏览器中下载一个文件,通常都是会指向一个服务器上的连接,而后浏览器发起请求从服务器上下载文件。那么若是咱们须要让用户下载一个在客户端生成的文件,好比从 canvas 上生成的图像,应该怎么办呢?其实让客户端主动下载文件已经有现成的库 FileSaver.js 实现了,它的原理能够用下面的代码简述:

const a = document.createElement('a');
const blob = new Blob(chunk, options);
const url = URL.createObjectURL(blob);
a.href = url;
a.download = 'filename';
const event = new MouseEvent('click');
a.dispatchEvent(event);
setTimeout(() => {
    URL.revokeObjectURL(url);
    if (blob.close) blob.close();
}, 1e3);

这里利用了 HTML <a> 标签上的 download 属性,当连接存在该属性时,浏览器会将连接的目标视为一个须要下载的文件,连接不会在浏览器中打开,转而会将连接的内容下载到设备的硬盘上。此外在浏览器中还有 Blob 对象,它至关于一个相似文件的二进制数据对象(File 就是继承于它)。咱们能够将须要下载的数据(不管是什么类型,字符串、TypedArray 甚至是其余 Blob 对象)传进 Blob 的构造函数里,这样咱们就获得了一个 Blob 对象。最后咱们再经过 URL.createObjectURL() 方法能够获得一个 blob: 开头的 Blob URL,将它放到有 download 属性的 <a> 连接上,并触发鼠标点击事件,浏览器就能下载对应的数据了。

顺带一提,在最新的 Chrome 76+ 和 Firefox 69+ 上, Blob 实例支持了 stream() 方法,它将返回一个 ReadableStream 实例。因此如今咱们终于能够直接以流的形式读取文件了——看,只要 ReadableStream 实现了,相关的原生数据流源也会完善,其余的流或许也只是时间问题而已。

不过问题来了,若是须要下载的文件数据量很是大,好比这个数据是经过 XHR/fetch 或者 WebRTC 传输获得的,直接生成 Blob 可能会遇到内存不足的问题。

下面是一个比较极端的糟糕例子,描述了在浏览器客户端打包下载图片的流程。客户端 JavaScript 发起多个请求获得多个文件,而后经过 JSZip 这个库生成了一个巨大的 ArrayBuffer 数据,也就是 zip 文件的数据。接下来就像以前提到的那样,咱们基于它构造一个 Blob 对象并用 FileSaver.js 下载了这个图片。如你所想的同样,全部的数据都是存放在内存中的,而在生成 zip 文件时,咱们又占用了近乎同样大小的内存空间,最终可能会在浏览器内占用峰值为总文件大小 2-3 倍的内存空间(也就是下图中黄色背景的部分),流程事后可能还须要看浏览器的脸色 GC 回收。

如今有了 Streams API,咱们就有了另外一种解决方式。StreamSaver.js 就是这样的一个例子,它借助了 Streams API 和 Service Worker 解决了内存占用过大的问题。阅读它的源码,能够看出它的工做流程相似下面这样:

StreamSaver.js 包含两部分代码,一部分是客户端代码,一部分是 Service Worker 的代码(对于不支持 Service Worker 的状况,做者在 GitHub Pages 上提供了一个运行 Service Worker 的页面供跨域使用)。

在初始化时客户端代码会建立一个 TransformStream 并将可写入的一端封装为 writer 暴露给外部使用,在脚本调用 writer.write(chunk) 写入文件片断时,客户端会和 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,数据块通过流的流转后直接被回收了,下降了内存的占用。

因此借助 StreamSaver.js,以前下载图片的流程能够优化以下:JSZip 提供了一个 StreamHelper 的接口来模拟流的实现,因此咱们能够调用 generateInternalStream() 方法以小文件块的形式接收数据,每次接收到数据时数据会写入 StreamSaver.js 的 writer,通过 Service Worker 后数据直接被下载。这样就不会再像以前那样在生成 zip 时占用大量的内存空间了,由于 zip 数据在实时生成时被划分红了小块并迅速被处理掉了。

课后习题 Q3:StreamSaver.js 在不支持 TransformStream 的浏览器下实际上是能够正常工做的,这是怎么实现的呢?

总结

通过了这么长时间的学习,咱们从 Fetch API 的角度出发探索 Streams API,大体了解了如下几点:

  • Streams API 容许咱们以流的形式实时处理数据,每次只须要处理数据的一小部分
  • 可使用 pipeTo()pipeThrough() 方法方便地将多个流链接起来
  • ReadableStream 是可读取的流,WritableStream 是可写入的流,TransformStream 是既可写入又可读取的流
  • Fetch API 的返回值是一个 Response 对象,它的 body 属性是一个 ReadableStream
  • 借助 Streams API 咱们能够实现中断 fetch 请求或者计算 fetch 请求的下载速度,甚至能够直接对返回的数据进行修改
  • 咱们学习了如何构造一个流,并将其做为 fetch 请求的返回值
  • 在 Service Worker 里也可使用 Streams API,使用 onfetch 事件能够监听全部的请求,并对请求进行篡改
  • 顺带了解了如何中断一个 fetch 请求,使用 download 属性下载文件,Blob 对象,MessageChannel 双向通讯……

Streams API 提出已经有很长一段时间了,因为浏览器支持的缘由再加上使用场景比较狭窄的缘由一直没有获得普遍使用,国内的相关资料也比较少。随着浏览器支持逐渐铺开,浏览器原生提供的可读取流和可写入流也会逐渐增长(好比在本文即将写成时才注意到 Blob 对象已经支持 stream() 方法了),能使用上的场景也会愈来愈多,让咱们拭目以待吧。

参考答案

  1. 试试看将上面的 fetch 请求用原生 XMLHttpRequest 实现一遍,看看你还记得多少知识?

    const xhr = new XMLHttpRequest();
    xhr.open('POST', 'https://example.org/foo');
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.responseType = 'json';
    xhr.withCredentials = true;
    xhr.addEventListener('load', () => {
        const data = xhr.response;
        // ...
    });
    xhr.send(JSON.stringify({ foo: 'bar' }))

    在使用 XHR 初始化请求时会有较多的配置项,虽然这些配置项能够发出更复杂的请求,可是或许你也注意到了,发送请求时既有方法的调用,又有参数的赋值,看下来仍是不如 Fetch API 那样直接传入一个对象做为请求参数那么简洁的。此外,若是须要兼容比较早的不支持 XHR 2 的浏览器,你可能还须要改为使用 onreadystatechange 事件并手动解析 xhr.responseText

  2. 若是咱们调用了流的 tee() 方法获得了两个流,但咱们只读取了其中一个流,另外一个流在以后读取,会发生什么吗?

    使用 tee() 方法分流出来的两个流之间是相互独立的,因此被读取的流会实时读取到传递的数据,过一段时间读取另外一个流,拿到的数据也是彻底同样的。不过因为另外一个流没有被读取,克隆的数据可能会被浏览器放在一个缓冲区里,即使后续被读取可能也没法被浏览器即时 GC。

    const file = document.querySelector('input[type="file"]').files[0];
    const stream = file.stream();
    const readStream = (stream) => {
        let total = 0;
        const reader = stream.getReader();
        const read = () => reader.read().then(({ value, done }) => {
            if (done) return;
            total += value.length;
            console.log(total);
            read();
        });
        read();
    };
    
    const [s1, s2] = stream.tee();
    readStream(s1);
    readStream(s2);

    例如在上述代码中选择一个 200MB 的文件,而后直接调用 readStream(stream),在 Chrome 浏览器下没有较大的内存起伏;若是调用 stream.tee() 后获得两个流 s1s2,若是同时对两个流调用 readStream() 方法,在 Chrome 浏览器下一样没有较大的内存起伏,最终输出的文件大小也是一致的;若是只对 s1 调用的话,会发现执行结束后 Chrome 浏览器下内存占用多了约 200MB,此时再对 s2 调用,最终获得的文件大小虽然一致,可是内存并无及时被 GC 回收,此时浏览器的内存占用仍是以前的 200MB。

    可能你会好奇,以前咱们尝试过使用 tee() 方法获得两段流,一个流直接返回另外一个流用于输出下载进度,会有这样的资源占用问题吗?会不会出现两个流速度不一致的状况?其实计算下载进度的代码并不会很是耗时,数据计算完成后也不会再有多余的引用,浏览器能够迅速 GC。此外计算的速度是大于网络传输自己的速度的,因此并不会形成瓶颈,能够认为两个流最终的速度是基本同样的。

  3. 若是不调用 controller.error() 抛出错误强制中断流,而是继续以前的流程调用 controller.close() 关闭流,会发生什么事吗?

    从上面的结果来看,当咱们调用 aborter() 方法时,请求被成功停止了。不过若是不调用 controller.error() 这个方法抛出错误的话,因为咱们主动关闭了 fetch 请求返回的流,循环调用的 reader.read() 方法会接收到 done = true,而后会调用 controller.close()。这就意味着这个流是被正常关闭的,此时 Promise 链的后续操做不会被中断,而是会收到已经传输的不完整数据。

    若是没有作特殊的逻辑处理的话,直接返回不完整的数据可能会致使错误。不过若是能好好利用上的话,或许能够作更多事情——好比断点续传的另外一种实现,这就有点像 Firefox 的私有实现 moz-chunked-arraybuffer 了。

  4. StreamSaver.js 在不支持 TransformStream 的浏览器下实际上是能够正常工做的,这是怎么实现的呢?

    记得咱们以前提到过构造一个 ReadableSteam 而后包装成 Response 对象返回的实现吧?咱们最终的目的是须要构造一个流并返回给浏览器,这样传入的数据能够当即被下载,而且没有多余引用而迅速 GC。因此对于不支持 TransformStream 甚至 WritableStream 的浏览器,StreamSaver.js 封装了一个模拟 WritableStream 实现的 polyfill。当 polyfill 获得数据时,会将获得的数据片断经过 MessageChannel 直接传递给 Service Worker。Service Worker 发现这不是一个流,会构造出一个 ReadableStream 实例,并将数据经过 controller.enqueue() 方法传递进流。后续的流程估计你已经猜到了,和当前的后续流程是同样的,一样是生成一个随机 URL 并跳转,而后返回封装了这个流的 Response 对象。

    事实上,如今的 Firefox Send 就使用了这样的实现,当用户下载文件时会发出请求,Service Worker 接收到下载请求后会创建真实的 fetch 请求链接服务器,将返回的数据实时解密后直接下载到用户的设备上。这样的直观效果是,浏览器直接下载了文件,文件会显示在浏览器的下载列表中,同时页面上还会有下载进度:

参考资料

本文发布自 网易云音乐前端团队,基于 CC BY-SA 4.0 协议 进行许可,欢迎自由转载,转载请保留出处。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们
相关文章
相关标签/搜索