fetch+readablestream(Streamable)流式输出

流程图

代码

data字段

javascript 复制代码
data() {
  return {
    transcript: "",
    answerBuffer: "",
    thinkingBuffer: "",
    thinkingCompleteMarkdown: "",
    answerCompleteMarkdown: "",
    isThinking: false,
    renderTimer: null,
    // 中断控制器,用于取消上一次流式请求
    streamAbortController: null,
    chats: [],
    historyId: "",
    uploadImgUrl: "",
    textarea: "",
    textareaHeight: 3,
    // 防止短时间多次强制渲染
    isForceRendering: false,
  };
}

sendMessage发送消息

javascript 复制代码
sendMessage() {
  // ===== 新增:终止上一条还在跑的流式请求 =====
  if (this.streamAbortController) {
    this.streamAbortController.abort();
    this.streamAbortController = null;
  }
  // 清理上一轮定时器
  clearTimeout(this.renderTimer);
  this.renderTimer = null;

  // 重置流式缓存变量
  this.transcript = "";
  this.answerBuffer = "";
  this.thinkingBuffer = "";
  this.thinkingCompleteMarkdown = "";
  this.answerCompleteMarkdown = "";
  this.isForceRendering = false;

  let id = this.chats.length;
  if (this.isTextEmpty) {
    this.$message.error("还没说呢");
    return;
  } else {
    if (this.uploadImgUrl !== "") {
      this.sendImg();
    } else {
      let message = {
        id: id,
        type: "user",
        avatar: this.$store.state.avatarImageUrl,
        thinking: "",
        answer: "",
        content: this.textarea,
        imgUrl: "",
        modules: [],
      };

      const aiMessage = {
        id: id + 1,
        type: "assistant",
        avatar:
          "https://oss-mtc.oss-cn-hangzhou.aliyuncs.com/2c927a761478a4a4248706b534bac146.gif",
        thinking: "",
        answer: "",
        content: "",
        imgUrl: "",
        modules: [],
      };

      this.chats.push(message);
      this.isThinking = true;
      this.$nextTick(() => {
        const chatContainer = this.$refs.cultureBox;
        if (chatContainer) {
          chatContainer.scrollTop = chatContainer.scrollHeight;
        }
      });

      const url = `${this.$baseUrl}agent/chat`;
      const chatAto = {
        message: message.content,
        historyId: this.historyId,
      };

      this.fetchData2(url, chatAto, aiMessage);

      setTimeout(() => {
        this.getHistoryId();
      }, 500);

      this.textarea = "";
      this.transcript = "";
      this.textareaHeight = 3;
      this.uploadImgUrl = "";
    }
  }
},

fetchData2包装请求

javascript 复制代码
async fetchData2(url, data, aiMessage) {
  return this.fetchDataCommon(url, data, aiMessage, {
    "Content-Type": "application/json",
  });
},

fetchDataCommon发送请求

javascript 复制代码
async fetchDataCommon(url, data, aiMessage, headers) {
  let isStreamComplete = false;
  // 创建本次请求的中断控制器
  const abortController = new AbortController();
  this.streamAbortController = abortController;

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        ...headers,
        token: `${this.$store.state.token}`,
      },
      body:
        typeof data === "object" && !(data instanceof FormData)
          ? JSON.stringify(data)
          : data,
      signal: abortController.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    if (!response.body) {
      throw new Error("当前环境不支持 ReadableStream");
    }

    const reader = response.body.getReader();
    const decoder = new TextDecoder("utf-8");
    let buffer = "";
    let currentEvent = { name: null, data: "" };

    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        // 处理缓存里最后一条事件
        if (currentEvent.name) {
          this.handleEvent(currentEvent, aiMessage);
        }
        isStreamComplete = true;
        break;
      }
      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop();
      for (const line of lines) {
        if (line.trim() === "") {
          if (currentEvent.name) {
            this.handleEvent(currentEvent, aiMessage);
            currentEvent = { name: null, data: "" };
          }
        } else if (line.startsWith("event:")) {
          currentEvent.name = line.split(":")[1].trim();
        } else if (line.startsWith("data:")) {
          currentEvent.data += line.slice(5);
        }
      }
    }

    if (isStreamComplete) {
      // ✅ 流全部结束:强制最后渲染一次,清理资源
      clearTimeout(this.renderTimer);
      this.renderMarkdown("思考");
      this.renderMarkdown("答案");

      // 缓冲区统一清空(重点:不再在render里清空)
      this.thinkingBuffer = "";
      this.answerBuffer = "";
      this.streamAbortController = null;
      this.isThinking = false;

      this.getHistory();
    }
  } catch (error) {
    // 区分:手动abort 和 真实报错
    if (error.name === "AbortError") {
      console.log("流式请求被手动终止");
    } else {
      console.error("请求出错:", error);
      this.$message.error("回答异常中断,请稍后重试!");
    }
    this.isThinking = false;
    this.streamAbortController = null;
  }
},

handleEvent处理分片

javascript 复制代码
handleEvent(event, aiMessage) {
  if (this.isThinking) {
    this.isThinking = false;
    this.chats.push(aiMessage);
  }

  const handleEventData = (completeMarkdownKey, bufferKey, eventName) => {
    // 1. 处理换行归一
    const normalizedData = event.data
      .replace(/\\n/g, "\n")
      .replace(/\r?\n\r?\n/g, "\n\n");

    // 2. 追加完整文本 和 buffer
    this[completeMarkdownKey] += normalizedData;
    this[bufferKey] += normalizedData;

    // 3. 防抖渲染
    clearTimeout(this.renderTimer);
    this.renderTimer = setTimeout(() => {
      this.renderMarkdown(eventName);
    }, 100);

    // 4. 满足强制渲染标记,立刻渲染(增加锁防止重复调用)
    if (!this.isForceRendering && this.shouldForceRender(normalizedData)) {
      this.isForceRendering = true;
      this.renderMarkdown(eventName);
      setTimeout(() => {
        this.isForceRendering = false;
      }, 50);
    }
  };

  if (event.name === "思考") {
    handleEventData("thinkingCompleteMarkdown", "thinkingBuffer", "思考");
  } else if (event.name === "答案") {
    handleEventData("answerCompleteMarkdown", "answerBuffer", "答案");
  }
},

renderMarkdown渲染md

javascript 复制代码
renderMarkdown(type) {
  const bufferKey = this.getBufferKey(type);
  // 没有数据直接返回
  if (!this[bufferKey] && !this.getCompleteMarkdownKey(type)) return;

  try {
    const completeMarkdownKey = this.getCompleteMarkdownKey(type);
    const renderedContent = this.md.render(this[completeMarkdownKey]);

    this.$nextTick(() => {
      const chatKey = this.getChatKey(type);
      const lastAiMsg = this.chats[this.chats.length - 1];
      if (lastAiMsg) {
        lastAiMsg[chatKey] = renderedContent;
      }
      this.scrollToBottom();
    });
  } catch (error) {
    console.error("Markdown 渲染错误:", error);
    this.$message.error("Markdown 渲染错误");
  }
},

工具函数

javascript 复制代码
scrollToBottom() {
  this.$nextTick(() => {
    const chatContainer = this.$refs.cultureBox;
    if (chatContainer) {
      chatContainer.scrollTop = chatContainer.scrollHeight;
    }
  });
},

shouldForceRender(text) {
  const forceRenderMarkers = [
    "###",
    "##",
    "####",
    "\n\n",
    "。",
    "!",
    "?",
  ];
  return forceRenderMarkers.some((marker) => text.includes(marker));
},

getBufferKey(type) {
  return type === "思考" ? "thinkingBuffer" : "answerBuffer";
},

getCompleteMarkdownKey(type) {
  return type === "思考"
    ? "thinkingCompleteMarkdown"
    : "answerCompleteMarkdown";
},

getChatKey(type) {
  return type === "思考" ? "thinking" : "answer";
},

面试问题

1.为什么用fetch不用axios

浏览器环境,传统 axios 默认适配器(XHR)不支持真正的 ReadableStream 流式逐块接收;fetch 原生基于 Streams 标准,response.body 直接返回 ReadableStream,所以 AI Streamable / SSE 流式对话大家首选 fetch

⚠️ 补充:Node.js 里 axios 很早就支持 responseType: 'stream';axios 1.x 新版本浏览器端可以切换 adapter: 'fetch' 适配器,间接支持流,但并不是主流方案

fetch(浏览器原生) 底层标准:WHATWG Fetch API,原生绑定 ReadableStream

javascript 复制代码
const res = await fetch(url);
const reader = res.body.getReader(); // ✅ 原生可读流,收到一块就能读一块
  • 数据分片到达,立刻读取,不需要等后端返回全部内容
  • 搭配 AbortController 原生支持随时中断流式请求(停止 AI 生成)
  • 完全适配 OpenAI / 各类大模型 SSE(event:xxx data:xxx)流式协议

axios 默认浏览器适配器(XMLHttpRequest) XHR 规范里 responseType 合法值不包含 stream ,只能是:arraybuffer / blob / document / json / text

  • XHR 会缓存完整响应,等后端全部传输完毕,你才能拿到完整字符串
  • onDownloadProgress 只能拿到进度数字,不能直接拿到每一块原始二进制分片,只能拿到累积拼接后的文本,不是真正流式
  • 如果你强行用 onDownloadProgress 模拟 SSE,要自己维护 buffer,容易出现中文截断、分包错乱,不稳定
误区澄清:axios 能不能做流式
  1. Node.js 服务端:axios 原生支持 responseType: 'stream',可用
  2. 浏览器 axios ≥1.x:切换 fetch 适配器
javascript 复制代码
axios({
  method:"POST",
  url:"/stream",
  adapter:"fetch", // 关键:底层切到fetch
  responseType:"stream"
})

缺点:

  1. 很多老项目还是 axios 0.x,没有 fetch 适配器
  2. 全局 axios 拦截器、baseURL、超时、token 处理,切换适配器后行为容易不一致,踩坑多
  3. 社区示例、大模型官方 SDK(OpenAI)全部是 fetch 实现,遇到问题资料少

绝对不能:直接用普通 axios.post 接收 SSE

复制代码
// ❌ 错误写法,会等待全部输出完成后一次性返回
axios.post('/agent/chat', data).then(res=>{})
为什么行业里 Streamable、AI 流式对话统一用 fetch?
  1. 标准原生,无额外依赖,不用引入 axios
  2. 精准控制流生命周期:AbortController 随时终止生成(停止回答)
  3. 直接操作二进制 Uint8Array + TextDecoder,完美处理中文不截断
  4. 所有大模型流式接口规范(SSE chunked)原生适配
  5. 不存在适配器兼容问题,跨框架(Vue/React/ 原生 JS)写法统一

2.流式输出为什么不用 EventSource,选择 fetch + ReadableStream?

后端返回 SSE 协议 chunked 流式响应,前端用 fetch 获取 response.body ReadableStream,手动解析 event/data。 没有用浏览器自带 EventSource,原因:

  1. EventSource 只支持 GET 请求,我们要传复杂 JSON 请求体,必须 POST;
  2. EventSource 设置请求头、携带 token 比较麻烦;
  3. 需要 AbortController 灵活中断请求,切换会话、发送下一条消息时终止上一条流,防止乱数据;
  4. 自己解析流逻辑可控,可以区分多事件类型(event:思考 / event:答案)。
    补充:EventSource 适合简单 GET 订阅,POST 场景基本都会手动读 ReadableStream。

3.SSE 的协议格式是什么?空行的作用?

复制代码
event:思考
data:xxx思考片段

event:答案
data:xxx回答片段
  • event: 声明事件名称;
  • data: 是数据载荷;
  • \n\n(连续换行空行)代表一条事件结束,浏览器 / 我们代码收到空行,就把当前缓存的 event+data 当作一条完整事件去处理。

这就是代码里 if(line.trim()==="") 触发 handleEvent 的原因。

4.ReadableStream、reader.read () 返回值是什么?

参考答案 const {done, value} = await reader.read()

  • done:布尔,true 代表流传输全部结束;
  • valueUint8Array 二进制字节数组;

TCP 会分片,一次 read 不一定拿到完整一条业务数据,必须本地 buffer 缓存不完整的行。

5.TextDecoder 的 {stream:true} 参数是干嘛的?不加会出现什么问题?

参考答案 decoder.decode(value, {stream:true})

  • stream:true:流式解码,多字节字符(中文、emoji)被 TCP 分片截断时,把残缺字节缓存,下一块数据到来再完成解码
  • 不加 stream:true:遇到被切割的中文,直接乱码、出现问号。

6.AbortController 在这个项目做什么?为什么一定要加?

参考答案 用于中断 fetch 请求 。 场景:用户快速连续发消息、切换对话,上一个 AI 流还在返回,如果不 abort,旧流还会继续执行 handleEvent,往当前聊天列表乱写入旧思考 / 答案,造成消息错乱。

发送新消息时调用 abort(),流循环会抛出 AbortError,需要区分这个错误,不要给用户弹异常报错。

7.解释代码里那个 buffer 变量作用?为什么要 buffer = lines.pop()

参考答案 网络 TCP 层会把服务端返回的字符串任意切割分片,分片边界不一定刚好在换行符上 。 例如: 服务端输出:event:答案\ndata:abc123\n\n 网络分片切成两段:event:答案\ndata:abc123\n\n

  1. buffer.split('\n') 按换行切割所有行;
  2. lines.pop() 取出数组最后一项,这一项大概率是不完整的半行 ,放回 buffer,留给下一次 reader.read() 拼接;
  3. lines 数组剩下的都是完整行,循环解析。

如果不做这个缓存,会丢数据、解析错乱。

8.为什么要防抖渲染,同时还要有 shouldForceRender 强制渲染逻辑?

参考答案

  1. 防抖:AI 流返回颗粒非常细,可能几十毫秒就回来一小块片段,如果每块都执行 markdown 渲染 + DOM 操作,会频繁重渲染,页面卡顿闪烁;设置 100ms 防抖合并多次渲染。
  2. 但是防抖会带来延迟感,看到句号、双换行、标题这些语义结束标记,强制立刻渲染,提升用户体验。

追问:那强制渲染会不会多次重复渲染? 答:会,所以加了 isForceRendering 锁,短时间避免重复调用渲染函数。

9.每次 renderMarkdown 都渲染完整 completeMarkdown,长对话会有什么性能问题?怎么优化?

高频面试坑点! 参考答案 问题: AI 输出几千字,每次流片段过来,marked.render(完整大字符串),markdown 全量解析,字符串越长 CPU 开销越大,页面卡顿。

优化方案两个方向:

  1. 增量渲染:只解析新增 buffer 片段,append HTML,不再全量渲染整篇;

缺点:要自己处理 markdown 块闭合,要防范 XSS。

  1. 虚拟滚动:聊天列表使用虚拟滚动,不在 DOM 渲染看不见的历史消息。

10.SSE、WebSocket、fetch+ReadableStream 三者对比,选型场景

方案 方向 底层 适用场景
fetch+ReadableStream 单向:一次请求,持续接收 HTTP Chunked AI 问答,提问一次,流式返回回答
SSE(EventSource) 单向,服务端推送 HTTP 消息通知,只收不发,限制 GET
WebSocket 双向收发 TCP 聊天、实时协作,客户端也需要频繁发消息

重点:fetch+stream 不能双向长连接,请求体一次性发送,只能接收流式响应。客户端不能持续发送数据。

11.会遇到什么异常边界情况?

  1. 网络中断,流中途断掉;
  2. TCP 分片刚好切在中文中间,依赖 TextDecoder stream:true;
  3. 用户疯狂点发送,上一条流没结束,没有 abort 造成消息错乱;
  4. SSE 事件 data 多行,data 会拼接多行内容;
  5. 后端返回不规范,缺少\n\n结束符;
  6. markdown 解析报错,要 try catch 包裹渲染逻辑。

12.如果后端要同时返回思考和答案,有哪些方案?你们项目是 event 区分,还有别的方案吗?

  1. SSE event:思考 / event:答案(你们项目方案)

  2. 同一个 event,data 返回 JSON,字段 type 区分think / answer

    {"type":"think","content":"xxx"}
    {"type":"answer","content":"xxx"}

  3. 字符串标记前缀 [think]:xxx

评价:event 区分可读性最好;JSON 方案兼容性最强。

SSE 原生 event: 本身没有浏览器兼容性硬缺陷,但在你这种 fetch + ReadableStream 手动解析的场景下,不是兼容性问题,而是【协议解析复杂度】和【后端框架适配问题】;如果用原生 EventSource,event 完全稳定。

真正容易踩坑的不是浏览器支不支持 event,而是:有些后端框架 / 网关不规范输出 SSE 流,以及手动解析代码容易写错。

1. event 本身浏览器兼容性

event: xxx标准 SSE 协议(W3C) ,所有现代浏览器都支持,不存在浏览器不识别 event: 的情况。

  • Chrome / Edge / Firefox / Safari 全支持
  • 移动端浏览器也没问题

❗ 注意:兼容性坑不在解析 event 字段,而在你自己手写流解析逻辑

2. event 方案的实际短板
短板 1:SSE 事件必须用 \n\n 分割,对后端输出格式强约束
复制代码
event:think
data:思考内容

event:answer
data:回答内容

必须双换行作为一条事件结束标记。 如果后端少换行、或者网关 /nginx 转义、日志插入额外换行,你的手动解析逻辑直接错乱。

对比 JSON 方案:单条 data 里直接 {"type":"think","content":"xxx"},只要 JSON 字符串完整就能解析,换行干扰容错更强。

短板 2:一条事件的 data 支持多行,解析逻辑更复杂

SSE 标准:data: 可以多行,连续多个data:行会自动拼接,直到空行。

复制代码
event:think
data:第一行思考
data:第二行思考

手写解析器必须合并多行 data,很多人写的简易解析器没处理多行 data,直接 bug。 而JSON 方案天然单行一条对象,处理简单

短板 3:部分老旧网关、代理、中间件对 SSE event 支持差

有些代理、老的 BFF 框架,会忽略 / 过滤event:前缀,只透传 data 字段。

这个不是浏览器问题,是中间件兼容问题,生产环境容易踩暗坑。

方案 优点 缺点 适合场景
✅ SSE event 区分(event:think /event:answer) 语义标准清晰,协议原生设计,data 载荷干净 后端输出格式要求严格;手写解析要处理多行 data、双换行;部分代理容易出问题 前后端自主可控、新服务,规范度高
✅ 同 event,data 返回 JSON 带 type 兼容性最强,容错高,解析简单,不受 event 字段影响 data 里要序列化 JSON,多一点序列化开销 通用首选,对接复杂网关 / 第三方模型接口
✅ 字符串前缀标记 [think]:xxx 实现最简单,后端不用遵守 SSE 完整规范 内容本身如果包含[think]:会解析错误,内容转义麻烦 快速原型、内部测试,不推荐线上
相关推荐
风骏时光牛马41 分钟前
AIRAG服务异常监控告警
前端
小义_43 分钟前
Linux 性能排查实战
linux·服务器·php
珍珠先生1 小时前
第18章 泛型入门
java
大厂码农老A1 小时前
汤森路透的座上宾?Qwen3.5-397B-A17B到底有什么本事?
前端·人工智能·后端
我命由我123451 小时前
人脸识别 - 判断人脸是否在画面中央
java·人工智能·python·java-ee·人脸识别·android jetpack·android runtime
程序猿乐锅1 小时前
从 dsh 源码看「一切皆插件」与 Spring IoC
java·网络·数据库·人工智能·后端·spring
LINgZone21 小时前
Maven多模块依赖管理与私有JAR包策略
java·maven·jar
静开1 小时前
Skills和MCP 到底什么区别
前端·人工智能
珍珠先生1 小时前
第17章 集合框架:Map
java