文章

跨标签页通信全解析

同源多标签页如何互相通知:BroadcastChannel / storage 事件 / SharedWorker / postMessage 的选型对比与实战坑点。

跨标签页通信全解析

一句话概括

浏览器里同一个源的多个标签页,会共享 Cookie 和 IndexedDB,但每个标签页是独立的 JS 运行时、独立的内存空间——你在一个标签页改了内存里的变量,另一个标签页根本不知道。所以”在一个标签页退出登录,其他标签页也跟着退出”“A 标签页加购,B 标签页购物车数字同步+1”这类需求,就得靠专门的跨标签页通信方案。面试常问的本质是:同源场景下,有哪些”广播消息”的手段?各自的边界和坑在哪? 记住一条主线——现代项目首选 BroadcastChannel,老浏览器兜底用 storage 事件,跨源/iframe 才用 postMessage,状态特别复杂再上 SharedWorker。

核心知识点

1. 先分清:同源 vs 跨源,决定了用什么

“跨标签页”大多数情况是同源(协议+域名+端口都一样,比如都是 https://app.com)。同源场景的三大主力是 BroadcastChannel、storage 事件、SharedWorker。只有要和不同源的 iframe 通信、或和 Worker/Service Worker 通信时,才用 postMessage。

1
2
3
4
5
6
7
8
9
10
11
// 同源:直接开一个同名频道就能互相喊话
const ch = new BroadcastChannel('app-sync');
ch.postMessage({ type: 'LOGOUT' });

// 跨源:比如和嵌入的第三方 iframe 通信,必须指定目标窗口并校验来源
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage('hi', 'https://other.com');
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://other.com') return; // ❗跨源必校验 origin,否则谁都能伪造消息
  console.log(e.data);
});

一句话:同源用广播类 API,跨源用 postMessage 且必校验 origin。

2. BroadcastChannel:现代项目的首选

BroadcastChannel 是专门为同源广播设计的 API:开一个带名字的频道,任何同源的标签页、Worker、iframe 只要连同一个名字,就能互相收消息,典型的”发布-订阅”。

1
2
3
4
5
6
7
8
9
10
11
// 标签页 A(发送)
const ch = new BroadcastChannel('cart');
ch.postMessage({ type: 'ADD', item: '耳机' });

// 标签页 B(接收)
const ch = new BroadcastChannel('cart');
ch.onmessage = (e) => {
  if (e.data.type === 'ADD') renderCart(e.data.item);
};
// 不再需要时记得关掉,释放监听器
window.addEventListener('beforeunload', () => ch.close());

它的几个关键点,面试很容易漏:

  • 发消息的标签页自己收不到(只广播给”其他”上下文),天然适合”通知别人”。
  • 底层走结构化克隆算法,和 structuredClone() 一样——能直接传对象、Map、Set、Date、ArrayBuffer,不用自己 JSON 序列化;但传函数、DOM 节点会抛 DataCloneError。
  • 只限同源,且不能指定发给某个特定标签页(是广播)。
  • 兼容性:Baseline 2022,Chrome 54 / Firefox 38 / Safari 15.4 起支持;IE 不支持。

对比旧方案,它就是来”干掉 storage 事件 hack”的:

1
2
3
4
5
6
7
// ❌ 老做法:拿 localStorage 当消息总线——写值、监听 storage、再删值、还要处理去重
localStorage.setItem('__bc', JSON.stringify({ type: 'LOGOUT', t: Date.now() }));
localStorage.removeItem('__bc'); // 删掉还会再触发一次 storage 事件,得专门过滤

// ✅ 新做法:一行 postMessage 解决,没有序列化、没有去重、没有副作用
const ch = new BroadcastChannel('app-sync');
ch.postMessage({ type: 'LOGOUT' });

3. storage 事件:最稳的兜底方案

当 localStorage 被其他标签页修改时(setItem / removeItem / clear),浏览器会在除修改者之外的所有同源标签页触发 storage 事件。注意三个坑:

1
2
3
4
5
6
7
8
9
window.addEventListener('storage', (e) => {
  if (e.key === 'token') {
    if (e.newValue) console.log('登录态变了,更新 UI');
    else console.log('被清空了,可能是退出登录');
  }
});
// ① 自己改自己不触发——只有"别人改"才会通知你
// ② 只认 setItem/removeItem/clear,单纯读 localStorage 不触发
// ③ 值只能是字符串,对象要 JSON.stringify / parse;约 5MB 上限

适用场景:低频的状态同步(登录态、主题、用户设置),”用存储同步状态,而不是用它频繁发消息”。优点是所有浏览器都支持(含 IE8+),缺点是同步、字符串、有节流、不能发瞬时消息。所以它的最佳定位是 BroadcastChannel 的兼容兜底,而不是主力。

4. SharedWorker:状态特别复杂时的中央管理器

多个标签页共享一个 SharedWorker 实例,它能在内存里维护一份共享状态,当某个标签页发消息过来时,由它统一改状态并广播给所有标签页。相当于把”全局状态”搬到了一个独立的线程里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// shared-worker.js(被所有标签页共享)
const ports = [];
let state = { count: 0 };
self.onconnect = (e) => {
  const port = e.ports[0];
  ports.push(port);
  port.postMessage({ type: 'SYNC', state }); // 新标签页进来先同步一份
  port.onmessage = (e) => {
    if (e.data.type === 'INCREMENT') state.count++;
    ports.forEach((p) => p.postMessage({ type: 'UPDATE', state })); // 广播给所有人
  };
  // 如果用 addEventListener 监听 message,必须调用 port.start()
};

// 每个标签页里
const worker = new SharedWorker('shared-worker.js');
worker.port.onmessage = (e) => console.log(e.data); // 用 .onmessage 就不用 start()
worker.port.postMessage({ type: 'INCREMENT' });

坑:代码和调试都比前两种复杂,IE 不支持;而且 port 上如果用 addEventListener('message') 才需要 port.start(),用 .onmessage 赋值则不用——这是 MessagePort 的通用规则,容易踩。一般只有”客户端状态逻辑非常复杂、需要一个稳健中央管理器”时才上它。

5. 四种方案怎么选(面试常考对比表)

方案同源/跨源能否发复杂数据是否持久化兼容性最佳定位
storage 事件同源仅字符串是(localStorage)全(含 IE8+)低频状态同步 / 兜底
BroadcastChannel同源结构化克隆(对象等)否Baseline 2022同源广播首选
SharedWorker同源结构化克隆否(内存态)无 IE复杂中央状态
postMessage均可结构化克隆否全跨源 / iframe / Worker

口诀:同源简单广播用 BroadcastChannel 最省心,老浏览器兜底用 storage 事件,跨源精准打到 iframe 用 postMessage,状态太复杂才上 SharedWorker。

其实你每天都在用

  • 一处退出、处处退出:在 A 标签页点退出登录,B/C 标签页通过 BroadcastChannel 收到 LOGOUT 事件,一起清掉登录态跳登录页
  • 购物车跨标签页同步:A 标签页加了一件耳机,B 标签页顶部购物车角标 +1,就是广播了 ADD 消息
  • 主题/深色模式切换:在一个标签页切了深色,其他标签页监听同步事件跟着切,用的就是跨标签页通信
  • 登录态不一致兜底:storage 事件监听 token 被清空,其他标签页立刻弹回登录页
  • 后台管理改了配置/开关:运营在设置标签页改了功能开关,所有打开的业务标签页实时刷新,不用手动刷新页面

常见误解(FAQ)

❌ 误区一:”localStorage 能存东西,那用它在标签页之间传消息最方便”

localStorage 是持久化存储,不是消息总线。拿它传消息要 JSON 序列化、要处理”同值不发”“删除又触发一次”等边界,既慢又别扭。BroadcastChannel 一行 postMessage 就搞定,且底层结构化克隆不用序列化。结论:要持久化低频状态用 storage 事件,要即时广播消息用 BroadcastChannel,别把存储当总线用。

❌ 误区二:”BroadcastChannel 发消息的标签页,自己也能 onmessage 收到”

恰恰相反——发送方不会收到自己发出去的消息,只有”其他”同源上下文能收到。这是它天然适合”通知别人”的原因。如果你需要在自己这侧也立即更新 UI,得在 postMessage 的同时本地直接改状态,而不是等回调。

❌ 误区三:”跨标签页通信都能用 BroadcastChannel,跨源也一样”

BroadcastChannel 严格同源(协议/域名/端口全相同),app.com 和 staging.app.com 都算不同源、互相收不到。要和跨源 iframe 或 Worker 通信,必须用 postMessage,并且务必校验 e.origin,否则任意页面都能伪造消息打进来。

❌ 误区四:”SharedWorker 和 Web Worker 一样,只是能多标签页用”

普通 Web Worker 是一对一(每个标签页各自一个实例,互不通信);SharedWorker 是多对一(同一源的所有标签页共享同一个实例,由它做中央状态)。前者适合”把重计算搬出主线程”,后者适合”多标签页共享状态”——目的完全不同,别混。

一句话总结

同源多标签页各自为政,靠”广播”互通:现代首选 BroadcastChannel(结构化克隆、一行搞定、自己不收自己的),老浏览器用 storage 事件兜底(注意只通知别人、字符串、低频),跨源/iframe 用 postMessage 且必校验 origin,状态特别复杂才上 SharedWorker——记住”同源广播选 BroadcastChannel,跨源精准用 postMessage”就够了。

本文由作者按照 CC BY 4.0 进行授权