跳到主要内容

ReadableStream:前端为什么需要“可读流”?背压、读取器、pipeTo 怎么理解?

面试速答(30 秒版 TL;DR)

  • ReadableStream 是 Web Streams API 的“可读端”,适合处理 分块到达的数据,而不是等整包数据全部到齐。
  • 它的核心价值是:边到边处理、节省内存、支持背压(backpressure)
  • 高频 API 要会:getReader()read()cancel()pipeTo()pipeThrough()tee()
  • 实战里最常见的来源是:fetch(...).body、自定义流、文件流、SSE/LLM 流式输出。

版本说明

  • 这里说的是 WHATWG Web Streams API
  • 在现代浏览器,以及 Node 18+ 的 Web 平台兼容环境中都较常见。

心智模型:不是“一次拿完整数据”,而是“持续读 chunk”

普通接口思维是:

  • 请求发出去
  • 等响应完整返回
  • 一次性解析整段内容

流的思维是:

  • 数据不断到达
  • 每来一块(chunk)就先处理一块
  • 需要时还可以把处理压力反向传给上游,这就是背压

一、最小例子:读取 fetch 返回的流

const response = await fetch("/api/stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();

let text = "";

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

要点:

  • response.bodyReadableStream
  • getReader() 拿到独占读取器
  • read() 每次返回 { value, done }

二、构造器与底层控制器

你也可以自己造一个 ReadableStream

const stream = new ReadableStream({
start(controller) {
controller.enqueue("hello ");
controller.enqueue("world");
controller.close();
},
});

控制器常见方法:

方法作用
controller.enqueue(chunk)推送一段数据
controller.close()关闭流,后续不再产出
controller.error(err)让流进入错误状态

三、消费者最常用的实例方法

方法作用
getReader()获取 reader,手动逐块读取
cancel(reason?)取消流
pipeTo(dest, options?)把当前流直接写到目标 WritableStream
pipeThrough(transform, options?)经过 TransformStream 转换后得到新流
tee()把一个流分叉成两个可读流

pipeThrough

const textStream = response.body.pipeThrough(new TextDecoderStream());

这很适合把二进制字节流先转成文本流,再继续处理。

tee

const [a, b] = response.body.tee();

常见用途:

  • 一路给 UI 实时显示
  • 一路做日志/缓存/分析

四、背压(backpressure)为什么重要

这是流的高频追问。

如果上游生产得太快,下游处理不过来,会发生什么?

  • 全部堆内存里,容易爆内存
  • 无节制缓存,延迟抖动大
  • 用户还没消费完,数据已经压成大包

背压的意思就是:

  • 下游处理不过来时,上游要感知并减速

面试一句话:

  • 流不只是“边读边处理”,更重要的是“生产速率和消费速率能协调”

五、锁(lock)和读取模式

拿了 reader 后,流会被锁定:

const reader = stream.getReader();

这意味着:

  • 当前流被这个 reader 独占
  • 不能再被别的 reader 或 pipeTo 同时消费

要么:

  • 读完后释放锁
  • 要么一开始就用 tee() 分叉

六、和 for await...of 的关系

在支持 async iteration 的环境里,流可以更自然地消费:

for await (const chunk of response.body) {
console.log(chunk);
}

但面试里更常见的还是 getReader().read() 写法,因为它更能说明底层机制。


七、典型使用场景

1) 大文件下载/上传进度处理

  • 不用等文件全进内存
  • 一块一块处理或落盘

2) AI / SSE / 聊天流式输出

  • 服务端不断推 token
  • 前端边收到边渲染

3) 文本解码、协议解析

  • 原始字节先经 TextDecoderStream
  • 再按行、按分隔符切分

八、和传统“整包响应”的对比

维度普通一次性响应ReadableStream
内存占用通常更高通常更低
首字节到首内容显示更晚更早
处理方式全量后处理边到边处理
复杂度更高

典型题 & 标准答法

Q1:ReadableStream 的核心价值是什么?

  • 不是“API 很新”,而是能让数据分块处理
  • 好处是更省内存、首屏更快、能做实时流式 UI

Q2:什么是背压?

  • 下游消费不过来时,上游要感知并减速
  • 这是流模型区别于“简单事件推送”的关键能力

Q3:为什么拿了 reader 之后流会被锁住?

因为一个可读流同一时刻通常只能有一个主动消费者,否则会造成消费顺序和数据归属混乱。


易错点/坑

  • TextDecoder 不带 { stream: true },跨 chunk 字符可能被截断。
  • 拿了 reader 后又去 pipeTo,会因为锁冲突出问题。
  • 以为流能天然并行多消费,实际上通常要先 tee()
  • 忘记 cancel() 或释放资源,长连接场景下容易泄漏。

速记要点(可背诵)

  • ReadableStream = 分块可读的数据源。
  • 常见来源是 fetch().body
  • 常用动作:getReaderreadpipeThroughpipeTotee
  • 流的高级价值不是“边读边渲染”四个字,而是“背压协调”。