Module Federation + Effector 跨框架共享 Store
发布时间:2026/10/29 7:13:12 锦皓数字建站

跨框架微前端里最容易被低估的一件事是状态到底归谁管。React 子应用和 Vue3 子应用各自维护一份 Store页面看起来能跑一旦用户在主应用改了筛选条件远程面板还停在旧数据联调就开始互相甩锅。我这次拿 Module Federation 和 Effector 做了一次组合React 作为 HostVue3 作为 Remote两个框架共享同一份 Store事件、派生状态、异步副作用都走同一条链路。标题里的 module-federation-examples 实战、Effector 跨框架共享 Store、React Vue3说的就是这套方案。它适合已经在做微前端拆分、又不想把状态同步退化成 postMessage 或 URL 参数的团队参考也适合想理解 Module Federation 共享依赖边界的前端。下面我按实际落地的顺序从方案取舍、Store 建模、联邦配置、联调步骤到踩坑排查完整拆一遍代码可以直接照着搭一个最小可运行版本。1. 方案整体设计与技术选型拆解1.1 跨框架共享状态到底难在哪React 和 Vue3 的状态模型不一样React 靠外部 Store 加订阅触发重渲染Vue3 靠响应式依赖收集。微前端又把应用拆成独立构建、独立部署的远程容器Module Federation 能帮你把远程模块加载过来但它不会自动帮你保证运行时实例唯一。很多人第一次做跨框架共享会把 Store 挂在 window 上或者通过 props 一层层往远程组件里传短期能跑后面路由一变、远程组件嵌套一深维护成本立刻上来。window 方案还有类型丢失、热更新污染、测试隔离困难的问题props 方案则要求 Host 和 Remote 的组件契约高度耦合远程组件很难独立演进。另一种常见做法是 postMessage。它适合跨 iframe、跨窗口的松耦合通信但用来做高频状态同步会引入序列化成本、消息时序和重复广播问题。你改一个计数器消息发过去再发回来中间还要处理旧消息覆盖新状态调试时非常难受。Effector 的优势在这里就体现出来了它的 store 是框架无关的可订阅对象事件是显式的业务动作React 侧用 effector-react 订阅Vue3 侧用 store.watch 转成 ref两个框架不需要互相知道对方的存在。Module Federation 负责把远程 Store 模块和远程挂载函数装载进来Effector 负责状态流转职责清晰排查问题也有明确边界。我选这个组合还有一个现实原因module-federation-examples 里已经有不少 Host/Remote、shared 依赖、异步边界的参考模式工程结构可以借鉴但跨框架共享 Store 这块需要自己把“单例”这件事做扎实。下面表格是我在方案评审时常用的对比方便你判断是否值得换成这套方案优点主要问题适用场景props 逐层传递实现简单、类型直接跨应用耦合重远程组件难独立父子组件、同构建内window 全局对象上手快污染全局、类型弱、测试难隔离临时 DemopostMessage跨窗口松耦合异步时序、序列化成本iframe、跨标签页共享 npm 包类型好、可版本化容易打出多份实例同版本、同构建体系联邦暴露 Store 模块单例可控、跨框架需要 shared 依赖配置微前端共享状态1.2 职责边界联邦负责装载Effector 负责状态这套方案里Vue3 Remote 暴露两个东西一个是./store里面是 Effector 的 store、event、effect另一个是./mountCounter负责把 Vue 组件挂载到 Host 提供的 DOM 节点上。React Host 不直接 import Vue 的.vue文件也不关心 Vue 的运行时细节只通过远程模块拿 Store 和挂载函数。这样 React 侧没有 Vue 编译负担Vue 侧也能独立运行、独立调试边界比较干净。关键点是 Store 模块必须只存在一份。我的做法是让 Store 归属 Remote 容器React Host 通过import(remote_vue/store)拿远程模块Vue 组件内部也 import 同一个../store/counter。在 Remote 容器内部Webpack 的模块缓存会保证这两条路径指向同一个模块实例。与此同时effector在 Host 和 Remote 的shared配置里都设为singleton: true这样 React 侧的useUnit和 Remote 里创建 store 用的是同一个 Effector 运行时。少了这一步Host 和 Remote 很可能各自打包一份 effectorstore 对象看起来能传过去订阅却不一定按预期工作这是后面排查章节会重点讲的问题。还有一个小取舍我没有把effector-react共享给 Vue Remote。它是 React 绑定层Vue 侧用不到共享过去只会把 React 依赖带进远程容器。shared 里只放框架无关的effector以及各自框架运行时需要的react、react-dom、vue。这个边界建议在项目初期就定好后面加远程应用时不容易乱。1.3 项目结构与版本基线我用的目录结构如下根目录用 workspaces 管理两个应用Host 和 Remote 各自有 package.json 和 webpack 配置。这样依赖版本容易对齐启动脚本也集中mf-effector-demo/ package.json apps/ shell-react/ package.json webpack.config.js tsconfig.json public/index.html src/ index.tsx bootstrap.tsx App.tsx types/remotes.d.ts remote-vue/ package.json webpack.config.js tsconfig.json public/index.html src/ index.ts bootstrap.ts App.vue components/CounterPanel.vue composables/useEffectorStore.ts store/counter.ts mountCounter.ts shims-vue.d.ts版本上建议固定主版本不要一个应用用 Effector 22另一个用 23。我的基线是 React 18、Vue 3.4、Effector 23、Webpack 5。effector和effector-react的版本要匹配vue和vue-loader也要匹配。根package.json可以这样写{ private: true, workspaces: [apps/*], scripts: { dev:vue: npm --workspace remote-vue run start, dev:react: npm --workspace shell-react run start, dev: concurrently \npm run dev:vue\ \npm run dev:react\ }, devDependencies: { concurrently: ^8.2.2 } }Host 的依赖里要有effector、effector-react、react、react-dom构建依赖里要有webpack、webpack-cli、webpack-dev-server、html-webpack-plugin、ts-loader、typescript。Remote 的依赖里要有effector、vue构建依赖里要有webpack、webpack-cli、webpack-dev-server、html-webpack-plugin、vue-loader、vue/compiler-sfc、ts-loader、typescript如果组件里写样式还要补style-loader和css-loader。这些依赖看起来多但都是最小闭环需要的少一个就会在启动或编译阶段报错。2. 共享 Store 的建模与跨框架订阅机制2.1 Effector 核心模型设计Store 我放在 Remote 里因为它既是状态源也是远程组件内部直接消费的数据源。代码不复杂但要把事件和状态分开跨框架调用时只暴露事件不让外部直接改 store。下面是我用的计数器模型import { createEffect, createEvent, createStore } from effector; export const incrementBy createEventnumber(); export const stepChanged createEventnumber(); export const reset createEvent(); export const loadCountFx createEffectvoid, number(async () { await new Promise((resolve) setTimeout(resolve, 500)); return 10; }); export const $count createStore(0) .on(incrementBy, (count, by) count by) .on(reset, () 0) .on(loadCountFx.doneData, (_, value) value); export const $step createStore(1).on(stepChanged, (_, step) step); export const $double $count.map((count) count * 2); export const $loading loadCountFx.pending;这里有几个设计意图。incrementBy接收增量React 和 Vue 都可以传1、-1或者当前 step事件本身不关心调用方是谁。reset是无 payload 事件调用时直接reset()不要传事件对象否则类型上容易别扭。loadCountFx是异步副作用.pending派生出$loading两个框架都能订阅这样加载状态也共享不需要 React 和 Vue 各写一套。$double是派生状态用map而不是在组件里算保证两个框架看到的是同一份计算结果。注意Store 里只放业务数据不要放 React 组件、Vue 实例、DOM 节点这类框架相关对象。跨框架共享的边界越干净后面越好排查。2.2 React 侧用 useUnit 订阅远程 StoreReact Host 不能直接在异步回调里调用useUnit因为 Hook 必须在组件顶层执行。我的做法是把远程 Store 作为 prop 传给一个独立的计数组件App 只负责异步加载 Store 和挂载 Vue 面板。计数组件长这样import React from react; import { useUnit } from effector-react; import type { Store, Event, Effect } from effector; export type RemoteStore { $count: Storenumber; $step: Storenumber; $double: Storenumber; $loading: Storeboolean; incrementBy: Eventnumber; stepChanged: Eventnumber; reset: Eventvoid; loadCountFx: Effectvoid, number; }; export function ReactCounter({ store }: { store: RemoteStore }) { const count useUnit(store.$count); const step useUnit(store.$step); const double useUnit(store.$double); const loading useUnit(store.$loading); return ( section style{{ border: 1px solid #ddd, padding: 16, borderRadius: 8 }} h2React Shell 计数/h2 pcount: {count}/p pstep: {step}/p pdouble: {double}/p {loading ? p同步中.../p : null} div style{{ display: flex, gap: 8, flexWrap: wrap }} button onClick{() store.incrementBy(1)}1/button button onClick{() store.incrementBy(-1)}-1/button button onClick{() store.incrementBy(step)}加 step/button button onClick{() store.reset()}重置/button button onClick{() store.loadCountFx()}加载初始值/button /div label style{{ display: block, marginTop: 8 }} step input typenumber value{step} onChange{(event) store.stepChanged(Number(event.target.value))} / /label /section ); }useUnit接收远程 store 对象后Effector 会建立订阅store 更新时 React 组件重渲染。这里能成立的前提是 Host 的effector-react和 Remote 创建 store 用的effector是同一个运行时所以shared里的singleton配置不是可选项。实际联调时如果 React 侧数字不动先别怀疑组件写法优先检查 Effector 是否被打了两份。2.3 Vue3 侧手写 composable 订阅 StoreVue3 没有现成的 effector 官方绑定但不需要复杂封装只要把 Effector 的订阅桥接到 Vue 的ref上。核心是store.watch加生命周期清理import { onMounted, onUnmounted, ref, type Ref } from vue; import type { Store } from effector; export function useEffectorStoreT(store: StoreT): RefT { const value ref(store.getState()) as RefT; let unsubscribe: (() void) | undefined; onMounted(() { const subscription store.watch((next) { value.value next; }); unsubscribe () subscription.unsubscribe(); }); onUnmounted(() { unsubscribe?.(); }); return value; }store.watch会立即回调一次当前值所以onMounted里再赋值也不会漏掉初始状态。关键在于onUnmounted必须清理订阅否则远程组件反复挂载卸载时会留下旧订阅表现为“点一次加两次”或者组件销毁后还在更新。Vue 组件里这样用script setup langts import { $count, $double, $loading, $step, incrementBy, loadCountFx, reset, stepChanged, } from ../store/counter; import { useEffectorStore } from ../composables/useEffectorStore; const count useEffectorStore($count); const step useEffectorStore($step); const double useEffectorStore($double); const loading useEffectorStore($loading); function onStepInput(event: Event) { const target event.target as HTMLInputElement; stepChanged(Number(target.value)); } /script template section classvue-counter h3Vue3 远程面板/h3 pcount: {{ count }}/p pstep: {{ step }}/p pdouble: {{ double }}/p p v-ifloading同步中.../p div button clickincrementBy(1)1/button button clickincrementBy(-1)-1/button button clickincrementBy(step)加 step/button button clickreset()重置/button button clickloadCountFx()加载初始值/button /div label step input typenumber :valuestep inputonStepInput / /label /section /template模板里count、step这些是 refVue 会自动解包按钮事件直接调 Effector 事件。这里不要把 Effector store 直接塞进reactive那样会破坏它自己的订阅机制反而容易出现更新不同步。用ref做适配层是最稳的。3. 联邦配置与两端工程落地3.1 React Host 的 webpack 配置Host 的配置重点是remotes指向 Vue Remote 的remoteEntry.jsshared里固定effector单例同时把 React 运行时也设为单例。下面是我用的最小配置const path require(path);
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。