模板部分(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. 前后端交互流程
- POST /api/chat/submit :提交提问,后端缓存对话,返回唯一
sessionId - GET /api/stream/connect?sessionId=xxx :EventSource 建立长连接,浏览器自动携带
Last-Event-ID请求头断点续传 - 后端每条推送携带
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. 错误分层处理
- 网络断连:进入指数退避重连流程
- 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)
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 请求头用于断点续传。