
第一次正式接触 window.postMessage是在做在线协作编辑器的时候。当时主页面和全屏预览 iframe 部署在不同子域名下顶部工具栏要把光标位置同步给预览区直接操作 iframe 内 DOM 被同源策略拦得死死的只能在网上翻方案。折腾到半夜最终靠 window.postMessage 把功能跑通。但第二天就收到同事反馈部分浏览器下消息偶尔丢失还有一次传出去的 ArrayBuffer 在接收端变成了空对象。回头看文档才发现这个 API 表面上只有一行真正用的时候参数、对象转移、安全校验都有一堆细节。这篇不会只甩一个示例我会把 postMessage 的参数拆开讲清楚尤其是 transferable 接口的转移机制再把我实际踩过的坑和可复现的代码片段整理出来适合正在做 iframe 通信、多窗口同步、Web Worker 大数据传输或者被跨域问题折磨过的前端开发者参考。1. 为什么要跨窗口通信同源策略和 postMessage 的设计意图1.1 旧方案为什么都绕不开同源限制浏览器默认不允许一个页面直接读取另一个窗口的 DOM、Cookie 或者 localStorage这是最基本的安全边界。但父子窗口、弹窗和 opener、iframe 与外部页面之间经常要互通状态早期只能靠 location.hash 轮询、设置 document.domain 降级到同一主域或者让后端中转。这些方案要么有历史包袱要么只对同主域生效遇到完全跨域的第三方嵌入基本无能为力。location.hash 轮询的做法是子窗口修改自己的 hash父窗口通过定时器读取并解析反过来再改父窗口 hash 给子窗口响应。这种方式能跑通但只适合低频率、轻量级的消息而且会把状态写进 URL容易污染浏览器历史传不了二进制数据。document.domain 更麻烦它把两个页面的 origin 强制降级本质是削弱安全策略一旦跨到完全不同的域名就失效了。1.2 postMessage 的出现解决了什么postMessage 是浏览器提供的一套显式消息通道发送方和接收方不必受同源限制只要双方都通过 message 事件收发就能完成跨窗口、跨上下文的数据交换。它不像 JSONP 那样依赖 script 标签也不需要服务端配合两个页面各写一段 JS 就能通信。它覆盖的上下文范围比很多人想得更广window 与 iframe、window.open 打开的窗口、Web Worker、SharedWorker、Service Worker以及 MessagePort 之间都可以用 postMessage 传数据。指定 targetOrigin 之后浏览器只会把消息投递给符合源条件的接收方接收方依然要自己校验 event.origin。这个机制就像拿着挂牌的信使按门铃只有门牌号对上了才进门但作为主人你也得先确认门外的人是谁。1.3 它适合什么不适合什么适合的场景包括主应用嵌入第三方 iframe 时的状态同步、跨域单点登录后的 tab 间消息、编辑器与预览页的双向通信、把耗时任务丢给 Worker 再取回结果。不适合用于高频、超大数据量的实时同步比如每秒几十次的鼠标轨迹配合大体积 JSON这会让结构化克隆成为性能瓶颈也不适合替代后端传输毕竟消息只存在于客户端内存刷新页面就丢了。实际项目里我一般会先问一个问题接收方是否真的在另一个 window/worker 上下文里如果只是同一个页面内的模块通信直接用自定义事件或闭包回调更简单完全没必要绕 postMessage。它把手伸到别的窗口时最有用但也在你自己的代码里多了一层异步和序列化的开销。2. 参数逐个拆解message、targetOrigin、transfer 各不相同2.1 message 的克隆语义你发过去的并不是原来的对象postMessage 的第一个参数是消息内容。它会被结构化克隆算法复制一份再交给接收方。这意味着发送方和接收方拿到的是不同引用后续修改不会互相干扰。可以做一个小测试发送一个对象接收方改掉属性再给发送方回一条消息发送方这个属性还是原来的值。结构化克隆能处理大部分类型普通对象、数组、Map、Set、Date、RegExp、Blob、File、ArrayBuffer、TypedArray、ImageBitmap 等。但有三类东西发不过去或者会失真函数、DOM 节点、Symbol、Error 对象里的 stack 等会被丢弃或转成普通对象自定义类的原型链不会保留new Customer() 到了接收方会变成普通对象instanceof Customer 不成立循环引用对象引用自己可以正常克隆不用像 JSON.stringify 那样担心死循环。所以不要把方法放在消息对象里传过去接收方拿不到函数。更稳妥的做法是只传可序列化数据把行为定义在接收端代码里。2.2 targetOrigin一个*省事也可能惹祸第二个参数 targetOrigin 决定了哪些窗口能收到这条消息。它可以是一个具体的源地址也可以是 表示不限制接收方。官方建议是尽量不传 尤其是发送敏感信息时。targetOrigin 的匹配是非常严格的https://example.com 和 http://example.com 不一样端口也要一模一样。如果你明确知道接收 iframe 来自哪个源就老老实实填完整地址。比如父页面嵌入了 https://cdn.example.com/editor.html发送时就应该写iframe.contentWindow.postMessage(data, https://cdn.example.com);如果消息发给了同源窗口也可以用 targetOrigin 为 / 的写法表示与当前窗口同源。但要注意/ 只是相对 origin 的简写遇到跨域 iframe 不生效。我的建议是一律写全源别去省字符。2.3 transfer可转移对象的所有权移交第三个参数 transfer 是一个可转移对象数组。传给它的对象不会像 message 那样被克隆而是直接把底层资源的所有权从当前上下文移交到接收方。最典型的是 ArrayBuffer 和 MessagePort。转移之后发送方的对象会被置为 detached 状态再访问会抛异常。transfer 参数的对象必须同时存在于 message 中可以是 message 本身也可以是 message 嵌套属性里的引用。比如 postMessage({ data: buffer }, origin, [buffer])这样 buffer 在传输过程中不会被拷贝接收方拿到的是同一块内存。如果 transfer 列表里放了一个根本没有出现在 message 中的对象会直接抛 DataCloneError。用一个不太严谨但好记的类比克隆等于复印一本书原书还在转移等于把书递过去但对方收到后你自己手上的书会被涂成空白想再翻就必须拿回来。对于大体积二进制数据复印的开销远高于递送这就是 transferable 存在的意义。2.4 options 形态和其他新参数较新的浏览器支持传一个 options 对象替代 targetOrigin 和 transfer 两个位置参数window.postMessage(msg, { targetOrigin: https://example.com, transfer: [buffer], includeUserActivation: true });includeUserActivation 可以在消息里携带发送者是否处于用户激活状态比如点击事件后的短暂窗口主要用来处理跨窗口时的用户手势传递。这个特性不是所有浏览器都支持生产环境使用前要做兼容测试。位置参数的形式兼容性最好我大部分项目还是用老写法只有明确需要 includeUserActivation 时才用对象形式。3. transferable 接口可转移对象为什么比深拷贝高效那么多3.1 可克隆对象与可转移对象的区别只要能被结构化克隆算法认识的对象都叫可克隆数量很多。可转移对象则是在克隆基础上额外实现了 [Transferable] 接口允许把底层资源直接转移给另一个上下文。常见的可转移对象包括 ArrayBuffer、MessagePort、ImageBitmap、OffscreenCanvas、ReadableStream、WritableStream、TransformStream、AudioData、VideoFrame 等。注意可转移不意味着只能转移。ArrayBuffer 不传 transfer 参数时仍然会被克隆只是效率更低。MessagePort 则特殊一些它在 postMessage 中必须通过 transfer 传递否则会被结构化克隆变成普通对象接收方可能收到 port但它不是有效的通信端口。3.2 从内存拷贝到所有权移动的差距假设主线程有一个 1GB 的 ArrayBuffer要传给 Worker 做处理。普通克隆会先在 Worker 里复制一份同样的 Block内存占用瞬间翻倍克隆过程还发生在调用 postMessage 的线程上会阻塞主线程一段时间。使用 transfer 则是直接把 ArrayBuffer 的底层内存块所有权移交给 Worker几乎不需要拷贝调用后主线程立即返回。传输大文件时这个差距体感非常明显。我做过一张 4K 图像缩略图的示例原始 RGBA 数据约 33MB普通克隆时 UI 明显卡顿transfer 方式几乎无感。核心原因是 transfer 避免了深拷贝只移动了内存块的引用和生命周期管理权。3.3 转移之后原变量还能用吗detach 陷阱转移之后发送方的 ArrayBuffer 会进入 detached 状态。直接访问 byteLength 或通过它创建 TypedArray 会抛错const buffer new ArrayBuffer(8); worker.postMessage(buffer, [buffer]); // buffer.byteLength 此时已经是 undefined不访问会抛异常 try { console.log(buffer.byteLength); } catch (err) { console.error(buffer has been detached); }更常见的是 TypedArray 基于 buffer 创建转移 buffer 后TypedArray 也跟着无效。所以代码里的惯例是转移前先把数据拷贝到新对象或者干脆把变量置空避免误访问。接收方要处理某些数据对象可能是被转移来的不像普通 JSON 一样可以随意持有。3.4 ArrayBuffer 从 iframe 传回父页面的完整示例父页面创建一个任务描述对象其中包含一块 ArrayBufferiframe 收到后对数据进行处理再通过 transfer 把结果传回父页面。下面是一个能直接运行的简化版// 父页面 const iframe document.getElementById(workerFrame); const rawData new Uint8Array([1, 2, 3, 4, 5]).buffer; iframe.contentWindow.postMessage( { type: PROCESS, payload: rawData }, http://localhost:3000, [rawData] ); window.addEventListener(message, (event) { if (event.origin ! http://localhost:3000) return; const { type, result } event.data; if (type ! PROCESS_DONE) return; const view new Uint8Array(result); console.log(接收处理结果, Array.from(view)); });// iframe 内部 window.addEventListener(message, (event) { if (event.data.type ! PROCESS) return; const bytes new Uint8Array(event.data.payload); for (let i 0; i bytes.length; i) bytes[i] * 2; event.source.postMessage( { type: PROCESS_DONE, result: bytes.buffer }, event.origin, [bytes.buffer] ); });注意iframe 内部在收到消息后可以用 event.source 直接回信不必再持有父窗口引用。回传时把处理过的 bytes.buffer 放进 message并同时放进 transfer 数组这样就不会复制第二份内存。4. 必须背下来的注意点校验、消息风暴与事件时序4.1 接收端的第一行代码应该是 origin 校验凡是 window.addEventListener(message) 的处理函数我都建议第一行就校验 event.origin。因为任意页面都可以通过 iframe 加载你的页面并给其中的 window 发消息如果你不校验来源等于给所有陌生窗口开了一个浏览器后门。白名单校验的写法很固定const ALLOWED_ORIGINS [https://app.example.com, https://static.example.com]; window.addEventListener(message, (event) { if (!ALLOWED_ORIGINS.includes(event.origin)) return; // 处理业务消息... });不要用 event.origin.startsWith 去匹配容易被前缀劫持尽量用精确匹配。如果来源可能包含端口和协议建议维护带协议的完整 origin 列表。4.2 被忽略的 event.source防止第三方窗口仿冒校验了 origin 还不够event.source 也很重要。你的窗口可能同时被好几个 iframe 嵌入每个 iframe 都可能发消息。如果不比较 source 是不是你预期的那一个就无法区分消息到底来自哪个窗口也容易让其他嵌入方冒充。const targetFrame document.getElementById(trustedFrame); window.addEventListener(message, (event) { if (event.source ! targetFrame.contentWindow) return; if (!ALLOWED_ORIGINS.includes(event.origin)) return; // 业务逻辑 });event.source 还有一个方便用途回复消息时你不需要保存对方窗口引用直接用 event.source.postMessage(...) 即可。但注意发送给事件源时第二参数还是要用 event.origin否则可能投递失败。4.3 父子互发导致的消息风暴这是我在多窗口同步场景里踩过的坑。父页面收到 iframe 的我准备好了之后回发一条那我也准备好了iframe 收到后再回发我也准备好了如果两边没有自己的状态判断就会陷入无限循环页面很快就卡死。避免方法很简单每一条消息带一个明确的 type 字段接收方只处理自己认知范围内的类型处理完如果需要回消息设置一个状态位比如 alreadyAcked重复消息直接忽略。命名消息类型时不要用太通配的名字像 update、sync 这种越具体越好比如 EDITOR_CURSOR_MOVE。4.4 message 事件的同步与异步行为调用 postMessage 本身是异步投递的但 message 事件的触发时机在不同环境里有差异。在同一个浏览器上下文里传给同一窗口的消息通常会在当前执行栈结束后进入事件循环可能比定时器更早触发跨 iframe 或跨 Worker 时延迟会更明显。一个实际影响是你不能在调用 postMessage 之后立刻读取接收方修改的全局变量。正确姿势是发送后等 message 事件回调如果需要请求-响应就在回调里处理结果不要依赖同步返回值。此外message 事件本身不能被 preventDefault 取消也不像普通事件那样有冒泡控制监听函数必须自己写好过滤逻辑。4.5 从 file:// 到 sandboxorigin 为 null 的古怪情况当 iframe 使用 sandbox 属性或者页面来自 file:// 协议、data: URL 时event.origin 的值是字符串 null。如果你在 targetOrigin 里填具体域名消息根本发不进去填 * 能发送但接收方校验时发现 event.origin null白名单里没有它又会直接丢弃。遇到这种结构我一般建议改成 MessageChannel创建一个 MessagePort 传入 iframe之后双方通过 port 通信不再依赖窗口之间的 origin 匹配。这样既绕开了 null origin 的坑也避免给陌生窗口开放消息入口。5. 从 iframe 到 Worker四组可以直接抄的实战示例5.1 父页面与 iframe 的双向通信首先是一个最常见的同源 iframe 双向通信注意 targetOrigin 和 event.source 都做了校验!-- 父页面 -- iframe ideditorFrame srchttps://app.example.com/editor.html/iframe script const frame document.getElementById(editorFrame); window.addEventListener(message, (event) { if (event.source ! frame.contentWindow) return; if (event.origin ! https://app.example.com) return; console.log(来自 iframe, event.data); }); frame.contentWindow.postMessage( { type: PING, payload: { time: Date.now() } }, https://app.example.com ); /script!-- iframe 内 editor.html -- script window.addEventListener(message, (event) { if (event.origin ! https://app.example.com) return; if (event.data.type ! PING) return; event.source.postMessage( { type: PONG, payload: event.data.payload }, event.origin ); }); /script如果两个页面部署在不同域名只需要把 targetOrigin 和校验源都改成对应域名即可逻辑完全一样。这里刻意加了 source 校验防止同一个页面里其他 iframe 注入消息。5.2 用 window.open 打开弹窗后的通信弹窗场景有个特殊问题window.open 返回的引用可能因为弹窗被拦截变成 null所以要先判空。弹窗打开后通常要等它主动通知我准备好了再发正式消息否则弹窗里的监听器可能还没挂上。// 父页面 const child window.open(https://child.example.com, _blank); if (!child) return; window.addEventListener(message, function onReady(event) { if (event.origin ! https://child.example.com) return; if (event.data.type ! READY) return; child.postMessage({ type: INIT, data: { token: abc } }, https://child.example.com); window.removeEventListener(message, onReady); });// 子页面 window.addEventListener(message, (event) { if (event.origin ! https://parent.example.com) return; if (event.data.type INIT) { // 向 opener 汇报处理结果 event.source.postMessage({ type: DONE, data: event.data.data }, event.origin); } });给弹窗发消息前最好先确认 child 已经加载完脚本否则消息会丢失。上面这段用事件回调的方式处理就相对稳妥父页面等待子窗口的 READY 消息。5.3 Worker 里的 transferable 大数据传输Worker 场景最能体现 transferable 的价值。主线程投递一块大 ArrayBufferWorker 处理完再原样转移回来// 主线程 const worker new Worker(process-worker.js); const data new Uint8Array([10, 20, 30, 40]); worker.postMessage({ type: RUN, buffer: data.buffer }, [data.buffer]); // 此时 data.buffer 已 detached try { console.log(data.buffer.byteLength); } catch { console.log(转移后原 buffer 不可访问); } worker.onmessage (event) { console.log(worker 返回, new Uint8Array(event.data)); };// process-worker.js self.onmessage (event) { const bytes new Uint8Array(event.data.buffer); for (let i 0; i bytes.length; i) bytes[i] 1; self.postMessage(bytes.buffer, [bytes.buffer]); };这里主线程把 data.buffer 转移给 Worker 后主线程就不能再碰 data.buffer 了。如果业务上还需要原数据就先复制一份再转移。对于几十 MB 以上的数据这种写法的内存占用和阻塞时间都能明显降下来。5.4 用 MessageChannel 建立一条私聊通道窗口之间的 postMessage 是所有监听者都能收到的如果你只希望两个特定窗口通信可以借助 MessageChannel。父页面创建一个 MessageChannel把其中一个 port 通过 transfer 传给 iframe之后双方直接用 port 通信不再走 window.postMessage。// 父页面 const channel new MessageChannel(); const iframe document.getElementById(frame); iframe.contentWindow.postMessage({ type: HANDSHAKE }, https://app.example.com, [channel.port2]); channel.port1.onmessage (event) { console.log(通过 port 收到的消息, event.data); }; channel.port1.postMessage({ hello: world });// iframe 内 window.addEventListener(message, (event) { if (event.origin ! https://app.example.com) return; if (event.data.type ! HANDSHAKE) return; const port event.ports[0]; port.onmessage (e) { console.log(iframe 通过 port 收到, e.data); port.postMessage({ replay: yes }); }; });port 是双向的并且不会泄漏给页面上的其他监听器非常适合隔离两个上下文的通信协议。需要注意的是port2 塞进 postMessage 的同时必须出现在 transfer 数组里否则它不会保留端口语义。6. 把 postMessage 做成一套可维护的通信协议6.1 消息结构统一成 type payload裸字符串和裸对象用久了会出现大妈写代码式的混乱。我在生产环境会强制定义消息协议每条消息都带一个 type 字段payload 放数据// 消息示例 { type: CURSOR_MOVE, requestId: uuid-xxx, payload: { x: 12, y: 34 } }再加一个 requestId就能实现请求-响应匹配。接收方需要回复时把同一个 requestId 带回发送方可以据此把响应路由到对应的 Promise 回调里。这样 postMessage 就变成了一套简单的 RPC 通道。6.2 封装一层简单的 sendMessage 工具基于上面的协议我写过一个小函数接收方统一走一条通道发送方按 requestId 等结果。核心代码如下const pending new Map(); let requestId 0; function sendMessage(targetWindow, targetOrigin, type, payload, transfer []) { return new Promise((resolve, reject) { const id requestId; const timer setTimeout(() { pending.delete(id); reject(new Error(postMessage timeout)); }, 5000); pending.set(id, { resolve, reject, timer }); targetWindow.postMessage({ type, requestId: id, payload }, targetOrigin, transfer); }); } window.addEventListener(message, (event) { if (event.data typeof event.data.requestId number) { const pendingItem pending.get(event.data.requestId); if (pendingItem) { clearTimeout(pendingItem.timer); pendingItem.resolve(event.data); pending.delete(event.data.requestId); } } });这个封装在多个 iframe 同时通信时特别好用让我不用每个场景都重复写事件监听和条件判断。如果担心单向通知也可以不等待响应把 requestId 留空。6.3 调试时如何追踪消息排查 postMessage 问题最直接的办法是先往监听列表里加一个临时的 console.log。如果你不想改业务代码可以在 DevTools 的 Console 里输入window.addEventListener(message, (e) { console.debug([message debug], e.origin, e.source window ? self : other, e.data); });如果消息内容是一个不能被 JSON 序列化的对象console.debug 打印出来能看到类型但直接 console.log 会更方便展开。对于 worker 通信也可以在 worker 脚本里临时加一行 postMessage把收到的原始消息再转给另一个调试通道或者直接在 Worker 的 onmessage 里打日志。更优雅的调试是在 Sources 面板的事件监听器断点里勾选 Message这样每个 message 事件都会暂停能清楚地看到调用栈和 event 对象。6.4 对不支持的浏览器做能力检测postMessage 本身几乎所有现代浏览器都支持但 options 对象形式、某些转移对象的支持度不一样。我会在生产代码里写一个小工具函数function canUseOptionsPostMessage() { try { window.postMessage(, { targetOrigin: / }); return true; } catch { return false; } }调用时如果返回 false就回退到旧的位置参数形式。另外如果只是为了传递纯字符串完全可以用 BroadcastChannel 或者 LocalStorage 的 storage 事件做替代后者在多个 tab 之间通信时更简单但这个话题就超出本文范围了。最后一点个人感受用 postMessage 这几年我总结出的最大体会是它的 API 很简单难的是把消息当作一种协议来设计。如果你只是零散地往别的窗口丢几条消息后续维护一定痛苦一旦定义好 type requestId payload再加上严格的 origin 和 source 校验这个 API 在项目里会非常可靠。我也越来越倾向用 MessageChannel 把通信限制在特定端口内减少全局消息的噪声和安全风险。碰到大体积二进制数据时记得优先考虑 transferable能省掉很多内存和卡顿问题。希望这些参数细节和代码片段能让你少走一点弯路。