前端知库

浏览器多个标签页之间怎样通信?

同源标签页可以用 localStorage 的 storage 事件、BroadcastChannel、SharedWorker 或 Service Worker 转发消息;用 window.open 打开的页面还能拿到窗口引用直接 postMessage,这也是唯一能跨源的办法。前几种都受同源限制,选哪种看是只发通知,还是要共享状态。

参考答案

简答

用户在一个标签页退出登录,其他开着的标签页也要马上跳到登录页,这就是多标签页通信要解决的事。

同源页面有四种常用办法。往 localStorage 写一个值,其他同源标签页会收到 storage 事件,写的那个页面自己收不到;BroadcastChannel 用同一个频道名一发多收,最省事;SharedWorker 是多个页面共用的一个后台脚本,可以在里面保存状态、转发消息;Service Worker 能通过 clients.matchAll() 找到它控制的页面,逐个 postMessage。

还有一种靠窗口引用:window.open() 返回新窗口的引用,新窗口通过 window.opener 拿到原窗口,双方可以用 postMessage 通信,而且能跨源。

要注意,前四种都要求同源,sessionStorage 的 storage 事件也到不了别的标签页。窗口引用在 noopener 或 Cross-Origin-Opener-Policy 隔离时会断开,跨源发消息还要校验 origin。

用 BroadcastChannel 同步退出登录

最常见的需求是“一处操作,处处同步”。下面在 Node 里用同一个频道名建三个对象,模拟三个标签页,看看 A 发出的消息谁能收到:

JavaScript
const tabA = new BroadcastChannel('auth');
const tabB = new BroadcastChannel('auth');
const tabC = new BroadcastChannel('auth');
let received = 0;

tabA.onmessage = (e) => console.log('A 收到', e.data);
for (const [name, ch] of [['B', tabB], ['C', tabC]]) {
  ch.onmessage = (e) => {
    console.log(`${name} 收到`, e.data);
    received += 1;
    if (received === 2) [tabA, tabB, tabC].forEach((c) => c.close());
  };
}

tabA.postMessage({ type: 'logout', at: 1 });
// C 收到 { type: 'logout', at: 1 }
// B 收到 { type: 'logout', at: 1 }

为什么 A 没有打印?因为发送方自己收不到自己发的消息,所以真实代码里,发起退出的页面要自己清会话、自己跳转。B 和 C 是不同的接收方,代码不要依赖它们谁先收到。消息走结构化克隆,可以直接发对象。更多细节见《BroadcastChannel 适合解决什么跨标签通信问题?》。

storage 事件:最老的办法

BroadcastChannel 普及之前,大家借 localStorage 来通信。原理是:一个页面改了 localStorage,同源的其他页面会收到 storage 事件,事件里带着 key、oldValue 和 newValue。

JavaScript
// 发送方
localStorage.setItem('auth-event', JSON.stringify({ type: 'logout', at: Date.now() }));

// 接收方
window.addEventListener('storage', (event) => {
  if (event.key !== 'auth-event' || !event.newValue) return;
  const message = JSON.parse(event.newValue);
  if (message.type === 'logout') location.assign('/login');
});

为什么要带一个 at 时间戳?因为值没有变化时,不算一次修改,带上变化的字段才能保证每次都触发。localStorage 只存字符串,所以对象要先 JSON.stringify。

和 BroadcastChannel 一样,改值的那个页面自己不会收到事件。sessionStorage 也有 storage 事件,但 MDN 写明它只在同一个标签页里的同源 iframe 之间触发,不会到达其他标签页,所以不能拿来做多标签页通信。

需要共享状态时用 SharedWorker

前两种都是广播,消息发完就没了,后打开的页面收不到。如果几个标签页要共用一份状态,或者共用一条 WebSocket 连接,就需要一个大家都连得上的中心,这就是 SharedWorker:同源页面用同一个脚本地址创建它,得到的是同一个实例。

JavaScript
// shared.js,由所有标签页共用
const ports = new Set();
let user = null;

onconnect = (event) => {
  const port = event.ports[0];
  ports.add(port);
  port.postMessage({ type: 'init', user });
  port.onmessage = (e) => {
    user = e.data.user;
    for (const p of ports) p.postMessage({ type: 'update', user });
  };
};

// 页面里
const worker = new SharedWorker('/shared.js');
worker.port.onmessage = (e) => render(e.data);
worker.port.postMessage({ user: { name: '小王' } });

新页面一连上就会收到 init,拿到当前状态,这是广播做不到的。代价是要自己维护连接列表:页面关掉时不能指望 Worker 一定收到通知,ports 里可能留下失效的端口,实际项目里常让页面卸载前发一条“断开”消息。SharedWorker 和普通 Worker 的区别见《SharedWorker 和 Dedicated Worker 有什么区别?》。

Service Worker 转发

已经注册了 Service Worker 的站点,也可以让它当中转站。页面把消息发给 Service Worker,Service Worker 用 clients.matchAll() 找到它控制的页面,再调用每个页面的 postMessage,页面在 navigator.serviceWorker 上监听 message 事件接收。

JavaScript
// sw.js
self.addEventListener('message', async (event) => {
  const list = await self.clients.matchAll({ type: 'window' });
  for (const client of list) {
    if (client.id !== event.source.id) client.postMessage(event.data);
  }
});

// 页面里
navigator.serviceWorker.addEventListener('message', (e) => console.log(e.data));
navigator.serviceWorker.controller?.postMessage({ type: 'logout' });

它比 BroadcastChannel 绕,只有在本来就用 Service Worker 处理推送或离线时才值得这样做。还要注意 controller 可能是 null,比如强制刷新后页面不受 Service Worker 控制。

窗口引用:唯一能跨源的办法

上面四种都只在同源之间工作。如果页面 A 用 window.open() 打开了页面 B,A 能拿到返回的窗口引用,B 能通过 window.opener 拿到 A,双方就能用 postMessage 直接通信,而且可以跨源。

JavaScript
// 页面 A
const child = window.open('https://pay.example/checkout');
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://pay.example') return;
  console.log('支付结果', event.data);
});

// 页面 B(pay.example)
window.opener?.postMessage({ status: 'paid' }, 'https://shop.example');

为什么要写目标源和检查 event.origin?因为窗口里的页面可能已经跳到别的网站,不写目标源,消息可能发给错误的对象;不检查来源,任何页面都能冒充对方,详见《跨窗口 postMessage 怎样避免把消息发给错误对象?》。

这种办法有几个前提:两个窗口必须是这种打开关系,用户自己新开的标签页没有引用;window.open() 被弹窗拦截时返回 null;用了 noopener,新窗口的 window.opener 是 null;页面设置了 Cross-Origin-Opener-Policy 并被放进不同的浏览上下文组时,引用会断开,返回的窗口对象 closed 为 true。

怎么选

只发通知,用 BroadcastChannel;要兼容很老的浏览器,用 storage 事件;要共享状态或一条连接,用 SharedWorker;已经有 Service Worker,可以让它转发;跨源,只能用窗口引用加 postMessage。不同源的标签页之间没有引用时,浏览器里没有直接通道,只能经服务端,比如 WebSocket 推送。

常见追问

同源是什么意思?两个子域算同源吗?

同源要求协议、主机名和端口都相同,a.example.com 和 b.example.com 主机名不同,不算同源,BroadcastChannel、SharedWorker 都连不上。另外 MDN 还提到,BroadcastChannel 实际按存储分区隔离,嵌在别的站点里的同源 iframe 也和顶层打开的页面通不了。区别见《同源、同站点与跨域分别是什么意思?》。

多个标签页同时收到“数据更新了”,都去请求接口怎么办?

这是广播的通病。可以只让一个标签页去请求,再把结果广播出去;选谁去做可以用 Web Locks 抢锁,或者用 SharedWorker 统一请求再分发。

为什么不轮询 localStorage?

能用,但要定时读、比较值,既浪费又有延迟。storage 事件和 BroadcastChannel 都是有变化才通知,没有理由再轮询。