前端实时通信方案
聊天、通知、行情、协同编辑、AI 流式输出,这些场景都需要服务端把数据“推”给浏览器。HTTP 本身是“客户端请求、服务端响应”的模型,围绕“怎么让服务端主动发消息”出现了几种方案。这篇按演进顺序讲短轮询、长轮询、SSE 和 WebSocket 的原理和取舍,给出 WebSocket 心跳重连的实现,最后理清 XMLHttpRequest、fetch、axios 的关系。
最直接的做法:客户端每隔固定时间发一次请求,问服务端有没有新数据。
function startPolling(url, interval, onData) { let timer = null; let stopped = false;
async function poll() { try { const res = await fetch(url); if (res.ok) onData(await res.json()); } catch (err) { console.error('轮询失败', err); } if (!stopped) timer = setTimeout(poll, interval); }
poll(); return () => { stopped = true; clearTimeout(timer); };}
const stop = startPolling('/api/messages', 5000, (data) => console.log(data));这里用 setTimeout 递归而不是 setInterval:等上一次请求结束再计时,避免网络慢时请求堆积。
- 优点:实现简单,服务端无需任何改造,兼容所有环境。
- 缺点:实时性取决于轮询间隔,间隔短则浪费请求,间隔长则延迟高;大部分请求返回“没有新数据”,白白消耗带宽和服务端资源。
- 适用:数据更新不频繁、对实时性要求不高的场景,比如每分钟刷新一次的统计面板、任务状态查询。
长轮询是对短轮询的改进:客户端发出请求后,服务端如果没有新数据,先不响应,把请求挂起,直到有新数据或超时才返回。客户端收到响应后立即发起下一次请求。
async function longPoll(url, onData) { while (true) { try { const res = await fetch(url); if (res.status === 200) { onData(await res.json()); } // 204 表示服务端超时且没有数据,直接进入下一轮 } catch (err) { // 网络错误时稍等再重试,避免失败后疯狂重发 await new Promise((r) => setTimeout(r, 3000)); } }}- 优点:有数据时几乎能立即送达,没有数据时也不会频繁请求;基于普通 HTTP,兼容性好。
- 缺点:服务端要维持大量挂起的连接,对线程模型的服务端压力较大;每条消息之后都要重新建立请求,消息频繁时开销接近短轮询;超时、断线、消息在两次请求之间到达等边界情况需要处理。
- 适用:在 WebSocket 不可用时作为降级方案。早期很多实时方案(统称 Comet)都基于长轮询或类似技术。
SSE(Server-Sent Events)
Section titled “SSE(Server-Sent Events)”SSE 是 HTML 标准定义的服务端推送机制。客户端发起一个普通的 HTTP 请求,服务端返回 Content-Type: text/event-stream,然后保持连接不关闭,持续往里写文本格式的事件。
每条消息由若干字段行组成,以空行结束:
id: 1event: noticedata: {"title":"系统维护"}
data: 普通消息,没有 event 字段时触发 message 事件
retry: 5000| 字段 | 含义 |
|---|---|
data |
消息内容,多行 data 会用换行符拼接 |
event |
事件名,客户端用 addEventListener(事件名) 监听,不写则触发 message |
id |
消息 ID。断线重连时浏览器会通过 Last-Event-ID 请求头带上最后收到的 ID,服务端据此补发 |
retry |
告诉浏览器断线后多少毫秒重连 |
以 : 开头的行 |
注释,常用来定时发送,防止连接被中间代理因空闲而断开 |
const source = new EventSource('/api/stream');
source.onmessage = (e) => console.log('消息', e.data);source.addEventListener('notice', (e) => { console.log('通知', JSON.parse(e.data));});source.onerror = () => { // 浏览器会自动重连;如果连接已被关闭(readyState 为 CLOSED),则不会再重连 console.warn('连接异常,状态', source.readyState);};
// 不再需要时主动关闭// source.close();服务端(Node.js)
Section titled “服务端(Node.js)”import http from 'node:http';
http .createServer((req, res) => { if (req.url !== '/api/stream') return res.end();
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', });
let id = 0; const timer = setInterval(() => { id += 1; res.write(`id: ${id}\ndata: ${JSON.stringify({ time: Date.now() })}\n\n`); }, 1000);
req.on('close', () => clearInterval(timer)); }) .listen(3000);- 优点:基于 HTTP,不需要协议升级,代理和负载均衡容易支持;浏览器原生实现了自动重连和断点续传(
Last-Event-ID);协议是纯文本,调试方便。 - 单向:只能服务端推给客户端。客户端要发消息,另外用普通 HTTP 请求。
- 只能传文本:二进制数据需要编码(如 Base64)。
EventSource的限制:只能发 GET 请求,不能自定义请求头(无法直接带Authorization头,鉴权通常依赖 Cookie 或 URL 参数)。- 连接数限制:在 HTTP/1.1 下,浏览器对同一域名的并发连接数有限制(通常是 6 个),每个 SSE 连接都会长期占用一个,多开几个标签页就可能耗尽。HTTP/2 下多个流复用同一个连接,这个问题基本消失。
用 fetch 读取流式响应
Section titled “用 fetch 读取流式响应”需要 POST 请求或自定义请求头时(比如调用大模型接口的流式输出),可以不用 EventSource,改用 fetch 读取响应体的流,自己解析事件格式:
async function streamChat(prompt, onChunk, signal) { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ prompt }), signal, });
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); let buffer = '';
while (true) { const { value, done } = await reader.read(); if (done) break; buffer += value; // 一个事件以空行结束,最后一段可能还不完整,留到下一次拼接 const events = buffer.split('\n\n'); buffer = events.pop(); for (const event of events) { const data = event .split('\n') .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trim()) .join('\n'); if (data) onChunk(data); } }}这种方式失去了 EventSource 的自动重连,需要自己处理;signal 传入 AbortController.signal,用于中途取消。
WebSocket
Section titled “WebSocket”WebSocket 是独立的应用层协议(RFC 6455),在一个 TCP 连接上提供全双工通信:建立连接后,客户端和服务端随时都可以向对方发消息,消息可以是文本或二进制。
WebSocket 连接从一次 HTTP 请求开始,通过协议升级切换过去。客户端发出:
GET /chat HTTP/1.1Host: example.comUpgrade: websocketConnection: UpgradeSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Version: 13Origin: https://example.com服务端同意升级,返回:
HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=几个关键点:
Sec-WebSocket-Key是客户端生成的随机值,Base64 编码。Sec-WebSocket-Accept是服务端把这个 Key 拼上协议规定的固定 GUID,计算 SHA-1,再做 Base64 编码得到的。它的作用是确认对方真的理解 WebSocket 协议,而不是一个误把请求当普通 HTTP 处理的服务器,不是安全认证手段。- 状态码 101 之后,这个 TCP 连接就不再走 HTTP,而是按 WebSocket 的帧格式收发数据。
- 地址使用
ws://或wss://,后者基于 TLS。生产环境应使用wss://,否则不仅不安全,还容易被中间代理干扰。 - 浏览器会带上
Origin头,服务端应校验它,防止其他站点的页面借用户的 Cookie 建立连接(跨站 WebSocket 劫持)。
const ws = new WebSocket('wss://example.com/chat');
ws.addEventListener('open', () => { ws.send(JSON.stringify({ type: 'join', room: 'general' }));});
ws.addEventListener('message', (e) => { const msg = JSON.parse(e.data); console.log('收到', msg);});
ws.addEventListener('close', (e) => { console.log('连接关闭', e.code, e.reason);});
ws.addEventListener('error', () => { // error 事件不提供具体原因,之后一定会触发 close console.error('连接出错');});常见的关闭码:1000 表示正常关闭;1001 表示一端离开(如页面关闭);1006 表示连接异常断开、没有收到关闭帧,这个值不能主动发送,只会在异常时由浏览器报告。
WebSocket 连接可能在双方都不知情的情况下失效:网络切换、设备休眠、中间的代理或负载均衡因为连接空闲而把它关掉。浏览器不一定能立即触发 close,于是出现“看起来连着,其实已经收不到消息”的情况。所以需要:
- 心跳:定时发送一条消息,并期待对方回应。超时没回应,就认为连接已死,主动关闭并重连。
- 重连:断开后自动重新建立连接,用指数退避控制重试间隔,避免服务端恢复时被所有客户端同时冲击。
协议本身定义了 Ping / Pong 控制帧,但浏览器的 WebSocket API 不允许手动发送 Ping,所以前端的心跳通常在应用层实现,约定一种消息类型,比如 {"type":"ping"},服务端回 {"type":"pong"}。
class ReconnectingSocket { constructor(url, { onMessage, heartbeatInterval = 15000, heartbeatTimeout = 5000 } = {}) { this.url = url; this.onMessage = onMessage; this.heartbeatInterval = heartbeatInterval; this.heartbeatTimeout = heartbeatTimeout; this.retries = 0; this.closedByUser = false; this.connect(); }
connect() { this.ws = new WebSocket(this.url);
this.ws.onopen = () => { this.retries = 0; this.startHeartbeat(); };
this.ws.onmessage = (e) => { // 收到任何消息都说明连接是活的 this.resetHeartbeatTimeout(); const msg = JSON.parse(e.data); if (msg.type === 'pong') return; this.onMessage?.(msg); };
this.ws.onclose = () => { this.stopHeartbeat(); if (!this.closedByUser) this.scheduleReconnect(); }; }
startHeartbeat() { this.stopHeartbeat(); this.pingTimer = setInterval(() => { if (this.ws.readyState !== WebSocket.OPEN) return; this.ws.send(JSON.stringify({ type: 'ping' })); // 发出 ping 后等待回应,超时则判定连接已死 this.pongTimer ??= setTimeout(() => this.ws.close(), this.heartbeatTimeout); }, this.heartbeatInterval); }
resetHeartbeatTimeout() { clearTimeout(this.pongTimer); this.pongTimer = null; }
stopHeartbeat() { clearInterval(this.pingTimer); this.resetHeartbeatTimeout(); }
scheduleReconnect() { // 指数退避:1s、2s、4s……最长 30s,加随机抖动分散重连时间 const base = Math.min(1000 * 2 ** this.retries, 30000); const delay = base / 2 + Math.random() * (base / 2); this.retries += 1; setTimeout(() => this.connect(), delay); }
send(data) { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); return true; } return false; // 调用方可以选择缓存消息,等重连后补发 }
close() { this.closedByUser = true; this.stopHeartbeat(); this.ws.close(1000); }}
const socket = new ReconnectingSocket('wss://example.com/chat', { onMessage: (msg) => console.log(msg),});实际项目还需要考虑:
- 页面可见性:页面切到后台时,浏览器可能对定时器降频,心跳间隔会被拉长。可以监听
visibilitychange和online事件,回到前台或网络恢复时立即检查连接状态。 - 消息补偿:重连期间服务端发出的消息会丢失。常见做法是消息带递增序号,重连后客户端告诉服务端最后收到的序号,由服务端补发。
- 鉴权:浏览器的
WebSocket构造函数不能设置自定义请求头。通常的做法是依赖 Cookie、在 URL 上带短期有效的 token,或者连接建立后第一条消息发送凭证。 - 服务端多实例:WebSocket 是有状态的长连接,多实例部署时,发给某个用户的消息需要路由到持有他连接的实例上,通常借助消息队列或发布订阅系统广播。
Socket.IO 这类库在 WebSocket 之上提供了自动重连、房间、消息确认和降级到长轮询等能力。要注意它有自己的协议,客户端和服务端必须配套使用,不能直接连接普通的 WebSocket 服务。
| 方案 | 方向 | 实时性 | 服务端成本 | 断线重连 | 数据类型 | 典型场景 |
|---|---|---|---|---|---|---|
| 短轮询 | 客户端拉取 | 取决于间隔 | 请求多,大部分是无效请求 | 天然无状态 | 任意 | 低频状态查询 |
| 长轮询 | 近似服务端推送 | 较好 | 需维持挂起的请求 | 需自行处理 | 任意 | WebSocket 不可用时的降级 |
| SSE | 服务端到客户端 | 好 | 长连接,基于普通 HTTP | 浏览器内置 | 文本 | 通知、行情、日志、AI 流式输出 |
| WebSocket | 双向 | 好 | 长连接,需要支持协议升级 | 需自行处理 | 文本与二进制 | 聊天、协同编辑、游戏、高频双向交互 |
选择思路:
- 只需要服务端推给客户端,优先考虑 SSE,实现和运维都更简单。
- 需要高频的双向通信,选 WebSocket。
- 更新频率低、对实时性不敏感,短轮询就够了,不要过度设计。
- 需要兼容无法使用长连接的网络环境时,准备长轮询作为降级。
此外还有 WebTransport,基于 HTTP/3(QUIC),支持多路流和不可靠传输,适合对延迟极其敏感的场景,但浏览器和服务端的支持仍在推进中,使用前要确认目标环境。
XMLHttpRequest、fetch 与 axios
Section titled “XMLHttpRequest、fetch 与 axios”这三者经常被放在一起比较,但它们不在同一个层面:
- Ajax 是一种技术思路的统称(Asynchronous JavaScript and XML):在不刷新页面的情况下,用 JS 异步请求数据并更新页面。它不是某个具体 API。
- XMLHttpRequest(XHR) 是浏览器最早提供的、实现 Ajax 的原生 API。名字里有 XML,但可以传输任何格式。
- fetch 是更现代的原生请求 API,基于 Promise,和
Request、Response、Headers、流等 API 配套。Node.js 新版本也内置了 fetch。 - axios 是第三方库,在浏览器中基于 XHR 封装(新版本也可以配置使用 fetch 作为底层),在 Node.js 中基于
http模块。
对比:
| 方面 | XMLHttpRequest | fetch | axios |
|---|---|---|---|
| 来源 | 浏览器原生 | 浏览器与 Node 原生 | 第三方库 |
| 编程风格 | 事件回调 | Promise | Promise |
| HTTP 错误状态 | 需自己判断 status |
4xx / 5xx 不会 reject,需检查 res.ok |
默认非 2xx 都 reject |
| JSON 处理 | 手动解析或设置 responseType |
手动调用 res.json() |
自动序列化请求体、解析响应 |
| 超时 | xhr.timeout |
用 AbortSignal.timeout(ms) |
timeout 配置项 |
| 取消请求 | xhr.abort() |
AbortController |
支持 AbortController 的 signal |
| 上传进度 | 支持(xhr.upload.onprogress) |
不直接支持 | 支持(浏览器中基于 XHR) |
| 下载进度 / 流式读取 | 下载进度支持,流式读取不方便 | 可逐块读取 res.body |
浏览器中不方便流式读取 |
| 拦截器 | 无 | 无,需自行封装 | 内置请求与响应拦截器 |
| 携带 Cookie 跨域 | withCredentials |
credentials: 'include' |
withCredentials |
| 体积 | 0 | 0 | 需要引入依赖 |
fetch 最容易踩的坑是错误处理:
async function request(url, options) { const res = await fetch(url, { ...options, signal: AbortSignal.timeout(10000), }); if (!res.ok) { // fetch 只在网络错误、被取消时 reject,HTTP 错误需要自己抛出 throw new Error(`请求失败:${res.status}`); } return res.json();}选择建议:
- 新代码优先用 fetch,配合一个薄封装统一处理错误、超时、鉴权头。
- 需要上传进度、或团队已经大量依赖拦截器等功能时,axios 依然是成熟的选择。
- 直接使用 XHR 的场景已经很少,主要是需要上传进度又不想引入依赖时。
Web Worker 与 Service Worker 的定位
Section titled “Web Worker 与 Service Worker 的定位”早期一些资料把 Web Worker、Service Worker 和轮询、WebSocket 并列为“即时通讯方案”,这不准确。它们不是通信协议,而是浏览器提供的在主线程之外运行脚本的机制,可以和上面的通信方案配合使用。
| Web Worker | Service Worker | |
|---|---|---|
| 定位 | 后台线程,分担主线程的计算 | 介于页面和网络之间的代理脚本 |
| 生命周期 | 随创建它的页面存在 | 独立于页面,由浏览器按需启动和终止 |
| 能否操作 DOM | 不能 | 不能 |
| 与页面通信 | postMessage |
postMessage、clients API |
| 典型用途 | 大数据计算、解析、压缩;也可以把 WebSocket 连接放在 Worker 里处理,减轻主线程负担 | 拦截请求实现离线缓存(PWA)、接收 Web Push 推送消息、后台同步 |
和实时通信相关的两种用法:
- SharedWorker:同源的多个标签页可以共享一个 Worker。把 WebSocket 连接放在 SharedWorker 里,多个标签页只需要一条连接,避免每开一个页面就多一条长连接。注意 SharedWorker 的浏览器支持并不完整,使用前要确认目标环境。
- Service Worker + Web Push:页面关闭后仍然能收到服务端推送并展示系统通知。这依赖浏览器厂商的推送服务,需要用户授权通知权限,适合“离线也要触达”的通知类场景,不适合高频的实时数据。
- 短轮询实现最简单但浪费请求;长轮询让服务端挂起请求,有数据才返回,主要用作降级方案。
- SSE 基于普通 HTTP 实现服务端单向推送,浏览器内置自动重连和
Last-Event-ID续传;需要 POST 或自定义请求头时可以用 fetch 读取流式响应。 - WebSocket 通过 HTTP 101 协议升级建立全双工连接,适合高频双向通信;生产环境需要应用层心跳、指数退避重连、消息补偿和 Origin 校验。
- 选型原则:单向推送优先 SSE,双向高频用 WebSocket,低频更新用短轮询。
- Ajax 是技术统称,XHR 和 fetch 是原生 API,axios 是封装库;fetch 不会因 HTTP 错误状态 reject,需要检查
res.ok。 - Web Worker、Service Worker 是在主线程外运行脚本的机制,不是通信协议,可以承载连接或接收推送。