最近把项目的微前端方案从 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();
代码量明显少了很多,看着清爽多了。
迁移步骤
简单列一下步骤:
- 装
@module-federation/vite - 创建共享依赖配置文件
- 改 Vite 配置,移除 qiankun 插件,加上 federation 插件
- 改入口文件,主应用用 lazy 加载,子应用导出普通组件
- 状态共享改成通过 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 的玩法。