1. 同源策略下的跨域困局:为什么必须要用 postMessage
干前端时间久了,几乎每个人都会撞上跨域这堵墙。Ajax 请求被 CORS 拦截、Cookie 带不过去、iframe 里操作父页面直接报错,这一系列问题全是因为浏览器在背后死死守着一条规则——同源策略。所谓同源,指协议、域名、端口三者完全一致,但凡差一个,浏览器就会默认把两个页面当成“外人”对待,彼此之间的数据访问、DOM 操作、网络请求都会受到限制。
在实际项目里,跨域的痛往往不是访问一个后端接口那么简单,而是“页面与页面之间”的通信。我做过一个多租户后台系统,主应用跑在 a.example.com,子应用被嵌入到主应用的 iframe 里,跑在 b.example.com。当时的需求是:用户在子应用里完成某项操作后,要把结果状态实时同步给主应用,同时主应用也要把当前登录用户的 token 下发给子应用。AJAX 能做接口转发,但绕不开后端配合;CORS 能放开跨域请求,但解决不了两个窗口之间的直接互操作。那段时间我试过改 document.domain、试过通过服务端中转轮询,体验都很糟糕。
最后真正把问题解决的,是 postMessage。这玩意从 HTML5 时代就存在了,却直到今天仍有不少前端同事对它停留在“听说过、没用过”的状态。postMessage 提供了一条跨域窗口之间的“合法通道”——能在两个不同源、甚至不同协议(比如 http 和 https)的窗口之间安全地传递消息,而且不需要后端参与,纯前端就能打通。只要你能拿到目标窗口的引用,就可以给它发消息;只要绑定了一个事件监听,就能接收别人发来的消息。
这篇文章我就从底层机制讲起,把 postMessage 的发送、接收、安全校验和真实场景里的坑全部过一遍。不管你是被 iframe 嵌套折磨的后台系统开发者,还是要做多窗口联动的可视化大屏,还是想在 Web Worker 场景里做数据桥接,这篇文章都能帮你少走不少弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. postMessage 核心机制拆解:发送、接收与消息传递的全链路
2.1 发送端:postMessage 的两个关键参数
postMessage 是 window 对象上的一个方法。标准签名是:
javascript复制targetWindow.postMessage(message, targetOrigin, [transfer]);
三个参数里,targetWindow 是你要把消息发往的那个窗口引用。这个引用怎么拿,取决于你的场景:
- 主页面给 iframe 发消息,用
iframeElement.contentWindow; - iframe 给父页面发消息,用
window.parent; - 通过
window.open()打开的新窗口,用window.open()的返回值; - 多窗口场景里,可以用
window.opener拿回打开当前窗口的那个页面引用。
message 参数是要传递的数据。很多人以为只能传字符串,其实不是。现代浏览器里,postMessage 支持结构化克隆算法,可以传普通对象、数组、甚至部分二进制数据,比如 ArrayBuffer。但要注意,被传递的数据是“拷贝”出来的,不是引用传递,所以接收方改了数据,发送方原对象不会受影响。这一点和很多面试题里考的“JS 值传递与引用传递”很像,但 postMessage 走的是更彻底的深拷贝路线——严格来说是结构化克隆,不是 JSON.parse(JSON.stringify()),因为结构化克隆能保留 Date、RegExp、Map、Set 这些复杂类型。
targetOrigin 是第二个参数,也是我最看重的参数。它指定了“只有目标窗口的源匹配这个值时,消息才会被发送出去”。如果传 "*",则不管目标窗口是什么源,消息都会被发送。这里有个关键认知:targetOrigin 不是用来做安全校验的——它只是发送方在发消息前的一个“前置确认”,防止你把消息发到一个恶意页面。真正的安全防线,在接收方。
2.2 接收端:message 事件的四个属性
接收消息靠的是 window.addEventListener("message", handler).事件处理函数会收到一个 MessageEvent 对象,里面有四个属性最常用:
| 属性 | 含义 | 注意事项 |
|---|---|---|
data |
发送方传过来的数据 | 经过结构化克隆后的拷贝,修改它不影响原始对象 |
origin |
发送消息的窗口的源 | 格式是 协议://域名:端口,这是接收方做安全校验的第一道关卡 |
source |
发送消息的窗口引用 | 可以用它来回发消息,实现双向通信 |
lastEventId |
消息编号 | 一般用不到,在 Server-Sent Events 里更常见 |
接收端拿到消息后的第一步操作,绝对应该是验证 origin。我见过太多项目直接在事件回调里处理 data,完全不做任何校验,这是把安全敞口开到了最大。正确姿势是先判断 event.origin 是否在白名单里,然后再决定要不要处理消息。
这里还有一个容易被忽略的细节:origin 属性永远是由浏览器提供的,不能被发送方伪造。这一点是 postMessage 安全模型的核心基石。前端代码再花哨,也不可能在 message 事件里伪造一个假的 origin 出来,所以接收方可以放心拿这个属性做校验。
2.3 消息传递的完整链路
我画过一张图来理解整个消息链路,发消息和收消息共涉及四个环节:
- 发送方调用
postMessage,把数据交给浏览器; - 浏览器对数据进行结构化克隆,生成一份全新的数据副本;
- 浏览器将包含数据、来源 origin、发送窗口 source 的事件,投递到目标窗口的事件队列;
- 目标窗口的 message 事件处理器被触发,完成数据消费。
这条链路里最值得玩味的是第三步。postMessage 是异步的,但它的异步和 setTimeout 又不一样。消息从发送到接收,遵循的是浏览器的事件循环机制,会把 message 事件作为一个宏任务排队执行。你在发送方 postMessage 之后立刻修改原数据,接收方拿到的仍然是发送时刻的数据快照,因为数据在发送那一刻就已经被克隆了。
实际开发中,这种异步特性偶尔会引发一些“时序错觉”——发送方以为消息发出去了,结果接收方还没处理完。要应对这种情况,设计通信协议时最好给每条消息带上一个唯一的消息 ID,接收方处理完后回发一个带有相同 ID 的确认消息。这样发送方可以维护一个“待确认消息表”,超时未确认就重发,整个通信就可靠了很多。这套思路本质上是从 TCP 协议里抄来的,但用在跨窗口通信上非常实用。
3. 安全边界:postMessage 最常见的三个安全漏洞与防护
3.1 忘了校验 origin:把大门敞给了所有人
直接进主题。postMessage 的安全问题里,排名第一的绝对是“接收方不校验 origin”。如果你在代码里写了这么一段:
javascript复制window.addEventListener("message", (event) => {
const data = event.data;
// 直接处理 data,比如修改 DOM、提交表单、改变路由
doSomethingWith(data);
});
那么只要任何页面能把自己的窗口引用传到你的窗口上下文里(比如通过 iframe 嵌套、弹窗等),它就能往你这里发消息,你还会照单全收。这个漏洞的可怕之处在于攻击成本极低——不用绕过任何防火墙,不用拿什么后台权限,只要诱导用户打开一个恶意页面,里面嵌套一个你的页面 iframe,然后恶意页面调用 iframe.contentWindow.postMessage(...),就能把你的前端逻辑调动起来。
防护措施我不用多说,就是加白名单:
javascript复制const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
window.addEventListener("message", (event) => {
if (!ALLOWED_ORIGINS.has(event.origin)) {
return;
}
// 安全了,继续处理
});
注意这里 Set 的用法比数组的 includes 性能好一些,但更重要的是语义表达得清楚:你只处理来自白名单源的消息。
3.2 targetOrigin 传 "*":发送端也成了风险点
发送端的风险没有接收端那么大,但依然存在。如果你在父页面往 iframe 发消息时写了:
javascript复制iframe.contentWindow.postMessage(token, "*");
那就意味着,只要 iframe 的 src 被篡改成了任意第三方页面(比如你的代码允许用户自定义 iframe 地址,或者攻击者在你的页面上注入了修改 iframe src 的脚本),这个第三方页面就能收到你的 token。这属于“裸奔式发送”。
正确的写法一定是显式指定目标源:
javascript复制iframe.contentWindow.postMessage(token, "https://sub.example.com");
如果你想判断一下当前 iframe 的 src 是否在预期域名下,可以先读取 iframe.src 做字符串匹配,或者干脆在 iframe 加载完成后再发送消息。另外一个生产中常用的招是:不要给对方发敏感数据,而是发一个“拉取请求”,让对方向你来要。数据流向是主动拉取而非被动推送,能显著减少敏感信息的暴露面。
3.3 把消息数据直接当命令执行:没有做协议校验
这个问题比前两个更隐蔽。即使你校验了 origin,如果对 event.data 的内容不做验证就直接操作 DOM 或者执行逻辑,依然可能被攻击者利用。举个例子:你写了一个 onmessage 处理器,收到数据后直接把 data.action 作为函数名调用:
javascript复制window.addEventListener("message", (event) => {
if (event.origin !== "https://app.example.com") return;
const fn = window[event.data.action];
if (typeof fn === "function") {
fn(event.data.payload);
}
});
那么攻击者虽然不能伪造 origin,但他只要能找到办法在你的白名单源页面里执行一段 postMessage 调用(比如 XSS 注入、浏览器插件篡改、中间人攻击等),就能通过构造 { action: "someDangerousFunction", payload: ... } 来调用你页面里任何全局函数。这种“命令映射”模式就是典型的协议薄弱环节。
防护思路是:一定要为通信定义一个严格的消息协议,尽量用“白名单分派”而非“动态调用”。比如:
javascript复制const MESSAGE_HANDLERS = {
"SYNC_STATUS": handleSyncStatus,
"UPDATE_TOKEN": handleUpdateToken,
"REQUEST_USER_INFO": handleRequestUserInfo,
};
window.addEventListener("message", (event) => {
if (!ALLOWED_ORIGINS.has(event.origin)) return;
const { type, payload } = event.data || {};
const handler = MESSAGE_HANDLERS[type];
if (!handler) return;
handler(payload, event.source, event.origin);
});
这样攻击者就算能发消息,也只能触发你定义好的有限行为集合,没法利用消息内容做任意函数调用。安全设计的原则永远是“默认拒绝,显式放行”。
3.4 安全模型的本质:同源策略之外的信任边界
聊到这里,我想把 postMessage 的安全模型再拔高一层。整个机制的信任模型可以概括为一句话:origin 是唯一可信的身份凭证,消息内容是永远不可信的用户输入。
浏览器不会帮你去校验消息内容是否合法,它只负责告诉你“这条消息是从哪个源来的”。因此,你的所有安全判断都应该围绕两个环节展开:第一,判断“谁发的”(origin 白名单);第二,判断“该不该处理”(消息协议校验)。前者解决身份认证,后者解决授权控制。这两个环节缺一不可,顺序也不能颠倒。
实际项目里,我还见过有人试图用加密来加固 postMessage 通信。比如发送前用 RSA 加密 payload,接收后再解密。坦白讲,如果你已经正确校验了 origin,且你的页面本身没有其他 XSS 漏洞,那么 HTTPS 传输已经保证了内容在传输链路中的机密性,二次加密属于重复劳动,收益非常有限,还会消耗大量 CPU。真正该花精力的是把页面自身的 XSS 漏洞堵住——毕竟 postMessage 安全的最大敌人不是监听者,而是你页面里不小心引入的一段恶意脚本。
4. 实战中的踩坑实录:消息时序、重复绑定与野指针问题
4.1 iframe 还没加载完就 postMessage:消息丢了
这是我第一次做 iframe 跨域通信时踩的坑。代码逻辑非常简单:主应用在 iframe onload 后马上向子应用发送初始化消息。我预期子应用收到了就会回一条欢迎消息,结果页面打开了,等了三秒,子应用毫无反应。
排查了半天,最后发现 onload 只代表 iframe 的文档加载完成了,不代表子应用里的 React 应用已经完成了 JS 的初始化。子应用的 message 事件监听器是在 React 组件 mount 后才绑定的,而我的 postMessage 在 onload 那一刻就已经发出去了。事件发出去的时候,子应用还没监听,这消息就石沉大海了。
解决办法有几个层次。最简单的方案是用“握手协议”:子应用里绑定好消息监听后,主动向父页面发送一条 APP_READY 消息,父页面收到后再发真正的初始化数据。这个方案虽然会多一次来回,但在高可靠场景里是最稳的。
javascript复制// 子应用中
useEffect(() => {
const handleReady = () => {
window.parent.postMessage(
{ type: "APP_READY", payload: { version: "1.0.0" } },
"https://app.example.com"
);
};
handleReady();
}, []);
// 父应用中
window.addEventListener("message", (event) => {
if (event.origin !== "https://sub.example.com") return;
const { type, payload } = event.data || {};
if (type === "APP_READY") {
iframe.contentWindow.postMessage(
{ type: "INIT_DATA", payload: { token } },
"https://sub.example.com"
);
}
});
第二个层次是“消息重发+自动去重”。父页面发出消息后,如果在一段时间内没收到确认,就定时重发,直到接收方响应为止。结合前面提到的消息 ID 机制,确保接收方即使收到重复消息也不会重复处理。这种方法对弱网环境尤其有效,但复杂度更高一些,一般用在高价值消息场景。
4.2 同一个 message 监听器绑了两次:回调被触发两遍
这个坑常见于 SPA 项目。父页面里动态加载了同一个模块,模块内部对 window.addEventListener("message", handler) 执行了多次,结果就是一条消息被 handler 处理了两遍。
我遇到过的最离谱的场景是:用 Vue Router 切换路由时,一个全局组件被销毁又重新挂载,而组件 mounted 钩子里的 addEventListener 没有在 beforeUnmount 里 remove,于是每次切路由都多一个监听器。用户点一次按钮,页面上出现两条重复记录。
这类问题的根治方案是把“事件绑定的生命周期管理”做好。在 Vue 的 setup 或者 React 的 useEffect 里绑定监听器,同时在卸载阶段务必移除:
javascript复制useEffect(() => {
const handler = (event) => {
// ...
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
另外一个实践技巧是:不要把 message 监听器注册在组件里,而是提升为一个模块级的单例。全局只绑定一次监听,然后用一个事件分发器(比如 EventEmitter)把消息分发给各个业务模块。这样既能避免重复监听,也方便统一做 origin 校验和消息协议校验。我在生产项目里就是这么做的,效果非常好。
4.3 window.open 返回的引用变成 null:新窗口被拦截的坑
window.open 场景下的 postMessage 有一个常见陷阱:浏览器弹窗拦截机制会把 window.open 的返回值设为 null。如果你的代码在用户点击按钮的 setTimeout 回调里执行 window.open,或者在一个复杂的异步链路末尾执行,浏览器大概率会判定这是“非用户主动行为”,直接把弹窗拦下来,你的 newWindow 就是 null。接下来你再调 newWindow.postMessage(...),直接抛 TypeError。
我踩坑时是在“点击按钮后先发请求,拿到数据再 open 新窗口并传数据”的场景里。后面改成:点击按钮后立刻 open 一个空白页并保存引用,等异步数据回来后再往这个已打开的窗口里 postMessage,数据到达后由新窗口页面自己渲染内容。这种做法既能躲开弹窗拦截,也能保证通信链路是通的。
javascript复制let detailWindow = null;
function openDetailWindow() {
detailWindow = window.open("", "_blank"); // 先开空白窗口
if (!detailWindow) {
// 提示用户允许弹窗
return;
}
detailWindow.document.write("<h1>加载中...</h1>");
}
async function fetchDataAndSend() {
const data = await fetch("/api/detail").then((r) => r.json());
detailWindow.postMessage({ type: "DETAIL_DATA", payload: data }, "https://detail.example.com");
}
这里还有个细节:新开的窗口如果没有同源的话,你是没法直接写它 DOM 的。我上面用了 document.write,是因为 window.open("", "_blank") 打开的空页面默认是 about:blank,通常视为同源,才允许写入。更优雅的做法是让新窗口打开你业务域下的一个真实页面,页面加载后主动向 opener 发送 APP_READY,然后 opener 再 postMessage 传数据。链路就回到了前面说的握手协议。
4.4 单页应用路由切换时 source 引用已失效
最后一个大坑,是关于 event.source 的生命周期。接收方拿到一条消息,准备用 event.source.postMessage 回消息时,如果发送方窗口已经被关闭或 iframe 已被移除,那么 event.source 就成了一个“悬空引用”,调用 postMessage 不会报错,但消息永远不会被送达。
还有一种情况更诡异:iframe 的 src 被替换成了一个新的页面,但 contentWindow 引用本身不会立刻变成 null。此时你往这个旧引用上 postMessage,消息会被发到 iframe 里的新页面去,如果新页面没有做 origin 校验,可能就白收了一条无用消息;如果新页面恰好也绑了监听器,甚至可能造成数据污染。
我的解决办法是:在每次通信前都重新获取一次窗口引用,而不是把 contentWindow 缓存起来长期使用。对高频通信的 iframe,可以建立一个“连接状态管理”,通过心跳消息监测 iframe 是否还活着,超过 N 秒没响应就主动清理旧引用并重新建立连接。这套面向连接的设计思路虽然听着重,但在复杂多窗口场景里非常值得。
5. 从 postMessage 到更广阔的消息传递:与其它跨域方案的对比
5.1 为什么不能只用 document.domain 或者 CORS?
聊完 postMessage 的细节,我把跨域通信方案横向铺开对比一下。很多项目最初的跨域方案是 document.domain。这个方案的历史比 postMessage 更久:只要两个页面的主域名一致,把各自的 document.domain 设置成同一个值,就可以解除同源限制,互相操作 DOM。
但这个方案有两个硬伤。第一,它要求两个页面必须“同主域”,比如 a.example.com 和 b.example.com 可以,但 a.example.com 和 b.other.com 不行。第二,也是最致命的,设置 document.domain 会“降级”你的同源安全级别——一旦你把域名放宽到 example.com,那 example.com 下的所有子域页面都可以来访问你,这相当于把安全域主动扩大了一圈。相比之下,postMessage 的 origin 白名单是精确匹配到协议、域名、端口的,粒度细得多,而且不会降低任何同源安全限制。
CORS 是另一个常见方案,但它解决的是“浏览器跨域发请求”的问题,属于 HTTP 层。如果你想在页面与页面之间直接传递数据,CORS 完全帮不上忙,因为它不管窗口间的通信。如果你需要跨域调接口且后端由你控制,CORS 很合适;如果你做的是窗口间通信,postMessage 就是正解。
5.2 Web Worker 中的 postMessage:同一种 API 的不同用法
postMessage 不仅存在于 window 之间,Web Worker 里也有。Worker 场景下,主线程通过 worker.postMessage() 向 Worker 发消息,Worker 里通过 self.postMessage() 往主线程回消息。消息机制与窗口间通信本质相同——都是结构化克隆、异步传递、message 事件接收。
但 Worker 场景里没有“跨域”一说。Worker 脚本本身受同源策略限制,不能随便加载其他域名的 JS。所以 Worker 里的 postMessage 主要解决的是“线程间通信”,而不是“跨源通信”。不过,两者的数据传递方式是完全一致的,理解了 window 场景的 postMessage,Worker 场景基本是零成本迁移。
5.3 数据量边界:超大消息的性能影响
postMessage 虽然方便,但不是无脑用的。结构化克隆一个很大的对象(比如含有上百万条数据的数组)会带来明显的性能开销。我测试过一个包含 50 万条记录的对象,postMessage 到 iframe 里耗时接近 200ms,而且这个耗时是阻塞发送方主线程的——发送完消息之前,页面滚动都会卡顿。
所以,高频、大体积的数据不适合直接塞进 postMessage。更合理的做法是只传数据的元信息(比如数据 ID、分页参数),接收方自己去后端拉取完整数据。或者用 transferable objects 机制,把 ArrayBuffer 的所有权转移给接收方,转移后发送方的 ArrayBuffer 会被 detach 掉,无法再访问。这个机制避免了结构化克隆的拷贝开销,在大二进制数据传输场景(比如文件上传预览、图片处理)里有明显优势:
javascript复制// 发送方
const buffer = new ArrayBuffer(1024 * 1024);
worker.postMessage({ type: "DATA", buffer }, [buffer]); // 转移所有权
// 发送方这里的 buffer 已经不可用了
需要注意,transfer 列表只能放 transferable 对象(ArrayBuffer、MessagePort 等),普通对象不能转移,只能克隆。
5.4 前端通信方案的选型决策表
最后给一张决策表,方便你按项目场景快速选型:
| 需求场景 | 推荐方案 | 原因 |
|---|---|---|
| 两个不同源页面/iframe 间通信 | postMessage | 原生支持跨源、精确白名单、不需要后端配合 |
| 同主域两个子域页面互操作 DOM | postMessage 优先 | document.domain 会扩大安全面,不推荐 |
| 跨域调后端接口 | CORS / JSONP | 这是 HTTP 请求层的事,postMessage 管不了 |
| 主线程与 Web Worker 间通信 | postMessage(Worker 版) | 标准线程通信机制,性能优于自定义 EventBus |
| 同源页面间的复杂状态同步 | 可以选 BroadcastChannel | 广播语义更清晰,但跨源场景下还是得回 postMessage |
| 兼容极其古老的浏览器(IE9 及以下) | 退回到 window.name / hash 变更 | 老方案不稳定,建议直接引导用户升级浏览器 |
现在前端生态越发复杂,微前端、多窗口工作台、虹软人脸识别跨域适配这些场景都在用 postMessage 做信令通道。熟练使用它是现代前端的必备技能,但比 API 本身更值钱的是你脑子里那张信任边界和生命周期的地图——知道消息从哪来、能去哪、谁可信、谁要防。
我在实际项目里的体会是,postMessage 用起来不难,难的是把它放到一个系统的通信架构里去思考。你先定义好协议(消息类型、字段约束)、定义好白名单(哪些源允许通信)、定义好生命周期(连接建立、心跳、销毁),然后再去写那些 addEventListener 和 postMessage,整体的稳定性会好非常多。如果你现在正被 iframe 传数据、多窗口同步或者 Worker 通信折磨,不妨回头检查一下:你的接收端做了 origin 校验吗?你的监听器清理干净了吗?你的消息协议是白名单派发还是动态调用?把这三个问题想清楚,你踩过的坑基本都能填上。
