随着AI对话的发展,SSE又火了!

现在 AI 对话发展迅速,而这个功能主要就是基于 SSE,因此我们面试时,SSE 会有很高的频率被问到,这里简单聊一聊 SSE。

  1. 概念与适用场景

  • 什么是 SSE?SSE(Server-Sent Events)是浏览器端通过 EventSource 与服务端建立的一条长期单向连接(HTTP),服务端可以持续推送文本事件到客户端。协议基于普通 HTTP(text/event-stream),使用服务器连续写入并 flush 的方式发送数据。

HTTP1.1 起开始支持 SSE 。

  • 适用场景​(单向、频率中等到高、对实时性有要求但不需要双向交互):
    • 实时日志 / 监控面板
    • 实时通知 / 活动推送(消息提醒、系统广播)
    • 数据流(股票/行情的只读流)
    • 长轮询替代:更高效、延迟更低
  • 不适合的场景​:
    • 双向大量实时交互(聊天/多人游戏)→ 用 WebSocket
    • 需要二进制帧(SSE 仅文本)→ 可 base64 编码但非优雅
  1. SSE 与 WebSocket / Long Poll 比较

  • 单向 vs 双向​:SSE 是服务器 → 客户端单向流;WebSocket 是全双工。
  • 协议复杂度​:SSE 基于 HTTP(简单),WebSocket 握手更复杂(升级协议)。
  • 连接管理​:SSE 每个浏览器 tab 一条持久 HTTP 连接(但需要注意浏览器具有并发连接数限制);WebSocket 也是 1:1 持久连接。
  • 可靠性 & 重连​:SSE 原生支持自动重连与 Last-Event-ID 机制;WebSocket 需自己实现重连逻辑。
  • 二进制​:WebSocket 原生支持二进制;SSE 只能发文本(可以 encode)。
  1. 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 并保持连接不关闭。

  1. 浏览器端 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));
});
  1. 服务端实现

关键点:

  • 必须设置 Content-Type: text/event-streamCache-Control: no-cacheConnection: 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 负担。

常用的解决方案:

  1. 尽量后端发送最小变更(deltas / HTML fragment / token)而不是全部重新发送;
    1. 服务端把已经生成的 HTML 片段(或每个单词/句子的 HTML)按事件下发,使客户端只需把新片段 appendChild 到容器,而不是重新 innerHTML 整块替换。
    2. 优点:最省主线程(无需再次解析 markdown);缺点:需要后端生成 HTML(如果是在服务端渲染流中比较常见,比如 SSR/streaming render)。
  2. 把重 CPU 的解析任务放到 Web Worker(避免阻塞主线程);
    1. SSE 发送“chunk”(可以是文本 token、行、单词等)。
    2. Worker 接收 chunk,逐步用 markdown-it(或其它流式解析器)转换成 HTML 片段并 postMessage 给主线程(可使用 postMessage(fragment, [transfer]) 传字符串)。
    3. 主线程收集多个 fragment,用 requestAnimationFrame 批量 createContextualFragment 并 append。
  3. 在主线程上尽量做增量 DOM 更新并批量化(用 rAF / microtask 批处理);
  4. 对重复/回滚情况用 id/序号去重,避免重复渲染。
posted @ 2025-12-08 11:24  秀秀不只会前端  阅读(2)  评论(0)    收藏  举报