下面从 fetch 和 ReadableStream 分别讲起,再说明它们为什么经常一起出现。
一、fetch 是什么?
fetch 是浏览器内置的全局方法,用来发起 HTTP 请求 。它基于 Promise,是现代前端替代 XMLHttpRequest 的标准网络请求接口。
基本语法
javascript
fetch(url, options)
.then(response => {
// response 是 Response 对象
})
.catch(error => {
// 网络错误
});
async/await 写法
javascript
async function getData() {
const res = await fetch('/api/user');
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
console.log(data);
}
二、fetch 的返回值不是"最终数据",而是 Response 对象
很多人第一次用 fetch 时,会误以为:
javascript
const res = await fetch('/api/data');
这一步已经拿到了接口返回的数据。
其实不是 。fetch()返回的是一个 Promise<Response>,也就是说,它先给你一个 响应对象,而不是直接给你 JSON、文本或文件内容。
这个 Response 对象里包含两类信息:
| 类型 | 常见内容 | 说明 |
|---|---|---|
| 响应元信息 | res.status、res.ok、res.headers |
状态码、是否成功、响应头 |
| 响应体 | res.body、res.json()、res.text() |
真正的业务数据 |
所以常见写法是:
javascript
const res = await fetch('/api/data');
const data = await res.json();
这里有两步:
fetch():拿到响应对象res.json():把响应体解析成 JSON
三、Response 响应体有多种读取方式
Response 的响应体可以用不同方法读取,但注意:响应体通常只能读取一次。
javascript
await res.json(); // 解析成 JSON
await res.text(); // 读取成纯文本
await res.blob(); // 读取成二进制文件对象
await res.arrayBuffer(); // 读取成原始二进制缓冲
await res.formData(); // 读取成表单数据
这些方法本质上都是把响应体一次性消费掉。
比如:
javascript
const data = await res.json();
它适合普通接口:服务端把完整 JSON 返回,前端等完整响应到达后,再一次性解析。
但这不适合"边生成边返回"的场景,比如大模型流式输出。
四、普通 fetch 用法的问题:必须等完整响应
普通用法里,你调用:
javascript
const data = await res.json();
浏览器会等整个响应体接收完,再把它解析成 JSON。对于普通接口没问题。
但如果服务端是流式返回,比如大模型一边生成一边推送:
plain
data: {"content":"你"}
data: {"content":"好"}
data: {"content":","}
data: {"content":"我是"}
...
你如果还用 res.json(),就不合适,因为:
- 它不是一整段合法 JSON;
- 数据是分批到达的;
- 你需要"来一块,处理一块",而不是等全部结束。
这时候就要用到 res.body。
五、res.body 是什么?
res.body 就是 Response的响应体流 。它的类型通常是:ReadableStream
可以简单理解成:res.body 是一根从服务端通向浏览器的"数据水管"。服务端产生的数据会一块一块地流过来,前端可以一块一块地读取,而不需要等全部数据到达。
所以:
javascript
const res = await fetch('/api/stream');
console.log(res.body);
// ReadableStream
这里 res.body不是一个完整字符串,也不是一个 JSON 对象,而是一个可以持续读取的数据流。
六、ReadableStream 是什么?
ReadableStream 是浏览器 Streams API 的一部分,表示一个**可读的数据流**。
它的作用是:让 JavaScript 可以按块读取数据,而不是必须等全部数据准备好。
它最典型的数据单位是:Uint8Array,也就是二进制字节块。
你可以把它理解成:
plain
服务端数据
↓
分成很多小块 chunk
↓
前端每次 read() 拿到一块
↓
前端自己决定怎么解码、拼接、解析、渲染
所以 ReadableStream 的核心价值是:
- 支持边接收边处理;
- 降低首字节等待时间;
- 避免一次性把大量数据塞进内存;
- 可以中途取消;
- 可以和其他流处理机制组合。
七、ReadableStream 的核心 API
ReadableStream 最常用的是这几个方法:
| 方法 | 作用 |
|---|---|
getReader() |
创建一个 reader,开始读取流 |
reader.read() |
每次读取一个数据块 |
cancel() |
取消流 |
pipeThrough() |
把流经过一个转换流处理 |
pipeTo() |
把流写入一个 WritableStream |
tee() |
把一个流拆成两个相同的分支 |
其中最关键的是:
javascript
response.body.getReader()
它的作用是:从响应体流上安装一个"水龙头",然后你通过这个水龙头一块一块地取水。
八、 reader.read() 是怎么工作的?
reader.read()返回一个 Promise,结果是: { value, done }
含义如下:
| 字段 | 含义 |
|---|---|
value |
当前读到的数据块,通常是 Uint8Array |
done |
流是否已经结束 |
示例:
javascript
const reader = response.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
console.log(value); // Uint8Array
}
这里有一个很关键的点:
javascript
await reader.read()
它不是死循环一直占用 CPU,而是:
- 有数据块到达时,Promise 会 resolve;
- 没有数据时,它会暂停等待;
- 数据结束后,
done会变成true。
所以它是由"数据到达"驱动的,而不是由 CPU 一直轮询驱动的。
九、为什么流式场景里 fetch 和 ReadableStream 经常一起出现?
因为大模型流式接口通常是这样的链路:
plain
fetch 发起 POST 请求
↓
服务端返回 text/event-stream 或分块响应
↓
response.body 是一个 ReadableStream
↓
getReader() 获取 reader
↓
reader.read() 逐块读取 Uint8Array
↓
TextDecoder 把二进制转成文本
↓
前端解析 SSE 事件或 JSON 增量
↓
页面逐步渲染
所以它们分工很明确:
| API | 职责 |
|---|---|
fetch() |
负责发起 HTTP 请求,拿到 Response |
response.body |
提供响应体的可读流 |
ReadableStream |
负责让前端按块读取数据 |
reader.read() |
每次取出一个数据块 |
TextDecoder |
把二进制字节转成文本 |
也就是说:
fetch 负责"请求",ReadableStream 负责"流式读取响应体"。
它们不是同一个东西。
十、 一个最小示例
javascript
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: '你好'
})
});
const reader = response.body.getReader();
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);
}
这段代码的意思是:
- 用
fetch发起请求; - 从
response.body拿到可读流; - 用
getReader()创建读取器; - 循环调用
reader.read()逐块读取; - 用
TextDecoder把二进制块转成文本。
这就是流式读取的基本模型。
有一个疑问:这段代码是一个循环,条件为true,接口不断的推送数据一块一块的,但如果第一块的数据推送过来了,代码一轮循环执行完毕,然后第二块的代码还没推送到,这轮循环会再把第一块的数据再读取一遍吗,很疑惑,因为我之前开发的循环有具体数字的加减来标记,知道什么时候结束循环,但这个无法通过代码看出来。
回答:
不会重复读取 。 这个循环不会"倒回去"重读,关键在于reader.read() 是一个有状态的、带内部指针的读取器,它内部维护了一个"读到哪了"的游标,每次调用只会返回下一块新数据。
核心机制:内部游标与"消费即丢弃"
ReadableStream 内部有一个队列(内部队列),数据从网络到达后,会按顺序排进这个队列里。
reader.read() 每次调用时,做的事情是:
- 从队列头部取出下一块数据(不是从头取)
- 把这块数据返回给你
- 内部指针自动往后移一位
数据一旦被 read()取走,就从队列里移除了,不会再出现第二次。 这就像排队买票------你买完票往前走,后面的人只能买下一张,不可能再买到你已经买走的那张。
第二块数据还没到时会发生什么?
这是你最关心的点。当第一块处理完,循环进入第二轮,调用 await reader.read() 时:
- 如果第二块数据已经到了(已经在内部队列里)→ 立刻返回,不等待
- 如果第二块数据还没到 → await reader.read() 这个 Promise 会挂起等待 ,代码暂停在这一行,不会往下执行,也不会回到循环顶部重新读
它不会空转,不会重复读,就是安静地等着。 等数据到了,Promise 自动 resolve,代码继续往下走。
十一、和普通 res.json() 的区别
普通接口:
javascript
const res = await fetch('/api/user');
const data = await res.json();
特点:
- 等完整响应;
- 一次性解析;
- 适合普通 CRUD 接口。
流式接口:
javascript
const res = await fetch('/api/stream');
const reader = res.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// 处理 value
}
特点:
- 不等完整响应;
- 来一块处理一块;
- 适合大模型流式输出、大文件下载、实时日志、边下边处理 等场景。
十二、一句话总结
fetch是用来发起 HTTP 请求并拿到 Response的; ReadableStream是用来把响应体当作数据流,按块持续读取的。
普通接口里,你通常只关心:
javascript
await res.json()
但在流式场景里,你不再一次性消费响应体,而是通过:
javascript
res.body.getReader()
逐块读取数据。
所以大模型流式返回常用 fetch + ReadableStream,不是因为 fetch本身能流式解析 JSON,而是因为 fetch的response.body暴露了一个可读流,让前端可以边接收、边解码、边解析、边渲染。