iframe 通信这件事,第一次做的人几乎都会卡在同一个地方:能拿到 iframe,却拿不到里面的东西。
const frame = document.querySelector('iframe')
frame.contentDocument // null
frame.contentWindow.document // SecurityError
只要父页面和 iframe 的源不同,同源策略就会把 iframe 内部的文档整个挡住。但浏览器留了一扇合法的门——postMessage。
这篇先从 postMessage 的基本用法讲起,再把它跑成一个完整的 demo:父页面在一个端口、子页面在另一个端口,两边各放一个按钮,点一下就把消息发到对面。
一、postMessage 的基本用法
postMessage 只有两个动作:发和收。
发消息
完整签名是:
targetWindow.postMessage(message, targetOrigin, [transfer])
targetWindow:发给谁。发给 iframe 内部页面用iframe.contentWindow;发给父页面用window.parent;发给window.open()打开的窗口用它返回的引用。message:消息体。可以是字符串、对象、数组等,走结构化克隆——函数、DOM 节点、原型链都会丢失。targetOrigin:只有这个源的文档才能收到这条消息。可以写具体源'http://localhost:3100',也可以写'*'(不限制来源,但不安全,后面会讲)。transfer(可选):要转移所有权的可转移对象(如ArrayBuffer、MessagePort)。转移后原上下文不再持有它。
// 父页面 → iframe
iframe.contentWindow.postMessage({ type: 'PING' }, 'http://localhost:3100')
// 子页面 → 父页面
window.parent.postMessage({ type: 'PONG' }, 'http://localhost:3000')
收消息
接收方监听 message 事件,回调参数是一个 MessageEvent:
window.addEventListener('message', (event) => {
event.data // 对方发来的消息体,类型取决于对方发了什么
event.origin // 发送方所在的源,例如 'http://localhost:3100'
event.source // 发送方的 window 引用,可用于比对身份
})
三个属性各有用途,也是后面所有安全校验的落点:
| 属性 | 含义 | 常见用途 |
|---|---|---|
event.data | 消息体 | 业务数据 |
event.origin | 发送方的源 | 白名单校验,最重要 |
event.source | 发送方的 window 引用 | 多 iframe 场景下确认身份 |
四个容易误解的点
- 发送方无法指定「我是谁」。
event.origin由浏览器根据真实的加载源填充,页面自己伪造不了。 message事件是全局的。同一页面上任何窗口发来的消息都会触发,所以必须自己判断event.origin。- 没有队列、没有重发。对方没有监听时,消息直接丢弃,不会缓存。这点在第七节的握手里会展开。
postMessage是异步的。调用后立即返回,对方在之后的某个时刻才收到。
一个最小的双向例子
// A 页面(父)
const frame = document.querySelector('iframe')
frame.contentWindow.postMessage('hi', 'http://localhost:3100')
window.addEventListener('message', (e) => {
if (e.origin !== 'http://localhost:3100') return
console.log('收到子页面', e.data)
})
// B 页面(iframe 内)
window.parent.postMessage('hello from child', 'http://localhost:3000')
window.addEventListener('message', (e) => {
if (e.origin !== 'http://localhost:3000') return
console.log('收到父页面', e.data)
})
记住这几行就够了——剩下的都是围绕它补协议、补时序、补校验。
二、先搞清楚:端口不同算不算跨域
算。源(origin)由协议 + 域名 + 端口三部分组成,任意一个不同就是跨源:
| 父页面 | 子页面 | 是否跨源 |
|---|---|---|
http://localhost:3000 | http://localhost:3000 | 同源 |
http://localhost:3000 | http://localhost:3100 | 跨源(端口不同) |
http://localhost:3000 | http://127.0.0.1:3000 | 跨源(域名不同) |
https://a.com | http://a.com | 跨源(协议不同) |
所以做跨域通信的 demo,不需要买域名、也不需要起两个框架项目,把子页面放到另一个端口就够了。
同源策略挡住的是「直接访问」,没挡住「发消息」:
| 操作 | 同源 | 跨源 |
|---|---|---|
拿到 iframe.contentWindow 对象 | ✅ | ✅(只是个代理对象) |
读 contentDocument / contentWindow.document | ✅ | ❌ null 或抛错 |
| 读内部 DOM、调内部函数 | ✅ | ❌ |
contentWindow.postMessage(...) | ✅ | ✅ |
接收内部页面发来的 message 事件 | ✅ | ✅ |
下面这个 demo 就只依赖最后两行。
三、先定协议,再写代码
裸发数据是后期最难维护的做法。一开始就加一层信封,把消息关进自己的命名空间:
interface Envelope<T = unknown> {
type: string // 消息类型
payload?: T
}
这个 demo 只有四种类型,够用且能说清楚方向:
| type | 方向 | 说明 |
|---|---|---|
PARENT_HELLO | 父 → 子 | 父页面发起握手,子页面收到后回 CHILD_READY |
PARENT_MESSAGE | 父 → 子 | 父页面发给子页面的正文 |
CHILD_READY | 子 → 父 | 子页面就绪,可以开始通信 |
CHILD_MESSAGE | 子 → 父 | 子页面发给父页面的正文 |
四、demo 结构
visionary/
├── src/app/demo/iframe-postmessage/
│ ├── page.tsx # 父页面,跑在 localhost:3000
│ └── page.module.scss
└── demo-child/
├── public/index.html # 子页面,独立 origin,跑在 localhost:3100
└── server.mjs # 一个零依赖的静态服务器
子页面没有用任何框架,就是一个纯 HTML + 内联 <script>。因为它要演示的是跨源,跟工程栈无关——起一个静态服务器反而是最干净的。
server.mjs 用 Node 内置模块,三十行就够:
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const PORT = Number(process.env.PORT || 3100);
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), 'public');
const server = createServer(async (req, res) => {
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
const relative = normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
const filePath = join(ROOT, relative === '/' ? 'index.html' : relative);
try {
const body = await readFile(filePath);
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(body);
} catch {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not found');
}
});
server.listen(PORT, () => {
console.log(`子页面已启动: http://localhost:${PORT}`);
});
Cache-Control: no-store 是为了改完子页面刷新就能看到,不用清缓存。
五、父页面:发消息 + 收消息
父页面是 React 客户端组件。核心只有三块。
第一块,发消息——必须写清楚 targetOrigin:
const CHILD_ORIGIN = 'http://localhost:3100';
const sendToChild = () => {
const target = iframeRef.current?.contentWindow;
if (!target) return;
const text = input.trim() || '(空消息)';
target.postMessage({ type: 'PARENT_MESSAGE', payload: text }, CHILD_ORIGIN);
appendLog('sent', text);
};
postMessage 的第二个参数不能图省事写 '*'。它的含义是「任何当前或未来的文档都能收到这条消息」。如果 iframe 中途被导航到别的站点,你的数据就发给了那个站点。正确做法是写死目标源。
第二块,收消息——先校验来源,再处理:
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== CHILD_ORIGIN) {
appendLog('blocked', `来源 ${event.origin} 不在白名单内,已忽略`, event.origin);
return;
}
const data = event.data as { type?: string; payload?: unknown } | undefined;
if (data?.type === 'CHILD_READY') {
setChildReady(true);
return;
}
if (data?.type === 'CHILD_MESSAGE') {
appendLog('received', String(data.payload), event.origin);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [appendLog]);
event.origin !== CHILD_ORIGIN 这一行是整个 demo 的安全底线:页面上的 message 事件是全局的,任何窗口、任何站点都能给你发消息。不校验就处理,等于把接口公开给整个互联网。
第三块,iframe:
<iframe
ref={iframeRef}
src={CHILD_ORIGIN}
title="跨域子页面"
onLoad={handleFrameLoad}
/>
六、子页面:对称的一侧
子页面是纯 HTML,逻辑和父页面完全对称,只是收发对象换成了 window.parent:
<script>
var PARENT_ORIGIN = 'http://localhost:3000';
function sendToParent() {
var text = input.value.trim() || '(空消息)';
window.parent.postMessage({ type: 'CHILD_MESSAGE', payload: text }, PARENT_ORIGIN);
appendLog('sent', text);
}
window.addEventListener('message', function (event) {
if (event.origin !== PARENT_ORIGIN) {
appendLog('sys', '已拦截来源 ' + event.origin + ' 的消息');
return;
}
var data = event.data;
if (data && data.type === 'PARENT_MESSAGE') {
setConnected(true);
appendLog('parent', String(data.payload));
}
});
</script>
注意子页面同样双向校验:只接收 PARENT_ORIGIN 的消息,只往 PARENT_ORIGIN 发。
到这里,两个按钮的链路就通了:
父页面「父 → 子 发送」 ──postMessage(PARENT_MESSAGE)──▶ 子页面日志出现
父页面日志出现 ◀──postMessage(CHILD_MESSAGE)── 子页面「子 → 父 发送」
七、最容易踩的坑:握手时序
如果只做到上面这些,你会遇到一个很隐蔽的问题:子页面在加载时发的「我准备好了」,父页面根本没收到。
原因是时序:
1. 父页面 HTML 到达浏览器,<iframe> 立刻开始加载
2. 子页面脚本执行,往 parent 发 CHILD_READY
3. 父页面的 React 还没 hydration 完,window 上还没有 message 监听器
4. 子页面的 CHILD_READY 被丢掉 —— postMessage 没有队列,没有重发
iframe.onload 也救不了你:SSR 场景下 iframe 在 hydration 之前就已经触发过 load 了,React 挂上去的 onLoad 永远等不到。
正确的做法是父页面主动重试,直到收到子页面的应答:
useEffect(() => {
window.addEventListener('message', handleMessage);
const ping = () => {
if (readyRef.current) return;
iframeRef.current?.contentWindow?.postMessage({ type: 'PARENT_HELLO' }, CHILD_ORIGIN);
};
ping();
const timer = window.setInterval(ping, 700);
return () => {
window.removeEventListener('message', handleMessage);
window.clearInterval(timer);
};
}, [appendLog]);
子页面收到 PARENT_HELLO 就回一条 CHILD_READY,父页面收到后把 readyRef 置为 true,轮询停止:
if (data && data.type === 'PARENT_HELLO') {
setConnected(true);
handshake(); // postMessage({ type: 'CHILD_READY' }, PARENT_ORIGIN)
return;
}
这本质上是一个幂等的握手:父页面问「你在吗」,子页面答「我在」,问到为止。比起赌对方一定先就绪,这更可靠。
八、安全清单
postMessage 相关的历史漏洞,基本都出在「收消息时太信任对方」。下面几条不是可选项。
1. 发消息写死 targetOrigin,不要用 '*'
'*' 代表「任何源都能收」。唯一可以使用它的时刻是握手的第一条消息(此时还不知道对方源),之后就该锁定。
2. 收消息必须校验 event.origin
message 事件是全局的,任何页面都能给你发。白名单校验是最低要求。
3. 页面里可能有多个 iframe,用 event.source 再确认一次
只校验 origin 挡不住「同一个源里另一个 iframe 伪造消息」。更严格的做法是同时比对:
if (event.source !== iframeRef.current?.contentWindow) return;
4. 不要把 payload 当可信数据
子页面发上来的内容要当成用户输入对待:显示时转义、参与业务逻辑前再校验。在本 demo 里它只是显示到日志,但如果用它拼 URL、拼 DOM,就必须再筛一遍。
5. 小心 origin 为 'null' 的情况
带 sandbox 但没有 allow-same-origin 的 iframe、srcdoc、data: URL,它们的 event.origin 都是字符串 "null"。它的语义是「不透明源」——没有任何办法判断它是谁。一旦在 isAllowedOrigin 里放行 'null',等于放行任意站点。
九、踩坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
读 contentDocument 得到 null / 抛错 | 跨源,同源策略拦截 | 改走 postMessage |
| 子页面首条消息父页面收不到 | 父页面监听挂载晚于消息发出 | 父侧轮询握手 + 子侧应答 |
iframe.onload 里的代码不执行 | SSR 下 iframe 在 hydration 前已 load | 别依赖 onLoad,改用 useEffect |
| 收到无关消息、解析报错 | 页面其他脚本也在用 postMessage | 信封带 type,先判类型再处理 |
| 消息发给了不该发的站点 | targetOrigin 写了 '*' | 锁定具体源 |
| 出现来源不明的消息 | 未校验 origin | 监听里第一步就校验 event.origin |
| 子页面怎么都嵌不进来、白屏 | 对方返回 X-Frame-Options / CSP frame-ancestors | 这是对方站的策略,直连无解 |
十、验证一下
两个终端分别启动:
npm run demo:child # 子页面 → http://localhost:3100
npm run dev # 父页面 → http://localhost:3000/demo/iframe-postmessage
打开父页面,输入框里写点东西,点「父 → 子 发送」,子页面的日志面板会实时出现这条消息;再点子页面的「子 → 父 发送」,父页面右侧的通信日志里会多出一条 子 → 父 记录。
日志里每条消息都带方向和 origin,用来确认「消息确实只在白名单源之间流动」。如果你在控制台手动执行:
window.postMessage({ type: 'CHILD_MESSAGE', payload: '伪造消息' }, '*')
父页面会把它记录成「已拦截」——因为 event.origin 是父页面自己,不在白名单里。
十一、小结
- 端口不同就是跨源,做跨域通信 demo 不需要两个框架项目,另一个端口上的静态页就够。
- 发送写死
targetOrigin,接收校验event.origin,这两条是所有 postMessage 代码的前提。 - 先定消息协议(
type+payload),再写收发逻辑,后期才不容易失控。 - 握手不要赌时序:
postMessage没有队列,父页面没监听就永久丢消息。用「父侧重试 + 子侧应答」的幂等握手。 - 把 payload 当用户输入,把 origin 校验当安全底线——postMessage 的漏洞几乎都出在「太信任对方」。