前言
在开发 AI 聊天应用时,fetch-event-source 几乎是前端标配。但你是否思考过:为什么原生的 EventSource 不行?它是如何解析二进制流的?当网络波动导致连接“假死”时,如何实现无感重连和数据去重?本文将带你拆解这些核心细节。
一、 为什么原生 EventSource 在 AI 场景“退环境”了?
原生 EventSource 虽好,但在复杂的 AI 业务场景中有两个“致命伤”:
- 仅支持 GET 请求:AI 对话通常需要发送长篇累牍的上下文(Context),URL 长度限制会导致请求失败。
- 无法自定义 Header:无法在请求头中携带
Authorization令牌,给鉴权带来了麻烦。
fetch-event-source 的原理:它是基于原生 fetch 的 ReadableStream(可读流) 实现的。它通过手动解析 HTTP 响应体中的二进制数据,模拟了 SSE 的行为,同时继承了 fetch 支持各种 Method 和 Header 的灵活性。
二、 核心实战:如何处理 SSE 异常中断与超时?
在长连接中,最怕“连接还在,但数据没了”的假死状态。我们需要对库进行二次封装,引入超时检测与指数退避重连。
1. 超时检测机制
设置一个心跳定时器。如果在规定时间内(如 15s)没有收到任何 onmessage 信号,说明连接可能已失效。
- 动作:主动调用
abort()中断当前请求,并触发重连。 - 重置:每当有新数据到达或连接开启时,重置该定时器。
2. 指数退避自动重连
为了减轻服务器压力,重连间隔不应是固定的。
- 策略:从 2s 开始,每次失败翻倍(2s → 4s → 8s...),上限 30s。
- 终止:设置最大重连次数(如 10 次),失败后提示用户“服务器繁忙,请手动重试”。
3. 断点续传与去重
重连后,后端可能会重新推送历史数据。
- 前端方案:维护一个
lastMsgId。请求时带上这个标识,让后端从断点处开始推送;或者前端根据id对收到的消息进行Map去重。
三、 中断超时处理实现:基于fetchEventSource 简易实现
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage, ElMessageBox } from 'element-plus'
// 全局状态管理(避免多请求冲突)
let controller = new AbortController()
let timeoutTimer = null // 超时定时器
let reconnectCount = 0 // 重连次数
let reconnectInterval = 2000 // 初始重连间隔(2s)
const MAX_RECONNECT_COUNT = 10 // 最大重连次数
const MAX_RECONNECT_INTERVAL = 30000 // 最大重连间隔(30s)
let lastMessageId = '' // 记录最后一条消息ID(断点续传用)
/**
* 重置超时定时器(收到消息/建立连接时调用)
* @param {number} timeout 超时时间(默认30s)
*/
const resetTimeoutTimer = (timeout = 30000) => {
// 清除原有定时器
if (timeoutTimer) clearTimeout(timeoutTimer)
// 新建超时定时器:超时未收到消息则主动中断
timeoutTimer = setTimeout(() => {
ElMessage.warning('连接超时,正在尝试重连...')
controller.abort() // 主动中断请求
reconnectStream() // 触发重连
}, timeout)
}
/**
* 重连流式请求(指数退避策略)
* @param {string} url 接口地址
* @param {Object} headers 请求头
* @param {Object} data 请求参数
* @param {Function} handleMessage 消息处理回调
*/
const reconnectStream = async (url, headers, data, handleMessage) => {
// 超过最大重连次数,停止自动重连
if (reconnectCount >= MAX_RECONNECT_COUNT) {
ElMessageBox.alert('服务器繁忙,请稍后手动重试', '重连失败', {
confirmButtonText: '确定'
})
// 重置重连状态
reconnectCount = 0
reconnectInterval = 2000
return
}
// 指数退避:间隔翻倍,不超过30s
const currentInterval = Math.min(reconnectInterval, MAX_RECONNECT_INTERVAL)
ElMessage.info(`第${reconnectCount + 1}次重连,间隔${currentInterval / 1000}s...`)
// 延迟重连
await new Promise((resolve) => setTimeout(resolve, currentInterval))
// 更新重连状态
reconnectCount++
reconnectInterval *= 2
// 重新发起请求(携带最后一条消息ID,实现断点续传)
requestStream(
url,
headers,
{
...data,
lastMessageId: lastMessageId // 传给后端,让后端从断点续传
},
handleMessage
)
}
/**
* 流式请求核心方法(带超时、重连、断点续传)
* @param {string} url 接口地址
* @param {Object} headers 请求头
* @param {Object} data 请求参数
* @param {Function} handleMessage 消息处理回调(接收流式数据)
*/
export const requestStream = (url, headers, data, handleMessage) => {
// 中断原有请求
if (controller) controller.abort()
controller = new AbortController()
// 初始化超时定时器(30s超时检测)
resetTimeoutTimer()
fetchEventSource(url, {
method: 'POST',
signal: controller.signal,
headers: {
...headers,
Accept: 'text/event-stream', // SSE必需头
'Cache-Control': 'no-cache'
},
body: JSON.stringify(data),
openWhenHidden: true, // 页面隐藏时继续请求
async onopen(response) {
console.log('建立连接的回调')
// 连接建立:重置超时定时器+重连状态
resetTimeoutTimer()
reconnectCount = 0
reconnectInterval = 2000
// 校验响应合法性
if (!response.ok) {
throw new Error(`连接失败,状态码:${response.status}`)
}
},
onmessage(msg) {
// 收到消息:重置超时定时器
resetTimeoutTimer()
// 记录最后一条消息ID(断点续传核心)
if (msg.id) lastMessageId = msg.id
// 处理消息(去重逻辑:避免重连后数据重复)
handleMessage(msg)
},
onclose() {
console.log('连接正常关闭')
// 清除定时器+中断请求
if (timeoutTimer) clearTimeout(timeoutTimer)
controller.abort()
// 重置状态
reconnectCount = 0
reconnectInterval = 2000
lastMessageId = ''
},
onerror(err) {
// 清除超时定时器
if (timeoutTimer) clearTimeout(timeoutTimer)
// 手动中断不触发重连(比如用户点击停止)
if (controller.signal.aborted) {
console.log('用户手动中断请求')
return
}
// 异常重连
ElMessage.error(`连接异常:${err.message || '网络错误'}`)
reconnectStream(url, headers, data, handleMessage)
// 必须抛出错误才会停止当前请求循环
throw err
}
})
}
/**
* 停止流式请求(手动中断)
*/
export const stopRequest = () => {
// 清除超时定时器
if (timeoutTimer) {
clearTimeout(timeoutTimer)
timeoutTimer = null
}
// 中断请求
if (controller) {
controller.abort()
controller = new AbortController()
}
// 重置重连状态
reconnectCount = 0
reconnectInterval = 2000
lastMessageId = ''
ElMessage.info('已停止数据请求')
}
四、 注意:关于 Nginx 与浏览器限制
- Nginx 缓存屏蔽:一定要记得设置
proxy_buffering off;,否则 Nginx 会等缓冲区满了才一次性吐给前端,导致流式效果失效。 - 浏览器连接数限制:如果是 HTTP/1.1,浏览器对同一个域名的长连接通常限制在 6 个。如果打开多个 AI 对话页,可能会导致后续连接卡死。建议升级 HTTP/2,它可以多路复用,避开此限制。
- 手动停止 vs 自动重连:当用户点击“停止生成”时,必须标记一个
manualStop状态位,否则onerror可能会误以为是网络异常而不断尝试重连。
五、💡 扩展:异步并发池 (Async Pool)
它不直接用于单个 SSE 连接,但在批量 AI 任务处理(例如一次性给 100 张图片生成描述)时非常有用。它可以限制同时进行的 HTTP 请求数量,防止瞬间撑爆浏览器带宽或后端并发限制。
1. 归属识别:唯一 ID + 专属缓存
- 每个请求分配
requestId(如stream-request-0); streamDataCache以requestId为 key,每个请求的片段只往自己的缓存里加;- 即使多个请求的
onmessage同时触发,也不会串数据(比如stream-request-0的片段绝不会跑到stream-request-1的缓存里)。
2. 有序拼接:数组按顺序存储片段
- 每个请求的缓存里用
fragments数组存储片段; onmessage每次触发时,cache.fragments.push(msg.data)保证片段按返回顺序存储;- 收到结束标识
[DONE]时,用join('')拼接数组,得到完整结果。
3. 并发控制:不等待 Promise 完成,只控制启动数
runningRequestCount记录正在运行的请求数;runTasks里用while (runningRequestCount >= limit)等待,直到有请求结束、并发数下降;- 每个请求结束后(
onclose/onerror),runningRequestCount--,并自动执行下一个任务; - 这种方式既限制了并发数,又不阻塞流式请求的 “持续返回片段”。
/**
* 异步任务池(适配流式请求的并发控制)
* @param {Array<Object>} requestList 批量请求列表(含url/headers/data)
* @param {number} limit 最大并发数
* @param {Function} onComplete 单个请求完成回调(参数:requestId, fullResult)
*/
export const batchStreamRequest = async (requestList, limit = 3, onComplete) => {
// 为每个请求分配唯一ID
const requestListWithId = requestList.map((item, index) => ({
...item,
requestId: `stream-request-${index}`
}))
// 任务执行队列:递归执行,控制并发数
const runTasks = async (taskIndex = 0) => {
// 所有任务处理完毕
if (taskIndex >= requestListWithId.length) return
const currentTask = requestListWithId[taskIndex]
const { requestId, url, headers, data } = currentTask
// 等待:直到并发数低于限制
while (runningRequestCount >= limit) {
await new Promise((resolve) => setTimeout(resolve, 100)) // 每100ms检查一次
}
// 启动当前流式请求
runningRequestCount++
console.log(`启动请求${requestId},当前并发数:${runningRequestCount}`)
// 执行单个流式请求(不等待完成,只标记启动)
singleStreamRequest(requestId, url, headers, data, onComplete)
.catch((err) => console.error(`请求${requestId}失败:`, err))
.finally(() => {
// 当前请求结束后,自动执行下一个任务
runTasks(taskIndex + 1)
})
// 立即执行下一个任务(检查并发数)
runTasks(taskIndex + 1)
}
// 启动任务队列
await runTasks(0)
}
/**
* 停止单个/所有流式请求
* @param {string} [requestId] 可选:指定停止的请求ID,不传则停止所有
*/
export const stopStreamRequest = (requestId) => {
if (requestId) {
// 停止指定请求
const controller = requestControllers[requestId]
if (controller) {
controller.abort()
delete requestControllers[requestId]
// 标记缓存为完成
if (streamDataCache[requestId]) {
streamDataCache[requestId].isCompleted = true
}
runningRequestCount--
}
} else {
// 停止所有请求
Object.keys(requestControllers).forEach((id) => {
requestControllers[id].abort()
delete requestControllers[id]
if (streamDataCache[id]) {
streamDataCache[id].isCompleted = true
}
})
runningRequestCount = 0
ElMessage.info('已停止所有流式请求')
}
}
// ---------------------- 调用示例 ----------------------
// 批量请求列表
const batchRequests = [
{ url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSO单点登录' } },
{ url: '/api/stream/ai', headers: {}, data: { prompt: '介绍Token无感刷新' } },
{ url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSE流式请求' } },
{ url: '/api/stream/ai', headers: {}, data: { prompt: '介绍asyncPool并发控制' } }
]
// 执行批量请求(限制最大并发数2)
batchStreamRequest(batchRequests, 2, (requestId, fullResult) => {
// 单个请求完成后的回调:拿到拼接好的完整结果
console.log(`请求${requestId}完成,完整结果:`, fullResult)
// 这里可以做后续处理:渲染、入库等
})