手把手教你用 fetch 读取 SSE 流,给 AI 聊天加上打字机效果

背景

上一篇文章 别再让用户干等了:用 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 流式数据的解析,然后通过队列,实现边生成边展示的"打字机"效果,避免用户等太久。

相关推荐
Csvn1 小时前
Tailwind 动态拼接类名失效?JIT 引擎正在"静态分析"你
前端
柳杉1 小时前
我用Threejs 搓了一个 3D 中国地图设计器,开箱即用
前端·three.js·数据可视化
DJ斯特拉1 小时前
Tlias智能学习辅助系统(前端部分)
前端·javascript·学习
码云数智-大飞1 小时前
Go Channel 详解:并发通信的正确姿势
前端·数据库·git
蜡台2 小时前
uni-indexed-list 之扩展组件实现城市列表带索引查询过滤功能
前端·vue.js·uniapp·uni-indexed
LaughingZhu2 小时前
Product Hunt 每日热榜 | 2026-06-16
前端·人工智能·经验分享·chatgpt·html
snow@li2 小时前
前端:构建工具(Vite / Webpack)的 文件指纹(File Hash) 机制 / 浏览器缓存控制
前端·webpack·哈希算法
ayqy贾杰2 小时前
SpaceX 收购 Cursor,马斯克花600亿美元买了个代码编辑器
前端·人工智能·机器学习
云飞云共享云桌面10 小时前
传统工作站 vs 云飞云共享云桌面:制造业设计云桌面选型深度对比
运维·服务器·前端·网络·3d·架构·制造