useeventsource vue3 - 完整解决方案与实战教程
在构建实时数据大屏、AI 对话流式输出或站内通知系统时,Vue 3 开发者常会选择基于 SSE(Server-Sent Events)的 @vueuse/core 提供的 useEventSource 组合式函数。然而,由于 useEventSource 底层依赖浏览器原生 EventSource API,它天然不支持自定义请求头,且默认无法发送 POST 请求体,导致在需要携带 Authorization Token、传递复杂查询参数或对接需要 POST 的流式接口时,开发者会遭遇连接 401、参数丢失、无法关闭连接或热更新时重复创建连接等棘手问题。 本文将从真实业务场景出发,逐一拆解这些坑点并给出生产级解决方案。
问题现象
在使用 useEventSource 对接后端流式接口时,通常会遇到以下几类典型报错或异常行为:
- 浏览器控制台报
401 Unauthorized,后端日志显示未收到Authorization头,但前端代码里明明传了 token。 - 需要传递一个长数组或嵌套对象作为筛选条件,但 URL 拼接后超出长度限制,或后端只接受 POST 请求体。
- 组件卸载后网络面板中 SSE 连接依然处于
pending状态,造成内存泄漏与连接数堆积。 - 在 Vite HMR 热更新时,同一个组件被反复挂载,导致同一时间建立多条 SSE 连接,后端连接池被打满。
- 使用
immediate: false后手动调用open(),却发现无法重新连接或状态未同步。
原因分析
要彻底解决上述问题,必须理解 useEventSource 的底层实现约束:
- 原生 EventSource 不支持自定义 Header。 这是 W3C 规范层面的限制,浏览器只允许通过 URL 传递信息,无法像 fetch 那样设置
headers。因此 token 只能走 query string 或 Cookie。 - 原生 EventSource 仅支持 GET 请求。 它没有
method和body配置项,任何需要 POST 体的流式接口都无法直接使用。 - useEventSource 的自动重连机制。 当连接断开时,它会按照
retry策略自动重连,如果组件销毁时没有正确调用close(),重连定时器仍会触发。 - 响应式 URL 的副作用。 当传入的 URL 是
ref或computed时,URL 变化会自动重建连接,若依赖项不稳定(如每次渲染生成新对象),会导致连接抖动。 - Vite HMR 与组合式函数的生命周期。 热更新时旧组件实例的
onUnmounted可能未及时执行,新实例已创建新连接。
解决方案(附完整代码)
下面给出一套生产级封装方案,覆盖「带 Token 的 GET 场景」与「必须 POST 的场景」两种路径。
方案一:GET + Token 场景(基于 useEventSource 封装)
核心思路:将 token 放入 query string,并利用 computed 稳定 URL,配合 onUnmounted 显式关闭连接,同时处理 HMR 重复连接问题。
// composables/useSafeEventSource.ts
import { computed, onUnmounted, ref, watch, type Ref } from 'vue'
import { useEventSource } from '@vueuse/core'
interface Options {
// 业务参数,会被序列化到 URL
params?: Ref<Record<string, any>>
// 是否立即建立连接
immediate?: boolean
// 自定义事件名列表
events?: string[]
}
export function useSafeEventSource(
baseUrl: string,
options: Options = {}
) {
const { params, immediate = true, events = ['message'] } = options
// 1. 稳定 URL:使用 computed 缓存,避免每次渲染生成新字符串
const url = computed(() => {
const u = new URL(baseUrl, window.location.origin)
// 从 localStorage 或 pinia 中读取 token
const token = localStorage.getItem('access_token')
if (token) u.searchParams.set('token', token)
// 合并业务参数
if (params?.value) {
Object.entries(params.value).forEach(([k, v]) => {
if (v !== undefined && v !== null) {
u.searchParams.set(k, String(v))
}
})
}
return u.toString()
})
// 2. 调用 useEventSource,注意 immediate 传 false 由我们手动控制
const { status, data, error, close, open } = useEventSource(url, events, {
immediate: false,
// 关键:自动重连间隔,避免雪崩
autoReconnect: {
retries: 5,
delay: 2000,
onFailed() {
console.error('[SSE] 重连失败,请检查网络或 Token 是否过期')
}
}
})
// 3. 手动控制连接,避免 HMR 重复创建
if (immediate) {
open()
}
// 4. 监听 URL 变化,重建连接(例如筛选条件改变)
watch(url, () => {
close()
open()
})
// 5. 组件卸载时务必关闭,防止内存泄漏
onUnmounted(() => {
close()
})
return { status, data, error, close, open }
}
在组件中使用:
<script setup lang="ts">
import { ref } from 'vue'
import { useSafeEventSource } from '@/composables/useSafeEventSource'
const filters = ref({ roomId: '1001', type: 'chat' })
const { data, status, error } = useSafeEventSource(
'https://api.example.com/sse/stream',
{ params: filters, events: ['message', 'update'] }
)
</script>
<template>
<div>状态:{{ status }}</div>
<div>最新消息:{{ data }}</div>
<div v-if="error">错误:{{ error.message }}</div>
</template>
方案二:必须 POST 的场景(基于 fetch + ReadableStream 手写)
由于原生 EventSource 无法发送 POST,此时需要放弃 useEventSource,改用 fetch 流式读取,并手动解析 SSE 协议格式。
// composables/usePostSSE.ts
import { ref, onUnmounted } from 'vue'
export function usePostSSE(url: string) {
const data = ref('')
const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle')
let controller: AbortController | null = null
async function connect(body: Record<string, any>) {
// 1. 中止上一次未完成的请求
controller?.abort()
controller = new AbortController()
status.value = 'connecting'
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 关键:POST 场景下终于可以带 Authorization 了
Authorization: `Bearer ${localStorage.getItem('access_token')}`
},
body: JSON.stringify(body),
signal: controller.signal
})
if (!res.ok || !res.body) {
throw new Error(`SSE 连接失败:${res.status}`)
}
status.value = 'open'
const reader = res.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
// 2. 逐块读取并解析 SSE 协议
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// SSE 以 \n\n 分隔事件块
const chunks = buffer.split('\n\n')
buffer = chunks.pop() || ''
for (const chunk of chunks) {
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data:')) {
const payload = line.slice(5).trim()
if (payload === '[DONE]') {
status.value = 'closed'
return
}
data.value += payload
}
}
}
}
status.value = 'closed'
} catch (e: any) {
if (e.name !== 'AbortError') {
console.error('[POST SSE] 异常:', e)
status.value = 'closed'
}
}
}
function close() {
controller?.abort()
controller = null
status.value = 'closed'
}
// 组件卸载自动关闭
onUnmounted(close)
return { data, status, connect, close }
}
排查步骤清单
遇到 useEventSource 相关问题时,按以下顺序排查可快速定位:
- 打开 Network 面板,筛选
EventStream类型,确认请求是否真正发出、状态码是否为 200。 - 检查 Request URL 中是否携带了 token 与业务参数,若缺失说明 URL 拼接逻辑有误。
- 查看 Response Headers 是否包含
Content-Type: text/event-stream,缺失则后端配置有误。 - 若使用 Nginx 反代,确认已关闭
proxy_buffering,否则消息会被缓冲无法实时推送。 - 检查组件卸载后 Network 中连接是否关闭,未关闭说明
close()未执行或onUnmounted未注册。 - HMR 场景下,可在
import.meta.hot中手动调用close()清理旧连接。
总结来说,useEventSource 适合 GET + 无需自定义 Header 的轻量场景,一旦涉及鉴权头或 POST 体,务必切换到 fetch + ReadableStream 方案。同时牢记:任何 SSE 连接都必须在组件卸载时显式关闭,这是避免内存泄漏与连接数爆炸的第一原则。