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

模板部分(template)

javascript 复制代码
<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)

javascript 复制代码
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 气泡独立sessionIdsseMap存储多个独立 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

javascript 复制代码
// 正常分片增量
{
  "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 请求头用于断点续传。

相关推荐
BIM云平台开发1 小时前
【App.vue里跟踪页面跳转和用户ID】
开发语言·前端·javascript
汪汪大队u2 小时前
Zabbix 6.0 部署踩坑记:从启动失败到 Web 成功访问
前端·zabbix
顺颂时绥_12 小时前
new Set 过滤数据实战
前端
赵大仁2 小时前
Next.js + Vercel AI SDK 实战:30 分钟搭出流式 Chat 页面
前端·ai·实战·react·next.js·vercel
云空2 小时前
《Three.js 完整版3D魔方:带贴纸+中心固定+自动求解复原》
前端·javascript·3d·three.js
酸梅果茶2 小时前
【7】lightning_lm项目-LIO 前端 -IVox 局部地图
前端·slam
gis开发之家3 小时前
《Vue3 从入门到大神33篇》Vue3 源码详解(三):响应式核心思想——从 Object.defineProperty 到 Proxy
前端·javascript·vue.js·vue3·vue3源码
Hyyy3 小时前
流式渲染的基础——SSE
前端·后端·面试
Csvn3 小时前
📐 Layout Thrashing——一个 `offsetHeight` 让你的页面卡成 PPT
前端