2026/10/29 7:13:15

Module Federation 与 Effector 跨框架状态共享实践

Module Federation 与 Effector 跨框架状态共享实践 先把结论摆出来跨框架共享状态这件事难点从来不在怎么让 Vue 读到 React 的数据而在于怎么保证两边读到的是同一份数据、同一个实例、同一套事件流。Module Federation 负责把代码搬过去Effector 负责让这份状态脱离框架存在两者叠在一起才能跑通。这篇文章把我从一个 React Vue3 双栈项目里趟出来的东西完整拆开为什么这么选型、共享层怎么切、webpack 配置怎么写、React 侧和 Vue3 侧分别怎么接、踩过的双实例和响应式丢失坑以及一套可以直接抄的排查清单。读者对象不分深浅——刚接触微前端的朋友能看懂整体流程已经在做 Module Federation 的人可以直接跳到第 4、5 节拿配置和排错表。1. 状态共享这件事为什么最后落到了框架中立上1.1 从一次框架并存的实际场景说起我经手过一个典型的过渡期项目主站是 React 18后台运营端是 Vue3两边的代码分别放在不同仓库部署也是独立的。麻烦出在订单中心——主站顶部要显示购物车角标和实时总价运营端要展示同一批数据的明细列表还要能改数量。最初的做法是两边各写一套 store中间靠后端接口轮询同步。结果就是用户在 React 侧加了商品Vue 侧要等 5 秒轮询才变运营端改了数量主站角标纹丝不动客服电话被打爆。这种场景下最直觉的方案是事件总线 手动同步但它会带来第二个问题谁是真源两边都有自己的 store谁覆盖谁全靠约定时间一长没人说得清。后面又试过把状态提到 URL 或 localStorage同样治标不治本因为缺少统一的变更通知机制。真正的转折点是意识到状态本身不该属于任何一个框架。React 的useState、Vue 的reactive本质上都是框架自己的响应式实现 框架自己的渲染调度。只要你把 store 建在里面它就天然绑死在这个框架上。想让两个框架共享就必须把 store 提到一个不依赖任何 UI 框架运行时的层里让 React 和 Vue 都退化成订阅者。这就是框架中立状态层的由来也是我最终选 Effector 而不是任何框架自带方案的根本原因。1.2 Redux 和 Pinia 卡在哪很多人第一反应是Redux 不是框架无关的吗。这话对一半。Redux 的 core 确实不依赖 ReactcreateStore/dispatch/subscribe都是纯 JS。但实际用起来你很难绕开react-redux那套ProvideruseSelector的生态惯性团队里写的 selector、中间件、devtools 集成一大半都带着 React 味道。更实际的问题是让 Vue 团队去写 Redux 的 reducer 和 action creator接受度极低代码评审时两边会吵架。Pinia 更直接它就是为 Vue 设计的defineStore返回的东西依赖 Vue 的响应式系统拿给 React 用就得自己包一层useSyncExternalStore还得处理$subscribe的触发时机。这条路我试过能跑但每加一个 store 就要重复一遍胶水代码维护成本压不住。再往深一层看还有个跨实例一致性的问题。Redux 的 store 是显式创建的你在 host 里createStore一次、在 remote 里又createStore一次就是两个独立 storedispatch互不影响。这个坑在微前端里极其常见Module Federation 打包时如果不做singletonredux和react-redux各加载一份Provider传下去的 context 都对不上。所以选型的判断标准其实很朴素这个库的 core 是不是纯 JS、有没有官方维护的多框架绑定、单元unit是不是模块级单例。这三条 Effector 全占了。1.3 Effector 的取舍与代价Effector 的模型很干净createStore造状态createEvent造动作createEffect造异步三者都是单元unit可以互相sample、combine。关键点在于这些单元的定义写在模块顶层模块被import多少次拿到的都是同一个引用——这就是跨框架共享的物理基础。它的绑定层也齐effector-react提供useUniteffector-vue提供useUnit和EffectorScopePlugin。两边调用的是同一批 unit订阅的是同一张图。代价也得说清楚不然就是耍流氓学习曲线不平滑。sample的clock/source/filter/target四个参数新手第一次看基本是懵的。React 团队习惯了useState的心智模型切过来要适应一两天。调试工具不如 Redux DevTools 成熟。虽然能接effector-logger之类的社区工具但时间旅行、action 面板这类体验确实弱一档。不可变更新必须自己做。Effector 靠引用比较判断变化你如果store.getState().push(x)直接改数组视图不会动。这一点和 Vue 的reactive深比较是相反的Vue 同学最容易在这栽跟头。接受这三点剩下的收益就很明显了一份 store 定义两个框架同时订阅事件从任意一侧发出两侧视图同步更新不需要任何轮询和手动同步。2. 架构拆分谁持有 store谁只是订阅者2.1 共享层单独成包别塞进任一宿主架构上最容易犯的错是把共享 store 塞进 React 宿主里然后让 Vue 通过 remote 去引。这样能跑但语义是错的——React 应用一重启Vue 那边的 store 就跟着断而且 Vue 团队从此要看 React 仓库的发布节奏协作上非常别扭。我采用的划分是三层层职责是否依赖 UI 框架共享状态层store-app定义所有 store、event、effect暴露 remoteEntry只依赖effectorReact 宿主react-host消费 store负责 React 侧渲染effector-reactreactVue3 宿主vue-host消费 store负责 Vue 侧渲染effector-vuevue共享状态层是一个只做exposes不做remotes的 remote它本身没有页面甚至可以不配HtmlWebpackPlugindevServer 只是为了能把remoteEntry.js发出去。这样做的好处是状态层的发布和任何一个宿主的发布解耦状态层加一个新 store两个宿主下个版本引进来就行。还有一点值得强调共享层里不要出现任何 JSX 或模板代码。一旦你写了个 React 组件放进去Vue 那边就用不了共享层的意义直接归零。我的规矩是共享层只允许出现.ts文件出现.tsx或.vue就打回去重写。2.2 Module Federation 的 shared 到底做了什么事很多人配了shared但说不清它干了什么出问题时就只能瞎试。这里拆开讲。Module Federation 在运行时维护一个叫share scope的全局注册表默认叫default。每个应用启动时会把自己打包产物里符合shared配置的模块注册进去同时向注册表查询有没有别人已经提供了这个包。查询到的版本如果满足自己的requiredVersion范围就直接复用不再加载自己那份不满足就加载自己的。这个机制对effector是生死攸关的。假设宿主加载的是effector23.2.0remote 单独又加载了一份effector23.2.0两个不同的模块实例。这两份库各自维护自己的 store 图你在 remote 里addToCart()宿主订阅的$cart根本收不到通知——因为它们压根不是同一个对象。这就是所谓的双实例问题第 5 节会专门给排查方法。配置上有三个关键点shared: { effector: { singleton: true, // 只在全局加载一次强制复用 requiredVersion: ^23.2.0, eager: false, // 不要打进 initial chunk }, }singleton: true是必须的。没有它即使版本相同Module Federation 也可能各加载各的。eager: false是推荐值。eager: true会把这个包打进入口 chunk导致 remoteEntry 还没来得及初始化就已经被消费容易出现Shared module is not available for eager consumption这类报错。requiredVersion建议从package.json里读别手写死。手写死最容易出现的情况是升级了依赖忘了改配置运行时直接抛版本不兼容。2.3 版本策略requiredVersion 与 strictVersion 怎么定版本不匹配是微前端里第二常见的故障源第一是双实例。这里说清楚两个选项的差别。requiredVersion是一个 semver 范围Module Federation 会拿它和注册表里的实际版本比对。比如你写^23.2.0注册表里有23.5.1就复用皆大欢喜注册表里只有22.8.0那就各加载各的——注意默认行为是各加载各的不是报错。这个默认值很坑因为它会静默地退化成双实例。如果你希望版本不兼容时直接炸出来而不是静默降级可以加strictVersion: true。开发阶段建议开能第一时间暴露问题线上环境我一般关掉配一个兜底版本避免一个小版本差异导致整个页面白屏。实践里我的做法是effector、effector-react、effector-vue、react、react-dom、vue这六个包全部singleton: truestrictVersion: true开发环境并且在 CI 里加一条检查——三个应用的package.json里这几个包的版本必须完全一致不一致直接挂。这条检查拦下的问题比任何运行时兜底都多。3. 共享 Store 的核心实现细节3.1 store、event、effect 三件套的最小组合共享层里最核心的是购物车这一个模块拿它把三件套讲透。先看代码// apps/store-app/src/cart.ts import { createStore, createEvent, createEffect, sample } from effector export type CartItem { id: string title: string price: number qty: number } export const addToCart createEventOmitCartItem, qty() export const removeFromCart createEventstring() export const qtyChanged createEvent{ id: string; qty: number }() export const cartCleared createEvent() export const $cart createStoreCartItem[]([]) .on(addToCart, (list, item) { const hit list.find((i) i.id item.id) if (hit) { return list.map((i) (i.id item.id ? { ...i, qty: i.qty 1 } : i)) } return [...list, { ...item, qty: 1 }] }) .on(removeFromCart, (list, id) list.filter((i) i.id ! id)) .on(qtyChanged, (list, { id, qty }) list.map((i) (i.id id ? { ...i, qty: Math.max(1, qty) } : i)), ) .reset(cartCleared)几个细节值得单独拎出来说。第一所有 unit 必须定义在模块顶层。这是 Effector 的硬规矩也是我最强调的一条。如果你把createStore写在一个函数里每调用一次就新建一个 store订阅关系全乱还会有内存泄漏。团队里我会在 ESLint 加一条自定义规则禁止在函数体内调用create...系列 API。第二.on必须返回新引用。上面每个 handler 都是map/filter/ 展开运算符没有一处原地修改。原因是 Effector 判断变化用的是Object.is比较你原地push之后引用没变下游全部不触发。Vue 同学在这里最容易翻车因为 Vue 的reactive是深比较改属性也能触发两套心智模型完全相反。第三.reset()比手写清空逻辑更可靠。写在链式调用末尾语义明确也方便未来加第二个重置触发源。3.2 派生状态与跨模块联动派生状态用.map就够了不需要额外的 memoexport const $totalPrice $cart.map((list) list.reduce((sum, i) sum i.price * i.qty, 0), ) export const $count $cart.map((list) list.reduce((sum, i) sum i.qty, 0), ) export const $isEmpty $cart.map((list) list.length 0).map返回的是一个新的派生 store它是惰性的——没人订阅就不会计算。这个特性在微前端里很有用Vue 宿主只关心$count那$totalPrice的计算就完全不会发生。跨 store 联动就得请出sample了。举个实际需求用户信息模块里有个$user当用户切换时购物车要清空。// apps/store-app/src/user.ts import { createStore, createEvent } from effector import { cartCleared } from ./cart export type User { id: string; name: string } export const loggedIn createEventUser() export const loggedOut createEvent() export const $user createStoreUser | null(null) .on(loggedIn, (_, u) u) .reset(loggedOut) // 用户变化时清空购物车 sample({ clock: loggedOut, target: cartCleared, })sample的clock是什么时候触发target是触发谁。如果还要带数据就加source取状态、fn做转换sample({ clock: loggedIn, source: $cart, fn: (cart, user) ({ items: cart, userId: user.id }), target: reportCartOwner, })这种写法的好处是数据流向在代码里是显式的读代码的人一眼能看出什么触发了什么。相比 Redux 那种 reducer 里写一堆switch可读性高不少。3.3 异步 effect 与失败态处理异步用createEffect。它的好处是自动生成pending/done/fail三个派生单元不用自己造 loading 状态export const checkoutFx createEffect(async (items: CartItem[]) { const res await fetch(/api/checkout, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ items }), }) if (!res.ok) { throw new Error(checkout failed: ${res.status}) } return (await res.json()) as { orderId: string } }) export const $checkingOut checkoutFx.pending export const $lastError createStorestring | null(null) .on(checkoutFx.failData, (_, err) err.message) .reset(checkoutFx)触发用sample加filter把购物车为空时不提交这条业务规则直接编进数据流export const checkoutClicked createEvent() sample({ clock: checkoutClicked, source: $cart, filter: (items) items.length 0, target: checkoutFx, }) $cart.reset(checkoutFx.done)这里有个坑要说filter返回false时整个sample静默不触发不会有任何报错。所以如果你的sample看起来没执行第一个要怀疑的就是filter条件不满足。我调试时习惯临时把filter去掉确认链路通了再加回来。还有一个更隐蔽的坑effect 的 promise 在宿主和 remote 之间是共享的。因为checkoutFx是同一个 unitReact 侧触发、Vue 侧能看到pending变 true。这既是优点也是风险——如果 React 侧在.finally里做了什么 UI 重置Vue 侧也会受影响。所以共享层里的 effect不要挂任何框架相关的副作用纯数据进出。3.4 类型如何在两个框架之间流转TypeScript 在 Module Federation 场景下需要额外处理因为 remote 是运行时加载的编译期 TS 不知道storeApp/cart是什么。第一种方案是手写声明文件简单可靠// apps/react-host/src/types/remotes.d.ts declare module storeApp/cart { import type { Store, Event, Effect } from effector export type CartItem { id: string title: string price: number qty: number } export const $cart: StoreCartItem[] export const $totalPrice: Storenumber export const $count: Storenumber export const $isEmpty: Storeboolean export const addToCart: EventOmitCartItem, qty export const removeFromCart: Eventstring export const qtyChanged: Event{ id: string; qty: number } export const cartCleared: Eventvoid export const checkoutFx: EffectCartItem[], { orderId: string } }缺点是同步成本共享层加个导出两个宿主的 d.ts 都要改。我的做法是把这份 d.ts 放在共享层的types/目录下通过一个私有 registry 或 monorepo 的file:依赖引过去只维护一份。第二种方案是用module-federation/typescript之类的插件构建时自动生成 remote 的类型。好处是全自动坏处是它依赖构建产物的dts目录CI 里要多跑一步而且 devServer 热更新时类型经常滞后。我在新项目里会用老项目迁移一般就手写够用。不管哪种方案有一条原则不变共享层对外暴露的类型必须是纯数据 unit 类型不能带任何框架特有的类型。一旦你在类型里写了React.FC或RefTVue 那边就废了。4. 工程实操从配置到跑通4.1 目录结构与端口规划工程结构我建议直接用 monorepo哪怕三个应用独立部署。因为共享层和两个宿主之间存在类型依赖拆成三个仓库光是版本对齐就能耗掉半天。mf-effector-demo/ ├── apps/ │ ├── store-app/ # 共享状态层端口 3001 │ │ ├── src/ │ │ │ ├── cart.ts │ │ │ ├── user.ts │ │ │ └── index.ts │ │ ├── webpack.config.js │ │ └── package.json │ ├── react-host/ # React 宿主端口 3000 │ └── vue-host/ # Vue3 宿主端口 3002 └── package.json端口规划有个经验shared 层单独占一个端口且启动顺序无所谓。这点很重要Module Federation 的 remote 是运行时加载的宿主启动时连不上 remote 只会报个 warning页面渲染到引用处才报错。所以本地开发时三个终端都开起来最稳。依赖版本对齐我在根package.json里固定{ dependencies: { effector: 23.2.0, effector-react: 23.2.0, effector-vue: 23.2.0 } }三个应用都用^的话npm install时间不同就可能装出不同小版本然后被strictVersion拦下来。所以我直接锁死升级时统一改。4.2 共享包的 webpack 配置共享层不需要 React/Vue 的任何东西配置最简// apps/store-app/webpack.config.js const path require(path) const { ModuleFederationPlugin } require(webpack).container const deps require(./package.json).dependencies module.exports { mode: development, entry: ./src/index.ts, devtool: source-map, devServer: { port: 3001, hot: true, headers: { Access-Control-Allow-Origin: * }, }, output: { publicPath: auto, uniqueName: storeApp, clean: true, }, resolve: { extensions: [.ts, .js] }, module: { rules: [ { test: /\.ts$/, exclude: /node_modules/, use: { loader: swc-loader, options: { jsc: { parser: { syntax: typescript }, target: es2020 }, }, }, }, ], }, plugins: [ new ModuleFederationPlugin({ name: storeApp, filename: remoteEntry.js, exposes: { ./cart: ./src/cart.ts, ./user: ./src/user.ts, }, shared: { effector: { singleton: true, requiredVersion: deps.effector, }, }, }), ], }三个细节必须注意。output.publicPath: auto。这行不加remoteEntry 加载子 chunk 时会用错误的相对路径控制台里一堆 404。auto会让 webpack 在运行时根据当前 script 地址推导。headers: {Access-Control-Allow-Origin: *}。宿主的域名和 remote 不同域不加这个跨域头remoteEntry.js直接加载失败。exposes的 key 就是 import 路径。./cart对应宿主里的import ... from storeApp/cart。key 写错是最常见的低级错误而且报错信息很不友好通常是一句Cannot read properties of undefined。4.3 React 宿主的接入方式React 宿主的 webpack 配置重点是remotes和shared// apps/react-host/webpack.config.js const HtmlWebpackPlugin require(html-webpack-plugin) const { ModuleFederationPlugin } require(webpack).container const deps require(./package.json).dependencies module.exports { mode: development, entry: ./src/main.tsx, devServer: { port: 3000, hot: true, historyApiFallback: true }, output: { publicPath: auto, uniqueName: reactHost }, resolve: { extensions: [.tsx, .ts, .jsx, .js] }, module: { rules: [ { test: /\.tsx?$/, exclude: /node_modules/, use: { loader: swc-loader, options: { jsc: { parser: { syntax: typescript, tsx: true }, transform: { react: { runtime: automatic } }, }, }, }, }, ], }, plugins: [ new ModuleFederationPlugin({ name: reactHost, remotes: { storeApp: storeApphttp://localhost:3001/remoteEntry.js, }, shared: { effector: { singleton: true, requiredVersion: deps.effector }, effector-react: { singleton: true, requiredVersion: deps[effector-react] }, react: { singleton: true, requiredVersion: deps.react }, react-dom: { singleton: true, requiredVersion: deps[react-dom] }, }, }), new HtmlWebpackPlugin({ template: ./public/index.html }), ], }react和react-dom也必须singleton这条单拎出来说。微前端里如果两个 React 实例共存hooks 会直接抛Invalid hook call报错信息完全指不到根因非常难查。消费侧用useUnit写法有三种按需选// apps/react-host/src/CartBadge.tsx import { useUnit } from effector-react import { $count, $totalPrice, $cart } from storeApp/cart export function CartBadge() { const [count, total] useUnit([$count, $totalPrice]) return ( span classNamebadge {count} 件 / ¥{total.toFixed(2)} /span ) }// apps/react-host/src/CartList.tsx import { useUnit } from effector-react import { $cart, addToCart, removeFromCart } from storeApp/cart export function CartList() { const cart useUnit($cart) const { removeFromCart: remove } useUnit({ removeFromCart }) if (cart.length 0) return p购物车是空的/p return ( ul {cart.map((item) ( li key{item.id} {item.title} × {item.qty} button onClick{() remove(item.id)}删除/button /li ))} /ul ) }数组形式useUnit([$a, $b])的好处是一次订阅多个减少 hook 调用次数对象形式useUnit({ addToCart })适合只取事件不取状态能避免无谓重渲染。这两个写法我在项目里都用了前者给小型组件后者给纯操作型组件。4.4 Vue3 宿主的接入方式Vue3 侧用effector-vue先在入口挂插件// apps/vue-host/src/main.ts import { createApp } from vue import { EffectorScopePlugin } from effector-vue import App from ./App.vue const app createApp(App) app.use(EffectorScopePlugin) app.mount(#app)这个插件在纯 CSR 场景下不挂也能跑但挂了有两个好处一是未来接 SSR 时不用改结构二是能在 devtools 里看到 scope 信息。我一般建议挂上成本很低。组件里用useUnit注意导入路径是effector-vue/composition!-- apps/vue-host/src/components/CartPanel.vue -- script setup langts import { useUnit } from effector-vue/composition import { $cart, $totalPrice, removeFromCart, cartCleared } from storeApp/cart const cart useUnit($cart) const total useUnit($totalPrice) const remove useUnit(removeFromCart) const clear useUnit(cartCleared) /script template div classcart-panel p v-ifcart.length 0购物车是空的/p ul v-else li v-foritem in cart :keyitem.id {{ item.title }} × {{ item.qty }} button clickremove(item.id)删除/button /li /ul p合计¥{{ total.toFixed(2) }}/p button :disabledcart.length 0 clickclear()清空/button /div /template这里有两个 Vue 特有的点必须说。第一useUnit返回的是Ref不是值。在模板里 Vue 会自动解包所以cart.length能正常工作但在script setup里要读值必须写cart.value。我第一次用的时候直接在script里写if (cart.length 0)得到的结果永远是undefined找了半天才反应过来。第二useUnit拿到的事件是普通函数不是 ref。所以remove(item.id)直接调用即可不需要.value。这一点 React 和 Vue 是一致的。如果因为某些原因不想引effector-vue手写一个 composable 也行代码不到十行// apps/vue-host/src/composables/useEffector.ts import { ref, onUnmounted, type Ref } from vue import type { Store } from effector export function useEffectorT(store: StoreT): RefT { const state ref(store.getState()) as RefT const unsubscribe store.watch((value) { state.value value }) onUnmounted(unsubscribe) return state }这段代码本身就把原理讲清楚了store.watch是 Effector 提供的底层订阅 API回调里拿到新值塞给 Vue 的ref就完成了桥接。理解了这个碰到响应式问题就知道从哪下手。4.5 启动顺序与验证方法三个应用启动后验证顺序我固定按这个流程走能快速定位问题出在哪一环。第一步直接访问http://localhost:3001/remoteEntry.js能看到一段 JS 说明远程入口正常。看不到就是 devServer 或 CORS 问题。第二步在宿主浏览器控制台执行__webpack_share_scopes__.default打印出来应该能看到effector键里面包含已注册的版本号。如果这个对象是空的说明shared压根没生效检查配置层级是不是写错了位置。第三步在 React 里点一次addToCartVue 侧应该立刻变化。如果不变按第 5 节的清单排查。如果两边都变了但只有一侧刷新那就是响应式绑定的问题不是共享层的问题——这个区分很重要能把排查范围缩小一半。第四步反向验证在 Vue 里点removeReact 侧的角标数字应该同步减少。序列化验证比单向验证靠谱得多因为单向通过可能只是巧合比如某一侧刚好在轮询。5. 故障排查实录那些查了两小时才发现的问题5.1 状态改了但视图不更新这是出现频率最高的现象但根因有四五种得按顺序排除。排第一的是原地修改。上面提过.on里list.push(item)之后返回list引用没变Effector 认为状态没变下游一律不触发。查法是加一行日志看引用.on(addToCart, (list, item) { const next [...list, { ...item, qty: 1 }] console.log(ref changed?, next ! list) return next })如果打印false那就是原地改了。这种 bug 在 Vue 团队写的代码里出现率很高因为他们习惯了reactive的深比较。排第二的是订阅了错误的 unit。比如 Vue 侧订阅$cart但 React 侧改的是$cartFiltered这个派生 store。这两个看起来相关实际上是独立的订阅链。查法是在两侧都打印store.getState()看是不是同一个值。排第三的是组件没订阅到变化的字段。React 里useUnit($cart)拿到数组如果你在子组件里通过 props 传下去而子组件用了React.memo且比较函数写错就会不更新。这种问题用 React DevTools 的 highlight updates 功能一秒定位。排第四的是 Vue 的 ref 解包问题。前面说过useUnit返回 ref在 JS 逻辑里忘记.value就读不到新值。这种问题的特征是模板里是新的script 里是旧的非常好认。5.2 两个框架的数据对不上这个现象指向双实例排查手法我整理成了一套固定流程。第一步在浏览器控制台执行const s await import(storeApp/cart) console.log(s.$cart window.__sharedCart)当然这需要你提前在共享层挂个全局引用做对照。更简单的做法是在共享层加一行// apps/store-app/src/cart.ts if (typeof window ! undefined) { ;(window as any).__cartStore $cart }然后两个宿主里分别打印自己的$cart和window.__cartStore比对。三个引用必须全等。如果宿主里的和全局的不等说明宿主自己加载了一份 effectorsingleton没生效。第二步检查shared配置是否写在了每一个应用里。我遇到过一种情况store-app 配了singletonreact-host 忘了配结果 host 自己打了一份 effector 进去双实例。所以三个应用的shared都要写一个都不能漏。第三步确认requiredVersion没有互相冲突。比如 host 写^23.2.0store-app 写^22.0.0虽然实际装的都是 23.2.0但 semver 校验时 store-app 认为 host 的版本不满足自己的范围于是加载自己那份。统一版本是最省事的解法。还有一种不常见但很坑的情况eager: true导致的加载顺序问题。表现是首次加载正常刷新后数据错乱。原因是 eager 模块在 remoteEntry 初始化之前就被执行了store 在错误的上下文里创建。解法就是老老实实eager: false。5.3 HMR 与重复初始化开发环境下改一行共享层的代码热更新之后购物车被清空了这个体验非常劝退。根因是 webpack 的 HMR 会重新执行模块createStore又跑了一遍新的 store 覆盖了旧的状态自然没了。Effector 官方给了方案在 babel/swc 插件里开hmr选项。// swc 配置片段 { jsc: { experimental: { plugins: [ [effector/swc-plugin, { hmr: true, addLoc: true }], ], }, }, }开了之后Effector 会在模块重新执行时尝试复用已有的 store 实例状态能保住。注意这个插件要配在共享层宿主那边不用配宿主里没有 unit 定义。不过我得说句实话HMR 保状态这件事我到现在也只做到大部分情况能用。复杂场景下比如 effect 正在 pending 时热更新还是会有状态错乱。所以我的习惯是一旦遇到诡异的状态问题先关 HMR 重启一次能排除掉一大半误判。另一个相关的问题是模块重复执行。exposes里的文件如果在宿主侧又被import了一次比如路径写成了相对路径而不是 remote 路径webpack 会当成两个模块创建两份 store。这种问题的特征是只有某个特定页面数据不同步其他页面正常。查法是在createStore后面打一行日志看控制台打印了几次。5.4 常见问题速查表现象大概率原因快速验证处理方式一侧更新另一侧不动双实例effector 加载了两份控制台比对$cart引用是否全等三个应用都配singleton: true统一版本点击事件完全没反应sample的filter返回 false临时注释掉filter检查过滤条件与当前状态控制台报Invalid hook callReact 双实例检查 share scope 里 react 的版本react/react-dom都设singletonremoteEntry 加载 404publicPath配置错误直接访问 remoteEntry 地址设output.publicPath: auto跨域报错devServer 没加 CORS 头看 Network 面板的响应头加Access-Control-Allow-Origin改了值视图不刷新原地修改了状态打印前后引用比较改成不可变更新Vue 里读不到最新值忘了.value看模板和 script 是否一致script 里补.valueHMR 后状态丢失模块重新执行关 HMR 重启验证配effector/swc-plugin的hmr类型报错找不到模块缺 remote 的 d.ts看 TS 是否报Cannot find module手写declare module只有某个页面不同步模块被重复 import在createStore后打日志数次数统一用 remote 路径引入这张表我贴在项目 wiki 首页新人遇到问题先自查能省掉大量沟通成本。6. 再往下挖一层scope 隔离、测试与性能6.1 用 fork 做作用域隔离与单元测试Effector 有个很多人不知道的能力fork。它可以基于同一套 unit 定义创建出多个互相隔离的作用域每个 scope 有自己的状态副本。这在两个场景下特别好用。第一个是单元测试。你不需要 mock 任何东西直接 fork 一个 scope在 scope 里跑事件断言结果import { fork, allSettled } from effector import { $cart, addToCart, $totalPrice } from ../src/cart test(加两次同一商品数量变 2总价翻倍, async () { const scope fork() await allSettled(addToCart, { scope, params: { id: sku-1, title: 样品, price: 100 }, }) await allSettled(addToCart, { scope, params: { id: sku-1, title: 样品, price: 100 }, }) expect(scope.getState($cart)).toHaveLength(1) expect(scope.getState($cart)[0].qty).toBe(2) expect(scope.getState($totalPrice)).toBe(200) })注意每个测试用例都要新建fork()这样用例之间不会互相污染。这一点比 Redux 的测试体验好因为不用手动重置 store。第二个是SSR 场景。服务端渲染时每个请求要独立的 scope否则并发请求之间会串数据。Vue 侧通过EffectorScopePlugin传 scopeReact 侧用Provider包一层。虽然纯 CSR 的微前端用不上但如果未来要做同构这套机制是现成的。还有一个进阶用法用 scope 做预览模式。运营端有个功能是预览用户看到的购物车实现方式就是 fork 一个 scope把数据写进去渲染时用这个 scope退出预览就丢弃。完全不影响真实状态代码量还很小。6.2 订阅粒度与重渲染控制共享 store 一旦被两个框架同时订阅性能问题会放大。React 那边一个useUnit触发重渲染Vue 那边一次ref更新触发 patch两边加起来开销不小。控制手段主要是收窄订阅粒度。最直接的做法是只订阅需要的切片。别在组件里useUnit($cart)然后自己算长度直接订阅$count// 差的写法整个列表变化都重渲染 const cart useUnit($cart) const count cart.length // 好的写法只有数量变化才重渲染 const count useUnit($count)$count是.map出来的派生 store只有值真的变了才通知中间过程可以省略很多次渲染。第二个手段是事件和状态分开订阅。如果你只需要addToCart不要写成const [cart, add] useUnit([$cart, addToCart])直接const add useUnit(addToCart)Effector 的事件 unit 是稳定的引用订阅它不会引起任何重渲染。这个细节在列表项组件里收益特别明显——一百个商品卡片每个都订阅$cart的话任何一次加购都会导致一百次重渲染只订阅事件的话零额外渲染。第三个手段是大对象拆小 store。如果购物车里存了商品的完整信息图片 URL、详情描述等每次数量变化都要复制整个对象内存和渲染双重浪费。我的做法是拆成两个 store$cartIds只存{id, qty}的数组$productMap存详情渲染时按 id 查。这样数量变化只影响$cartIds详情部分完全不动。Vue 侧还有一个额外的注意点useUnit返回的 ref 内部如果包的是大对象Vue 的响应式追踪会有开销。我一般建议共享层只存id、qty、price这类小字段展示用的富文本、图片地址由宿主自己维护一份缓存通过 id 关联。这样既降低了跨框架传输的数据量也让两侧的渲染逻辑各归各管耦合度更低。这套方案我在两个项目里落地过一次是 React 主站 Vue3 运营端一次是 React 商城 Vue3 数据看板。后者的共享数据量更大实时订单流订阅粒度做得更细跑下来在中等配置的机器上也没有明显卡顿。真要说经验的话就是别指望一次设计到位——先把 store 拆成最小的原子单位跑起来看性能面板哪里红了就把哪里的订阅收窄比一开始就画一张完美的架构图靠谱得多。共享层的东西宁可拆得碎一点也不要图省事塞一个大对象进去后面想拆就难了。