Skip to content

应用间通信

基于微前端 2026 生态 · 核于 2026-07

速查

  • 第一原则是少通信:single-spa 官方立场——UI 状态极少需要跨应用共享,「如果两个微前端间频繁通信,说明它们耦合过度,应考虑合并」;Fowler 同调:通信应间接、少耦合
  • 五模式按方向与耦合排布:props 下行(容器→子应用,含 CustomElement 的 attributeChangedCallback 形态)、CustomEvent 上行(子→主,冒泡 + 团队前缀命名)、发布订阅/全局状态(qiankun initGlobalState 型、wujie 去中心化 EventBus)、URL 即通信(Fowler 力荐:倒逼忠实建模)、utility module 直接 import(single-spa 首选)
  • single-spa 明确反对全局 Redux/Mobx:依赖全局 state 形状或他人 dispatch 的 action,微前端就无法真正独立部署——各应用自持状态,共享走导出 API
  • initGlobalState 型三件套:onGlobalStateChange(cb, fireImmediately) 监听 / setGlobalState 修改(只能改第一层属性)/ offGlobalStateChange 卸载时移除
  • URL 通信的六个优点(Fowler 原文):开放标准、页面上任何代码全局可见、容量有限倒逼只传小数据、面向用户倒逼忠实建模领域、声明式而非命令式、强制间接通信互不依赖
  • postMessage 是跨域 iframe 场景的唯一通道:消息经结构化克隆(structured clone)序列化——能传 Object/Array/Map/Set/Date/RegExp/ArrayBuffer/Blob/File传不了函数与 DOM 节点,类实例只剩自有数据属性(原型链丢失
  • 发送端安全底线(MDN 原话级):发敏感数据必须写精确 targetOrigin,绝不用 *——「恶意站点可在你不知情时改掉窗口的 location,从而截获 postMessage 发送的数据」;省略时默认 "/"(仅同源)
  • 接收端安全底线:必须校验 event.origin(可再加 event.source)——MDN:「不校验 origin 与 source 会造成跨站脚本攻击」;且校验完身份还要校验消息语法;不期望收消息就别挂 message 监听(万无一失的做法)
  • event.origin发送时刻的 origin,不保证窗口现在还在那个 origin;回信写 event.source.postMessage(reply, event.origin)
  • MessageChannel:把 port2 经 postMessage 转移给对方,后续通信走专用双向管道(不再广播、不再逐条校验 origin);transfer 数组里的可转移对象(ArrayBuffer/MessagePort 等)转移后原端不可用
  • 与沙箱路线的勾稽:wujie 子应用 iframe 与主应用同域window.parent 直接调用即可,不需要 postMessage;真正跨域 iframe 集成才必须 postMessage + origin 白名单

一、先问要不要通信

通信机制选型之前,先过一道架构闸门。single-spa 官方文档在这件事上的表态罕见地强硬:跨微前端共享 UI 状态的需求本身就该被怀疑——「如果你发现两个微前端之间需要频繁共享状态,考虑把它们合并」。Fowler 给的正面标准同样明确:好的微前端架构里应用之间间接通信、互不知晓

这不是洁癖,而是因果链:通信通道一旦建立,两端就有了共同的协议要维护——协议变更要两边同步发版,「独立部署」名存实亡。所以五种模式的排序原则是:能靠路由解决的不建通道,能单向的不双向,能显式契约的不全局广播

二、五模式逐个过

1. props 下行:容器把数据递下去

最朴素也最健康的方向:主应用(容器)在挂载子应用时把数据与回调递进去——Fowler 认可的「React 式 callbacks and data downwards」。生命周期型框架落在 mount props 上(qiankun registerMicroAppsprops、single-spa 的 custom props);CustomElement 容器型框架(micro-app 代表)则多一条 Web 标准通道:容器改标签属性/property,子应用侧由 CE 生命周期回调 attributeChangedCallback 感知变化(micro-app 的 data 属性 + setData 即这一形态的封装;CustomElement 标准本体属 Web API 章,待产出)。

适用边界清晰:父知子的场景(登录态、主题、语言下发)。局限也天然:只有下行方向,且子应用要跟容器约定 props 形状——契约仍在,只是被限制在挂载点一处。

2. CustomEvent 上行:子应用冒泡喊话

子应用要往上报事(「购物车加了一件」),DOM 自带的 CustomEvent 是零依赖方案:

js
// 子应用:从自己的容器节点冒泡上去,detail 携带数据
container.dispatchEvent(
  new CustomEvent("team-checkout:cart-add", {
    detail: { sku: "A-1024", count: 1 },
    bubbles: true, // 冒泡到主应用监听层
  }),
);

// 主应用:在容器外层统一收
document.addEventListener("team-checkout:cart-add", (e) => {
  updateCartBadge(e.detail);
});

Fowler 对它的定位:「custom events 让微前端间接通信,是把耦合降到最低的好办法」。single-spa 也把 window.dispatchEvent/addEventListener 列为可选事件机制(与 RxJS Subject 并列)。实践要点一条:事件名带团队/应用前缀team-checkout: 型命名空间)——事件名是页面级全局命名空间,不带前缀迟早撞车。局限与 props 对偶:只适合「广播事实」,不适合请求-响应;事件是发后不管(fire-and-forget),先发出的事件不会补发给后来的监听者。

3. 发布订阅 / 全局状态:框架内置的广播台

把「事件 + 数据快照」封装成状态中心,是框架们的通用答案。qiankun 的 initGlobalState 是典型形态:

js
// 主应用:初始化全局状态,拿到 actions
const actions = initGlobalState({ user: { name: "alice" }, theme: "dark" });

// 任一应用:监听变化(fireImmediately: true 时注册即回放当前值,缓解「先发后订」丢消息)
actions.onGlobalStateChange((state, prev) => applyTheme(state.theme), true);

// 任一应用:修改 —— 注意只能修改第一层属性
actions.setGlobalState({ theme: "light" });

// 子应用卸载时:必须移除监听,否则就是跨应用的内存泄漏
actions.offGlobalStateChange();

wujie 的对应物是去中心化 EventBus(不依赖主应用中转,任意应用间收发)。这一模式的甜区是「少量、低频、全局关心」的状态:登录态、主题、语言。风险则要背下来:广播没有 schema——谁都能发、字段随口约定,规模一大就是「事件满天飞、没人说得清谁在听」;时序问题(先 set 后订阅是否能拿到值,取决于有没有 fireImmediately 型回放);以及卸载不解绑造成的监听泄漏。single-spa 反对全局 Redux/Mobx 的理由在这里同样成立一半——区别在于 initGlobalState 型方案只共享数据不共享 store 形状与 reducer 逻辑,耦合半径小得多,但仍要克制使用。

4. URL 即通信:把状态写进地址栏

Fowler 在文章里为「address bar 作为通信机制」列了一串优点,值得全文背诵级引用——URL 结构是定义良好的开放标准;对页面上任何代码全局可见容量有限,倒逼你只传少量数据面向用户,倒逼你的结构忠实地建模领域(用户能看懂、能收藏、能分享的 URL,往往就是好的领域建模);声明式而非命令式(「我们现在在这里」,而不是「请你做什么」);强制微前端间接通信、互不直接依赖。

text
https://shop.example.com/order/1024?tab=logistics
        └── 主应用路由:订单域   └── 子应用内状态:当前页签

选中的订单 id、激活的页签——这类状态放 URL 而不是全局 store,刷新即恢复、跨应用天然同步(大家都读同一根地址栏)、还免费获得深链接。它的适用边界由自身优点反推:只适合小而可公开的状态——放不下大对象,也不该放敏感数据。

5. utility module:直接 import 共享模块

single-spa 官方的首选(文档称 cross microfrontend imports):把共享逻辑做成一个独立构建、独立部署的 in-browser module(utility module),其他应用像用 npm 包一样 import 它:

js
// @org/api 模块(独立部署的 utility module)导出带缓存的用户信息获取
import { getLoggedInUser, authenticatedFetch } from "@org/api";

const user = await getLoggedInUser(); // 多应用调用共享同一份缓存,避免重复请求

这一模式的通信是显式函数契约:类型可查、可版本化、可单测——五模式里唯一有真实 API 边界的。single-spa 同时给出反面清单:核心团队「caution against redux、mobx 及其他全局状态管理库」——一旦各应用依赖全局 store 的形状、或依赖其他应用 dispatch 的 action,它们就不能真正独立部署了;正确姿势是各应用自持状态,跨应用共享的只有 utility module 导出的函数与数据缓存。落地依赖模块加载基建(import maps 或 MF,见依赖共享三路线)。

三、postMessage 深讲:跨浏览上下文的正门

前五种模式都活在同一个 JS 上下文里。一旦子应用跑在 iframe(尤其跨域 iframe)中,同上下文假设失效,浏览器给的唯一正门是 window.postMessage。这个 API 的每个参数都有安全含义,值得逐项过。

消息能带什么:结构化克隆

消息经结构化克隆算法(structured clone)序列化,能力比 JSON 大一圈:Object/Array/Map/Set/Date/RegExp/ArrayBuffer/TypedArray/Blob/File 都能过。三样过不去的要记牢:函数(想传能力就传 MessagePort)、DOM 节点Symbol;类实例能过但原型链丢失——到对面只剩自有数据属性,instanceof 判定全部失效。所以跨 iframe 协议一律按纯数据 + 类型字段设计({ type: "cart:add", payload: {…} })。

发送端:targetOrigin 不是可选项

js
// 发送端:第二个参数写死预期接收方的精确 origin
iframe.contentWindow.postMessage(
  { type: "auth:token", payload: token },
  "https://sub.example.com", // 若窗口已被导航到别的源,消息直接不投递
);

MDN 的安全告诫接近原话级:给其他窗口发数据时,永远指定精确的 targetOrigin,不要用 *——「恶意站点可以在你不知情的情况下改变窗口的 location,从而截获用 postMessage 发送的数据」。省略该参数时默认值是 "/"(仅投递给同源窗口);"*" 唯一合理的场景是接收方是 data: URL 这类 opaque origin(没法精确匹配)——而那本身就该触发你重新审视方案。

接收端:origin 校验的标准写法

js
// 接收端:白名单校验 origin → 校验消息语法 → 才处理
const TRUSTED_ORIGINS = new Set(["https://main.example.com"]);

window.addEventListener("message", (event) => {
  if (!TRUSTED_ORIGINS.has(event.origin)) return; // ① 身份:不在白名单直接丢弃
  const { type, payload } = event.data ?? {};
  if (typeof type !== "string") return;           // ② 语法:schema 不对也丢弃
  handlers[type]?.(payload);                       // ③ 按类型分发,绝不 eval 消息内容
});

MDN 把利害说得很重:「不校验 origin(以及可能的 source)就是在给跨站脚本攻击开门」——任何窗口都可以往你这儿投消息,页面里嵌的第三方 iframe 也不例外。三条纪律:不期望收消息就不要挂 message 监听器(MDN 称之为万无一失的做法);期望收,就用 event.origin(必要时加 event.source)做白名单校验;身份过了还要校验消息语法——否则你信任的那一方有洞,就会顺着通道变成你的洞(origin 概念与更多攻防见浏览器安全)。两个细节:event.origin发送时刻的 origin,不保证对方窗口现在还在那个源上;回信的标准姿势是 event.source.postMessage(reply, event.origin)——拿发来的 origin 当回信的 targetOrigin。

升级形态:MessageChannel 专线

广播 + 逐条校验的模式在高频通信下既啰嗦又易错,MessageChannel 提供专线方案:

js
// 主应用:建通道,port2 作为「话筒」转移给 iframe
const { port1, port2 } = new MessageChannel();
iframe.contentWindow.postMessage({ type: "init" }, "https://sub.example.com", [port2]);
port1.onmessage = (e) => console.log("子应用说:", e.data);

// 子应用:从首条消息里接住 port,此后走专线对讲
window.addEventListener("message", (event) => {
  if (event.origin !== "https://main.example.com") return; // 首次握手仍要校验
  const [port] = event.ports;
  port.postMessage("ready"); // 专线通信:不再广播、不再逐条对 origin
});

transfer 数组(第三个参数)里的对象走所有权转移而非拷贝——MessagePortArrayBuffer 等转移后原端立即不可用(大数据零拷贝的关键)。握手一次校验 origin,之后通道两端点对点,协议清爽得多。同源多标签页广播则另有 BroadcastChannel,不在跨源话题内。

与沙箱路线的勾稽

postMessage 与 iframe 沙箱路线的关系常被想当然。事实是:wujie 恰恰不靠 postMessage——它的子应用 iframe 与主应用同域window.parent 拿到的就是主应用真 window,函数直接调用、对象直接引用(wujie 三层通信:props、window.parent 直通、EventBus),根本不需要序列化。postMessage 真正的主场是跨域 iframe 集成:嵌第三方应用、跨公司协作、遗留系统乐高——那里同域假设不成立,结构化克隆 + origin 白名单是唯一安全走法(iframe 嵌入属性与加固见 iframe 嵌入浏览器安全)。

四、选型判据

模式方向耦合物跨域 iframe甜区典型坏法
props 下行主→子props 形状契约登录态/主题/回调下发契约膨胀成「万能 props 袋」
CustomEvent 上行子→主事件名 + detail 结构子应用广播领域事实无前缀撞名、拿事件做请求-响应
发布订阅/全局状态多↔多状态字段的隐式约定少量全局共享态事件满天飞、卸载不解绑
URL 即通信多↔多路由结构(公开契约)是(地址栏共享)页面级状态、深链接敏感数据进 URL、塞大对象
utility module调用方→模块显式导出 API共享逻辑/请求缓存演化成事实上的全局 store
postMessage双向消息 schema + origin 白名单是(唯一正门)跨域 iframe 集成targetOrigin: "*"、不校验 origin

判读顺序建议:先试 URL(要传的是不是页面级状态?)→ 再看方向(单向下发用 props、单向上报用 CustomEvent)→ 共享逻辑抽 utility module → 全局态才动用发布订阅并克制字段 → 跨域 iframe 别无选择,postMessage 按安全写法来。

小结

通信机制的谱系一头连着架构(少通信、间接通信、能合并就别拆),一头连着 Web 平台的安全模型。同上下文五模式各守一个方向:props 管下行、CustomEvent 管上行、发布订阅管全局态(initGlobalState 型三件套 + 卸载解绑)、URL 管页面级状态(Fowler 六优点,本质是拿开放标准当总线)、utility module 管共享逻辑(single-spa 首选,同时点名反对全局 Redux)。跨域 iframe 则是 postMessage 的主场——结构化克隆定了「能传什么」(无函数、无 DOM、原型丢失),targetOriginorigin 白名单定了「安全怎么写」,MessageChannel 把广播升级成专线。而 wujie 的同域 iframe 设计提醒我们:通信方案永远是沙箱路线的下游——先定隔离模型,再选通信通道。应用会说话了,下一个问题是别让它们各背一份 React:依赖共享三路线