本文深入对比了前端实现 SSE(Server-Sent Events)流式请求的三种主流方案,包括原理剖析、代码示例、优劣对比和实战踩坑经验,帮助开发者选择最适合的技术方案。
一、什么是 SSE?
SSE(Server-Sent Events)是 HTML5 标准的一部分,允许服务器主动向客户端推送数据。与 WebSocket 双向通信不同,SSE 是单向的(服务器 → 客户端),但实现更简单,且基于 HTTP 协议。
SSE 的典型应用场景
- AI 对话流式输出:ChatGPT、Claude 等 AI 助手的打字机效果
- 实时通知推送:消息提醒、系统通知
- 实时数据更新:股票行情、体育比分
- 进度监控:文件上传进度、任务执行状态
SSE 协议格式
data: 消息内容\n\n
完整格式包含多个字段:
event: message\n id: 1\n retry: 3000\n data: 消息内容\n\n
data:- 消息内容(必需)event:- 事件类型(可选,默认为 message)id:- 消息 ID(可选,用于断线重连)retry:- 重连间隔(可选,单位毫秒)\n\n- 事件分隔符(两个换行符)
二、三种实现方案对比
方案一:原生 EventSource API
基本用法
const eventSource = new EventSource('/api/sse');
eventSource.onmessage = (event) => {
console.log('收到消息:', event.data);
};
eventSource.onerror = (error) => {
console.error('连接错误:', error);
eventSource.close();
};
// 手动关闭连接
eventSource.close();
完整封装示例
class SSEClient {
constructor(url, options = {}) {
this.url = url;
this.options = options;
this.eventSource = null;
this.isCompleted = false;
}
connect() {
this.eventSource = new EventSource(this.url);
this.eventSource.onmessage = (event) => {
// 检查结束信号
if (event.data === '[DONE]') {
this.close('done');
return;
}
// 触发消息回调
this.options.onMessage?.(event.data);
};
this.eventSource.onerror = () => {
if (!this.isCompleted) {
this.options.onError?.(new Error('SSE 连接异常'));
}
this.close('error');
};
}
close(reason = 'manual') {
if (this.isCompleted) return;
this.isCompleted = true;
this.eventSource?.close();
this.options.onComplete?.(reason);
}
}
// 使用示例
const client = new SSEClient('/api/chat', {
onMessage: (data) => {
console.log('收到:', data);
},
onComplete: (reason) => {
console.log('结束:', reason);
},
onError: (err) => {
console.error('错误:', err);
},
});
client.connect();
// 手动停止
client.close();
优势
✅ 零依赖:浏览器原生 API,无需引入任何库
✅ 自动解析:浏览器自动处理 SSE 协议解析
✅ 自动重连:连接断开后自动重连(可配置 retry 时间)
✅ 稳定可靠:浏览器原生实现,经过充分测试
✅ 代码简洁:核心逻辑仅需 20-30 行代码
劣势
❌ 仅支持 GET 请求:无法发送 POST 请求体
❌ 无法自定义 Header:除了 Cookie,无法添加其他 Header(如 Authorization)
❌ 认证限制:只能通过 URL 参数或 Cookie 传递认证信息
适用场景
- 后端接口支持 GET 请求
- 认证信息通过 Cookie 自动携带
- 追求零依赖和最简实现
- 不需要自定义 Header
常见踩坑
1. 跨域问题
// 错误:跨域请求会失败
const es = new EventSource('https://other-domain.com/sse');
// 解决方案 1:服务器设置 CORS
// Access-Control-Allow-Origin: *
// Access-Control-Allow-Credentials: true
// 解决方案 2:使用代理
const es = new EventSource('/api/proxy/sse');
2. 自动重连导致重复请求
// 问题:连接断开后会自动重连,可能导致重复请求
// 解决方案:服务器端使用 id 字段去重
// event: message
// id: 123
// data: 消息内容
// 客户端重连时会自动发送 Last-Event-ID header
3. 无法传递 POST 数据
// 错误:EventSource 不支持 POST
const es = new EventSource('/api/chat', {
method: 'POST', // 无效!
body: JSON.stringify({ message: 'Hello' })
});
// 解决方案:先通过 POST 保存数据,再用 GET 建立 SSE 连接
async function sendMessage(message) {
// 1. POST 保存消息
const res = await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify({ message }),
});
const { messageId } = await res.json();
// 2. GET 建立 SSE 连接
const es = new EventSource(`/api/sse?messageId=${messageId}`);
}
方案二:Fetch API + ReadableStream
基本用法
async function fetchSSE(url, options = {}) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token',
},
body: JSON.stringify({ message: 'Hello' }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
options.onComplete?.();
break;
}
// 解码二进制数据
buffer += decoder.decode(value, { stream: true });
// 按行分割(SSE 以 \n\n 分隔事件)
const lines = buffer.split('\n\n');
buffer = lines.pop() || ''; // 保留不完整的行
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
options.onComplete?.();
return;
}
options.onMessage?.(data);
}
}
}
}
// 使用示例
fetchSSE('/api/chat', {
onMessage: (data) => {
console.log('收到:', data);
},
onComplete: () => {
console.log('完成');
},
});
核心原理详解
1. ReadableStream 流式读取
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value 是 Uint8Array 类型的二进制数据
console.log(value);
}
2. TextDecoder 解码
const decoder = new TextDecoder();
// { stream: true } 很重要!
// 处理多字节字符被拆分到不同 chunk 的情况
const text = decoder.decode(value, { stream: true });
为什么需要 { stream: true }?
// 假设 "你好" 的 UTF-8 编码被拆分到两个 chunk
// chunk1: [0xE4, 0xBD] // "你" 的前两个字节
// chunk2: [0xA0, 0xE5, 0xA5, 0xBD] // "你" 的最后一个字节 + "好"
const decoder = new TextDecoder();
// 不使用 stream: true(错误)
decoder.decode(chunk1); // 乱码或报错
decoder.decode(chunk2); // 乱码或报错
// 使用 stream: true(正确)
decoder.decode(chunk1, { stream: true }); // "" (等待完整字符)
decoder.decode(chunk2, { stream: true }); // "你好"
3. Buffer 缓冲区管理
let buffer = '';
// 收到 chunk1: "data: Hello\n"
buffer += "data: Hello\n";
const lines = buffer.split('\n\n'); // ["data: Hello\n"]
buffer = lines.pop(); // buffer = "data: Hello\n"
// 收到 chunk2: "\ndata: World\n\n"
buffer += "\ndata: World\n\n"; // "data: Hello\n\ndata: World\n\n"
const lines = buffer.split('\n\n'); // ["data: Hello", "data: World", ""]
buffer = lines.pop(); // buffer = ""
// 处理完整的事件
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
onMessage(data); // "Hello", "World"
}
}
4. 支持中断
const controller = new AbortController();
fetch(url, {
signal: controller.signal,
});
// 中断请求
controller.abort();
完整封装示例
class FetchSSEClient {
constructor(url, options = {}) {
this.url = url;
this.options = options;
this.controller = new AbortController();
}
async connect() {
try {
const response = await fetch(this.url, {
method: this.options.method || 'POST',
headers: this.options.headers || {},
body: this.options.body,
signal: this.controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
this.options.onComplete?.('done');
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
this.options.onComplete?.('done');
return;
}
this.options.onMessage?.(data);
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
this.options.onComplete?.('abort');
} else {
this.options.onError?.(error);
this.options.onComplete?.('error');
}
}
}
abort() {
this.controller.abort();
}
}
// 使用示例
const client = new FetchSSEClient('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token',
},
body: JSON.stringify({ message: 'Hello' }),
onMessage: (data) => {
console.log('收到:', data);
},
onComplete: (reason) => {
console.log('结束:', reason);
},
onError: (err) => {
console.error('错误:', err);
},
});
client.connect();
// 手动停止
client.abort();
优势
✅ 支持 POST 请求:可以发送请求体
✅ 自定义 Header:可以添加任意 Header(如 Authorization)
✅ 完全控制:可以自定义超时、重试等逻辑
✅ 零依赖:浏览器原生 API
✅ 支持中断:使用 AbortController 中断请求
劣势
❌ 需要手动解析 SSE 协议:需要处理 data:、event:、id: 等字段
❌ 需要处理 chunk 边界:一个事件可能被拆分到多个 chunk
❌ 需要管理 buffer:手动拼接不完整的数据
❌ 代码复杂度高:核心逻辑需要 50+ 行代码
❌ 容易出错:边界情况处理不当会导致数据丢失或重复
适用场景
- 需要 POST 请求和自定义 Header
- 需要完全控制请求行为
- 后端返回的不是标准 SSE 格式
- 需要读取响应 Header 或状态码
常见踩坑
1. 忘记使用 { stream: true }
// 错误:多字节字符会乱码
const text = decoder.decode(value);
// 正确
const text = decoder.decode(value, { stream: true });
2. buffer 管理错误
// 错误:直接 split 会丢失不完整的数据
const lines = buffer.split('\n\n');
for (const line of lines) {
// 处理...
}
buffer = ''; // 错误!最后一个可能不完整
// 正确:保留最后一个元素
const lines = buffer.split('\n\n');
buffer = lines.pop() || ''; // 保留不完整的部分
for (const line of lines) {
// 处理...
}
3. 没有处理中断
// 错误:没有处理 AbortError
try {
await fetch(url, { signal });
} catch (error) {
console.error(error); // AbortError 也会被当作错误
}
// 正确:区分 AbortError
try {
await fetch(url, { signal });
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消');
} else {
console.error('请求失败:', error);
}
}
4. 代理层缓冲问题
// 问题:Nginx 等代理可能会缓冲响应,导致消息延迟
// 解决方案:服务器端配置
// Nginx:
// proxy_buffering off;
// proxy_cache off;
// proxy_set_header Connection '';
// proxy_http_version 1.1;
// chunked_transfer_encoding off;
// Node.js:
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 立即发送 headers
方案三:@microsoft/fetch-event-source
基本用法
npm install @microsoft/fetch-event-source
import { fetchEventSource } from '@microsoft/fetch-event-source';
fetchEventSource('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token',
},
body: JSON.stringify({ message: 'Hello' }),
onmessage(event) {
if (event.data === '[DONE]') {
return;
}
console.log('收到:', event.data);
},
onclose() {
console.log('连接关闭');
},
onerror(err) {
console.error('错误:', err);
throw err; // 抛出错误会停止重连
},
openWhenHidden: true, // 页面隐藏时保持连接
});
高级用法
class MSFetchSSEClient {
constructor(url, options = {}) {
this.url = url;
this.options = options;
this.ctrl = new AbortController();
}
async connect() {
await fetchEventSource(this.url, {
method: this.options.method || 'POST',
headers: this.options.headers || {},
body: this.options.body,
signal: this.ctrl.signal,
onmessage: (event) => {
if (event.data === '[DONE]') {
this.abort();
return;
}
this.options.onMessage?.(event.data);
},
onclose: () => {
this.options.onComplete?.('done');
},
onerror: (err) => {
this.options.onError?.(err);
// 抛出错误会停止重连
throw err;
},
openWhenHidden: true,
});
}
abort() {
this.ctrl.abort();
}
}
// 使用示例
const client = new MSFetchSSEClient('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token',
},
body: JSON.stringify({ message: 'Hello' }),
onMessage: (data) => {
console.log('收到:', data);
},
onComplete: (reason) => {
console.log('结束:', reason);
},
onError: (err) => {
console.error('错误:', err);
},
});
client.connect();
// 手动停止
client.abort();
优势
✅ 支持 POST 请求:可以发送请求体
✅ 自定义 Header:可以添加任意 Header
✅ 自动解析 SSE 协议:无需手动处理
✅ 内置自动重连:连接断开后自动重连
✅ 页面隐藏时保持连接:openWhenHidden 选项
✅ 体积小:~2KB gzip
✅ TypeScript 支持:完整的类型定义
劣势
❌ 需要引入外部依赖:增加打包体积
❌ 自动重连可能不符合需求:需要手动控制
❌ 错误处理复杂:需要抛出错误才能停止重连
❌ 中断机制不直观:需要使用 AbortController
适用场景
- 需要 POST 请求和自定义 Header
- 需要自动重连功能
- 不介意引入外部依赖
- 追求开发效率
常见踩坑
1. 自动重连导致重复请求
// 问题:连接断开后会自动重连,可能导致重复请求
// 解决方案 1:抛出错误停止重连
onerror(err) {
console.error(err);
throw err; // 停止重连
}
// 解决方案 2:使用 AbortController
const ctrl = new AbortController();
fetchEventSource(url, {
signal: ctrl.signal,
onerror(err) {
ctrl.abort(); // 停止重连
},
});
2. 无法获取完成状态
// 问题:onclose 回调无法区分正常结束还是错误结束
// 解决方案:自己维护状态
let isCompleted = false;
fetchEventSource(url, {
onmessage(event) {
if (event.data === '[DONE]') {
isCompleted = true;
ctrl.abort();
}
},
onclose() {
if (isCompleted) {
console.log('正常结束');
} else {
console.log('异常结束');
}
},
});
3. 页面隐藏时连接断开
// 问题:默认情况下,页面隐藏时连接会断开
// 解决方案:设置 openWhenHidden
fetchEventSource(url, {
openWhenHidden: true, // 页面隐藏时保持连接
});
三、方案对比总结
| 维度 | EventSource | Fetch + ReadableStream | @microsoft/fetch-event-source |
|---|---|---|---|
| 支持 POST | ❌ 仅 GET | ✅ | ✅ |
| 自定义 Header | ❌ 仅 Cookie | ✅ | ✅ |
| SSE 协议解析 | ✅ 浏览器原生 | ❌ 需手动实现 | ✅ 自动处理 |
| 自动重连 | ✅ 浏览器原生 | ❌ 需手动实现 | ✅ 内置 |
| 手动中断 | ✅ close() | ✅ AbortController | ⚠️ AbortController + throw |
| 代码复杂度 | 低(~30行) | 高(~50行) | 中(~10行) |
| 外部依赖 | 零依赖 | 零依赖 | ~2KB |
| 浏览器兼容性 | IE 10+ | 现代浏览器 | 现代浏览器 |
| 学习成本 | 低 | 高 | 中 |
| 适用场景 | GET + Cookie 认证 | 完全自定义 | POST + 自动重连 |
四、选型建议
选择 EventSource 的情况
- ✅ 后端接口支持 GET 请求
- ✅ 认证信息通过 Cookie 自动携带
- ✅ 追求零依赖和最简实现
- ✅ 不需要自定义 Header
选择 Fetch + ReadableStream 的情况
- ✅ 需要完全控制请求行为
- ✅ 后端返回的不是标准 SSE 格式
- ✅ 需要读取响应 Header 或状态码
- ✅ 需要自定义超时、重试逻辑
选择 @microsoft/fetch-event-source 的情况
- ✅ 需要 POST 请求和自定义 Header
- ✅ 需要自动重连功能
- ✅ 不介意引入外部依赖
- ✅ 追求开发效率
五、实战经验总结
1. 如何处理认证
方案 A:Cookie(推荐 EventSource)
// 服务器设置 Cookie
res.cookie('token', 'xxx', { httpOnly: true });
// 客户端自动携带
const es = new EventSource('/api/sse');
方案 B:URL 参数(适用 EventSource)
const token = 'xxx';
const es = new EventSource(`/api/sse?token=${token}`);
方案 C:Authorization Header(需要 Fetch)
fetch('/api/sse', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
2. 如何处理超时
EventSource(无法设置超时)
// 解决方案:手动实现超时
const es = new EventSource('/api/sse');
const timeout = setTimeout(() => {
es.close();
console.log('超时');
}, 30000);
es.onmessage = () => {
clearTimeout(timeout);
};
Fetch(使用 AbortController)
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
fetch('/api/sse', {
signal: controller.signal,
});
3. 如何处理重连
EventSource(自动重连)
// 服务器端设置重连间隔
res.write('retry: 3000\n\n'); // 3 秒后重连
// 客户端无法禁用自动重连
// 只能通过 close() 关闭连接
Fetch(手动重连)
async function connectWithRetry(url, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
await fetchSSE(url, { /* ... */ });
break; // 成功,退出循环
} catch (error) {
if (i === maxRetries - 1) {
throw error; // 最后一次重试失败
}
await new Promise(resolve => setTimeout(resolve, 3000));
}
}
}
4. 如何处理代理层缓冲
问题: Nginx 等代理可能会缓冲响应,导致消息延迟
解决方案:
# Nginx 配置
location /api/sse {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
// Node.js 服务器
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // 禁用 Nginx 缓冲
res.flushHeaders(); // 立即发送 headers
5. 如何处理跨域
CORS 配置:
// 服务器端
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
代理方案:
// 开发环境使用代理
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'https://backend.com',
changeOrigin: true,
},
},
},
};
六、总结
SSE 是实现服务器推送的简单而强大的技术,三种实现方案各有优劣:
- EventSource:零依赖、最简单,适合 GET 请求场景
- Fetch + ReadableStream:完全控制、最灵活,适合复杂场景
- @microsoft/fetch-event-source:功能完善、开箱即用,适合快速开发
选择哪种方案取决于具体需求,建议优先考虑 EventSource,只有在需要 POST 或自定义 Header 时才使用其他方案。
参考资料:
- MDN - Server-sent events
- MDN - EventSource
- MDN - Fetch API
- MDN - ReadableStream
- @microsoft/fetch-event-source