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 请求头用于断点续传。

本周精选

本月热点