从 Fetch 到 Streams —— 以流的角度处理网络请求
自第一个实现的浏览器开始计算,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 请求:
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/await:
const res = await fetch('https://example.org/foo', { ... });
const data = await res.json();
再回过头来看久经风霜的 XMLHttpRequest,如果你已经习惯使用诸如 jQuery 的 $.ajax() 或者 axios 这类更为现代的封装 XHR 的库的话,估计已经忘了裸写 XHR 是什么样子了。简单来说,你需要调用 open() 方法开启一个请求,然后调用其他的方法或者设置参数来定义请求,最后调用 send() 方法发起请求,再在 onload 或者 onreadystatechange 事件里处理数据。看,这一通下来你已经乱了。
Fetch API 真香吗?
看起来 Fetch API 相比较于传统的 XHR 优势不少,不过在「真香」之前,我们先来看三个在 XHR 上很容易实现的功能:
- 如何中断一个请求?XMLHttpRequest 对象上有一个 abort() 方法,调用这个方法即可中断一个请求。此外 XHR 还有 onabort 事件,可以监听请求的中断并做出响应。
- 如何超时中断一个请求?XMLHttpRequest 对象上有一个 timeout 属性,为其赋值后若在指定时间请求还未完成,请求就会自动中断。此外 XHR 还有 ontimeout 事件,可以监听请求的超时中断并做出响应。
- 如何获取请求的传输进度?
在异步请求一个比较大的文件时,由于可能比较耗时,展示文件的下载进度在 UI 上会更友好。XMLHttpRequest 提供了 onprogress 事件,所以使用 XHR 可以很方便地实现这个功能。
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 晚了近三年。随着 AbortController 与 AbortSignal 在各大浏览器上完整实现,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 对象除了提供 headers、redirect() 等参数和方法外,还实现了 Body 这个 mixin 类,而在 Body 上我们才看到我们常用的那些 res.json()、res.text()、res.arrayBuffer() 等方法。在 Body 上还有一个 body 参数,这个 body 参数就是一个 ReadableStream。
既然本文是从 Fetch API 的角度出发,而如前所述,能产生数据的数据源才是流处理中最重要的一个部分,那么下面我们来重点了解下这个在 Body 中负责提供数据的 ReadableStream。
这篇文章不会讨论流的排队策略(也就是下文即将提到的构造流时传入的 queuingStrategy 参数,它可以控制流的缓冲区大小,不过 Streams API 有一个开箱即用的默认配置,所以可以不指定),也不会讨论没有浏览器实现的 BYOR reader,感兴趣的同学可以参考相关规范文档
ReadableStream
下面是一个 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.done 为 true 时表明流已经关闭,不会再有新的数据,此时 result.value 的值为 undefined。
回到我们之前的问题,我们可以通过读取 Response 中的流得到正在接收的文件片段,累加各个片段的 length 就能得到类似 XHR onprogress 事件的 loaded,也就是已下载的字节数;通过从 Response 的 headers 中取出 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 对象的第一个参数即是返回值,可以是字符串、Blob、TypedArray,甚至是一个 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() 方法。这个方法可以将一个流分流成两个一模一样的流,两个流可以读取完全相同的数据。
所以我们可以利用这个特性将一个流分成两个流,将其中一个流用于输出下载进度,而另一个流直接返回:
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 里,例如将请求得到的结果缓存起来等等。
很好,下载进度的问题完美解决了,那么让我们回到最早的问题。Fetch API 最早是没有 signal 这个参数的,所以早期的 fetch 请求很难中断——对,是「很难」,而不是「不可能」。如果浏览器实现了 ReadableStream 并在 Response 上提供了 body 的话,是可以通过流的中断实现这个功能的。
中断一个 ReadableStream
总结一下我们现在已经知道的内容,fetch 请求返回一个 Response 对象,从中可以得到一个 ReadableStream,然后我们还知道了如何自己构造 ReadableStream 和 Response 对象。再回过头看看 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();
流的锁机制
或许你还是很奇怪,既然流本身就有一个 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:
If this object is disturbed or locked, return a new promise rejected with a TypeError.
Let stream be body’s stream if body is non-null, or an empty ReadableStream object otherwise.
Let reader be the result of getting a reader from _stream_. If that threw an exception, return a new promise rejected with that exception.
Let promise be the result of reading all bytes from stream with _reader_.
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,此时这个流就被锁住了,自然也无法从外部取消。
最后,我们看个实例
示例:断点续传
现在我们可以随时中断一个请求,以及获取到请求过程中的数据,甚至还能修改这些数据。或许我们可以用来做些有趣的事情,比如各个下载器中非常流行的断点续传功能。
首先我们先来了解下断点续传的原理,简述如下:
- 发起请求
- 从响应头中拿到 Content-Length 属性
- 在响应过程中拿到正在下载的数据
- 终止下载
- 重新下载,但是此时根据已经拿到的数据设置 Range 请求头
- 重复步骤 3-5,直至下载完成
- 下载完成,将已拿到的数据拼接成完整的
在过去只能使用 XMLHttpRequest 或者还没有 Stream API 的时候,我们只能在请求完成时拿到数据。如果期间请求中断了,那也不会得到已经下载的数据,也就是这部分请求的流量被浪费了。所以断点续传最大的问题是获取已拿到的数据,也就是上面的第 3 步,根据已拿到的数据就能算出还有哪些数据需要请求。
其实在 Streams API 诞生之前,大家已经有着各种各样奇怪的方式实现断点续传了。例如国外的 Mega 网盘在下载文件时不会直接通知浏览器下载,而是先把数据放在浏览器内,传输完成后再下载文件。此外它还可以暂停传输,在浏览器内实现了断点续传的功能。仔细观察网络请求就会发现,Mega 在下载时不是下载整个文件,而是下载文件的一个个小片段。所以 Mega 是通过建立多个小的请求获取文件的各个小片段,待下载完成后再拼接为一个大文件。即便用户中途暂停,已下载的块也不会丢失,继续下载时会重新请求未完成的片段。虽然暂停时正在下载的片段还是会被丢弃(注意下面的视频中,暂停下载后重新请求的 URL 和之前的请求是一样的),不过相比较于丢弃整个文件来说,现在的实现已经是很大的优化了。
除了建立多个小请求得到零散文件块,变相实现断点续传外,其实 Firefox 浏览器上的私有特性允许开发者获取正在下载的文件片段,例如云音乐就使用了该特性优化了 Firefox 浏览器上的音频文件请求。Firefox 浏览器的 XMLHttpRequest 为 responseType 属性提供了私有的可用参数 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 上查看。
封装的 ResumableFetch 类会在请求过程中创建一个 ReadableStream 实例并直接返回,同时已下载的片段将会放进一个数组 chunks 并记录已下载的文件大小 length。当请求中断并重新下载时会根据已下载的文件大小设置 Range 请求头,此时拿到的就是还未下载的片段。下载完成后再将片段从 chunks 中取出,此时不需要对片段进行处理,只需要逐一传递给 ReadableStream 即可得到完整的文件。