流程图

代码

data字段

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

sendMessage发送消息

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包装请求

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

fetchDataCommon发送请求

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处理分片

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

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 渲染错误");
  }
},

工具函数

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

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 适配器
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 锁,短时间避免重复调用渲染函数。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐