在这里插入图片描述

🤖 作者简介:水煮白菜王,一个web开发工程师 👻
👀 文章专栏: 前端专栏 ,记录一下平时在博客写作中,总结出的一些开发技巧、记录和知识归纳总结✍。
感谢支持💕💕💕

在构建 AI 聊天问答功能时,通常采用 Server-Sent Events(SSE)进行流式数据推送。然而,标准的 EventSource 接口不支持 POST 请求,仅允许使用 GET 方法发起连接。考虑到需要传递复杂参数或大量请求体的场景,可以使用 fetch 结合 ReadableStream 来模拟 SSE 的流式输出行为。

可访问 测试地址 提前体验实际效果 😊
在这里插入图片描述

下面将详细介绍如何通过 fetch + stream 实现一个兼容 POST 请求的流式问答接口对接。

✅ 使用 fetch 获取流式响应并解析

⏳ 代码重点分析

  • 使用 fetch 发起请求,获取响应对象 response。
  • 通过 response.body.getReader() 获取流式数据读取器 ReadableStreamDefaultReader。
  • 读取并解析流式数据:读取、解码、解析、输出更新
  // 请求问答流式输出
  async getFetchStream() {
  	// 初始化聊天内容占位符
    this.talkList.push({ id: Date.now(), userType: 0, content: "..." });
    const responseIndex = this.talkList.length - 1;
    try {
      // 构造请求参数
      const url = "https://models.csdn.net/v1/chat/completions";
      const API_KEY = "sk-yeaxrzuqufgtkqnvmdzszrsahlaaiyfxmezciiugk";

      const headers = {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      };
      const params = {
        model: `Deepseek-V3`,
        messages: [
          {
            role: "user",
            content: this.content,
          },
        ],
        stream: true,
      };
	  // fetch发起请求
      const response = await fetch(url, {
        method: "POST",
        headers: headers,
        body: JSON.stringify(params),
      });
      // 检查响应状态
      if (!response.ok || response.status != 200) {
        this.talkLoading = false;
        this.talkList[responseIndex].content = this.chatDesc.responseError;
        return;
      }
	  // 获取响应体的读取器
      const reader = response.body.getReader();
      // 解码
      const decoder = new TextDecoder("utf-8");
      let done = false;
      let buffer = ""; // 缓冲文本

      this.talkList[responseIndex].content = ""; // 清除占位符内容

      while (!done) {
        const { value, done: doneReading } = await reader.read();
        done = doneReading;
        buffer += decoder.decode(value, { stream: !done });

        let start;
        // 解析 JSON 
        while ((start = buffer.indexOf("data:")) !== -1) {
          const end = buffer.indexOf("\n", start);
          if (end === -1) break;

          const chunk = buffer.substring(start + 5, end).trim();
          buffer = buffer.substring(end + 1);
          console.log(JSON.parse(chunk));
		  // 内容输出
          try {
            const data = JSON.parse(chunk);
            // 提取content内容
            this.talkList[responseIndex].content +=
              data.choices[0]?.delta?.content || "";
          } catch (error) {
            console.error("Failed to parse JSON:", error);
          } finally {
            this.talkLoading = false;
          }
        }
      }
    } catch (error) {
      this.talkLoading = false;
    }
  },

📌不同模型的接口,返回的流式数据格式可能略有差异,这里调用的是CSDN提供 模型服务接口 ,大部分AI模型厂商返回的数据格式都是按OpenAI 标准的 API 响应格式,来兼容OpenAI 的 API 格式。

📅后端返回格式(SSE)

后端需返回如下格式的流式数据,每条消息以 data: 开头,并以换行符号 \n\n 分隔结尾。
返回格式示例:

data: {"created":1753689169,"usage":null,"model":"Deepseek-V3","service_tier":null,"id":"02175368916923595a4d016b90785da794806a35be38526786d8e","choices":[{"finish_reason":null,"delta":{"role":"assistant","function_call":null,"refusal":null,"tool_calls":null,"content":"关于"},"index":0,"logprobs":null}],"system_fingerprint":null,"object":"chat.completion.chunk"}

data: {"created":1753689169,"usage":null,"model":"Deepseek-V3","service_tier":null,"id":"02175368916923595a4d016b90785da794806a35be38526786d8e","choices":[{"finish_reason":null,"delta":{"role":"assistant","function_call":null,"refusal":null,"tool_calls":null,"content":"AI"},"index":0,"logprobs":null}],"system_fingerprint":null,"object":"chat.completion.chunk"}

⚠️注意事项:

  • 响应头必须包含:Content-Type: text/event-stream
  • 必须开启 flush 输出,确保浏览器能及时接收到流式数据,否则可能因缓冲机制导致延迟

🧩 对比

方法 是否支持 POST 请求 是否支持 SSE(Server-Sent Events) 特点说明
EventSource(或 EventSourcePolyfill ❌(仅支持 GET) ✅(原生支持) - 原生支持 SSE
- 自动重连机制
- 无法发送请求体(不能传大量参数)
- 只能使用 GET 请求
fetch + ReadableStream ✅(模拟支持) - 使用 fetch 获取响应流
- 支持 POST 请求
- 可发送请求体(适合复杂参数)
- 需要手动处理消息解析、连接保持和重连逻辑
- 更加灵活,适用于复杂控制场景

🧠 总结

通过 fetch + ReadableStream 的方式,我们可以在不依赖原生 EventSource 的前提下,实现对 AI 聊天模型服务的流式对接。这种方式不仅支持 POST 请求,还能处理复杂的认证和参数传递需求,适用于现代 Web 应用中对实时性和交互性的高要求场景。

📅 代码示例

在这里插入图片描述

<template>
  <div class="main" id="main">
    <div class="chat-main">
      <div class="chat-content w100 h100">
        <!-- 问答主体talk -->
        <div class="chat-content-list w100" ref="chatContentList">
          <div
            :class="['talk', item.userType == 0 ? 'aiTalk' : 'userTalk']"
            v-for="(item, index) in talkList"
            :key="index"
          >
            <span v-if="item.userType == 0" v-html="mdRender(item.content)"></span>
            <span v-if="item.userType == 1">{{ item.content }}</span>
          </div>
        </div>
        <!--  -->
        <div class="chat-input w100">
          <el-form @submit.prevent="getQuestion" class="w100" style="margin: 6px auto 0;width: 57%;">
              <el-input
                placeholder="请输入内容"
                v-model="contentVal"
                size="small">
                <i slot="suffix" class="el-input__icon el-icon-position" style="cursor: pointer;" @click="getQuestion"></i>
              </el-input>
          </el-form>
          <div class="chat-remark">{{ chatDesc.remark }}</div>
        </div>
      </div>
    </div>
  </div>
</template>
  
  <script>
export default {
  name: "aiChat",
  data() {
    return {
      chatDesc: {
        talkPadding: "回答中",
        inputTip: "请输入你的问题",
        placeholder:
          '来说点什么吧...(试试输入"周报策划"、"自我介绍"或"tag😊"; 挑选优质提示词模版)',
        MarkdownRead: "Markdown解析器出错! ",
        streamLoading: "内容输出中! 请稍等...",
        inputNull: "输入内容不能为空",
        allError: "无法获取有效内容,请重新尝试!",
        responseError: "会话出现问题,请重试!",
        remark:
          "此对话生成的所有内容均由人工智能模型生成,其生成内容的准确性和完整性无法保证,不代表我们的态度或观点",
      },
      talkLoading: false,
      talkList: [], // 对话列表
      contentVal: "", // 输入内容
      content: "", // 对话内容
      isFocused: false, // 输入框是否获取焦点
    };
  },
  computed: {},
  updated() {
    this.scrollToB();
  },
  created() {},
  mounted() {},
  beforeDestroy() {},
  methods: {
    // 解析markdown文本 需要引入markdown插件
    mdRender(textObj) {
      const text = textObj.toString();
      // 如果 markdownit 未加载,返回原文
      if (!window.markdownit()) {
        console.warn(this.chatDesc.MarkdownError);
        return text;
      }

      // 尝试解析 Markdown
      try {
        return window.markdownit().render(text);
      } catch (error) {
        console.error(`${this.chatDesc.MarkdownRead}`, error);
        return text;
      }
    },

    // 会话区域滚动
    scrollToB() {
      this.$nextTick(() => {
        let box = this.$el.querySelector(".chat-content-list");
        if (box) {
          box.scrollTo({
            top: box.scrollHeight,
            behavior: "smooth", // 平滑滚动
          });
        }
      });
    },

    // 发起提问
    getQuestion() {
      this.content = this.contentVal.trim();
      if (!this.content) {
        this.$message.error(this.chatDesc.inputNull);
        return;
      }
      this.talkList.push({
        id: Date.now(),
        userType: 1,
        content: this.content,
      });
      this.talkLoading = true;
      this.getFetchStream();
      this.contentVal = "";
    },

    // 请求问答流式输出
    async getFetchStream() {
      // 初始聊天内容占位符
      this.talkList.push({ id: Date.now(), userType: 0, content: "..." });
      const responseIndex = this.talkList.length - 1;
      try {
        // 构造请求参数
        const url = "https://models.csdn.net/v1/chat/completions";
        const API_KEY = "sk-yeaxrzuqufgtkqnvmdzszrsahlaaiyfxmezciiugk";
        const headers = {
          Authorization: `Bearer ${API_KEY}`,
          "Content-Type": "application/json",
        };
        const params = {
          model: `Deepseek-V3`,
          messages: [
            {
              role: "user",
              content: this.content,
            },
          ],
          stream: true,
        };
        // fetch发起请求
        const response = await fetch(url, {
          method: "POST",
          headers: headers,
          body: JSON.stringify(params),
        });
        if (!response.ok || response.status != 200) {
          this.talkLoading = false;
          this.talkList[responseIndex].content = this.chatDesc.responseError;
          return;
        }

        // 获取响应体的读取器
        const reader = response.body.getReader();
        // 解码
        const decoder = new TextDecoder("utf-8");
        let done = false;
        // 缓冲文本
        let buffer = "";

        this.talkList[responseIndex].content = ""; // 清除占位符内容

        while (!done) {
          const { value, done: doneReading } = await reader.read();
          done = doneReading;
          buffer += decoder.decode(value, { stream: !done });

          let start;
          // 解析 JSON 
          while ((start = buffer.indexOf("data:")) !== -1) {
            const end = buffer.indexOf("\n", start);
            if (end === -1) break;

            const chunk = buffer.substring(start + 5, end).trim();
            buffer = buffer.substring(end + 1);
            console.log(JSON.parse(chunk));
            // 内容输出
            try {
              const data = JSON.parse(chunk);
              // 提取content内容
              this.talkList[responseIndex].content += data.choices[0]?.delta?.content || "";
            } catch (error) {
              console.error("Failed to parse JSON:", error);
            } finally {
              this.talkLoading = false;
            }
          }
        }
      } catch (error) {
        this.talkLoading = false;
      }
    },
    // 延迟函数
    delay(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    },
  },
};
</script>
<style lang="scss" scoped>
.w100 {
  width: 100%;
}
.h100 {
  height: 100%;
}
.text-align-left {
  text-align: left;
}
.text-align-center {
  text-align: center;
}
.text-align-right {
  text-align: right;
}
$border-color: #f0f0f0;
$border-radius: 15px;

.main {
  width: 100vw;
  height: 100vh;
  background: #fff;
  overflow: hidden;
}

.chat-main {
  width: calc(100% - 20px);
  height: calc(100% - 20px);
  border: 1px solid $border-color;
  border-radius: $border-radius;
  overflow: hidden;
  position: relative;
  margin: 10px;
}

.chat-content {
  position: relative;
  margin: 0 auto;
  font-size: 14px;
  flex: 1;
  z-index: 1000;
  display: flex;
  flex-direction: column;
}

.chat-content-list {
  flex: 1;
  margin: 0 auto 10px;
  overflow: hidden;
  overflow-y: scroll;
  transition: height 0.2s ease;
  width: 60%;
}

.talk {
  display: flex;
  margin: 10px 10px 25px 10px;
  font-size: 15px;
  transition: opacity 0.2s ease;
}

.aiTalk span {
  max-width: calc(100% - 20px);
  display: inline-block;
  background: white;
  border-radius: $border-radius;
  padding: 0 15px;
  border: 1px solid $border-color;
  border-top-left-radius: 2px;
  word-break: break-all;
  text-align: left;
}

.userTalk {
  display: flex;
  flex-direction: row-reverse;
  margin: 10px 10px 10px 10px;
}
.userTalk span {
  display: inline-block;
  border-radius: $border-radius;
  border-top-right-radius: 2px;
  background: #eff6ff;
  padding: 15px;
  word-break: break-all;
  text-align: left;
}

.chat-input {
  position: relative;
  z-index: 1000;
  margin: 10px auto 0;
  z-index: 1001;
  transition: height 0.2s ease;
}


.chat-remark {
  width: 80%;
  font-size: 11px;
  margin: 10px auto;
  color: #a3a3a3;
  text-align: center;
  letter-spacing: 0px;
}
</style>

✅ 这段代码主要实现了通过 fetch + ReadableStream 模拟 SSE 流式输出 的基础逻辑,用于支持 AI 聊天问答的实时流式响应。虽然已具备基本的流式接收与渲染能力,但整体实现仍较为简单,缺少中断请求、自动重连、详细的错误处理机制增强功能,在实际生产环境中需进一步完善。

该输入框使用的是标准的 <input> 组件,适用于仅需支持基础文本输入的场景,实现方式简洁且易于维护。但如果需要支持 富文本格式、内容高亮、@提及、代码块插入 等高级功能,则更适合采用 contenteditable="true"<div> 或者 <textarea> 元素,它在内容表现力和交互灵活性方面更具优势。

🧪 补充:自定义可编辑输入框实现如下:

  <!-- 自定义输入框 -->
  <div
    @keydown.enter.prevent="handleEnter"
    class="chat-input-box"
    contenteditable="true"
    ref="contentEditable"
    :placeholder="contentVal ? '' : chatDesc.placeholder"
    enterkeyhint="send"
    v-contenteditable="contentVal"
    :disabled="talkLoading"
    tabindex="0"
    @focus="isFocused = true"
    @blur="isFocused = false"
    autocomplete="off"
    spellcheck="false"
  ></div>

📌 效果示例:

在这里插入图片描述

然而,由于contenteditable 元素本身不支持 Vue 的 v-model 双向绑定机制,因此需要通过自定义指令 v-contenteditable 来实现 DOM 与 Vue 数据的双向同步,具体实现方式要看项目框架来做对应策略。

  directives: {
    // 输入框值绑定指令
    // 实现 contenteditable 属性元素与 Vue 数据的双向绑定。当用户编辑内容时自动更新数据,当数据变化时自动更新DOM内容。
    // 解决 DIV 在 contenteditable="true" 模式下输入内容能够正确更新
    contenteditable: {
      // 初始化绑定
      bind(el, binding, vnode) {
        // el: DOM元素,binding: 绑定对象,vnode: 虚拟节点
        el.innerHTML = binding.value; // 将初始值设置到可编辑元素
        el.addEventListener("input", () => {
          // 监听输入事件
          vnode.context[binding.expression] = el.innerHTML; // 更新Vue实例数据
        });
      },
      // 数据更新时触发
      update(el, binding) {
        if (binding.value !== el.innerHTML) {
          // 防止无限循环更新
          el.innerHTML = binding.value; // 同步Vue数据到DOM
        }
      },
    },

✅ 最终优化效果示例

可访问 测试地址 体验实际效果 😊

其中对输入框进行了优化,支持内容高亮与代码块插入功能,结合 DeepSeek R1 模型进行智能思考回复,同时实现了代码语法高亮(Highlight),并提供了快速模板提问等实用功能,整体交互体验更加流畅与高效。

在这里插入图片描述

🌟感谢阅读,如果你在阅读过程中发现任何问题,或有改进建议,也欢迎在评论区指出,我会及时修正并持续优化内容。

如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀

在这里插入图片描述

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐