1. 同样是"收消息",为什么体验差距这么大
先别急着看 WebSocket 有多高级,我先说一个每天都在真实项目里发生的场景:你打开后台管理页面,订单列表里最新一条状态永远停留在十分钟以前。前端同学第一反应是调接口,后端同学第一反应是刷新缓存,最后两边在群里对了一遍才发现,服务端根本没有主动把变化推给页面的通道。于是有人提议用 setInterval 每三秒刷一次接口,这就是消息推送技术里最原始的轮询,也是后面所有更聪明方案的起点。等到轮询的请求量涨起来,又会有人提出换 SSE,再后来 WebSocket 也被拉进讨论,一场关于消息推送方案的较量就这样开始了。
1.1 HTTP 的请求-响应模型:服务端没有敲门权
客户端发请求,服务端给响应,响应结束连接使命结束。这条链路里,服务端永远处于"被呼叫"的位置,它想主动告诉客户端"订单状态变了""任务跑完了",但手里没有客户端的地址,或者说,没有一条长期稳定的通道。
所以消息推送这个概念听起来神秘,实际要解决的只有一件事:在服务端状态发生变化时,如何用最短时间让客户端感知到。所谓轮询、SSE、WebSocket,本质上是三套不同的"让服务端能开口"的方案,差别在于用多少资源、多快的速度、能不能双向说话。
我见过不少团队一听到实时消息就默认上 WebSocket,理由写的是"以后要做 IM"。结果两年过去,页面里只显示了一个通知列表,这套 WebSocket 却让服务器维护连接、做心跳、搞鉴权忙得不轻。选型的前提不是方案多新,而是先把"谁主动、多快、能不能断"这三个问题想清楚。
1.2 实时性不是非黑即白,先给业务定个量级
很多人把"实时"理解成 0 延迟,实际业务里,延迟 3 秒的轮询可能已经够用,延迟 300 毫秒的 SSE 可能也很流畅,真正要求 100 毫秒以内端到端响应的场景远没有想象中多。
所以在拆解具体技术前,最好先给业务定个量级:状态轮询能不能忍?如果客户五分钟才看一次页面,那 5 秒轮询和 WebSocket 的体验差异根本测不出来;如果一个在线文档要多人同时编辑,那轮询完全不可行,必须 WebSocket。接下来的内容,我把三种方案的原理、代码和坑都摆出来,你自己就能对号入座。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 轮询:看起来最笨,却还有一批场景非它不可
轮询这个词一出来,很多人的第一反应是不屑。实际上,轮询是三种方案里唯一不需要服务端做额外配合的,普通 HTTP 接口就能实现,而且天然兼容所有网络环境。
2.1 短轮询的写法与成本
前端实现一个短轮询,简单到不能再简单:
javascript复制async function checkStatus() {
try {
const res = await fetch('/api/task/status');
const data = await res.json();
if (data.status === 'finished') {
clearInterval(timer);
showResult(data.result);
}
} catch (err) {
// 网络抖动时不要立即抛出,保留下一次轮询
}
}
const timer = setInterval(checkStatus, 5000);
这种做法的成本很好算:每 5 秒一次请求,一小时就是 720 次。用户开 100 个浏览器页面,服务端一小时就要被刷 72000 次。大多数请求返回的数据根本没变化,纯属浪费。但换个角度看,这套实现没有状态、没有长连接、不用心跳,后端接口只要写成普通 HTTP,压力再大也扛得住。
我自己做任务状态页时,轮询间隔很少会低于 5 秒。1 秒以内的轮询会明显放大空转压力,而 5 到 10 秒的间隔对"任务跑完没"这种场景完全够用。
2.2 长轮询:模拟推送的妥协方案
短轮询的问题在于"问得太勤"。长轮询(Long Polling)改了思路:客户端发一个请求,服务端不立即返回,而是把请求挂起,等到有事件或者超时了再响应。这样在事件到达的那一瞬间,客户端就能收到数据,而不是等下一个轮询周期。
用 Node 写一个最小实现大概是这样:
javascript复制// 服务端收到请求后不 response,事件触发时再补上
app.get('/lazy', (req, res) => {
const timer = setTimeout(() => {
res.json({ time: Date.now() });
}, 30000);
eventBus.once('dataChanged', () => {
clearTimeout(timer);
res.json({ changed: true });
});
});
客户端仍是普通 fetch,请求返回后立刻发起下一次请求。这个方案把实时性提升到了事件级,但代价也很明显:每个在线用户都占着一个挂起的请求,服务端要小心处理超时、并发连接数和代理层的连接限制。而且每来一个事件,所有挂起请求都会一起返回,没有真正省掉连接成本。
2.3 哪些业务现在仍然适合轮询
轮询到今天没有消失,主要有两个原因:第一,低频状态下它的成本可控;第二,它不会被企业防火墙、离线环境、老旧浏览器卡住。
我举几个实际例子。微信小程序在某些场景里拿不到后台长连接的保活能力,页面退到后台连接还会被系统回收,如果只是查一个订单状态,用 wx.request 轮询反而比 WebSocket 更省事。扫码登录的"请确认"页面也大量依赖轮询或长轮询,因为扫码端和确认端都不方便维护长连接。任务上传、视频转码、报表生成这类批处理任务,后端主动推一次当然好,但如果不推,5 秒一次轮询用户也感知不到区别。
这种"一遍遍问"的思路在工业领域也很常见。西门子 S7-1500 做 RS485 通讯时,上位机要按固定周期去轮询各个从站的数据,从站不会主动上报,主机就得一遍遍地问。协议换了,消息推送的底层思想没有变:当对端没有主动通知能力时,轮询就是最朴素也最可靠的兜底方案。
3. SSE:一条被低估的单向长连接,最近又跟着 AI 翻红了
如果说轮询是客户端一次次去敲门,SSE(Server-Sent Events)就是客户端把门打开一条缝,让服务端可以随时往里面递纸条。它的历史比 WebSocket 还早,却被很多项目忽略。直到大模型开始逐字吐答案,SSE 这种"流式输出"的天然能力才被重新捡起来。
3.1 EventSource 协议和一次完整握手
SSE 基于普通 HTTP,服务端把响应头里的 Content-Type 改成 text/event-stream,并且不关闭连接。浏览器收到后,会通过内置的 EventSource 对象维持这个连接,数据按行格式推过来。
一个标准事件流长这样:
code复制id: 1024
event: order_updated
data: {"orderId":10086,"status":"paid"}
data 是多行合并的一条消息,event 是自定义事件名,id 用于断线重连时告诉服务端"我收到哪一条了"。每条消息以空行结尾,这个格式理解起来没有任何门槛。
3.2 服务端与浏览器端最小实现
服务端用 Express 写起来非常直观:
javascript复制app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const send = (data, event) => {
res.write(`event: ${event}\n`);
res.write(`id: ${Date.now()}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const timer = setInterval(() => {
send({ now: Date.now() }, 'tick');
}, 5000);
req.on('close', () => clearInterval(timer));
});
浏览器端更简单,不需要引入任何库:
javascript复制const es = new EventSource('/events');
es.addEventListener('tick', (e) => {
const payload = JSON.parse(e.data);
updatePage(payload.now);
});
es.onerror = () => {
// 不用手动重连,EventSource 自己会退避重连
};
这里要强调一个容易忽略的点:你在 onerror 里通常不需要重新 new EventSource。浏览器对 SSE 有内置重连机制,服务端只要把 id 写对,重连时浏览器会自动带上 Last-Event-ID,让服务端从断点继续推,这个能力是 WebSocket 需要自己实现半天的。
3.3 SSE 的隐藏优点:自动重连与断点续传
WebSocket 断了之后,客户端如果要恢复现场,需要自己记录最后一条消息,再重新建立一个新连接,把"我要从哪开始"告诉服务端。SSE 则是协议层面的默认行为:客户端断线后自动重新发起 HTTP 请求,并带上 Last-Event-ID。对于订单状态、系统通知、行情这类只需要服务端单向推送的场景,这套机制能省掉不少代码。
SSE 也有限制。当前浏览器在 HTTP/1.1 下每个域名最多开 6 个并发连接,一个 SSE 长连接会长期占掉一个名额。如果页面里同时开多个 EventSource,很容易撞上这个上限。项目切到 HTTP/2 之后这个问题基本消失,但老系统还是要留意。
