跨标签页事件总线设计:消息协议、存储降级与并发控制
本文目录
多个标签页之间不是一个共享 JavaScript 内存。每个页面各自有事件循环、组件树和内存状态;浏览器提供的通信 API 解决的是不同问题,不能把“消息发到了另一个标签”误当成共享数据库、可靠队列或权限边界。先确定数据是否需要持久化、参与者是否已知、工作是否必须唯一执行,再选择机制。
想先切换 BroadcastChannel 与 storage 回退并动手发送短消息,请看API 选型与本地演示;本文继续深入消息协议、去重和事件总线的边界。
先按问题选通道
| 需求 | 优先考虑 | 限制 |
|---|---|---|
| 同源页面/Worker 之间发布短消息 | BroadcastChannel | 无历史回放;消息不持久化;受 origin 与存储分区约束 |
| 简单兼容性降级,跨页面通知 | localStorage 的 storage 事件 | 同步写入、容量有限;事件只发给其他文档,不发给写入它的当前页面 |
| 多个页面共享一个长生命周期执行者/内存状态 | SharedWorker | 需要管理 MessagePort 和连接生命周期;旧浏览器、WebView 与隐私分区需实测 |
| 保证同源范围内某段任务最多一个执行者 | Web Locks | 只做协作互斥,不负责广播、持久化或跨设备协调 |
| 已知 opener、iframe 或 popup 之间点对点通信 | Window.postMessage | 必须校验 origin、source 和消息 schema;不要用通配符 targetOrigin |
| 页面与受控客户端之间交换消息 | Service Worker messaging | Worker 生命周期由浏览器管理,不是永不退出的后台进程 |
同源由协议、主机和端口共同决定;路径不同仍可能同源,www 子域或端口不同则不是同源。BroadcastChannel 还受浏览器存储分区影响:第三方 iframe 即使 URL 与另一个顶层站点同源,也不一定能跨顶层站点相互通信。跨域业务要由服务器 API、明确配置的跨源窗口消息或其他受控通道解决,不能用“通道名字相同”绕过浏览器隔离。
BroadcastChannel:轻量广播
BroadcastChannel 适合通知其他标签某件事已经发生,例如退出登录、设置已更新、某份文档需要重新拉取。它不保留历史:新开的标签收不到之前消息,所以启动时仍需从服务器或持久化存储读取权威快照。不同页面可能同时处理旧消息,服务端版本号或资源 revision 才能判断当前数据是否过期。
const tabId = crypto.randomUUID();
const channel = new BroadcastChannel('haoge-app-events-v1');
function publish(type, payload) {
channel.postMessage({
version: 1,
id: crypto.randomUUID(),
source: tabId,
type,
payload,
sentAt: Date.now(),
});
}
function onMessage(event) {
const message = event.data;
if (!message || message.version !== 1 || message.source === tabId) return;
if (typeof message.id !== 'string' || typeof message.type !== 'string') return;
if (message.type === 'logout') {
clearPrivateViewState();
refreshServerSession();
} else if (message.type === 'document-invalidated') {
refetchDocumentIfOpen(message.payload?.documentId);
}
}
channel.addEventListener('message', onMessage);
function disposeChannel() {
channel.removeEventListener('message', onMessage);
channel.close();
}
实际消息还要按 type 做字段校验、限制长度、检测重复 id,并处理 postMessage 的 DataCloneError。由本页主动改动产生的状态也要在本页直接更新;BroadcastChannel 不会代替发送者完成本地状态更新。短名版本化可避免升级后不同页面把不兼容消息互相误读;channel 名称不是秘密,所有同源脚本都可能加入。
带存储降级与去重的事件总线
需要兼容旧环境时,可以把 BroadcastChannel 作为主通道,把 storage 事件作为降级,并让两种传输共用 schema 校验、ID 去重和本地派发。不要同时无条件双发同一条消息;发送页也要直接通知自己的订阅者。
function createMessageId() {
if (crypto.randomUUID) return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)),
(byte) => byte.toString(16).padStart(2, '0')).join('');
}
function createCrossTabBus(name) {
const senderId = createMessageId();
const storageKey = name + ':fallback';
const listeners = new Set();
const seen = new Set();
let channel = null;
let closed = false;
try {
if (typeof BroadcastChannel !== 'undefined') channel = new BroadcastChannel(name);
} catch {
channel = null;
}
function dispatch(value) {
if (!value || value.version !== 1 || typeof value.id !== 'string' ||
typeof value.senderId !== 'string' || typeof value.type !== 'string' ||
typeof value.sentAt !== 'number' || seen.has(value.id)) return;
seen.add(value.id);
if (seen.size > 512) seen.delete(seen.values().next().value);
for (const listener of listeners) {
try { listener(value); } catch { /* Report through sanitized diagnostics. */ }
}
}
const onMessage = (event) => dispatch(event.data);
const onStorage = (event) => {
if (event.key !== storageKey || !event.newValue) return;
try { dispatch(JSON.parse(event.newValue)); } catch { /* Ignore malformed data. */ }
};
channel?.addEventListener('message', onMessage);
window.addEventListener('storage', onStorage);
return {
emit(type, payload) {
if (closed) throw new Error('Cross-tab bus is closed');
const message = { version: 1, id: createMessageId(), senderId, type, sentAt: Date.now(), payload };
const encoded = JSON.stringify(message);
dispatch(message);
try {
if (channel) channel.postMessage(message);
else localStorage.setItem(storageKey, encoded);
} catch {
try { localStorage.setItem(storageKey, encoded); }
catch { throw new Error('Cross-tab message could not be sent'); }
}
},
subscribe(listener) {
if (closed) throw new Error('Cross-tab bus is closed');
listeners.add(listener);
return () => listeners.delete(listener);
},
close() {
if (closed) return;
closed = true;
channel?.removeEventListener('message', onMessage);
channel?.close();
window.removeEventListener('storage', onStorage);
listeners.clear();
},
};
}
这个示例的 ID 去重集合只保留最近 512 条,仅是短时消息去重,不是持久幂等账本。storage fallback 会把最后一条非敏感消息暂存在 localStorage;只允许发送失效通知或小型状态,禁止写入凭据和正文。每个 listener 都有独立错误边界,组件卸载时调用退订函数并关闭总线。
storage 事件:低依赖降级
localStorage 被另一个文档修改时,其他同源文档会收到 storage 事件;发起 setItem 的当前文档不会收到自己的事件。它可以用于小型失效通知,但 localStorage 是同步 API,读改写不是跨标签事务,也不能可靠选主。不要把业务正文、Token、验证码或 Cookie 写进去;退出广播只发送无凭据的“需要重新验证”信号,服务端仍须撤销会话。
如果提供降级通道,要让本地发送者直接调用同一处理函数,再由 storage 事件通知其他标签;存储值仅当作不可信消息解析并验证。设置失败可能来自配额、隐私模式或策略禁止,必须回退到页面内状态并提供重新拉取入口。storage 事件可能合并不了你的业务操作,也不应被当作持久消息队列。
sessionStorage 通常面向单个顶层浏览上下文;新标签若由 opener 打开,初始值可能被复制,但之后彼此独立,不是可靠的同步总线。IndexedDB 适合持久结构化状态并提供事务,但仍需广播“数据已变更”或使用版本校验让其他页面刷新。
SharedWorker:共享连接或协调器
当多个标签确实需要共用一个 WebSocket、轮询器、内存缓存或高成本资源时,可以由 SharedWorker 持有共享状态,再为每个页面建立 MessagePort。每个端口连接和断开都要登记;页面退出时清理端口及订阅。Worker 可能在所有引用消失后终止,不能把它当成永久后台服务或唯一的数据副本。
SharedWorker 在较新浏览器上的支持已改善,但旧版 Safari、嵌入式 WebView、隐私浏览和不同存储分区仍应逐一验证。若失败,退回每页独立连接或 BroadcastChannel 通知;不要为了复用一个 socket 把可用性压在单一共享 Worker 上。Worker 脚本必须来自可信同源路径,消息 schema 与页面中的一样需要校验。
Web Locks:只让一个标签做事
Web Locks 可协调同源页面/Worker 对共享资源的访问。拿锁期间的异步回调未结束前,其他请求会等待;回调结束后浏览器释放锁。它适合“只允许一个标签刷新某份共享缓存”或“一个标签承担定期同步”,但不是消息总线,也不保存锁状态。不要在锁回调里等待另一个标签的消息后又要求对方拿同一把锁,否则可能形成死锁。
async function syncOnceAcrossTabs(signal) {
if (!navigator.locks) return runWithPerTabFallback();
return navigator.locks.request(
'haoge-app-cache-sync-v1',
{ ifAvailable: true, signal },
async (lock) => {
if (!lock) return { skipped: true };
await refreshSharedCache({ signal });
publish('cache-invalidated', { revision: getCurrentRevision() });
return { skipped: false };
},
);
}
锁是同源协作机制,不是服务器锁;设备休眠、页面挂起和任务超时都要考虑。任务必须可中止、可幂等,并有运行时上限。对关键数据完整性,最终仍要使用服务器事务、条件更新或 IndexedDB 事务,而不是相信浏览器里某个标签永远持锁。
给消息设计稳定协议
建议每个消息包含协议 version、message id、sender id、type、createdAt 和经过 schema 验证的 payload。消费方只接受明确允许的消息类型;用户输入、iframe 消息、同源其他脚本发来的内容都不能直接作为可信命令。更新配置时优先广播资源标识与 revision,让每页重新从权限允许的 API 拉取,而不是把完整私有对象广播一圈。
登录与退出尤其要小心:跨标签可广播 session-invalidated,让其他页面清理界面状态、停止私有请求并重新验证;不能广播访问令牌、刷新令牌、邮箱验证码或用户私密内容。前端清理只改善体验,凭据撤销和权限判断必须发生在服务端。
如果多个标签能同时编辑同一份内容,不要用“最后收到的广播”覆盖本地草稿。给文档带 revision/etag,保存时进行条件更新;收到版本冲突后让用户比较、合并或显式覆盖。只靠消息顺序无法解决离线写入、网络重试和不同设备并发。
验收清单
- 用两个独立标签和三个标签同时打开,检查登录、退出、设置更新和资源失效消息;再新开标签确认它通过快照读取恢复状态。
- 关闭发送标签、让页面进入后台、切换账号、断网再恢复,确认监听器、端口、锁和缓存都有释放/重建路径。
- 模拟畸形 payload、未知版本、重复 id、storage 禁用、配额错误和多个编辑者并发;拒绝消息不能清空或覆盖有效草稿。
- 在 Chromium、Safari/iOS、目标 WebView、隐私模式与受限 iframe 中验证支持矩阵和降级,不用桌面浏览器一次成功推断所有平台。
- 检查浏览器存储和广播日志:无令牌、验证码、Cookie、私密文章正文或内部诊断信息。
参考:MDN Broadcast Channel API、MDN storage event、MDN Web Locks API、MDN SharedWorker、MDN Window.postMessage、MDN 同源策略。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。