跨标签页通信
跨标签页通信的核心问题不是“能不能发消息”,而是:
- 同一站点的多个标签页如何共享状态。
- 状态变化如何尽快、可靠地同步到所有页签。
- 页面关闭、刷新、崩溃后如何保证一致性。
先明确通信边界
跨标签页通信永远先看两个边界:是否同源、是否需要实时。
| 维度 | 你需要先确认什么 | 影响 |
|---|---|---|
| 同源限制 | 标签页是否 协议 + 域名 + 端口 一致 | 决定能否直接用 BroadcastChannel、storage、SharedWorker |
| 实时性要求 | 是“秒级同步”还是“最终一致” | 决定是事件广播还是轮询/懒同步 |
| 数据可靠性 | 消息丢失是否可接受 | 决定要不要做状态回放和兜底拉取 |
方案总览(按实战优先级)
| 方案 | 同源要求 | 实时性 | 适合场景 | 关键注意点 |
|---|---|---|---|---|
BroadcastChannel | 需要同源 | 高 | 多标签状态广播(登录态、主题、权限刷新) | 老浏览器兼容性一般,需降级方案 |
storage 事件 | 需要同源 | 中 | 轻量通知、兼容兜底 | 事件不会在“当前触发页”回调 |
SharedWorker | 需要同源 | 高 | 多标签共享单连接(如 WebSocket) | 实现复杂,需管理生命周期 |
window.postMessage | 可跨源 | 中 | window.open / iframe 通信 | 必须校验 origin,禁止裸 * |
Service Worker + clients | 需要同源 | 中高 | PWA 全局消息调度 | 心智负担较高,适合成熟架构 |
推荐主方案:BroadcastChannel
如果是同源多标签同步,优先用 BroadcastChannel,API 简单、语义清晰。
为业务域创建一个频道名
同一频道下的所有标签页都能互相收发消息。
约定消息协议
至少包含
type和payload,复杂场景再加tabId、version。页面加载时订阅,卸载时关闭
避免重复监听和内存泄漏。
收到广播后先做消息类型分发
不要在
onmessage里堆业务逻辑,统一走switch(type)。
基础实现
const channel = new BroadcastChannel('app:sync')
channel.onmessage = (event) => {
const msg = event.data as { type: string, payload?: any }
switch (msg.type) {
case 'AUTH_LOGOUT':
clearAuthState()
redirectToLogin()
break
case 'THEME_CHANGE':
applyTheme(msg.payload.theme)
break
default:
break
}
}
export function publish(type: string, payload?: any) {
channel.postMessage({ type, payload, at: Date.now() })
}
window.addEventListener('beforeunload', () => {
channel.close()
})登录态同步示例
import { publish } from './channel'
async function logout() {
await fetch('/api/logout', { method: 'POST', credentials: 'include' })
clearAuthState()
publish('AUTH_LOGOUT')
redirectToLogin()
}兼容兜底:storage 事件
当你需要更广兼容性时,可以用 localStorage + storage 作为降级策略。
- 在触发页写入一条事件数据(建议带时间戳)。
- 其他标签页监听
storage事件。 - 按
key过滤并解析消息。 - 处理完可选择清理该 key,避免历史污染。
storage 事件的一个关键行为
storage 事件只会在“其他标签页”触发,不会在当前执行 setItem 的标签页触发。 所以当前页仍需手动执行本地逻辑,不要只依赖事件回调。
const EVENT_KEY = 'app:event'
export function emitStorageEvent(type: string, payload?: any) {
const data = JSON.stringify({ type, payload, at: Date.now() })
localStorage.setItem(EVENT_KEY, data)
}
window.addEventListener('storage', (e) => {
if (e.key !== EVENT_KEY || !e.newValue) {
return
}
const msg = JSON.parse(e.newValue) as { type: string, payload?: any }
if (msg.type === 'AUTH_LOGOUT') {
clearAuthState()
redirectToLogin()
}
})进阶方案:SharedWorker(多标签共享单连接)
当你要做“一个站点多个标签共享一个长连接(例如 WebSocket)”,SharedWorker 是更稳的方案。
- 所有标签页连接到同一个
SharedWorker实例。 - Worker 内部维护唯一长连接和端口列表。
- 任一标签消息先发给 Worker,再由 Worker 广播到其他标签。
- 标签关闭时清理对应端口,连接可按引用数决定是否关闭。
shared-worker.js
const ports = new Set()
onconnect = (event) => {
const port = event.ports[0]
ports.add(port)
port.onmessage = (e) => {
const msg = e.data
for (const p of ports) {
if (p !== port) {
p.postMessage(msg)
}
}
}
port.start()
port.postMessage({ type: 'WORKER_READY' })
}client.ts
const worker = new SharedWorker('/shared-worker.js')
const port = worker.port
port.start()
port.onmessage = (event) => {
const msg = event.data
if (msg.type === 'AUTH_LOGOUT') {
clearAuthState()
redirectToLogin()
}
}
export function notifyLogout() {
port.postMessage({ type: 'AUTH_LOGOUT' })
}跨源窗口通信:postMessage
当通信双方不是同源页签(或是父子窗口、iframe)时,用 postMessage。
// parent
const child = window.open('https://child.example.com/page')
child?.postMessage(
{ type: 'PING' },
'https://child.example.com',
)
window.addEventListener('message', (event) => {
if (event.origin !== 'https://child.example.com') {
return
}
if (event.data?.type === 'PONG') {
console.log('receive pong')
}
})安全底线
postMessage 必须做 origin 白名单校验。 除非确有必要,不要把目标源写成 *。
一个可直接落地的组合策略
如果你的目标是“多标签登录态一致”,可以用这套组合:
- 首选
BroadcastChannel作为实时通道。 - 降级用
storage事件兜底兼容。 - 服务端接口作为最终真相源(页面恢复时再拉一次用户状态)。
这样可以同时覆盖“实时同步、兼容性、最终一致性”三个目标。
