跨页面通信
问题
浏览器中不同页面(标签页、iframe、弹出窗口)之间如何通信?有哪些技术方案?
答案
跨页面通信是前端常见需求,包括同源标签页之间的数据同步和跨域页面之间(如 iframe 与父页面)的消息传递。不同场景适用不同技术方案。
通信场景分类
一、同源标签页通信
1. BroadcastChannel(推荐)
BroadcastChannel API 是专为同源标签页通信设计的 API,使用最简单。
// 页面 A:发送消息
const channel = new BroadcastChannel('app-channel');
channel.postMessage({
type: 'THEME_CHANGE',
payload: { theme: 'dark' },
});
// 页面 B:接收消息
const channel = new BroadcastChannel('app-channel');
channel.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data;
switch (type) {
case 'THEME_CHANGE':
document.documentElement.setAttribute('data-theme', payload.theme);
break;
case 'LOGOUT':
window.location.href = '/login';
break;
}
};
// 页面关闭时释放资源
window.addEventListener('beforeunload', () => {
channel.close();
});
- 最简单:创建同名 channel 即可通信,无需手动管理连接
- 多对多:所有同名 channel 的页面都能收到消息(广播模式)
- 同源限制:只能在同源页面之间使用
- 结构化克隆:支持传递对象、数组、ArrayBuffer 等复杂数据
- 不会发给自己:发送方不会收到自己发出的消息
封装通用跨标签页通信
type MessageHandler<T = unknown> = (data: T) => void;
class CrossTabMessenger {
private channel: BroadcastChannel;
private handlers = new Map<string, Set<MessageHandler>>();
constructor(channelName: string = 'app-channel') {
this.channel = new BroadcastChannel(channelName);
this.channel.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data;
this.handlers.get(type)?.forEach(handler => handler(payload));
};
}
send<T>(type: string, payload: T): void {
this.channel.postMessage({ type, payload });
}
on<T>(type: string, handler: MessageHandler<T>): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type)!.add(handler as MessageHandler);
// 返回取消订阅函数
return () => this.handlers.get(type)?.delete(handler as MessageHandler);
}
destroy(): void {
this.handlers.clear();
this.channel.close();
}
}
// 使用
const messenger = new CrossTabMessenger();
messenger.on<{ theme: string }>('THEME_CHANGE', ({ theme }) => {
console.log('主题切换:', theme);
});
messenger.send('THEME_CHANGE', { theme: 'dark' });
2. localStorage 事件
利用 storage 事件:当一个标签页修改 localStorage 时,其他同源标签页会收到 storage 事件。
// 页面 A:发送消息(通过写入 localStorage)
function sendMessage(type: string, data: unknown): void {
const message = JSON.stringify({
type,
data,
timestamp: Date.now(), // 必须加时间戳,否则相同值不会触发事件
});
localStorage.setItem('cross-tab-message', message);
}
sendMessage('USER_LOGIN', { userId: '123', name: 'Alice' });
// 页面 B:监听 storage 事件
window.addEventListener('storage', (event: StorageEvent) => {
if (event.key !== 'cross-tab-message' || !event.newValue) return;
try {
const { type, data } = JSON.parse(event.newValue);
switch (type) {
case 'USER_LOGIN':
console.log('用户登录:', data);
break;
case 'USER_LOGOUT':
// 退出登录时清除本页状态
clearSession();
break;
}
} catch {
// 忽略解析错误
}
});
- 不会触发当前页面:
storage事件只在其他标签页触发,发送方自己收不到 - 必须有值变化:设置相同的值不会触发事件,所以加
timestamp - 只能传字符串:需要手动
JSON.stringify/parse - 容量限制:localStorage 通常只有 5~10MB
- 同步 API:
setItem是同步的,大量写入会阻塞主线程
3. SharedWorker
SharedWorker 是多个页面共享的 Worker 线程,可以作为中心化的消息中转站。
// shared-worker.ts —— Worker 代码
const ports: MessagePort[] = [];
// 每个页面连接时触发
self.onconnect = (event: MessageEvent) => {
const port = event.ports[0];
ports.push(port);
port.onmessage = (e: MessageEvent) => {
// 广播给所有其他连接的页面
ports.forEach(p => {
if (p !== port) { // 不发给发送者自己
p.postMessage(e.data);
}
});
};
port.start();
};
// 页面代码:连接 SharedWorker
const worker = new SharedWorker('/shared-worker.js');
worker.port.start();
// 发送消息
worker.port.postMessage({
type: 'CART_UPDATE',
payload: { itemId: '001', quantity: 3 },
});
// 接收消息
worker.port.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data;
console.log('收到消息:', type, payload);
};
- SharedWorker 可以维护状态(比如在线用户列表)、做复杂逻辑处理
- BroadcastChannel 只是广播通道,不能存储状态
- 如果只是简单的消息广播,BroadcastChannel 更简单;如果需要中心化状态管理,用 SharedWorker
- SharedWorker 的兼容性比 BroadcastChannel 差(Safari 到 16+ 才支持)
4. Service Worker
Service Worker 作为浏览器后台代理,也可以向所有受控页面广播消息。
// Service Worker 内部
self.addEventListener('message', (event: ExtendableMessageEvent) => {
// 向所有受控的客户端广播消息
self.clients.matchAll().then(clients => {
clients.forEach(client => {
if (client.id !== (event.source as Client)?.id) {
client.postMessage(event.data);
}
});
});
});
// 页面代码
navigator.serviceWorker.controller?.postMessage({
type: 'DATA_SYNC',
payload: { key: 'settings', value: { lang: 'zh' } },
});
navigator.serviceWorker.addEventListener('message', (event: MessageEvent) => {
console.log('收到 SW 广播:', event.data);
});
- 应用已经注册了 Service Worker(如 PWA)
- 需要离线场景下也能进行某些通信处理
- 结合缓存、推送通知等功能一起使用
二、跨域通信
5. window.postMessage(最通用)
postMessage 是唯一支持跨域页面通信的标准 API。
iframe 通信
// 父页面 → iframe
const iframe = document.getElementById('child-frame') as HTMLIFrameElement;
// 等 iframe 加载完成后发送
iframe.onload = () => {
iframe.contentWindow?.postMessage(
{ type: 'INIT', config: { theme: 'dark' } },
'https://child.example.com' // 目标 origin,不要用 '*'
);
};
// iframe 内部接收
window.addEventListener('message', (event: MessageEvent) => {
// 安全校验:验证消息来源
if (event.origin !== 'https://parent.example.com') return;
const { type, config } = event.data;
if (type === 'INIT') {
applyConfig(config);
}
});
// iframe → 父页面
window.parent.postMessage(
{ type: 'RESIZE', height: document.body.scrollHeight },
'https://parent.example.com'
);
弹出窗口通信
// 父页面打开新窗口
const popup = window.open('https://other-domain.com/auth', '_blank');
// 父页面接收子窗口消息
window.addEventListener('message', (event: MessageEvent) => {
if (event.origin !== 'https://other-domain.com') return;
if (event.data.type === 'AUTH_SUCCESS') {
const { token } = event.data;
handleLogin(token);
popup?.close();
}
});
// -----
// 子窗口(other-domain.com)发送消息给父页面
window.opener?.postMessage(
{ type: 'AUTH_SUCCESS', token: 'xxx' },
'https://parent.example.com'
);
- 永远验证
event.origin:不要忽略来源检查,否则任何页面都能伪造消息 - 不要用
'*'作为 targetOrigin:除非确实需要任意源都能收到 - 不要直接执行消息中的代码:避免
eval(event.data)等危险操作 - 验证消息格式:用 Zod 等工具校验
event.data的结构
// ❌ 危险
window.addEventListener('message', (event) => {
eval(event.data.code); // 任何页面都能注入代码!
});
// ✅ 安全
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted.example.com') return;
const result = messageSchema.safeParse(event.data);
if (!result.success) return;
handleMessage(result.data);
});
6. MessageChannel
MessageChannel 创建一对相互关联的端口,实现点对点的双向通信。常用于父页面与 iframe 之间建立专用通道。
// 父页面:创建通道,把其中一个端口发给 iframe
const channel = new MessageChannel();
const iframe = document.getElementById('child') as HTMLIFrameElement;
iframe.onload = () => {
// 把 port2 通过 postMessage 转移给 iframe
iframe.contentWindow?.postMessage('INIT_PORT', '*', [channel.port2]);
};
// 父页面通过 port1 通信
channel.port1.onmessage = (event: MessageEvent) => {
console.log('收到 iframe 消息:', event.data);
};
channel.port1.postMessage({ type: 'PING' });
// iframe 内部:接收端口
window.addEventListener('message', (event: MessageEvent) => {
if (event.data === 'INIT_PORT' && event.ports[0]) {
const port = event.ports[0];
port.onmessage = (e: MessageEvent) => {
console.log('收到父页面消息:', e.data);
port.postMessage({ type: 'PONG', timestamp: Date.now() });
};
port.start();
}
});
postMessage是广播式的(所有 message 监听器都会收到)MessageChannel是点对点的专用通道,互不干扰- 当页面有多个 iframe 需要独立通信时,MessageChannel 更清晰
- React 内部(Scheduler)也使用 MessageChannel 来实现任务调度
三、其他通信方式
7. IndexedDB + 轮询
IndexedDB 支持同源页面共享数据,但没有变更事件,需要轮询检测变化。
// 写入端
async function writeMessage(db: IDBDatabase, message: unknown): Promise<void> {
const tx = db.transaction('messages', 'readwrite');
const store = tx.objectStore('messages');
await store.put({ id: 'latest', data: message, time: Date.now() });
}
// 读取端:轮询检测变化
let lastTime = 0;
setInterval(async () => {
const tx = db.transaction('messages', 'readonly');
const store = tx.objectStore('messages');
const record = await store.get('latest');
if (record && record.time > lastTime) {
lastTime = record.time;
handleNewMessage(record.data);
}
}, 1000); // 每秒检查一次
轮询有延迟且浪费性能,只适合非实时的数据共享(如离线数据同步)。实时通信请用 BroadcastChannel 或 SharedWorker。
8. Cookie + 轮询(传统方案)
原理与 IndexedDB 轮询类似,通过定时读取 Cookie 检测变化。已过时,不推荐使用。
9. window.name
window.name 在页面跳转时会保留,可用于同一窗口内的跨页面数据传递。
// 页面 A 设置
window.name = JSON.stringify({ token: 'xxx', userId: '123' });
// 导航到页面 B 后,window.name 仍然保留
window.location.href = '/page-b';
// 页面 B 读取
const data = JSON.parse(window.name);
console.log(data.token); // 'xxx'
window.name = ''; // 用完清空
window.name 容量通常在 2MB 左右,且数据对同一窗口的后续页面可见,存在安全隐患。实际开发中几乎不用,了解即可。
方案对比总结
| 方案 | 同源/跨域 | 通信模式 | 实时性 | 复杂度 | 兼容性 |
|---|---|---|---|---|---|
| BroadcastChannel | 同源 | 广播 | 实时 | ⭐ 最低 | Chrome 54+, FF 38+, Safari 15.4+ |
| localStorage 事件 | 同源 | 广播 | 实时 | ⭐⭐ | 所有现代浏览器 |
| SharedWorker | 同源 | 中心化 | 实时 | ⭐⭐⭐ | Chrome 4+, FF 29+, Safari 16+ |
| Service Worker | 同源 | 广播 | 实时 | ⭐⭐⭐ | Chrome 40+, FF 44+, Safari 11.1+ |
| postMessage | 跨域 | 点对点 | 实时 | ⭐⭐ | 所有现代浏览器 |
| MessageChannel | 跨域 | 点对点 | 实时 | ⭐⭐ | 所有现代浏览器 |
| IndexedDB 轮询 | 同源 | 共享存储 | 延迟 | ⭐⭐⭐ | 所有现代浏览器 |
- 同源标签页通信 → 首选 BroadcastChannel(最简单),需要中心状态管理用 SharedWorker
- 跨域 iframe 通信 → postMessage,多 iframe 用 MessageChannel
- 兼容性要求高 → localStorage 事件(最广泛支持)
- 已有 Service Worker → 直接复用 SW 广播
常见面试问题
Q1: 如何实现多标签页之间的数据同步?
答案:
同源场景下有 4 种主流方案:
// 方案 1:BroadcastChannel(推荐)
const bc = new BroadcastChannel('sync');
bc.postMessage({ type: 'SYNC', data: newData });
bc.onmessage = (e) => updateUI(e.data);
// 方案 2:localStorage 事件
localStorage.setItem('sync', JSON.stringify({ data: newData, t: Date.now() }));
window.addEventListener('storage', (e) => {
if (e.key === 'sync') updateUI(JSON.parse(e.newValue!));
});
// 方案 3:SharedWorker(需要中心状态)
const sw = new SharedWorker('/worker.js');
sw.port.postMessage(newData);
sw.port.onmessage = (e) => updateUI(e.data);
// 方案 4:Service Worker
navigator.serviceWorker.controller?.postMessage(newData);
navigator.serviceWorker.addEventListener('message', (e) => updateUI(e.data));
Q2: postMessage 有哪些安全风险?如何防范?
答案:
| 风险 | 防范措施 |
|---|---|
| 消息伪造 | 严格验证 event.origin |
| 代码注入 | 不要 eval 消息内容 |
| 信息泄露 | 不要用 targetOrigin: '*' |
| 原型污染 | 用 Zod 等校验消息结构 |
// 安全的 postMessage 处理
const ALLOWED_ORIGINS = ['https://trusted.com', 'https://app.trusted.com'];
window.addEventListener('message', (event) => {
// 1. 验证来源
if (!ALLOWED_ORIGINS.includes(event.origin)) return;
// 2. 校验数据格式
const result = z.object({
type: z.enum(['INIT', 'UPDATE', 'CLOSE']),
payload: z.unknown(),
}).safeParse(event.data);
if (!result.success) return;
// 3. 按类型处理
handleMessage(result.data);
});
Q3: BroadcastChannel 和 localStorage 事件哪个更好?
答案:
| 对比项 | BroadcastChannel | localStorage 事件 |
|---|---|---|
| 易用性 | 更简单,专为此设计 | 需要手动序列化、加时间戳 |
| 数据类型 | 支持结构化克隆(对象、ArrayBuffer) | 只支持字符串 |
| 副作用 | 无(纯通信通道) | 会写入 localStorage(有容量限制) |
| 性能 | 更好 | 写 localStorage 是同步的 |
| 兼容性 | Safari 15.4+ | 所有浏览器 |
结论:优先用 BroadcastChannel;如果需要兼容旧浏览器(尤其旧版 Safari),退回 localStorage。
Q4: iframe 与父页面如何安全通信?
答案:
// 父页面
const iframe = document.getElementById('child') as HTMLIFrameElement;
// 发送消息
iframe.contentWindow?.postMessage(
{ type: 'CONFIG', data: config },
'https://child.example.com' // 明确指定 origin
);
// 接收消息
window.addEventListener('message', (event) => {
if (event.origin !== 'https://child.example.com') return;
if (event.source !== iframe.contentWindow) return; // 确认来自目标 iframe
handleChildMessage(event.data);
});
关键安全措施:
- 指定 targetOrigin:不用
'*' - 验证 event.origin:确认发送方身份
- 验证 event.source:多个 iframe 时区分来源
- 数据校验:不信任消息内容
Q5: SharedWorker 什么时候会被销毁?
答案:
SharedWorker 的生命周期:
- 创建:第一个页面
new SharedWorker()时 - 存活:只要有至少一个页面保持连接
- 销毁:所有连接的页面都关闭后,Worker 被回收
// Worker 内部可以监听连接断开
self.onconnect = (event) => {
const port = event.ports[0];
ports.push(port);
port.onclose = () => {
// 移除断开的端口
const index = ports.indexOf(port);
if (index !== -1) ports.splice(index, 1);
};
};
注意:SharedWorker 的调试需要在 chrome://inspect/#workers 中查看。
Q6: React 中如何优雅地使用跨标签页通信?
答案:
封装为自定义 Hook:
import { useEffect, useCallback, useRef } from 'react';
function useBroadcast<T>(channelName: string) {
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
channelRef.current = new BroadcastChannel(channelName);
return () => channelRef.current?.close();
}, [channelName]);
const send = useCallback((data: T) => {
channelRef.current?.postMessage(data);
}, []);
const useListener = (callback: (data: T) => void) => {
useEffect(() => {
const channel = channelRef.current;
if (!channel) return;
const handler = (event: MessageEvent<T>) => callback(event.data);
channel.addEventListener('message', handler);
return () => channel.removeEventListener('message', handler);
}, [callback]);
};
return { send, useListener };
}
// 使用
function ThemeSyncComponent() {
const { send, useListener } = useBroadcast<{ theme: string }>('theme');
useListener(({ theme }) => {
document.documentElement.dataset.theme = theme;
});
return (
<button onClick={() => send({ theme: 'dark' })}>
切换暗色模式(同步到其他标签页)
</button>
);
}
Q7: 跨域 iframe 高度自适应怎么实现?
答案:
经典场景:父页面嵌入跨域 iframe,需要 iframe 内容多高,父页面就给它多高。
// iframe 内部:监听内容高度变化,通知父页面
const observer = new ResizeObserver(() => {
window.parent.postMessage(
{ type: 'RESIZE', height: document.documentElement.scrollHeight },
'*' // 这里用 * 是因为 iframe 不一定知道父页面 origin
);
});
observer.observe(document.body);
// 父页面:接收高度并设置 iframe 尺寸
window.addEventListener('message', (event) => {
if (event.data?.type === 'RESIZE') {
const iframe = document.getElementById('child') as HTMLIFrameElement;
iframe.style.height = `${event.data.height}px`;
}
});
Q8: 如何实现"一个标签页登录/退出,其他标签页自动同步"?
答案:
// 登录/退出时广播
const authChannel = new BroadcastChannel('auth');
function login(token: string): void {
localStorage.setItem('token', token);
authChannel.postMessage({ type: 'LOGIN', token });
}
function logout(): void {
localStorage.removeItem('token');
authChannel.postMessage({ type: 'LOGOUT' });
}
// 其他标签页监听
authChannel.onmessage = (event) => {
switch (event.data.type) {
case 'LOGIN':
// 更新当前页面的登录状态
store.dispatch(setToken(event.data.token));
break;
case 'LOGOUT':
// 跳转到登录页
store.dispatch(clearAuth());
window.location.href = '/login';
break;
}
};
兼容方案(用 localStorage 事件):
window.addEventListener('storage', (event) => {
if (event.key === 'token') {
if (event.newValue) {
// 其他标签页登录了
store.dispatch(setToken(event.newValue));
} else {
// 其他标签页退出了
store.dispatch(clearAuth());
window.location.href = '/login';
}
}
});