2698 字
约 8 分钟
14
S14-X11 · WebSocket 在前端的连接管理:重连、心跳与降级

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. 自测

  1. onerror 与 onclose 各能拿到什么信息?为什么不能在 onerror 里重连?
  2. 心跳间隔应该由链路上哪个参数决定?为什么浏览器端不能发协议层 ping?
  3. 指数退避里为什么必须加抖动?
  4. onopen 成功为什么不等于会话恢复?还需要补什么?
  5. 浏览器的 WebSocket 构造器为什么不能带 Authorization 头?三种替代方案各有什么风险?
  6. 连接"假死"时 readyState 是什么值?靠什么发现它?
S14-X11 · WebSocket 在前端的连接管理:重连、心跳与降级
https://www.clxhxhhr.top/posts/5817/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。