联邦状态做对了:Zustand、TanStack Query 与真正有效的模式
Source: Dev.to
请提供您希望翻译的正文内容,我将按照要求保留源链接、格式和技术术语进行简体中文翻译。
问题
我们都遇到过这种情况:你配置了 Module Federation,把应用拆分成微前端,结果 Zustand 状态在某个模块更新了,却在另一个模块没有同步。更糟的是——你的 TanStack Query 缓存因为每个远程子应用都以为自己是唯一的,而把同一个用户资料请求了三次。
在单体 React 应用中运行良好的模式,在联邦化架构下会失效。
- Context providers 不能跨模块边界传递。
- Store 会被实例化两次。
- 缓存失效变成了你未曾预料的分布式系统问题。
本指南涵盖了在生产环境中真正可行的模式——防止重复实例的单例配置、不会导致紧耦合的缓存共享策略,以及在联邦化应用中保持可维护性的关键区分:客户端状态(Zustand)和 服务器状态(TanStack Query)。这些并非理论建议,而是来自 Zalando、PayPal 等在大规模使用 Module Federation 的团队的实践经验。
为什么会发生
在单体 SPA 中,内存是连续的共享资源。根组件中实例化的 Redux store 或 React Context 提供者是全局可访问的。
在联邦系统中,应用由不同的 JavaScript 包组成——这些包通常由不同的团队开发、在不同时间部署,并在运行时异步加载。这些包在同一个浏览器标签页中执行,但它们被不同的闭包作用域和依赖树所隔离。
**根本原因:**如果没有显式的单例配置,每个联邦模块都会获得自己的 React、Redux 或 Zustand 实例。用户会体验到:
- 认证在某些区块有效,而在另一些区块无效,
- 主题切换只影响界面的一部分,
- 在微前端之间导航时购物车商品消失。
Webpack 如何共享模块
驱动状态共享的引擎是 __webpack_share_scopes__ 全局对象——这是一个内部的 Webpack API,充当浏览器会话中所有共享模块的注册表。
- Host 引导 – 为每个标记为共享的库在
__webpack_share_scopes__.default中初始化条目。每个条目包含版本号和用于加载模块的工厂函数。 - Remote 引导 – 执行握手:检查共享作用域,将可用版本与自身需求进行比较,并使用语义化版本解析来确定兼容性。
如果 Host 提供 React 18.2.0 而 Remote 需要 ^18.0.0,运行时会判断兼容并让 Remote 使用 Host 的引用。此 引用共享 确保 Remote 调用 React.useContext 时,访问的正是与 Host 完全相同的 Context Registry。
如果握手失败,Remote 将加载自己的 React 副本,形成一个平行宇宙——在该宇宙中 Host 的 provider 并不存在。
必要的 Webpack 配置
// webpack.config.js – Every federated module needs this
const deps = require('./package.json').dependencies;
module.exports = {
// …other config
plugins: [
new ModuleFederationPlugin({
// …name, remotes, exposes, etc.
shared: {
react: {
singleton: true,
requiredVersion: deps.react,
strictVersion: true,
},
'react-dom': {
singleton: true,
requiredVersion: deps['react-dom'],
strictVersion: true,
},
zustand: {
singleton: true,
requiredVersion: deps.zustand,
},
'@tanstack/react-query': {
singleton: true,
requiredVersion: deps['@tanstack/react-query'],
},
},
}),
],
};
三个属性最为关键
| 属性 | 功能说明 |
|---|---|
singleton: true | 确保在所有联邦模块中只存在一个实例。 |
strictVersion | 当版本冲突时抛出错误,而不是静默加载重复的副本。 |
requiredVersion | 强制使用 semver 范围,防止意外的版本不匹配。 |
从 package.json 动态加载版本可以让配置与已安装的包保持同步。
处理 “Shared module is not available for eager consumption”
该错误常出现在新的 Module Federation 配置中。标准入口点同步导入 React,但共享模块是异步加载的。运行时在导入执行之前尚未初始化共享作用域。
两种可能的解决方案
-
Eager loading(快速修复,会增加包体积)
// In the shared config react: { singleton: true, eager: true, requiredVersion: deps.react },这会强制将 React 打包进初始 bundle,给 Time‑to‑Interactive 增加约 100‑150 KB(gzip 后)的体积。
-
Asynchronous entry point(推荐)
// index.js – Simple wrapper enabling async loading import('./bootstrap');// bootstrap.js – Your actual application entry import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; ReactDOM.render(<App />, document.getElementById('root'));当 Webpack 看到
import('./bootstrap')时,会生成一个 Promise。在解析期间,Federation 运行时会初始化__webpack_share_scopes__,检查远程入口点,并确保共享依赖已就绪。等到bootstrap.js执行时,React 已经在共享作用域中可用了。
为什么 Zustand 在微前端中表现良好
- 体积极小 – 大约 1 KB gzipped。
- 单例友好的架构 – 不需要 provider 层级。
- 无需 React Context – store 是普通的 JavaScript 对象。
常见 bug:Remote 不响应 Host 更新
Remote 正确渲染了初始状态,但当 Host 更新状态时,Remote 仍停留在初始值。
发生了什么?
Host 与 Remote 都通过构建时别名导入同一个 store,导致打包器在两个 bundle 中都包含了 store 代码。运行时:
- Host 创建
Store_Instance_A。 - Remote 创建
Store_Instance_B。
Host 更新 实例 A;Remote 监听 实例 B。更新没有传播。
解决方案:共享同一个 JavaScript 对象
// Remote 模块导出其 store(例如 src/store.js)
import create from 'zustand';
export const useSharedStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// Host 使用远程 store
import { useSharedStore } from 'remoteApp/store';
function Counter() {
const { count, increment } = useSharedStore();
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
由于 store 模块 已共享(通过上面的 shared 配置),Host 与 Remote 获得的是同一个 useSharedStore 引用,确保状态更新在所有地方都能被感知。
TL;DR
- 将共享库配置为单例(
singleton: true)。 - 启用严格版本检查(
strictVersion: true)。 - 异步加载入口点,以便共享作用域有时间完成初始化。
- 在联邦应用中首选 Zustand 进行客户端状态管理——它无需 provider 即可工作。
- 从共享模块导出同一个 store 实例,让 Host 与 Remote 真正共享状态。
有了这些模式,你的联邦微前端将表现为一个统一、连贯的应用,而不是一堆相互孤立的岛屿。
模块联邦 – Store 共享示例
1. 从 Host 暴露 Store
// cart-remote/webpack.config.js
module.exports = {
// …
exposes: {
'./CartStore': './src/stores/cartStore',
'./CartComponent': './src/components/Cart',
},
};
2. Host Store 实现(Zustand)
// libs/shared/data-access/src/lib/cart.store.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
export const useCartStore = create(
devtools(
persist(
(set) => ({
items: [] as any[],
addItem: (item) =>
set((state) => ({
items: [...state.items, item],
})),
clearCart: () => set({ items: [] }),
}),
{ name: 'cart-storage' }
),
{ name: 'CartStore' }
)
);
// Export atomic selectors (prevents unnecessary re‑renders)
export const useCartItems = () => useCartStore((state) => state.items);
export const useCartActions = () => useCartStore((state) => state.actions);
3. Remote 使用 Host Store
// remote/src/App.tsx
import { useCartStore } from 'host/CartStore';
export const RemoteApp = () => {
const items = useCartStore((state) => state.items);
return <div>{items.length} items in cart</div>;
};
注意: 当
remote/App.tsx导入host/CartStore时,Webpack 会将请求委托给 Module Federation 运行时。运行时返回 完全相同 的 store 实例,该实例已经在 Host 中创建,因此双方共享同一个闭包和状态。
基于 Redux 的架构 – 避免嵌套 Provider
问题
为每个 Remote 单独包裹一个 <Provider> 会产生 危险的嵌套 Provider。
更简洁的模式 – 依赖注入
// Remote component contract
interface Props {
store: StoreType;
}
const RemoteWidget = ({ store }: Props) => {
const state = useStore(store);
return <div>{state.value}</div>;
};
export default RemoteWidget;
// Host side
const RemoteWidget = React.lazy(() => import('remote/Widget'));
const App = () => (
<React.Suspense fallback="Loading…">
<RemoteWidget store={hostStore} />
</React.Suspense>
);
- 将 Remote 与 store 所在位置解耦。
- 便于使用 mock store 进行测试。
- 将 store 的所有权牢牢交给 Host。
TanStack Query v5 – 共享 QueryClient
5.1 共享 QueryClient(推荐用于内部 MFE)
// host/src/queryClient.ts (exposed via Module Federation)
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 300_000,
refetchOnWindowFocus: false,
},
},
});
// Remote side
const { queryClient } = await import('host/QueryClient');
function RemoteApp() {
return (
<QueryClientProvider client={queryClient}>
{/* Remote components */}
</QueryClientProvider>
);
}
优势
| ✅ | 好处 |
|---|---|
| ✅ | 即时缓存去重 – 远程端可以直接使用主机已获取的数据,无需再次发起网络请求。 |
| ✅ | 全局失效 – 任意模块中的 mutation 会更新 所有 使用者的缓存。 |
风险
- 需要在 Host 与 Remote 之间使用 相同的 React 实例。如果不一致,会出现 “No QueryClient set” 错误。
5.2 独立 QueryClient(适用于第三方 MFE)
// Remote creates its own client
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient(/* …options… */);
function RemoteApp() {
return (
<QueryClientProvider client={queryClient}>
{/* Remote components */}
</QueryClientProvider>
);
}
优势
| ✅ | 好处 |
|---|---|
| ✅ | 远程端可以运行不同版本的 React Query。 |
| ✅ | 主机缓存的崩溃不会影响远程端。 |
劣势
| ❌ | 缺点 |
|---|---|
| ❌ | 网络请求重复 – 主机和远程端可能都会去获取相同的数据。 |
| ❌ | 远程端的 mutation 不会自动更新主机的缓存(需要手动同步)。 |
比较:共享 vs. 隔离 QueryClient
| 功能 | 共享 QueryClient | 隔离 QueryClient |
|---|---|---|
| 数据复用 | 高(即时缓存命中) | 低(依赖 HTTP 缓存) |
| 耦合 | 紧密(必须共享 React) | 松散(独立实例) |
| 失效 | 全局(一次变更更新全部) | 本地(需要手动同步) |
| 稳健性 | 脆弱(上下文问题致命) | 稳健(容错) |
| 何时使用 | 内部、受信任的 MFEs | 第三方或不同域 |
分布式缓存协调 – BroadcastChannel
// 用于缓存失效的广播通道
const channel = new BroadcastChannel('query-cache-sync');
function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProduct,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
channel.postMessage({ type: 'INVALIDATE', queryKey: ['products'] });
},
});
}
// 每个微前端的订阅者
channel.onmessage = (event) => {
if (event.data.type === 'INVALIDATE') {
queryClient.invalidateQueries({ queryKey: event.data.queryKey });
}
};
TanStack DB(Beta)– “同步数据库副本”
- 概念: 与其缓存 API 响应,TanStack DB 同步远程数据库的 本地副本。
- 数据模型: 类型化的 Collections 充当严格的契约;MFEs 可以在不同过滤条件下查询同一集合,而无需协调获取。
- 收益: 该副本成为所有 MFE 的 唯一真实来源。
多标签页同步 – broadcastQueryClient 插件
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';
broadcastQueryClient({
queryClient,
broadcastChannel: 'my-app-session',
});
通过广播缓存更新,使同一应用的所有标签页保持同步。
基于事件的通信(Fire‑and‑Forget)
10.1 共享事件总线(BroadcastChannel)
// authChannel.ts
export const authChannel = new BroadcastChannel('auth_events');
export const sendLogout = () => {
authChannel.postMessage({ type: 'LOGOUT' });
};
10.2 同文档信号(CustomEvent)
// UI signal – open cart drawer
window.dispatchEvent(
new CustomEvent('cart:open', { detail: { productId: 123 } })
);
- 优点: 零库依赖,利用浏览器原生事件循环。
- 使用场景: 仅 UI 交互,如 “Buy Now” → 打开购物车抽屉。
模块联邦 2.0 – RetryPlugin
import { RetryPlugin } from '...'; // add appropriate import path
// Configuration example