Vue 3 中如何工程化使用 SSE 服务器发送事件?

来源:站长源码作者:多肉头衔:草根站长
导读:本期聚焦于多肉创作的《Vue 3 中如何工程化使用 SSE 服务器发送事件?》,敬请观看详情。实时推送场景里,WebSocket 常常被优先想到,但它的双向通信和协议复杂度对一些单向通知需求来说过于沉重。SSE 基于普通 HTTP,服务端只需按格式发送文本流,浏览器端用 EventSource 就能接收,自带断线重连和事件 ID 管理,在 Vue 3 工程中反而能实现得更轻快。这篇文章从 Vue 3 的组合式编程习惯出发,先对比 SSE 与 WebSocket 的适用边界,再给出一个可复用的 EventSource 封装,包含连接创建、自动重连、心跳保活和命名事件分发。接着讨论如何把推送到前端的数据安全地接入 ref、reactive 和 Pinia 状态,避免组件卸载后仍然更新状态导致的内存泄漏。另外还会探讨服务端发送格式的规范、错误处理策略以及如何用类型定义约束事件数据结构。整套方案可以直接嵌入现有 Vue 3 项目中作为基础设施。

SSE,全称 Server-Sent Events,是一种允许服务器通过 HTTP 长连接向浏览器单向推送数据的技术。与 WebSocket 相比,它不需要封装复杂的二进制帧协议,也不需要额外的握手升级开销,而是复用了普通的 HTTP 请求与响应模型。在 Vue 3 工程中,SSE 特别适合做行情推送、日志流、任务进度通知这类服务器到前端的单向数据流场景。如果项目仅仅是需要服务器主动发送消息,而客户端很少需要向服务器发实时指令,使用 SSE 往往比引入 WebSocket 更简单、更稳定。

Vue 3 中如何工程化使用 SSE 服务器发送事件?

不过,工程化使用 SSE 并不是在组件里 new 一个 EventSource 然后接收消息这么简单。连接什么时候建立、什么时候关闭、断网后如何重连、事件如何分发、数据如何进入 Vue 的响应式状态,这些问题都会在多人协作的项目中逐渐暴露出来。接下来我们从工程设计角度逐步拆解。

为什么在 Vue 3 中选择 SSE 而不是 WebSocket

WebSocket 是很多开发者首先想到的实时通信方案,但它的强项是全双工通信。聊天室、协同编辑、在线游戏这类需要双向高频交互的场景,WebSocket 是合理的。但对一些业务来说,客户端只需要显示服务端推送的状态,例如用户上传文件后需要知道处理进度,运行中的任务需要随时汇报日志,这类需求本质上只有服务端往客户端发送数据,客户端即使要发送指令,也不过是普通 REST API 可以完成的工作。此时引入 WebSocket 会增加服务端网关、心跳保活、消息协议兼容等多方面的维护成本。

SSE 自带 HTTP 层的兼容性,走的是普通的 GET 请求,只是响应头里设置 Content-Type 为 text/event-stream。浏览器原生提供了 EventSource 对象,支持自动重连和事件 ID 追踪。也就是说,连接一旦断开,浏览器会主动连接服务端,并把 last-event-id 带过去,服务端可以据此补发客户端错过的消息。这个能力在 WebSocket 方案里需要自己实现消息确认和重发机制,而在 SSE 里是现成的。

在 Vue 3 工程中使用 SSE 还有一个响应式整合方面的优势。EventSource 回调函数是原生事件回调,与 Vue 的运行时没有任何耦合,我们可以很自然地把收到的数据封装成 ref 或 reactive 对象,再通过 computed 派生出视图所需的数据。这样做既不需要引入额外的第三方库,也不影响 Vue 的依赖收集机制,数据更新时组件会自动响应。

封装一个可复用的 SSE 客户端

工程化核心是复用和可配置。直接在业务组件里写 EventSource 会带来大量重复代码,还会让组件卸载时忘记调用 close,导致连接泄漏。我们先设计一个通用工具函数,接收 URL 和事件监听器对象,返回一个包含 close 方法的控制句柄。vue 3 中这些逻辑可以封装成 composable,也可以封装成一个纯 TypeScript 模块,再在组件中调用。

下面这段代码实现了一个基础版本的 SSE 客户端工厂函数,支持命名事件监听和自动重连配置。为了让重连行为可控,我们关闭了浏览器默认重连,改用自定义重连策略,这样可以更灵活地控制重连间隔:

export interface SSEOptions {
  url: string
  onMessage?: (data: string) => void
  onError?: (event: Event) => void
  onOpen?: (event: Event) => void
  autoReconnect?: boolean
  reconnectInterval?: number
  maxRetries?: number
  namespace?: string
}

export function createSSE(options: SSEOptions) {
  const client = {
    es: null as EventSource | null,
    retries: 0,
    manualClosed: false,
    timer: null as number | null,
    listeners: new Map<string, (event: MessageEvent) => void>(),
    start() {
      if (!options.url || this.es) return
      const es = new EventSource(options.url)
      this.es = es

      es.onopen = (event) => {
        this.retries = 0
        options.onOpen && options.onOpen(event)
      }

      es.onmessage = (event) => {
        // 通用消息回调
        options.onMessage && options.onMessage(event.data)
      }

      // 自定义命名事件,交给外部注册的监听器
      es.onerror = (event) => {
        options.onError && options.onError(event)
        this.es = null
        if (options.autoReconnect !== false) {
          this.scheduleReconnect()
        }
      }
    },
    scheduleReconnect() {
      if (this.manualClosed) return
      const maxRetries = options.maxRetries ?? Infinity
      if (this.retries >= maxRetries) return
      const delay = options.reconnectInterval ?? 3000
      this.timer = window.setTimeout(() => {
        this.retries++
        this.start()
      }, delay)
    },
    addEventListener(eventName: string, handler: (event: MessageEvent) => void) {
      if (!this.es) return
      this.listeners.set(eventName, handler)
      this.es.addEventListener(eventName, handler)
    },
    close() {
      this.manualClosed = true
      if (this.timer) clearTimeout(this.timer)
      if (this.es) {
        // 移除事件监听,避免对象被引用后无法回收
        this.es.close()
        this.es = null
      }
    }
  }

  client.start()
  return client
}

这个封装的要点在于把重连逻辑从业务代码中剥离出来。es.onerror 触发时,说明连接已经断开,我们先把 this.es 置空,再根据配置决定是否重连。retries 可以结合服务端返回的错误码来决定是否继续重连。另外,close 方法必须放在清理函数里,保证组件卸载时连接被关闭。

在 Vue 3 组件中,我们可以通过 onBeforeUnmount 钩子调用 close,避免连接泄漏:

import { createSSE } from '@/utils/sse'

export function useNotificationStream() {
  let client: ReturnType<typeof createSSE> | null = null

  function start() {
    if (client) client.close()
    client = createSSE({
      url: '/api/notifications/stream',
      onMessage: (data) => {
        // 处理推送数据
      },
      autoReconnect: true,
      reconnectInterval: 2000
    })
  }

  function stop() {
    client?.close()
    client = null
  }

  return { start, stop }
}

当然,如果你的工程中已经引入了 Pinia,最好的做法是把 SSE 的创建与销毁放到 store 的 action 中管理,由 store 统一持有客户端实例,然后通过 store 状态去驱动视图更新。这样不同组件之间共享同一份推送数据时,就不会出现重复创建连接的问题。

将 SSE 数据接入 Vue 3 响应式状态

EventSource 回调里的数据是字符串或经过解析的对象,如果直接赋值给普通变量,Vue 的响应式系统无法感知变化。我们需要把这些数据存入 ref 或 reactive 对象中。最简单的做法是维护一个数组 ref,每次收到新的推送数据就 push 进去,然后组件里基于这个数组渲染列表。对于实时监控场景,可以只保留最近 N 条数据,有效避免内存无限增长。

下面是一个接入 Pinia 的例子,展示了如何把服务端推送的数据变成全局状态:

import { defineStore } from 'pinia'
import { createSSE } from '@/utils/sse'

interface Notice {
  id: number
  title: string
  content: string
  createdAt: string
}

export const useNoticeStore = defineStore('notice', {
  state: () => ({
    list: [] as Notice[],
    connected: false,
    client: null as ReturnType<typeof createSSE> | null
  }),
  actions: {
    startStream() {
      if (this.client) return // 已存在连接,避免重复创建
      this.client = createSSE({
        url: '/api/notices/stream',
        onOpen: () => {
          this.connected = true
        },
        onError: () => {
          this.connected = false
        },
        onMessage: (rawData) => {
          this.addNotice(JSON.parse(rawData) as Notice)
        }
      })
    },
    addNotice(notice: Notice) {
      // 去重并限制长度
      if (this.list.some(item => item.id === notice.id)) return
      this.list.push(notice)
      if (this.list.length > 100) {
        this.list.splice(0, this.list.length - 100)
      }
    },
    stopStream() {
      this.client?.close()
      this.client = null
      this.connected = false
    }
  }
})

在组件中,我们只需要引入 store,然后调用 startStream 即可。由于 store 是响应式的,list 的变更会立即反映到模板中。这里有一个细节值得注意:startStream 中要判断 client 是否已经存在,否则如果组件在 setup 里重复执行,会产生多条 EventSource 连接,给服务器造成不必要的压力。这种保护在工程化中是基础要求。

对于更复杂的数据结构,比如服务端每隔几秒推送一条结构化 JSON,我们可以先用 TypeScript 定义好类型,再用 zod 或简单的 runtime parser 做数据校验。这样做的好处是,当后端调整字段时,前端能在编译期或运行期间尽早暴露问题,而不是在模板中渲染出 undefined 时才排查。

处理事件类型与服务端数据格式

SSE 协议允许服务端发送一种简单文本格式,每个事件由若干字段组成,字段之间用换行分隔。最常用的是 data 字段,代表数据内容;id 字段用于浏览器自动重连时发送 Last-Event-ID 头;event 字段用于声明自定义事件类型;retry 字段则告诉浏览器重连的时间间隔(毫秒)。服务端返回 Content-Type 必须是 text/event-stream,且需要关闭缓存,不然浏览器可能无法实时获取数据。

如果服务端直接发送命名事件,例如 event: notice\ndata: {...}\n\n,那么 EventSource 对象的 onmessage 处理器只处理未声明 event 字段的默认事件,命名事件需要单独用 addEventListener 绑定。我们的封装中提到了 addEventListener 方法,就是为了在基础连接之上提供命名事件分发能力。下面展示一个命名事件的典型推送:

// 服务端推送格式示例 (伪代码)
// 可以通过后端框架的 SSE 工具生成
event: notice
id: 1001
data: {"id": 1001, "title": "系统维护通知", "content": "凌晨2点升级"}

event: heartbeat
data: ping

在 Vue 3 中,我们可以为每个事件类型创建一个处理器,并注册到 EventSource 上。但注意,浏览器原生 EventSource 的 addEventListener 只能管理同一个对象的监听器,一旦关闭就没有办法再自动重新注册。因此封装中的 addEventListener 方法必须保留原有的事件监听器映射。更稳妥的做法是,在封装内部直接维护一个事件处理函数表,然后在连接建立后统一注册。重连时,EventSource 对象会被重新创建,我们需要重新执行注册过程。

为了避免每次重连都重复注册监听器,我们的 createSSE 可以在 start 方法内部通过遍历 listeners 来完成注册。这样,事件监听逻辑只写一次,后续无论是初次连接还是自动重连,都能得到相同的事件绑定。这也是工程化中常见的“注册与连接解耦”模式。

断线重连与心跳保活策略

浏览器 EventSource 自带重连能力,只要连接意外断开,浏览器会按照 retry 字段指定的时长尝试重连。但在一些网关或代理环境中,长连接可能会因为没有数据传输而被空闲超时关闭。为了避免这种情况,服务端需要定期发送一个注释行或心跳事件。HTTP 协议中,SSE 的注释行以冒号开头,客户端会忽略它,但这一行数据会让底层 TCP 连接产生活动,防止被代理服务器回收。

我们也可以在客户端配合一个定时器检测连接的健康状态。典型做法是:如果超过一定时间没有收到任何消息,就主动触发重连。但这种做法需要与服务端约定心跳间隔,比如服务端每 20 秒发送一条心跳注释,客户端每 30 秒检测一次。如果 30 秒内没有收到任何数据,就关闭现有连接,然后重新调用 start。下面是一个简单的客户端心跳检测逻辑:

export function createSSEWithHeartbeat(options) {
  let lastMessageTime = Date.now()
  const heartbeatInterval = options.heartbeatInterval || 30000
  const checkTimer = setInterval(() => {
    const elapsed = Date.now() - lastMessageTime
    if (elapsed > heartbeatInterval * 1.5) {
      // 判断为连接失效,强制重新连接
      client.close()
      client.start()
    }
  }, heartbeatInterval)

  const baseClient = createSSE({
    ...options,
    onMessage: (data) => {
      lastMessageTime = Date.now()
      if (options.onMessage) options.onMessage(data)
    }
  })

  // 监听 open 事件也会重置时间
  baseClient.start = () => {
    lastMessageTime = Date.now()
    baseClient.es = null
    baseClient.retries = 0
    baseClient.start()
  }

  return {
    ...baseClient,
    close() {
      clearInterval(checkTimer)
      baseClient.close()
    }
  }
}

这段代码刻意简化了 start 的覆盖逻辑,实际使用时需要确保不会在重连过程中多次创建定时器。正确的做法是将实时计时器统一挂在封装对象内部,通过一个标志位防止重复启动。另外,重连间隔可以使用指数退避策略,第一次 1 秒,第二次 2 秒,第三次 4 秒,最大不超过 30 秒,这样可以在网络抖动时避免频繁请求服务器。

最后,工程化还意味着要处理用户直接关闭浏览器页签的情况。页面关闭时,浏览器会自动断开 SSE 连接。如果业务对数据完整性要求较高,服务端需要根据 Last-Event-ID 判断从哪个位置补发数据。前端只需确保事件带有 id 字段,并且连接建立后不要禁用原生重连。这样,即使手机短暂断网再恢复,用户也能收到断线期间遗漏的消息,无需手动刷新页面。

Vue 3SSEServer-Sent Events修改时间:2026-08-23 08:18:32

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。