编程 ReadableStream 工程笔记:从一次 UI 卡顿到流式消费

2026-09-01 00:04:20

ReadableStream 工程笔记:从一次 UI 卡顿到流式消费

遇到的真实问题

产品有个下载报表的功能,后端返回一个约 30MB 的 JSON。最初实现很简单:

const res = await fetch('/api/report');
const data = await res.json(); // 一次性读入内存

结果用户反馈:下载过程中浏览器 Tab 卡死,切走页面回来还要等。原因很直观——30MB 的 JSON 一次性加载进内存,JSON.parse 期间主线程被占用,UI 自然卡顿。

当时的想法是:能不能边下载边解析、边渲染?哪怕不能让用户看到半截数据,至少别阻塞主线程。

正题:response.body 就是 ReadableStream

fetch 返回的 response.body 暴露的就是一个 ReadableStream。这意味着响应体不是一次性到位的,而是像水管一样,后端数据陆续流过来,前端可以边收边处理。

const res = await fetch('/api/report');
const reader = res.body.getReader(); // 获取 reader,并锁定流

这里有个细节:getReader()锁定这个流,锁定之后同一个流上不能再调用 getReader()。如果强行再调,浏览器会抛:

TypeError: ReadableStream is locked

reader.read() 返回什么

reader.read() 返回一个 Promise,resolve 后得到 { value, done }

  • value:一个 Uint8Array,也就是原始字节
  • done:布尔值,为 true 表示流结束
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log(value.byteLength); // 每一块的字节长度
}

注意:value 一定是 Uint8Array,不是字符串。要想变成文本,需要用 TextDecoder

TextDecoder 跨块解码

如果直接 new TextDecoder().decode(value),会遇到一个经典 bug:中文等多字节字符可能被网络层切成两半,一个字符的前半段在上一块、后半段在下一块。直接解码会导致乱码。

正确姿势:

const decoder = new TextDecoder();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  const text = decoder.decode(value, { stream: true });
  console.log(text);
}

{ stream: true } 的意思是告诉 decoder:这只是输入流中的一个片段,如果最后有半个字符,先缓存,等下一块来再拼上。流结束时再调用一次 decoder.decode() 冲刷缓冲。

块边界:网络不关心你的消息格式

血泪教训:网络层分块的边界和你的业务消息边界没有半毛钱关系。协议根本不认识 \n\r\n 或 JSON 的 }

如果你的后端是换行分隔的 JSON(NDJSON),或者你要解析 SSE 事件流,那么必须自己做缓冲:

let buffer = '';

while (true) {
  const { value, done } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });

  // 按 \n 切分,最后一段可能不完整,留到下一轮
  let idx;
  while ((idx = buffer.indexOf('\n')) !== -1) {
    const line = buffer.slice(0, idx).trim();
    buffer = buffer.slice(idx + 1);
    if (line) handleLine(line);
  }
}

不做这个缓冲,log 里会出现各种半截 JSON。

流只能消费一次,多消费者用 tee 或 clone

流是单向、单消费者的。getReader() 之后,整个流就归这个 reader 了。如果两个模块都想读同一份数据,直接再 getReader 会报 stream is locked

两个办法:

  1. response.clone():fetch 阶段就克隆,得到两个独立的响应对象,各自有独立的流。注意 clone 必须在 body 被消费前调用,且两个 body 都会占内存。
  2. stream.tee():把一个流分成两个分支,每个分支都可以独立 getReader。tee 返回两个新的 ReadableStream。
const [streamA, streamB] = res.body.tee();
const readerA = streamA.getReader();
const readerB = streamB.getReader();

tee 的代价:内部有缓冲。如果分支 A 消费慢,分支 B 的数据会被堆积在内存里。所以 tee 并不适合"一个消费、一个丢弃"的场景,更不适合两个分支消费速度差异巨大的场景。

AbortController:用户走了,流还在跑

长流场景下最常见的资源泄漏是:用户点了下载、中途放弃、关掉页面,但 fetch 还在继续。

用 AbortController 可以在必要时主动掐断:

const controller = new AbortController();

fetch('/api/report', { signal: controller.signal });

// 用户点击取消:
controller.abort('user canceled');
// 或者监听页面隐藏/卸载事件
document.addEventListener('pagehide', () => controller.abort());

取消后,reader.read() 会抛异常,需要 try/catch 兜住,否则控制台会出现未捕获的 rejection:

DOMException: The user aborted a request.

也有人用 reader.cancel() 来取消流本身,但配合 fetch 的 signal 更干净——它同时取消网络请求和流。reader.cancel() 只是停止读取,fetch 的网络请求可能还在继续。

BYOB:减少拷贝

默认的 reader.read() 返回的 Uint8Array 是流内部分配的,每次都有拷贝开销。如果对性能敏感,可以用 BYOB(Bring Your Own Buffer)模式:自己准备好一个 ArrayBuffer,让流直接把数据写进来,减少一次内存拷贝。

const reader = res.body.getReader({ mode: 'byob' });
const buffer = new ArrayBuffer(4096);
const { value, done } = await reader.read(new Uint8Array(buffer));

限制:BYOB 只在字节流上可用,也就是 type: 'bytes' 的 ReadableStream。普通对象流不支持。而且 value 不保证填满整个 buffer——要看实际读了多少字节。BYOB 在 Socket、WebTransport 这类高频传输场景收益明显,普通 fetch 场景收益有限,别为了炫技而用。

背压:desiredSize 与 pull

凡是流,就有生产者和消费者。消费者读得快,生产者就赶紧产;消费者读得慢,生产者就该停下来。

ReadableStream 内部有一个队列和 desiredSize 字段,表示"还能往队列里塞多少块"。desiredSize 为正表示还有余量,为负表示消费端积压了。

自定义流时会用到:

const stream = new ReadableStream({
  start(controller) {
    // 一次性初始化,比如设置定时器、打开连接
  },
  pull(controller) {
    // 消费者准备好接收数据时被调用,按需生产
  },
  cancel(reason) {
    // 消费者取消时清理资源
  }
});

pull 的典型逻辑:只有队列里有空间时才往 controller.enqueue() 塞数据,消费者不读,生产者就不产。这就是背压机制。

工程上,直接用原生 fetch 的流通常不需要手写 pull,但理解背压对排查"为什么下载速度忽快忽慢"有直接帮助。

不适用场景:什么时候别用流

流不是银弹。以下几点是踩过坑才明白的:

  • 小响应体:几百 KB 的 JSON 直接 res.json() 更快、更简单。流式处理带来的复杂度不值得。
  • 需要两次遍历的数据:比如先算总大小再渲染进度条,又要把数据喂给解析器。流只能消费一次,两次遍历要么 tee,要么先落硬盘缓存,都是额外成本。
  • SSE / 超大 JSON 之外的分隔协议:如果后端没有明确的分隔符,流式解析很难做。此时宁可整体读完再解析。
  • 低版本浏览器:部分老浏览器不支持 ReadableStream,需要 polyfill 或降级方案。原文未提供具体的兼容性版本清单。
  • 内存不是瓶颈的场景:整体读入简单可靠,不要为了"看起来先进"而引入流式处理。

小结

回到最初的 UI 卡顿问题,最终方案是:

  • response.body.getReader() 边收边解析,按行分割;
  • 配合 TextDecoder(..., { stream: true }) 处理中文;
  • 用户离开页面时用 AbortController 中断长流;
  • 进度条渲染使用 requestAnimationFrame 节流,避免每块数据都触发重绘。

UI 不再卡顿,内存峰值明显下降,取消下载时网络请求也被真正掐断。这就是 ReadableStream 在工程里的价值——不是让代码变得更华丽,而是让数据流动的方式匹配真实的使用节奏。

复制全文 生成海报 Read Stream ReadableStream Web Streams Fetch JavaScript

推荐文章

程序员茄子在线接单