1、SSE 需要配合http2实现
HTTP/1.1 协议下,浏览器对同一个域名下**的并发 TCP 连接数有严格限制,通常为 6 个。
应用中打开了 6 个 SSE 连接,当尝试打开第 7 个页签或发起一个新的普通请求时,请求会被浏览器挂起,直到前面的某个连接关闭。如图
HTTP/2 引入了多路复用机制,改变了这一局面。
最大并发流限制默认通常为 100,如果应用极其复杂,在一个页面内打开了上百个流,依然会触碰到上限。
2、断线重连与数据连续性
2.1 关键陷阱:为什么我的重连不带 Last-Event-ID?
- 后端发送的报文格式不标准,没有id字段
- 手动断开后的再次重连不会携带
2.2 心跳机制优化:心跳包是否该带 ID?(通常不带)
每 15-30 秒发一次 :ping\n\n。维持 TCP 长连接,这样不会触发前端onmessage逻辑,减少前端 CPU 的消耗。并且不用在业务逻辑里过滤心跳数据。
3、代理与网关适配
3.1 proxy_buffering off:为什么我的流是“一坨一坨”出来的,而不是“一个字一个字”?
Nginx 默认会开启缓冲区,攒满一波数据(通常是 4k 或 8k)再发给客户端,以提高网络效率
3.2 listen 443 ssl http2:开启HTTP/2协议,解决HTTP/1.1的同域名请求限制的问题
4、SSE错误处理/重连机制
在生产环境中,我们通常将错误分为两类:致命错误(需立即停止)和非致命错误(需尝试恢复)。
4.1 致命错误: 401 & 其他4开头的状态,throw 并手动处理,停止重试。
4.2 致命错误: 500/502/503 (服务器异常),保持沉默,让库自动进行指数退避重连。
4.3 重试原则: onopen中抛出错误,会在onerror中接收到。在onerror中继续向上抛出错误,才会停止重试。
4.4 错误处理原则: 在onopen中只做错误的分发,抛出错误。在onerror中判断终止重试并进行业务逻辑编写。
为什么要这么设计错误处理?
1、职责分离,符合单一职责原则,onopen负责“诊断”,onerror负责“处理和决策”
2、避免重复处理,onopen中抛出的错误不能直接终止重试,需要在onerror中再次抛出才会终止重试,这样就可能在onerror中需要再次检查状态码
3、无法利用 onerror 的返回值控制重试时间,比如401,在onopen中直接跳转登录了。
5、代码实现
/**
* SSE 连接管理
* 全局连接:消息通过 EventBus 广播
* 业务连接:消息直接回调处理
*/
import { fetchEventSource } from "@microsoft/fetch-event-source";
import { eventBus, EventNames } from "@/utils/eventBus";
import { config } from "@/api/axios/config";
import router from "@/router";
import type { SSEEventHandler, SSEConnectionConfig, BusinessSSEOptions } from "./types";
const { base_url } = config;
export type { SSEEventHandler, SSEConnectionConfig, BusinessSSEOptions };
// 致命错误:不可重试的错误(如401认证失败、4xx客户端错误)
class FatalError extends Error {
constructor(message: string) {
super(message);
this.name = "FatalError";
}
}
// 可重试错误:临时性错误(如5xx服务器错误、网络问题)
class RetriableError extends Error {
constructor(message: string) {
super(message);
this.name = "RetriableError";
}
}
export class SSEConnection {
private controller: AbortController | null = null;
private lastEventId: string | null = null;
private config: SSEConnectionConfig;
constructor(config: SSEConnectionConfig) {
this.config = config;
}
async connect(): Promise<void> {
if (this.controller) return;
const token = getAccessToken(); // 获取token
this.controller = new AbortController();
const method = this.config.method || "GET";
const headers: Record<string, string> = {
Accept: "text/event-stream",
Authorization: `Bearer ${token}`,
// 可手动维护last-event-id,也可以交由库维护
...(this.lastEventId ? { "last-event-id": this.lastEventId } : {})
};
if (method === "POST" && this.config.body) {
headers["Content-Type"] = "application/json";
}
try {
await fetchEventSource(this.config.url, {
method,
headers,
body: method === "POST" && this.config.body ? JSON.stringify(this.config.body) : undefined,
signal: this.controller.signal,
openWhenHidden: false,
onopen: async response => {
if (response.ok) {
this.config.isGlobal && eventBus.emit(EventNames.SSE_CONNECTED, { timestamp: Date.now() });
this.config.onConnected?.();
return;
}
if (response.status === 401) {
throw new FatalError("登录失效,请重新登录");
}
if (response.status >= 400 && response.status < 500) {
throw new FatalError(`客户端错误: ${response.status}`);
}
console.log(`[SSE ${this.config.id}] 连接失败 ${response.status},1秒后重试...`);
await new Promise(resolve => setTimeout(resolve, 1000));
throw new RetriableError(`服务器暂时不可用: ${response.status}`);
},
onmessage: event => {
if (event.id) this.lastEventId = event.id;
let data: any;
try {
data = JSON.parse(event.data);
} catch {
data = event.data;
}
const eventName = event.event || "message";
if (this.config.isGlobal) {
// 全局连接:通过 EventBus 广播
eventBus.emit(eventName, data);
} else {
this.config.handlers?.[eventName]?.(data);
}
},
onerror: error => {
this.config.isGlobal && eventBus.emit(EventNames.SSE_ERROR, { error, timestamp: Date.now() });
this.config.onError?.(error);
if (error instanceof FatalError) {
if (error.message.includes("登录失效")) {
removeToken();
router.replace(LOGIN_URL);
}
this.disconnect();
throw error; // 向上抛出,彻底停止重试
}
return 1000;
},
onclose: () => {
this.config.isGlobal && eventBus.emit(EventNames.SSE_DISCONNECTED, { timestamp: Date.now() });
this.config.onClosed?.();
}
});
} catch (error: any) {
console.error(`SSE 连接失败 [${this.config.id}]:`, error);
}
}
disconnect(): void {
this.controller?.abort();
this.controller = null;
}
isConnected(): boolean {
return this.controller !== null;
}
}
let globalConnection: SSEConnection | null = null;
// 初始化全局 SSE 连接
export const initSSEConnection = async (url?: string) => {
if (globalConnection?.isConnected()) return;
globalConnection = new SSEConnection({
id: "global",
url: url || `${base_url}/xxxx`,
isGlobal: true
});
await globalConnection.connect();
};
export const disconnectSSE = () => {
globalConnection?.disconnect();
globalConnection = null;
};
// 构建查询参数字符串
const buildQueryString = (params: Record<string, any>): string => {
const queryParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
queryParams.append(key, String(value));
}
});
return queryParams.toString();
};
// 创建业务 SSE 连接
export const createBusinessSSE = (options: BusinessSSEOptions): SSEConnection => {
const { id, path, method = "GET", params, eventHandlers, onConnected, onClosed, onError } = options;
let url = `${base_url}${path}`;
let body: Record<string, any> | undefined;
if (method === "GET" && params) {
const queryString = buildQueryString(params);
if (queryString) url += `?${queryString}`;
} else if (method === "POST") {
body = params;
}
const connection = new SSEConnection({
id,
url,
method,
body,
isGlobal: false,
handlers: eventHandlers,
onConnected,
onClosed,
onError
});
// 自动连接
connection.connect();
return connection;
};
export default { initSSEConnection, disconnectSSE, createBusinessSSE };
- 通过
initSSEConnection方法创建全局的SSE连接,可以用于网站的广播、消息等,通过eventBus来发布各种类型的消息,在不同业务中接收消息并解析。 - 通过
createBusinessSSE方法创建业务SSE连接,如在进入某个页面或点击某个按钮建立连接,在离开的时候断开连接,专为某一种类型的业务而建立的SSE连接。 - 两个方法实际都是调用
SSEConnection类来创建SSE实例,SSE的错误、重试、分发等机制统一封装在此类中,这部分代码整体可公用,后续无需再更改。
以上就是我在生产中遇到的问题和最后的结论,后续还会针对fetch-event-source库的源码进行详细分析,欢迎大家关注~