说到网页上的“实时”体验,很多人第一反应就是那种消息秒到、数据不刷新的流畅感。但在这层光鲜亮丽的背后,其实是一场关于带宽、延迟和兼容性的精密舞蹈。作为一名在一线摸爬滚打多年的开发者,我见过太多因为忽略了移动端兼容性或者没有做好重连机制而导致的线上故障。今天,我们不谈枯燥的理论定义,直接切入实战,聊聊 WebSocket 和 Server-Sent Events (SSE) 这两大主角,看看如何把它们调教得既听话又高效,顺便把那些让人头秃的兼容性问题一次性解决掉。
为什么选择 WebSocket?双向奔赴的艺术
WebSocket 之所以成为实时通信的王者,核心在于它建立了一个全双工的通道。想象一下,传统的 HTTP 请求就像是你给客服打电话,说完一句要挂断,等对方回复了再打过去;而 WebSocket 则像是一条直通专线,一旦接通,你和服务器可以随时互相说话,互不打扰。
1. 握手与建立连接:不仅仅是换协议
很多初学者以为 new WebSocket(url) 就完事了,其实真正的挑战在于握手阶段。HTTP 升级请求中携带的头信息非常关键,尤其是对于需要鉴权的场景。
const wsUrl = 'wss://api.example.com/realtime';
const token = localStorage.getItem('auth_token');
// 创建 WebSocket 实例,并可以在构造函数中传入协议或自定义头(取决于浏览器支持)
// 注意:标准 WebSocket API 不支持直接设置 Header,通常需要在服务端通过 Cookie 或 Token 查询参数处理
const socket = new WebSocket(wsUrl);
socket.onopen = function(event) {
console.log('连接已建立');
// 有些架构会在连接建立后立即发送一个认证消息
socket.send(JSON.stringify({ type: 'auth', token: token }));
};
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
handleIncomingData(data);
};
socket.onerror = function(error) {
console.error('WebSocket 错误:', error);
};
socket.onclose = function(event) {
if (event.wasClean) {
console.log(`连接已关闭,代码=${event.code},原因=${event.reason}`);
} else {
// 例如服务器进程被杀死或网络中断
console.log('连接异常中断');
attemptReconnect();
}
};
这里有个坑:Header 限制。标准的 WebSocket 构造函数无法像 fetch 那样随意添加自定义 Header。如果你的鉴权依赖复杂的 Header,通常的做法是:
- 使用 URL 查询参数传递 Token(如
ws://...?token=xxx),然后在服务端验证。 - 利用 Cookie,确保 WebSocket 域名与登录域名一致,且
SameSite配置得当。 - 在连接建立后,立即发送第一条包含 Token 的消息进行二次校验。
2. 心跳检测与自动重连:让连接“活”下来
网络环境是多变的,特别是移动端,WiFi 切换、弱网环境下,TCP 连接可能会静默断开。如果你不检测,客户端会以为还连着,直到下次发送数据才发现失败。
心跳机制(Heartbeat)是必须的。
class ReliableSocket {
constructor(url) {
this.url = url;
this.ws = null;
this.reconnectInterval = 3000;
this.pingInterval = null;
this.pongTimeout = null;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'pong') {
// 收到服务器的心跳回应,清除超时定时器
clearTimeout(this.pongTimeout);
return;
}
// 处理业务消息...
this.handleMessage(msg);
};
this.ws.onclose = () => {
console.log('Disconnected. Reconnecting in', this.reconnectInterval / 1000, 's...');
this.stopHeartbeat();
setTimeout(() => this.connect(), this.reconnectInterval);
};
this.ws.onerror = (err) => {
console.error('WebSocket Error:', err);
};
}
startHeartbeat() {
this.pingInterval = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping' }));
// 如果 5 秒内没收到 pong,视为连接断开
this.pongTimeout = setTimeout(() => {
console.warn('Pong timeout, closing connection');
this.ws.close();
}, 5000);
}
}, 30000); // 每 30 秒发一次 ping
}
stopHeartbeat() {
clearInterval(this.pingInterval);
clearTimeout(this.pongTimeout);
}
sendMessage(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.warn('Socket not open, message queued or dropped');
// 实际项目中应加入消息队列
}
}
handleMessage(msg) {
console.log('Received:', msg);
}
}
这段代码的核心逻辑是:客户端定时发 ping,服务端必须回 pong。如果客户端发了 ping 但在规定时间内没收到 pong,主动断开连接并触发重连逻辑。这能有效避免“假死”连接占用服务器资源。
Server-Sent Events (SSE):单向广播的优雅之选
如果你的场景是服务器向客户端推送数据,比如股票行情、新闻流、聊天室通知,而不需要客户端频繁地向服务器发送大量二进制数据,那么 SSE 是比 WebSocket 更轻量、更简单的选择。
SSE 基于 HTTP,长连接,支持断线重连,而且天然支持跨域(CORS),调试起来也方便,直接用浏览器打开就能看日志。
1. SSE 的基本用法
前端非常简单,就是一个 EventSource 对象。
const eventSource = new EventSource('/api/stream');
eventSource.onopen = function() {
console.log('SSE Connection opened');
};
eventSource.addEventListener('news-update', function(e) {
const data = JSON.parse(e.data);
updateUI(data);
});
// 也可以监听默认的 message 事件
eventSource.onmessage = function(e) {
console.log('Default message:', e.data);
};
eventSource.onerror = function(err) {
if (eventSource.readyState === EventSource.CLOSED) {
console.log('Connection closed');
} else {
console.log('Error occurred');
}
};
后端(以 Node.js + Express 为例)需要设置特殊的响应头,并保持连接开启。
app.get('/api/stream', (req, res) => {
// 关键头信息
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 立即发送头部,开始流传输
let lastEventId = req.headers['last-event-id']; // 用于断线重连时的 ID 追踪
// 模拟发送数据
const interval = setInterval(() => {
const data = { time: new Date().toISOString(), value: Math.random() };
// SSE 格式要求:
// event: <event_name>
// id: <optional_id>
// data: <json_data>
// \n\n
res.write(`event: news-update\n`);
res.write(`id: ${Date.now()}\n`); // 唯一 ID,帮助客户端断点续传
res.write(`data: ${JSON.stringify(data)}\n\n`);
// 如果连接断开太久,可能需要清理间隔或检查客户端状态
}, 1000);
// 当客户端断开连接时,清理间隔
req.on('close', () => {
clearInterval(interval);
});
});
2. SSE 的优势与局限
- 优势:实现极简,原生支持重连(浏览器会自动尝试重连,并发送
Last-Event-ID头),基于 HTTP,防火墙友好。 - 局限:只能由服务器推送到客户端,客户端不能通过 SSE 发送数据(如果需要双向通信,得再建一个普通的 HTTP 请求或 WebSocket)。此外,并发连接数较多时,对服务器的内存和文件描述符消耗比 WebSocket 略高(因为基于 HTTP 长连接)。
性能优化:从字节到用户体验
有了通信能力,接下来就是如何让它在高并发下依然丝滑。
1. 数据压缩与序列化
WebSocket 支持二进制帧,SSE 只能传文本。对于高频数据(如每秒几十条的传感器数据),JSON 字符串体积较大。
- 方案 A:Gzip/Brotli 压缩。在 Nginx 或网关层开启压缩。但对于 WebSocket,由于是长连接,动态压缩开销较大,需谨慎评估 CPU 成本。
- 方案 B:二进制协议。使用 Protocol Buffers (Protobuf)、MessagePack 或 FlatBuffers 替代 JSON。
- Protobuf 将数据序列化为紧凑的二进制格式,体积通常比 JSON 小 3-10 倍,解析速度也快得多。
- 前端需要使用对应的 JS 库(如
protobufjs)进行反序列化。
// 伪代码示例:使用 MessagePack 压缩数据
import * as msgpack from 'msgpack-lite';
// 发送前
const packed = msgpack.encode({ type: 'update', payload: bigObject });
socket.send(packed);
// 接收后
const unpacked = msgpack.decode(buffer);
2. 批量发送与节流
不要每条数据都立即发送。如果传感器每秒产生 100 个点,客户端每秒刷新 UI 100 次会导致严重的渲染卡顿。
- 服务端聚合:服务端可以攒够一定数量(如 50 条)或时间窗口(如 100ms)后再推送一条合并后的消息。
- 客户端节流:在
onmessage中使用requestAnimationFrame或防抖函数来更新 UI,确保每一帧只处理一次数据更新。
let pendingUpdates = [];
socket.onmessage = (event) => {
pendingUpdates.push(JSON.parse(event.data));
// 如果还没有安排动画帧,则安排一个
if (!isAnimating) {
isAnimating = true;
requestAnimationFrame(processUpdates);
}
};
function processUpdates() {
// 处理所有积压的数据
pendingUpdates.forEach(update => {
updateChart(update);
});
pendingUpdates = [];
isAnimating = false;
}
3. 连接复用与多路复用
在浏览器中,同源策略限制了并行连接的数量(通常 HTTP/1.1 限制为 6 个)。虽然 WebSocket 可以突破这个限制(每个 WS 连接独立),但如果你的应用有多个模块都需要实时数据,建立多个 WebSocket 连接会增加浏览器的内存负担。
- 单连接多频道:在一个 WebSocket 连接中,通过消息体中的
channel或topic字段来区分不同业务的数据流。 - HTTP/2 多路复用:如果使用 SSE,尽量部署支持 HTTP/2 的服务端。HTTP/2 允许在一个 TCP 连接上并行处理多个流,解决了队头阻塞问题,且头部压缩效率高,非常适合 SSE 场景。
浏览器兼容性排查:那些“看不见”的坑
虽然现代浏览器对 WebSocket 和 SSE 的支持已经非常好,但在企业级开发或老旧系统维护中,你仍可能遇到“鬼影”问题。
1. WebSocket 兼容性矩阵
| 特性 | Chrome | Firefox | Safari | Edge | IE | Android WebView | iOS Safari |
|---|---|---|---|---|---|---|---|
| WebSocket | ✅ | ✅ | ✅ | ✅ | ❌ (10+) | ✅ | ✅ |
| Binary Data | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| Compression | ⚠️(实验性) | ⚠️(实验性) | ⚠️(实验性) | ⚠️(实验性) | ❌ | ⚠️ | ⚠️ |
IE 11 及以下:完全不支持 WebSocket。这是最大的痛点。
- 解决方案:使用 SockJS + Polyfill。SockJS 是一个 JavaScript 库,它提供了 WebSocket 类似的 API,并在底层根据浏览器能力自动降级。如果浏览器不支持 WebSocket,它会尝试 XHR 轮询、JSONP 轮询或 iframe 传输。
- 代码改造:
// 引入 sockjs-client const socket = new SockJS('https://example.com/chat'); socket.onopen = function() { ... }; socket.onmessage = function(e) { ... }; - 注意:SockJS 会增加一定的流量开销(因为是 HTTP 轮询模拟),且延迟高于原生 WebSocket。仅在必要时使用。
iOS WKWebView (iOS 8+):支持良好,但要注意
background-mode。如果 App 进入后台,iOS 可能会切断 WebSocket 连接以保持省电。需要在 App 配置中声明支持后台模式。
2. SSE 兼容性矩阵
| 特性 | Chrome | Firefox | Safari | Edge | IE | Android WebView | iOS Safari |
|---|---|---|---|---|---|---|---|
| EventSource | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
- IE 全系列:不支持
EventSource。 - 解决方案:同样可以使用 SockJS 的 SSE 传输模式,或者降级为 长轮询 (Long Polling)。
- 长轮询实现简单:客户端发请求 -> 服务端挂起 -> 有数据返回 -> 客户端收到后立即发下一个请求。虽然效率低,但兼容性最好。
3. 混合部署与代理问题
在生产环境中,WebSocket 和 SSE 往往经过 Nginx 或 Cloudflare 等反向代理。
Nginx 配置陷阱: 默认情况下,Nginx 的
proxy_pass不会正确转发 WebSocket 的升级头。你需要显式配置:location /ws/ { proxy_pass http://backend_server; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; }Cloudflare 等 CDN: Cloudflare 默认支持 WebSocket,但需要确保你的区域规则中没有禁用 WebSocket 流量。对于 SSE,CDN 通常会缓存响应,这会导致客户端收不到最新数据。务必在 SSE 响应头中设置
Cache-Control: no-cache, no-store, must-revalidate,并配置 CDN 规则排除该路径的缓存。防火墙与代理: 某些公司内网防火墙会拦截非 80⁄443 端口的 WebSocket 连接,或者深度包检测(DPI)识别出 WebSocket 流量并阻断。
- 对策:始终使用
wss://(HTTPS 下的 WebSocket)。如果仍然被拦,可以考虑将 WebSocket 伪装成普通的 HTTPS 请求(即使用 SockJS 的 XHR 模式),虽然牺牲了部分性能,但能穿透大部分严格的企业防火墙。
- 对策:始终使用
结语:没有银弹,只有最适合的场景
选择 WebSocket 还是 SSE,不要只看技术热度,要看业务本质。
- 如果你需要高频、低延迟的双向互动(如在线游戏、协同编辑、即时通讯),WebSocket 是不二之选,配合 Protobuf 和完善的断线重连机制,它能扛住巨大的并发压力。
- 如果你只需要服务器向客户端推送数据(如仪表盘监控、新闻推送、股票报价),SSE 更简单、更稳定,且更容易被现有 HTTP 基础设施(CDN、Nginx)所接纳。
- 如果需要兼容 IE 或极端受限的网络环境,请果断降级到 SockJS 或 长轮询。
记住,最好的架构不是最复杂的,而是最能解决问题的。希望这份指南能帮你在构建实时应用时少走弯路,让你的用户享受到真正“实时”的快乐。
