联邦状态做对了:Zustand、TanStack Query 与真正有效的模式

发布: (2025年12月17日 GMT+8 04:53)
13 min read
原文: Dev.to

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,充当浏览器会话中所有共享模块的注册表。

  1. Host 引导 – 为每个标记为共享的库在 __webpack_share_scopes__.default 中初始化条目。每个条目包含版本号和用于加载模块的工厂函数。
  2. 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,但共享模块是异步加载的。运行时在导入执行之前尚未初始化共享作用域。

两种可能的解决方案

  1. Eager loading(快速修复,会增加包体积)

    // In the shared config
    react: { singleton: true, eager: true, requiredVersion: deps.react },

    这会强制将 React 打包进初始 bundle,给 Time‑to‑Interactive 增加约 100‑150 KB(gzip 后)的体积。

  2. 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
Back to Blog

相关文章

阅读更多 »

在 LLM 聊天 UI 中追求 240 FPS

TL;DR 我构建了一个 benchmark suite 来测试在 React UI 中 streaming LLM responses 的各种优化。关键要点:1. 首先构建合适的 state,然后再进行优化……