1. postMessage 是什么?解决什么问题?

浏览器出于安全考虑有同源策略(scheme +host + port 完全一致才同源)。不同源页面之间默认不能直接读写彼此数据

window.postMessage 提供一个安全的跨源通信通道,允许:

父页面 ↔ 子 iframe

页面 ↔ 新开弹窗(window.open)

同源多个标签页(也可用 BroadcastChannel)

页面 ↔ Service Worker(client.postMessage)

主线程 ↔ Web Worker(worker.postMessage)

核心是消息传递:发送端调用 postMessage,接收端监听 message 事件。

2. 基本 API 与数据传输

2.1 发送端

targetWindow.postMessage(message, targetOrigin /* 必填! */, transferOrOptions);
  • message:可被 structured clone 的数据(对象/数组/字符串/数值/布尔/ArrayBuffer/ImageBitmap/MessagePort/OffscreenCanvas 等)。

  • targetOrigin:强烈建议指定确切源,如 'https://example.com'。仅调试时可用 "*"。

  • transferOrOptions(可选):可转移所有权的对象列表(如 MessagePort、ArrayBuffer),或较新的 options 对象(兼容性以主流实现为准)。

2.2 接收端

window.addEventListener('message', (event) => {
  // event.data       → 消息体
  // event.origin     → 发送方源(务必校验)
  // event.source     → 发送方 window 引用(可回发)
  // event.ports      → 携带的 MessagePort(如用 MessageChannel)
});

2.3 结构化克隆(structured clone)

  • JSON.stringify 更强:可传复杂对象 & 二进制(可选“转移所有权”零拷贝)。

  • 不可传:函数、DOM 节点等。


3. 安全与健壮性要点(务必牢记)

  1. 永远校验 event.origin:只处理来自白名单源的消息。

  2. 不要使用 targetOrigin: "*" (除非完全公开且无敏感数据)。

  3. 消息验签/版本/类型校验:设计统一数据格式:

interface Message<T = any> {
  v: '1.0';         // 协议版本
  type: string;     // 业务类型
  id?: string;      // 请求-响应关联ID
  payload?: T;      // 负载
  error?: string;   // 错误信息
}

4.永不信任消息内容:做 schema 校验 & XSS 过滤;严禁 eval

5.生命周期管理:在不需要时移除事件监听,防内存泄漏。

6.超时与重试:请求-响应模式要有超时、重试与取消。

7.内容安全策略(CSP):减少注入风险。

4. 常见场景与代码

4.1 父页面 ↔ 子 iframe(跨源)

文件:parent.html(与 child.html 不同端口/域名即可模拟跨源)

<!doctype html>
<html>
<head><meta charset="utf-8"><title>Parent</title></head>
<body>
  <h1>Parent</h1>
  <iframe id="child" src="http://127.0.0.1:5501/child.html" style="width:600px;height:200px;"></iframe>
  <button id="ask">向子页面请求数据</button>
  <pre id="log"></pre>
  <script>
    const child = document.getElementById('child');
    const CHILD_ORIGIN = 'http://127.0.0.1:5501'; // 白名单子源
    const log = (...a)=>document.getElementById('log').textContent += a.join(' ')+'\n';
 
    // 请求-响应:用 id 关联,并带超时
    const pending = new Map();
    const req = (type, payload, timeout=3000) => new Promise((resolve, reject) => {
      const id = Math.random().toString(36).slice(2);
      pending.set(id, {resolve, reject});
      const timer = setTimeout(() => {
        pending.delete(id);
        reject(new Error('timeout'));
      }, timeout);
      pending.get(id).timer = timer;
 
      child.contentWindow.postMessage({v:'1.0', type, id, payload}, CHILD_ORIGIN);
    });
 
    window.addEventListener('message', (e) => {
      if (e.origin !== CHILD_ORIGIN) return;      // 安全校验
      const {v, id, type, payload, error} = e.data || {};
      if (v !== '1.0') return;                    // 协议版本
      if (id && pending.has(id)) {
        const {resolve, reject, timer} = pending.get(id);
        clearTimeout(timer);
        pending.delete(id);
        return error ? reject(new Error(error)) : resolve(payload);
      }
      // 也可处理“推送类”消息
      if (type === 'child:hello') log('来自子页面:', payload);
    });
 
    document.getElementById('ask').onclick = async () => {
      try {
        const data = await req('parent:getTime', null, 5000);
        log('子页面返回时间:', data.now);
      } catch (err) {
        log('请求失败:', err.message);
      }
    };
  </script>
</body>
</html>

文件:child.html

<!doctype html>
<html>
<head><meta charset="utf-8"><title>Child</title></head>
<body>
  <h3>Child iframe</h3>
  <script>
    const PARENT_ORIGIN = 'http://127.0.0.1:5500'; // 父页面源
 
    // 启动时向父页面打个“招呼”(推送消息)
    window.parent.postMessage({v:'1.0', type:'child:hello', payload:'child ready'}, PARENT_ORIGIN);
 
    window.addEventListener('message', (e) => {
      if (e.origin !== PARENT_ORIGIN) return; // 校验来源
      const {v, id, type, payload} = e.data || {};
      if (v !== '1.0') return;
 
      if (type === 'parent:getTime') {
        // 处理请求并响应
        const resp = {v:'1.0', id, type:'resp:time', payload: {now: new Date().toISOString()}};
        e.source.postMessage(resp, e.origin);
      }
    });
  </script>
</body>
</html>

运行建议:开两个本地静态服务器(端口不同即不同源),如 5500parent.html5501child.html

4.2 页面 ↔ 弹窗(OAuth 登录/授权回调常用)

文件:main.html

<!doctype html>
<html>
<body>
  <button id="login">打开登录弹窗</button>
  <pre id="out"></pre>
  <script>
    const AUTH_ORIGIN = 'https://auth.example.com'; // 假定第三方登录域
    const out = (...a)=>document.getElementById('out').textContent += a.join(' ')+'\n';
 
    document.getElementById('login').onclick = () => {
      const win = window.open(AUTH_ORIGIN + '/login.html', 'auth', 'width=400,height=600');
      const id = Math.random().toString(36).slice(2);
      const timer = setInterval(() => { if (win.closed) { clearInterval(timer); out('弹窗被关闭'); }}, 300);
 
      const onMsg = (e) => {
        if (e.origin !== AUTH_ORIGIN) return;
        const {type, payload} = e.data || {};
        if (type === 'auth:success') {
          out('登录成功,token=', payload.token);
          window.removeEventListener('message', onMsg);
          win.close();
        }
        if (type === 'auth:error') {
          out('登录失败:', payload.reason);
          window.removeEventListener('message', onMsg);
          win.close();
        }
      };
      window.addEventListener('message', onMsg);
    };
  </script>
</body>
</html>

文件:登录页(第三方域)login.html(示意)

<!doctype html>
<html>
<body>
  <h3>模拟第三方登录</h3>
  <button id="ok">同意并返回</button>
  <button id="fail">失败</button>
  <script>
    // 注意:真实环境要把此处改为主站 origin
    const PARENT = 'https://your-app.example.com';
    document.getElementById('ok').onclick = () => {
      window.opener.postMessage({type:'auth:success', payload:{token:'abc123'}}, PARENT);
    };
    document.getElementById('fail').onclick = () => {
      window.opener.postMessage({type:'auth:error', payload:{reason:'user cancelled'}}, PARENT);
    };
  </script>
</body>
</html>

4.3 使用 MessageChannel 建立专用双工通道(更高效)

创建 MessageChannel,将 port2 通过一次 postMessage 发送给子页面。

之后双方用 port1/port2 通道通信,避免全局 message 池子里的“串台”。

父页面:

const channel = new MessageChannel();
const {port1, port2} = channel;
const CHILD_ORIGIN = 'http://127.0.0.1:5501';
const iframe = document.querySelector('iframe');
 
port1.onmessage = (e) => {
  console.log('来自子页(专用通道):', e.data);
};
 
// 把 port2 作为可转移对象发给子页面
iframe.contentWindow.postMessage({type:'init-port'}, CHILD_ORIGIN, [port2]);
 
// 之后用 port1 发消息
port1.postMessage({type:'ping', t: Date.now()});

子页面:

let port;
window.addEventListener('message', (e) => {
  // 校验 origin 省略…
  if (e.data?.type === 'init-port') {
    port = e.ports[0];
    port.onmessage = (me) => {
      console.log('父页来的:', me.data);
      port.postMessage({type:'pong', t: Date.now()});
    };
  }
});

优点:专线通信、更清晰、更易做请求-响应协议,也能避免误处理其它 postMessage

4.4 主线程 ↔ Web Worker(计算/IO 解耦)

main.js

const worker = new Worker('./worker.js', {type: 'module'});
const call = (cmd, payload) => new Promise((resolve) => {
  const id = Math.random().toString(36).slice(2);
  const onMsg = (e) => {
    if (e.data?.id === id) { worker.removeEventListener('message', onMsg); resolve(e.data.result); }
  };
  worker.addEventListener('message', onMsg);
  worker.postMessage({id, cmd, payload});
});
 
// 调用
call('sum', [1,2,3,4]).then(res => console.log('sum=', res));

worker.js

self.addEventListener('message', (e) => {
  const {id, cmd, payload} = e.data || {};
  if (cmd === 'sum') {
    const result = payload.reduce((a,b)=>a+b,0);
    self.postMessage({id, result});
  }
});

Worker 的 postMessage 同样基于结构化克隆,并支持转移 ArrayBuffer 进行零拷贝大数据传输(worker.postMessage(data, [data.buffer]))。

5. 可靠的请求-响应(Promise 封装)

在复杂业务里,经常需要像 RPC 一样“请求 → 等响应/错误”。下面给出可复用的小工具(父或子都能用):

// rpc.js
export function createRPC(sendFn, onMessage) {
  const pendings = new Map();
  function request(type, payload, {timeout=5000}={}) {
    const id = Math.random().toString(36).slice(2);
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => { pendings.delete(id); reject(new Error('timeout')); }, timeout);
      pendings.set(id, {resolve, reject, timer});
      sendFn({v:'1.0', id, type, payload});
    });
  }
  function handle(msg) {
    const {v, id, type, payload, error} = msg || {};
    if (v !== '1.0') return;
    if (id && pendings.has(id)) {
      const {resolve, reject, timer} = pendings.get(id);
      clearTimeout(timer); pendings.delete(id);
      return error ? reject(new Error(error)) : resolve(payload);
    }
    onMessage?.(msg); // 非请求响应类推送
  }
  return {request, handle};
}

用法举例(父页使用全局 postMessage)

import {createRPC} from './rpc.js';
const CHILD_ORIGIN = 'http://127.0.0.1:5501';
const childWin = document.querySelector('iframe').contentWindow;
 
const rpc = createRPC(
  // sendFn
  (msg) => childWin.postMessage(msg, CHILD_ORIGIN),
  // onMessage(可选)
  (push) => console.log('推送:', push)
);
 
window.addEventListener('message', (e) => {
  if (e.origin !== CHILD_ORIGIN) return;
  rpc.handle(e.data);
});
 
// 调用
rpc.request('getProfile', {uid: 123}).then(console.log).catch(console.error);

6. BroadcastChannel(同源多页群发)

同源多个标签页/iframe/worker 之间广播消息:

const bc = new BroadcastChannel('room-1');
bc.onmessage = (e) => console.log('收到:', e.data);
bc.postMessage({type:'notify', text:'hello everyone'});

7. 调试与常见坑

  • 看不到消息?

    1. targetOrigin 不匹配;2) 接收方未监听或过早关闭;3) 被浏览器拦截的弹窗未创建成功。

  • 多次触发/串台?

    • 统一消息协议 + 指定 type + 使用 MessageChannel 专线。

  • 性能问题?

    • 批量发送合并/节流;大数据使用转移(ArrayBuffer)避免拷贝。

  • Safari/移动端差异?

    • 事件循环时序可能有差异,初始化连接(如发送 port)时机要在 load 之后更稳妥。

8. 何时用 postMessage,何时用别的?

  • 多源页面通信 → postMessage

  • 同源群聊 → BroadcastChannel

  • 复杂、稳定的点对点通道 → MessageChannel

  • 计算/IO 解耦 → Web Worker

  • 页面 ↔ Service Worker → postMessage(navigator.serviceWorker.controller.postMessage)