Fetch 流式传输实战:为什么你的“打字机效果”其实在等全量响应
原文:https://dev.to/parsajiravand/your-fetch-already-streams-youre-buffering-it-anyway-33ib(作者 @parsajiravand)
你实现了一个聊天功能。回复应该有一种“活着”的感觉——文字逐字出现,仿佛模型在边思考边输出。你用一个 setInterval 设置每 20 毫秒从响应文本中显示一个字符,它确实生效了。刷新页面,发送一条消息,看着文字自己打出来。然后上线。
接着有人问,为什么“打字”效果要等服务器端完全生成回复后才开始?你打开网络面板,看到请求在整个生成期间都处于“pending”状态——三秒,五秒,无论完整答案花了多长时间——然后你那漂亮的打字机动画才开始,回放一个刚刚才写完的答案。
这个动画从未真正实现流式传输。它是一本事后绘制的翻页书。
显而易见的修复,以及为什么它不是修复
这段代码让你走到了这一步:
async function getReply(prompt) {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const { reply } = await res.json();
typewriter(reply); // 逐字符显示
}
它看起来像流式传输。字符随着时间推移按顺序出现,速度适中。但看看 await 在哪里:res.json() 在服务器发送完所有响应字节并且浏览器将其解析为对象之前,是不会 resolve 的。整个回复完整地存在于内存中,typewriter() 才会被调用。这个动画只是附加到一个从始至终都未曾分段的请求-响应周期上的装饰。
这就是陷阱:在一个快速连接上,一个假的打字机效果和一个真正的流式响应会产生完全相同的视觉结果。你无法通过观察最终页面来区分它们。只有通过观察第一像素的文字背后何时才有数据到达,才能区分——而到那时,一个五秒钟的 bug 已经存在于生产环境中了。
<!-- playground:start -->
亲手试试
[▶️ 打开交互式演练场 →](https://bestpractic.org/blog/fetch-already-streams-readablestream/playground)
_直接在你的浏览器中运行——动手操作,观察概念如何实时响应。_
<!-- playground:end -->
res.json() 在悄悄做什么
fetch() 并不会给你一个完整的字符串。它给你一个 Response 对象,其 body 属性是一个真实的、符合规范的 ReadableStream<Uint8Array>——一个原始字节块随时间从网络到达的流,就像平台上任何其他流一样。你可以在每个块到达的那一刻,直接逐块读取它。
res.json() 和 res.text() 是便捷的包装器。在底层,它们在同一个流上调用 getReader(),读取所有块直到流结束,连接字节,解码它们,然后才 resolve。它们并非在异步方面撒谎——它们确实会等待——但它们每次都等待整个主体,无论它有多大或多晚才到达。调用它们就是一次明确的选择来缓冲数据,即使你最初选择流式 API 正是为了避免这种行为。
自己读取块,就没有什么能强迫你在使用第一个块之前等待最后一个:
async function getReply(prompt) {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value 是一个 Uint8Array 块——现在就解码并显示它,而不是稍后
const text = decoder.decode(value, { stream: true });
appendToChat(text);
}
}
reader.read() 在一个块出现时就会 resolve,而不是等所有块都到齐。解码器上的 { stream: true } 标志比它看起来更重要:一个多字节的 UTF-8 字符可能会被分割在两个块中到达,没有这个标志,TextDecoder 会损坏它看到的那一半,而不是将其保留下来与剩余部分连接。现在,文字在服务器写入的瞬间就出现了,而你之前构建的“打字”动画是多余的——真实的到达速率就是动画。
那个让人栽一次的陷阱
一旦你调用 res.body.getReader(),主体就被锁定给那个读取器了。之后在同一个响应上调用 res.json() 或 res.text()——比如,因为某个共享的日志记录器会触及每个响应——会抛出一个 TypeError,因为流已经被(或正在被)消费。Response.bodyUsed 在任何东西开始消耗主体(无论是流式还是缓冲)的那一刻就会翻转为 true,而一个 Response 只能被读取一次。如果你需要原始文本并且需要流式处理,可以在操作任何一个副本之前使用 res.clone() 克隆响应。
API 之下的教训
这其实不是一个关于聊天界面的故事。它关于一种习惯:出于反射去调用便捷方法(.json(), .text(), .blob()),而没有注意到它悄悄地将一个随时间到达的东西,折叠成了一个一次性到达的东西。修复方法从来不是“添加一个动画来模拟恢复流式传输”——而是检查一个缓冲调用是否已经吞掉了你想要的流式处理,在你所处位置的上游。
自测一下
觉得理解了吗?[来完成这个 7 题的小测验 →](https://bestpractic.org/blog/fetch-already-streams-readablestream/quiz)
_即时反馈,每道题都有提示,无论对错都会提供解析。_
<!-- quiz:end -->
下次当你发现自己正在为一个“只是感觉需要等一下”的值编写进度条、打字效果或加载动画时——检查一下底层是否已经有某个东西知道了真实的时间节奏,而你只是没有去问它。你自己的代码中,上一次伪造一个其实已经存在的延迟是在哪里?
原文:https://dev.to/parsajiravand/your-fetch-already-streams-youre-buffering-it-anyway-33ib(作者 @parsajiravand)
