SSE(流式数据)不用原生EventSource而是用fetch
需要认证,灵活控制,或非标准模式时候使用fetch
| 对比项 | EventSource |
fetch + ReadableStream |
|---|---|---|
| ✅ 自动重连 | 是(有限) | 否(需手动) |
✅ 自动解析 data:/id: |
是 | 否(需手动解析) |
| ❌ 自定义 Header | 否(无法加 Authorization 等) |
✅ 可以 |
| ❌ 无法中止连接(旧版) | 需调用 .close() |
✅ 可用 AbortController 精确控制 |
| ❌ 无法处理非标准格式 | 仅支持 text/event-stream |
✅ 可处理任意流(JSONL、自定义文本等) |
| ❌ 无法获取响应头 | 否 | ✅ 可读取 response.headers |
流式响应
服务端立马不关闭;
持续通过Connection:keep-alive保持TCP连接;
分块发送数据(Transfer-Encoding:chunked);
客户端通过response.body.getReader()分块读取;
javascript
const response = await fetch('/stream');
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break; // 连接关闭
console.log('收到数据块:', value); // Uint8Array
}
一旦reader.read()非正常中断或报错 ,重新发起fetch请求,HTTP是无状态的,不能恢复一个已经断开的TCP连接。
中断:网络丢失(网络切换或丢失),服务端重启或者崩溃或其他的原因导致流式数据未完成而中断;
重连:中断后重新发起全新的请求;
重连的两种机制(断点续传,指数退避)
断点续传-Last-Event-ID
SSE协议的标准做法,使用fetch也可以使用这种进行重连。
前端:记录每条消息也就是每条消息块的ID,重连时通过ID进行请求头发送。
javascript
GET /events HTTP/1.1
Last-Event-ID: abc123
后端:读取Last-Event-ID头,从该ID后的消息开始推送,避免重复或丢失消息。
指数退避-Exponential Backoff
在中断发起重连时,通过时间间隔进行重连比如 (第一次/1s 第二次/2s 第三次/4s 第四次/8s)成指数的形式时间间隔避免时间间隔太短带给服务器压力,设置最大时间不超过30s
javascript
delay = min( base * 2^retryCount + random_jitter, max_delay )
代码例子
前端fetchServe.js
javascript
// src/utils/fetchServe.js
/**
* 增强版 SSE 客户端(基于 fetch)
* 特性:
* - 自动检测中断并重连(指数退避)
* - 支持 Last-Event-ID 断点续传
* - 提供 onStatus 回调(connecting / connected / disconnected)
* - 完全兼容 Vue 2 生命周期
*/
export class FetchSSEClient {
constructor(url, { onMessage, onError, onStatus }) {
this.url = url;
this.onMessage = onMessage || (() => {});
this.onError = onError || console.error;
this.onStatus = onStatus || (() => {});
this.lastEventId = null;
this.abortController = null;
this.reconnectTimer = null;
this.retryCount = 0;
this.isClosing = false;
this.isConnected = false;
}
start() {
if (this.isClosing) return;
this.connect();
}
async connect() {
this.cleanup();
// 更新状态:正在连接
this.updateStatus('connecting');
const controller = new AbortController();
this.abortController = controller;
try {
const headers = {};
if (this.lastEventId) {
headers['Last-Event-ID'] = this.lastEventId;
}
const response = await fetch(this.url, {
method: 'GET',
headers: {
Accept: 'text/event-stream',
Connection: 'keep-alive',
...headers,
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
this.retryCount = 0;
this.isConnected = true;
this.updateStatus('connected'); // 连接成功!
while (true) {
const { done, value } = await reader.read();
if (done) {
// 服务端主动关闭(如 Nginx 超时、后端重启)
console.warn('[SSE] 服务端关闭了连接');
this.handleDisconnect();
break;
}
if (value?.length) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let currentId = '';
let currentData = '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('id:')) {
currentId = trimmed.slice(3).trim();
} else if (trimmed.startsWith('data:')) {
currentData = trimmed.slice(5).trim();
} else if (trimmed === '') {
if (currentData) {
try {
const parsed = JSON.parse(currentData);
if (currentId) this.lastEventId = currentId;
this.onMessage(parsed, currentId);
} catch (e) {
console.warn('SSE 解析失败:', currentData);
}
currentData = '';
currentId = '';
}
}
}
}
}
} catch (err) {
if (controller.signal.aborted) return; // 正常关闭
console.error('[SSE] 连接异常:', err.message);
this.onError(err);
this.handleDisconnect();
}
}
/**
* 统一处理连接断开逻辑
*/
handleDisconnect() {
this.isConnected = false;
this.updateStatus('disconnected');
this.scheduleReconnect();
}
/**
* 调度自动重连(指数退避 + 随机抖动)
*/
scheduleReconnect() {
if (this.isClosing || this.reconnectTimer) return;
const baseDelay = 1000;
const maxDelay = 30000;
const delay = Math.min(
baseDelay * Math.pow(2, this.retryCount) + Math.random() * 1000,
maxDelay
);
this.reconnectTimer = setTimeout(() => {
console.log(`[SSE] 自动重连中... 第 ${this.retryCount + 1} 次尝试`);
this.reconnectTimer = null;
this.retryCount++;
this.start();
}, delay);
}
updateStatus(status) {
this.onStatus(status); // 通知外部(如 Vue 组件更新 UI)
}
close() {
this.isClosing = true;
this.cleanup();
this.updateStatus('closed');
}
cleanup() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.retryCount = 0;
}
}
vue2组件EventSource.vue
javascript
<!-- src/components/AutoReconnectSSE.vue -->
<template>
<div style="padding: 20px; font-family: Arial, sans-serif;">
<h2>自动重连 SSE 示例</h2>
<!-- 状态指示器 -->
<div :style="{ color: statusColor }">
● 状态: {{ statusText }}
</div>
<!-- 最新消息 -->
<div v-if="latestMessage" style="margin-top: 15px; padding: 10px; background: #f9f9f9;">
<strong>最新消息 (ID: {{ lastId }}):</strong>
<pre>{{ JSON.stringify(latestMessage, null, 2) }}</pre>
</div>
<!-- 手动重连按钮(仅用于调试) -->
<button
v-if="status !== 'connected'"
@click="forceReconnect"
style="margin-top: 10px; padding: 6px 12px;"
>
强制重连
</button>
</div>
</template>
<script>
import { FetchSSEClient } from '@/utils/FetchSSEClient';
// 状态映射
const STATUS_MAP = {
connecting: { text: '连接中...', color: '#ffa500' },
connected: { text: '已连接', color: '#00aa00' },
disconnected: { text: '连接中断,正在重连...', color: '#ff6600' },
closed: { text: '已关闭', color: '#888' },
};
export default {
name: 'AutoReconnectSSE',
data() {
return {
status: 'connecting',
latestMessage: null,
lastId: null,
sseClient: null,
};
},
computed: {
statusText() {
return STATUS_MAP[this.status]?.text || '未知';
},
statusColor() {
return STATUS_MAP[this.status]?.color || '#000';
}
},
created() {
this.initSSE();
},
beforeDestroy() {
// 清理 防止内存泄漏和重复连接
if (this.sseClient) {
this.sseClient.close();
}
},
methods: {
initSSE() {
this.sseClient = new FetchSSEClient('http://localhost:3000/api/events', {
onMessage: (data, id) => {
this.latestMessage = data;
this.lastId = id;
},
onError: (error) => {
console.error('SSE 错误:', error);
},
onStatus: (status) => {
// 自动同步状态到 Vue 响应式数据
this.status = status;
}
});
this.sseClient.start(); // 启动自动连接
},
forceReconnect() {
if (this.sseClient) {
this.sseClient.close();
}
this.status = 'connecting';
this.initSSE();
}
}
};
</script>
node.js模拟后端服务
javascript
// server.js
const express = require('express');
const cors = require('cors');
const app = express();
// 启用 CORS,允许前端跨域请求,并支持自定义 Header(如 Last-Event-ID)
app.use(cors({
origin: 'http://localhost:8080', // Vue DevServer 默认地址
credentials: true,
// 暴露 Last-Event-ID 给前端(虽然本例中前端不读响应头,但良好实践)
exposedHeaders: ['Last-Event-ID'],
}));
// 模拟一个全局消息队列(生产环境应替换为 Redis、Kafka 或数据库)
let globalMessageId = 0;
const messageHistory = []; // 保存最近的消息,用于断点续传
/**
* 生成一条新消息并存入历史记录
* @returns {{id: string, data: object}} 新消息对象
*/
function generateMessage() {
globalMessageId++;
const msg = {
id: String(globalMessageId), // SSE 要求 id 是字符串
data: {
timestamp: new Date().toISOString(),
value: Math.floor(Math.random() * 100),
messageId: globalMessageId,
}
};
messageHistory.push(msg);
// 限制内存占用:只保留最近 100 条
if (messageHistory.length > 100) messageHistory.shift();
return msg;
}
// 初始化几条消息,确保客户端首次连接就有数据
for (let i = 0; i < 5; i++) generateMessage();
/**
* SSE 流式接口:/api/events
* 支持标准 SSE 格式 + Last-Event-ID 断点续传
*/
app.get('/api/events', (req, res) => {
// 从请求头中读取客户端上次收到的最后消息 ID
const lastId = req.headers['last-event-id'] || null;
console.log(`[SSE] 客户端重连,Last-Event-ID: ${lastId}`);
// 设置响应头 ------ 这是 SSE 的核心!
res.writeHead(200, {
'Content-Type': 'text/event-stream', // 告诉浏览器这是 SSE 流
'Cache-Control': 'no-cache', // 禁用缓存
'Connection': 'keep-alive', // 保持 TCP 连接
'Access-Control-Allow-Origin': '*', // 允许跨域(生产环境应指定域名)
'X-Accel-Buffering': 'no', // 关键!禁用 Nginx/Apache 缓冲
});
// 确定从哪条消息开始推送(实现断点续传)
let startIndex = 0;
if (lastId) {
// 在历史消息中查找 lastId 的位置
const lastIdx = messageHistory.findIndex(m => m.id === lastId);
if (lastIdx !== -1) {
startIndex = lastIdx + 1; // 从下一条开始发送,避免重复
}
// 如果 lastId 太旧(不在 history 中),则从最新或开头发(按业务需求调整)
}
// 推送缺失的历史消息(确保不丢消息)
for (let i = startIndex; i < messageHistory.length; i++) {
const msg = messageHistory[i];
// SSE 标准格式:每条消息以 "id:" 和 "data:" 开头,空行结束
res.write(`id: ${msg.id}\n`);
res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
}
// 启动定时器,持续生成并推送新消息
const interval = setInterval(() => {
const msg = generateMessage();
res.write(`id: ${msg.id}\n`);
res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
// 注意:res.write() 不会自动 flush,但 Node.js 通常会及时发送
}, 2000); // 每 2 秒推送一次
// 监听客户端断开连接(如关闭标签页、网络中断)
req.on('close', () => {
console.log('[SSE] 客户端断开连接,清理定时器');
clearInterval(interval); // 停止生成新消息
res.end(); // 显式关闭响应
});
});
// 启动服务器
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`SSE 服务已启动,请访问 http://localhost:${PORT}`);
});
根据实际对接接口以及需求来参考进行开发