浏览器跨标签页通信实战:API 选型与本地演示
本文目录
同一个站点打开多个标签页后,常见需求包括同步退出登录、刷新配置、避免重复轮询、协调草稿保存,以及让多个页面共享一次昂贵任务。它们看起来都是“标签页通信”,但其实分成两类问题:消息通知和并发协调。选错原语,通常会把简单广播做成一套脆弱的选主协议。
本文侧重 API 职责、浏览器生命周期边界和可交互的同源实验。若你要看可复用事件总线、消息去重与 storage 降级的工程化写法,继续阅读跨标签页事件总线设计;两篇覆盖不同深度,不是重复目录。
先按问题选 API
| 需求 | 首选 | 关键边界 |
|---|---|---|
| 同源页面间发即时通知 | BroadcastChannel | 不持久化;排除发起调用的 channel 对象,同文档的其他同名对象仍可接收 |
| 兼容性回退或传递很小的状态变更 | localStorage + storage 事件 | 事件只在其他文档触发;需校验 event.storageArea;存储同步且有配额 |
| 保证同一时刻只有一个标签页执行任务 | Web Locks API | 用锁协调,不要用“最后心跳时间”自己模拟互斥 |
| 多个页面需要共享一个长期运行的 worker | SharedWorker | 生命周期和支持情况要测试;需显式关闭端口 |
| 需要后台代理网络请求、离线缓存或推送事件 | Service Worker | 它是事件驱动的站点代理,不是通用的标签页消息队列 |
这些机制通常要求相同 origin。协议、主机名或端口不同就是不同 origin;BroadcastChannel 还受浏览器存储分区策略约束。跨站 iframe 不能仅凭“URL 同源”假设就一定能和顶层页面互通。
动手验证两个标签页
打开同频道的第二个页面,在一个标签页发送短消息,另一个页面会收到。可以切换 BroadcastChannel 与 localStorage 回退,或断开后重新连接。演示只用于传递无敏感信息的短文本:消息不会发送到服务器,storage 回退会在短时间后清理临时值。
BroadcastChannel:轻量消息总线
适用于“发生了一件事,请其他页面刷新自己的状态”。它不负责持久化、重放、确认送达或冲突解决,因此消息应是可丢弃的提示,而不是唯一的数据副本。
let channel = null;
function onMessage(event) {
const message = event.data;
if (!message || message.version !== 1) return;
if (message.type === 'preferences-updated') {
void reloadPreferencesFromServer();
}
}
function connectChannel() {
if (channel || typeof BroadcastChannel === 'undefined') return;
channel = new BroadcastChannel('app-events-v1');
channel.addEventListener('message', onMessage);
}
function publishPreferencesUpdated() {
if (!channel) return;
channel.postMessage({
type: 'preferences-updated',
version: 1,
changedAt: Date.now(),
});
}
function closeChannel() {
if (!channel) return;
channel.removeEventListener('message', onMessage);
channel.close();
channel = null;
}
window.addEventListener('pagehide', closeChannel);
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return;
connectChannel();
void reloadPreferencesFromServer();
});
connectChannel();
pagehide 也可能发生在页面进入前进/后退缓存(bfcache)时;BroadcastChannel.close() 不会随页面恢复而自动撤销。页面从 bfcache 返回时要在 pageshow 且 event.persisted 为真时重建通道,并刷新可能过期的权威状态。后台冻结期间的消息不能当作一定已处理;关键草稿应在 visibilitychange 变为隐藏时尽早保存,不能依赖 pagehide 一定触发。
收到通知后重新读取可信数据源,通常比广播整份业务状态更稳。消息里不要放 access token、个人资料、私密草稿或服务端认为可信的权限字段;同源脚本本来就处在同一安全边界内,广播不会让这些数据变得安全。
BroadcastChannel 会把消息派发给符合条件的同名 channel 对象,并排除发起这次 postMessage() 调用的对象;如果同一文档另有一个同名对象,它仍可能收到消息。当前页需要更新 UI 时,仍应直接更新本地状态,再通知其他标签页。每个消息都应有可识别的 type 和协议版本;对旧版本、未知类型和畸形对象要忽略,而不要让页面脚本因一条消息崩溃。
storage 事件:朴素回退通道
localStorage 写入会在同源的其他页面触发 storage 事件,但不会在执行写入的当前页面触发。sessionStorage 的事件不会跨独立标签页,却可能在同一顶层页面中的其他同源文档(例如 iframe)触发。事件的 key 不能区分存储类型;接收端还要检查 event.storageArea === localStorage,否则同名键的 sessionStorage 事件可能被误当成跨标签消息。一个常见的通知写法是使用带唯一值的短消息键:
const KEY = 'app:cross-tab-event';
function createMessageId() {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID();
if (typeof cryptoApi?.getRandomValues !== 'function') {
throw new Error('当前环境不支持安全随机数生成');
}
return Array.from(cryptoApi.getRandomValues(new Uint8Array(16)),
(byte) => byte.toString(16).padStart(2, '0')).join('');
}
export function publishPreferencesUpdated() {
const envelope = {
version: 1,
type: 'preferences-updated',
id: createMessageId(),
sentAt: Date.now(),
};
try {
localStorage.setItem(KEY, JSON.stringify(envelope));
return true;
} catch {
return false;
}
}
window.addEventListener('storage', (event) => {
try {
if (event.storageArea !== localStorage) return;
} catch {
return;
}
if (event.key !== KEY || !event.newValue) return;
let message;
try {
message = JSON.parse(event.newValue);
} catch {
return;
}
if (
!message || typeof message !== 'object' || Array.isArray(message)
|| message.version !== 1
|| typeof message.id !== 'string'
|| message.type !== 'preferences-updated'
) return;
void reloadPreferencesFromServer();
});
这个 ID 只用于消息关联或去重,不是认证凭证。代码优先用 crypto.randomUUID();如果它不可用,再用 crypto.getRandomValues() 生成随机字节。后者是 Web Crypto 中可在非安全上下文使用的例外,因此这个 ID 生成回退本身不要求 HTTPS;生产站点仍应全程使用 HTTPS。若两种能力都不可用,应明确降级或停止发送,而不是改用时间戳加 Math.random() 冒充安全随机数。接收端同时校验对象形状、协议版本和事件类型,避免 null、数组、未知版本或畸形消息触发异常。存储被禁用或超出配额时,发送函数返回 false,调用方应保留当前页本地功能并按需采用其他通道。
参考:MDN:Crypto.getRandomValues()。
真实项目还要处理浏览器禁用存储、配额异常、同一毫秒多次写入和旧值残留。不要把 storage 当消息队列:新写入会覆盖旧值,而且写入是同步操作。高频鼠标、光标或流式 token 更新不应走它。
Web Locks:让任务只有一个执行者
如果目的是“多个标签页中只跑一个刷新任务”,BroadcastChannel 只能通知,不能提供互斥。Web Locks API 适合协调同源页面和 Worker 中的异步工作:
async function refreshSharedCache() {
if (!navigator.locks) {
// A fallback may skip this optimization or use server-side idempotency.
return refreshWithServerIdempotency();
}
await navigator.locks.request(
'app:shared-cache-refresh',
{ ifAvailable: true },
async (lock) => {
if (!lock) return;
await fetchAndPersistFreshCache();
},
);
}
锁在回调 Promise 完成后释放。持锁代码必须有超时、取消和异常处理;避免在锁回调里等待一个只有另一个同名锁持有者才能完成的操作,否则会形成死锁。若目标是数据正确性,服务端仍应使用幂等键、事务或唯一约束兜底,不能把客户端锁当数据库锁。
SharedWorker 与 Service Worker 不要混为一谈
SharedWorker 可被同源的多个页面连接,用于共享内存中的连接或协调逻辑。它适合确实需要一个共享 Worker 实例的场景,但部署、浏览器兼容、Worker 脚本加载策略和页面关闭后的生命周期都要纳入验证。每个页面在用完 MessagePort 后应关闭端口,并为 Worker 不可用准备降级路径。
Service Worker 则由浏览器按事件唤醒,常用于请求拦截、离线缓存、推送和后台同步。它可以与受控客户端通信,但会被浏览器终止并重新启动,不能依赖其中的全局变量长期保存状态。需要持久化的数据应放 IndexedDB 或服务端。
一个可维护的组合方式
把数据和通知分开,通常最简单:
- 当前页先更新自己的状态,并将需要持久化的数据写到服务端或 IndexedDB。
- 使用 BroadcastChannel 通知其他标签页“某类数据已变化”,必要时通过
storage作为旧环境回退。 - 其他标签页收到消息后重新拉取权威数据;消息丢失时,也能在聚焦、恢复网络或定时校验时自愈。
- 只有需要去重执行的任务才额外用 Web Locks;最终写入仍由服务端幂等保护。
- 初始化时监听,页面销毁时关闭 channel、移除 listener、取消 timer 和未完成请求。
例如退出登录时,只广播 session-invalidated 事件,不广播令牌。其他页收到后清理自己的会话并跳转登录;后端每个受保护请求仍独立验证令牌是否有效。广播是体验同步,不是鉴权机制。
测试清单
- 同源两个页面能收到事件;同一文档的另一个同名 channel 对象也可能收到,而发起调用的对象不会收到自己的广播。
- 非法消息、未知类型、旧协议版本不会导致脚本异常。
- 浏览器禁用存储或 BroadcastChannel 不可用时,页面仍能正常使用核心功能。
- 关闭标签页后 channel、Worker 端口、定时器和事件监听不会无限累积。
- 高频更新不会触发大量同步 storage 写入。
- 两个标签页同时触发刷新时,最多一个持有锁;服务端重复提交仍然幂等。
- 页面休眠、恢复、离线、重新联网和 BFCache 往返后,最终状态仍可从权威数据源恢复。
- 安全检查确认消息中没有 token、敏感数据或可被客户端伪造后直接采信的权限结论。
资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。