每次看到新项目仅仅为了做后台给前台单向推送个系统通知、通知栏红点,或者像大模型对话那种流式文本返回,就火急火燎地架设一套 WebSocket 服务的,我都忍不住想吐槽。WS 虽好,但心智负担实在太重了。你得自己写应用层心跳以应对运营商的各种空闲丢弃、折腾断网重连、还要配置复杂的 Nginx 升级规则。如果只是单向推送数据,底层的 Server-Sent Events (SSE) 才是真正省时省力的银弹。

SSE 是基于常规 HTTP 协议的单向流式传输技术。它的原理极其朴素:客户端发起一个普通的 HTTP 请求,服务端通过将响应头设为 text/event-stream,然后保持这个 TCP 连接不关闭,以纯文本流的形式不断地往下游泵数据。更牛的是,浏览器内部的 EventSource 规范自带了自动断线重连、事件标识 ID 追踪以及自动回退补偿等能力。

我们来看看一个标准的 SSE 服务端流数据格式到底长什么样:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Access-Control-Allow-Origin: *

id: 1001
event: message
retry: 5000
data: {"user": "admin", "msg": "系统维护通知:今晚 12 点进行数据库割接"}

id: 1002
event: alert
data: {"level": "warning", "msg": "磁盘空间不足 10%"}

不需要任何奇奇怪怪的二进制协议解析,全是明文,调试起来直接在 Chrome DevTools 的 Network 面板里就能看得一清二楚。

下面是一个在 Node.js 中使用原生 HTTP 模块实现的最简 SSE 广播推送服务:

const http = require('http');

const clients = new Set();

const server = http.createServer((req, res) => {
  if (req.url === '/api/stream') {
    // 设置必要的 SSE 响应头
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*'
    });

    // 写入第一个心跳/确认包
    res.write(': ok\n\n');
    clients.add(res);

    // 监听连接断开,移除客户端
    req.on('close', () => {
      clients.delete(res);
    });
    return;
  }
});

// 定时向所有已连接的客户端广播消息
setInterval(() => {
  const payload = JSON.stringify({ timestamp: Date.now(), status: "running" });
  const dataString = `id: ${Date.now()}\nevent: system_status\ndata: ${payload}\n\n`;
  
  for (const client of clients) {
    client.write(dataString);
  }
}, 3000);

server.listen(4000, () => console.log('SSE server running on port 4000'));

前端消费这段 SSE 流的逻辑简单到令人发指,直接用浏览器自带的 EventSource API 几行搞定,再也不用套什么 Socket.io 或者写指数退避的重连类了:

const eventSource = new EventSource('http://localhost:4000/api/stream');

// 监听默认的 message 事件
eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log("收到默认消息:", data);
};

// 监听自定义的 system_status 事件
eventSource.addEventListener('system_status', (event) => {
  const status = JSON.parse(event.data);
  console.log("状态更新:", status);
});

eventSource.onerror = (err) => {
  console.error("连接异常,浏览器正在准备自动重连...");
};

这里要补充一个排坑要点:如果你在 Nginx 后面跑 SSE,必须在 location 块里配置 proxy_buffering off;,不然 Nginx 会自作聪明地把服务端的文本流缓存到一定大小后才一次性吐给前端,导致所谓的“流式输出”变成“憋大招式一次性吐出”。

综上所述,除非你做的是在线棋牌、多人协作白板或者多人射击游戏这种需要极致低延迟、强双工双向交互的场景,否则大部分的实时看板、消息盒子、日志查看器直接无脑上 SSE 即可。

不过,SSE 在老旧的 HTTP/1.1 下存在一个浏览器连接数限制的大坑(通常同一个域名最大限制为 6 个连接)。你们在没有升级 HTTP/2 的遗留项目里是怎么规避这个限制的?使用 SharedWorker 共享连接,还是干脆上域分片?