最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
手把手教你用 fetch 读取 SSE 流: 给 AI 聊天加上打字机效果
时间:2026-06-19 09:56:03 编辑:袖梨 来源:一聚教程网
背景
上一篇文章 别再让用户干等了:用 Express + SSE 实现《红楼梦》AI 问答实时输出 中实现了将《红楼梦》AI 问答的接口转换成流式数据,并使用 SSE 接口返回给前端。本篇文章则是主要讲前端如何处理和接收 sse 流式数据,并将 AI 返回的答案最终展示给用户。
非流式接口版本
非流式的接口,前端需要等待后端把完整答案生成完,才能拿到,然后在页面一次性显示完整答案。
复制代码// 发请求 → 等待 → 拿到完整 JSON → 显示
const response = await fetch("/hongloumeng/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: input }),
});// 必须等后端把完整答案生成完,才能拿到
const data = await response.json();// 一次性显示完整答案
messages.value.push({ role: "assistant", content: data.answer });
这样会产生一个问题,大语言模型生成一段长回答可能要 10 秒,用户盯着空白屏幕干等。
SSE 流式接收
什么是 SSE?
SSE(Server-Sent Events)是服务器向浏览器单向推送消息的协议:
复制代码后端推送格式(文本流):
─────────────────────────────
event: start
data: {"question":"贾宝玉的外貌如何?"}event: chunk // 一个 chunk 就是一小段文字
data: {"content":"贾"}event: chunk
data: {"content":"宝玉"}event: chunk
data: {"content":"面如秋月"}event: done // 全部生成完毕
data: {"question":"...","answer":"贾宝玉面如秋月..."}
─────────────────────────────
fetch 如何接收 SSE 流?
复制代码async function readStream(response, onEvent) {
// 1. 拿到响应体的"读取器"
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ""; // 缓冲区:存放"还没凑成完整事件"的碎片 // 2. 循环读取
while (true) {
const { value, done } = await reader.read();
// value 是 Uint8Array 二进制块,需要解码成文字
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done }); // 3. 用 "nn" 切割出完整的事件块
let boundary = buffer.indexOf("nn");
while (boundary !== -1) {
const block = buffer.slice(0, boundary).trim(); // 取出一个完整事件
buffer = buffer.slice(boundary + 2); // 剩下的留在缓冲区
if (block) {
const parsed = parseStreamEvent(block); // 解析 event + data
if (parsed) onEvent(parsed); // 回调给上层处理
}
boundary = buffer.indexOf("nn");
} if (done) break; // 流结束
}
}
流程图:

为什么需要 buffer 缓冲区?
网络传输是"碎片化"的,一个 SSE 事件可能被拆成多个 chunk 到达:
复制代码chunk1: "event: chu"
chunk2: "nkndata: {"content":"贾"}nn"
buffer 的作用就是攒够一个完整事件再处理,避免解析半截数据。
请求时需要设置什么?
复制代码const response = await fetch("/hongloumeng/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream", // 告诉服务器:我要 SSE 流
},
body: JSON.stringify({ question: input }),
});
注意 Accept: "text/event-stream" 这个请求头,告诉后端"我期望接收 SSE 格式的流式响应"。
createTypewriter:打字机效果的核心
为什么需要打字机?
后端推送 chunk 的速度是不均匀的——有时一秒推好几个字,有时好几秒没动静。如果直接显示,用户看到的是"一卡一卡蹦出文字"。
打字机的作用:把不均匀的推送,变成均匀的、有节奏的逐字显示。
整体架构

逐个函数解析
splitIntoTypingUnits —— 拆成"打字单位"
一个 chunk 可能包含好几个字,不能一次性全显示,要拆开:
复制代码function splitIntoTypingUnits(text) {
// 规则:
// - 中文:2个字一组("贾宝" "玉面")
// - 英文/数字:连续的算一组("hello" "123")
// - 标点:单独一组("。" ",")
// - 空格:单独一组
// 示例:"贾宝玉面如秋月,色如春晓之花"
// 拆成 → ["贾宝", "玉面", "如秋", "月,", "色如", "春晓", "之花"]
}
为什么中文 2 字一组? 模拟真人打字节奏,一个字一个字太慢,一整句又太快。
getTypingDelay —— 不同内容不同停顿
复制代码function getTypingDelay(unit) {
if (!unit.trim()) return 0; // 空格:不停
if (/[。!?!?]/) return 240; // 句末标点:长停顿(0.24秒)
if (/[,;:,;:]/) return 140; // 句中标点:中停顿(0.14秒)
if (/^[a-zA-Z0-9]/) return 55; // 英文/数字:快一点
if (中文>=2字) return 70; // 中文词组:稍慢
return 36; // 默认:0.036秒
}
效果:读到句号自然停顿,读到逗号短暂停顿,就像真人朗读一样。
createTypewriter —— 调度中心
复制代码function createTypewriter(message) {
const queue = []; // 待显示的"打字单位"队列
let active = true; // 是否还在工作
let finished = false; // 是否已标记结束
let pumping = false; // pump 是否正在运行(防重入) // 核心循环:从队列取内容,逐个显示
async function pump() {
if (pumping) return; // 防止多个 pump 同时跑
pumping = true; while (active) {
const nextUnit = queue.shift(); // 取出一个单位
if (!nextUnit) break; // 队列空了,暂停 message.content += nextUnit; // 追加到消息(Vue 自动更新页面)
scrollChatToBottom(); // 滚到底部
await sleep(getTypingDelay(nextUnit)); // 按节奏停顿
} pumping = false;
// 如果已标记结束且队列清空,通知 finish() 的 await
if (finished && queue.length === 0) {
resolveIdle();
}
} return {
// 外部调用:SSE chunk 到达时推入内容
push(text) {
if (!active || !text) return;
queue.push(...splitIntoTypingUnits(text)); // 拆分后入队
pump(); // 启动/继续消费
}, // 外部调用:SSE 流结束时调用,等待打字机播完
async finish(fallbackText) {
// 如果一个 chunk 都没收到,用 fallbackText 兜底
if (active && queue.length === 0 && !message.content && fallbackText) {
queue.push(...splitIntoTypingUnits(fallbackText));
pump();
}
finished = true;
pump();
await idlePromise; // 等队列消费完才 resolve
}, // 外部调用:出错或用户中断时,立即停止
stop() {
active = false;
queue.length = 0;
finished = true;
resolveIdle();
},
};
}
效果演示
总结
当后端接口改造为 SSE 流式接口后,前端可以使用 fetch + ReadableStream 实现 SSE 流式数据的解析,然后通过队列,实现边生成边展示的“打字机”效果,避免用户等太久。
相关文章
- 九牧之野:新手开荒阵容推荐 06-19
- 九牧之野关羽属性说明 06-19
- 崩坏星穹铁道4.3差分宇宙逆会心追击队打法攻略 06-19
- 九牧之野张辽属性说明 06-19
- 洛克王国世界棋绮后技能配置 06-19
- Llama开发者隐私风险:数据隔离与模型调用权限说明 06-19