JavaScript 中 Fetch 返回的 Response 为什么只能被读取一次

我们在任意网站的 Console 中输入如下代码:

let promise = fetch('/')
promise.then(res => res.text())
promise.then(res => res.text())

会发现第二次对 res 调用 res.text() 时会报如下错误:

Uncaught (in promise) TypeError: Failed to execute 'text' on 'Response': 
body stream already read

也就是说,fetch 返回的 response 只能被读取一次,这个应该是在设计 API 时就是这么规定的。但是我一直有个疑问,为什么要这么规定呢?很长一段时间我都是百思不得其解,最近在学习 rust 并且在补习大学已经丢掉的网络相关知识时,突然就有点明白了。可能不一定对,但我个人认为极有可能就是这样的。

我之前零星的查阅过相关资料,比较权威的 MDN 有这么一段描述:

这个很有用,因为 request 和 response bodies 只能被使用一次
(译者注:这里的意思是因为设计成了 stream 的方式,所以它们只能被读取一次)。
创建一个拷贝就可以再次使用 request/response 了,当然也可以使用不同的 init 参数。

关键就这这个译者注里(英文版的并没有这句话),他说 response 的 body 是一个 stream 所以只能被读取一次。

这是个不错的提示,现在的问题就变成了,为什么 stream 只能被读取一次呢?

又是一顿谷歌加百度,基本都是说如何解决这个问题,却没发现有说明为什么会这样。

其实关键点就在于如何理解 stream。

stream 中文翻译为流,我第一次听说这个词是在大学的计算机网络课程里,不过当时也是一脸懵逼,只是有个直观的理解就是啥东西一旦是流,就说明它更快,更好。

比如 nodejs 里通过 http 返回一个文件的内容时,最直观(LowB)的方式就是使用同步函数:

res.body = fs.readFileSync(filePath);

稍微好一点的方式是使用回调函数的方式:

fs.readFile(filePath, (err, content) => {
	res.body = content;
});

而公认最佳方式就是使用流了:

res.body = fs.createReadStream(filePath)

那流到底是什么呢? 在我看来流并不是一个具体的数据类型,它是一种操作数据的范式,一种类似于接口的东西。流有如下的特点:

  1. 流由数据块组成
  2. 组成流的数据块是有序的
  3. 流的大小可以是不定的

正是因为这些特点,TCP 在传输的时候才会采取流的方式。比如我要传输 1G 大小的内容,显然一次 TCP 连接是无法传输这么大的数据的,他要考虑很多情况包括当前网络的带宽以及拥塞情况等,既然一次传不了,那就多传几次嘛,把 1G 分成若干个 1KB 大小的数据块,只要最终能把这些数据块按序送达即可。

其实不止是 TCP,任何需要读取或者写入数据的场景流都是不错的选择。

因为流的大小可以是不定的,所以在从流中读取数据时,一般需要指定读取多少字节,通过循环读取的方式来获取最终需要的数据,因此流在内部也要知晓下次再有人从我这读数据时应该从哪读,而最经济实惠的方式莫过于直接将已经读取过的数据直接丢弃掉。

而这才是流只能被读取一次的真正原因。

加餐环节:上述 nodejs 例子为什么第三种比第二种好,好在哪里?

第二种方式的执行流程是这样的:

  1. 客户端发起 HTTP 请求获取文件内容
  2. 进程执行读取文件的系统调用
  3. 内核获取文件内容后通知进程
  4. 进程把文件内容从内核完整的读到自己的内存中
  5. 进程将内存中内容拷贝给 TCP
  6. 文件内容经由 TCP 传输到客户端

而第三种的执行流程是这样的:

  1. 客户端发起 HTTP 请求获取文件内容
  2. 进程执行读取文件的系统调用
  3. 内核获取文件内容后通知进程
  4. 进程把文件内容通过流直接传输给 TCP
  5. 文件内容经由 TCP 传输到客户端

在读取超大文件时,第二种方式需要首先开辟足够大的内存来存储这个大文件,而使用流则可以直接将文件内容转交给 TCP。

无论从内存占用还是传输效率上来看流都是更佳的选择。