随着AI对话的发展,SSE又火了!
现在 AI 对话发展迅速,而这个功能主要就是基于 SSE,因此我们面试时,SSE 会有很高的频率被问到,这里简单聊一聊 SSE。
-
概念与适用场景
- 什么是 SSE?SSE(Server-Sent Events)是浏览器端通过
EventSource与服务端建立的一条长期单向连接(HTTP),服务端可以持续推送文本事件到客户端。协议基于普通 HTTP(text/event-stream),使用服务器连续写入并 flush 的方式发送数据。
HTTP1.1 起开始支持 SSE 。
- 适用场景(单向、频率中等到高、对实时性有要求但不需要双向交互):
- 实时日志 / 监控面板
- 实时通知 / 活动推送(消息提醒、系统广播)
- 数据流(股票/行情的只读流)
- 长轮询替代:更高效、延迟更低
- 不适合的场景:
- 双向大量实时交互(聊天/多人游戏)→ 用 WebSocket
- 需要二进制帧(SSE 仅文本)→ 可 base64 编码但非优雅
-
SSE 与 WebSocket / Long Poll 比较
- 单向 vs 双向:SSE 是服务器 → 客户端单向流;WebSocket 是全双工。
- 协议复杂度:SSE 基于 HTTP(简单),WebSocket 握手更复杂(升级协议)。
- 连接管理:SSE 每个浏览器 tab 一条持久 HTTP 连接(但需要注意浏览器具有并发连接数限制);WebSocket 也是 1:1 持久连接。
- 可靠性 & 重连:SSE 原生支持自动重连与
Last-Event-ID机制;WebSocket 需自己实现重连逻辑。 - 二进制:WebSocket 原生支持二进制;SSE 只能发文本(可以 encode)。
-
SSE 协议格式(文本格式,必须以
\n\n分隔事件)
服务端发送的文本由多行字段组成,常用字段:
data: <内容>— 事件的主体(可以多行,多行data:连续会拼接为一个事件)。id: <event-id>— 事件 ID,用于重连后服务器可通过Last-Event-ID恢复(若连接断开并重连,浏览器会在请求头中自动带上Last-Event-ID: <id>(如果上次事件包含id:),服务器可据此补发未接事件或跳过重复)。event: <event-name>— 自定义事件类型(客户端可以用es.addEventListener('event-name', fn)监听)。retry: <ms>— 告诉客户端重连等待时间(覆盖默认值)。- 注释行以
:开头(例如:keep-alive),客户端忽略,常用于心跳或绕过代理超时。
示例消息(完整):
id: 42
event: price
data: {"symbol":"AAPL","price":171.23}
多行 data 示例(会被拼接为包含换行的字符串):
data: line1
data: line2
每个事件以空行(\n\n)终止;服务器必须设置 Content-Type: text/event-stream 并保持连接不关闭。
-
浏览器端 API(EventSource)
- 客户端重连:浏览器
EventSource会自动重连,默认间隔约 3 秒。服务端可通过retry: <ms>字段设置客户端重连时间,或客户端手动实现自定义重连逻辑(在自定义封装里)。
最基本示例:
const es = new EventSource('/sse/stream');
es.onopen = () => console.log('connected');
es.onmessage = (e) => {
// 默认接收 event: 'message'
const data = JSON.parse(e.data);
console.log('message', data);
};
es.onerror = (err) => {
console.error('sse error', err);
};
// 自定义事件
es.addEventListener('price', (e) => {
console.log('price event', JSON.parse(e.data));
});
-
服务端实现
关键点:
- 必须设置
Content-Type: text/event-stream、Cache-Control: no-cache、Connection: keep-alive。 - 每次发送数据后
res.write()并尽可能res.flush()(在 Node 中可用res.flush()或使用compression/helmet注意)。 - 保持连接打开(不
res.end()),直到要断开或客户端断开。 - 对大量连接需考虑并发限制、心跳、超时、连接清理。
简单 Express 示例:
import express from 'express';
import { createServer } from 'http';
const app = express();
app.get('/sse', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 可发送初始事件
res.write(`:ok\n\n`); // 注释行,避免某些代理超时
let id = 0;
const timer = setInterval(() => {
id++;
const payload = { ts: Date.now() };
res.write(`id: ${id}\n`);
res.write(`event: heartbeat\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
// 如果使用 compression,请确保 flush
}, 5000);
req.on('close', () => {
clearInterval(timer);
console.log('client closed connection');
});
});
createServer(app).listen(3000);
如果设置 Keep-Alive: timeout=10,服务端超过 10s 没发送数据,会超时断开,我们一般的解决方案是服务端定期发送心跳(keep-alive)/注释行。
在 SSE 中常用注释行 : \n\n 或发送空事件 /heartbeat event,周期应小于代理超时时间(例如代理 10s,则心跳一般 5s 或更小)。
app.get('/sse', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(': connected\n\n'); // 注释行可以避免某些代理把第一个 chunk buffer 掉
const heartbeat = setInterval(() => {
// 注释行以 `:` 开头是 SSE 的规范支持,浏览器忽略数据但能维持 TCP 活动。
res.write(': heartbeat\n\n'); // 注释行,不会触发 onmessage,但会保持连接活跃
}, 5000);
req.on('close', () => {
clearInterval(heartbeat);
});
});
这样,我们在逐词渲染 markdown 时怎么就不可避免的遇到重复渲染带来的性能问题:
- 每次把完整 markdown→HTML→innerHTML 写回 DOM,浏览器需要做 parse HTML、构建 DOM 节点、样式计算、布局(reflow)、绘制(repaint)。频繁做会占用主线程,多次触发布局抖动(尤其是
innerHTML覆盖整块内容)。 - 另外客户端若每个 chunk 都运行完整 Markdown 解析(如 markdown-it)也会造成 CPU 负担。
常用的解决方案:
- 尽量后端发送最小变更(deltas / HTML fragment / token)而不是全部重新发送;
- 服务端把已经生成的 HTML 片段(或每个单词/句子的 HTML)按事件下发,使客户端只需把新片段
appendChild到容器,而不是重新innerHTML整块替换。 - 优点:最省主线程(无需再次解析 markdown);缺点:需要后端生成 HTML(如果是在服务端渲染流中比较常见,比如 SSR/streaming render)。
- 服务端把已经生成的 HTML 片段(或每个单词/句子的 HTML)按事件下发,使客户端只需把新片段
- 把重 CPU 的解析任务放到 Web Worker(避免阻塞主线程);
- SSE 发送“chunk”(可以是文本 token、行、单词等)。
- Worker 接收 chunk,逐步用
markdown-it(或其它流式解析器)转换成 HTML 片段并postMessage给主线程(可使用postMessage(fragment, [transfer])传字符串)。 - 主线程收集多个 fragment,用
requestAnimationFrame批量createContextualFragment并 append。
- 在主线程上尽量做增量 DOM 更新并批量化(用 rAF / microtask 批处理);
- 对重复/回滚情况用 id/序号去重,避免重复渲染。

浙公网安备 33010602011771号