React 里 WebSocket 的正确管理:多标签页、断线重连与状态同步
为什么不能直接写在组件里
很多人第一次在 React 里接 WebSocket,会本能地写在某个组件的 useEffect 里:挂载时 new 一个连接,卸载时 close 掉。功能短时间能跑,但页面一多、用户习惯开三四个标签页,问题就来了:连接数爆掉、消息重复接收、刷新后短暂掉线、多个组件各连各的。
最常见的写法有三个问题。第一,每个用到这个 Hook 的组件都会建一条独立连接,一个页面就三四条,浏览器对单域并发连接是有上限的。第二,组件卸载就 close,切个路由连接就断,切回来又重连,中间消息会丢。第三,用户开多个标签页,每个标签页是独立 JS 上下文,各自建连接、各自重连,后端一抖,所有标签页同时重连,瞬间形成重连风暴,反而把后端打挂。
核心结论:WebSocket 连接应该是"应用级单例",而不是"组件级局部变量"。
第一步:连接管理器做成单例
把连接从组件里抽出来,放到一个模块级的单例对象里。组件不关心连接怎么建、建几次,只关心"订阅消息"和"发送消息"。
class ConnectionManager {
private ws: WebSocket | null = null;
private listeners = new Set<(data: any) => void>();
private url: string;
private closedByUser = false;
constructor(url: string) { this.url = url; }
connect() {
if (this.ws && this.ws.readyState <= WebSocket.OPEN) return;
this.closedByUser = false;
this.ws = new WebSocket(this.url);
this.ws.onmessage = (e) => {
const data = JSON.parse(e.data);
this.listeners.forEach((fn) => fn(data));
};
this.ws.onclose = () => {
if (!this.closedByUser) this.scheduleReconnect();
};
}
subscribe(fn: (data: any) => void) {
this.listeners.add(fn);
this.connect();
return () => this.listeners.delete(fn);
}
send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}
这样无论多少组件、多少处订阅,底层始终只有一条连接。
第二步:指数退避重连
onclose 里不能直接无脑重连,否则后端一抖,前端高频重试会把情况搞得更糟。用指数退避,每次重连间隔翻倍,并设上限。重连成功后重置间隔,避免"抖一次之后 forever 慢重连"。
第三步:多标签页状态同步
单例解决了单标签页内共用一条连接,但每个标签页仍是独立连接。要做到多设备、多标签互不干扰,需要在协议层带上 device 字段,让后端按连接聚合,而不是按用户粗暴地设一个在线标志。前端这边,跨标签页状态同步可以用 BroadcastChannel:一个标签页收到的消息广播给同源其他标签页,避免重复触发侧边栏红点动画,再配合 visibilitychange 在页面回到前台时主动检查连接。
第四步:包成 React Hook
前面都是框架无关的纯 TS,最后给组件一个干净接口:组件里完全看不到 WebSocket 怎么建、怎么重连,只管收发。新页面接入实时能力,就是调一个 Hook 的事。
小结
连接单例化,别写在组件里各自建连;重连用指数退避,别无脑重试;多标签页用单连接加 BroadcastChannel 同步;设备级状态交给协议层的 device 字段。把连接当成基础设施而不是组件状态,实时功能的稳定性会好很多。
test1
文章作者
为什么不能直接写在组件里 很多人第一次在 React 里接 WebSocket,会本能地写在某个组件的 useEffe...
- 分类
- 技术
- 发布时间
- 2026-09-11
- 字数
- 约 1891 字
- 阅读
- 0 次
