1. 先搞明白:跨域到底卡在哪了
1.1 浏览器的同源策略是道门
如果你是个前端,早晚会撞上这么一个需求:页面里嵌了iframe,里面的页面可能是你自己的子应用,也可能是第三方服务,结果两边要互传数据、同步状态、触发方法。要是同源,直接iframe.contentWindow.document拿过来调就完了,可一旦跨域,浏览器直接给你红牌——报SecurityError,连碰都不让你碰。这时候,postMessage就是绕开同源限制的官方通道。
我第一次接触跨域时,心里特别不服气:我自己的页面,嵌自己的iframe,凭什么不让通信?后来才明白,浏览器把每一个独立的上下文(origin)当成一个“房间”。同源策略就是房间的墙,目的不是恶心开发者,而是防止恶意脚本在你不知情的情况下窃取另一个站点页面的数据。
同源的定义包括三点:协议、域名、端口。只要有一个不同,就算跨域。注意一个容易翻车的地方:很多人以为端口不同不算跨域,其实是算的,http://localhost:8080和http://localhost:5173这俩就是完全不同的 origin。本地开发时,前端 dev server 默认跑在 5173,后端服务跑在 8080,iframe一嵌进去,几乎必然踩到端口跨域这道坎。
跨域的限制并不是单点的,而是分了好几个层面:
- DOM 访问限制:你尝试读
iframe.contentWindow.document,直接抛SecurityError; - 存储隔离:
localStorage、cookie等也按 origin 隔离,互相看不见; - 网络请求限制:
XMLHttpRequest/fetch走 CORS 控制,响应对 JS 不可见; - 窗口通信限制:
window.opener、window.parent之间的跨窗口操作也受限。
这里有一个点必须分清:不同限制的“解锁方式”不一样。fetch 跨域靠 CORS 响应头,页面间通信靠postMessage。CORS 需要服务器配合设置响应头Access-Control-Allow-Origin;而postMessage不需要服务器配合,它是浏览器内置的、专门给跨窗口、跨 iframe、跨域页面发消息用的 API。把这两件事混为一谈的人,后面排查问题时会走很多弯路。
1.2 同源判定与“跨域”的几种形态
要判断一个页面和它内嵌的iframe是不是同源,最直接的方式是在控制台跑一下:
const currentOrigin = window.location.origin; const iframeOrigin = new URL(document.getElementById('myFrame').src).origin; console.log(currentOrigin, iframeOrigin);常见的几种跨域形态,我列一下,你对照着看:
- 协议不同:
https的父页面嵌http的子页面(这类还被浏览器标记为混合内容,可能直接拦截); - 域名不同:
a.example.com嵌b.example.com; - 端口不同:本地开发最常见,
localhost:8080嵌localhost:5173; - 子域名不同:
app.example.com嵌static.example.com,也算跨域。
子域名不同这种场景,以前可以靠document.domain把两边的主域降级成一致来通信,但document.domain现在已经被标记为不推荐使用,一方面它有安全风险(放宽了同源限制),另一方面新标准下很多场景已经不再有效。我在新项目里基本不碰它,统一用postMessage解决,简单又干净。
跨域情况下,你能做什么、不能做什么,边界要记清楚:
- 能调用
window.postMessage发消息; - 能读
iframe.contentWindow.name这种极少数属性,以及能读iframe.contentWindow.location的有限信息; - 能通过修改
iframe.src给 iframe 导航(因为这是用户显式授权的导航行为); - 不能读写 iframe 内页面的 DOM、JS 变量、storage、cookie。
了解了这个边界之后,再去看postMessage就豁然开朗了:它是跨域场景下,唯一一个能让两个 window 上下文直接对话的公开通道。
2. postMessage 基础:先把 API 吃透
2.1 postMessage 的三个参数
postMessage是谁的方法?关键就一句话:消息要发给谁,就用谁的 window 对象调用它。父页面要发给 iframe,就用iframe.contentWindow.postMessage(...);iframe 要发给父页面,就用window.parent.postMessage(...)或window.top.postMessage(...)。
完整签名是:
targetWindow.postMessage(message, targetOrigin, [transfer]);三个参数挨个说清楚。
message:要发送的数据。理论上可以是任意“结构化可克隆”的值,比如字符串、对象、数组、Map、Set。注意是结构化克隆算法复制了一份传过去,接收端拿到的是副本不是引用,在接收端改了不会反向影响发送端。函数、DOM 节点、class 实例不能传,会直接抛DataCloneError。这里有个很多人踩过的坑:想传一个对象,但对象里带着方法、循环引用、Date实例,直接传一般没事,可如果你自作聪明先JSON.stringify再传,Date变字符串、方法被丢弃,到对面类型全变了。
targetOrigin:安全闸门。它指定“只发给指定源的页面”,写*表示不管对方是什么源都发送。但强烈建议别上来就*。正确姿势是在父页面嵌入 iframe 时,明确写 iframe 的src对应的 origin:
iframe.contentWindow.postMessage(data, 'https://child.example.com');为什么强调这一点?设想一下:你的页面嵌了个 iframe,如果恶意代码通过某种方式把你的 iframe 的src换成了一个钓鱼页面,此时你调postMessage(secret, '*'),消息就直接发给了钓鱼页面。但如果你写死了targetOrigin,浏览器会在发送前检查目标窗口的当前 url origin,不匹配就压根不发,直接帮你挡住这层风险。
一个容易弄反的点:targetOrigin匹配的是目标窗口里当前页面的 origin,不是它初始src的 origin,也不是你自己页面的 origin。如果 iframe 内部做了跳转(比如从登录页跳到了业务页),发送前务必确认目标页的当前 origin。
transfer:可转移对象。可选项,用于传ArrayBuffer、MessagePort这类对象。转移的意思是:原对象在发送端会被“清空/不可再用”,数据所有权直接给接收端。这对大二进制数据、音视频帧处理特别有用,能避免结构化克隆的拷贝开销。普通业务消息通信基本用不到。
2.2 message 事件怎么接
接收端只需要在 window 上加一个事件监听:
window.addEventListener('message', handleMessage); function handleMessage(event) { // 先校验,再处理 }handler收到的是一个MessageEvent对象,关键字段有这么几个:
event.data:对方发来的数据;event.origin:发送方的 origin,格式是协议://域名:端口,这是判断消息到底是谁发的核心依据,必须校验;event.source:发送方 window 对象的引用,可以用它来回发消息,比如event.source.postMessage(...);event.lastEventId:配合MessageChannel用的,普通场景忽略。
注意,这里的监听对象是当前 window,不是某个元素。我见过有人写成iframe.addEventListener('message', handler),结果半天收不到消息。message事件是在 window 上派发的,不是 iframe 元素。
另外一个新手容易忽略的点:message事件的监听不区分同源还是跨源,哪怕同源 iframe 发的消息,也会触发message事件。所以,即使业务场景本来就是同源,我也建议用postMessage统一封装一层,不要直接在父页面里用contentWindow.document去操作子页面 DOM。这样做的维护性好很多,后面详细说。
3. 实战:父页面与 iframe 双向通信
3.1 场景A:父页面向 iframe 发消息
假设我有一个父页面https://parent.example.com,内嵌子页面https://child.example.com/report.html。父页面代码大概长这样:
<iframe id="childFrame" src="https://child.example.com/report.html" style="width:100%;height:600px;"></iframe> <script> const childFrame = document.getElementById('childFrame'); const CHILD_ORIGIN = 'https://child.example.com'; function sendToChild(payload) { if (!childFrame.contentWindow) return; childFrame.contentWindow.postMessage({ type: 'UPDATE_REPORT', payload: payload, requestId: Date.now() }, CHILD_ORIGIN); } </script>我在实际项目里给消息加了一个requestId字段。原因很实用:iframe 里的请求可能是异步的,返回时我需要知道它对应的是哪条请求;而且如果 iframe 连续收到多条消息,每条回执必须对得上号。postMessage本身不提供“请求-响应”关联机制,所以要在业务协议里自己维护消息 ID。
这里有一个非常典型的坑:iframe 加载时序问题。iframe 的contentWindow在 iframe 加载过程中是存在的,但子页面的监听器可能还没注册完成,你发的消息就丢了。postMessage的特点是“发出去就不管了”,不排队、不重试、不报错。解决办法一般有两个:
- 等 iframe 的
load事件触发后再发; - 子页面加载完成后主动给父页面发一条
ready通知,父页面收到ready后才放心往下发。
第二种在实际项目中更靠谱。load事件只能说明资源加载完了,不能说明子页面的业务代码已经执行到注册监听器那一步。比如子页面是个 Vue 应用,要等框架mounted后才注册监听,那父页面在load之后立刻发消息,一样会丢。所以生产环境我都是用“握手”模式。
3.2 场景B:iframe 向父页面回传
子页面里这样写:
window.addEventListener('message', (event) => { // 安全校验永远放在第一位 if (event.origin !== 'https://parent.example.com') { console.warn('rejected message from unexpected origin:', event.origin); return; } if (typeof event.data !== 'object' || event.data === null) return; if (event.data.type !== 'UPDATE_REPORT') return; // 处理业务逻辑... const result = { code: 0, data: { ok: true } }; // 回发给父页面,用 event.origin 作为 targetOrigin event.source.postMessage({ type: 'UPDATE_REPORT_RESULT', requestId: event.data.requestId, result: result }, event.origin); });这里用event.source回发,比自己写window.parent再指定 origin 好处很大:万一 iframe 被嵌到了多个不同的父页面下面(比如同一个子页面同时服务多个父级),代码依然正确。event.origin是“当前这次消息的真实来源”,用它做回发的targetOrigin是既安全又动态。
有人会问:event.source一定等于window.parent吗?大部分情况是的,但也有例外。比如页面 A 里嵌了 iframe B,B 里又嵌了 iframe C,C 里的window.parent是 B,但 B 如果做了消息转发,C 收到的消息event.source可能是 A 的 window。所以不要假设event.source就是window.parent,一切以event.source为准。
3.3 数据序列化与传输细节
前文提到,postMessage会做结构化克隆,但实际开发中还是有不少“活见鬼”场景:
- 传 Date 对象:结构化克隆能保持 Date 类型,但如果你中间用
JSON.stringify包了一层,Date 会变成字符串,到对面要自己重新new Date()。别问我是怎么踩到的。 - 传 Map/Set:结构化克隆支持,但老版本浏览器兼容性有坑。保守做法是转成普通数组再传。
- 传 undefined:
postMessage(undefined, '*')其实会抛错。一些封装层不做类型检查,直接把 undefined 当普通值传,结果运行时炸了。 - 循环引用:直接传给
postMessage,结构化克隆是能处理循环引用的,不会抛错。但如果你先JSON.stringify,循环引用直接抛TypeError。
所以我的经验是:能用结构化克隆就直接传对象,不要没事先 stringify。不过,接受端做类型校验永远推荐,因为消息经过网络(在部分跨域场景其实是同进程传递)和不同封装层后,类型可能会变。
什么场景下需要序列化成 JSON 字符串再传?我总结了几种:
- 要兼容非常老的浏览器(老 IE 对结构化克隆支持不完整);
- 消息需要被非浏览器端消费,比如 WebView 原生层拦截处理;
- 你需要消息跨系统传递中间件,JSON 字符串更通用。
4. 安全底线:不校验 origin 就是在裸奔
4.1 每个消息都必须经过三查
经常在网上看到 demo 代码,监听message后拿到data就直接用,也不看origin。演示可以,生产环境千万别学。
原因不复杂:postMessage是公开通道,任何能往你这个 window 发消息的窗口(包括恶意页面)都能触发你的message监听器。如果没有 origin 校验,就等于你家门一直开着,谁来都能递纸条,你还都当真。
我总结了一套“三查”原则,每次处理消息前必做:
- 一查
event.origin是否在白名单里; - 二查
event.source是否是预期的来源窗口(有时候 origin 对但窗口不对,比如同源下别的 iframe 也发了消息); - 三查
data结构是否合法(type、字段、类型都对不上就丢弃)。
对于第二点,什么时候要深入校验source?同源多 iframe 场景比较重要。比如页面里有 A、B 两个同源 iframe,A 发消息时可能目标明确,但 B 也监听了 window 上的 message,就需要通过requestId或预存的source引用区分。更严谨的做法是在初始化时存下期望的 source 引用,收到消息时逐一比对。
4.2 我踩过的安全坑和实战清单
整理一份这些年用postMessage的安全清单:
targetOrigin别写*,写具体源。即使对方是你自己家的子应用,也建议明确写。多花一秒钟,少交一份智商税。- 接收端校验 origin 白名单,不要用
includes就完事。比如白名单配了https://example.com,却用event.origin.includes('example.com'),那https://evil-example.com也能通过。要用new URL(event.origin)拿协议 + 域名 + 端口做精确比对。 - 永远不要用
event.data直接拼接innerHTML、eval、new Function。即使消息来自可信源,也遵循“内容不等于可执行代码”的原则。如果业务上必须要渲染 HTML,也要走 HTML 转义或白名单过滤。 - 不要信任
event.data里的任何url和path并直接用于重定向或资源加载,先做一次 URL 解析校验。 - 敏感操作(改密码、支付、删数据)不要只靠
postMessage一条消息触发,最好带上一次性 token,或者由父页面弹出确认后走服务端二次校验。
另外还有一个很多人忽略的问题:消息风暴。如果你的页面监听了message事件,然后又通过message事件触发新的postMessage回发,编程不小心会形成无限循环——A 给 B 发,B 收到后回发,A 又收到再发……尤其双方都用event.source回发时,特别容易出现“对讲机回声”。
我有个项目就出过线上事故:一次操作导致两个 iframe 互相发了几万条消息,页面直接卡死。从那以后,所有消息处理函数都加了去重逻辑:记录最近处理过的requestId,重复的就丢弃。这个经验在我做过的每一个 iframe 通信项目里都值回票了。
5. 常见坑位与排查实录
5.1 为什么收不到 message?
排查postMessage问题,我一般按这个顺位来:
- 是不是发给了错误的 window 对象?父页面要用
iframe.contentWindow.postMessage,子页面要用window.parent.postMessage或window.top.postMessage,顶层窗口就用event.source。这个错了,后面一切白搭。 - 监听器是否注册成功?子页面代码是否在合适时机执行了
addEventListener?如果子页面用了框架,注意生命周期。我见过 Vue3 项目在setup里注册监听器,但因为组件卸载又重建导致监听丢失,还要在onUnmounted里清理掉,否则会重复注册。 - 是不是消息发太早了?子页面还没加载完、还没 ready。
- targetOrigin 是否写错了?写死
https://child.example.com,但 iframe 实际加载的是http://localhost:3000时,消息会被浏览器直接丢弃,而且不发任何报错。这就是postMessage的一个特点——静默失败。不发消息,也不报错,排查要花时间。 - 是不是传了不可序列化的数据?比如函数、Symbol,或者在某些 WebView 环境里传了原生对象。会抛
DataCloneError,但有些框架会把异常吞掉,表面看不出。
有一个非常典型的场景:本地开发时,父页面是http://localhost:5173,iframe 是http://localhost:8080,代码里targetOrigin写的是线上域名,开发环境就收不到。我建议把目标 origin 做成配置项,开发环境从环境变量读取,不要硬编码。
5.2 iframe 加载时序问题:用握手协议解决
前面提到过 ready 握手,这里展开讲讲。
正常的工作流是这样的:
- 父页面创建 iframe,并挂载 DOM;
- iframe 的
src加载,子页面脚本执行; - 子页面注册
message监听器,完成后向父页面发CHILD_READY; - 父页面收到
CHILD_READY后,再发业务消息。
要注意,如果 iframe 内部有异步初始化(比如调接口、等框架挂载),CHILD_READY一定得等所有初始化完成后再发,否则还是丢消息。
另外,子页面如果允许被多个页面嵌入,建议在CHILD_READY消息里携带一个iframeId或sessionId,用来区分这次 ready 对应的是哪个 iframe 实例。父页面那边维护一个Map<iframeId, { ready, pendingQueue }>,消息来了但还没 ready,就先放进pendingQueue,等 ready 事件后统一 flush。这套机制虽然简单,但很抗造。
我实际写过一个简化版本:
const bridgeState = { ready: false, pendingQueue: [], }; function requestToChild(payload) { if (!bridgeState.ready) { bridgeState.pendingQueue.push(payload); return; } childFrame.contentWindow.postMessage(payload, CHILD_ORIGIN); } function onChildReady() { bridgeState.ready = true; bridgeState.pendingQueue.forEach(p => { childFrame.contentWindow.postMessage(p, CHILD_ORIGIN); }); bridgeState.pendingQueue = []; }这个写法的好处是,即使子页面初始化很慢,业务侧也不用关心时机,只管调用requestToChild就行。
5.3 与 Vue3 嵌套 iframe 的配合
现在不少项目是用 Vue3 做前端框架,嵌套 iframe 时有几个注意点。
第一,iframe 的src不要用响应式变量做频繁变更。每次src变化都会触发 iframe 重新加载,子页面会重新走加载流程,之前的 ready 状态就失效了。如果业务上有切换需求,建议用v-if来控制 iframe 实例的挂载卸载,切换时顺便清理监听。
第二,监听message事件时记得清理。Vue3 的setup里用onMounted注册,在onBeforeUnmount里移除,否则组件被销毁后监听还挂在 window 上,既浪费内存,又可能在组件重建后重复处理消息。注意,多个 iframe 会同时在同一 window 上监听message,所以消息里必须有标识区分来源。
第三,Vue3 的组件通信习惯是 props / emit / eventBus,但跨 iframe 是隔离的,不能用这些常规手段。如果你在 Vue3 项目里做父子 iframe 通信,最好的方式还是封装一个通信模块,统一管理postMessage的发送、接收、路由。我习惯封装一个createIframeBridge(iframe, targetOrigin)函数,返回一个 Promise 风格的消息发送接口,内部处理 ready 握手和requestId匹配。
5.4 动态 iframe:加载失败、CSP 拦截与超时处理
动态创建的 iframe(比如用户点了个按钮才加载报表),特别容易踩到加载失败的问题。src指向的资源 404、网络断开、被 CSP 阻挡,iframe 一样会存在于 DOM 中,但contentWindow可能可以访问,你发的消息也没人接。
排查办法:给 iframe 加onload和onerror监听。但onerror并不能覆盖所有情况(比如 404 的页面其实也会触发load),所以最靠谱的还是 ready 握手,配合超时机制。
我的做法是:父页面发一个PING,子页面 3 秒内没有回PONG,就认为加载失败,然后提示用户。这个 PING/PONG 机制非常简单,但在动态 iframe 场景下特别实用。
还有一个高发问题是CSP 拦截。如果父页面配置了Content-Security-Policy,其中frame-src或child-src不包含子页面域名,iframe 会被浏览器直接拦截。控制台会显示类似 “Refused to frame ... because it violates the following Content Security Policy directive”。看到这种日志,直接去查 CSP 配置,别在 postMessage 代码里浪费时间。
6. 扩展:与 iframe 相关的高频场景
既然聊到 iframe,顺便把几个和它有千丝万缕联系但又容易被混淆的场景一起说清楚,省得你遇到时抓瞎。
6.1 隐藏滚动条与样式隔离
很多人嵌完 iframe 后第一件事就是想去掉丑丑的滚动条。同源的话,你可以直接操作iframe.contentDocument里的样式;跨域就不行了,外部样式管不到 iframe 内部文档的滚动条。
跨域情况下,能做的有限:
- 给 iframe 设置样式
overflow: hidden,但注意,这个方法对 iframe 滚动条未必有效,因为滚动条是 iframe 内部文档的,外部样式管不到; - 更靠谱的招数是让子页面内部的
body/html上设置overflow: hidden,由子页面自己处理内容滚动。如果内容确实超高需要滚动,可以在子页面容器上用scrollbar-width: none(Chrome)或::-webkit-scrollbar { display: none }隐藏滚动条。
这一点和postMessage的关系在于:跨域下你执行不了子页面的样式操作,所以必须在子页面里配合实现。这时候可以约定一套消息协议,比如父页面发SET_OVERFLOW_HIDDEN,子页面收到后设置自己document的样式——这就是postMessage在“跨域样式协调”上的典型用法。
6.2 后端跨域 CORS 与 postMessage 的区别
经常有入门同学把 CORS 和postMessage搞混。我拿一张简单的对照表说清楚:
| 对比项 | CORS | postMessage |
|---|---|---|
| 面向对象 | 网络请求(fetch / XHR) | window 对象之间 |
| 依赖服务器 | 需要服务器响应头配合 | 不需要服务器配置 |
| 用途 | 决定“我能不能拿你的数据” | 决定“我能不能和你说话” |
| 绕过方式 | 前端无法绕过,只能后端配置 | 浏览器原生支持,无配置 |
一句话总结:CORS 管的是能不能请求资源,postMessage 管的是能不能互发消息。两者方向完全不同。
如果你遇到后端跨域配置错误(比如Access-Control-Allow-Origin设置成了前端域名,但漏了Access-Control-Allow-Credentials: true),前端 fetch 会报 CORS 错误,这种情况你去找postMessage没有任何用处,问题出在服务端响应头。
6.3 关于 DataEase 社区版禁止 iframe 嵌入的边界
我之前做过一个项目,想用 iframe 把 DataEase 的报表嵌入到企业门户里,结果发现 DataEase 社区版通过响应头限制了不允许被 iframe 嵌入。这是服务端通过X-Frame-Options或 CSP 的frame-ancestors指令直接拒绝的,和跨域postMessage无关。
遇到这种“嵌不进去”的情况,常规排查思路是:
- 看响应头里有没有
X-Frame-Options: DENY / SAMEORIGIN,或者 CSP 里有没有frame-ancestors指令; - 如果服务端设置了这个限制,前端没有任何办法绕过。这是浏览器基于安全策略做的强制限制,不能靠前端 hack;
- DataEase 商业版或特定版本可能开放嵌入白名单,或者通过它的 API 取数据后自己渲染,而不是直接 iframe。
这个案例告诉你:postMessage只管“嵌进去之后怎么通信”,但“能不能嵌进去”是由服务端响应头和浏览器安全策略说了算的。这两件事要分开排查。
6.4 主页面可以调用 iframe 里的函数吗
这也是高频问题,分两种情况:
- 同源:可以,直接
iframe.contentWindow.someFunction(),或者拿到iframe.contentDocument之后操作 DOM; - 跨域:不行,直接访问会抛
SecurityError。唯一可行的方式就是通过postMessage发送指令,iframe 内部监听后调用自己的函数。
即使同源,我也推荐用postMessage封装一层接口,而不是直接暴露内部函数。原因很简单:直接暴露函数会让父页面和子页面耦合很深——父页面要知道子页面的内部函数名、参数结构、返回方式,子页面一重构就崩。用消息协议可以做到接口稳定,子页面内部怎么实现完全自由。
我在实际项目里用postMessage做了快 5 年,最大的感受是:这个 API 本身极简,五分钟就能学会,难的是把通信双方的组织方式、安全边界和异常处理设计清楚。如果你也正在做类似功能,建议先把四件事想明白再动手:校验event.origin、管理消息 ID、做好 ready 握手、处理 iframe 生命周期。把这四件事做好了,剩下的业务逻辑都只是往这套框架里填代码而已。