创见博客
用 postMessage 实现父子页面通信:从零搭一个跨域 demo
七崽爱吃小饼干2026/09/21阅读 0

iframe 通信这件事,第一次做的人几乎都会卡在同一个地方:能拿到 iframe,却拿不到里面的东西。

js
const frame = document.querySelector('iframe')

frame.contentDocument              // null
frame.contentWindow.document       // SecurityError

只要父页面和 iframe 的源不同,同源策略就会把 iframe 内部的文档整个挡住。但浏览器留了一扇合法的门——postMessage。

这篇先从 postMessage 的基本用法讲起,再把它跑成一个完整的 demo:父页面在一个端口、子页面在另一个端口,两边各放一个按钮,点一下就把消息发到对面。

一、postMessage 的基本用法

postMessage 只有两个动作:发和收。

发消息

完整签名是:

js
targetWindow.postMessage(message, targetOrigin, [transfer])
  • targetWindow:发给谁。发给 iframe 内部页面用 iframe.contentWindow;发给父页面用 window.parent;发给 window.open() 打开的窗口用它返回的引用。
  • message:消息体。可以是字符串、对象、数组等,走结构化克隆——函数、DOM 节点、原型链都会丢失。
  • targetOrigin:只有这个源的文档才能收到这条消息。可以写具体源 'http://localhost:3100',也可以写 '*'(不限制来源,但不安全,后面会讲)。
  • transfer(可选):要转移所有权的可转移对象(如 ArrayBuffer、MessagePort)。转移后原上下文不再持有它。
js
// 父页面 → iframe
iframe.contentWindow.postMessage({ type: 'PING' }, 'http://localhost:3100')

// 子页面 → 父页面
window.parent.postMessage({ type: 'PONG' }, 'http://localhost:3000')

收消息

接收方监听 message 事件,回调参数是一个 MessageEvent:

js
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 是异步的。调用后立即返回,对方在之后的某个时刻才收到。

一个最小的双向例子

js
// 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)
})
js
// 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:3000http://localhost:3000同源
http://localhost:3000http://localhost:3100跨源(端口不同)
http://localhost:3000http://127.0.0.1:3000跨源(域名不同)
https://a.comhttp://a.com跨源(协议不同)

所以做跨域通信的 demo,不需要买域名、也不需要起两个框架项目,把子页面放到另一个端口就够了。

同源策略挡住的是「直接访问」,没挡住「发消息」:

操作同源跨源
拿到 iframe.contentWindow 对象✅✅(只是个代理对象)
读 contentDocument / contentWindow.document✅❌ null 或抛错
读内部 DOM、调内部函数✅❌
contentWindow.postMessage(...)✅✅
接收内部页面发来的 message 事件✅✅

下面这个 demo 就只依赖最后两行。

三、先定协议,再写代码

裸发数据是后期最难维护的做法。一开始就加一层信封,把消息关进自己的命名空间:

ts
interface Envelope<T = unknown> {
  type: string      // 消息类型
  payload?: T
}

这个 demo 只有四种类型,够用且能说清楚方向:

type方向说明
PARENT_HELLO父 → 子父页面发起握手,子页面收到后回 CHILD_READY
PARENT_MESSAGE父 → 子父页面发给子页面的正文
CHILD_READY子 → 父子页面就绪,可以开始通信
CHILD_MESSAGE子 → 父子页面发给父页面的正文

四、demo 结构

text
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 内置模块,三十行就够:

js
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:

tsx
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 中途被导航到别的站点,你的数据就发给了那个站点。正确做法是写死目标源。

第二块,收消息——先校验来源,再处理:

tsx
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:

tsx
<iframe
  ref={iframeRef}
  src={CHILD_ORIGIN}
  title="跨域子页面"
  onLoad={handleFrameLoad}
/>

六、子页面:对称的一侧

子页面是纯 HTML,逻辑和父页面完全对称,只是收发对象换成了 window.parent:

html
<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 发。

到这里,两个按钮的链路就通了:

text
父页面「父 → 子 发送」  ──postMessage(PARENT_MESSAGE)──▶  子页面日志出现
父页面日志出现  ◀──postMessage(CHILD_MESSAGE)──  子页面「子 → 父 发送」

七、最容易踩的坑:握手时序

如果只做到上面这些,你会遇到一个很隐蔽的问题:子页面在加载时发的「我准备好了」,父页面根本没收到。

原因是时序:

text
1. 父页面 HTML 到达浏览器,<iframe> 立刻开始加载
2. 子页面脚本执行,往 parent 发 CHILD_READY
3. 父页面的 React 还没 hydration 完,window 上还没有 message 监听器
4. 子页面的 CHILD_READY 被丢掉 —— postMessage 没有队列,没有重发

iframe.onload 也救不了你:SSR 场景下 iframe 在 hydration 之前就已经触发过 load 了,React 挂上去的 onLoad 永远等不到。

正确的做法是父页面主动重试,直到收到子页面的应答:

tsx
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,轮询停止:

html
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 伪造消息」。更严格的做法是同时比对:

js
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这是对方站的策略,直连无解

十、验证一下

两个终端分别启动:

bash
npm run demo:child   # 子页面 → http://localhost:3100
npm run dev          # 父页面 → http://localhost:3000/demo/iframe-postmessage

打开父页面,输入框里写点东西,点「父 → 子 发送」,子页面的日志面板会实时出现这条消息;再点子页面的「子 → 父 发送」,父页面右侧的通信日志里会多出一条 子 → 父 记录。

日志里每条消息都带方向和 origin,用来确认「消息确实只在白名单源之间流动」。如果你在控制台手动执行:

js
window.postMessage({ type: 'CHILD_MESSAGE', payload: '伪造消息' }, '*')

父页面会把它记录成「已拦截」——因为 event.origin 是父页面自己,不在白名单里。

十一、小结

  1. 端口不同就是跨源,做跨域通信 demo 不需要两个框架项目,另一个端口上的静态页就够。
  2. 发送写死 targetOrigin,接收校验 event.origin,这两条是所有 postMessage 代码的前提。
  3. 先定消息协议(type + payload),再写收发逻辑,后期才不容易失控。
  4. 握手不要赌时序:postMessage 没有队列,父页面没监听就永久丢消息。用「父侧重试 + 子侧应答」的幂等握手。
  5. 把 payload 当用户输入,把 origin 校验当安全底线——postMessage 的漏洞几乎都出在「太信任对方」。
评论
0/100