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。
两个办法:
response.clone():fetch 阶段就克隆,得到两个独立的响应对象,各自有独立的流。注意 clone 必须在 body 被消费前调用,且两个 body 都会占内存。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 在工程里的价值——不是让代码变得更华丽,而是让数据流动的方式匹配真实的使用节奏。