Fetch 的 response.body 到底特殊在哪:和 XMLHttpRequest 的流式能力边界
先给结论:
在「浏览器标准里的 HTTP 响应体」这个范围内,这句话基本成立:Fetch 的
Response.body是一个ReadableStream,而 XMLHttpRequest 没有直接暴露流对象的能力。
但这句话很容易被读成两个过强的版本,而它们都不对:
- 「XHR 完全不能边收边处理」——不对。XHR 在
responseType为text或空字符串时,readyState === 3(LOADING)期间responseText会不断增长,你确实可以「边收边处理」文本。只是它给你的是一个不断变长的字符串,不是流对象。 - 「只有 Fetch 能产生
ReadableStream」——不对。Blob.stream()、File.stream()、CompressionStream、TransformStream、ReadableStream构造器都能产生流。准确说法是:在 HTTP 响应体这个位置上,Fetch 是标准里唯一直接把 body 暴露成ReadableStream的 API。
这篇就把这句话拆开:Fetch 的 body 模型长什么样,XHR 到底暴露了什么,两者的边界在哪,以及我在 Chrome 里做的一组对照实测。
实测脚本与官方原文留档在 source/fetch-stream-lab/。环境:Chrome 156.0.8078.47、Node v24.14.1、macOS 15.7.3,本机回环,无代理 / CDN。
0. 先把一句话拆成三个词
原句里有三个关键词,每个都值得单独确认。
| 关键词 | 准确含义 | 容易误解成 |
|---|---|---|
| 只有 Fetch API | 在「HTTP 响应体」这个位置,标准 API 里只有 Fetch 的 Response.body 直接给流 | 「只有 Fetch 能创建 ReadableStream」 |
| 原生 | 浏览器内置 API 直接提供,不经过 polyfill / 手写适配器 | 「任何叫 fetch 的函数都算」 |
响应体为 ReadableStream | Response.body 的类型是 ReadableStream(或 null) | 「XHR 完全不能增量读」 |
把这三个词摆正之后,原句可以改写成一句更精确的话:
浏览器原生 API 中,Fetch 的
Response.body是唯一把 HTTP 响应体直接建模成ReadableStream的入口;XHR 只提供事件、累积文本和最终的整体响应,没有「响应体流对象」这一层。
下面逐层展开。
1. 先分清三种「流」
在浏览器里说「流」,至少有三个不同的东西,混在一起就会得出错误结论。
1.1 WHATWG ReadableStream
这是本文说的流:
const res = await fetch('/api');
const stream = res.body; // ReadableStream<Uint8Array> | null
const reader = stream.getReader();
const { value, done } = await reader.read(); // value: Uint8Array
它是 Streams 标准里的对象,特点是:
- 有
getReader(),可以逐 chunk 读; - 有内部队列和背压;
- 可以
pipeTo/pipeThrough; - 可以被
tee()分成两路; - 字节流还支持 BYOB(bring your own buffer)零拷贝读取。
1.2 Node.js 的 stream.Readable
Node 的 http.IncomingMessage、fs.createReadStream() 返回的是 Node 的 Readable,不是 WHATWG ReadableStream。两者 API 不同:
WHATWG ReadableStream | Node stream.Readable | |
|---|---|---|
| 读法 | getReader().read() | on('data') / async iterator |
| 背压 | 内建队列 + desiredSize | write() 返回值 / drain |
| 转换 | Readable.fromWeb() / toWeb() | 同名反向 API |
Node 18+ 的全局 fetch 返回的 response.body 是 WHATWG ReadableStream;如果你用 http.request,拿到的是 Node 流。两者可以互转,但不是同一个东西。这一点在写同构代码时特别容易踩。
1.3 「事件 + 累积数据」不是流
XHR 的 onprogress、responseText,以及 EventSource 的 onmessage,都属于「事件 + 累积数据」:
- 事件告诉你「又收到了多少」;
- 数据是一个不断增长的整体(
responseText)或一条条消息(SSE); - 你没有「从当前读取位置拿下一块字节」的 reader。
这不是流对象。它可以是流式效果,但不是流抽象。
把常见 API 摆在一起:
| API | 你拿到的东西 | 是 ReadableStream 吗 |
|---|---|---|
| Fetch | Response.body | 是(或 null) |
| XMLHttpRequest | responseText / response | 否 |
| EventSource | 一条条 message 事件 | 否 |
| WebSocket | 一条条 message 事件 | 否(WebSocketStream 是实验性提案,另算) |
Blob.stream() / File.stream() | ReadableStream | 是,但不是「网络响应体」 |
这张表就是原句的边界:「网络响应体 + 原生流对象」这一格,只有 Fetch。
2. Fetch API 的 body 模型
2.1 Request 和 Response 都有 body
Fetch 不只把响应建模成流,请求也可以:
Request.body:请求体流;Response.body:响应体流。
MDN 对 Request.body 的原话:
【MDN 原文】"The body read-only property of the Request interface contains a ReadableStream with the body contents that have been added to the request. Note that a request using the GET or HEAD method cannot have a body and null is returned in these cases." —— MDN · Request.body(抓取日期 2026-10-23,
curl,HTTP 200)
对 Response.body:
【MDN 原文】"The body read-only property of the Response interface is a ReadableStream of the body contents."
【MDN 原文】"A ReadableStream, or else null for any Response object constructed with a null body property, or for any actual HTTP response that has no body." —— MDN · Response.body(抓取日期 2026-10-23,
curl,HTTP 200)
这两句话决定了 Fetch 的能力边界:
- 有 body 时,你拿到的是一个真正的流对象;
- 没有 body 时,它是
null,不是「空流」; - GET / HEAD 请求不能有请求体,
Request.body为null。
2.2 它是一条「字节流」
Response.body 给的是 Uint8Array 的 chunk,不是字符串。字节流有两个直接后果:
- 你不能假设 chunk 边界等于字符边界。一个 UTF-8 汉字可能被切成两半;直接
TextDecoder不带stream: true会得到�。 - 你可以零拷贝读取。MDN 说它支持
ReadableStreamBYOBReader:
【MDN 原文】"The stream is a readable byte stream, which supports zero-copy reading using a ReadableStreamBYOBReader." —— 同上
BYOB 的用法是「自带缓冲区」:
const reader = res.body.getReader({ mode: 'byob' });
const buf = new Uint8Array(64 * 1024);
const { value, done } = await reader.read(buf);
// value 是写入了数据的 Uint8Array 视图
普通 getReader() 每次返回一个新的 Uint8Array;BYOB 让你复用同一块内存,适合大文件、高频读取。普通业务代码用默认 reader 就够了,不必为了「显得高级」上 BYOB。
2.3 bodyUsed:body 是一次性的
Response.body 不是可以反复读的数组。它是一次性的流:
const res = await fetch('/api');
console.log(res.bodyUsed); // false
await res.text();
console.log(res.bodyUsed); // true
MDN 对 bodyUsed 的定义:
【MDN 原文】"The bodyUsed read-only property of the Response interface is a boolean value that indicates whether the body has been read yet." —— MDN · Response.bodyUsed(抓取日期 2026-10-23,
curl,HTTP 200)
一旦读过,再调用 .text() / .json() / .arrayBuffer() 会抛 TypeError。如果你确实要读两遍,用 response.clone()。
2.4 clone() 是 tee(),不是免费复制
response.clone() 的语义是 ReadableStream.tee():把一条流分成两条。MDN 对背压的说明很关键:
【MDN 原文】"Like the underlying ReadableStream.tee api, the body of a cloned Response will signal backpressure at the rate of the faster consumer of the two bodies, and unread data is enqueued internally on the slower consumed body without any limit or backpressure."
【MDN 原文】"If only one cloned branch is consumed, then the entire body will be buffered in memory." —— MDN · Response.clone(抓取日期 2026-10-23,
curl,HTTP 200)
翻成实践建议:
- 两个分支都读、速度差不多:可以用
clone(); - 只读一个分支:另一个分支会把整个 body 缓冲在内存里,大文件会炸;
- 想「先看一眼再交给下游」:更好的做法是读一次,把需要的部分缓存下来,而不是 clone 后放着不读。
2.5 读流的标准姿势
最小、最稳的写法:
const res = await fetch('/api/stream', {
headers: { Accept: 'text/event-stream' }
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
}
if (!res.body) {
throw new Error('响应没有 body');
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 关键:stream: true,保留被切半的多字节字符
buf += decoder.decode(value, { stream: true });
// 按业务边界切分(SSE 是 \n\n,NDJSON 是 \n)
let sep;
while ((sep = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, sep);
buf = buf.slice(sep + 2);
handleFrame(frame);
}
}
这里有四个容易漏的点:
-
res.body可能为null,先判空; -
getReader()会锁住流,同一时间只能有一个 reader:【MDN 原文】"The getReader() method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released." —— MDN · ReadableStream.getReader(抓取日期 2026-10-23,
curl,HTTP 200) -
TextDecoder要带{ stream: true }; -
read()返回的 chunk 与业务消息边界无关,必须自己缓冲。
2.6 用 TextDecoderStream 把解码做成管道
如果你喜欢管道风格,可以不用手写 TextDecoder:
【MDN 原文】"The TextDecoderStream interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. It is the streaming equivalent of TextDecoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods." —— MDN · TextDecoderStream(抓取日期 2026-10-23,
curl,HTTP 200)
const textStream = res.body.pipeThrough(new TextDecoderStream());
const reader = textStream.getReader();
while (true) {
const { done, value } = await reader.read(); // value: string
if (done) break;
// value 仍然可能只是一段文本,不等于一整条消息
}
注意:TextDecoderStream 解决的是「字节 → 字符串」,不解决「字符串 → 消息」。你仍然需要按 \n\n 或 \n 做业务分帧。
2.7 背压:Fetch 流不是「越快越好」
Streams 的背压定义:
【MDN 原文】"An important concept in streams is backpressure — this is the process by which a single stream or a pipe chain regulates the speed of reading/writing. When a stream later in the chain is still busy and isn't yet ready to accept more chunks, it sends a signal backwards through the chain to tell earlier transform streams (or the original source) to slow down delivery so that you don't end up with a bottleneck anywhere." —— MDN · Streams API concepts(抓取日期 2026-10-23,
curl,HTTP 200)
这对服务端也是压力信号:客户端读得慢,浏览器会把未读数据放进流的内部队列;队列压力增大时,浏览器可能减慢从网络读取的速度(具体策略取决于实现)。XHR 没有这套东西——responseText 只会一直涨,内存也一直涨。
2.8 body 什么时候是 null
按规范和文档,以下情况 Response.body 可能是 null:
HEAD请求;204 No Content、205 Reset Content、304 Not Modified;no-cors的 opaque response;- 你用
new Response(null)构造的响应。
opaque 的定义很明确:
【MDN 原文】"A response to a cross-origin request whose mode was set to no-cors. The status property is set to 0, body is null, headers are empty and immutable." —— MDN · Response.type(抓取日期 2026-10-23,
curl,HTTP 200)
但这里有一个规范与实现的偏差,MDN 自己就标注了:
【MDN 原文】"Current browsers don't actually conform to the spec requirement to set the body property to null for responses with no body (for example, responses to HEAD requests, or 204 No Content responses)." —— MDN · Response.body
我在 Chrome 里实测 fetch('/empty')(204)得到的 response.body 也是一个 ReadableStream,不是 null。所以:
不要用
response.body === null来判断「有没有内容」;用response.status和Content-Length/Content-Type判断。
3. XMLHttpRequest 到底暴露了什么
3.1 它是事件驱动的,不是流驱动的
XHR 的核心是状态机和事件:
readyState | 名称 | 含义 |
|---|---|---|
| 0 | UNSENT | 还没 open() |
| 1 | OPENED | 调用了 open() |
| 2 | HEADERS_RECEIVED | 收到响应头 |
| 3 | LOADING | 响应体正在接收 |
| 4 | DONE | 完成 / 失败 |
你可以监听 readystatechange、progress、load、error、abort、timeout、loadend。但这些事件给你的是「进度」和「累积结果」,不是一个可以 read() 的流。
3.2 responseType 的合法值里没有 stream
MDN 列出的合法值:
【MDN 原文】
"":等价于"text";"arraybuffer":ArrayBuffer;"blob":Blob;"document":Document;"json":解析后的对象;"text":字符串。 —— MDN · XMLHttpRequest.responseType(抓取日期 2026-10-23,curl,HTTP 200)
XHR 规范里的 IDL 枚举也是这六个:
enum XMLHttpRequestResponseType {
"",
"arraybuffer",
"blob",
"document",
"json",
"text"
};
没有 "stream"。
我在 Chrome 里试了:
const xhr = new XMLHttpRequest();
xhr.open('GET', '/stream');
xhr.responseType = 'stream';
console.log(xhr.responseType); // ""
赋值没有抛错,但 responseType 仍然是空字符串——等价于 text,拿不到流。至少可以确定:它不会给你一个 ReadableStream。
3.3 LOADING 阶段:文本有,二进制没有
这是 XHR 最容易被误读的地方。MDN 对 response 的说明非常精确:
【MDN 原文】"The value is null if the request is not yet complete or was unsuccessful, with the exception that when reading text data using a responseType of "text" or the empty string (""), the response can contain the response so far while the request is still in the LOADING readyState (3)." —— MDN · XMLHttpRequest.response(抓取日期 2026-10-23,
curl,HTTP 200)
XHR 规范的 response getter 写得更直白:
1. If this’s response type is the empty string or "text":
1. If this’s state is not loading or done, then return the empty
string.
2. Return the result of getting a text response for this.
2. If this’s state is not done, then return null.
拆开:
responseType是text或"":LOADING时response/responseText是已经收到的文本;- 其他类型(
arraybuffer/blob/document/json):LOADING时response一律是null,必须等DONE。
MDN 对 readyState 的 LOADING 也写了同一件事:
【MDN 原文】"Response's body is being received. If responseType is "text" or empty string, responseText will have the partial text response as it loads." —— MDN · XMLHttpRequest.readyState(抓取日期 2026-10-23,
curl,HTTP 200)
所以:
- 文本:可以边收边读,但读到的是一个不断变长的字符串;
- 二进制:不能边收边读,只能等完整响应。
3.4 progress 事件里没有 chunk
progress 事件看起来像「流式」,但它只告诉你进度:
interface ProgressEvent : Event {
readonly attribute boolean lengthComputable;
readonly attribute double loaded;
readonly attribute double total;
};
没有 chunk、没有 data、没有 bytes。你可以知道「已经收了 32KB」,但拿不到「这 32KB 里新增的那 4KB」——除非你从 responseText 的长度差里自己切。
3.5 文本增量读取的经典写法
如果你只有 XHR,又需要处理增量文本,可以这样做:
const xhr = new XMLHttpRequest();
xhr.open('GET', '/stream');
xhr.responseType = 'text';
let offset = 0;
xhr.onprogress = () => {
const text = xhr.responseText;
if (text.length > offset) {
const chunk = text.slice(offset);
offset = text.length;
handleChunk(chunk);
}
};
xhr.onload = () => {
// 处理最后一段
if (xhr.responseText.length > offset) {
handleChunk(xhr.responseText.slice(offset));
}
};
xhr.send();
这段代码能跑,但有五个硬限制:
- 只能文本。二进制必须
arraybuffer/blob,而那两者在 LOADING 阶段是null。 - 没有背压。
responseText只会越来越长,浏览器不会因为你处理得慢就让服务端慢下来。 - 内存是 O(n)。整个响应会一直留在
responseText里;大文件会持续占内存。 - 分帧不可靠。
onprogress的触发节奏由浏览器决定,可能合并、可能延迟;它不保证「一次 progress = 一个网络 chunk」。 - 编码已被解码。你拿到的是字符串,不是原始字节;如果服务端不是 UTF-8,或者你想做二进制处理,这条路走不通。
我在实测里还观察到一个更细的点:小响应可能被浏览器缓冲到结束才触发 LOADING。用 8 字节一个 chunk 的流时,XHR 的 LOADING 一次性拿到了全部 48 字节;换成 16KB 一个 chunk,才看到 responseText 逐步增长。也就是说,XHR 的「增量文本」不是协议保证的节奏,而是浏览器缓冲策略的副产物。
3.6 Blob.stream():下载完之后可以拿到流
XHR 设 responseType = 'blob',DONE 之后拿到一个 Blob,它确实有 .stream():
【MDN 原文】"The stream() method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the blob." —— MDN · Blob.stream(抓取日期 2026-10-23,
curl,HTTP 200)
但注意时序:
XHR 请求开始
→ LOADING:xhr.response === null
→ DONE:xhr.response 是 Blob
→ 这时才能 blob.stream()
这不是「响应体的流」,而是「已经完整下载到内存/磁盘的 Blob 的流」。它解决的是「后续处理想用流 API」,不解决「边下载边处理」。
3.7 手写一个 XHR → ReadableStream 适配器
你当然可以把 XHR 包成一个 ReadableStream:
function xhrToReadableStream(url) {
let xhr;
return new ReadableStream({
start(controller) {
xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.responseType = 'text';
let offset = 0;
xhr.onprogress = () => {
const text = xhr.responseText;
if (text.length > offset) {
const chunk = text.slice(offset);
offset = text.length;
controller.enqueue(new TextEncoder().encode(chunk));
}
};
xhr.onload = () => controller.close();
xhr.onerror = () => controller.error(new Error('XHR error'));
xhr.onabort = () => controller.error(new DOMException('Aborted', 'AbortError'));
xhr.send();
},
cancel() {
xhr?.abort();
}
});
}
这个适配器有用,但要清楚它的本质:它是用户态把「累积文本」切成块,再伪装成流。它没有解决:
- 二进制原始字节(
responseText已经解码); - 背压(
controller.enqueue不会让 XHR 慢下来); - 内存(
responseText仍然保留全文); - 分帧可靠性(progress 的触发节奏仍由浏览器决定)。
所以它是「兼容层」,不是「XHR 原生流」。
3.8 Axios / jQuery 也绕不开 XHR
浏览器里的 Axios(默认 XHR 适配器)和 jQuery $.ajax 都建立在 XHR 之上:
- 它们能给你
onDownloadProgress/progress事件; - 它们不会给你
response.body这样的ReadableStream; responseType: 'stream'通常只在 Node 适配器下有意义(这一条是经验推断,未做浏览器矩阵实测)。
如果你在浏览器里看到「Axios 流式下载」的文章,先确认它用的是不是 onDownloadProgress,或者是不是在 Node 里跑。进度事件 ≠ 响应体流。
4. 实测对照:同一台机器上的 Fetch 与 XHR
测试服务器用 Node 原生 http 写,按固定间隔 res.write():
/stream:每次 8 字节文本,间隔 200ms,共 6 次;/bigstream:每次约 16KB 文本,间隔 200ms,共 6 次;/bigbinary:每次 16KB 二进制,间隔 200ms,共 6 次;/empty:204。
客户端用 Chrome headless + CDP 驱动,结果如下。
4.1 Fetch:每次 read() 对应一次到达
fetch: status 200 body ctor ReadableStream bodyUsed false
fetch: body instanceof ReadableStream true
fetch: read#1 at 206ms bytes 8 text "chunk-0\n"
fetch: read#2 at 405ms bytes 8 text "chunk-1\n"
fetch: read#3 at 604ms bytes 8 text "chunk-2\n"
fetch: read#4 at 806ms bytes 8 text "chunk-3\n"
fetch: read#5 at 1005ms bytes 8 text "chunk-4\n"
fetch: read#6 at 1207ms bytes 8 text "chunk-5\n"
fetch: done at 1407ms reads 6 bodyUsed true
6 次 read(),每次 8 字节,间隔约 200ms,和服务器写出的节奏一致。body 是 ReadableStream,读完后 bodyUsed 变成 true。
4.2 XHR 文本:responseText 在增长,但没有 chunk
xhr-text: state 3 at 1612ms responseText.len 3856
xhr-text: state 3 at 1663ms responseText.len 16393
xhr-text: state 3 at 1812ms responseText.len 32786
xhr-text: state 3 at 2012ms responseText.len 49179
xhr-text: state 3 at 2213ms responseText.len 65572
xhr-text: state 3 at 2414ms responseText.len 81965
xhr-text: state 3 at 2613ms responseText.len 98358
xhr-text: state 4 at 2814ms responseText.len 98358
它确实在增量增长,但:
- 你拿到的是
responseText的总长; onprogress的loaded也是总字节数;- 没有「这一块的数据」字段;
- 要拿到新增内容,必须自己做
slice(offset)。
4.3 XHR 二进制:LOADING 阶段 response 始终是 null
xhr-arraybuffer: state 3 at 3016ms response is null true
xhr-arraybuffer: progress at 3016ms loaded 3857
xhr-arraybuffer: state 3 at 3066ms response is null true
xhr-arraybuffer: progress at 3066ms loaded 16384
...
xhr-arraybuffer: state 4 at 4218ms response is null false response ctor ArrayBuffer
xhr-arraybuffer: onload at 4219ms byteLength 98304
进度一直在涨,但 xhr.response 在 LOADING 阶段始终是 null;直到 DONE 才一次性拿到 98304 字节的 ArrayBuffer。这就是「二进制没有增量视图」的直接证据。
4.4 XHR Blob:Blob.stream() 只在 DONE 之后存在
xhr-blob: state 3 ... response is null true
...
xhr-blob: state 4 ... response is null false response ctor Blob
xhr-blob: onload ... blob? true size 98358 has stream() function
xhr-blob: Blob.stream() read after DONE, text len 98358
blob.stream() 是可用的,但它是「下载完成后的 Blob 的流」,不是「网络响应的流」。
4.5 XHR responseType = 'stream':没有这个能力
xhr-stream: set responseType=stream OK, actual=
在 Chrome 里赋值不报错,但值仍是空字符串,等价于 text。没有 ReadableStream。
4.6 Fetch 204:Chrome 给了流,而不是 null
fetch-204: status 204 body is null false body ctor ReadableStream
这印证了 MDN 的那条 Note:浏览器并没有完全按规范把无 body 响应设为 null。所以不要用 body === null 判断「没有内容」。
4.7 一句话总结这组实验
| 能力 | Fetch | XHR |
|---|---|---|
| 拿到响应体流对象 | ✅ ReadableStream | ❌ |
| 逐 chunk 读文本 | ✅ | ⚠️ 只能读累积的 responseText |
| 逐 chunk 读二进制 | ✅ | ❌ LOADING 阶段 response 为 null |
| 背压 | ✅ | ❌ |
| 取消 | ✅ AbortController / reader.cancel() | ✅ xhr.abort() |
| 下载完成后拿流 | ✅(本来就是流) | ✅ Blob.stream() |
| 进度事件 | ⚠️ 需要自己按 chunk 统计 | ✅ progress 的 loaded / total |
5. 为什么会有这个差异
这不是「Fetch 比 XHR 新,所以更强」这么简单,而是两个 API 的设计目标不同。
XHR 的形状是「一次请求,一次响应」:
- 状态机 + 事件;
responseText/response是「到目前为止的结果」;- 浏览器可以在内部缓冲、合并、延迟;
- 它没有把「读取位置」暴露给作者。
Fetch 的形状是「请求 / 响应都是可组合的 body」:
Request和Response都有ReadableStream;- 流可以被
pipeThrough、tee、clone; - 读取位置由 reader 持有;
- 背压可以沿着管道往上传。
这也是为什么 Fetch 能自然支撑 Service Worker、流式 SSR、渐进式 JSON 解析、大文件下载这些场景,而 XHR 只能通过「累积文本 + 自己切」来模拟。
再强调一次:这不是速度差异。两者最终都走 HTTP,网络层的吞吐不会因为 API 名字而改变。差异在抽象:你能不能拿到「当前这一块字节」,以及能不能把「读得慢」反馈给上游。
6. 实践:什么时候用 Fetch,什么时候 XHR 够用
6.1 选型表
| 场景 | 建议 | 原因 |
|---|---|---|
| SSE / LLM token 流 | Fetch | 需要逐帧读文本;XHR 的 responseText 不是帧 |
| NDJSON / JSONL 渐进解析 | Fetch | 需要按行缓冲、边收边解析 |
| 大文件下载 + 边下边写盘 | Fetch | 需要字节流 + 背压;可接 File System Access / OPFS |
| 二进制协议 / 自定义帧 | Fetch | XHR 在 LOADING 阶段拿不到二进制 |
| 只要一个下载进度条 | XHR 也行 | progress.loaded / total 足够;Fetch 需要自己统计 |
| 表单上传 + 上传进度 | XHR 更直接 | xhr.upload.onprogress;Fetch 的请求流支持有限 |
| 老浏览器 / 老代码 | XHR | Fetch 流式能力依赖 Streams API |
| Node 端 | Fetch 或 http | Fetch 给 Web Stream;http 给 Node Stream,可互转 |
6.2 一个按行读取的 Fetch 工具
export async function* readLines(response) {
if (!response.body) throw new Error('响应没有 body');
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buf = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += value;
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).replace(/\r$/, '');
buf = buf.slice(nl + 1);
if (line) yield line;
}
}
if (buf.trim()) yield buf;
} finally {
// 消费方提前 break 时取消上游,避免连接继续挂着
try { await reader.cancel(); } catch {}
reader.releaseLock();
}
}
用法:
const res = await fetch('/api/ndjson');
for await (const line of readLines(res)) {
const obj = JSON.parse(line);
// ...
}
这个工具做对了三件事:
TextDecoderStream处理多字节字符被切半;- 跨 chunk 保留未完成的行;
- 流结束/异常时释放 reader。
6.3 Node 端:Web Stream 与 Node Stream 的转换
Node 18+ 的 fetch:
const res = await fetch(url);
// res.body 是 WHATWG ReadableStream
const nodeReadable = Readable.fromWeb(res.body);
nodeReadable.pipe(createWriteStream('out.bin'));
反过来,Node 的 http.IncomingMessage 是 Node 流,可以转成 Web Stream:
import { Readable } from 'node:stream';
const webStream = Readable.toWeb(req);
Node 文档列出的 API:
stream.Readable.fromWeb(readableStream[, options])
stream.Readable.toWeb(streamReadable[, options])
如果你在写同构代码,不要假设 response.body 在 Node 里也是 Node 流。它是 Web 流;要 pipe 到文件,先 Readable.fromWeb。
7. 常见误区与坑
7.1 「XHR 完全不能流式」
不对。XHR 在 text / "" 下可以边收边读 responseText。但它是文本累积,不是流对象;二进制则完全不行。
7.2 「onprogress 的 event 里有 chunk」
没有。ProgressEvent 只有 lengthComputable / loaded / total。要拿新增内容,只能 diff responseText。
7.3 「responseType = 'stream' 能拿到流」
不在标准枚举里。Chrome 实测会把它当成空字符串(等价 text),不会给你 ReadableStream。不要依赖这个写法。
7.4 「Blob.stream() 等于响应流」
不等于。它是下载完成后的 Blob 的流;在 XHR 里,你必须先等 DONE。
7.5 「response.body 总是存在」
不。HEAD / 204 / opaque 等场景可能是 null;而且浏览器对「无 body」的实现并不完全一致(Chrome 的 204 仍给了流)。用 status / headers 判断,不要用 body 是否为 null 判断。
7.6 「response.body 的 chunk 是完整消息」
不。chunk 是任意字节片段,可能切在 JSON 中间、UTF-8 汉字中间,甚至一个 emoji 的 4 个字节中间。必须缓冲 + 分帧。
7.7 「getReader() 可以开多个」
一个流同一时间只能有一个 reader。要并发处理,用 tee() 分成两条流,或者读一次后自己分发。
7.8 「clone() 是免费复制」
不是。tee() 的慢分支会无限缓冲;只读一个分支时,另一个分支会把整个 body 留在内存里。
7.9 「Fetch polyfill 也有 response.body」
whatwg-fetch 这类 polyfill 建立在 XHR 上:它在 xhr.onload 之后才构造 Response,没有真正的网络流。用它时,response.body 要么不存在,要么不是「边下边读」的流。需要真流式,就不能只靠 polyfill。
7.10 「Fetch 一定比 XHR 快」
不一定。两者都走 HTTP;Fetch 的优势是流式抽象、组合能力、Service Worker 集成,不是「协议更快」。
7.11 「Node 的 response.body 是 Node 流」
不是。Node 18+ 的 fetch 返回 WHATWG ReadableStream;Node 的 http 返回 stream.Readable。用 Readable.fromWeb / toWeb 转换。
7.12 「HTTP/2 或压缩不影响流式」
会。代理缓冲、压缩中间件、Content-Encoding 都可能把流攒成一批。SSE 场景要设置 X-Accel-Buffering: no、Cache-Control: no-transform,并确认压缩中间件跳过 text/event-stream。这些在上一篇 SSE 全链路里已经展开。
8. 把结论说精确
现在可以给出一句更严谨的版本:
在浏览器原生 API 中,Fetch 的
Response.body是唯一把 HTTP 响应体直接暴露为ReadableStream的入口。XMLHttpRequest 没有「响应体流对象」:它在文本模式下能通过responseText做增量读取,在二进制模式下必须等DONE;下载完成后可以用Blob.stream()得到流,但那是完整响应的流,不是网络响应的流。
再压缩成三句话:
- 要 live bytes / 背压 / 二进制逐块读:用 Fetch,别绕。
- 只要进度百分比:XHR 的
progress够用,而且兼容性更好。 - 看到「XHR 流式」:先问它是
responseTextdiff、Blob.stream(),还是手写适配器——它们都不是原生响应流。
9. 参考资料与实测文件
9.1 一手来源(抓取日期 2026-10-23,curl,HTTP 200)
| 资料 | URL | 用途 |
|---|---|---|
| MDN · Response.body | developer.mozilla.org/.../Response/body | ReadableStream / null / BYOB |
| MDN · Request.body | developer.mozilla.org/.../Request/body | 请求体也是流 |
| MDN · Response.clone | developer.mozilla.org/.../Response/clone | tee / 背压 / 内存 |
| MDN · Response.type | developer.mozilla.org/.../Response/type | opaque body 为 null |
| MDN · XHR responseType | developer.mozilla.org/.../XMLHttpRequest/responseType | 合法枚举,没有 stream |
| MDN · XHR response | developer.mozilla.org/.../XMLHttpRequest/response | LOADING 阶段返回什么 |
| MDN · XHR readyState | developer.mozilla.org/.../XMLHttpRequest/readyState | LOADING 的定义 |
| WHATWG XHR Standard | xhr.spec.whatwg.org | responseType 枚举 / response getter / ProgressEvent |
| MDN · Streams concepts | developer.mozilla.org/.../Streams_API/Concepts | 背压定义 |
| MDN · ReadableStream.getReader | developer.mozilla.org/.../ReadableStream/getReader | reader 锁 |
| MDN · TextDecoderStream | developer.mozilla.org/.../TextDecoderStream | 流式解码 |
| MDN · Blob.stream | developer.mozilla.org/.../Blob/stream | 下载后的流 |
| Node.js Stream | nodejs.org/api/stream.html | fromWeb / toWeb |
原文留档在 source/fetch-stream-lab/official/,逐字引用与抓取命令见 source/fetch-stream-lab/official-sources.md。
9.2 实测文件
source/fetch-stream-lab/server.mjs:测试服务器(含四个端点)source/fetch-stream-lab/cdp.mjs:Chrome DevTools Protocol 结果收集source/fetch-stream-lab/lab.log:本文引用的真实输出- 环境:Chrome
156.0.8078.47、Nodev24.14.1、macOS15.7.3
9.3 局限
- XHR 的
responseText增量节奏依赖浏览器缓冲策略;本文只测了 Chrome,Firefox / Safari 可能不同。 responseType = 'stream'的行为是 Chrome 实测;其他浏览器可能抛错或静默忽略,但都不在标准枚举里。- 没有测试真实网络、代理、CDN、HTTP/2;这些因素会影响「流是否被缓冲」,但不改变「Fetch 有流对象、XHR 没有」这个 API 层结论。
fetch的请求体流式上传(duplex: 'half')支持度有限,本文没有展开;它属于「请求体也是流」的另一条线。
