前端跨页面通信,你知道哪些方法
Category(分类): Browser, Web API Status: 已更新
浏览器中的每个页面都有自己的 JavaScript 全局对象和运行上下文。列表页、详情页、多个标签页、弹窗、iframe 之间有时需要同步登录状态、收藏状态、主题、草稿或上传进度,这就是跨页面(跨文档/跨上下文)通信。
原文介绍了 BroadcastChannel、Service Worker、localStorage、SharedWorker、IndexedDB、window.opener 和 WebSocket,并尝试用 iframe 作为跨域桥。下面保留这些方法,同时修正同源、生命周期、消息安全和“服务器推送”等概念。
一、先区分源、上下文和通信目标
1. 同源不等于同一个页面
同源要求协议、主机和端口都相同:
https://example.com:443/a
https://example.com:443/b
上面两个 URL 同源,但它们可以位于不同标签页、窗口或 iframe 中,不能直接共享普通 JavaScript 变量。
下面的任意一项不同,都不是同源:
http://example.com # 协议不同
https://admin.example.com # 主机不同
https://example.com:8443 # 端口不同
“跨域”在实际讨论中可能指 CORS、跨源 postMessage、跨站 Cookie 或同源策略,不能用一个 API 解决所有问题。
2. 选择方式前先问四个问题
- 页面是否同源?
- 需要一对一、广播,还是服务器向多个页面推送?
- 消息是否允许丢失?是否需要离线后补发?
- 接收方是否可信,消息中是否包含敏感数据?
短小的“收藏状态变化”适合 BroadcastChannel;需要跨源 iframe 通信适合 postMessage;需要可靠恢复的任务状态应使用 IndexedDB 保存状态,再用消息通知刷新,而不是把 IndexedDB 当成高频消息队列。

二、BroadcastChannel:同源广播的首选
Broadcast Channel API 允许同源的窗口、标签页、iframe、Worker 和 Service Worker 加入同名频道。数据通过结构化克隆算法传递,不要求手动 JSON.stringify()。
const channel = new BroadcastChannel('app-events')
channel.addEventListener('message', event => {
const message = event.data
if (message?.type !== 'favorite-changed') return
updateFavoriteUI(message.articleId, message.favorite)
})
channel.postMessage({
type: 'favorite-changed',
articleId: 'article-42',
favorite: true,
messageId: crypto.randomUUID(),
sentAt: Date.now()
})
window.addEventListener('pagehide', () => channel.close(), { once: true })
特点和限制
- 只在同源上下文之间工作,不是任意“同域名”都可以;
- 一个频道可以一对多广播;
- 同一个
BroadcastChannel对象不会收到自己发出的消息,其他已连接对象可以收到; - 消息不提供持久化、确认、重试和历史回放;
- 页面被冻结、挂起或关闭时,不能保证接收消息;
- 使用完应调用
close(),避免长期页面创建大量频道。
它很适合“状态已经保存在服务端或 IndexedDB,消息只负责提示其他页面刷新”的场景:

// 不把完整订单、Token 或用户隐私直接广播出去
channel.postMessage({
type: 'order-updated',
orderId: 'order-42',
version: 8
})
三、localStorage + storage 事件:兼容性较好的轻量方案
当同源页面修改 localStorage 时,其他页面可以收到 storage 事件:
window.addEventListener('storage', event => {
if (event.storageArea !== localStorage) return
if (event.key !== 'app-event' || !event.newValue) return
try {
const message = JSON.parse(event.newValue)
handleMessage(message)
} catch {
// 忽略格式错误的消息
}
})
function sendMessage(message) {
localStorage.setItem('app-event', JSON.stringify({
...message,
messageId: crypto.randomUUID(),
sentAt: Date.now()
}))
}
必须记住的细节
storage事件通常只发送给其他同源文档,不会在修改值的同一个文档中触发;sessionStorage的事件范围与标签页/iframe 关系不同,跨标签页同步通常使用localStorage;- 设置相同的字符串值可能不会产生新的事件,所以历史代码常添加时间戳,但更好的方式是添加唯一
messageId; clear()时event.key和event.newValue可能是null;- 事件不是可靠消息队列,页面关闭时消息可能丢失;
- localStorage 是同步 API,高频写入会阻塞主线程;
- 不要把密码、Access Token 或隐私数据写到 localStorage 作为“消息中转站”。
它适合登录登出提示、主题切换、简单状态失效通知;复杂消息建议优先用 BroadcastChannel,兼容性不足时再降级到 storage 事件。

四、postMessage:窗口、弹窗和 iframe 的跨源通信
window.postMessage() 是专门用于跨源窗口通信的 API。发送方需要拿到目标窗口引用,例如:
const popup = window.open(
'https://accounts.example.com/popup',
'account-popup',
'width=480,height=640'
)
popup?.postMessage(
{
type: 'auth-result',
code: 'one-time-code'
},
'https://app.example.com'
)
接收方必须同时校验 origin、必要时校验 source,并验证消息结构:
window.addEventListener('message', event => {
if (event.origin !== 'https://accounts.example.com') return
if (event.source !== expectedPopup) return
const message = event.data
if (!message || message.type !== 'auth-result') return
if (typeof message.code !== 'string') return
exchangeOneTimeCode(message.code)
})
为什么不能随便使用 '*'
原文示例把 '*' 作为 iframe 的目标源,虽然演示方便,但生产代码应填写精确的目标 origin:
iframe.contentWindow.postMessage(
{ type: 'theme-changed', theme: 'dark' },
'https://widget.example.com'
)
发送敏感数据时使用 '*' 可能让被导航后的窗口或恶意页面收到消息。接收端只检查 event.data 而不检查 event.origin,也可能让任意网站伪造管理员消息。
还要注意:event.origin 是发送时的源,目标窗口之后可能已经导航到另一个页面,所以对重要通信还应校验 event.source、握手随机数和会话状态。
window.opener 和安全
window.open() 返回被打开窗口的引用,被打开页面可能通过 window.opener 回传消息。对于不需要建立通信的外部链接,应使用:
<a href="https://external.example" target="_blank" rel="noopener noreferrer">
打开外部网站
</a>
noopener 可以阻断新页面访问 opener,降低反向 Tabnabbing 风险。需要通信时,应明确设计 origin、source、握手和生命周期,不能把所有弹窗都放进“口口相传”的无限转发树中。消息转发还要有 messageId 和 visited 集合,防止循环和重复处理。
五、MessageChannel:创建一对专用端口
如果两个窗口或 Worker 已通过一次 postMessage 建立联系,可以传递一个 MessagePort,后续用专用端口通信:
const channel = new MessageChannel()
channel.port1.addEventListener('message', event => {
console.log('收到回复:', event.data)
})
channel.port1.start()
iframe.contentWindow.postMessage(
{ type: 'connect', port: channel.port2 },
'https://widget.example.com',
[channel.port2]
)
channel.port1.postMessage({ type: 'ping' })
iframe 内:
window.addEventListener('message', event => {
if (event.origin !== 'https://app.example.com') return
if (event.data?.type !== 'connect') return
const port = event.ports[0]
if (!port) return
port.onmessage = messageEvent => {
if (messageEvent.data?.type === 'ping') {
port.postMessage({ type: 'pong' })
}
}
})
MessageChannel 适合点对点协议和传输可转移对象;MessagePort 需要启动(使用 onmessage 赋值时通常会自动启动,使用 addEventListener 时显式调用 start() 更清晰)。
六、Service Worker:以它为中转站
Service Worker 可以通过 clients.matchAll() 找到它作用域内的页面,再调用 client.postMessage() 广播:
// service-worker.js
self.addEventListener('message', event => {
const senderId = event.source?.id
event.waitUntil(
self.clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then(clients => {
for (const client of clients) {
if (client.id === senderId) continue
client.postMessage(event.data)
}
})
)
})
页面侧:
const registration = await navigator.serviceWorker.ready
navigator.serviceWorker.addEventListener('message', event => {
handleMessage(event.data)
})
const controller = navigator.serviceWorker.controller
if (controller) {
controller.postMessage({
type: 'favorite-changed',
articleId: 'article-42'
})
} else {
// 首次注册后可能还没有 controller;可以等待 controllerchange,
// 或向 registration.active/等待中的 worker 发送消息。
registration.active?.postMessage({ type: 'request-sync' })
}
Service Worker 的边界
- 它只服务于注册作用域内的页面,不能天然给任意跨源页面广播;
- 它有安装、激活、等待和终止生命周期,不是永远运行的后台进程;
- 首次注册页面可能尚未被当前 Service Worker 控制,
navigator.serviceWorker.controller可以是null; clients.matchAll()默认不一定包含未控制页面,需要按需求设置includeUncontrolled;- Service Worker 适合和 Cache API、推送、离线策略组合,但不应把所有业务消息都塞进它;
- 对敏感消息要验证页面来源、消息类型和权限,不要因为消息来自 Service Worker 就默认可信。
七、SharedWorker:多个页面共享一个 Worker
SharedWorker 允许同源的多个浏览上下文连接到同一个 Worker。Worker 通过 onconnect 得到每个页面的 MessagePort,可以维护连接列表并广播:
页面:
const worker = new SharedWorker('/workers/shared-hub.js')
const port = worker.port
port.addEventListener('message', event => {
console.log('来自 SharedWorker:', event.data)
})
port.start()
port.postMessage({
type: 'subscribe',
clientId: crypto.randomUUID()
})
shared-hub.js:
const ports = new Set()
self.onconnect = event => {
const port = event.ports[0]
ports.add(port)
port.start()
port.addEventListener('message', messageEvent => {
const message = messageEvent.data
if (message?.type === 'close') {
ports.delete(port)
port.close()
return
}
for (const target of ports) {
if (target !== port) target.postMessage(message)
}
})
}
原文用“每秒轮询 SharedWorker 的 get 指令”实现同步,这可以展示思路,但会产生无意义轮询。SharedWorker 可以直接保存端口并广播;如果要保存最后状态,使用 IndexedDB,页面在 visibilitychange 或重新连接时读取一次。
SharedWorker 的浏览器支持和移动端场景要单独评估。它的生命周期由连接页面影响,页面进入后台、浏览器节流或设备资源紧张时也不能把它当作可靠服务器。

八、IndexedDB:持久状态 + 通知,而不是高频轮询
IndexedDB 适合保存跨页面需要恢复的状态:
const request = indexedDB.open('app-state', 1)
request.onupgradeneeded = () => {
request.result.createObjectStore('events', { keyPath: 'id' })
}
request.onsuccess = () => {
const db = request.result
const transaction = db.transaction('events', 'readwrite')
transaction.objectStore('events').put({
id: crypto.randomUUID(),
type: 'favorite-changed',
articleId: 'article-42',
version: 8,
createdAt: Date.now()
})
}
更合理的组合是:
IndexedDB 保存最后状态/未完成任务
+
BroadcastChannel 通知其他页面刷新
+
页面重新打开或从后台恢复时再次读取 IndexedDB
不要用每秒轮询 IndexedDB 代替事件系统。若必须兼容缺少 BroadcastChannel 的环境,可以使用低频轮询并保存 lastSeenId,同时清理已处理事件,防止数据库无限增长。
九、Cookie 和服务器推送
Cookie 可以让多个同源页面看到服务端会话状态,但 Cookie 本身不是通用消息总线:
- 页面 JavaScript 不能读取
HttpOnlyCookie; - Cookie 自动随请求发送,不适合承载高频消息;
- 修改 Cookie 不会像 localStorage 那样为其他页面提供标准
storage事件; - Cookie 的共享范围由 Domain/Path/SameSite 等属性决定,跨顶级域不能直接共享。
如果状态变化由服务端产生,可以使用:
- SSE:服务器到浏览器的单向长连接,适合通知和进度;
- WebSocket:双向长连接,适合实时协作和聊天;
- WebTransport:更底层、更灵活,但部署和浏览器支持要求更高;
- 轮询:最简单但有延迟和请求成本。
这些是服务器推送或实时通信,不应和“两个标签页之间的本地通信”混为一谈。一个页面从服务器收到消息后,可以再通过 BroadcastChannel 通知其他同源页面。
十、非同源页面如何通信

非同源页面不能直接访问对方 DOM、localStorage 或 JavaScript 变量,但在双方都同意的情况下,可以使用 postMessage:
// app.example.com
const frame = document.querySelector('#payment-frame')
window.addEventListener('message', event => {
if (event.origin !== 'https://pay.example.net') return
if (event.source !== frame.contentWindow) return
if (event.data?.type !== 'payment-completed') return
refreshOrder(event.data.orderId)
})
frame.contentWindow.postMessage(
{ type: 'ready' },
'https://pay.example.net'
)
付款 iframe:
window.addEventListener('message', event => {
if (event.origin !== 'https://app.example.com') return
if (event.data?.type !== 'ready') return
window.parent.postMessage(
{ type: 'payment-completed', orderId: 'order-42' },
'https://app.example.com'
)
})
原文提出“在每个页面嵌入同源 bridge iframe,再由 iframe 通过 BroadcastChannel 转发”。这在多个受控产品必须共用一个通信桥时仍可能有用,但通常直接使用 postMessage 更简单。桥接页面会扩大攻击面,必须:
- 为每个消息方向维护精确的 allowlist;
- 监听器先校验
origin、source和消息 schema; - 不使用
'*'传输敏感内容; - 防止重放、循环转发和消息洪泛;
- 不把 bridge 做成可以执行任意命令的“万能接口”。
十一、消息协议的工程化
跨页面通信真正难的部分通常不是调用 API,而是协议:
interface AppMessage {
type: string
messageId: string
sentAt: number
version: number
payload: unknown
}
建议至少处理:
- 消息类型白名单和运行时 schema 校验;
messageId去重,避免重复消费;version兼容旧页面和新页面;- 是否需要 ACK、重试、超时和错误响应;
- 页面隐藏、冻结、恢复和重新连接;
- 消息是否需要持久化;
- 不把完整用户对象、Token 和密码广播到所有标签页;
- 发送方和接收方的权限边界。
登录状态同步可以只发送:
channel.postMessage({
type: 'auth-invalidated',
sessionVersion: 12
})
接收方收到后重新请求 /api/me,而不是广播 Access Token。
十二、方式对比
| 方式 | 同源要求 | 方向 | 是否持久化 | 适合场景 |
|---|---|---|---|---|
| BroadcastChannel | 同源 | 广播 | 否 | 多标签页状态通知 |
storage 事件 | 同源 | 其他页面接收 | 值在 Storage 中 | 简单兼容方案 |
postMessage | 可跨源 | 点对点 | 否 | iframe、弹窗、窗口 |
| MessageChannel | 需先传递端口 | 点对点 | 否 | 专用通信端口 |
| Service Worker | 同作用域 | 页面/Worker 中转 | 可配合 Cache/IDB | PWA、页面广播、网络代理 |
| SharedWorker | 同源 | 多端口 | Worker 生命周期内 | 多标签页共享 Worker |
| IndexedDB | 同源 | 读写共享状态 | 是 | 离线数据、任务状态 |
| SSE/WebSocket | 网络可达 | 服务端推送/双向 | 需自行设计 | 实时消息和协作 |
| iframe bridge | 双方嵌入并同意 | 跨源间接通信 | 否 | 受控多产品桥接 |
总结
- 同源标签页之间优先考虑 BroadcastChannel,兼容性需要时再使用
storage事件; - iframe、弹窗和跨源页面使用
postMessage,必须校验origin、source和消息结构; - MessageChannel 适合建立专用点对点端口;
- Service Worker 和 SharedWorker 都有生命周期,不是永远在线的服务器;
- IndexedDB 应保存可恢复状态,消息通知和持久化状态最好组合使用;
- WebSocket/SSE 解决服务器推送,不等于浏览器本地跨标签页通信;
window.opener要结合noopener、精确 origin 和消息去重;- 跨页面通信要设计消息协议、错误、重试、权限和隐私,不能只关注 API 调用。