系统梳理不同页签(Tab / Window / Frame)之间的通信手段:能做什么、怎么做、底层原理、限制与踩坑、降级方案,以及一份可落地的通用消息总线实现。
1. 典型场景与选择指南
诉求 | 推荐优先级 | 说明 |
---|---|---|
同源多标签页简单广播 | BroadcastChannel → localStorage(storage 事件) | 现代浏览器首选 BroadcastChannel;需要兼容老浏览器时降级到 storage 事件 |
父子窗口/打开者-被打开者直接通信 | postMessage + Window 引用 | 通过 window.open、window.opener、iframe.contentWindow 获取引用,再 postMessage |
需要单点中枢、复杂路由或持久化 | SharedWorker / Service Worker | SharedWorker 做内存中枢;Service Worker 能向所有受控客户端群发,并可持久化 |
跨设备或登录用户维度的广播 | WebSocket / SSE / WebRTC | 通过后端转发(或点对点)实现端到端同步 |
单例任务/互斥锁(而非消息) | Web Locks API(navigator.locks) | 不是通信,但常与通信结合做"选主/互斥" |
原则:能用 BroadcastChannel 就别用 localStorage(更干净、性能好、不阻塞)。要兼容老环境,再做降级;有复杂编排再上 Worker。
2. BroadcastChannel:同源广播的首选
2.1 核心 API
javascript
// 建立频道
const bc = new BroadcastChannel('app_bus');
// 接收
bc.onmessage = (event) => {
// event.data 支持 Structured Clone(可传对象、ArrayBuffer、MessagePort 等)
console.log('[BC] recv:', event.data);
};
// 发送
bc.postMessage({ type: 'PING', ts: Date.now() });
// 关闭
bc.close();
2.2 原理与语义
- 同源 标签页共享同名频道;浏览器内部维护订阅者列表,消息采用 Structured Clone 语义拷贝。
- 有序性:同一发送方的消息在同一接收方表现为 FIFO,但不同发送方之间没有全局序。
- 传输保障:非持久、非可靠(页面关闭或后台冻结时可能丢)。
2.3 实战要点
- 大消息(> 数百 KB)不建议直传,改为 "信令 + IndexedDB 取件" 。
- 页面卸载时(visibilitychange/pagehide)清理资源;必要时发送 LEAVE。
- 去重:消息附带 id(如 crypto.randomUUID()),接收方维护 LRU Set 去重。
3. localStorage + storage 事件:兼容兜底
3.1 使用示例
javascript
// 监听(只在"其他"标签页触发)
window.addEventListener('storage', (e) => {
if (e.key === 'app_bus') {
const payload = JSON.parse(e.newValue || 'null');
if (payload) handleMessage(payload);
}
});
// 发送(会同步写磁盘;同页不触发 storage 事件)
function send(msg) {
localStorage.setItem('app_bus', JSON.stringify({
...msg,
id: crypto.randomUUID(),
ts: Date.now(),
}));
}
3.2 限制与坑
- 同步阻塞:setItem 会阻塞主线程,频繁发送会卡顿。
- 同页不触发:当前页调用 setItem 不会触发本页 storage 事件。
- 只有字符串:需要 JSON 编解码。
- 同值不触发:如果写入的值未变化,不会触发事件(可附带随机 nonce)。
- 隐私模式/分区存储:不同容器/场景可能彼此隔离。
适用于"偶发 广播 + 低频 消息 + 老浏览器兼容"。
4. postMessage + Window 引用:点对点直连
4.1 适用场景
- A 打开 B(window.open),或 A 内嵌 B(iframe),或 B 由 A 打开(window.opener)。
- 同源可直接读写;跨源也能 postMessage,但需 targetOrigin 校验。
4.2 使用示例
javascript
// 打开子窗口并握手
const child = window.open('/child.html', 'child');
window.addEventListener('message', (event) => {
// 严格校验来源
if (event.origin !== location.origin) return;
console.log('from child:', event.data);
});
// 向子窗口发送
child?.postMessage({ type: 'PING' }, location.origin);
跨源时:必须使用精确的 targetOrigin(不要用 *),并对 event.origin 做白名单校验,防止 XSS/点击劫持类问题。
5. SharedWorker:内存中枢
5.1 思路
多个同源页面连接到同一个 SharedWorker ,通过 MessagePort 与之通信;Worker 内部充当"Hub"做路由/广播/状态管理。
5.2 示例
worker.js
javascript
const ports = new Set();
onconnect = (e) => {
const port = e.ports[0];
ports.add(port);
port.onmessage = (evt) => {
// 广播给其他端
for (const p of ports) {
if (p !== port) p.postMessage(evt.data);
}
};
port.start();
port.addEventListener('close', () => ports.delete(port));
};
页面:
javascript
const sw = new SharedWorker('/worker.js');
const port = sw.port;
port.start();
port.onmessage = (e) => console.log('[SW] recv:', e.data);
port.postMessage({ type: 'HELLO' });
5.3 优缺点
- ✅ 灵活、可做复杂编排/缓存;
- ⚠️ 兼容性较 BroadcastChannel 差一些;调试门槛略高。
6. Service Worker:全客户端中转与持久化
6.1 模式
- 页面 → SW:navigator.serviceWorker.controller.postMessage
- SW → 所有页面:self.clients.matchAll() → client.postMessage
6.2 示例
sw.js
javascript
self.addEventListener('message', (event) => {
// 简单群发
self.clients.matchAll({ includeUncontrolled: true, type: 'window' })
.then((clients) => {
clients.forEach((client) => client.postMessage(event.data));
});
});
self.addEventListener('activate', (e) => self.clients.claim());
页面:
javascript
navigator.serviceWorker.register('/sw.js');
navigator.serviceWorker.addEventListener('message', (e) => {
console.log('[SW] recv:', e.data);
});
function sendViaSW(data) {
navigator.serviceWorker.ready.then((reg) => {
reg.active?.postMessage(data);
});
}
6.3 要点
- 仅受控页面能直接与 SW 通信;首次加载可能未受控,clients.claim() 可加速接管。
- SW 可结合 Cache/IndexedDB 做可靠队列或离线重放。
7. 服务器中转(WebSocket / SSE / WebRTC)
- 跨设备/账号级广播的标准方案;同设备多标签页也可统一走服务器,减少本地复杂度。
- WebSocket:双向、低时延;SSE:单向、简单;WebRTC:P2P,常与信令(WebSocket)结合。
实战建议:本地(BC/Worker)优先、服务器兜底 。对"必须送达"的关键消息,做ACK + 重试。
8. 更高阶:SharedArrayBuffer(SAB)与跨上下文共享
- 在跨源隔离(COOP+COEP)启用时,可通过 BroadcastChannel / postMessage 传递 SharedArrayBuffer,配合 Atomics 做无锁队列/环形缓冲,获得极低延迟。
- 复杂且对环境要求高,通常用于多媒体/计算密集型场景。
9. 通用"可靠广播"模式(ACK/去重/重试)
9.1 消息格式
typescript
interface BusMessage<T=any> {
id: string; // 唯一 ID(UUID v4)
ts: number; // 发送时间戳
type: string; // 主题/事件名
payload: T; // 载荷
ack?: boolean; // 是否为 ACK 消息
to?: string; // 指定接收者(可选)
from?: string; // 发送者实例 ID
}
9.2 接收侧去重
- 维护 seenIds(如 LRU 缓存 1--5 分钟),收到重复 id 直接丢弃。
9.3 ACK + 重试
- 发送后 setTimeout 等待 ACK;超时重发(指数退避);达到上限告警。
10. 一个可落地的跨标签页消息总线(含降级)
目标:优先 BroadcastChannel → 失败则 Service Worker → 再失败则 localStorage。
typescript
// 简化版:事件订阅、可靠发送(可自行扩展 ACK/重试)
type Handler = (msg: any) => void;
export class CrossTabBus {
private channelName: string;
private bc?: BroadcastChannel;
private swReady: Promise<ServiceWorkerRegistration> | null = null;
private lsKey: string;
private handlers: Map<string, Set<Handler>> = new Map();
private instanceId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
private seen = new Set<string>();
constructor(channelName = 'app_bus') {
this.channelName = channelName;
this.lsKey = `${channelName}__ls`;
// 1) BroadcastChannel
try {
this.bc = new BroadcastChannel(channelName);
this.bc.onmessage = (e) => this._onMessage(e.data);
} catch {}
// 2) Service Worker(可选)
if ('serviceWorker' in navigator) {
this.swReady = navigator.serviceWorker.ready.catch(() => null as any);
navigator.serviceWorker.addEventListener('message', (e) => this._onMessage(e.data));
}
// 3) localStorage 兜底
window.addEventListener('storage', (e) => {
if (e.key === this.lsKey && e.newValue) {
try { this._onMessage(JSON.parse(e.newValue)); } catch {}
}
});
}
on(type: string, fn: Handler) {
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type)!.add(fn);
return () => this.off(type, fn);
}
off(type: string, fn: Handler) {
this.handlers.get(type)?.delete(fn);
}
emit(type: string, payload: any) {
const msg = { id: crypto.randomUUID?.() || String(Math.random()), ts: Date.now(), type, payload, from: this.instanceId };
this._fanout(msg);
}
private _fanout(msg: any) {
// BroadcastChannel
if (this.bc) {
try { this.bc.postMessage(msg); } catch {}
}
// Service Worker(向 active SW 发送)
this.swReady?.then((reg) => reg?.active?.postMessage?.(msg)).catch(() => {});
// localStorage 兜底
try { localStorage.setItem(this.lsKey, JSON.stringify(msg)); } catch {}
}
private _onMessage(msg: any) {
if (!msg || typeof msg !== 'object') return;
if (this.seen.has(msg.id)) return; // 去重
this.seen.add(msg.id);
// LRU 简化:超过一定大小清理
if (this.seen.size > 2000) this.seen.clear();
const set = this.handlers.get(msg.type);
set?.forEach((fn) => fn(msg.payload));
}
}
使用:
javascript
import { CrossTabBus } from './CrossTabBus';
const bus = new CrossTabBus('my_app_bus');
bus.on('user:logout', () => {
// 做登出清理
location.reload();
});
// 比如收到服务端事件,广播给所有标签页
function onServerLogout() {
bus.emit('user:logout', { reason: 'token_expired' });
}
11. 选主(Leader Election)与单例任务
目的:同一站点只让一个标签页跑"定时同步/心跳/后台任务"。
11.1 BroadcastChannel + 心跳
javascript
const bc = new BroadcastChannel('leader');
const myId = crypto.randomUUID();
let isLeader = false;
let lastBeat = Date.now();
function tryElect() {
// 若长时间未收到 leader 心跳,则自荐
if (Date.now() - lastBeat > 3000 && !isLeader) {
bc.postMessage({ type: 'ELECT', id: myId, t: Date.now() });
}
}
bc.onmessage = (e) => {
const m = e.data;
if (m.type === 'BEAT') lastBeat = Date.now();
if (m.type === 'ELECT') {
// 简单"Bully":ID 更大者胜出
if (m.id > myId) isLeader = false; else isLeader = true;
}
};
setInterval(() => {
tryElect();
if (isLeader) bc.postMessage({ type: 'BEAT', id: myId });
}, 1000);
更严谨可用 Web Locks API:
javascript
navigator.locks.request('singleton-task', { mode: 'exclusive' }, async () => {
// 只有获得锁的标签页会执行这里
await runBackgroundJob();
});
12. 安全、性能与可靠性
12.1 安全
- postMessage 必须指定精确 targetOrigin,并校验 event.origin。
- 对所有外部输入(消息)做结构校验(如 zod/superstruct)。
- 不要把敏感数据放入 localStorage 明文广播。
12.2 性能
- localStorage.setItem 会阻塞主线程;高频通信避免使用。
- 大对象建议经 IndexedDB 持久化,消息只带"索引"。
- 背景标签页计时器可能被节流,心跳/重试策略要容忍抖动。
12.3 可靠性
- 关键消息实现 ACK/重试/去重;
- 页面卸载前(pagehide/visibilitychange)做最后通知;
- 对 SW/SharedWorker 引入 健康检查 与 重新连接。
13. 兼容性与降级建议
-
首选 BroadcastChannel;如果环境不支持:
- 若存在 SW:走 SW 中转;
- 否则:storage 事件兜底。
-
SharedWorker 在部分浏览器/版本支持较弱,尽量作为可选增强。
-
IE 等古老环境:只能用 storage 事件 / postMessage(在能拿到引用的前提下)。
14. 常见需求的实现清单
- 跨标签页单点登录/登出同步:Bus 广播 user:logout,收到后清 Token + 刷新。
- 表单协同编辑(同账号) :频道内发送 cursor/patch,并对本地变更做去抖与去重。
- 通知徽标同步:收到服务器通知后在一个标签页拉取计数,再广播至其他标签页。
- "只保留一个播放实例" :选主后非 Leader 收到 play 指令时转成 pause。
15. 小结
- BroadcastChannel 是同源多页签通信的"现代默认";
- localStorage(storage 事件) 是简易兼容兜底;
- postMessage 适用于存在窗口引用的点对点场景;
- SharedWorker / Service Worker 能承载更复杂的中枢化逻辑;
- 做到可观测、可恢复、可降级,你的跨页通信就能稳如老狗。