微前端源码精讲
第6章 乾坤的应用间通信
第6章 乾坤的应用间通信
微前端架构中,沙箱隔离的目的是让应用互不干扰——但业务永远需要它们彼此对话。如何在隔离与协作之间找到精确的平衡点,是每个微前端方案必须回答的核心问题。
本章要点
- 深入
initGlobalState源码,理解乾坤基于发布订阅模式实现全局状态通信的完整机制- 掌握 Props 传递的实现原理,理解主应用与子应用之间直接通信的数据流向
- 剖析
loadMicroApp手动加载模式下通信的差异与适用场景- 对比 CustomEvent、BroadcastChannel、共享 Store 等替代方案,在性能与复杂度之间做出理性权衡
本章引用的 qiankun 源码以 v2.10.16 为准(
src/globalState.ts、src/apis.ts、src/loader.ts),single-spa 以 v6.0.3 为准。
微前端的沙箱机制——无论是 SnapshotSandbox 还是 ProxySandbox——都在做一件事:隔离。它们用精心设计的代理拦截和快照恢复,确保子应用之间的全局变量不会互相污染。前几章我们已经深入理解了这些隔离机制的实现原理。
但隔离只是硬币的一面。
回到现实业务场景:用户在主应用的导航栏点击头像,弹出个人信息面板——这个面板属于"用户中心"子应用。用户修改了头像,主应用的导航栏需要立即更新。与此同时,"消息中心"子应用也需要知道头像变了,因为它在消息列表里展示了用户头像。
三个独立部署的应用,需要在同一个浏览器窗口中实时同步一份数据。沙箱把它们隔开了,但业务又要求它们协作。这就像你精心设计了一栋公寓——每户都有独立的门锁和隔音墙——然后住户们说:"我们需要一个公共公告板。"
乾坤对此的回答是三种通信机制:全局状态(initGlobalState)、Props 传递、以及 loadMicroApp 的手动加载模式。它们各自适用于不同场景,背后的实现原理也截然不同。这一章,我们将逐行阅读这些机制的源码,理解它们的设计意图,然后跳出乾坤本身,对比更广泛的微前端通信方案——因为只有理解了全部选项,你才能为自己的项目做出正确的架构决策。
6.1 initGlobalState:基于发布订阅的全局状态
发布订阅模式(Publish-Subscribe Pattern)是软件工程里年代最久远、应用最广泛的设计模式之一——它的雏形出现在 1970 年代末 Smalltalk-80 的 MVC 架构里(视图订阅模型的变化)、在 1994 年的 GoF《设计模式》里被正式命名为 Observer 并列为 23 个经典模式之一。为什么这么古老的模式在今天仍然经久不衰?因为它完美地解耦了"事件的产生者"和"事件的消费者"——发布者不需要知道谁在订阅、订阅者不需要知道谁在发布、两者只通过"事件的名字和内容"这个契约建立联系。乾坤的 initGlobalState 是这个古老模式在微前端场景下的一次应用——主应用发布状态变更、子应用订阅状态变更、两者互不知道彼此的存在、只通过"状态对象"这个共享契约建立联系。你在 Redux 里看到过它、在 Vuex 里看到过它、在 RxJS 里看到过它、在 MQTT 里看到过它、在 Kafka 里看到过它——发布订阅是软件架构里最通用的一颗"螺丝钉"、它简单、牢固、适用于无数场景。
6.1.1 从使用方式开始
"从使用方式开始读源码"——这是一个非常有用的阅读习惯。很多人读源码时犯的错误、是从底层实现开始"往上反推"、结果走到一半发现不知道这些代码是干嘛的、失去了方向感。正确的方式是相反——先在大脑里建立"这个 API 的使用姿势"、然后带着"我知道它要达到什么效果、现在我来看它是怎么达到的"的问题感去读实现。这种方法论、在任何技术学习里都适用——先看 API、再看实现;先看使用、再看原理;先看效果、再看细节。它符合认知规律——人脑理解任何东西、都是"从目标倒推方法"比"从方法推断目标"要容易。
先看 initGlobalState 的典型使用方式,带着"它要解决什么问题"的思维去阅读实现代码。
// 主应用 - main-app/src/micro.ts
import { initGlobalState, MicroAppStateActions } from 'qiankun';
const actions: MicroAppStateActions = initGlobalState({
user: { name: '杨艺韬', avatar: '/default.png' },
theme: 'light',
locale: 'zh-CN',
});
actions.onGlobalStateChange((state, prevState) => {
console.log('主应用感知到状态变化:', state);
updateNavbar(state.user);
});
actions.setGlobalState({
user: { name: '杨艺韬', avatar: '/new-avatar.png' },
});
// 子应用 - sub-app/src/main.ts
export function mount(props) {
const { onGlobalStateChange, setGlobalState } = props;
onGlobalStateChange((state, prevState) => {
console.log('子应用感知到状态变化:', state);
store.commit('updateUser', state.user);
});
setGlobalState({ theme: 'dark' });
}
API 很简洁——初始化一个全局状态对象,主应用和子应用都可以监听变化、修改状态。但简洁的 API 背后,隐藏着几个值得深入思考的设计决策:
- 为什么全局状态只应由主应用初始化? 乾坤的设计里只有主应用调用
initGlobalState(子应用即使自己 import 一份 qiankun 去调用,拿到的也是另一个模块实例里的另一份globalState,和主应用的互不相通)。如果任何子应用都能初始化全局状态,状态的"起点"就变得不可预测——你永远不知道哪个子应用先加载、先初始化。 - 子应用通过
props获取通信能力,而不是直接导入。 这意味着通信能力是由主应用"授予"的,子应用没有办法在主应用不知情的情况下参与全局通信。 setGlobalState是合并(merge)而非替换(replace)。 子应用修改theme不会丢失user数据。这降低了子应用之间的协调成本——你不需要知道全局状态的完整结构就能安全地修改自己关心的部分。
带着这些问题,我们进入源码。
下图展示了乾坤全局状态通信的整体架构,包括主应用和子应用之间的数据流向:
flowchart TB
Main["主应用"] -->|"initGlobalState(state)"| GS["globalState\n模块级变量(沙箱不可见)"]
GS --- Deps["deps 订阅者注册表\n(按 id 存回调)"]
Main -->|"getMicroAppStateActions(id, isMaster=true)"| MainActions["主应用 Actions\n可添加新 key"]
SubA -->|"getMicroAppStateActions(id, isMaster=false)"| SubAActions["子应用A Actions\n只能修改已有 key"]
SubB -->|"getMicroAppStateActions(id, isMaster=false)"| SubBActions["子应用B Actions\n只能修改已有 key"]
MainActions -->|"setGlobalState"| GS
SubAActions -->|"setGlobalState"| GS
SubBActions -->|"setGlobalState"| GS
GS -->|"emitGlobal(cloneDeep)"| MainCB["主应用回调"]
GS -->|"emitGlobal(cloneDeep)"| SubACB["子应用A回调"]
GS -->|"emitGlobal(cloneDeep)"| SubBCB["子应用B回调"]
SubA["子应用A"]
SubB["子应用B"]
style GS fill:#e3f2fd,stroke:#1565c0
style MainActions fill:#e8f5e9,stroke:#2e7d32
style SubAActions fill:#fff3e0,stroke:#e65100
style SubBActions fill:#fff3e0,stroke:#e65100
6.1.2 initGlobalState 的核心实现
乾坤的全局状态管理核心逻辑不到 100 行,但信息密度极高。
// qiankun/src/globalState.ts
import { cloneDeep } from 'lodash';
let globalState: Record<string, any> = {};
const deps: Record<string, OnGlobalStateChangeCallback> = {};
type OnGlobalStateChangeCallback = (
state: Record<string, any>,
prevState: Record<string, any>
) => void;
第一个值得注意的设计:globalState 和 deps 都是模块级变量。它们不在 window 上,也不在任何类实例上。这意味着不受子应用沙箱的影响——ProxySandbox 代理的是 window,而不是乾坤内部的模块变量。放在模块作用域,是最简单也最安全的位置。
// qiankun/src/globalState.ts
export function initGlobalState(state: Record<string, any> = {}) {
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] globalState tools will be removed in 3.0, pls don't use it!`);
}
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
} else {
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(state);
emitGlobal(globalState, prevGlobalState);
}
return getMicroAppStateActions(`global-${+new Date()}`, true);
}
几个关键事实:使用 cloneDeep 确保外部修改不会绕过通信机制;初始化时立即触发 emitGlobal 通知已注册的订阅者;时间戳 global-${+new Date()} 用于在 deps 中唯一标识主应用的回调;第二个参数 true 就是下文的 isMaster——主应用的操作句柄由此获得"可以新增顶层 key"的特权。另外别忽略开头那条开发环境警告:这套 API 在 2.x 是稳定可用的,但官方已明确它不会进入 3.0,选型时要把这一点计入长期成本。
6.1.3 发布与通知:emitGlobal
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
Object.keys(deps).forEach((id: string) => {
if (deps[id] instanceof Function) {
deps[id](cloneDeep(state), cloneDeep(prevState));
}
});
}
一个不起眼却至关重要的细节:每次通知都传递深拷贝。如果传递引用,子应用的回调可以直接修改全局状态而不触发其他订阅者的通知——这是灾难性的。深拷贝确保了只有 setGlobalState 才是修改全局状态的合法通道,与 Redux 的"单一数据流"理念异曲同工。
🔥 深度洞察:深拷贝的性能代价与设计取舍
cloneDeep的时间复杂度是 O(n)。如果全局状态包含大型数组,每次emitGlobal会产生 2N 次深拷贝(N 是订阅者数量,state与prevState各拷一次)。对 3 个子应用这完全不是问题,但 20 个子应用加上 10000 条记录的列表,性能就可能成为瓶颈。乾坤选择"安全优先"而非"性能优先",这是正确的默认值——但在极端场景下,你需要意识到这个代价。
6.1.4 MicroAppStateActions:操作句柄的生成
"操作句柄(Handle/Actions)"的设计、是把"API 使用者"和"API 实现者"之间的通信边界、从"共享全局对象"降级为"拿到一个对象、只能调用它的方法"——这是一个典型的"能力受限代理(Capability-based Proxy)"模式。能力受限代理的好处在于:(1) 调用者只能做它被允许做的事情、框架实现者可以在 Handle 里做任何审计、日志、权限检查;(2) 框架实现可以在不改 Handle 接口的前提下演化内部实现;(3) 调用者的代码变得容易测试——你可以很容易 mock 一个 Handle。乾坤的 MicroAppStateActions、正是这种思路的典型应用——每个子应用拿到的不是 "整个 deps 字典"、而是"一份只包含 onGlobalStateChange、setGlobalState、offGlobalStateChange 的 handle"(经 props 下发给子应用的只有前两个,offGlobalStateChange 由乾坤在卸载时代为调用)——这让子应用无法越权干预其他子应用的订阅、也无法绕过 setGlobalState 直接修改状态。这种"能力限定式的 API 暴露"、在操作系统的文件描述符、数据库的游标、Web 的 AbortController 里都能看到。
// src/globalState.ts:37(签名在源码里是一行;各方法的 docstring 已省略)
export function getMicroAppStateActions(
id: string,
isMaster?: boolean
): MicroAppStateActions {
return {
onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
if (!(callback instanceof Function)) {
console.error('[qiankun] callback must be function!');
return;
}
if (deps[id]) {
console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
}
deps[id] = callback;
if (fireImmediately) {
const cloneState = cloneDeep(globalState);
callback(cloneState, cloneState);
}
},
setGlobalState(state: Record<string, any> = {}) {
if (state === globalState) {
console.warn('[qiankun] state has not changed!');
return false;
}
const changeKeys: string[] = [];
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(
Object.keys(state).reduce((_globalState, changeKey) => {
if (isMaster || _globalState.hasOwnProperty(changeKey)) {
changeKeys.push(changeKey);
return Object.assign(_globalState, { [changeKey]: state[changeKey] });
}
console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
return _globalState;
}, globalState)
);
if (changeKeys.length === 0) {
console.warn('[qiankun] state has not changed!');
return false;
}
emitGlobal(globalState, prevGlobalState);
return true;
},
offGlobalStateChange() {
delete deps[id];
return true;
},
};
}
setGlobalState 中那个 reduce 循环是核心——它实现了权限分级:
- 主应用(
isMaster === true):可以添加新的顶层 key,拥有完全的状态控制权。 - 子应用(
isMaster为假——loader.ts调用getMicroAppStateActions(appInstanceId)时根本没传第二个参数):只能修改已存在的顶层 key,不能添加新 key。
还有一个细节:每个 id 在 deps 里只占一个槽位,同一个子应用第二次调用 onGlobalStateChange 会覆盖而不是追加前一个回调(源码注释说这是为了防止全局监听滥用导致内存膨胀)。想在子应用内部分发给多个消费者,得自己在回调里再做一层分发。
为什么要做这个限制?想象一个没有限制的世界:
// 子应用 A 添加了一个 key
setGlobalState({ featureFlagA: true });
// 子应用 B 也添加了一个 key
setGlobalState({ featureFlagB: true });
// 子应用 C 又加了一个...
setGlobalState({ tempData: { /* 一大堆临时数据 */ } });
// 三个月后,globalState 变成了一个巨大的垃圾场
// 没人知道哪些 key 还在被使用,哪些已经是僵尸数据
通过限制子应用只能修改已有 key,乾坤确保了主应用是全局状态结构的唯一定义者。这是一种"合同制"——主应用定义了全局状态的"Schema",子应用只能在这个 Schema 内操作。
6.1.5 完整的数据流
下图展示了一次完整的全局状态变更的时序过程,从子应用发起修改到所有订阅者收到通知:
sequenceDiagram
participant SubApp as 子应用A
participant Actions as MicroAppStateActions
participant GS as globalState
participant Emit as emitGlobal()
participant MainCB as 主应用回调
participant SubBCB as 子应用B回调
SubApp->>Actions: setGlobalState({ theme: 'dark' })
Actions->>Actions: isMaster=false, 检查 'theme' 是否存在
Actions->>GS: Object.assign 合并变更
Actions->>GS: cloneDeep 生成新状态
Actions->>Emit: emitGlobal(newState, prevState)
Emit->>MainCB: callback(cloneDeep(state), cloneDeep(prev))
Emit->>SubBCB: callback(cloneDeep(state), cloneDeep(prev))
Note over MainCB,SubBCB: 每个订阅者收到的都是深拷贝,无法绕过通信机制修改状态
1. 主应用调用 initGlobalState({ user, theme, locale })
├── globalState = cloneDeep({ user, theme, locale })
├── emitGlobal(globalState, prevGlobalState)
└── return getMicroAppStateActions('global-xxx', true)
2. 乾坤加载子应用时,在 props 中注入通信能力
├── const { onGlobalStateChange, setGlobalState, offGlobalStateChange } = getMicroAppStateActions(appInstanceId)
└── mount 时注入 props.onGlobalStateChange / props.setGlobalState(offGlobalStateChange 留给乾坤自己在卸载时调用)
3. 子应用调用 setGlobalState({ theme: 'dark' })
├── isMaster = false → 检查 'theme' 存在 → 允许修改
├── emitGlobal → 通知所有订阅者(每个拿到深拷贝)
└── return true
4. 子应用卸载时 → loader 的 unmount 链调用 offGlobalStateChange() → delete deps[appInstanceId]
6.1.6 initGlobalState 的局限性
审视一个 API 的局限、比赞美它的能力更能让你成为成熟的工程师——因为在真实项目里、你遇到的问题往往恰好发生在某个 API 的能力边界上。乾坤官方 API 文档只写明了其中一条(setGlobalState "按一级属性设置全局状态,微应用中只能修改已存在的一级属性"),其余几条要从源码里自己读出来:
// 局限 1:只支持一层浅合并
// 子应用想改 user.preferences.theme,必须传递整个 user 对象
// 遗漏 fontSize 就会丢失——Object.assign 只做第一层合并
// 局限 2:没有选择性订阅(selector)
// 任何 key 变化,所有回调都触发
// 局限 3:没有中间件、时间旅行、DevTools
这些局限性不是设计缺陷——而是有意为之的简化。乾坤的全局状态机制定位是"轻量级的跨应用通信",而不是"完整的状态管理"。如果你需要 Redux 级别的能力——中间件、时间旅行、selector、DevTools——应该使用独立的状态管理方案(我们会在 6.4 节讨论)。
理解一个工具的边界,和理解它的能力同样重要。当你清楚地知道 initGlobalState 能做什么、不能做什么,才能在项目中做出准确的技术选型,而不是在错误的场景下使用它然后抱怨它的局限。
6.2 Props 传递:父子应用的直接通信
如果 initGlobalState 是"发布-订阅"的代表、那么 Props 传递就是"依赖注入(Dependency Injection)"的代表——两种截然不同的通信哲学在同一个框架里并存、不是冗余、而是互补。发布订阅解决的是"多对多、松耦合"的信息广播问题;依赖注入解决的是"一对一、显式依赖"的能力传递问题。父应用给子应用传递一个 authToken 这种"身份凭证"、传递一个 navigateTo 函数这种"能力"、传递一个 showConfirmDialog 这种"主应用的服务"——这些都是依赖注入的典型用法。为什么这种用法要用 Props 而不是 GlobalState?因为它们是"由父应用向子应用提供的东西"、不是"全局共享的数据"——这种语义上的单向性、在代码里应该被显式地表达出来。如果所有通信都通过 GlobalState 走、你会失去"谁提供了什么、谁消费了什么"的追溯能力——GlobalState 是个大黑盒、任何人都能读写;而 Props 是一份显式契约、父应用提供什么、子应用接收什么、一目了然。
6.2.1 Props 的注入时机
当乾坤加载子应用时,在生命周期函数中注入 props:
// qiankun/src/loader.ts(精简)
export async function loadApp(app, configuration = {}, lifeCycles) {
const { entry, name: appName } = app;
const appInstanceId = genAppInstanceIdByName(appName); // 首个实例就是 appName,之后是 appName_1、appName_2 …
// ... 加载 HTML、创建沙箱、执行脚本 ...
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global, /* ... */);
const { onGlobalStateChange, setGlobalState, offGlobalStateChange } = getMicroAppStateActions(appInstanceId);
const parcelConfigGetter = (remountContainer = initialContainer) => {
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
// ... 单例等待、创建/渲染包裹元素、mountSandbox、beforeMount 钩子 ...
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
// ... 渲染完成、afterMount 钩子 ...
],
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
async (props) => unmount({ ...props, container: appWrapperGetter() }),
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
render({ element: null, loading: false, container: remountContainer }, 'unmounted');
offGlobalStateChange(appInstanceId); // 卸载时自动注销该实例的全局监听
// ...
},
],
};
if (typeof update === 'function') {
parcelConfig.update = update; // 子应用导出了 update 才会有 update 生命周期
}
return parcelConfig;
};
return parcelConfigGetter;
}
看到了吗?子应用的 mount 函数接收到的 props 是由乾坤组装而成的。它包含三个来源:
- single-spa 注入的 props:
name(loadMicroApp挂载的 parcel 取 parcel 配置里的appInstanceId,registerMicroApps注册的应用取注册时的name)、mountParcel、singleSpa实例等基础信息 - 用户自定义的 props:主应用在
registerMicroApps/loadMicroApp时传入的props对象 - 乾坤注入的通信 API:
setGlobalState、onGlobalStateChange
这种组装式的设计意味着,子应用不需要知道自己运行在乾坤环境中——它只需要按照约定的接口读取 props。
registerMicroApps([{
name: 'sub-app',
entry: '//localhost:7100',
container: '#subapp-container',
activeRule: '/sub-app',
props: {
navigate: (path: string) => router.push(path),
getToken: () => localStorage.getItem('token'),
showGlobalModal: (config: ModalConfig) => modal.show(config),
baseApiUrl: 'https://api.example.com',
eventBus: mitt(),
},
}]);
6.2.2 Props 与 GlobalState 的本质区别
一个常见的误区是认为 "Props 和 GlobalState 可以互相替代、用哪个都行"——实际上两者的语义完全不同、不可混淆。Props 的本质是"单向的能力传递"——父应用显式告诉子应用"你可以用这几个函数、这几个数据";GlobalState 的本质是"多向的状态同步"——所有参与方都可能读写同一份状态。当你用 Props 传递一份数据时、你的意图是"这是我要给你的快照、不会变";当你用 GlobalState 传递同样一份数据时、你的意图是"这是共享的、谁都可能改、大家都要订阅变化"。混用这两种机制会让代码变得难以推理——比如你通过 Props 传了一个 user 对象、后来主应用换了一个新的 user、但已挂载的子应用手里还是 mount 那一刻拿到的引用、bug 产生了却不知道为什么。所以选择通信机制时、第一个问题永远是"这是能力、还是数据?"——能力用 Props、数据用 GlobalState、这个原则能帮你避开大部分通信设计陷阱。
// GlobalState:发布-订阅模式 —— 多对多、同步回调通知(setGlobalState 内直接调用各订阅者)、数据驱动
// Props:依赖注入模式 —— 一对一、同步传递、能力驱动
// 用 GlobalState 传递数据(广播)
initGlobalState({ user: { id: 1001, name: '杨艺韬', role: 'admin' } });
// 用 Props 传递能力(授权)
registerMicroApps([{
name: 'order-app',
props: {
navigateToProduct: (id: string) => router.push(`/product/${id}`),
checkPermission: (action: string) => permissionService.check(currentUser, action),
reportError: (error: Error, ctx: Record<string, any>) => sentry.captureException(error, { extra: ctx }),
},
}]);
🔥 深度洞察:Props 是"能力传递",GlobalState 是"数据共享"
如果传递的是数据,用 GlobalState;如果传递的是能力(函数、服务、实例),用 Props。不要通过 GlobalState 传递函数(lodash 的
cloneDeep不会复制嵌套的函数,而是原样透传引用——这等于把能力混进了数据通道),也不要通过 Props 传递需要实时同步的数据(Props 没有响应式通知机制)。把这两者混为一谈,是微前端通信设计中最常见的反模式。
6.2.3 动态 Props 更新
Props 在子应用加载时传递,但业务场景中经常需要动态更新。在路由注册模式(registerMicroApps)下,Props 是静态的——注册时的 props 对象原样作为 single-spa 的 customProps,每次调用生命周期时由 getProps 浅拷贝一份(single-spa src/lifecycles/prop.helpers.js),已挂载的子应用没有任何通道接收新的 props,最早也要等下一次 mount 才能看到主应用对这个对象的改动。只有在 loadMicroApp 模式下,才能通过 update 方法动态更新 Props(详见 6.3 节)。
这也是很多团队选择 loadMicroApp 而非 registerMicroApps 的重要原因之一。
6.2.4 Props 传递的陷阱与最佳实践
Props 看起来是最简单的通信机制——父传子、一个对象、收到就用。但"最简单"有时候意味着"最容易踩坑"——因为简单的表象下藏着一些反直觉的细节。最经典的坑是"闭包陷阱"——注册子应用时、你捕获了某个变量的值;几分钟后这个变量更新了、但子应用里用的仍是当初捕获的旧值。这种 bug 在单体应用里也会出现、但在微前端里更难排查——因为子应用和主应用的代码分属不同仓库、bug 发生时你可能意识不到问题出在"跨应用的闭包"上。类似的坑还有"引用共享"——Props 传递不做序列化、所以主应用和子应用共享同一个对象引用——某个子应用改了这个对象、其他引用到它的代码都会被间接影响。这些坑的共同教训是——跨应用通信的 API、比它看起来要"脆弱"得多、任何看起来"理所当然"的用法都应该先在心智模型里推演一次——如果这个 API 背后的实现发生了变化、我的代码还能正常工作吗?
// 陷阱 1:闭包陷阱 —— Props 中的函数捕获了过时的变量
registerMicroApps([{
name: 'sub-app',
props: {
getToken: () => token, // ❌ token 是注册时的值,可能已过期
},
}]);
// 正确做法:让函数在调用时才读取最新值
registerMicroApps([{
name: 'sub-app',
props: {
getToken: () => localStorage.getItem('token'), // ✅ 每次调用时读取
},
}]);
// 陷阱 2:引用泄露 —— Props 中传递了可变对象
const sharedConfig = { apiUrl: 'https://api.example.com' };
registerMicroApps([{
name: 'sub-app',
props: { config: sharedConfig }, // ❌ 子应用可能修改这个对象
}]);
// 正确做法:传递不可变数据或冻结对象
registerMicroApps([{
name: 'sub-app',
props: {
config: Object.freeze({ apiUrl: 'https://api.example.com' }), // ✅
},
}]);
// 陷阱 3:传递了不可序列化的内容
registerMicroApps([{
name: 'sub-app',
props: {
domElement: document.getElementById('app'), // ⚠️ DOM 引用
circularRef: objWithCircularRef, // ⚠️ 循环引用
},
}]);
// Props 是直接传引用(不序列化),上述用法不会报错
// 但会造成子应用与主应用的紧耦合,违背微前端的独立部署原则
6.3 loadMicroApp:手动加载模式的实现
loadMicroApp 是乾坤 API 里最"灵活"也最容易被滥用的一个——它让开发者可以完全绕过路由驱动的模型、手动控制子应用的加载、卸载、更新。这种"逃生口"(escape hatch)的设计、在很多成熟框架里都能看到——React 的 useRef + imperativeHandle、Vue 的 $refs、Redux 的 store.dispatch(在函数组件外使用)——这些 API 存在的理由、都是"大多数情况下用主推模式、但偶尔需要突破框架约束的时候也能做到"。这种"保留灵活性的理性主义"、是成熟框架的一个重要特征——它不强求你在所有场景都走主流路径、允许你在真正需要的时候打破规则。但任何逃生口都有滥用风险——开发者可能因为"loadMicroApp 更直接"而放弃使用 registerMicroApps、结果失去了路由自动管理、预加载、生命周期自动调度这些好处。所以使用 loadMicroApp 的准则应该是——"只在路由驱动模型真的解决不了问题的场景下使用"——比如仪表盘的多子应用同屏、弹窗里的子应用、权限驱动的动态加载。
下图对比了乾坤三种通信机制的适用场景与数据流特征:
flowchart TB
subgraph GlobalState["initGlobalState -- 全局广播"]
direction LR
GS_Main["主应用\n初始化 + 监听 + 修改"] <-->|"发布订阅\n多对多"| GS_Sub1["子应用A\n监听 + 修改已有key"]
GS_Main <-->|"发布订阅\n多对多"| GS_Sub2["子应用B\n监听 + 修改已有key"]
end
subgraph PropsPass["Props 传递 -- 能力注入"]
direction LR
PP_Main["主应用\n传递函数/配置"] -->|"单向注入\n一对一"| PP_Sub["子应用\nmount(props)"]
end
subgraph LoadMicro["loadMicroApp -- 手动控制"]
direction LR
LM_Main["主应用组件\n动态 props"] -->|"update(props)\n可动态更新"| LM_Sub["Parcel 子应用\nupdate 生命周期"]
end
style GlobalState fill:#e3f2fd,stroke:#1565c0
style PropsPass fill:#fff3e0,stroke:#e65100
style LoadMicro fill:#e8f5e9,stroke:#2e7d32
6.3.1 从路由驱动到手动控制
在第 3 章中我们了解到,乾坤的默认模式是路由驱动——通过 registerMicroApps 注册子应用,由 URL 变化自动触发加载和卸载。但很多实际场景不是路由驱动的:
// 场景 1:一个页面中同时展示多个子应用(仪表盘的多个面板)
// 场景 2:弹窗中加载子应用
// 场景 3:根据用户权限动态加载管理面板
loadMicroApp 就是为这些场景设计的。它的实现与路由驱动模式共享大部分基础设施,但在通信方面有一些关键差异。
// qiankun/src/apis.ts(精简)
export function loadMicroApp(app, configuration?, lifeCycles?): MicroApp {
const { props, name } = app;
const container = 'container' in app ? app.container : undefined;
// 用「应用名 + 容器的 XPath」作为实例 id:同一个应用再次渲染到同一个容器时,不会重新加载和执行脚本
const containerXPath = getContainerXPath(container);
const appContainerXPathKey = `${name}-${containerXPath}`;
const memorizedLoadingFn = async (): Promise<ParcelConfigObject> => {
const userConfiguration = autoDowngradeForLowVersionBrowser(configuration ?? { ...frameworkConfiguration, singular: false });
if (container && containerXPath) {
const cached = appConfigPromiseGetterMap.get(appContainerXPathKey);
if (cached) return wrapParcelConfigForRemount((await cached)(container)); // 命中缓存:跳过 bootstrap,直接 remount
}
const parcelConfigObjectGetterPromise = loadApp(app, userConfiguration, lifeCycles);
if (container && containerXPath) appConfigPromiseGetterMap.set(appContainerXPathKey, parcelConfigObjectGetterPromise);
return (await parcelConfigObjectGetterPromise)(container);
};
// 与 registerMicroApps 不同:不注册 application,而是用 single-spa 的 mountRootParcel 直接挂载一个 parcel
if (!started && configuration?.autoStart !== false) {
startSingleSpa({ urlRerouteOnly: frameworkConfiguration.urlRerouteOnly ?? defaultUrlRerouteOnly });
}
microApp = mountRootParcel(memorizedLoadingFn, { domElement: document.createElement('div'), ...props });
// ... 记录同一容器上的多个实例,卸载后清理 ...
return microApp; // MicroApp 就是 single-spa 的 Parcel:mount / unmount / update / getStatus / mountPromise / unmountPromise
}
注意两点:其一,loadMicroApp 返回的就是 single-spa 的 Parcel 对象本身(MicroApp 类型只是 Parcel 的别名),乾坤没有再包一层;其二,singular 默认被置为 false——手动加载的场景天然允许多个子应用同时存在,也因此这条路径不会像路由模式那样等待上一个应用卸载完(例外是挂到同一个容器上的多个实例:wrapParcelConfigForRemount 会让后来者等前面的实例卸载)。
6.3.2 Parcel 与 Application 的通信差异
"Parcel"和"Application"的分化、反映了 single-spa 团队对真实使用场景的一次深度洞察——微前端不只是"路由切换加载整页子应用"这一种使用模式、还有"在一个页面里嵌入一个小组件"这种模式。前者适合用 Application、后者适合用 Parcel——它们的生命周期语义是不同的:Application 的 mount/unmount 是由路由决定、每次路由切换才有机会更新 props;Parcel 则允许宿主在任意时刻调用 update(newProps) 触发子应用重新渲染。这种"根据使用场景分化 API 语义"的做法、是任何大型框架都会演化出来的必然结果——One-size-fits-all 的 API 看起来简洁、实际上总会在某些场景下显得笨拙;分化为多个专门 API 看起来复杂、但每个场景下都有合适的工具。React 的函数组件 vs 类组件、Vue 的 Options API vs Composition API、Rust 的 Fn vs FnMut vs FnOnce、都是同一种"分化出专门工具"的设计智慧。
这里需要理解 single-spa 中两个核心概念的区别:
- Application:由路由自动管理生命周期,通过
registerApplication注册。Props(customProps)在注册时给定(也可以是一个函数,每次调用生命周期时求值),但已挂载的 Application 没有update通道,只能等下次 mount。 - Parcel:由开发者手动管理生命周期,通过
mountRootParcel挂载。Props 可以通过update()动态更新。
loadMicroApp 使用的就是 Parcel 模式。它的 update 方法实际上会触发子应用的 update 生命周期——single-spa 的 src/parcels/mount-parcel.js:214 只有在 config.update 存在时才会把 update 挂到返回对象上,而乾坤的 loader.ts 又只有在子应用导出了 update 时才把它放进 parcel 配置。也就是说,子应用不导出 update,microApp.update 就是 undefined:
// 子应用需要额外导出 update 生命周期
export async function mount(props) { renderApp(props); }
export async function update(props) { rerenderApp(props); } // 仅 loadMicroApp 模式
export async function unmount(props) { destroyApp(); }
6.3.3 loadMicroApp 的通信模式实践
// 主应用 - React 组件中使用 loadMicroApp
function DashboardPanel({ data, onAction }: SubAppProps) {
const containerRef = useRef<HTMLDivElement>(null);
const microAppRef = useRef<MicroApp | null>(null);
useEffect(() => {
if (!containerRef.current) return;
microAppRef.current = loadMicroApp({
name: 'dashboard-panel',
entry: '//localhost:7101',
container: containerRef.current,
props: { data, onAction },
});
return () => { microAppRef.current?.unmount(); };
}, []);
useEffect(() => {
if (microAppRef.current?.getStatus() === 'MOUNTED') {
// update(customProps):传入的对象会替换 parcel 的 customProps,再和 name / singleSpa 等基础 props 合并后传给子应用的 update
microAppRef.current.update?.({ data, onAction });
}
}, [data, onAction]);
return <div ref={containerRef} />;
}
🔥 深度洞察:loadMicroApp 让微前端回归组件化思维
registerMicroApps的心智模型是"多个独立应用共享一个浏览器窗口",loadMicroApp的心智模型是"一个应用中嵌入另一个应用的组件"。前者适合页面级微前端,后者适合组件级微前端。通信方案也随之明朗——页面级用 GlobalState 广播,组件级用 Props 传递。
6.3.4 多实例场景的通信挑战
多实例是微前端通信里最容易翻车的场景——它考验的不只是框架的隔离能力、更是子应用开发者的设计素养。很多子应用在设计之初、根本没有"我可能被同时加载多个实例"这种预期、于是在模块作用域里保存了大量"本应是实例级"的状态——登录用户信息、当前选中的 tab、输入框的值。这些状态在单实例模式下毫无问题、但一旦同一份模块代码要服务多个实例、或同一个实例被反复挂载、就会出现"实例之间互相覆盖、或上一次挂载的状态残留"的诡异 bug。解决这种 bug 的根本方法、不是在框架层打补丁、而是在子应用层做好设计——把"实例级状态"放到实例作用域内、而不是模块作用域内。这个设计原则、和 React 中"不要在组件外部保存组件状态"是同一回事——Both are about keeping instance state with instance lifetime。
loadMicroApp 允许同一子应用加载多个实例。乾坤通过 genAppInstanceIdByName 为每个实例生成唯一 appInstanceId(app、app_1、app_2……),GlobalState 回调按这个 id 独立注册。挂到不同容器上的实例,各自走一遍 loadApp、在各自的沙箱里重新执行入口脚本(import-html-entry 的 evalCode 缓存的只是包好的函数,每次都重新执行函数体,src/utils.js:171-179),所以模块级变量各有一份。真正共用模块级变量的,是命中缓存的情况——同名应用再次渲染到同一个容器(按"应用名 + 容器 XPath"缓存,见 6.3.1),这时复用的是同一份生命周期函数:
let instanceData = null; // 模块级变量
export function mount(props) {
instanceData = props.data; // 复用缓存的生命周期时,上一个实例留下的值还在,这里会覆盖它
}
——这种情况乾坤不会替你清理,需要开发者自己把实例级数据和模块级变量分开。正确做法是以容器作为作用域:
export function mount(props) {
const { container } = props;
const app = createApp(App);
app.mount(container.querySelector('#app'));
container.__vue_app__ = app;
}
export function unmount(props) {
props.container.__vue_app__?.unmount();
}
6.4 通信方案的性能与复杂度权衡
在开始这一节之前、让我们先建立一个"决策光谱"的心智模型——所有通信方案、都可以按"耦合度"从低到高摆在一条轴上:CustomEvent(零耦合、纯事件契约) → BroadcastChannel(同域的事件总线) → initGlobalState(框架提供的共享状态) → Props(父子显式能力传递) → 共享 Store(深度状态耦合)。耦合度越低、系统越能独立演化、但通信成本越高;耦合度越高、开发效率越高、但牵一发动全身。没有绝对最好的方案、只有在"独立演化"和"开发效率"之间、最适合你当前场景的那个平衡点。这种"光谱式思维"、在选型时比"二元对立式思维"要有用得多——它让你避免陷入"A 方案 vs B 方案、谁是永远的赢家"的无谓争论、转而问更有价值的问题——"我当前场景对耦合度的容忍度是多少、所以哪个方案最合适"?
6.4.1 五种方案的全景对比
到目前为止,我们深入分析了乾坤内置的两种通信机制。但在实际项目中,团队往往会结合或替代使用其他方案——有些是浏览器原生 API,有些是社区成熟的状态管理库。让我们系统性地对比五种主流方案,帮助你建立完整的技术视野:
// 方案 1:乾坤 initGlobalState(已详细分析)
// 方案 2:Props 传递(已详细分析)
// 方案 3:CustomEvent(浏览器原生)
// 方案 4:BroadcastChannel(浏览器原生,跨 Tab)
// 方案 5:共享 Store(Redux/Zustand/Pinia)
方案 3:CustomEvent
function emitMicroEvent(eventName: string, detail: any) {
window.dispatchEvent(new CustomEvent(`micro:${eventName}`, { detail }));
}
function onMicroEvent(eventName: string, handler: (detail: any) => void) {
const listener = (event: CustomEvent) => handler(event.detail);
window.addEventListener(`micro:${eventName}`, listener as EventListener);
return () => window.removeEventListener(`micro:${eventName}`, listener as EventListener);
}
CustomEvent 方案的优势是零依赖——它是浏览器原生 API,不需要引入任何库。但它在沙箱环境下的表现值得关注:
// 三代沙箱在 mount 阶段都会挂上 patchWindowListener(src/sandbox/patchers/windowListener.ts):
// - 子应用调用的 window.addEventListener 会被记录进一个 listenerMap
// - 子应用卸载时,沙箱遍历 listenerMap 逐个 removeEventListener
// - 不需要手动 removeEventListener,但你对监听生命周期失去了部分控制
//
// 两个边界:
// - 只拦截 window 上的监听,document / 具体元素上的 addEventListener 不受管理
// - ProxySandbox(默认)下,patch 改的是 proxy 上的 addEventListener,主应用(或其他子应用)
// 在 window 上注册的监听不会被误删;SnapshotSandbox 与 LegacySandbox 下 patch 直接改写真实
// window.addEventListener,子应用挂载期间主应用注册的 window 监听也会记进它的 listenerMap、随它卸载被移除
另外,CustomEvent 没有"状态"概念——它是纯事件驱动的。如果子应用在事件发出之后才加载,它将错过之前的所有事件。这与 initGlobalState 不同——后者的 fireImmediately 参数允许新订阅者立即获取当前状态。
方案 4:BroadcastChannel
// 主应用
const channel = new BroadcastChannel('micro-frontend');
channel.postMessage({ type: 'USER_UPDATED', payload: { id: 1001, name: '杨艺韬' } });
// 子应用
const channel = new BroadcastChannel('micro-frontend');
channel.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data;
if (type === 'USER_UPDATED') updateUser(payload);
};
export function unmount() { channel.close(); }
BroadcastChannel 的独特价值在于跨 Tab 通信。如果你的微前端应用允许用户同时打开多个浏览器 Tab(比如后台管理系统),用户在一个 Tab 中修改了设置,其他 Tab 需要同步更新——这是 initGlobalState 无法做到的。
// BroadcastChannel 的限制
// 1. 数据必须是可序列化的(不能传递函数、DOM 引用、类实例)
// 2. 是异步的(postMessage 不会立即触发 onmessage)
// 3. 没有"状态"概念——纯事件驱动,不保存历史数据
// 4. 乾坤沙箱不代理也不限制它(sandbox/globals.ts 里只是一个原生全局),也不会在卸载时替你 close,需在 unmount 里自己关闭
方案 5:共享 Store
// 主应用创建 store,通过 props 传递
import { create } from 'zustand';
export const useGlobalStore = create<GlobalStore>((set) => ({
user: null, theme: 'light', locale: 'zh-CN',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));
registerMicroApps([{ name: 'sub-app', props: { globalStore: useGlobalStore } }]);
// 注意:子应用若自带一份 React,不能把 globalStore 当 Hook 调用(它绑定的是主应用那份 React),
// 应改用 globalStore.getState() / globalStore.subscribe()
// 或通过 externals 共享模块
// webpack externals: { '@shared/store': 'SharedStore' }
// 子应用直接 import { useGlobalStore } from '@shared/store';
6.4.2 性能对比
五种方案没有统一的公开基准数据,具体数字取决于状态体积、订阅者数量和浏览器实现,所以这里只给出按单次通知开销从低到高的定性排序:
const costOrder = [
{ method: 'Props 传递', note: '直接函数调用,无中间层' },
{ method: '共享 Store', note: '状态更新 + selector 比较' },
{ method: 'CustomEvent', note: 'DOM 事件分发' },
{ method: 'initGlobalState', note: '每个订阅者两次 cloneDeep(state 与 prevState)' },
{ method: 'BroadcastChannel', note: '结构化克隆 + 跨上下文异步投递' },
];
几个关键观察:
- Props 传递是最快的——因为它本质上就是函数调用,没有任何中间层。
- BroadcastChannel 是最慢的——结构化克隆(structured clone)要跨执行上下文投递,而且是异步的。
initGlobalState的瓶颈在cloneDeep——对于小型状态对象这点开销可以忽略;但如果全局状态中包含大型数组,每次通知的成本会随状态体积和订阅者数量线性放大。- 共享 Store 的 selector 机制是一个巨大的优势——Zustand 的
subscribe配合subscribeWithSelector中间件即可按 selector 订阅,只有被选择的状态片段变化时才触发回调。initGlobalState没有这个能力,每次任何 key 变化,所有订阅者都会被通知。
6.4.3 复杂度对比
// 维护复杂度评估
// 1. initGlobalState
// 引入成本:★☆☆☆☆(零配置,乾坤内置)
// 类型安全:★★☆☆☆(需要手动维护类型定义)
// 调试体验:★★☆☆☆(没有 DevTools,只能 console.log)
// 团队协作:★★★☆☆(主应用定义 Schema,子应用遵循)
// 适用规模:3-5 个子应用,状态字段 < 20 个
// 2. Props 传递
// 引入成本:★☆☆☆☆(零配置)
// 类型安全:★★★★☆(可以用 TypeScript 接口约束)
// 调试体验:★★★☆☆(props 可以在组件树中追踪)
// 团队协作:★★★★☆(接口契约清晰)
// 适用规模:任意规模,但仅限主应用 → 子应用方向
// 3. CustomEvent
// 引入成本:★★☆☆☆(需要封装工具函数)
// 类型安全:★★☆☆☆(event.detail 是 any)
// 调试体验:★★★☆☆(浏览器 Event 面板可见)
// 团队协作:★★☆☆☆(事件名容易冲突,需要命名规范)
// 适用规模:简单的事件通知场景
// 4. BroadcastChannel
// 引入成本:★★☆☆☆(原生 API,但需要处理兼容性)
// 类型安全:★☆☆☆☆(MessageEvent.data 是 any)
// 调试体验:★☆☆☆☆(异步、跨 Tab,极难调试)
// 团队协作:★★☆☆☆(消息格式需要团队约定)
// 适用规模:跨 Tab 同步场景
// 5. 共享 Store
// 引入成本:★★★★☆(需要额外引入状态管理库,配置 externals 或 shared)
// 类型安全:★★★★★(完整的 TypeScript 支持)
// 调试体验:★★★★★(Redux DevTools / Zustand DevTools)
// 团队协作:★★★★★(统一的状态管理范式)
// 适用规模:大型项目,10+ 子应用,复杂状态逻辑
6.4.4 实战决策树
决策树是把"复杂的多维度决策"简化为"几次简单的二元选择"的工具——它的优点是让新人也能做出合理的选择、不会陷入纠结;缺点是可能过度简化、错失最优解。所以决策树最适合的场景、是"大多数标准情况"——对于少数复杂场景、你需要回到原理层面做精细权衡、不能只看决策树。
你的微前端项目需要什么类型的通信?
│
├─ 数据共享(用户信息、主题、权限等)
│ ├─ 子应用 ≤ 5,字段 < 20 → ✅ initGlobalState
│ ├─ 子应用 > 5 或数据复杂 → ✅ 共享 Store(Zustand/Redux)
│ └─ 需要跨 Tab 同步 → ✅ BroadcastChannel + 本地 Store
│
├─ 能力注入(路由、权限、错误上报)→ ✅ Props 传递
│
├─ 事件通知(无状态事件)
│ ├─ 主应用↔子应用 → ✅ Props 回调函数
│ └─ 子应用间广播 → ✅ CustomEvent
│
└─ 组件级嵌入 → ✅ loadMicroApp + Props + update()
6.4.5 混合方案的架构设计
在真实的生产级项目里、极少有"一招鲜"的情况——大型微前端项目几乎都是"多种通信方案分工协作"。这种分层组合的思路、和后端的分布式系统架构高度一致——后端系统也不会用"一种 RPC 框架解决所有通信"、而是根据场景选择:gRPC 做服务间调用、Kafka 做事件驱动、Redis 做缓存共享、HTTP 做外部 API。同样、微前端项目里——Props 传递能力、initGlobalState 共享基础数据、CustomEvent 做跨组件通知、BroadcastChannel 做跨 Tab 同步、共享 Store 管理复杂状态——每一种方案在各自的场景里发挥最佳。大型系统工程的精髓、从来不在"选一个方案"、而在"设计一张合适的方案拼图"。
大型项目应分层组合:
// 第一层:Props —— 能力注入
registerMicroApps([{
name: 'sub-app',
props: {
navigate: (path: string) => router.push(path),
checkAuth: (perm: string) => authService.check(perm),
reportError: (err: Error) => errorService.report(err),
},
}]);
// 第二层:initGlobalState —— 轻量级数据共享
const actions = initGlobalState({ user: currentUser, theme: 'light', locale: 'zh-CN' });
// 第三层:共享 Store(可选)—— 复杂业务状态
import { useCartStore } from '@shared/stores';
// 第四层:CustomEvent(可选)—— 临时性事件通知
window.dispatchEvent(new CustomEvent('micro:order:created', { detail: { orderId: '12345' } }));
将这些封装为统一 API,降低子应用接入成本:
import { isEqual } from 'lodash';
interface MicroCommunication {
call: <T>(capability: string, ...args: any[]) => T;
getState: <T>(key: string) => T;
watch: <T>(key: string, callback: (value: T, prev: T) => void) => () => void;
setState: (key: string, value: any) => void;
emit: (event: string, payload?: any) => void;
on: (event: string, handler: (payload: any) => void) => () => void;
}
function createMicroCommunication(props: any): MicroCommunication {
// 每个实例在 deps 里只有一个槽位:只注册一次监听,再在本地分发给各个 watcher
let currentGlobalState: Record<string, any> = {};
const watchers = new Set<(state: Record<string, any>, prev: Record<string, any>) => void>();
props.onGlobalStateChange((state: Record<string, any>, prev: Record<string, any>) => {
currentGlobalState = state;
watchers.forEach((w) => w(state, prev));
}, true);
return {
call: (capability, ...args) => {
const fn = props[capability];
if (typeof fn !== 'function') throw new Error(`Capability "${capability}" not found`);
return fn(...args);
},
getState: (key) => currentGlobalState[key],
watch: (key, callback) => {
// 回调拿到的是深拷贝,对象值每次都是新引用,要用 isEqual(lodash)比较
const watcher = (state: Record<string, any>, prev: Record<string, any>) => {
if (!isEqual(state[key], prev[key])) callback(state[key], prev[key]);
};
watchers.add(watcher);
return () => { watchers.delete(watcher); };
},
setState: (key, value) => props.setGlobalState({ [key]: value }),
emit: (event, payload) => window.dispatchEvent(new CustomEvent(`micro:${event}`, { detail: payload })),
on: (event, handler) => {
const listener = (e: Event) => handler((e as CustomEvent).detail);
window.addEventListener(`micro:${event}`, listener);
return () => window.removeEventListener(`micro:${event}`, listener);
},
};
}
🔥 深度洞察:通信架构的演进方向
乾坤的
initGlobalState随 qiankun 2.0 在 2020 年发布(globalState.ts的文件头标注 2020-04-10,v2.0.0 发布于 2020-04-16),那时微前端还处于"能跑起来就不错了"的阶段。到 2026 年,Module Federation 2.0 的 shared scope(Rspack 同样支持)提供了另一条路:共享模块在构建时声明、运行时在 share scope 里协商出实际加载的那一份——多个应用可以直接拿到同一个 store 模块,而不必在运行时传递 store 引用。这是一次思路上的转换:从运行时的消息传递,到模块级的共享。但运行时通信不会消失——它仍然适合处理动态事件和临时状态。更可能的形态是模块共享(静态依赖)+ 运行时通信(动态事件)的组合。
6.4.6 类型安全的通信层
initGlobalState 的最大痛点是 Record<string, any>——把 user.role 写成 user.roles、把 theme 写成 them 这类错误在编译时不会被发现,会悄悄把 undefined === 'admin' 这种永假条件带进运行时。用极低成本的包装可以把错误提前到编译时:
interface GlobalState {
user: { id: number; name: string; avatar: string; role: 'admin' | 'user' } | null;
theme: 'light' | 'dark';
locale: 'zh-CN' | 'en-US' | 'ja-JP';
}
function createTypedGlobalState<T extends Record<string, any>>(initialState: T) {
const actions = initGlobalState(initialState);
return {
onChange(callback: (state: T, prevState: T) => void, fireImmediately?: boolean) {
actions.onGlobalStateChange(callback as any, fireImmediately);
},
setState<K extends keyof T>(key: K, value: T[K]) {
actions.setGlobalState({ [key]: value } as any);
},
batchUpdate(partial: Partial<T>) {
actions.setGlobalState(partial as any);
},
offChange() { actions.offGlobalStateChange(); },
};
}
const globalState = createTypedGlobalState<GlobalState>({
user: null, theme: 'light', locale: 'zh-CN',
});
// 现在有完整的类型提示和编译时检查
globalState.setState('theme', 'dark'); // ✅ 类型正确
globalState.setState('theme', 'blue'); // ❌ TypeScript 报错:'blue' 不在 'light' | 'dark' 中
globalState.setState('typo', 'value'); // ❌ TypeScript 报错:'typo' 不在 keyof GlobalState 中
globalState.onChange((state) => {
// state.user 的类型是 { id: number; name: string; ... } | null
if (state.user) {
console.log(state.user.name); // 完整的类型推断和自动补全
}
});
这个包装层的代价几乎为零——它只是在编译时增加了类型检查,运行时没有任何额外开销。但它带来的收益是巨大的:拼写错误在编译时就被发现,IDE 提供完整的自动补全,代码审查时一眼就能看出状态结构。在任何 TypeScript 微前端项目中,这种封装都应该在项目初期就建立。
本章小结
initGlobalState基于发布订阅模式,使用模块级变量存储状态和订阅者,通过深拷贝确保状态不被绕过正式 API 直接修改- 主应用拥有完全的状态控制权(可以添加新 key),子应用只能修改已有 key——这是"合同制"的权限设计
- Props 传递是依赖注入模式,适合传递能力(函数、服务实例),而 GlobalState 适合传递数据
loadMicroApp通过 Parcel 机制支持动态 Props 更新,适用于"组件级"微前端场景- 五种通信方案各有适用场景:initGlobalState(轻量数据共享)、Props(能力注入)、CustomEvent(事件通知)、BroadcastChannel(跨 Tab)、共享 Store(复杂状态)
- 大型项目应采用分层通信架构,并尽早建立类型安全的通信层封装
通信设计的本质是"信任边界的设计"——决定哪些子应用可以访问哪些数据、哪些事件可以被哪些子应用监听、哪些能力必须经过主应用审批。建议的做法是在项目开始阶段就沉淀一份"通信契约"文档:哪些场景用 initGlobalState、哪些场景用 Props、哪些场景用 CustomEvent、哪些情况禁止(如子应用之间跳过主应用直接通信),新增子应用时按此契约评审。这与《Claude Code 源码深度解析》专栏第 9 章讨论的多模式权限模型、《Tokio 源码深度解析》专栏第 13 章讨论的 mpsc / broadcast / watch / oneshot 四种 channel 的分层,都是在同一问题空间里的不同投影。
思考题
-
源码理解:
emitGlobal每次通知订阅者时都执行cloneDeep。如果改为只在setGlobalState入口做一次深拷贝,然后把同一个拷贝传给所有订阅者,会有什么潜在风险?请从多个订阅者并发修改回调参数的角度分析。 -
设计分析:乾坤限制子应用不能添加新的顶层 key。请设计一个方案,在保留这个限制的前提下,允许子应用"申请"新的状态字段——主应用审批后生效。这个方案的 API 应该是什么样的?
-
方案对比:一个电商平台有 8 个子应用,需要实现:(a) 用户登录状态同步,(b) 子应用 A 创建订单后通知子应用 B 刷新物流列表,(c) 主应用的权限校验服务需要被所有子应用调用。请为每种需求选择最合适的方案并说明理由。
-
性能优化:全局状态包含 5000 条记录的数组,每当有新消息时需要通知 10 个子应用。使用
initGlobalState会产生多少次深拷贝?请提出优化方案。 -
架构设计:Module Federation 2.0 的 shared scope 允许多个应用共享同一个 Zustand store(构建时声明、运行时协商)。这种"模块共享"与乾坤的"运行时通信"在本质上有什么区别?各自的故障模式(failure mode)是什么?