SharedWorker 是唯一能实现多标签页共用单心跳连接的机制——所有页面共享一条 WebSocket,由 SharedWorker 内 setInterval 每 30 秒发 ping,服务端需响应 pong;Worker 收到后重置时间戳,连续两次超 45 秒未收到则 close 并指数退避重连(初始 1s,上限 30s),重连前清除旧定时器;状态变更通过 port.postMessage 单向广播,页面仅监听不轮询,避免 BroadcastChannel 唤醒开销;必须严格绑定心跳、重连、清理逻辑,否则比单页更耗资源。

SharedWorker 是唯一能避免多标签页各自心跳唤醒、把心跳逻辑收束到单点执行的机制——所有页面共用一条连接,心跳只发一次,不依赖页面是否在前台。
SharedWorker 内必须自己发 ping,不能靠页面定时器
页面切后台后,主线程的 setInterval 会大幅降频甚至暂停(Chrome 通常降到 1s 以上,Firefox 可能完全冻结),导致心跳失效、服务端误判断连。SharedWorker 不受页面活跃状态影响,只要还有同源页面打开,它就持续运行。
- 在 SharedWorker 脚本中用
setInterval(() => ws.send('ping'), 30000)发心跳,不要传参、不依赖页面传来的 timer ID - 服务端必须响应
'pong',Worker 收到后重置本地“最后收到 pong 时间戳” - 连续两次未在 45s 内收到
pong(即超时 + 重试窗口),主动调用ws.close()并触发重连 - 别在
ws.onmessage里直接处理 pong —— 要先判断event.data === 'pong',否则业务消息可能被误判
心跳失败后必须指数退避重连,且禁止多页面并发触发
如果每个页面都监听自己的 onclose 并立刻重连,几十个标签页会在断连瞬间同时发起连接请求,服务端可能触发频控或连接拒绝。
- 重连逻辑必须全在 SharedWorker 内:检查
ws?.readyState === WebSocket.CLOSED后才执行new WebSocket(url) - 使用
setTimeout实现退避,避免setInterval积压未清除的定时器:setTimeout(connect, delay),delay 初始为 1000,每次失败翻倍,上限 30000 - 重连前清空旧心跳定时器:
clearInterval(pingIntervalId),重连成功后再重新setInterval - 页面不能监听
onclose后自行重连 —— 它们只该监听 Worker 发来的{ type: 'status', state: 'reconnecting' }做 UI 提示
心跳与连接状态必须跨页面同步,但不能靠轮询
用户可能在 A 标签页看到“已连接”,却在 B 标签页看到“断开”,这种状态不一致源于各页面独立维护连接状态。SharedWorker 要承担状态广播职责,但广播本身不能成为新唤醒源。
- Worker 每次状态变更(
connecting/open/reconnecting/closed)都调用port.postMessage({ type: 'status', state, timestamp: Date.now() }) - 页面收到后更新本地状态,但不做任何定时轮询或主动拉取 —— 状态变更只由 Worker 单向推送
- 避免用
BroadcastChannel同步状态:它会在每个页面触发事件,反而增加不必要的 JS 执行和潜在唤醒 - Worker 自身用
self.clients.matchAll()不可用(SharedWorker 不支持),所以必须靠port列表逐个推送,确保无遗漏
最容易被忽略的是:心跳不是发了就完事,它必须和重连、状态清理、端口生命周期绑定。一个没清理的 setInterval 会在所有页面关闭后继续跑 30 秒,而一个没校验 readyState 的重连会反复 new WebSocket 导致内存泄漏。这些细节不处理,所谓“全局心跳”反而比单页更耗资源。

















