new WebSocket(url) 这行谁都会写。真正让人半夜爬起来的是它断了不告诉你、重连重两次、断在一半的消息永远等不到回复。
只谈连接管理这层:怎么建、怎么判断活着、断了怎么接、接上后怎么对齐状态。
1. 选型:三种通道的适用面
| 维度 | WebSocket | SSE(EventSource) |
长轮询 |
|---|---|---|---|
| 方向 | 双向 | 服务端 → 客户端 | 伪双向 |
| 协议 | ws:// wss://,需升级握手 |
普通 HTTP | 普通 HTTP |
| 自定义头 | 浏览器 API 不支持 | EventSource 也不支持 |
支持 |
| 自动重连 | 无,自己写 | 浏览器内建 | 自己写 |
| 代理/CDN 友好度 | 差(部分企业代理直接掐) | 好 | 好 |
| 二进制 | 支持 | 不支持(仅文本) | 需自行编码 |
| 适合 | 高频双向、协同编辑、游戏 | 通知流、进度推送 | 兼容兜底 |
判据:不需要高频上行就用 SSE——它自带重连与 Last-Event-ID 续传。WebSocket 的价值在双向与低延迟上行。
2. 生命周期与 readyState 四态
type State = 0 | 1 | 2 | 3;
// 0 CONNECTING 1 OPEN 2 CLOSING 3 CLOSED
| 状态 | 值 | 能发消息吗 | 常见误判 |
|---|---|---|---|
CONNECTING |
0 | 不能,send 会抛 InvalidStateError |
以为建好就能发 |
OPEN |
1 | 能 | —— |
CLOSING |
2 | 不能 | 以为还能补发最后一条 |
CLOSED |
3 | 不能 | 关闭后不重连,界面静默 |
onerror 不携带原因,有用信息在紧随其后的 onclose 里。别只监听 onerror 就判定失败。
const ws = new WebSocket(url);
ws.onopen = () => { setStatus('open'); flushQueue(); };
ws.onmessage = (e) => handle(e.data);
ws.onerror = () => { /* 只记日志,别在这里重连 */ };
ws.onclose = (e) => {
setStatus('closed');
scheduleReconnect(e.code, e.wasClean);
};
onopen 第一件事是冲刷离线队列,队列里可能还有上次没发出去的消息。
3. 心跳:为什么必须自己做
浏览器不会替你保活。链路上至少有三处会主动静默丢弃空闲连接:
| 中间环节 | 典型行为 | 后果 |
|---|---|---|
Nginx proxy_read_timeout |
默认 60 s 无数据即断 | 空闲一分钟就断 |
| 云负载均衡空闲超时 | 常见 60–350 s | 同上,且断得很"安静" |
| NAT / 家用路由器表项 | 老化时间不定 | 移动网络切换后连接假死 |
| 移动端后台化 | 系统冻结 JS 与网络 | 回到前台时连接已死 |
"假死"最难受:readyState 还是 1,send 不报错,消息发出去没人收。只有应用层心跳能发现。
const HB_INTERVAL = 25_000; // 必须小于链路上最短的空闲超时
const HB_TIMEOUT = 10_000;
let hbTimer: number | undefined;
function startHeartbeat(ws: WebSocket) {
stopHeartbeat();
hbTimer = window.setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: 'ping', t: Date.now() }));
const t = setTimeout(() => ws.close(4000, 'hb-timeout'), HB_TIMEOUT);
once('pong', () => clearTimeout(t));
}, HB_INTERVAL);
}
| 设计项 | 建议 | 理由 |
|---|---|---|
| 层次 | 应用层 JSON ping/pong |
协议层 ping 浏览器 API 不暴露 |
| 间隔 | 20–25 s | 小于最短空闲超时的一半 |
| 超时判定 | 一个间隔内没收到 pong 即判死 |
别等两个间隔,体验差 |
| 判死后动作 | close(4000) 而非直接重连 |
走统一收尾路径 |
协议层 ping/pong 浏览器拿不到——API 不暴露 ping 帧,只有服务端能主动发。服务端到客户端方向的存活靠协议内建 pong,反方向必须自己造。
4. 重连:退避、抖动、状态同步
const MAX_RETRY = 8;
let attempt = 0;
function scheduleReconnect(code: number, wasClean: boolean) {
if (code === 1000 || attempt >= MAX_RETRY) return; // 正常关闭不重连
const base = Math.min(30_000, 1000 * 2 ** attempt);
const jitter = Math.random() * 0.3 * base; // 抖动 ±30%
attempt += 1;
setTimeout(connect, base + jitter);
}
| 参数 | 建议 | 不这么做的后果 |
|---|---|---|
| 基数十进制退避 | 1s × 2^n,上限 30 s |
固定 1 s → 服务端被打穿 |
| 抖动 | ±20–30% | 无抖动 → 所有客户端同时重连(惊群) |
| 最大次数 | 6–10 次后转降级 | 无限重连 → 后台空转耗电 |
| 成功判定 | onopen 后重置 attempt = 0 |
不重置 → 后续断线一次就放弃 |
重连 ≠ 恢复会话。onopen 只说明握手通了,会话上下文、订阅关系、未确认消息全没了。必须显式同步一次:
ws.onopen = async () => {
attempt = 0;
ws.send(JSON.stringify({ type: 'resume', lastSeq })); // 带上最后收到的序号
const missed = await fetchMissed(lastSeq); // 或走一次 HTTP 补拉
applyMissed(missed);
};
lastSeq 是客户端维护的"处理到哪了"的游标。没有它只能全量刷新或丢消息。
5. onclose 的 code 与消息可靠性
code |
含义 | 前端动作 |
|---|---|---|
1000 |
正常关闭 | 不重连 |
1001 |
端点离开(页面卸载) | 不重连 |
1006 |
异常关闭,拿不到原因 | 重连,且按网络问题处理 |
1008 |
策略违规(如鉴权失败) | 不重连,走登录流程 |
1011 |
服务端内部错误 | 退避重连 |
4000+ |
应用自定义 | 按业务约定 |
1006 最常见也最没信息量:底层被掐断(断网、代理断开、进程被杀),浏览器拿不到关闭帧,既无 reason 也无语义。一律按网络问题处理。
需要"至少一次"语义时,在应用层补三件事:
const pending = new Map<string, { payload: unknown; sentAt: number }>();
function sendReliable(payload: unknown) {
const id = crypto.randomUUID();
pending.set(id, { payload, sentAt: Date.now() });
ws.send(JSON.stringify({ id, ...payload }));
}
// 收到 ack 才删;超时未 ack 的重发
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === 'ack') pending.delete(m.id);
else handleBusiness(m);
};
| 需求 | 机制 | 代价 |
|---|---|---|
| 不丢(至少一次) | id + ACK + 超时重发 |
可能重复,需按 id 去重 |
| 不重(至多一次) | 服务端按 id 幂等 |
前端要保留最近 id |
| 有序 | 单调序号 seq |
乱序到达要缓冲重排 |
三个都要就别硬做,改带序号的应用层协议,或回到 HTTP + 幂等接口。
6. 鉴权:握手时带 token 的限制
WebSocket 构造器只接受 URL 与子协议,没有放自定义头的位置,Authorization 用不了。
// 可行:token 放 query(会进日志与 Referer,必须用短时票据)
const ws = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`);
// 可行:子协议位塞 token(服务端需配合解析 Sec-WebSocket-Protocol)
const ws2 = new WebSocket('wss://api.example.com/ws', [ticket]);
| 方案 | 可行性 | 风险 |
|---|---|---|
| 自定义头 | 不可行 | 浏览器 API 不开放 |
| query 参数 | 可行 | 进 access log、代理日志 |
| 子协议 | 可行 | 服务端要显式回显协议名 |
| Cookie | 可行(同源) | 跨域受 SameSite 限制 |
| 先 HTTP 换一次性票据 | 最稳 | 多一次往返 |
推荐:HTTP 换一次性短时票据(30–60 s、绑 IP 或 UA)→ 建连 → 立即作废。URL 进日志也没法重放。
7. 页面隐藏、切网与降级
| 场景 | 现象 | 处理 |
|---|---|---|
visibilitychange 隐藏 |
移动端可能冻结连接 | 不主动断,回前台时先发一次心跳探活 |
| 网络切换(WiFi → 4G) | 原连接假死,readyState 仍是 1 |
监听 online 事件主动 close 后重建 |
| 后台挂起很久 | 连接已被服务端回收 | onopen 后立即 resume 同步 |
企业代理封 ws |
握手直接失败 | 降级到 SSE,再降级到轮询 |
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') probe(); // 探活而非直接重连
});
window.addEventListener('online', () => {
ws.close(4001, 'net-change'); // 主动关,走统一重连路径
});
降级别拍脑袋"重连三次就降"——那可能只是服务端重启。稳的判据是握手连续失败且 code 为 1006,说明链路层不通。
8. 排错:六种"连上了但没用"
| 现象 | 原因 | 处理 |
|---|---|---|
| 多个组件各建一条连接 | 连接逻辑写进了组件 | 提到单例/全局 store,引用计数 |
| 断线后一分钟内疯狂重连 | 无退避、attempt 未重置 |
指数退避 + 抖动 + 成功重置 |
| 消息延迟越来越大 | onmessage 里做重计算 |
只入队,处理丢给微任务或 Worker |
| 服务端收不到消息但不报错 | 连接假死 | 心跳 + 判死重连 |
| 鉴权 401 且重连无效 | token 过期但 URL 没更新 | 重连前重新取票据 |
| 关闭后仍收到消息 | 未解绑 onmessage,旧实例还活着 |
close 后把实例与回调一起清空 |
onmessage 里做重计算是隐蔽杀手:同一连接的 message 事件顺序派发,处理函数跑 200 ms,后面全积压 200 ms。必须"收下就返回",计算丢队列异步做。
9. 边界:什么情况本就不该用 WebSocket
| 场景 | 更合适 | 理由 |
|---|---|---|
| 单向通知、进度推送 | SSE | 自带重连与续传语义 |
| 低频(分钟级)拉取 | 轮询 | 连接保活成本高于收益 |
| 需要严格请求-响应 | HTTP | 语义与重试机制现成 |
| 消息需持久化与审计 | 消息队列 + HTTP 拉取 | WebSocket 不是存储 |
| 弱网移动端 | 轮询 + 本地缓存 | 长连接在弱网下反复重建更耗电 |
一句话:WebSocket 换来低延迟双向,代价是自造重连、心跳、会话恢复与鉴权。账算不过来时轮询更划算。
10. 自测
onerror与onclose各能拿到什么信息?为什么不能在onerror里重连?- 心跳间隔应该由链路上哪个参数决定?为什么浏览器端不能发协议层 ping?
- 指数退避里为什么必须加抖动?
onopen成功为什么不等于会话恢复?还需要补什么?- 浏览器的
WebSocket构造器为什么不能带Authorization头?三种替代方案各有什么风险? - 连接"假死"时
readyState是什么值?靠什么发现它?