背景
上一篇文章 别再让用户干等了:用 Express + SSE 实现《红楼梦》AI 问答实时输出 中实现了将《红楼梦》AI 问答的接口转换成流式数据,并使用 SSE 接口返回给前端。本篇文章则是主要讲前端如何处理和接收 sse 流式数据,并将 AI 返回的答案最终展示给用户。
非流式接口版本
非流式的接口,前端需要等待后端把完整答案生成完,才能拿到,然后在页面一次性显示完整答案。
javascript
// 发请求 → 等待 → 拿到完整 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)是服务器向浏览器单向推送消息的协议:
vbnet
后端推送格式(文本流):
─────────────────────────────
event: start
data: {"question":"贾宝玉的外貌如何?"}
event: chunk // 一个 chunk 就是一小段文字
data: {"content":"贾"}
event: chunk
data: {"content":"宝玉"}
event: chunk
data: {"content":"面如秋月"}
event: done // 全部生成完毕
data: {"question":"...","answer":"贾宝玉面如秋月..."}
─────────────────────────────
fetch 如何接收 SSE 流?
javascript
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. 用 "\n\n" 切割出完整的事件块
let boundary = buffer.indexOf("\n\n");
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("\n\n");
}
if (done) break; // 流结束
}
}
流程图:

为什么需要 buffer 缓冲区?
网络传输是"碎片化"的,一个 SSE 事件可能被拆成多个 chunk 到达:
swift
chunk1: "event: chu"
chunk2: "nk\ndata: {\"content\":\"贾\"}\n\n"
buffer 的作用就是攒够一个完整事件再处理,避免解析半截数据。
请求时需要设置什么?
javascript
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 可能包含好几个字,不能一次性全显示,要拆开:
javascript
function splitIntoTypingUnits(text) {
// 规则:
// - 中文:2个字一组("贾宝" "玉面")
// - 英文/数字:连续的算一组("hello" "123")
// - 标点:单独一组("。" ",")
// - 空格:单独一组
// 示例:"贾宝玉面如秋月,色如春晓之花"
// 拆成 → ["贾宝", "玉面", "如秋", "月,", "色如", "春晓", "之花"]
}
为什么中文 2 字一组? 模拟真人打字节奏,一个字一个字太慢,一整句又太快。
getTypingDelay ------ 不同内容不同停顿
javascript
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 ------ 调度中心
javascript
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 流式数据的解析,然后通过队列,实现边生成边展示的"打字机"效果,避免用户等太久。