记得十年前我刚入行时,前端获取实时数据只能靠不断刷新页面。后来Ajax出现了,我们开始用轮询(Polling)技术,每隔几秒就向服务器问一次:"有新数据吗?"。这种方式就像个固执的小孩,每隔五分钟就要问妈妈"到了没",不仅浪费流量,还让服务器不堪重负。
长连接(Long Polling)算是轮询的升级版,客户端发起请求后,服务器会一直hold住连接,直到有数据更新才响应。这就像打电话订餐时,服务员让你别挂电话,等厨师做好菜直接告诉你。但问题在于,每次收到数据后又要重新建立连接,TCP三次握手的开销依然存在。
直到HTML5时代,我们终于迎来了真正的服务器推送技术。WebSocket固然强大,但有时候我们只需要单向推送就够了。比如新闻推送、股票行情、物流跟踪这些场景,就像学校广播站通知放学时间,根本不需要学生回应。这时候SSE(Server-Sent Events)就是最优雅的解决方案。
SSE本质上还是HTTP协议,但服务器返回的Content-Type变成了text/event-stream。这就像把普通的水管换成了消防水带,数据可以持续不断地流动。我常跟团队解释,传统HTTP像是寄信,一来一往很耗时;而SSE像是装了对讲机,按住就能一直说。
消息格式特别简单,每段数据以data:开头,用两个换行符\n\n分隔。比如:
code复制data: 第一条消息\n\n
data: 第二条消息\n\n
最让我惊喜的是浏览器的自动重连机制。当网络波动导致连接中断,浏览器会按照retry字段指定的时间(默认3秒)自动重连。这就像手机信号不好时,它会自动尝试重新搜索网络。我们在代码中可以这样设置:
javascript复制res.write("retry: 5000\n\n"); // 5秒后重试
在电商订单状态推送场景中,消息顺序特别重要。SSE通过id字段实现消息追踪:
javascript复制let msgId = 0;
setInterval(() => {
res.write(`id: ${++msgId}\n`);
res.write(`data: 当前订单状态已更新\n\n`);
}, 1000);
当连接恢复时,客户端会在请求头带上Last-Event-ID,服务器就知道从哪条消息开始补发。
用Node.js的http模块搭建SSE服务只需30行代码。下面这个例子我常在内部培训时演示:
javascript复制const http = require('http');
http.createServer((req, res) => {
if (req.url === '/stream') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// 发送初始化消息
res.write('event: welcome\n');
res.write('data: 连接已建立\n\n');
// 定时推送
const timer = setInterval(() => {
res.write(`data: 服务器时间 ${new Date().toISOString()}\n\n`);
}, 1000);
// 清理连接
req.on('close', () => clearInterval(timer));
} else {
res.end('访问 /stream 查看SSE示例');
}
}).listen(3000);
在实际项目中,我总结了几条经验:
res.write(":\n\n")去年我们给物流系统做的车辆追踪看板,就是用SSE实现的。每辆车的位置更新会推送到前端地图上,代码结构是这样的:
javascript复制// 服务器端
function sendVehicleUpdate(res, vehicleId) {
const position = getLatestPosition(vehicleId);
res.write(`event: positionUpdate\n`);
res.write(`id: ${vehicleId}\n`);
res.write(`data: ${JSON.stringify(position)}\n\n`);
}
// 客户端
const es = new EventSource('/tracking');
es.addEventListener('positionUpdate', (e) => {
const vehicle = JSON.parse(e.data);
updateMapMarker(vehicle);
});
相比WebSocket,SSE在通知类场景有天然优势。某社交平台的未读消息提醒是这样实现的:
javascript复制// 服务端根据用户ID建立专属连接
app.get('/notifications/:userId', (req, res) => {
setupSSEHeaders(res);
// 当数据库有新消息时触发
notificationEmitter.on(`new-${req.params.userId}`, (msg) => {
res.write(`data: ${JSON.stringify(msg)}\n\n`);
});
});
在我的压力测试中,SSE的表现令人惊喜:
| 指标 | 短轮询 | 长轮询 | SSE |
|---|---|---|---|
| 平均延迟 | 2.5s | 1.2s | 0.3s |
| 带宽消耗 | 高 | 中 | 低 |
| 服务器负载 | 高 | 中 | 低 |
| 断线恢复 | 不支持 | 部分支持 | 自动恢复 |
特别是在移动网络环境下,SSE的自动重连机制能让消息恢复时间控制在3秒内。不过要注意的是,Nginx默认会缓冲SSE数据,需要配置proxy_buffering off才能保证实时性。
除了默认的message事件,我们可以定义多种事件类型。比如在线文档协作场景:
javascript复制// 服务端
res.write('event: cursorMove\n');
res.write(`data: {"userId":123,"position":45}\n\n`);
// 客户端
source.addEventListener('cursorMove', (e) => {
const data = JSON.parse(e.data);
updateRemoteCursor(data);
});
SSE不支持自定义请求头,但可以用URL参数传递token:
javascript复制const es = new EventSource('/stream?token=xxxx');
// 服务端验证
if (!validateToken(req.query.token)) {
res.writeHead(401);
return res.end();
}
我曾遇到过一个线上事故:没有及时清除interval导致内存飙升。正确的做法是:
javascript复制req.on('close', () => {
clearInterval(interval);
console.log(`客户端 ${req.socket.remoteAddress} 断开连接`);
});
虽然现代浏览器都支持EventSource,但需要为IE等老旧浏览器准备polyfill。我推荐使用eventsource库:
html复制<script src="https://cdn.jsdelivr.net/npm/eventsource@1.1.0/lib/eventsource.min.js"></script>
<script>
const es = typeof EventSource !== 'undefined' ?
new EventSource('/stream') :
new EventSourcePolyfill('/stream');
</script>
对于需要发送复杂数据的场景,记得先用JSON.stringify序列化:
javascript复制res.write(`data: ${JSON.stringify({user: '张三', action: '登录'})}\n\n`);
在最近的一个物联网项目中,我们用SSE推送设备状态变更,相比之前的轮询方案,服务器负载降低了70%,消息延迟从平均2秒降到了300毫秒以内。特别是在弱网环境下,自动重连机制让用户体验提升明显。