尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

Vue2 SSE 流式对话完整前端代码

Vue2 SSE 流式对话完整前端代码
📅 发布时间:2026/7/18 21:39:19

模板部分(template)

<template> <div class="chat-container"> <!-- 对话列表容器,不频繁销毁气泡 --> <div class="chat-left left" ref="chatScrollWrap"> <!-- 单条AI气泡,多气泡可循环,每条独立sessionId隔离SSE实例 --> <div class="ai-bubble" v-for="item in chatList" :key="item.sessionId"> <!-- white-space: pre-wrap 后端原生换行,前端不处理转义 --> <div class="md-content" v-html="item.answerHtml" style="white-space: pre-wrap;" ></div> <div v-if="item.loading" class="loading-tip">AI思考中...</div> <div v-if="item.errorMsg" class="error-tip">{{ item.errorMsg }}</div> </div> </div> <!-- 提问输入区 --> <div class="chat-input"> <textarea v-model="userInput" placeholder="输入提问"></textarea> <button @click="sendChat">发送对话</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; } </style>

JS 逻辑(script,Vue2 Options API)

import 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) } } // 分支2:AI生成完成 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. 前后端交互流程

  1. POST /api/chat/submit:提交提问,后端缓存对话,返回唯一sessionId
  2. GET /api/stream/connect?sessionId=xxx:EventSource 建立长连接,浏览器自动携带Last-Event-ID请求头断点续传
  3. 后端每条推送携带id: 数字分片下标,前端自动记录lastChunkId

2. 文本渲染优化(无频繁 DOM 重组)

  • 容器white-space: pre-wrap,后端直接输出\n\n,前端不做换行替换处理
  • 每条对话维护独立fullText完整缓存,增量仅做字符串+=单次追加
  • 缓冲区buffer防抖渲染,遇到###/##/。/!/?强制实时渲染
  • Markdown 只在防抖 / 强制节点执行一次渲染,不循环拆分重组 DOM

3. 滚动性能优化

  • 滚动置底使用节流 50ms,解决 onmessage 高频触发滚动卡顿
  • 容器设置scroll-behavior: auto,超大文本禁用平滑滚动,减少浏览器重绘开销

4. 多气泡隔离防串文

  • 每条 AI 气泡独立sessionId,sseMap存储多个独立 EventSource 实例
  • 分片携带sessionId+chunkId,数据按会话隔离,多对话不会文字错乱
  • 不使用 v-if 销毁气泡,对话列表持久存在,仅更新内部 html 内容

5. 重连与指数退避(解决原生 EventSource 无限快速重试)

  • 最大重连 5 次,退避策略:1000ms → 2000ms → 4000ms
  • 连接成功重置重连计数;达到上限永久关闭连接,提示网络异常
  • 浏览器原生自动携带Last-Event-ID,后端从对应分片续传,无需重新生成全文

6. 错误分层处理

  1. 网络断连:进入指数退避重连流程
  2. 401 鉴权失效:清除 token,跳转登录页,停止重试
  3. 429 限流 / 额度耗尽:终止重连,展示友好错误提示
  4. 自定义 event: error 业务推送:立即关闭 SSE,停止接收分片
  5. 25s 分片超时兜底:调用/stream/status同步任务状态,卡死场景兜底恢复完整文本

7. 生命周期资源释放(防内存泄漏 / 销毁后回调报错)

  1. beforeDestroy遍历关闭所有 EventSource,手动置空onmessage/onerror/onopen监听
  2. 全局清除滚动、loading、分片超时所有定时器
  3. 单条会话销毁时同步清除对应重连状态、超时计时器、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 请求头用于断点续传。

相关新闻

  • 为什么92%的初级开发者误用Gemini?:3个关键能力缺口+1套精准自测清单(限时公开)
  • 7-Zip官方原版,免费无广、极简、高效!
  • 2026年广州GEO优化服务:本地企业GEO增长获客工具深度解析与服务商选型七维指南 - 企业新闻快传

最新新闻

  • 智能小说下载器:200+网站支持,打造你的个人数字图书馆
  • 智能伸缩门如何融入智慧园区出入口管理体系?
  • 2026加州本土升学机构哪家更靠谱?美国高中本科申请十家深度推荐 - 环球新视野
  • 需要可追溯资质的磷酸钠哪个品牌靠谱?
  • C++ 内存安全改进能否及时见效?TIOBE 指数的市场信号
  • vue2项目点击跳转小程序

日新闻

  • 宝珀中国官方售后服务中心|官方热线和维修地址权威信息声明(2026年7月更新) - 宝珀官方售后服务中心
  • # 2026年北京知识产权律师推荐怎么选?看这五点关键不踩雷 - 本地品牌推荐
  • 2026实测教程:生成的拼豆图纸不满意怎么修改才省事 - 省事研究所

周新闻

  • IX9104 PCIe5.0 高速交换芯片@ACP#完整规格 + 应用场景总结
  • Unity游戏集成Coze智能体:实现NPC智能对话与知识库联动
  • SAP EPIC 建行回单查询:从标准类CL_EPIC_EXAMPLE_CN_CCB_GHTD到Z类的5处关键修改

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号