基于 fetch + ReadableStream 流式输出 实现 AI 聊天问答
🤖 作者简介:水煮白菜王,一个web开发工程师 👻
👀 文章专栏: 前端专栏 ,记录一下平时在博客写作中,总结出的一些开发技巧、记录和知识归纳总结✍。
感谢支持💕💕💕
目录
- ✅ 使用 fetch 获取流式响应并解析
- 📅后端返回格式(SSE)
- 🧠 总结
- 📅 代码示例
- 🧪 补充:自定义可编辑输入框实现如下:
- 📌 效果示例:
- ✅ 最终优化效果示例
- 如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀
在构建 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 分隔:
data: {"created":1752749854,"usage":null,"model":"Deepseek-V3","id":"0217527498532486352c5e04f127f4d863668155e78c0c219d619","choices":[{"finish_reason":null,"delta":{"role":"assistant","content":"你好"},"index":0}],"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-inputplaceholder="请输入内容"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;}// 尝试解析 Markdowntry {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>
元素,它在内容表现力和交互灵活性方面更具优势。
🧪 补充:自定义可编辑输入框实现如下:
<!-- 自定义输入框 --><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),并提供了快速模板提问等实用功能,整体交互体验更加流畅与高效。
🌟感谢阅读,如果你在阅读过程中发现任何问题,或有改进建议,也欢迎在评论区指出,我会及时修正并持续优化内容。