从 qiankun(乾坤) 迁移到 Module Federation(模块联邦),对MF只能说相见恨晚!

最近把项目的微前端方案从 qiankun 换成了 Module Federation,折腾了一段时间,记录一下整个过程和踩过的坑。

先说说项目情况

我们后台管理系统是微前端架构:

  • main:壳工程,负责登录、布局、路由分发
  • app-1:React 项目,核心业务都在这
  • app-2:React 项目,核心业务都在这

之前一直用的 qiankun,2026 年 2 月的时候决定换成 Module Federation。

为啥要换?

说实话 qiankun 用着也还行,但也有些让人头疼的地方。

现有功能稳定性存疑

样式隔离是个迷

qiankun 有样式隔离方案,但说实话不太靠谱。我们遇到过好几次样式冲突的问题,最后还是得靠 CSS Modules 和命名前缀来解决,等于隔离了个寂寞。

通信方案被遗弃

initGlobalState 官方通信方案被遗弃,且不大好用

父子路由冲突

这个相信不少用过乾坤的人都遇到过,

Vite 兼容性

qiankun 是给 Webpack 设计的,Vite 用起来得靠 vite-plugin-qiankun-lite 这种第三方插件。

3.0 难产

qiankun 3.0 2021年开始开发至今仍未发布,官方画的饼对于vite的支持、支持共享依赖等等迟迟不能吃上。

Module Federation 香在哪

Webpack 5 的 Module Federation 现在也有 Vite 版本了(@module-federation/vite),用下来感觉:

  • 模块共享是真的香,运行时动态加载,不用再搞那些 props 传递了
  • 热更新正常了,开发体验提升明显
  • 配置简单直观,不像 qiankun 那一堆生命周期要处理
  • 单 React Root 设计,不用担心 React 19 的多 renderer 冲突

迁移前的 qiankun 配置

先看看原来是怎么配的。

主应用

主应用用 registerMicroApps 注册子应用:

tsx 复制代码
// packages/main/src/main.tsx
import { registerMicroApps, start } from "qiankun";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);

registerMicroApps([
 {
    name: "app-1",
    entry: isDev ? `//${hostname}:8801` : `/app-1/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-1",
    props: {
      appStore: useAppStore,
    },
  },
  {
    name: "app-2",
    entry: isDev ? `//${hostname}:8802` : `/app-2/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-2",
    props: {
      appStore: useAppStore,
    },
  },
], {
  beforeLoad: () => {
    setAppLoading(true);
  },
  afterMount: () => {
    setAppLoading(false);
  },
});

start();

子应用

子应用得导出一堆生命周期钩子,mount、unmount、bootstrap 一个都不能少:

typescript 复制代码
// packages/app-2/src/main.tsx
import { createRoot } from "react-dom/client";

async function render(props: any) {

}

export async function mount(props) {
  render(props);
}

export async function bootstrap() {
  console.log("bootstrap");
}

export async function unmount(props) {
}

// 独立运行时的逻辑
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

子应用 Vite 配置

typescript 复制代码
// packages/app-2/vite.config.ts
import qiankun from "vite-plugin-qiankun-lite";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      qiankun({ name: "app-2" }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

迁移后的 Module Federation 配置

先搞个共享依赖配置

共享依赖这块挺重要的,单独抽了个文件出来管理:

typescript 复制代码
// packages/shared/config/federation.shared.ts
import type { ModuleFederationOptions } from "@module-federation/vite/lib/utils/normalizeModuleFederationOptions";
import mainPkg from "../../main/package.json";

const deps = mainPkg.dependencies;

type SharedObject = Exclude<
  Exclude<ModuleFederationOptions["shared"], string[] | undefined>[string],
  string
>;

type ModuleConfig = {
  base: SharedObject;
  main?: Partial<SharedObject>;
  child?: Partial<SharedObject>;
};

type SharedConfig = Record<string, SharedObject>;

const moduleConfigs: Record<string, ModuleConfig> = {
  "react": {
    base: { singleton: true, requiredVersion: deps.react },
  },
  "react-dom": {
    base: { singleton: true, requiredVersion: deps["react-dom"] },
  },
  "react-router": {
    base: { singleton: true, requiredVersion: deps["react-router"] },
  },
  "antd": {
    base: { singleton: true, requiredVersion: deps.antd },
  },
  "zustand": {
    base: { singleton: true, requiredVersion: deps.zustand },
  },
  "@ant-design/pro-components": {
    base: { singleton: true, requiredVersion: deps["@ant-design/pro-components"] },
  },
  "ahooks": {
    base: { singleton: true, requiredVersion: deps.ahooks },
  },
};

export const federationSharedMain: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.main || {}) },
  ]),
);

export const federationSharedChild: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.child || {}) },
  ]),
);

这里有个设计:主应用和子应用的配置分开导出,以后如果有差异化的需求也好扩展。

主应用 Vite 配置

typescript 复制代码
// packages/main/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedMain } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "main",
        dts: false,
        exposes: {
          "./useAppStore": "./src/store/index.ts",
        },
        filename: "remoteEntry.js",
        remotes: {
          mfapp2: {
            name: "mfapp2",
            type: "module",
            entry: mode === "production"
              ? `/app-2/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8802/remoteEntry.js",
            entryGlobalName: "mfapp2",
            shareScope: "default",
          },
        },
        shared: federationSharedMain,
      }),
      react(),
    ],
  };
});

主应用作为 Host,通过 remotes 加载子应用,同时把 useAppStore 暴露出去给子应用用。

子应用 Vite 配置

typescript 复制代码
// packages/app-2/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedChild } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "mfapp2",
        filename: "remoteEntry.js",
        dts: false,
        remotes: {
          main: {
            name: "main",
            type: "module",
            entry: mode === "production"
              ? `/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8800/remoteEntry.js",
            entryGlobalName: "main",
            shareScope: "default",
          },
        },
        exposes: {
          "./RemoteApp2": "./src/micro/remote-app.tsx",
        },
        shared: federationSharedChild,
      }),
      react({ reactRefreshHost: "http://localhost:8800" }),
      Pages({ routeStyle: "remix", extensions: ["tsx"] }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

注意这个 reactRefreshHost,这个是让子应用的热更新在主应用里也能生效的关键配置。

主应用入口改造

主应用用 React.lazy 动态加载远程组件:

tsx 复制代码
// packages/main/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";

const RemoteApp2 = React.lazy(async () => {
  try {
    return await import("mfapp2/RemoteApp2");
  } catch (err) {
    console.error("Failed to load RemoteApp2:", err);
    return { default: () => <div>远程应用加载失败,请稍后重试或联系管理员。</div> };
  }
});

function AppContent() {
  const { currentTheme } = useTheme();

  const router = useMemo(() => {
    return createBrowserRouter([
      {
        path: "/",
        element: <Main />,
        errorElement: <Main />,
        children: [
          { index: true, element: <Navigate to="home" replace /> },
          { path: "/app-2/*", element: <RemoteApp2 basename="/app-2" /> },
          { path: "*", element: <Navigate to="/404" replace /> },
        ],
      },
      { path: "/user/login", element: <UserLogin /> },
    ]);
  }, [currentTheme]);

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <ConfigProvider theme={antdTheme.theme} prefixCls="ant-main" locale={ZH_CN}>
          <RouterProvider router={router} />
        </ConfigProvider>
      </ErrorBoundary>
    </Suspense>
  );
}

子应用入口改造

子应用不用再写那些生命周期钩子了,直接导出一个普通组件就行:

tsx 复制代码
// packages/app-2/src/micro/remote-app.tsx(新增)
import type { MicroMountProps } from "appFacade";
import App from "../app";

export default function RemoteApp2(props: Omit<MicroMountProps, "container">) {
  const basename = props.basename || "/app-2";
  return <App basename={basename} />;
}
typescript 复制代码
// packages/app-2/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { useRoutes } from "react-router";
import routes from "~react-pages";

type AppProps = {
  basename?: string;
};

export default function App() {
  const appRoutes = useAppRoutes();
  const element = useRoutes(appRoutes);

  return (
      <Suspense fallback={null}>
        <ErrorBoundary FallbackComponent={ErrorFallback}>
          {element}
        </ErrorBoundary>
      </Suspense>
  );
}
typescript 复制代码
// packages/app-2/src/main.tsx
// 这个文件是为了子应用能单独运行,如果没有单独运行的需求,这个文件是可以不要的
import { createRoot } from "react-dom/client";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
import App from "./app";

const router = createBrowserRouter([
  { path: "/app-2/*", element: <App /> },
]);

function render() {
  createRoot(document.getElementById("root-2")!).render(<RouterProvider router={router} />);
}

render();

代码量明显少了很多,看着清爽多了。

迁移步骤

简单列一下步骤:

  1. @module-federation/vite
  2. 创建共享依赖配置文件
  3. 改 Vite 配置,移除 qiankun 插件,加上 federation 插件
  4. 改入口文件,主应用用 lazy 加载,子应用导出普通组件
  5. 状态共享改成通过 Module Federation 暴露

踩过的坑

热更新不生效

子应用改了代码,主应用那边不更新,这个很烦。

解决办法就是子应用 react 插件加个配置:

typescript 复制代码
react({ reactRefreshHost: "http://localhost:8800" });

生产环境部署

生产环境要注意远程入口的地址配置。我们是把子应用构建产物复制到主应用的 dist/app-2 目录,然后入口地址加上时间戳避免缓存:

typescript 复制代码
entry: mode === "production"
  ? `/app-2/remoteEntry.js?t=${Date.now()}`
  : "http://localhost:8802/remoteEntry.js";

类型定义

远程组件默认没有类型提示,写代码的时候有点难受。加个声明文件就行:

typescript 复制代码
// packages/app-2/src/types/remote.d.ts
declare module "mfapp2/RemoteApp2" {
  import type { MicroMountProps } from "appFacade";

  const RemoteApp2: React.FC<Omit<MicroMountProps, "container">>;
  export default RemoteApp2;
}

换完之后的感觉

整体来说还是值得的:

  • 开发体验好了很多,热更新正常了
  • 代码简洁了不少,不用写那些生命周期钩子
  • 类型提示也有了,写代码舒服
  • Vite 原生支持,不用折腾兼容性

不过也有要注意的:

  • 共享依赖版本要一致
  • 生产部署路径要配对
  • 远程组件加载失败要有降级处理

后续还想再优化一下远程组件的加载性能,以及探索更多 Module Federation 的玩法。


相关推荐
没想好d7 小时前
通用管理后台组件库-10-表单组件
前端
恋猫de小郭7 小时前
你用的 Claude 可能是虚假 Claude ,论文数据告诉你,Shadow API 中的欺骗性模型声明
前端·人工智能·ai编程
_Eleven8 小时前
Pinia vs Vuex 深度解析与完整实战指南
前端·javascript·vue.js
cipher8 小时前
HAPI + 设备指纹认证:打造更安全的远程编程体验
前端·后端·ai编程
WeNTaO8 小时前
ACE Engine FrameNode 节点
前端
郑鱼咚8 小时前
现在的AI热潮,恰恰证明了这个世界就是个草台班子
前端·人工智能·程序员
Striver_8 小时前
elpis总结——基于koa的elpis-core
前端
阿慧勇闯大前端8 小时前
在AI时代,再去了解react19新特性还有用吗? 最近总有朋友问我:“现在AI写代码这么厉害了,我写个需求丢给ChatGPT,几秒钟就生成一堆组件,还学新特
前端·react.js
秋水无痕8 小时前
从零搭建个人博客系统:Spring Boot 多模块实践详解
前端·javascript·后端