1. 问题的本质:组件复用在“两个页面”之间为什么失效
先说结论:组件复用方案解决的是单页面内多个组件共享连接的问题,而“两个页面复用一个WebSocket”根本上是跨JavaScript运行环境的连接共享,单靠组件复用解决不了。
我做项目时遇到过这样一个场景:后台管理系统的A页面是实时设备监控面板,B页面是设备详情页,两个页面都挂着同一个WebSocket,推送设备状态、报警信息。刚开始图省事,直接在组件里各建各的连接,结果一打开第二个页面,服务端日志里就多出一条连接,页面切换多了以后,服务端连接数直接翻了一倍。更难受的是,部分云网关服务对单用户并发WebSocket数量有限制,连接一多就踢掉先前的连接,两台页面之间开始互相“挤下线”。
于是我开始找方案。网上搜“WebSocket复用”,跳出来的大多是“组件内部做一个模块级单例,多个组件调用同一个连接实例”,这类文章非常多。它的实现思路是:在模块顶层定义一个全局连接对象,任何组件加载这个模块时拿到的是同一个引用,连接只建立一次。这在单页应用里没问题,因为所有组件跑在同一个JavaScript上下文里,内存共享,单例确实有效。但一旦跨页面,这个方案就不好使了,原因是:
- 浏览器每个标签页、窗口有独立的JavaScript执行环境,内存、变量、单例对象完全隔离;
- A页面里建立的WebSocket连接,B页面拿不到这个连接对象的引用;
- 两个页面各自执行模块代码时,“全局单例”其实各有一份,等于又建了一条连接。
所以,要解决跨页面的连接复用,必须换一个思路:把连接的生命周期从页面里抽出来,放到一个所有页面都能访问到的共享环境里。浏览器提供的可行方案有两个层次:一个是官方设计的SharedWorker,另一个是借助localStorage + storage事件的降级方案。往下我会把两条路线的实现细节、选型理由、踩坑过程全部拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SharedWorker方案:把WebSocket连接放到浏览器级共享环境
2.1 为什么SharedWorker是这一类问题的最优解
SharedWorker是HTML5里定义的“共享Worker”,和普通Web Worker的差别在于:普通Worker只能被创建它的页面使用,而SharedWorker可以被同一浏览器实例下、同一来源下的多个页面共享。它天然就是为“多页面共享计算资源”这种场景设计的。
用它托管WebSocket连接,核心逻辑就是:
- WebSocket生命周期由SharedWorker内部维护,连接只建立一次;
- 所有页面通过
new SharedWorker(scriptURL)获取同一个Worker实例; - 页面和Worker之间通过
postMessage收发消息; - 服务端推送到Worker的消息,由Worker广播给所有已连接的页面。
在浏览器实现上,SharedWorker的实例和脚本运行环境是全局唯一的。第一个页面创建SharedWorker时,浏览器启动Worker线程;后续其他页面再执行new SharedWorker(同一个URL)时,不再创建新线程,而是直接关联到已存在的Worker实例。这正好满足“多个页面共享同一个连接”的需求。
我把架构画成文字描述:页面A、页面B分别连接SharedWorker,SharedWorker内部持有WebSocket连接,所有页面通过消息ID区分数据来源,Worker定期发送心跳保活。这样页面A、B不需要感知彼此的存在,只需要和Worker通信。
2.2 SharedWorker脚本的完整实现
下面是我在实际项目里跑通的Worker脚本,管理WebSocket连接、订阅页面、心跳、重连都包含在里面。
javascript复制// shared-worker.js
const ports = new Set();
let ws = null;
let heartbeatTimer = null;
let retryTimer = null;
let retryCount = 0;
const RECONNECT_MAX_DELAY = 30000;
const HEARTBEAT_INTERVAL = 25000;
function broadcast(type, payload) {
const message = JSON.stringify({ type, payload });
ports.forEach(port => port.postMessage(message));
}
function connect(url) {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
ws = new WebSocket(url);
ws.onopen = () => {
retryCount = 0;
broadcast('status', { state: 'open' });
startHeartbeat();
};
ws.onmessage = (event) => {
// 这里可以根据业务消息体解析,统一广播到所有页面
broadcast('message', event.data);
};
ws.onclose = () => {
broadcast('status', { state: 'closed' });
stopHeartbeat();
scheduleReconnect(url);
};
ws.onerror = () => {
// 错误事件后一般会触发 close
ws.close();
};
}
function scheduleReconnect(url) {
if (retryTimer) return;
const delay = Math.min(1000 * Math.pow(2, retryCount), RECONNECT_MAX_DELAY);
retryCount += 1;
retryTimer = setTimeout(() => {
retryTimer = null;
connect(url);
}, delay);
}
function sta
