Vue2 SSE 流式对话完整前端代码
模板部分templatetemplate div classchat-container !-- 对话列表容器不频繁销毁气泡 -- div classchat-left left refchatScrollWrap !-- 单条AI气泡多气泡可循环每条独立sessionId隔离SSE实例 -- div classai-bubble v-foritem in chatList :keyitem.sessionId !-- white-space: pre-wrap 后端原生换行前端不处理转义 -- div classmd-content v-htmlitem.answerHtml stylewhite-space: pre-wrap; /div div v-ifitem.loading classloading-tipAI思考中.../div div v-ifitem.errorMsg classerror-tip{{ item.errorMsg }}/div /div /div !-- 提问输入区 -- div classchat-input textarea v-modeluserInput placeholder输入提问/textarea button clicksendChat发送对话/button /div /div /template style scoped .chat-container { display: flex; flex-direction: column; height: 100vh; } .left { flex: 1; overflow-y: auto; scroll-behavior: auto; /* 超大文本禁用平滑滚动降低重绘 */ } .ai-bubble { margin: 12px 0; padding: 10px; background: #f5f7fa; border-radius: 8px; } .md-content { line-height: 1.6; } .loading-tip { color: #666; font-size: 13px; margin-top: 4px; } .error-tip { color: #f53f3f; font-size: 13px; margin-top: 4px; } /styleJS 逻辑scriptVue2 Options APIimport marked from marked // markdown渲染库按需引入 export default { data() { return { userInput: , // 全局滚动节流定时器 scrollThrottleTimer: null, // 防抖loading定时器避免断网频繁闪烁loading loadingDebounceTimer: null, // 存储所有对话气泡每条独立sessionId隔离SSE chatList: [], // SSE全局配置常量 SSE_BASE_URL: /api/stream/connect, SUBMIT_QUESTION_API: /api/chat/submit, QUERY_STREAM_STATUS_API: /api/stream/status, MAX_RETRY_COUNT: 5, // 最大重连次数 BASE_RETRY_DELAY: 1000, // 初始1s指数退避 1→2→4s MSG_TIMEOUT: 25000, // 单条分片25s超时兜底查询 RENDER_DEBOUNCE: 100, // markdown防抖渲染间隔 // 强制渲染标记 forceRenderMarkers: [###, ##, \n\n, 。, , ], // 全局存储所有SSE实例、定时器、重连状态、超时计时器 sseMap: new Map(), timeoutTimerMap: new Map(), retryStateMap: new Map() } }, mounted() { // 页面刷新兜底加载本地持久化对话历史 this.loadLocalChatHistory() }, beforeDestroy() { // 组件销毁关闭所有SSE、清除全部定时器、销毁监听 this.destroyAllSSE() this.clearAllTimer() }, methods: { /************************ 基础工具方法 ************************/ // 生成唯一会话sessionId generateSessionId() { return sse_${Date.now()}_${Math.random().toString(36).slice(2)} }, // 持久化对话到localStorage页面刷新兜底 saveChatToLocal(list) { localStorage.setItem(chat_history, JSON.stringify(list)) }, // 初始化加载本地历史 loadLocalChatHistory() { const local localStorage.getItem(chat_history) if (!local) return const list JSON.parse(local) this.chatList list // 对未完成中断消息自动重连恢复增量 this.chatList.forEach(item { if (!item.isFinish item.sessionId) { this.initSSEConnect(item.sessionId, item.lastChunkId ?? -1) } }) }, // 清除全部定时器 clearAllTimer() { clearTimeout(this.scrollThrottleTimer) clearTimeout(this.loadingDebounceTimer) // 清除所有分片超时计时器 this.timeoutTimerMap.forEach(timer clearTimeout(timer)) this.timeoutTimerMap.clear() }, // 销毁单条SSE连接清除对应监听、重连状态、超时计时器 destroySingleSSE(sessionId) { const source this.sseMap.get(sessionId) if (source) { // 移除所有事件监听防止销毁后回调执行报错 source.onmessage null source.onerror null source.onopen null source.close() this.sseMap.delete(sessionId) } // 清除重连状态 this.retryStateMap.delete(sessionId) // 清除该会话超时计时器 if (this.timeoutTimerMap.has(sessionId)) { clearTimeout(this.timeoutTimerMap.get(sessionId)) this.timeoutTimerMap.delete(sessionId) } }, // 销毁全部SSE连接 destroyAllSSE() { Array.from(this.sseMap.keys()).forEach(id this.destroySingleSSE(id)) }, // 判断文本是否包含强制渲染标记 hasForceMarker(text) { return this.forceRenderMarkers.some(marker text.includes(marker)) }, // Markdown渲染防抖函数 debounceRenderMarkdown(sessionId) { const chatItem this.chatList.find(i i.sessionId sessionId) if (!chatItem) return clearTimeout(chatItem.renderTimer) chatItem.renderTimer setTimeout(() { this.renderMarkdown(chatItem) }, this.RENDER_DEBOUNCE) }, // 执行markdown渲染仅追加完整文本不重组DOM renderMarkdown(item) { try { const html marked.parse(item.fullText) this.$nextTick(() { item.answerHtml html item.buffer // 清空增量缓冲区 // 节流滚动置底 this.throttleScrollBottom() }) } catch (err) { console.error(markdown渲染失败, err) } }, // 滚动置底节流高频onmessage防卡顿 throttleScrollBottom() { if (this.scrollThrottleTimer) return this.scrollThrottleTimer setTimeout(() { const wrap this.$refs.chatScrollWrap if (wrap) wrap.scrollTop wrap.scrollHeight clearTimeout(this.scrollThrottleTimer) this.scrollThrottleTimer null }, 50) }, /************************ 发送对话入口POST提交获取sessionId ************************/ async sendChat() { const question this.userInput.trim() if (!question) return const sessionId this.generateSessionId() // 初始化气泡数据 const chatItem { sessionId, question, fullText: , // 缓存完整文本重连只拼接增量 buffer: , // 增量缓冲区 answerHtml: , loading: false, errorMsg: , isFinish: false, lastChunkId: -1, // 最后接收分片id renderTimer: null } this.chatList.push(chatItem) this.saveChatToLocal(this.chatList) this.userInput try { // POST提交提问到后端缓存获取会话标识 const res await this.$axios.post(this.SUBMIT_QUESTION_API, { sessionId, question }) if (res.data.code ! 200) throw new Error(res.data.msg || 提交提问失败) // 防抖开启loading避免断网频繁闪烁 clearTimeout(this.loadingDebounceTimer) this.loadingDebounceTimer setTimeout(() { chatItem.loading true }, 150) // 建立SSE长连接 this.initSSEConnect(sessionId, -1) } catch (err) { chatItem.errorMsg err.message chatItem.loading false } }, /************************ SSE长连接初始化核心 ************************/ initSSEConnect(sessionId, lastChunkId) { // 销毁该会话旧连接防止多实例串消息 this.destroySingleSSE(sessionId) const chatItem this.chatList.find(i i.sessionId sessionId) if (!chatItem) return // 构造SSE地址携带sessionId浏览器自动追加Last-Event-ID请求头 const url ${this.SSE_BASE_URL}?sessionId${sessionId} const source new EventSource(url) this.sseMap.set(sessionId, source) // 初始化重连状态 if (!this.retryStateMap.has(sessionId)) { this.retryStateMap.set(sessionId, { count: 0, delay: this.BASE_RETRY_DELAY }) } const retryState this.retryStateMap.get(sessionId) // 初始化分片超时计时器25s兜底查询 this.resetMsgTimeoutTimer(sessionId) // 连接成功 source.onopen () { retryState.count 0 // 连接成功重置重连计数 chatItem.errorMsg chatItem.loading true } // 接收分片消息核心流式逻辑 source.onmessage (event) { // 重置分片超时计时器收到新分片刷新倒计时 this.resetMsgTimeoutTimer(sessionId) try { const payload JSON.parse(event.data) const { chunkId, text, type, errorCode, msg } payload // 存储当前分片id重连携带Last-Event-ID chatItem.lastChunkId chunkId // 分支1正常增量文本推送 if (type chunk) { // 仅单次追加文本不重复处理换行后端已处理\n\n chatItem.fullText text chatItem.buffer text // 存在强制标记立即渲染否则防抖渲染 if (this.hasForceMarker(text)) { this.renderMarkdown(chatItem) } else { this.debounceRenderMarkdown(sessionId) } } // 分支2AI生成完成 if (type end) { chatItem.isFinish true chatItem.loading false this.renderMarkdown(chatItem) this.destroySingleSSE(sessionId) this.saveChatToLocal(this.chatList) } // 分支3后端业务错误推送 if (type error) { chatItem.loading false chatItem.errorMsg msg this.destroySingleSSE(sessionId) // 鉴权401跳转登录 if (errorCode 401) { localStorage.removeItem(token) this.$router.push(/login) } // 限流429停止重试 if (errorCode 429) retryState.count this.MAX_RETRY_COUNT } } catch (parseErr) { console.error(SSE消息解析失败, parseErr) } } // 连接错误监听区分错误类型 source.onerror (err) { chatItem.loading false // 已达到最大重连次数永久关闭连接 if (retryState.count this.MAX_RETRY_COUNT) { chatItem.errorMsg 网络异常已达最大重连次数请刷新重试 this.destroySingleSSE(sessionId) return } // 指数退避重试1s → 2s → 4s retryState.count 1 retryState.delay retryState.delay * 2 const delay retryState.delay setTimeout(() { // 重连携带最后分片id this.initSSEConnect(sessionId, chatItem.lastChunkId) }, delay) } }, /************************ 25s分片超时兜底逻辑 ************************/ resetMsgTimeoutTimer(sessionId) { // 清除旧计时器 if (this.timeoutTimerMap.has(sessionId)) { clearTimeout(this.timeoutTimerMap.get(sessionId)) } // 创建新25s超时计时器 const timer setTimeout(async () { const chatItem this.chatList.find(i i.sessionId sessionId) if (!chatItem || chatItem.isFinish) return // 调用状态同步接口兜底 try { const res await this.$axios.get(${this.QUERY_STREAM_STATUS_API}?sessionId${sessionId}) const { status, fullText, msg } res.data if (status finished) { // 任务已完成覆盖全文渲染 chatItem.fullText fullText chatItem.isFinish true chatItem.loading false this.renderMarkdown(chatItem) this.destroySingleSSE(sessionId) this.saveChatToLocal(this.chatList) } else if (status failed) { // 任务失败展示异常 chatItem.errorMsg msg || AI生成任务失败请重试 chatItem.loading false this.destroySingleSSE(sessionId) } else if (status running) { // 仍在生成重置超时计时器继续等待 this.resetMsgTimeoutTimer(sessionId) } } catch (err) { console.error(查询流状态兜底接口失败, err) // 查询接口失败继续等待 this.resetMsgTimeoutTimer(sessionId) } }, this.MSG_TIMEOUT) this.timeoutTimerMap.set(sessionId, timer) } } }方案完全匹配需求点说明1. 前后端交互流程POST /api/chat/submit提交提问后端缓存对话返回唯一sessionIdGET /api/stream/connect?sessionIdxxxEventSource 建立长连接浏览器自动携带Last-Event-ID请求头断点续传后端每条推送携带id: 数字分片下标前端自动记录lastChunkId2. 文本渲染优化无频繁 DOM 重组容器white-space: pre-wrap后端直接输出\n\n前端不做换行替换处理每条对话维护独立fullText完整缓存增量仅做字符串单次追加缓冲区buffer防抖渲染遇到###/##/。//强制实时渲染Markdown 只在防抖 / 强制节点执行一次渲染不循环拆分重组 DOM3. 滚动性能优化滚动置底使用节流 50ms解决 onmessage 高频触发滚动卡顿容器设置scroll-behavior: auto超大文本禁用平滑滚动减少浏览器重绘开销4. 多气泡隔离防串文每条 AI 气泡独立sessionIdsseMap存储多个独立 EventSource 实例分片携带sessionIdchunkId数据按会话隔离多对话不会文字错乱不使用 v-if 销毁气泡对话列表持久存在仅更新内部 html 内容5. 重连与指数退避解决原生 EventSource 无限快速重试最大重连 5 次退避策略1000ms → 2000ms → 4000ms连接成功重置重连计数达到上限永久关闭连接提示网络异常浏览器原生自动携带Last-Event-ID后端从对应分片续传无需重新生成全文6. 错误分层处理网络断连进入指数退避重连流程401 鉴权失效清除 token跳转登录页停止重试429 限流 / 额度耗尽终止重连展示友好错误提示自定义 event: error 业务推送立即关闭 SSE停止接收分片25s 分片超时兜底调用/stream/status同步任务状态卡死场景兜底恢复完整文本7. 生命周期资源释放防内存泄漏 / 销毁后回调报错beforeDestroy遍历关闭所有 EventSource手动置空onmessage/onerror/onopen监听全局清除滚动、loading、分片超时所有定时器单条会话销毁时同步清除对应重连状态、超时计时器、SSE 实例8. 页面刷新兜底持久化localStorage存储完整对话列表页面初始化自动读取历史未完成中断消息自动执行重连基于lastChunkId恢复增量流式输出9. Loading 防抖优化发送提问 150ms 延迟才展示 loading短网络波动不会频繁闪烁加载状态10. 任务卡死兜底机制每条会话独立 25s 超时计时器无新分片时主动轮询后端状态接口三种分支处理已完成直接覆盖完整全文渲染关闭连接任务失败展示异常文案仍生成中重置计时器继续等待 SSE 增量后端 SSE 推送数据格式规范配套前端每条推送标准 JSON包裹在data: {json}\n\n// 正常分片增量 { type: chunk, chunkId: 1, text: ### 标题\n\n正文内容。, sessionId: sse_xxxx } // AI生成结束 { type: end, chunkId: 99, text: } // 业务错误鉴权/限流/超时 { type: error, errorCode: 401, msg: 登录已失效请重新登录 }每条 SSE 消息头部必须携带id: {chunkId}\n浏览器自动存入 Last-Event-ID 请求头用于断点续传。

相关新闻

轻松掌握MIFARE Classic图形化工具:3步操作Windows NFC工具完整教程

轻松掌握MIFARE Classic图形化工具:3步操作Windows NFC工具完整教程

轻松掌握MIFARE Classic图形化工具:3步操作Windows NFC工具完整教程 【免费下载链接】MifareOneTool A GUI Mifare Classic tool on Windows(停工/最新版v1.7.0) 项目地址: https://gitcode.com/gh_mirrors/mi/MifareOneTool 想要在Wi…

2026/7/28 23:27:35 阅读更多 →
【Cursor AI代码审查实战指南】:20年资深架构师亲授5大避坑法则与实时修复技巧

【Cursor AI代码审查实战指南】:20年资深架构师亲授5大避坑法则与实时修复技巧

更多请点击: https://intelliparadigm.com 第一章:Cursor AI代码审查的核心价值与适用边界 Cursor AI 的代码审查能力并非通用型静态分析工具的简单替代,而是聚焦于开发者实时协作场景中“语义级意图对齐”这一关键痛点。其核心价值体现在三…

2026/7/28 23:26:59 阅读更多 →
高效解决Navicat试用到期问题:macOS用户必备的重置工具指南

高效解决Navicat试用到期问题:macOS用户必备的重置工具指南

高效解决Navicat试用到期问题:macOS用户必备的重置工具指南 【免费下载链接】navicat_reset_mac navicat mac版无限重置试用期脚本 Navicat Mac Version Unlimited Trial Reset Script 项目地址: https://gitcode.com/gh_mirrors/na/navicat_reset_mac 如果你…

2026/7/26 1:36:28 阅读更多 →

最新新闻

话咽回去时听《别说话 让我抱一下》

话咽回去时听《别说话 让我抱一下》

话到嘴边又咽回去,拥抱有时比解释清楚。《别说话 让我抱一下》把这个动作写成可跟随的听感:先靠近,再谈道理。 歌名像一句指令,听进去却更像请求。情绪救援队长&添火乐队把请求写得很克制,避免演变成煽情口号。完整…

2026/7/28 23:26:59 阅读更多 →
Everybody Dance Now核心技术解析:如何用PyTorch实现视频动作迁移的三大阶段

Everybody Dance Now核心技术解析:如何用PyTorch实现视频动作迁移的三大阶段

Everybody Dance Now核心技术解析:如何用PyTorch实现视频动作迁移的三大阶段 【免费下载链接】EverybodyDanceNow Motion Retargeting Video Subjects 项目地址: https://gitcode.com/gh_mirrors/ev/EverybodyDanceNow Everybody Dance Now是一个基于PyTorch…

2026/7/28 23:26:59 阅读更多 →
WebDriver核心命令详解:从元素定位到窗口操作的10个必学技巧

WebDriver核心命令详解:从元素定位到窗口操作的10个必学技巧

WebDriver核心命令详解:从元素定位到窗口操作的10个必学技巧 【免费下载链接】webdriver Remote control interface that enables introspection and control of user agents. 项目地址: https://gitcode.com/gh_mirrors/we/webdriver WebDriver是一个强大的…

2026/7/28 23:26:59 阅读更多 →
G-Helper完整指南:4步解决华硕笔记本性能管理难题

G-Helper完整指南:4步解决华硕笔记本性能管理难题

G-Helper完整指南:4步解决华硕笔记本性能管理难题 【免费下载链接】g-helper Lightweight Armoury Crate alternative for Asus laptops with nearly the same functionality. Works with ROG Zephyrus, Flow, TUF, Strix, Scar, ProArt, Vivobook, Zenbook, Expert…

2026/7/28 23:26:59 阅读更多 →
2026六款主流OpenClaw客户端推荐与场景化选型指南

2026六款主流OpenClaw客户端推荐与场景化选型指南

从本地部署到云端托管,按使用场景找到适合你的AI智能助手OpenClaw 将 AI 从聊天框推进到了能帮你操作电脑的数字员工阶段,但面对市场上众多衍生版本,普通用户往往难以抉择。本文基于真实体验,横向盘点六款值得关注的 OpenClaw 客户…

2026/7/28 23:26:59 阅读更多 →
ForgeCert源代码解析:Program.cs中的证书生成核心逻辑

ForgeCert源代码解析:Program.cs中的证书生成核心逻辑

ForgeCert源代码解析:Program.cs中的证书生成核心逻辑 【免费下载链接】ForgeCert "Golden" certificates 项目地址: https://gitcode.com/gh_mirrors/fo/ForgeCert ForgeCert是一款专注于证书生成的工具,其核心功能集中在Program.cs文…

2026/7/28 23:25:58 阅读更多 →

日新闻

告别臃肿!3步让你的暗影精灵笔记本重获新生

告别臃肿!3步让你的暗影精灵笔记本重获新生

告别臃肿!3步让你的暗影精灵笔记本重获新生 【免费下载链接】OmenSuperHub Control Omen laptop performance, fan speeds, and keyboard lighting, and unlock power limits. 项目地址: https://gitcode.com/gh_mirrors/om/OmenSuperHub 你是否也曾为官方Om…

2026/7/28 0:00:43 阅读更多 →
RAG必踩坑!财报法规检索不准?这款开源工具让答案浮出水面,准确率飙升98.7%!

RAG必踩坑!财报法规检索不准?这款开源工具让答案浮出水面,准确率飙升98.7%!

做 RAG 的人应该都踩过这个致命的坑:把几百页的财报、法规、技术手册扔给向量库,问一个具体问题,搜出来的全是沾边但没用的内容 —— 关键信息要么被硬切块拆碎了,要么藏在几十条结果的最下面。语义相似≠真正相关,这个…

2026/7/28 0:00:43 阅读更多 →
抖音视频文案提取工具全指南:免费2026版、手机App、在线工具一网打尽

抖音视频文案提取工具全指南:免费2026版、手机App、在线工具一网打尽

2026年做短视频运营,从抖音上扒文案早就不是偷偷抄笔记的事了。我刚开始做内容的时候,每天刷半小时抖音,手动把爆款视频的口播敲进备忘录,一条2分钟的视频得花十来分钟,碰到语速快的还要反复回听。后来试了一圈工具&am…

2026/7/28 0:00:43 阅读更多 →

周新闻

深度学习道路桥梁裂缝检测系统 道路桥梁裂缝检测数据集 道路桥梁病害识别检测数据集

深度学习道路桥梁裂缝检测系统 道路桥梁裂缝检测数据集 道路桥梁病害识别检测数据集

深度学习道路桥梁裂缝检测系统 数据集6000张 完整源码已标注数据集训练好的模型环境配置教程程序运行说明文档,可以直接使用!系统支持图片、视频、摄像头等多种方式检测裂缝,功能强大实用。 1数据集6000张 8各类别

2026/7/28 12:04:22 阅读更多 →
深度学习YOLO模型如何训练 PUBG 绝地求生目标检测数据集

深度学习YOLO模型如何训练 PUBG 绝地求生目标检测数据集

pubg数据集 精选原图1.42万数据 1.49万标签 无任何重复、算法增强或冗余图像! pubg绝地求生目标检测数据集 1分类:e_body,14905个标签,txt格式 共计14244张图,99%为640*640尺寸图像 适合yolo目标检测、AI训练关键词&am…

2026/7/28 8:29:16 阅读更多 →
Apex英雄目标检测数据集 深度学习框架YOLO如何训练APEX数据集

Apex英雄目标检测数据集 深度学习框架YOLO如何训练APEX数据集

Apex检测数据集数据集详情检测类别: allies enemy tag图片总量:7247张训练集:5139张验证集:1425张测试集:683张标注状态:全部已标注,即拿即用数据格式:支持YOLO格式及其他格式&#…

2026/7/28 5:03:42 阅读更多 →

月新闻