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.body是ReadableStreamgetReader()拿到独占读取器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。 - 常用动作:
getReader、read、pipeThrough、pipeTo、tee。 - 流的高级价值不是“边读边渲染”四个字,而是“背压协调”。