用 fetch + readablestream 可实现大模型“打字机效果”,核心是边接收 sse 或 ndjson 流、边解析 content 字段、边渲染;需正确处理编码、换行、json 容错及流锁释放。

用 fetch + ReadableStream 实现大模型的“打字机效果”,核心是边接收响应、边解析流、边渲染文本,而不是等整个响应完成再显示。关键在于处理 text/event-stream(SSE)或普通 chunked JSON 流,并逐块提取 content 字段。
确认后端返回的是可流式解析的格式
大模型 API(如 OpenAI、Ollama、自建 vLLM)通常支持 stream=true 参数,返回以换行分隔的 JSON 块(application/x-ndjson 或 text/event-stream)。前端必须匹配解析逻辑:
- OpenAI 官方 SDK 返回的是
text/event-stream,每行以data: {...}开头; - Ollama / vLLM 默认返回纯 JSON 行(每行一个完整 JSON 对象),无前缀;
- 若后端返回普通
Content-Type: application/json且不带Transfer-Encoding: chunked,则无法流式读取——必须确保服务端开启流式响应。
用 ReadableStream.getReader() 逐块读取并解码
Fetch 返回的 Response.body 是 ReadableStream,需用 getReader() 获取阅读器,配合 TextDecoder 处理 UTF-8 分块乱码问题:
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true })
});
if (!response.ok) throw new Error(response.statusText);
if (!response.body) throw new Error('ReadableStream not supported');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 将 Uint8Array 转为字符串,追加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 按行分割(兼容 SSE 和 NDJSON)
const lines = buffer.split(/\r\n|\n|\r/g);
// 保留最后一行(可能不完整)
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
// 若是 SSE:去掉 "data: " 前缀
let jsonStr = line.startsWith('data: ') ? line.slice(6) : line;
const chunk = JSON.parse(jsonStr);
// 提取 content(不同模型字段名不同:chunk.choices?.[0]?.delta?.content)
const text = chunk.choices?.[0]?.delta?.content || '';
if (text) {
appendToDisplay(text); // 渲染到页面,如 innerHTML += text
}
} catch (e) {
// 忽略解析失败的行(如 event: ping、id: xxx 等)
continue;
}
}
}
// 解析剩余 buffer(收尾)
if (buffer.trim()) {
try {
const chunk = JSON.parse(buffer);
const text = chunk.choices?.[0]?.delta?.content || '';
if (text) appendToDisplay(text);
} catch (e) {
// 可选:记录 warning
}
}
reader.releaseLock();
处理边界情况与用户体验优化
真实场景中需考虑中断、错误、防抖渲染、光标动画等:
-
取消请求:用
AbortController,调用reader.cancel()并releaseLock(); -
防重复渲染:避免高频
innerHTML +=触发重排,可用document.createTextNode()或textContent追加; -
模拟打字节奏:不直接追加,而是用
queueMicrotask或setTimeout(..., 0)让浏览器有空渲染,或按字符节流(但通常按 token 更合理); -
错误恢复:监听
reader.closed或捕获read()抛出的异常,做降级提示; -
光标效果:在容器末尾加一个
<span class="cursor">|</span>,用 CSS 动画控制闪烁。
简单封装成可复用的流式消费函数
把上述逻辑抽成函数,接受 URL、body、和一个回调处理每个 content 片段:
async function streamChat(url, options, onChunk) {
const controller = new AbortController();
const response = await fetch(url, {
...options,
signal: controller.signal
});
if (!response.body) throw new Error('No stream body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r\n|\n|\r/g);
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const jsonStr = line.startsWith('data: ') ? line.slice(6) : line;
const chunk = JSON.parse(jsonStr);
const text = chunk.choices?.[0]?.delta?.content;
if (text != null) onChunk(text);
} catch (e) {
// skip invalid lines
}
}
}
} finally {
reader.releaseLock();
}
}
// 使用示例
streamChat('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true })
}, (text) => {
document.getElementById('output').textContent += text;
});
不复杂但容易忽略细节:编码处理、换行符兼容、JSON 解析容错、流锁释放。只要后端真正流式输出,前端就能实现平滑的打字机效果。
大量免费API接口:立即使用
涵盖生活服务API、金融科技API、企业工商API、等相关的API接口服务。免费API接口可安全、合规地连接上下游,为数据API应用能力赋能!











