微前端拆到最后,最难受的地方往往不是路由,不是组件加载,而是状态。我当初接手一个微前端改造项目时,子应用各自管理登录态、用户信息、权限,看上去谁都不依赖谁。结果线上第一周就出了个大问题:用户在A应用改了昵称,切到B应用一看,还是老昵称;登录过期时,主应用跳了登录页,子应用却还傻傻地发请求,一个页面能蹦出十几个401。这事逼着我把目光从"怎么拆"转向"拆完之后怎么合",于是就有了这个模块:微前端基座store。
它不是什么复杂框架,本质上是挂在基座层的一套状态存储与事件分发机制。主应用、子应用共享同一份全局状态,谁改谁都能感知,跨应用通信不用再绕"自定义事件满天飞"的老路。如果你也在做微前端,并且正在被各应用之间的数据同步、状态归属、重复请求这些问题折磨,这篇内容应该对你有用。我会把设计取舍、核心实现、初始化链路、事件时序、旧项目迁移,以及我实际踩过的几个坑都摊开讲。
1. 为什么需要基座Store:微前端碎掉的那块"全局记忆"
微前端的本质是"分治"——把单体应用按业务域切成多个独立子应用,每个子应用有自己的脚手架、依赖、构建产物。分治解决了团队协作和独立部署的问题,却制造了一个新的副作用:应用间的全局状态被物理性割裂了。而一个完整的用户操作闭环,恰恰是跨应用流动的。
1.1 三个典型症状
最直接的是重复请求。拿最常见的用户信息来说,A应用要渲染头像和昵称,向登录服务拉了全量userInfo;B应用也要展示归属部门,不知道A已经拉过了,又发一次请求。子应用越多,这类重复请求越多,服务端被无意义的流量打满不说,更糟糕的是各应用拿到的数据可能版本还不一致。
第二个症状是全局UI状态的失同步。主题色、语言包、侧边栏折叠状态、顶部导航的高亮项,这些在单体应用里一个store就管住的状态,到了微前端里成了烫手山芋。你想在子应用里统一一下主题,就得在每个子应用各自实现一套"读取基座主题并监听变化"的逻辑。少做一个,就会出现某个应用白底、另一个应用黑底的割裂感。
第三个症状是跨应用的业务协作没有通路上限。用户走到A应用,把一个事项标记为"已处理",B应用需要立刻刷新自己的待办列表。现实中,大家最常用的做法是扔一个全局事件出去,或者直接往window上挂一个内存态。短期能跑,但事件没有统一约束,谁发、谁收、参数是什么全靠默契,时间一长就是在积累技术债。
1.2 Store的双重定位
所以基座Store在设计之初就给自己定了两条路:一是做状态底座,二是做事件通道。状态底座负责存放那些跨应用共享的数据,解决"数据放哪、谁来改、怎么感知"的问题;事件通道负责在状态变化之外,把一次性的业务动作从A应用传导到B应用,解决"这件事发生了、其他人要不要知道"的问题。
这个定位决定了它和子应用各自的内部store是分工关系,不是替代关系。子应用内部的页面级状态、组件局部状态、表单草稿,这些照旧留在子应用自己手里。只有涉及"两个以上应用都需要读"或"一个应用改了另一个应用要响应"的状态,才往上放到基座Store里。这条边界我们在前期评审里反复强调,否则人人都往里面塞东西,基座store就变成了一个谁都不敢动的"集中式大仓库"。
2. 基座Store的核心结构:状态命名空间与事件总线的业务模型
基础方案其实有不少。直接用Redux、Pinia这类现成状态库做跨应用通信,能跑但总有点别扭;事件总线用mitt或自己写一个发布订阅器,通信解决了,状态还是无处安放。我在实际落地过程中,选择了"分层状态 + 事件总线"的组合模型,整体非常轻,没有引用任何重量级依赖。
2.1 状态层:命名空间是第一公民
基座Store内维护一个全局唯一的state树。这棵树最外层不是业务字段,而是命名空间:
// store.state 结构 { global: { userInfo: null, theme: 'light', locale: 'zh-CN' }, app1: { listFilters: { status: 'pending' }, todoCount: 0 }, app2: { orderDetail: null } }global放主应用和所有子应用都要用的通用数据,比如用户信息、主题、权限点。每个子应用一个独立命名空间,空间内放它需要和外部共享/被外部感知的数据。这种结构的理由是隔离:A应用写入app1命名空间并不影响app2,谁也不会在set的时候把别人的字段覆盖掉。后续做不同子应用的数据可见性控制、持久化白名单、调试面板过滤,都以命名空间为基本单位,语义非常清晰。
2.2 事件层:订阅发布与弱约束
事件总线的实现不复杂:
class Bus { constructor() { this.events = new Map(); this.pendingQueue = new Map(); } on(eventName, handler, options = {}) { const { once = false, namespace = '' } = options; const key = namespace ? `${namespace}:${eventName}` : eventName; if (!this.events.has(key)) { this.events.set(key, []); } this.events.get(key).push({ handler, once }); // 处理"先发布后订阅"的遗留事件 if (this.pendingQueue.has(key)) { const queue = this.pendingQueue.get(key); queue.forEach(payload => handler(payload)); this.pendingQueue.delete(key); } return () => this.off(key, handler); } off(eventName, handler) { const key = eventName; const list = this.events.get(key); if (!list) return; const idx = list.findIndex(item => item.handler === handler); if (idx > -1) list.splice(idx, 1); if (list.length === 0) this.events.delete(key); } emit(eventName, payload) { const key = eventName; const list = this.events.get(key); if (!list || list.length === 0) { // 暂存给未来订阅者 this.pendingQueue.set(key, [payload]); return; } list.slice().forEach(item => { item.handler(payload); if (item.once) this.off(key, item.handler); }); } }这里有一个容易被忽略的细节:发布时没人订阅怎么办。微前端里子应用是懒加载的,A应用发布事件时,B应用可能还没mount完,订阅注册自然晚了一步。如果直接丢弃这个事件,B应用之后就永远无法感知这次变更。上面的实现里,emit发现无订阅者时会把最后的payload暂存在pendingQueue里,等到将来有监听者注册时立即补发一次。这个"先发布后订阅"的补偿机制,是我在实际迭代里踩了大坑才加进去的,后面专门讲。
2.3 订阅接口的"selector"式定位
订阅不能永远整棵state树通知,否则每个子应用一改数据,其他应用的所有组件全部re-render,性能很快就崩了。我给store的subscribe接口加了path定位,让监听者精确到字段:
store.subscribe('global.userInfo', (newVal, oldVal) => { // 只有 userInfo 变化才会触发 renderHeader(newVal); });global.userInfo这种点路径会做逐级拆分,订阅时记录目标叶子路径,set时把本次变更涉及的所有顶层命名空间、二级路径进行比对,交集命中才触发回调。这套东西实现起来很短,效果却立竿见影。跨应用状态同步最怕的就是"改一处、全量刷",selector式的订阅把抖动限制到了最小范围。
3. 初始化链路与状态合并:从主应用预置到子应用回填
基座Store难的不是API设计,是初始化时序。微前端主应用和子应用各自有独立的启动过程,谁先跑完、谁先写store,没有规则约束就会乱。我还观察到不少团队在这一步直接失败:主应用先set了一份状态,子应用挂载时又set了一份默认值,后者就把前者覆盖了,用户看到的结果是"明明登录了,子应用里却像没登录一样"。
3.1 三个阶段固定下来
我们最终把store初始化固定成三个阶段,并写成规范写进基座文档。
第一阶段,主应用在加载任何子应用之前,执行store预置。登录态、用户信息、权限、主题这些基础数据,在主应用初始化时就写入(如果用户已登录,否则写入空态)。预置阶段的特点是"只写入global命名空间",子应用命名空间此时一律为空。
第二阶段是子应用mount前的基本状态回填。比如A应用需要从接口拿自己的菜单配置,它可以在mount阶段发起异步请求,请求完成后立即写入自己的命名空间。这一阶段不需要等待其他应用,各自写各自的,互不干扰。
第三阶段是"ready"门闩。主应用在global.ready标志位写入true前,会把所有基础状态做一次快照校验;子应用内部如果设置了"等待global.ready再渲染主界面"的逻辑,就在该标志位之后放开。这样从根上避免了子应用先渲染、后注数据的白屏闪烁。
3.2 深合并、不可变与写保护
当一个子应用set自己的命名空间时,如果目标路径已经存在对象,我们不能整棵替换,因为另一个应用可能正持有该对象的子集引用。因此set实现里内置了深合并:
function isPlainObject(value) { return Object.prototype.toString.call(value) === '[object Object]'; } function deepMerge(target, source) { for (const key of Object.keys(source)) { const srcVal = source[key]; if (isPlainObject(srcVal)) { if (!isPlainObject(target[key])) target[key] = {}; deepMerge(target[key], srcVal); } else { target[key] = srcVal; } } return target; }写保护是另一道保险。开发环境下,我会对state树的每个命名空间执行Object.freeze或挂一层Proxy,任何子应用试图直接store.state.app1.xxx = yyy修改别人状态,控制台立刻跳警告。理由很简单:跨应用共享状态最忌讳"改得静悄悄",所有变更必须走store的set方法,变更才有迹可循、才能派发通知。
3.3 持久化白名单
有些状态需要刷新后继续保留,比如用户的列表筛选条件、主题色;有些状态绝对不该持久化,比如临时弹窗开关。基座Store提供持久化白名单配置:
initStore({ persist: { enabled: true, // 只有白名单内的路径会被写入 localStorage whitelist: ['global.theme', 'global.locale', 'app1.listFilters'] } });实现时hook掉set方法,白名单命中就把最新值序列化存入存储。恢复时机非常关键:必须在子应用回填之前恢复,否则子应用读到的还是空态。我们在主应用入口的store初始化方法里同步读取并merge,不搞异步恢复,就是为了避免"旧值晚到一步、新值已经被覆盖"的脏读场景。
4. 跨应用事件通知的时序问题与命名约束
如果说状态层是"数据的家",事件层就是"动作的信使"。实际写业务时,你会发现很多时候并不关心共享状态的最新值,只关心"某件事是不是发生了"。比如A应用告诉B应用"库存变了,你刷新一下列表",B应用并不需要存一份库存数据,它只需要收到一个信号然后拉最新列表。这种事我用事件总线做,而不是让B应用订阅某个库存字段。
4.1 事件命名的"视觉噪音"
刚开始大家都在一个对象上挂事件,什么refreshList、updateData、languageChanged都有,很快出现两个问题:一是不知道谁发的,二是业务业务之间互相影响。后来我们定下一个硬性约定:事件名必须带子应用前缀,格式为appId:业务动作,例如app1:inventory-changed、app2:order-paid。命名空间由基座在初始化时从应用meta信息里自动注入,业务代码里不需要手写,但打印日志时前缀清晰可见,排查问题时一眼就能定位来源。
4.2 发布时没有订阅者:延迟补偿
上面代码里pendingQueue的作用再展开聊一聊。初次上线的版本没有pending机制,A应用在初始化完成后立即emit了一个app2:data-refreshed事件,但B应用是懒加载的,至少要等用户点进B的菜单才会开始注册事件——这中间隔了整整几分钟甚至几小时。等到B应用最终加载,这个事件早就错过了,B应用里的列表永远停留在初始态。
正确做法就是我刚才说的:emit时如果事件列表为空,就把最近的payload暂存起来。B应用mount后第一次注册该事件时,基座能够判断"这条事件是历史遗留事件",应当补发一次。补发次数要限制在最近一次,避免把几百条历史事件全部回放,那会让新订阅者崩溃。
4.3 重复订阅与卸载清理
子应用被卸载后被再次加载是微前端里的常见场景。每次mount,子应用的入口代码都会执行一遍,如果入口里注册了一个事件监听,又没有在卸载时移除,那第二次mount后就会出现两个监听器,同一个事件触发两次回调。
解决办法分两层。代码层要求所有通过store.on注册的事件,其返回值就是一个取消订阅函数,子应用在unmount生命周期里统一调用;架构层则在基座Store内部维护一张"订阅者来源表",当某个子应用被卸载时,基座会自动把来源为该子应用命名空间的所有监听器全部摘除。这个自动清理机制,是防止"二次进入应用事件重复触发"最有效的手段。
5. 从旧方案迁移到基座Store:让存量子应用低成本接入
老项目不会等你把基座Store做得完美了再接入,大多数情况是线上已经跑着一个"localStorage+全局事件"的旧方案,业务方没有时间推倒重来。
5.1 旧接口与新API的差异
| 能力 | 旧方案(迁移前) | 基座Store(迁移后) |
|---|---|---|
| 状态读取 | window.globalData.xxx | store.get('global.xxx') |
| 状态更新 | window.globalData.xxx = val(静默) | store.set('global.xxx', val)(带通知) |
| 变更感知 | 自己派发CustomEvent再自己监听 | store.subscribe('global.xxx', cb) |
| 命名空间 | 无 | global、app1、app2等 |
| 历史事件补偿 | 无 | 有(pendingQueue机制) |
如果让子应用把所有的window.globalData用法一次性改成新API,改动量会非常大,很多页面是五六个组件里混着读。我的做法是提供一个兼容适配层,在基座里拦截旧的读写方式,把读写转发到store内部:
// 兼容层 v1-adapter.js Object.defineProperty(window, 'globalData', { get() { // 这里用 Proxy 做路径转发,读取具体字段时定位到 store return new Proxy({}, { get(target, prop) { return store.get(`global.${prop}`); }, set(target, prop, value) { store.set(`global.${prop}`, value); return true; } }); }, set() { throw new Error('不允许整体替换 globalData,请使用 store.set 修改字段'); } });这样子应用的既有代码不用动,业务方甚至感知不到底层已经换成了基座Store。但注意,这只是一种过渡手段。兼容层应该在新接入的应用里逐步废弃,最终目标是让所有应用直接调用storeAPI,避免长期维护一套"假window"。
5.2 切换验证的先后顺序
迁移不是一蹴而就。我建议先生命周期短的业务做试点,比如跨应用的全局通知事件,先把A->B的"列表刷新信号"从CustomEvent改为store事件,运行一两个迭代确认稳定,再推广到全局用户信息、主题这些核心共享状态。每一步切换都要写一个简单的冒烟清单:登录态切换后所有应用一致、A应用改动后B应用能收到通知、刷新页面后状态能恢复。贪多贪快,反而会让团队觉得这个store是个坑。
6. 实际踩过的三个坑:定位思路比修复本身更有价值
基座Store上线小半年,踩过的坑不少。我挑三个具有代表性的,讲讲当时是怎么一步步定位的,你遇到类似问题时可以直接复用这套排查思路。
6.1 子应用的defaultState把另一个子应用的状态覆盖了
现象:A应用挂载时写入app1: { activeMenu: 'data' },切到B应用后B应用什么都没配置,但A应用那里的app1数据变成{}。
排查链路:先在store的set方法里打日志,发现A应用的写操作并没有被调用,说明不是A自己覆盖的。再往上找,发现B应用的初始化代码里有一段"store.reset()"逻辑,本来目的是清空自己的命名空间,但我漏了传参,reset方法默认把整棵state树都置空重来。根因就是重置逻辑没有限定命名空间。修复很简单:reset方法必须声明namespace参数,无参数时直接throw,不允许全清。
6.2 状态set一次,子应用渲染了三次
现象:子应用某些页面,用户在某个交互后触发store.set一个对象,观察渲染走查发现组件被刷新了三次。
逐层分析:第一次渲染来自store本身对变更事件的通知;第二次源于store内部一个"同步旁路",我把部分被订阅的字段同时派发给了一个新的messageChannel,组件注册了双通道,一次变化收两遍;第三次是一个页面里有两个组件订阅了同一路径的邻近节点,selector的路径匹配用的includes写法过于宽松,等于把父子路径都匹配上了。三个问题一叠加就成了三次。
修复方案:统一改为只走事件通道,set方法内部不再双写;selector匹配从简单的includes改为精确路径匹配,过滤掉子路径。这之后渲染次数恢复为一次。
6.3 先发布的事件"丢了"
现象:A应用在基座ready后立刻emit了一个业务事件,B应用晚两秒才加载,后来B应用怎么都刷新不出新数据。
第一次排查以为是生命周期钩子顺序问题,把B的注册代码从入口文件挪到挂载后依然不行。后来翻了store源码,才想起事件总线只在"当前有监听者"的情况下才会同步派发,没有监听者时直接静默丢弃。这就是我前面说的pendingQueue机制缺失的版本。加上暂存补发之后,问题消失。
这个坑很典型——很多事件总线库不会告诉你"这次emit没有送达",业务方只会在几秒钟后才察觉数据不对,而排查时又容易往生命周期方向钻,忽略了总线本身的时序能力。所以我强烈建议:任何跨应用事件机制,都要处理"发布时无订阅者"这个边界场景,不是默认丢弃,而是给一个可配置的补偿策略。
7. 关于基座Store调试与观察的一点经验
光有API还不够,跨应用的状态调试比单体应用难得多,因为你很难直接看到一个状态是哪个应用在哪个时刻改的。我后来在基座里增加了一个极简的调试面板:监听store的每次set,把命名空间、路径、旧值、新值、调用来源(用new Error().stack抓调用栈)实时记录到一个环形缓冲区,并在页面右上角挂一个可折叠面板。
store.hook('beforeSet', ({ path, oldVal, newVal, source }) => { debugRing.push({ time: Date.now(), path, oldVal, newVal, source }); });当线上反馈"状态不对"时,打开面板直接看时间线,比瞎猜快得多。调用来源靠stack实现,开发环境保留,生产环境会自动关闭,避免序列化开销。
另一点,我习惯在整个大版本迭代期间每周review一次各个应用实际写入了哪些命名空间、写入了哪些字段。如果一个命名空间的字段越来越多,说明这个子应用可能把太多不该共享的东西放上来了,该收敛得收敛。基座Store不是无脑扩容的仓库,它应该始终保持在"只有真正需要跨应用的数据才在里面的"克制状态。
这套东西从设计到落地,最核心的心得就是一句话:微前端的"合"比"拆"更难,而合的关键不是组件复用,是状态与通信的契约化。基座Store契约定清楚,后续接入新应用的技术成本会直线下降。如果你也在做类似的事情,建议先从最小可用的状态命名空间开始,不要一上来就追求完美,跑通之后再根据实际问题逐步补事件补偿、持久化、调试面板这些能力。