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

不过,工程化使用 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