useeventsource vue3 - 完整解决方案与实战教程

在构建实时数据大屏、AI 对话流式输出或站内通知系统时,Vue 3 开发者常会选择基于 SSE(Server-Sent Events)的 @vueuse/core 提供的 useEventSource 组合式函数。然而,由于 useEventSource 底层依赖浏览器原生 EventSource API,它天然不支持自定义请求头,且默认无法发送 POST 请求体,导致在需要携带 Authorization Token、传递复杂查询参数或对接需要 POST 的流式接口时,开发者会遭遇连接 401、参数丢失、无法关闭连接或热更新时重复创建连接等棘手问题。 本文将从真实业务场景出发,逐一拆解这些坑点并给出生产级解决方案。

问题现象

在使用 useEventSource 对接后端流式接口时,通常会遇到以下几类典型报错或异常行为:

原因分析

要彻底解决上述问题,必须理解 useEventSource 的底层实现约束:

  1. 原生 EventSource 不支持自定义 Header。 这是 W3C 规范层面的限制,浏览器只允许通过 URL 传递信息,无法像 fetch 那样设置 headers。因此 token 只能走 query string 或 Cookie。
  2. 原生 EventSource 仅支持 GET 请求。 它没有 methodbody 配置项,任何需要 POST 体的流式接口都无法直接使用。
  3. useEventSource 的自动重连机制。 当连接断开时,它会按照 retry 策略自动重连,如果组件销毁时没有正确调用 close(),重连定时器仍会触发。
  4. 响应式 URL 的副作用。 当传入的 URL 是 refcomputed 时,URL 变化会自动重建连接,若依赖项不稳定(如每次渲染生成新对象),会导致连接抖动。
  5. 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 相关问题时,按以下顺序排查可快速定位:

  1. 打开 Network 面板,筛选 EventStream 类型,确认请求是否真正发出、状态码是否为 200。
  2. 检查 Request URL 中是否携带了 token 与业务参数,若缺失说明 URL 拼接逻辑有误。
  3. 查看 Response Headers 是否包含 Content-Type: text/event-stream,缺失则后端配置有误。
  4. 若使用 Nginx 反代,确认已关闭 proxy_buffering,否则消息会被缓冲无法实时推送。
  5. 检查组件卸载后 Network 中连接是否关闭,未关闭说明 close() 未执行或 onUnmounted 未注册。
  6. HMR 场景下,可在 import.meta.hot 中手动调用 close() 清理旧连接。

总结来说,useEventSource 适合 GET + 无需自定义 Header 的轻量场景,一旦涉及鉴权头或 POST 体,务必切换到 fetch + ReadableStream 方案。同时牢记:任何 SSE 连接都必须在组件卸载时显式关闭,这是避免内存泄漏与连接数爆炸的第一原则。