接手过跨域页面通信需求的人大概率都经历过这样的场景页面上嵌了一个第三方 iframe需要告诉它用户已登录uid 是 123或者反过来子页面要通知父页面订单状态变了。传统的 Cookie、URL 参数在这些场景下要么不安全要么绕太远而 window.postMessage 就是浏览器原生提供的、专门解决跨源通信问题的接口。这篇文章我会把 postMessage 的完整用法、参数细节、transferable 接口原理以及我在实际项目中踩过的坑一次性讲清楚。这篇文章适合谁前端开发者、正在做微前端方案选型的人、用 Web Worker 做性能优化的同学还有所有被跨域通信折磨过的人。不需要多深的基础但我默认你至少写过一点 JavaScript能看懂事件监听和回调就行。我会尽量把一个偏底层的 API 讲成人话保证你看完能直接上手也能避开那些文档里不会写明的雷区。1. 先搞懂 postMessage 到底解决什么问题1.1 同源策略的边界在哪浏览器的同源策略是个老生常谈的话题协议、域名、端口三者一致才算同源。同源策略限制了三个核心能力——DOM 访问、Cookie 与 Storage 读取、以及网络请求的跨源限制。也就是说一个运行在https://a.example.com的页面默认情况下是碰不到https://b.example.com里的任何 DOM 的也读不到对方的 localStorage更没法通过XMLHttpRequest直接去请求对方的数据。这个策略在保护用户数据安全方面立了大功但也带来了一个现实问题业务上确实存在合法的跨源通信需求。典型的例子就是支付页面——主站页面嵌入了第三方支付平台提供的 iframe用户输入银行卡信息支付完成后 iframe 需要告诉主站支付成功订单号是这个。这种场景如果全靠 URL 参数传递信息会暴露在地址栏和浏览器历史记录里如果用 Cookie又面临 SameSite 和跨域读写权限的复杂限制。于是浏览器提供了window.postMessage这个专用通道让两个不同源的窗口可以安全地交换消息。1.2 postMessage 的核心价值与适用场景postMessage最核心的价值是它让跨源通信变成了一条有门禁的通道。消息虽然是明文发送的但接收方可以通过event.origin校验发送方的身份发送方也可以通过targetOrigin限定消息只发给指定源。相比直接操作 DOM 或者 URL 传参这条通道要安全得多而且是浏览器原生支持的不需要任何第三方库。就我接触过的项目postMessage 最常见的三个场景是第一父页面与 iframe 互相通信比如主站给嵌入式报表 iframe 传筛选条件、iframe 把操作结果回传给主站第二主线程与 Web Worker 之间传递大体积数据比如图像处理、音视频解码这类耗时任务第三页面与window.open弹出的子窗口之间同步状态比如 OAuth 登录弹窗登录完成后通知父页面刷新用户信息。后面的章节我会逐一给出可用的示例代码。2. 参数逐个拆解message、targetOrigin、transfer2.1 message 参数能传什么不能传什么postMessage的完整签名是targetWindow.postMessage(message, targetOrigin, transfer)。第一个参数message是真正要传递的数据它可以是任意结构化可克隆的值。这句话看上去轻描淡写实际上结构化可克隆这五个字划定了严格的数据边界。可以用message传递的数据包括原始类型string、number、boolean、null、undefined、bigint、数组和普通对象、Date、RegExp、Blob、File、ArrayBuffer 以及各类 TypedArray 视图、ImageBitmap、ImageData、Map、Set、Error 对象现代浏览器支持等。但是函数、DOM 元素、Symbol、自定义类的实例原型链会丢失传过去变成普通对象、WeakMap/WeakSet、Promise 这些都无法通过结构化克隆算法处理。如果你传了不允许的数据浏览器会直接抛DataCloneError。这里有一个很容易踩的点很多人以为传一个普通对象就万事大吉但对象内部如果嵌套了函数或者 DOM 引用整个序列化就会失败。我见过一个同学把包含了onClick回调的对象直接丢进postMessage结果页面报错半天没定位到原因。所以发送前最好心里过一遍这个数据里有没有函数、有没有 DOM 节点引用、有没有类实例。如果有要么改造数据结构要么走别的通信方案。2.2 targetOrigin 参数安全的第一道闸门第二个参数targetOrigin指定了消息允许发送到哪个源它有以下几种写法明确指定源的字符串比如https://example.com写/表示与当前窗口同源以及通配符*表示不限制目标源。我强烈建议在能明确目标源的情况下永远不要用*。原因很简单*意味着任何其他窗口都能收到这份消息如果消息里包含了用户标识、业务数据就等于把这些信息广播到了所有同浏览器上下文里能访问到该窗口的对象中。安全上最稳妥的做法是显式写出目标源的协议、域名和端口这样即使消息被恶意页面截获目标窗口在逻辑上也会拒绝处理来源不明的消息。另外注意targetOrigin匹配的是目标窗口的源不是发送方的源。比如父页面在https://a.com给https://b.com的 iframe 发消息targetOrigin应写https://b.com。有些同学会习惯性写自己的域名消息发出去对面收不到排查半天才发现是这里搞反了。2.3 transfer 参数不仅传数据还传所有权第三个参数transfer是最容易被忽略、但性能收益最明显的一个。它是一个 Transferable 对象的数组用来把某些特殊类型的对象的所有权从发送方转移给接收方而不是复制一份。所谓所有权转移你可以类比成你把一把钥匙亲手递给别人交出去之后你自己手里就没有这把钥匙了不能再使用它。具体到代码层面最典型的例子是ArrayBuffer。当你用worker.postMessage(buffer, [buffer])发送一个 ArrayBuffer 时发送方的buffer.byteLength会变成 0实际数据被移交给了接收方整个过程零拷贝。相比之下如果不传transfer数组浏览器会通过结构化克隆把整个 ArrayBuffer 的数据复制一份数据量越大拷贝开销越明显。不过 transfer 并不是所有场景都适用它要求对象必须实现 Transferable 接口。目前在浏览器里支持转移的对象主要有ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas以及部分浏览器支持的 ReadableStream、WritableStream、TransformStream。普通对象、字符串、Blob 都不在 transfer 的范围内你就算把普通对象写进transfer数组浏览器也会忽略它。3. Transferable 接口与结构化克隆算法深挖3.1 结构化克隆到底做了什么要理解 transferable必须先搞清楚结构化克隆算法Structured Clone Algorithm怎么工作。它是 HTML 标准定义的一套序列化机制比 JSON.stringify 强大得多JSON 只能处理基本数据类型、数组和普通对象遇到 Date 会变成字符串、遇到 Map 和 Set 会变成空对象、遇到 ArrayBuffer 干脆变成{}。而结构化克隆能够递归地保留这些类型的内在结构Date 传过去还是 DateMap 传过去还是 MapTypedArray 的数据和视图信息都能完整保留。结构化克隆在实现上走的是拷贝路线也就是发送方和接收方各持有一份独立的数据。对于小数据量这种方式完全够用代码也更简单但是当你需要传递一个 50MB 的 ArrayBuffer 时一次完整的拷贝就意味着要额外分配 50MB 内存加上序列化和反序列化的时间主线程会出现肉眼可见的卡顿。Transferable 就是为了解决这个问题而存在的它直接跳过拷贝过程把底层内存块的所有权交给接收方。需要特别注意的是结构化克隆对类实例并不友好。一个带有原型的自定义类对象经过克隆后接收方拿到的是一个普通对象原型链上的方法全部丢失。这一点在设计通信协议时要提前想到最好只在消息里传纯数据不要传会干活的对象逻辑留在接收端自己处理。3.2 常见 Transferable 对象盘点目前实际工程里最常用的 Transferable 对象是ArrayBuffer、MessagePort、ImageBitmap和OffscreenCanvas。ArrayBuffer是最典型的一个主要用在 Web Worker 场景。比如主线程读取了一个大文件的 ArrayBuffer要交给 Worker 做哈希计算用transfer转移可以避免一次大块内存拷贝。MessagePort是MessageChannel的产物当你用new MessageChannel()创建一对端口后可以通过 transfer 把一个端口交给另一个执行上下文从而建立两条上下文之间的直接通信管道。ImageBitmap和OffscreenCanvas则更多用在图像处理场景你可以把解码后的图像位图直接转给 Worker 做滤镜处理或者是把 WebGL 的绘制结果转移出去。下表是我整理的常见类型在 postMessage 中是否支持结构化克隆、是否支持 transfer数据类型结构化克隆transfer 转移说明原始类型string/number/boolean支持不支持按值传递普通对象 / 数组支持不支持深拷贝语义Date / RegExp / Map / Set支持不支持保留类型信息Blob / File / FileList支持不支持内部数据引用复制ArrayBuffer支持支持最常用的可转移对象TypedArrayInt8Array 等支持支持转移其底层 buffer需取.buffer放入 transferImageBitmap支持支持图像数据处理利器MessagePort支持支持建立独立通信管道的核心OffscreenCanvas支持支持图形绘制转移函数 / Symbol不支持不支持直接抛 DataCloneErrorDOM 元素不支持不支持无法跨上下文传递3.3 什么时候该用 transfer什么时候不该用transfer 并不是万能的它有一个必须权衡的代价数据一旦转移发送方就彻底失去了对这块内存的访问权。如果你发送完 ArrayBuffer 之后还需要继续读取原始数据做其他计算那就不能使用 transfer否则会拿到一个 byteLength 为 0 的空壳。我的建议是数据量小于 1MB 时没有必要用 transfer直接走结构化克隆即可代码更简洁也不会有明显的性能问题数据量达到几十 MB 甚至上百 MB或者你明确知道接收方处理完数据后发送方不再需要原始数据时才值得付出所有权移交的代价换取零拷贝的性能收益。另外一个实用技巧是如果你使用的是 TypedArray 视图transfer 时需要传入typedArray.buffer而不是视图本身接收方再根据自己的需求重新构造视图即可。4. 三大典型场景的完整示例4.1 父页面与 iframe 的通信这是 postMessage 用得最多的场景。假设主站页面运行在https://app.example.com内嵌一个报表 iframe地址是https://report.example.com。主站需要把当前用户的筛选条件发给 iframeiframe 加载完数据后通知主站渲染完成。父页面的发送代码如下核心点在于等 iframe 的load事件触发后再发送避免消息发出去时子页面还没准备好监听器const iframe document.getElementById(reportFrame); iframe.addEventListener(load, function () { iframe.contentWindow.postMessage({ type: FILTER_CHANGE, payload: { dateRange: [2025-01-01, 2025-03-31], region: east, userId: 10086 } }, https://report.example.com); });iframe 内部的接收代码必须做两件事校验event.origin是不是可信来源校验event.data.type是不是自己关心的消息类型const TRUSTED_ORIGIN https://app.example.com; window.addEventListener(message, function (event) { if (event.origin ! TRUSTED_ORIGIN) { return; // 来源不可信直接丢弃 } const data event.data; if (!data || typeof data ! object || data.type ! FILTER_CHANGE) { return; // 结构不符合预期丢弃 } renderReport(data.payload); });子页面处理完数据后可以用event.source给父页面回消息。event.source是发起消息的窗口对象的引用回传时配合event.origin使用可以保证消息准确回到来源窗口function notifyParent() { if (window.parent window) { return; // 当前不是 iframe没有父窗口 } window.parent.postMessage({ type: REPORT_READY, payload: { cost: 42.5 } }, https://app.example.com); }4.2 主线程与 Web Worker 的数据交互Web Worker 场景下postMessage 是唯一的通信手段而且transfer参数在这里能发挥最大价值。假设我们要在一个 Worker 里对大文件做 SHA-256 哈希计算主线程读取到了文件的 ArrayBuffer。主线程代码const worker new Worker(/hash-worker.js); async function hashFile(file) { const buffer await file.arrayBuffer(); // 注意transfer 数组传入的是 buffer worker.postMessage({ type: HASH, buffer: buffer }, [buffer]); // 到这里原 buffer 已经被转移buffer.byteLength 为 0 } worker.addEventListener(message, function (event) { if (event.data.type HASH_DONE) { console.log(SHA-256:, event.data.hash); } });Worker 内部代码self.addEventListener(message, function (event) { const data event.data; if (data.type ! HASH) return; const buffer data.buffer; const hash computeSha256(new Uint8Array(buffer)); self.postMessage({ type: HASH_DONE, hash: hash }); });这里最关键的细节是传入transfer数组的是buffer本身而 Worker 收到后拿到的event.data.buffer就是那片被转移过来的内存。如果你不想转移只希望拷贝一份那就不传第三个参数直接worker.postMessage({ buffer: buffer })即可。前者快但斩断了发送方的访问权后者安全但多一次完整拷贝根据业务取舍。4.3 页面与 window.open 弹出的窗口通信这种模式常见于第三方登录。主站打开一个登录弹窗登录成功后弹窗要把结果通知主站。主站需要保存通过window.open返回的窗口引用然后用这个引用发送消息。主站代码const authWindow window.open( https://auth.example.com/login, authWindow, width480,height640 ); window.addEventListener(message, function (event) { if (event.origin ! https://auth.example.com) return; if (event.data.type LOGIN_SUCCESS) { localStorage.setItem(token, event.data.token); authWindow.close(); } });弹窗页面里的通知代码// 弹窗登录成功后 if (window.opener) { window.opener.postMessage({ type: LOGIN_SUCCESS, token: eyJhbGciOi... }, https://app.example.com); }需要注意window.open返回的引用在跨域情况下能力非常有限你唯一能安全使用的方法就是postMessage。别想着通过这个引用去访问子窗口的 DOM 或者变量跨域情况下这些访问会被浏览器直接拒绝。反过来弹窗里也别用window.opener去碰父页面的 DOM只把它当作一个消息投递目标即可。5. 使用注意点与安全实战5.1 origin 校验不是可选项我不知道强调多少遍才够接收端的event.origin校验永远是第一优先级的检查项。很多人写 demo 的时候图省事直接if (event.data.type xxx)就往下走这在生产环境里是巨大的安全漏洞。任何页面只要拿到了你 window 对象的引用都可以向它 postMessage不校验来源等于把一扇门敞开给所有访客。校验时要注意一个细节event.origin是字符串比较时最好精确到完整的源即协议 域名 端口。默认端口情况下 URL 里的 443 和 80 会被省略event.origin里也不会带端口直接写https://example.com即可。如果你在内网调试环境里用的端口不稳定可以把可信来源维护成一个数组统一遍历校验而不是写死在 if 判断里。5.2 event.source 的妙用与校验event.source是发送消息的窗口引用。在 iframe 场景中它通常是iframe.contentWindow在弹窗场景中它是window.opener。回消息时用event.source.postMessage(...)比绕过引用链去手动找窗口更可靠因为它是事件自带的、一定指向发送方。不过event.source同样需要谨慎使用。如果你没有校验event.origin直接相信event.source发来的任意消息那么攻击者可以伪造一个可信来源无关的窗口来向你注入恶意指令。正确的做法永远是先校验 origin再处理数据最后才考虑用 event.source 回消息。另外某些浏览器环境下event.source可能是 null比如消息来自同一个窗口的内部上下文使用前做个空值判断更稳妥。5.3 事件监听的注册与解绑接收消息用的是标准事件监听器所以同样存在内存泄漏的风险。特别是在单页应用里如果每次进入页面都注册一个新的message监听器而不解绑消息会被重复处理多次表现为回调执行了 N 次数据被重复提交。我的习惯是给处理函数命名并在组件卸载或页面隐藏时移除监听。React 里典型写法如下useEffect(function () { function handleMessage(event) { if (event.origin ! TRUSTED_ORIGIN) return; // 处理业务逻辑 } window.addEventListener(message, handleMessage); return function () { window.removeEventListener(message, handleMessage); }; }, []);Vue 里则是在onMounted注册、onUnmounted移除。如果你用的是 jQuery 时代的页面脚本同样记得在页面关闭或模块销毁时主动off。监听器重复注册这个问题在开发环境很难发现一到生产环境用户频繁切换路由后问题就爆发了排查起来还挺费劲。5.4 别用 postMessage 传敏感数据最后这条安全建议很多人会忽略postMessage 的消息内容本身是明文可见的任何能访问该窗口上下文的人都能通过 devtools 断点或者覆盖监听的方式看到消息内容。虽然跨源页面不能直接读取你的窗口内部数据结构但消息在传递过程中是经过序列化的不要在里面放密码、短信验证码、明文 token 这类敏感信息。如果确实需要传递认证类数据正确的做法是传递一个短期有效的授权码或一次性凭证接收方拿到后通过后端接口换取真正的访问凭证。换句话说postMessage 只负责说句话不要负责递钥匙。另外接收端拿到消息数据后如果数据里带有 URL千万不要直接拼进 innerHTML 或当作跳转地址先做 schema 和域名白名单校验防止把 postMessage 变成 XSS 的跳板。6. 常见问题与排查技巧实录6.1 消息发出去了对面收不到这种情况十个里有八个是targetOrigin写错了。如果目标窗口实际运行在https://b.example.com你却在targetOrigin里写了https://a.example.com自己的域名浏览器会直接拒绝发送消息不会抛任何错误。另一个常见原因是 iframe 还没加载完成就发送消息子页面监听器还没注册消息凭空丢了。排查思路很固定先在发送端把targetOrigin改成*试试只用于本地调试别上生产看能否收到能收到说明是 origin 不匹配对照目标窗口地址修改即可。然后确认发送时机iframe 场景务必在load事件之后再发弹窗场景在open之后也不要立即发送等弹窗加载到注册监听器的脚本后再投递必要时可以做一次握手确认。6.2 传过来的数据变成 undefined 或者抛 DataCloneError如果你在 postMessage 时抛了DataCloneError几乎可以确定消息里包含了结构化克隆无法处理的值比如函数、Symbol 或者 DOM 节点。如果你收到的数据是 undefined一种可能是你传的对象里嵌套了不可克隆的属性被静默丢弃了另一种可能是接收端解构时属性名对不上——两个页面各自维护了一份通信协议定义字段名大小写不一致、多了个下划线之类的问题在纯前端联调里特别常见。我的建议是把通信消息的 type 和 payload 字段名形成一份固定的协议文档收发两端严格按文档来在发送前对 message 做一次JSON.parse(JSON.stringify())预演也能帮你提前发现问题虽然它不能完全等价于结构化克隆但至少能暴露大部分序列化异常。接收端一律做防御式判断if (!event.data || typeof event.data.type ! string) return;。6.3 transfer 之后原对象 detached 了这是很多第一次用 transfer 参数的人会踩的坑。发送方把一个 ArrayBuffer 塞进 transfer 数组发出去之后回头想读arrayBuffer.byteLength发现变成 0 了或者访问 Uint8Array 视图时抛错提示 buffer 已被 detached。这不是 bug而是 transfer 的设计预期所有权交出后原上下文对该对象的访问就是非法的。解决办法很简单在调用 postMessage 之前把你还需要用的数据先复制一份保留下来或者调整业务逻辑保证发送之后不再碰原始数据。如果你发现自己在发送后还要频繁读取原对象那说明这个场景不适合用 transfer退回结构化克隆就好多一次拷贝换来得心应手这笔账是划算的。6.4 postMessage 的性能与替代方案选型postMessage 更适合低频、控制类的消息比如刷新数据切换主题登出这类指令。如果需要在两个同源窗口之间高频同步大量状态比如多标签页实时同步购物车数据postMessage 也能做但需要考虑替代方案同源下的BroadcastChannel专门用于同源上下文之间的广播通信API 更简单语义也更清晰同一个页面内部多个模块之间通信MessageChannel可以建立私有管道避免全局监听器的噪声而同源多标签页的场景localStorage的storage事件也是个轻量选择。我个人的选型经验是跨源场景没得选只能用 postMessage同源场景优先看 BroadcastChannel需要一对一精准通信时用 MessageChannel只是简单的跨标签页通知用 localStorage 的 storage 事件反而最省事。下表把这个对比整理了出来方便你按需取用通信方案跨源支持通信方向典型场景window.postMessage支持窗口间单向或双向iframe、弹窗、WorkerBroadcastChannel不支持同源多上下文广播多标签页状态同步MessageChannel支持一对一私有管道模块间定向通信localStorage storage 事件不支持同源多标签页轻量通知、缓存同步CustomEvent视页面而定同页面内组件间解耦通信单独说一下 BroadcastChannel 和 postMessage 的一个关键差异BroadcastChannel 只能在同源上下文之间广播不需要校验 origin因为同源策略已经帮你做了隔离而 postMessage 天生就是为跨源设计的。如果你在两个同源页面之间通信用 BroadcastChannel 可以减少很多安全校验代码但如果你有哪怕一个子页面是跨域的那还是老老实实用 postMessage。最后再分享一个我在项目里反复验证过的经验通信协议设计得越窄越不容易出错。不要图方便把整个业务对象一股脑丢进 postMessage尽量只传type加payload这种扁平结构字段取名用稳定的英文常量接收端用白名单校验 type。这样做了之后消息链路的可维护性会好非常多后来接手的人看代码也不会一头雾水。前一阵子我们排查一个线上偶发问题就是靠协议白名单五分钟锁定了异常来源而旁边的同事还在层层打断点。这种小习惯长期坚持下来比任何技巧都管用。
