1. 一个让页面"一字一字往外蹦"的需求,最后我选了SSE
事情是这样的:上个月接了一个数据报表页面的需求,产品经理要求页面里的分析结果像ChatGPT一样一段一段地"打字"出来,而不是等了半天白屏之后一次性吐给你。我第一反应是这有什么难的,轮询不就完了,前端setInterval每秒钟去拉一次最新状态。但真去推敲的时候发现,如果数据生成要三五秒甚至更久,轮询的实时性、服务端压力、代码复杂度都很别扭。
后来我在项目里用了SSE,也就是Server-Sent Events,服务端主动往客户端推送事件流。这名字听着像个新框架,其实它不是什么高深的东西——本质就是一次普通的HTTP请求,只是客户端不关闭连接,服务端把数据分成一小块一小块连续往响应体里写,浏览器端用内置的EventSource对象去接收。整个过程单向的,只有服务器推给客户端,典型的应用场景就是这种"服务端生成、客户端等待"的流式输出。
这篇博文我打算把这套东西从协议细节、服务端实现、客户端接入、到Streaming模式的Markdown渲染器,以及我在真实环境里踩过的代理缓冲、断线重连、连接数限制这些坑,完整捋一遍。适合正在做AI对话流式输出、实时日志、消息通知、数据大屏这些场景的人,想搞明白到底该用SSE还是WebSocket,或者是想自己从零实现一个SSE流式输出Markdown渲染器的朋友。
先说结论:SSE比WebSocket轻得多,浏览器原生支持,断线重连是协议自带的行为。它不是用来替代WebSocket的,而是在"服务器单向持续推送"这个场景里,你压根不需要WebSocket的双向通道,那套复杂的握手、心跳、协议帧反而成了负担。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SSE协议核心:一次"永远结束不了"的HTTP响应
2.1 从EventSource到text/event-stream
SSE的客户端入口极其简单,浏览器原生提供了一个全局的EventSource类。它只需要接收一个URL,然后监听message事件就能收到服务端推过来的消息:
javascript复制const es = new EventSource('http://localhost:3000/events');
es.onmessage = (event) => {
console.log('收到数据:', event.data);
};
es.onopen = () => {
console.log('连接已建立');
};
es.onerror = (err) => {
console.log('连接异常,浏览器会自动重连');
};
就这么几行代码,没有第三方库,没有打包依赖,也没有WebSocket那样的连接实例管理。核心原因在于EventSource内部默认把该请求的Accept头设置成了text/event-stream,服务端一旦识别到这个类型,就知道要开始"长时间的流式输出"了。
服务端的最简实现大概是这个感觉(用Node.js原生http模块):
javascript复制const http = require('http');
const server = http.createServer((req, res) => {
if (req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
// 每隔1秒推送一条数据
let id = 0;
const timer = setInterval(() => {
res.write(`id: ${id}\n`);
res.write(`data: 当前时间 ${new Date().toLocaleTimeString()}\n\n`);
id++;
}, 1000);
// 客户端断开连接时清理定时器
req.on('close', () => {
clearInterval(timer);
res.end();
});
} else {
res.end('Hello SSE');
}
});
server.listen(3000, () => {
console.log('SSE server running at http://localhost:3000');
});
关键点在于那几行响应头:Content-Type: text/event-stream告诉浏览器这次响应不是普通HTML,而是一个持续写入的事件流;Cache-Control: no-cache禁止中间缓存;Connection: keep-alive保持TCP连接不关闭。
2.2 数据帧格式:几个字段撑起全部协议
SSE协议的文本格式非常克制,它不定义二进制帧,就是纯文本,用换行符分隔字段。每条消息由一行或多行字段名: 值组成,以空行结尾。常用的字段有四个:
| 字段 | 作用 | 示例 |
|---|---|---|
data: |
消息内容,可多行,浏览器会自动用换行拼接 | data: hello |
event: |
自定义事件名,客户端用addEventListener监听 | event: chat |
id: |
消息ID,断线重连时通过Last-Event-ID请求头回传 | id: 42 |
retry: |
指定重连间隔(毫秒),默认浏览器约3秒 | retry: 5000 |
最常见的是data字段。多条data:换行后,浏览器会拼成一条数据,data: A\ndata: B\n\n最终收到的是"A\nB"。
有一行容易被忽略:以冒号开头的行是注释行,客户端收到会直接忽略。这个特性被很多服务端拿来当"心跳保活",因为某些网络设备会静默掐断空闲的连接。定期发一条注释行,比如: keep-alive\n\n,就能让连接保持活跃。这是我在生产环境里确认过非常有用的技巧,后面会在这篇文章的经验部分专门再讲。
2.3 断线重连不是"扩展功能",是协议内置行为
WebSocket断了就是断了,你要自己实现心跳、重连、消息补偿。而SSE不一样,EventSource一旦检测到连接异常,会自动按照协议重新发起请求。如果服务端在下发每条消息时带了id字段,浏览器在重连时会自动带上Last-Event-ID这个请求头,服务端读到这个值就能知道上次发到哪条了,把漏掉的消息继续推下去。
光这一条,就能让不少团队的架构简单很多。不需要在前端维护重连逻辑,不需要在应用层做复杂的消息确认,服务端只要读一下请求头,就知道要续传哪条。
retry字段的作用是调节重连频率。默认EventSource在连接断开后大约等待3秒重试。如果服务端在下发消息时携带了retry: 5000,浏览器会改为等待5秒再重试。注意,这个值一般在服务端消息流里下发,浏览器接收后自动生效,不需要前端额外写代码。
3. 先别急着用WebSocket:SSE和轮询、WebSocket的适用边界
3.1 轮询:实现简单,但成本被平摊到每一秒
如果数据生成只需要30秒到1分钟,轮询其实是可以接受的方案。但轮询的问题在于:你每隔1秒发一次请求,即使服务端没有任何新数据,HTTP请求和响应头也要完整走一遍。几十个用户可以接受,几百个用户同时打开页面,每秒钟就是几百次请求,服务端大量算力消耗在空转上。
更尴尬的是,轮询的请求频率和服务端的生成完成时间没有对齐。服务端恰好第2.5秒生成完,你第2秒查到没数据,第3秒才查到,这1秒的延迟在实时感要求高的交互场景里,非常容易被用户察觉。
3.2 WebSocket:灵活,但引入的复杂度是真实存在的
WebSocket本质上是TCP之上的独立协议,连接建立要先经过HTTP Upgrade握手,之后客户端和服务端之间的消息走的是二进制帧,双向实时性都很好,在线聊天、协同编辑、实时游戏这些必须用WebSocket。
但代价是:它需要一个独立的协议栈,客户端和服务端的连接状态管理、心跳、掉线重连、消息序列化这些都要自己写或者引库。如果服务端横跨多个节点,WebSocket还要考虑连接路由、消息广播、Session共享这些分布式问题。很多团队引入WebSocket之后,光是被迫处理"连接状态同步"就折腾了很久。
3.3 SSE:单向推送场景里的"刚刚好"
SSE的价值在于:当你明确只需要"服务器往客户端推"这一件事的时候,它把复杂度降到了最低。HTTP协议,普通GET请求,现有负载均衡、鉴权中间件、nginx配置全部可以直接复用,不需要专门的服务器支持。浏览器原生EventSource自动重连,协议自带事件ID续传,这是后端开发们最愿意看到的省心方案。
我自己的判断标准很直接:
- 如果只有服务端需要推数据给客户端,且有较长数据流,优先SSE;
- 如果需要双向频繁交互(消息、信令、白板),才上WebSocket;
- 对实时性要求极低、数据量小的低频状态查询,轮询也许更simple。
技术选型不是越炫越好,关键是让这个场景的代码量最少、出错概率最低。SSE在很多业务里就是那个"最少"的方案。
4. 从零搭一个SSE生产级服务端:Node.js实操与在线测试
4.1 服务端完整实现:断线续传与连接清理
刚才给的只是一个demo,生产环境里至少要处理连接断开、超时保活、任务编排。下面这是一个更接近于我实际项目的版本,模拟一个耗时的"生成任务":
javascript复制const http = require('http');
const { EventEmitter } = require('events');
const taskBus = new EventEmitter();
const clients = new Set();
// 模拟耗时生成任务:进度从0到100
function startTask(taskId, clientRes) {
let progress = 0;
const timer = setInterval(() => {
progress += 10;
const payload = JSON.stringify({ taskId, progress, status: 'running' });
clientRes.write(`id: ${progress}\n`);
clientRes.write(`event: task-progress\n`);
clientRes.write(`data: ${payload}\n\n`);
if (progress >= 100) {
clearInterval(timer);
clientRes.write(`id: 100\n`);
clientRes.write(`event: task-done\n`);
clientRes.write(`data: ${JSON.stringify({ taskId, status: 'done' })}\n\n`);
}
}, 200);
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
// 处理SSE连接
if (url.pathname === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'X-Accel-Buffering': 'no',
});
// 从Last-Event-ID恢复进度
const lastEventId = parseInt(req.headers['last-event-id'] || '0', 10);
res.write(`retry: 3000\n\n`);
res.write(`data: connected, lastId=${lastEventId}\n\n`);
clients.add(res);
// 如果有新的生成任务,按需启动
startTask(Date.now(), res);
// 连接关闭,清理
req.on('close', () => {
clients.delete(res);
console.log('client disconnected, total clients:', clients.size);
});
return;
}
// 健康检查
if (url.pathname === '/health') {
res.end('ok');
return;
}
res.writeHead(404);
res.end('Not Found');
});
server.listen(3000, () => {
console.log('SSE production server running at :3000');
});
注意这里加了X-Accel-Buffering: no,这个头在通过Nginx反向代理时极其重要,后面第6章踩坑部分会专门分析。
服务端开发里最容易忽略的一步是:req.on('close')一定要清理定时器和连接对象。很多SSE服务跑着跑着连接数暴涨,就是因为客户端刷新页面后,旧的响应对象还留在内存里,定时器还在跑,数据还在往一个已经断掉的socket里写。内存泄漏往往就是这么来的。
4.2 客户端完整接入:监听、断线提示与手动关闭
前端部分除了最基础的onmessage,还需要处理连接状态、自定义事件和主动关闭。
javascript复制const taskId = 'abc123';
const es = new EventSource(`/events?taskId=${taskId}`);
es.addEventListener('task-progress', (e) => {
const data = JSON.parse(e.data);
// 更新进度条
document.getElementById('progress').style.width = data.progress + '%';
});
es.addEventListener('task-done', (e) => {
const data = JSON.parse(e.data);
document.getElementById('status').textContent = '任务完成';
es.close(); // 任务结束,主动关闭连接
});
es.onopen = () => {
document.getElementById('status').textContent = '已连接';
};
es.onerror = () => {
document.getElementById('status').textContent = '连接中断,正在重试...';
// 注意:不需要手动es.open(),EventSource自己会重连
};
有一点值得反复强调:es.close()是前端主动断开唯一手段。如果你在onerror里误调用了es.close(),浏览器不会自动重连,这是不少新手踩过的坑——本来想让断线后重试,结果把人家的原生重连关掉了。
4.3 在线SSE测试工具与调试习惯
联调SSE接口时,不能光靠浏览器Console看数据,我建议至少准备两类工具:
第一类是命令行工具。curl -N是验证SSE服务最快的办法,加-N能关闭缓冲,实时输出内容:
bash复制curl -N http://localhost:3000/events
如果你用的是macOS,可以试试tail -f配合其他工具,但最统一的方式还是curl。看到类似这样的输出就说明服务端推送正常:
code复制retry: 3000
data: connected, lastId=0
id: 10
event: task-progress
data: {"taskId":123,"progress":10,"status":"running"}
第二类是在线SSE客户端测试网站。浏览器地址栏访问data:text/html,<script>new EventSource('你的地址').onmessage=e=>document.body.innerHTML+=e.data+'<br>'</script>这种书签方式可以做临时轻量测试,或者直接用网上现成的SSE测试页面,填上URL就能看到实时流。
调试SSE时我习惯同时开两个视角:一个是DOM看到的效果,一个是Network面板里的Events标签页。Chrome DevTools的Network tab会专门显示EventStream类型的请求,你能看到每一条事件的时间戳,排查"哪一条消息没到达"非常方便。
5. 进阶实战:SSE流式输出Markdown渲染器的核心处理逻辑
5.1 为什么流式Markdown渲染不能"每来一段就整篇渲染"
最近跟不少做AI应用的团队聊天,大家都在做"AI回答流式输出",而AI回答的内容绝大多数是Markdown格式。难点不在于把SSE数据接到手,而在于渲染这一层:如果用常规的markdown-it或marked直接渲染,每当SSE推来新一截内容,你都拿"当前已收到的全部文本"重新渲染一遍,问题马上就来了——
- 代码块还没闭合,渲染器会把后面的内容全部当成代码,界面直接乱掉;
- 表格正在拼装中,td/th数量不齐,输出会错位;
- 网络抖动一次,整篇文章频繁重渲染,浏览器计算开销高,还把用户的滚动位置搞乱。
所以真正要做的不是"拿到多少就渲染多少",而是"对不完整的Markdown做容忍性处理",业内通常叫Streaming Markdown Rendering。
5.2 半块处理策略:从边界截断替代整篇重渲染
我推荐一个稳妥的组合方案,同时做两级优化:
第一级:对不完整内容做"闭环修正",也就是在渲染前把常见的不完整语法临时"补全"。
比如文本以```开头但还没有闭合标签,那就先把末尾可能是半个代码块的部分去掉,渲染主体内容;等后续片段到了,再整体渲染一次。
第二级:利用渲染库的增量渲染能力。前端处理Markdown流式渲染,如果你用的是React生态,react-markdown配合remark相关插件,再加上一个递增的文本缓冲state,虽然每次仍是重新渲染,但配合虚拟DOM的diff,性能开销可控。如果追求极致性能,现在社区里也有专门的streaming markdown渲染库,它内部维护每次增量收到的文本片段token,只对新增部分做AST合并,思路很像编辑器的增量更新。
下面是一个简单的"末尾半个代码块"兜底方案示例:
javascript复制function sanitizePartialMarkdown(text) {
// 统计代码块开头数量
const openCount = (text.match(/```/g) || []).length;
// 奇数个```说明代码块未闭合,把最后一个```之前的内容当作完整内容渲染
if (openCount % 2 === 1) {
const lastIndex = text.lastIndexOf('```');
return text.slice(0, lastIndex);
}
return text;
}
这个函数不完美,但能应对90%的情况。代码块、列表、表格这些结构在流式输出时天然会"半截",服务端可以做的是尽量把"换行边界"作为断句位置,客户端做的是把不完整的片段"暂时过滤掉",等语义完整了再补上。
5.3 节流合并与滚动位置的坑
SSE推送频率如果很高(比如大模型输出每几十毫秒就一段token),渲染层每收到一段就立刻更新DOM,页面会非常卡。我的策略是加一层节流:前端用一个队列缓冲SSE消息,每100~150ms把队列里的增量合并渲染一次。这样视觉上依然流畅,又不会把CPU打满。
另一个需要注意的细节是滚动。流式输出时用户一般盯着底部看,如果内容往上顶导致页面跳动,体验很差。要让scrolling容器保持跟随,可以在每次渲染后做一个判断:如果用户当前滚动位置接近底部,就自动滚到底部;如果用户已经往上翻查看历史内容,就不要强制拉回。最粗暴的做法是每次都window.scrollTo(0, document.body.scrollHeight),结果用户一翻历史就被拽走,这是我在真实项目里被用户吐槽过的问题。
6. 真实环境里的坑:Nginx缓冲、连接数限制、断线假象
6.1 Nginx代理时必须关掉的缓冲
这是SSE上线最常见的坑。前端明明直连后端没问题,但走一层Nginx反向代理之后,数据就不再"流式"了——往往是等了好几分钟,一次性全部吐出来。原因是Nginx默认开启了proxy_buffering,它会先把上游返回的内容缓冲起来,等响应结束再发给客户端,彻底破坏了event-stream的实时性。
在Nginx配置里必须显式关闭:
nginx复制location /events {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_read_timeout 3600s;
}
那几个配置的含义分别是:proxy_buffering off关闭缓冲,让数据第一时间转发;proxy_cache off防止CDN或Nginx缓存层介入;proxy_http_version 1.1和proxy_set_header Connection ''是让Nginx与上游之间使用长连接,否则每个SSE连接可能被后端识别为短连接提前关闭;proxy_read_timeout调大,避免Nginx认为上游长时间没响应而主动断开。
如果你用的是云厂商的七层负载均衡,也务必看看有没有类似的响应缓冲配置项。很多云产品默认就是开启缓冲的,不关掉SSE根本跑不起来。
6.2 浏览器HTTP/1.1并发连接数限制
HTTP/1.1协议下,浏览器对同一个域名最多维护6个TCP连接。SSE每个连接都会长期占用一个,所以如果页面上同时开了多个EventSource,或者还有其他轮询请求,很容易触发浏览器等待连接池释放,表现为第7个请求一直pending。
解决办法有几个方向:
- 同一个页面尽量只保留一个SSE连接,多业务模块共用一个连接、按event类型区分消息;
- 升级到HTTP/2,浏览器对同域名并发流的上限大幅提升,多个SSE连接不再互相挤占;
- 把不同业务的SSE分布到不同子域名(注意跨域配置会更麻烦)。
我自己的项目里基本都采用了"单连接多事件"的架构,一个/events接口,后端通过event: user-msg、event: system-alert、event: task-progress等事件名区分不同业务,前端统一addEventListener分发。这种设计既省连接又方便管理。
6.3 断线重连的"假象"与Last-Event-ID的实战用法
有一个情况容易让人困惑:EventSource断线自动重连后,如果服务端没有做Last-Event-ID处理,客户端会从第一条重发,业务上可能出现消息重复或顺序错乱。
我在生产环境里的习惯是:
- 服务端生成消息时,
id字段用单调递增的数字或数据库自增ID; - 客户端收到一条消息后,如果业务需要严格去重,可以在本地记录lastMessageId;
- 服务端读取
req.headers['last-event-id'],如果有值,从这个ID之后的消息开始推;没有值,则是全新连接,从头推。
注意,如果你做一个AI对话场景,每次用户发一条新问题就新建一个流式连接,那Last-Event-ID其实往往用不上——因为每次连接的数据范围是固定的、不可续传的,断线重连之后需要重新发起生成。这种情况下要做的反而是:在服务端生成一个"流式会话ID",前端重连时带上这个ID,服务端根据会话ID恢复生成状态。这本质上已经超出SSE协议本身,变成应用层设计了。
6.4 readyState是理解EventSource状态的钥匙
EventSource实例有一个readyState属性,三个值分别是:
| readyState | 值 | 含义 |
|---|---|---|
| CONNECTING | 0 | 连接建立中或断线等待重连 |
| OPEN | 1 | 已建立连接,事件可以正常到达 |
| CLOSED | 2 | 连接已关闭,且不会自动重连 |
排查问题时,我最常用的手段就是在console里手动查看es.readyState。如果是0,说明连接没建立或正在重连;如果是2,说明某处调用了es.close(),或者服务端返回了非200状态导致浏览器放弃重连。
这里还有一个容易踩的坑:如果服务端返回的Content-Type不是text/event-stream,EventSource会直接触发error事件,并且不会重试。所以联调时先curl确认响应头,再折腾前端代码。
6.5 兼容性与生产环境建议
SSE在Chrome、Firefox、Safari、Edge这些现代浏览器里都原生支持。唯一的例外是老版本IE完全没有这个对象。现在国内的大多数企业级应用还在用“兼容到Chrome 90以上”的基线,基本不用担心。如果真的需要兼容IE,只能用轮询或引入polyfill,但这类方案已经非常边缘了。
还有一个场景需要留意:移动端WebView。部分安卓内置WebView对EventSource支持不一致,如果要在App的WebView里用SSE,建议先做一次特性检测:
javascript复制if (typeof EventSource !== 'undefined') {
// 正常走SSE
} else {
// 降级方案:轮询或提示更新WebView
}
7. 写在最后的几点实操心得
按惯例分享几个我认为最实用的经验,这些是文档里不会明确写、但实战里反复验证过的。
第一,不要给SSE加鉴权header。EventSource的构造函数只接受URL,不支持自定义headers。如果你要做token鉴权,最干净的方式是放在URL query参数里,或者通过cookie。我选择URL参数+短期有效token的方案,搭配服务端校验,在安全性和实现成本之间平衡得最好。
第二,心跳不要只发空注释,可以顺便带上服务端时间戳。我习惯每30秒发一条data: {"type":"heartbeat","ts":...},前端在onmessage里把本地时间和服务端时间做个差值,既可以用来感知时钟偏差,也能确认链路是活的。如果连续2分钟没收到任何消息,再考虑主动重连,因为某些移动网络网元在静默期会掐掉长连接。
第三,日志要记录每条消息的id和发送时间戳,否则排查"哪条消息丢了"的时候全靠猜。尤其在生产环境,SSE服务运行了很多天,偶尔出现某条消息没到达,只有足够完善的日志才能快速定位是客户端断线、服务端没发、还是中间代理吞掉了。
第四,SSE的"连接建立"不等于"时序保证"。SSE保证了消息按发送顺序到达,但不保证消息间的相对时序一定符合业务逻辑——比如任务取消事件可能先于任务结束事件被前端接收到的事,在分布式场景下是完全可能的。涉及状态变更的消息,建议在消息体里带上业务序号或时间戳,前端根据业务序号做乱序兜底。
做技术选型那会儿我也纠结过,是不是非得上WebSocket才能体现架构的前瞻性。实际做下来发现,能用HTTP解决的事情,用HTTP解决永远是最稳的。SSE这个技术不新,但它在LLM流式输出的浪潮下又焕发了第二春,值得每一个做前端和后端的同学都熟练掌握。
如果你正在做SSE相关的功能,这篇内容基本覆盖了从协议到生产环境的全链路。卡在哪个环节,顺着第2章到第6章的思路排查,大概率能解决问题。
