跳转到内容
123xiao | 无名键客

《前端开发中的模块联邦实战:在中型项目中落地微前端架构的拆分、共享与部署策略-487》

字数: 0 阅读时长: 1 分钟

前端开发中的模块联邦实战:在中型项目中落地微前端架构的拆分、共享与部署策略

在很多团队里,微前端不是“为了先进而先进”,而是项目长到一定阶段后,被现实逼出来的工程选择。

我自己第一次在中型项目里推动模块联邦(Module Federation)时,最直观的痛点不是技术,而是协作:一个仓库里塞了越来越多业务模块,发版互相等待、样式互相污染、依赖版本拉扯,最后谁都觉得“改个按钮像做开颅手术”。这类场景里,模块联邦确实能解决一部分问题,但前提是:拆得对、共享得稳、部署得住

这篇文章我会从中型项目落地的角度来讲,不只说概念,而是带你走完一条常见实施路径:为什么拆、怎么拆、共享什么、不共享什么、怎么上线、出问题怎么排查。


背景与问题

先说一个典型中型前端项目的状态:

  • 团队 10~30 人,前端按业务线分组
  • 主应用已经很重,构建时间明显上升
  • 某些模块改动频繁,但每次都要跟主站一起发布
  • UI 库、状态管理、路由方案开始出现“多版本共存”的风险
  • 一些页面是新技术栈,一些页面是历史包袱,难以整体重构

这时大家往往会考虑几种方案:

  1. 继续维护单体前端
  2. 改造为 Monorepo + 包级拆分
  3. 使用 iframe 型微前端
  4. 使用模块联邦型微前端

为什么是模块联邦,而不是 iframe?

iframe 的隔离性最好,但用户体验通常最差:

  • 路由同步麻烦
  • 样式统一麻烦
  • 通信复杂
  • 首屏体验不自然
  • SEO 和埋点也经常要特殊处理

模块联邦的思路更像是:运行时动态加载别的构建产物,并像本地模块一样消费它们。这意味着你既能拆分独立交付,又不至于割裂到像嵌套站点。

中型项目里最常见的三个误区

误区 1:按页面拆,不按边界拆

“订单页一个子应用,用户页一个子应用”看起来合理,但如果用户中心和订单中心大量共享组件、状态和权限逻辑,硬拆后反而增加耦合。

误区 2:什么都共享

React、UI 库、工具函数、业务 SDK、埋点模块、公共 hooks……全共享。结果是任何一个子应用升级依赖都容易牵一发而动全身。

误区 3:拆完才想部署策略

模块联邦不是只改 webpack 配置。你得提前想清楚:

  • remoteEntry 放哪
  • 是否允许独立发布
  • 缓存策略怎么做
  • 回滚怎么做
  • 宿主应用如何容错

这些不想明白,线上稳定性会很脆弱。


方案对比与取舍分析

在架构设计阶段,我建议先做“轻量微前端”评估,而不是一上来全面模块联邦化。

方案优点缺点适用场景
单体前端简单直接,调试容易构建慢、协作冲突大、发版耦合小团队或业务稳定阶段
Monorepo 包拆分复用性好、工程统一仍然常常需要整体发布强调工程治理但不急需独立部署
iframe 微前端隔离最强体验割裂、通信复杂强隔离、异构技术栈极多
模块联邦运行时集成、可独立部署、体验自然依赖共享和运行时问题更复杂中型项目、多团队协作、需要独立发布

我对中型项目的建议

如果满足下面 4 条中的 3 条,可以认真考虑模块联邦:

  • 不同业务线需要独立发版
  • 有明确可拆分的业务域
  • 团队已有一定前端工程化基础
  • 能接受运行时装配带来的复杂度

如果只是“包太大、构建太慢”,优先考虑:

  • 路由级按需加载
  • 构建缓存
  • Monorepo 包拆分
  • 公共库抽离

不要把模块联邦当成性能银弹。


核心原理

模块联邦的关键点,可以概括成三件事:

  1. Expose:把本应用的模块暴露给外部使用
  2. Remote:把别的应用当远程模块引入
  3. Shared:多个应用共享依赖,避免重复加载或版本冲突

一个最小认知模型

  • Host(宿主应用):主壳,负责路由、布局、导航、容错
  • Remote(远程应用):按业务域拆分的子应用
  • remoteEntry.js:远程应用暴露模块的入口清单
  • shared scope:运行时共享依赖池
flowchart LR
    A[Host 宿主应用] --> B[加载 remoteEntry.js]
    B --> C[订单子应用 Remote]
    B --> D[用户子应用 Remote]
    A --> E[共享依赖池]
    C --> E
    D --> E

运行时加载过程

当宿主应用访问某个远程模块时,大致流程是:

  1. 浏览器先请求远程应用的 remoteEntry.js
  2. 远程容器注册自己能提供哪些模块
  3. 宿主与远程协商 shared 依赖
  4. 实际业务模块再被异步拉取并执行
  5. 组件挂载到宿主应用中
sequenceDiagram
    participant U as 用户浏览器
    participant H as Host
    participant R as RemoteEntry
    participant M as 远程业务模块

    U->>H: 打开宿主页面
    H->>R: 请求 remoteEntry.js
    R-->>H: 返回暴露模块清单
    H->>H: 初始化 shared scope
    H->>M: 请求具体远程 chunk
    M-->>H: 返回组件代码
    H-->>U: 渲染远程页面

Shared 的本质

Shared 并不是“自动帮你解决所有版本冲突”,它只是提供一种运行时共享机制。常见配置项的含义:

  • singleton: true:只允许一个实例,适合 React/Vue 这类运行时核心库
  • requiredVersion:声明需要的版本范围
  • eager: true:提前加载,谨慎使用,容易放大首屏体积

经验上:

  • react / react-dom 适合共享为 singleton
  • 大型 UI 库视情况共享
  • 业务工具库不要轻易共享,版本变化快时更危险
  • “看起来公共”的东西,不一定适合 shared

拆分策略:从业务域而不是从代码目录下手

模块联邦最关键的不是配置,而是边界设计

推荐拆分原则

1. 以业务域拆分

比如中台系统:

  • shell:宿主壳应用
  • user-app:用户中心
  • order-app:订单中心
  • report-app:报表中心

这种拆法通常优于:

  • table-app
  • form-app
  • modal-app

因为后者是按技术组件拆,业务上下文会被切碎,通信成本会很高。

2. 宿主只保留“壳能力”

宿主应用应该尽量薄,只负责:

  • 一级路由
  • 菜单导航
  • 登录态校验
  • 权限守卫
  • 通用布局
  • 异常兜底

不要把大块业务又偷偷塞回宿主,不然最后还是“伪微前端”。

3. 跨应用共享要克制

共享的东西越多,独立性越差。中型项目里,我通常建议分三层:

  • 必须共享:React、ReactDOM
  • 可选共享:设计系统、埋点 SDK、国际化基础包
  • 尽量不共享:业务 hooks、业务 utils、接口封装层

一个推荐的目录组织

apps/
  shell/
  user-app/
  order-app/
packages/
  design-system/
  eslint-config/
  ts-config/

这里的思路是:

  • 应用层用模块联邦装配
  • 工程基础设施层用 Monorepo 统一管理

这是中型项目里比较稳的一种组合,而不是“所有问题都交给模块联邦”。


实战代码(可运行)

下面给一个简化但可运行的示例。技术栈使用 Webpack 5 + React

目标效果:

  • shell 作为宿主应用
  • user-app 作为远程应用
  • 宿主应用通过模块联邦动态加载远程组件

目录结构

mf-demo/
  shell/
    src/
      App.jsx
      bootstrap.jsx
      index.js
    webpack.config.js
    package.json
  user-app/
    src/
      UserCard.jsx
      index.js
    webpack.config.js
    package.json

远程应用:user-app

user-app/package.json

{
  "name": "user-app",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --config webpack.config.js --port 3001"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.25.0",
    "@babel/preset-env": "^7.25.0",
    "@babel/preset-react": "^7.24.7",
    "babel-loader": "^9.1.3",
    "html-webpack-plugin": "^5.6.0",
    "webpack": "^5.94.0",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^5.0.4"
  }
}

user-app/src/UserCard.jsx

import React from "react";

export default function UserCard() {
  return (
    <div style={{ padding: 16, border: "1px solid #ddd", borderRadius: 8 }}>
      <h3>用户中心远程组件</h3>
      <p>这是从 user-app 暴露出来的模块。</p>
    </div>
  );
}

user-app/src/index.js

import React from "react";
import ReactDOM from "react-dom/client";
import UserCard from "./UserCard";

const rootEl = document.getElementById("root");

if (rootEl) {
  const root = ReactDOM.createRoot(rootEl);
  root.render(<UserCard />);
}

user-app/webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  devServer: {
    port: 3001,
    headers: {
      "Access-Control-Allow-Origin": "*"
    }
  },
  output: {
    publicPath: "auto"
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env", "@babel/preset-react"]
          }
        }
      }
    ]
  },
  resolve: {
    extensions: [".js", ".jsx"]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "userApp",
      filename: "remoteEntry.js",
      exposes: {
        "./UserCard": "./src/UserCard.jsx"
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: "^18.2.0"
        },
        "react-dom": {
          singleton: true,
          requiredVersion: "^18.2.0"
        }
      }
    }),
    new HtmlWebpackPlugin({
      templateContent: `
        <!DOCTYPE html>
        <html>
          <head><meta charset="UTF-8"><title>user-app</title></head>
          <body><div id="root"></div></body>
        </html>
      `
    })
  ]
};

宿主应用:shell

shell/package.json

{
  "name": "shell",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --config webpack.config.js --port 3000"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.25.0",
    "@babel/preset-env": "^7.25.0",
    "@babel/preset-react": "^7.24.7",
    "babel-loader": "^9.1.3",
    "html-webpack-plugin": "^5.6.0",
    "webpack": "^5.94.0",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^5.0.4"
  }
}

shell/src/App.jsx

import React, { Suspense } from "react";

const RemoteUserCard = React.lazy(() => import("userApp/UserCard"));

export default function App() {
  return (
    <div style={{ padding: 24 }}>
      <h1>Shell 宿主应用</h1>
      <p>下面的卡片来自远程应用 user-app:</p>

      <Suspense fallback={<div>远程模块加载中...</div>}>
        <RemoteUserCard />
      </Suspense>
    </div>
  );
}

shell/src/bootstrap.jsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

shell/src/index.js

import("./bootstrap");

shell/webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  devServer: {
    port: 3000
  },
  output: {
    publicPath: "auto"
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env", "@babel/preset-react"]
          }
        }
      }
    ]
  },
  resolve: {
    extensions: [".js", ".jsx"]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "shell",
      remotes: {
        userApp: "userApp@http://localhost:3001/remoteEntry.js"
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: "^18.2.0"
        },
        "react-dom": {
          singleton: true,
          requiredVersion: "^18.2.0"
        }
      }
    }),
    new HtmlWebpackPlugin({
      templateContent: `
        <!DOCTYPE html>
        <html>
          <head><meta charset="UTF-8"><title>shell</title></head>
          <body><div id="root"></div></body>
        </html>
      `
    })
  ]
};

运行步骤

先启动远程应用:

cd user-app
npm install
npm run start

再启动宿主应用:

cd shell
npm install
npm run start

然后访问:

http://localhost:3000

你会看到宿主页面中渲染了来自 user-app 的组件。


在真实项目中如何扩展这套示例

上面的例子只是“组件级接入”。真实项目一般会继续扩展成下面几种模式:

模式 1:页面级暴露

远程应用直接暴露路由页面组件,例如:

exposes: {
  "./UserRoutes": "./src/routes"
}

宿主统一注册一级路由,子应用自己维护二级路由。

模式 2:挂载函数暴露

有些团队会暴露 mount/unmount 函数,而不是 React 组件,这对异构框架更友好:

exposes: {
  "./mount": "./src/mount"
}

这种方式更接近“微应用接入协议”,适合未来可能接入 Vue、React 混合系统。

模式 3:配置中心驱动 remote 地址

不要把线上地址硬编码在 webpack 里。实际项目常见做法是:

  • 测试环境 remote 指向测试 CDN
  • 预发环境指向预发 CDN
  • 生产环境由配置中心下发版本与地址

部署策略:真正决定你能不能稳定上线

很多文章讲到这里就停了,但线上可用性其实更多取决于部署设计。

推荐部署原则

1. 宿主与子应用独立部署

  • shell 独立发布
  • user-app 独立发布
  • order-app 独立发布

这样业务线可以按需上线,不互相阻塞。

2. 远程入口稳定,业务资源带 hash

一个很实用的策略:

  • remoteEntry.js 使用稳定访问路径
  • 远程业务 chunk 使用内容 hash 文件名

这样宿主只需要拿到最新入口清单,而静态资源仍可长期缓存。

3. 给 remoteEntry 设置短缓存

因为 remoteEntry.js 相当于模块映射表,不能长时间缓存过期版本。通常我会建议:

  • remoteEntry.js:短缓存或 no-cache
  • 业务 chunk:强缓存 + hash
flowchart TD
    A[发布子应用] --> B[生成新的 chunk hash]
    B --> C[上传静态资源到 CDN]
    C --> D[更新 remoteEntry.js]
    D --> E[宿主应用下次访问拉取新映射]

回滚策略要提前设计

线上最怕的不是出 bug,而是远程应用发布后宿主全部白屏

建议至少准备两层回滚:

  1. CDN 层快速回滚 remoteEntry.js
  2. 宿主层降级容错,远程加载失败时显示兜底页

比如:

import React, { Suspense } from "react";

const RemoteUserCard = React.lazy(() => import("userApp/UserCard"));

function ErrorFallback() {
  return <div>用户模块暂时不可用,请稍后重试。</div>;
}

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

export default function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>加载中...</div>}>
        <RemoteUserCard />
      </Suspense>
    </ErrorBoundary>
  );
}

容量估算与架构边界

中型项目里,模块联邦也不是拆得越多越好。

一个经验判断

如果一个团队维护的子应用数量已经超过 8~10 个,且每个子应用都需要:

  • 独立 CI/CD
  • 独立监控
  • 独立权限与埋点
  • 独立版本治理

那么运维和协作成本会明显上升。

我建议的边界

对中型项目来说,通常控制在:

  • 1 个宿主壳
  • 2~5 个核心远程应用

比较容易管理。

再往上扩时,最好引入:

  • 统一接入规范
  • 统一监控 SDK
  • 统一错误码和日志规范
  • 统一 remote 注册中心

否则“架构先进”,但团队会越来越累。


常见坑与排查

这一部分很重要。我把实战里最常见的问题和定位思路整理成一个“排障顺序”。

1. 远程模块加载失败

现象

浏览器控制台报错:

Loading script failed.

或者:

Cannot find module 'userApp/UserCard'

常见原因

  • remoteEntry.js 地址配置错了
  • 远程应用没启动或没部署成功
  • 跨域头没配
  • nameremotes 中的容器名不一致
  • exposes 路径写错

排查顺序

  1. 直接在浏览器打开 http://localhost:3001/remoteEntry.js
  2. 检查 ModuleFederationPlugin 中的 name
  3. 检查宿主里的 userApp@... 是否和远程名字一致
  4. 检查跨域头是否正确返回

2. React Hooks 异常或上下文失效

现象

报错类似:

Invalid hook call

或者 Context、Redux store 表现异常。

常见原因

通常是 React 被加载了多个实例

解决方式

确保 reactreact-dom 都配置为:

shared: {
  react: {
    singleton: true,
    requiredVersion: "^18.2.0"
  },
  "react-dom": {
    singleton: true,
    requiredVersion: "^18.2.0"
  }
}

如果还是有问题,检查:

  • lock 文件是否拉出了多版本
  • 某个远程应用是否把 React 打进了自己的 bundle
  • 包管理器是否存在链接依赖导致重复实例

3. publicPath 不对,静态资源 404

现象

remoteEntry.js 能加载,但后续 chunk 404。

原因

远程应用在加载自己的异步 chunk 时,拼接出来的资源前缀不对。

解决建议

多数情况下直接配置:

output: {
  publicPath: "auto"
}

这是我在联调时最常修的一个点,尤其是部署到 CDN 子路径时。


4. 样式污染

现象

某个子应用上线后,宿主或其他子应用样式突然变了。

原因

  • 全局样式覆盖
  • reset.css 重复注入
  • CSS 类名不隔离

解决建议

  • 优先使用 CSS Modules 或 CSS-in-JS
  • 将全局 reset 收敛到宿主壳
  • 约定设计系统的样式前缀
  • 不要在子应用中随意改 bodyhtml 样式

5. 路由互相抢占

现象

进入子应用页面后刷新,404;或浏览器回退异常。

原因

宿主和子应用都在处理 history 路由,但边界没有划清。

建议做法

  • 宿主管一级路由
  • 子应用管理自己域内路由
  • 明确 basename
  • 后端网关统一做 history fallback
stateDiagram-v2
    [*] --> ShellRoute
    ShellRoute --> UserAppRoute: /user/*
    ShellRoute --> OrderAppRoute: /order/*
    UserAppRoute --> UserAppRoute: 子路由跳转
    OrderAppRoute --> OrderAppRoute: 子路由跳转

安全/性能最佳实践

模块联邦是运行时加载远程代码,所以安全和性能都不能只看“能跑”。

安全最佳实践

1. 只加载可信来源的远程资源

远程模块本质上就是执行一段远端 JS。生产环境中一定要:

  • 限定 remote 域名白名单
  • 配合 HTTPS
  • 不允许随意拼接第三方地址

2. 配置 CSP

使用内容安全策略,限制脚本来源,避免 remote 被劫持后造成更大风险。

一个简化示例:

Content-Security-Policy: script-src 'self' https://cdn.example.com;

3. 对 remote 配置做中心化治理

不要让每个前端项目自己维护一套 remote 地址映射。建议通过:

  • 配置平台
  • 灰度发布平台
  • 服务端下发配置

来统一管理。

4. 避免在共享层暴露敏感逻辑

不要把鉴权密钥、签名逻辑、内部调试接口直接做成 shared 公共模块。共享层应该是基础能力,不该承载敏感实现。


性能最佳实践

1. 不要为拆而拆

模块联邦会增加运行时请求和初始化成本。拆分应优先服务于:

  • 独立发布
  • 团队协作
  • 业务边界清晰

而不是只为了“让包看起来更小”。

2. 远程模块按路由懒加载

不要在首屏一次性加载所有 remote。把远程模块挂到真正需要访问的路由上。

const UserPage = React.lazy(() => import("userApp/UserPage"));
const OrderPage = React.lazy(() => import("orderApp/OrderPage"));

3. 关键依赖共享,但不要过度共享

共享依赖太多,初始化协商成本也会上升,还会增加版本联动风险。

4. 做好监控

至少埋下面几类指标:

  • remoteEntry 加载耗时
  • 远程 chunk 加载耗时
  • 加载失败率
  • 远程模块渲染耗时
  • 兜底页触发次数

这是判断模块联邦是否“真的跑稳了”的依据。

5. 宿主应用要有超时与降级

远程模块如果长时间没返回,不应该拖死整个页面。

function loadRemoteWithTimeout(loader, timeout = 5000) {
  return Promise.race([
    loader(),
    new Promise((_, reject) =>
      setTimeout(() => reject(new Error("remote load timeout")), timeout)
    )
  ]);
}

配合 React.lazy 时可以做一层包装,失败就走兜底 UI。


一套更稳的落地建议

如果你正准备在中型项目里引入模块联邦,我会建议按下面节奏推进:

第一步:先做单点试验

选一个边界清晰、变更频繁、与主站耦合适中的业务模块试点。不要一上来拆核心首页。

第二步:先统一规范,再扩展应用数

至少先约定:

  • 子应用命名规范
  • 路由边界规范
  • shared 白名单
  • 错误兜底规范
  • 发布和回滚流程

第三步:把“工程治理”放在“业务拆分”前面

比如提前建设:

  • CI/CD 模板
  • 静态资源发布规范
  • 监控告警
  • 灰度能力
  • 版本回溯能力

第四步:持续评估边界是否合理

拆分不是一劳永逸。某些业务域后续可能:

  • 继续独立演进
  • 合并回主应用
  • 抽成共享包

这都很正常,别把边界神圣化。


总结

模块联邦非常适合中型前端项目里那种“业务已复杂,但还没复杂到要全面平台化”的阶段。它最有价值的地方,不是炫技,而是三件事:

  • 让业务域真正独立交付
  • 让主壳与子业务解耦
  • 在不牺牲体验的前提下引入微前端能力

但我想强调一个实际结论:模块联邦是架构工具,不是性能工具,也不是组织问题的万能解药。

落地时最值得优先做好的,不是 webpack 配置,而是这 5 件事:

  1. 按业务域拆分,而不是按组件目录拆分
  2. 只共享真正稳定且必须共享的依赖
  3. 宿主应用保持“壳化”,别回流业务逻辑
  4. 提前设计部署、缓存、回滚和降级策略
  5. 用监控数据验证收益,而不是凭感觉判断成功

如果你的项目还处在“一个前端团队、一个版本节奏、业务边界不清”的阶段,那先别急着上模块联邦;但如果你已经明显感受到多团队协作和独立发布的压力,它就很值得认真试一次。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》