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

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

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

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

在中型前端项目里,团队最容易遇到的不是“技术不会”,而是“系统开始变重”。代码仓库越来越大、构建越来越慢、团队之间互相等待上线窗口、公共组件改一下要联动测试一大片。这个阶段,如果继续靠“把项目再分几个目录”来维持秩序,通常撑不了太久。

模块联邦(Module Federation)是 Webpack 5 提供的一种运行时共享模块机制。它很适合解决这样一类问题:多个前端应用需要独立开发、独立部署,但又必须共享一部分能力和 UI。如果你正好处在一个中型项目阶段——团队不算大到需要极重的平台化,也不小到可以把所有人塞进一个仓库里共建——那么模块联邦往往是一个很现实的折中方案。

这篇文章我会从“架构落地”的角度讲,不只讲配置项,还会讲拆分策略、共享边界、部署设计,以及我自己在项目里踩过的坑。


背景与问题

先看一个典型场景。

假设你有一个业务平台,包含这些模块:

  • 主站壳应用:负责导航、登录态、路由和基础布局
  • 商品子系统
  • 订单子系统
  • 营销子系统
  • 公共组件与权限能力

在项目早期,大家可能都在一个 React/Vue 单体应用里写,目录大概像这样:

src/
  pages/
    product/
    order/
    marketing/
  components/
  services/
  stores/

随着业务增长,问题开始集中出现:

  1. 构建时间持续变长
    修改订单页面,也要重新打整个主应用。

  2. 发布互相阻塞
    营销模块想紧急上线,结果得跟商品、订单一起走完整回归。

  3. 技术债被放大
    某个子团队想升级依赖,但会影响整个工程。

  4. 公共能力耦合过深
    组件库、权限 SDK、埋点逻辑全都在一个仓库里,谁都能改,谁都得背锅。

  5. 团队协作成本提高
    一开始觉得“都在一起改很方便”,后面往往变成“谁都不敢轻易动”。

这时我们往往会考虑几条路:

  • 单体继续优化:拆包、按路由懒加载、提速构建
  • Monorepo:把代码组织好,但运行时还是一个应用
  • 微前端:在工程和运行时上真正拆开

模块联邦正好落在中间:它不是简单的代码仓管理方案,也不是完全隔离的 iframe 微前端,而是运行时级别的模块拼装机制。


先做判断:你的项目真的适合模块联邦吗?

这个问题很关键。模块联邦不是“更高级的代码分包”,它是一个架构选择,有成本。

适合的场景

  • 有 2~5 个相对独立的前端业务域
  • 子团队需要独立开发和部署
  • 页面之间需要共享 UI、工具库、登录态或上下文
  • 能接受一定的运行时复杂度换取组织效率

不太适合的场景

  • 项目很小,1~2 人维护
  • 团队没有稳定的工程规范
  • 子应用边界并不清晰,模块之间频繁互调内部状态
  • 对首屏性能极度敏感,但又没有做远程资源治理的能力

我自己的经验是:中型项目最容易从模块联邦获益,但前提是“业务边界明确”。如果边界没想清楚,模块联邦只会把混乱放大。


方案对比与取舍分析

在开始实战前,先把几个常见方案摆在一起比较。

方案优点缺点适用场景
单体应用 + 路由懒加载简单、调试直观发布耦合、团队互相影响小型项目
Monorepo + 多包管理依赖治理清晰、共享代码方便运行时仍可能耦合多团队协作但统一发布
iframe 微前端隔离强、技术栈自由通信复杂、体验割裂、样式与路由整合弱强隔离后台系统
模块联邦共享灵活、独立部署、用户体验好运行时依赖复杂、版本治理难中型业务平台
纯组件库复用维护简单只能复用静态能力,不能动态装载业务模块共性 UI 抽取

一个很实用的判断标准是:

如果你只是想“共享代码”,优先考虑 Monorepo/组件库;
如果你还想“独立部署并在运行时组合”,再考虑模块联邦。


核心原理

模块联邦的核心不是“把代码拆开”,而是:

  1. Host(宿主应用)在运行时加载 Remote(远程应用)
  2. Remote 暴露模块,Host 按需消费
  3. 多个应用可以共享依赖,比如 React、Vue、antd
  4. 共享依赖可以配置成单例、版本约束、按需加载

一句话概括:

模块联邦把“应用集成”从构建时,推迟到了运行时。

一个最小心智模型

  • host:主应用,负责路由、菜单、布局
  • remote-product:商品子应用
  • remote-order:订单子应用
  • shared:React、状态管理、设计系统
flowchart LR
    A[浏览器] --> B[Host 主应用]
    B --> C[加载 remoteEntry.js]
    C --> D[商品子应用 Remote]
    C --> E[订单子应用 Remote]
    B --> F[共享依赖 React/组件库]
    D --> F
    E --> F

关键配置概念

1. exposes

Remote 对外暴露什么模块。

比如:

  • ./ProductApp
  • ./routes
  • ./Widget

2. remotes

Host 从哪里加载远程模块。

比如:

  • productApp@http://localhost:3001/remoteEntry.js

3. shared

哪些依赖需要共享。

典型是:

  • react
  • react-dom
  • vue
  • 状态库
  • 设计系统组件库

加载过程可以这样理解

sequenceDiagram
    participant U as 用户
    participant H as Host
    participant R as RemoteEntry
    participant M as 远程模块
    participant S as Shared Scope

    U->>H: 访问 /product
    H->>R: 拉取 remoteEntry.js
    R->>S: 初始化共享依赖
    H->>M: 请求 ProductApp 模块
    M-->>H: 返回组件工厂
    H-->>U: 渲染商品页面

为什么共享依赖很重要?

如果 Host 和 Remote 各自打包一份 React,常见问题会立刻出现:

  • hooks 报错
  • context 不通
  • 包体积膨胀
  • 运行时行为不一致

所以大多数时候,reactreact-dom 都应该设置为单例。


拆分策略:中型项目里怎么拆最稳

这部分往往比配置更重要。很多失败案例不是 webpack 写错了,而是拆分方式不对。

1. 按业务域拆,不要按技术层硬拆

推荐这样拆:

  • 商品域
  • 订单域
  • 营销域
  • 用户中心域

不太推荐这样拆:

  • 所有表格页面一个应用
  • 所有弹窗一个应用
  • 所有 hooks 一个应用

原因很简单:技术层拆分会造成高频跨应用协作,业务链路被切碎。

2. 壳应用只做“薄编排”

Host 应该主要负责:

  • 路由入口
  • 导航与菜单
  • 权限校验
  • 全局主题
  • 登录态注入
  • 错误兜底

不要把大量业务逻辑继续留在 Host 里,否则所谓“微前端”会退化成“主应用 + 一堆挂件”。

3. 公共能力分层共享

建议把共享内容分三层:

  • 基础共享:React、UI 框架、工具库
  • 平台共享:权限、埋点、国际化、请求封装
  • 业务共享:慎用,只共享稳定领域模型或低频变动组件

我踩过的一个坑是:把半成熟的业务 hooks 也做成 shared,结果每个子应用都被它绑住,升级时非常痛苦。业务共享越多,架构边界越模糊。

4. 路由归属要提前定

有两种常见模式:

  • Host 管总路由,Remote 提供页面组件
  • Remote 自带子路由,Host 只挂载入口

中型项目里,我更推荐第二种:Host 管一级路由,Remote 管自己内部子路由。这样职责更清楚。

flowchart TD
    A[一级路由 Host] --> B[/product/*]
    A --> C[/order/*]
    B --> D[商品 Remote 内部子路由]
    C --> E[订单 Remote 内部子路由]

实战代码(可运行)

下面用一个最小 React + Webpack 5 的例子演示:

  • host:主应用,端口 3000
  • product-app:商品远程应用,端口 3001

代码是可运行骨架,适合你先验证链路,再扩展到真实项目。


目录结构

mf-demo/
  host/
    package.json
    webpack.config.js
    public/index.html
    src/index.js
    src/bootstrap.js
    src/App.jsx
  product-app/
    package.json
    webpack.config.js
    public/index.html
    src/index.js
    src/bootstrap.js
    src/ProductApp.jsx

1)product-app:远程应用

product-app/package.json

{
  "name": "product-app",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --config webpack.config.js",
    "build": "webpack --config webpack.config.js"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.22.0",
    "@babel/preset-env": "^7.22.0",
    "@babel/preset-react": "^7.22.0",
    "babel-loader": "^9.1.2",
    "html-webpack-plugin": "^5.5.1",
    "webpack": "^5.88.0",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^4.15.0"
  }
}

product-app/webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const deps = require('./package.json').dependencies;

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  devServer: {
    port: 3001,
    historyApiFallback: true,
    headers: {
      'Access-Control-Allow-Origin': '*'
    }
  },
  output: {
    publicPath: 'http://localhost:3001/',
    path: path.resolve(__dirname, 'dist'),
    clean: true
  },
  resolve: {
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-env', '@babel/preset-react']
        }
      }
    ]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'productApp',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductApp': './src/ProductApp.jsx'
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom']
        }
      }
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ]
};

product-app/public/index.html

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>Product App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

product-app/src/ProductApp.jsx

import React from 'react';

export default function ProductApp() {
  return (
    <div style={{ padding: 16, border: '1px solid #ddd', borderRadius: 8 }}>
      <h2>商品子应用</h2>
      <p>这是通过模块联邦从远程应用加载的页面模块。</p>
    </div>
  );
}

product-app/src/bootstrap.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import ProductApp from './ProductApp';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<ProductApp />);

product-app/src/index.js

import('./bootstrap');

2)host:宿主应用

host/package.json

{
  "name": "host",
  "version": "1.0.0",
  "scripts": {
    "start": "webpack serve --config webpack.config.js",
    "build": "webpack --config webpack.config.js"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@babel/core": "^7.22.0",
    "@babel/preset-env": "^7.22.0",
    "@babel/preset-react": "^7.22.0",
    "babel-loader": "^9.1.2",
    "html-webpack-plugin": "^5.5.1",
    "webpack": "^5.88.0",
    "webpack-cli": "^5.1.4",
    "webpack-dev-server": "^4.15.0"
  }
}

host/webpack.config.js

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
const deps = require('./package.json').dependencies;

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  devServer: {
    port: 3000,
    historyApiFallback: true
  },
  output: {
    publicPath: 'http://localhost:3000/',
    path: path.resolve(__dirname, 'dist'),
    clean: true
  },
  resolve: {
    extensions: ['.js', '.jsx']
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-env', '@babel/preset-react']
        }
      }
    ]
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        productApp: 'productApp@http://localhost:3001/remoteEntry.js'
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom']
        }
      }
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ]
};

host/public/index.html

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>Host App</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

host/src/App.jsx

import React, { Suspense, lazy } from 'react';

const RemoteProductApp = lazy(() => import('productApp/ProductApp'));

export default function App() {
  return (
    <div style={{ padding: 24 }}>
      <h1>Host 主应用</h1>
      <p>下面的内容来自远程子应用:</p>

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

host/src/bootstrap.js

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

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

host/src/index.js

import('./bootstrap');

3)如何运行

先分别安装依赖:

cd product-app && npm install
cd ../host && npm install

启动远程应用:

cd product-app
npm run start

再启动宿主应用:

cd host
npm run start

打开:

http://localhost:3000

你会看到 Host 页面中嵌入了 product-app 暴露的组件。


4)把它扩展到真实项目

上面的示例只证明一件事:远程模块可以被加载
但真实项目里,你还要加上这些能力:

  • 路由级懒加载
  • 错误边界
  • 超时与降级
  • 远程地址环境化
  • 共享基础库治理
  • 样式隔离策略
  • 监控与日志埋点

举个更接近生产的远程加载包装:

host/src/RemoteProductPage.jsx

import React, { Suspense, lazy } from 'react';

const ProductPage = lazy(() => import('productApp/ProductApp'));

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

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

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

  componentDidCatch(error) {
    console.error('remote module load failed:', error);
  }

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

export default function RemoteProductPage() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>商品模块加载中...</div>}>
        <ProductPage />
      </Suspense>
    </ErrorBoundary>
  );
}

这类包装在生产环境非常必要。因为远程模块失败,不应该把整个主应用拖死。


部署策略:如何做到独立发布又不失控

模块联邦的真正价值,通常体现在部署阶段。

一种常见部署模型

  • Host 独立部署到 app.example.com
  • 商品 Remote 部署到 product.example.com
  • 订单 Remote 部署到 order.example.com
  • 静态资源通过 CDN 分发
  • remoteEntry.js 由 Host 在运行时拉取
flowchart LR
    A[用户访问 app.example.com] --> B[Host HTML/JS]
    B --> C[读取远程配置]
    C --> D[CDN: product remoteEntry.js]
    C --> E[CDN: order remoteEntry.js]
    D --> F[商品 chunk]
    E --> G[订单 chunk]

远程地址不要写死

开发环境里写死 localhost 没问题,但生产环境必须环境化。常见方式有两种:

方式一:构建时注入

remotes: {
  productApp: `productApp@${process.env.PRODUCT_REMOTE_URL}`
}

方式二:运行时注入远程清单

例如在页面启动时读取:

window.__REMOTE_CONFIG__ = {
  productApp: 'productApp@https://cdn.example.com/product/remoteEntry.js'
};

这种方式的好处是:Host 不重新构建,也能切换 Remote 地址
对灰度、回滚、多环境联调都很有帮助。

版本发布建议

在中型项目里,建议至少做到这几点:

  1. remoteEntry.js 可缓存,但要有版本控制策略
  2. chunk 文件名带 contenthash
  3. 保留最近若干版本静态资源,支持快速回滚
  4. Host 和 Remote 建立兼容矩阵,别只靠“大家口头约定”

容量估算与成本边界

模块联邦不是免费午餐。你需要预留这些成本:

  • 多应用构建链路维护成本
  • 远程资源可用性治理
  • 版本兼容测试
  • 监控与告警链路建设

如果你的团队连基础 CI/CD 都还没稳定,直接上模块联邦,成功率不会太高。
先把工程纪律打稳,再上运行时编排。


常见坑与排查

这部分很重要,我尽量按真实问题来讲。

1. Shared module is not available for eager consumption

这类问题常出现在入口加载顺序不对,或者共享模块初始化时机异常。

典型处理方式

确保入口使用异步启动:

import('./bootstrap');

而不是直接在 index.js 里同步渲染。

排查思路

  • 看 Host 和 Remote 是否都用了异步 bootstrap
  • 看 shared 配置是否一致
  • 看是否启用了 eager 导致行为变化

2. React hooks 报错或 context 不生效

常见报错包括:

  • Invalid hook call
  • useContext 取不到值

这通常意味着:Host 和 Remote 没有真正共享同一个 React 实例。

检查点

shared: {
  react: { singleton: true },
  'react-dom': { singleton: true }
}

如果版本差距过大,也可能导致共享失败。

建议

  • 核心框架尽量统一主版本
  • 对 React/Vue 这类运行时依赖一律单例
  • 在 CI 里做依赖版本检查

3. 远程模块偶发 404

这是生产中非常常见的问题。原因一般有:

  • 发布时 remoteEntry.js 已更新,但其依赖 chunk 还没同步
  • CDN 缓存不一致
  • 删除了旧版本静态资源,老页面还在引用

止血建议

  • 原子化发布静态资源
  • 先上传资源,再切换入口
  • 保留旧版本资源一段时间
  • remoteEntry.js 缓存策略单独设计

4. 样式互相污染

模块联邦不是天然样式隔离。子应用加载进来后,CSS 还是在同一个 DOM 环境里。

解决思路

  • 统一 CSS 命名规范,如 BEM
  • 使用 CSS Modules
  • 使用 CSS-in-JS 并控制注入顺序
  • 对高风险区域使用 Shadow DOM(有成本)

如果团队样式纪律比较弱,我会优先推荐 CSS Modules + 设计系统约束,比强上 Shadow DOM 更现实。


5. 路由跳转冲突

Host 和 Remote 都想管路由时,最容易出现这些问题:

  • 子应用跳转把主应用 history 搞乱
  • 刷新后 404
  • 菜单高亮状态不一致

建议

  • Host 只管理一级路由
  • Remote 管理内部子路由
  • 明确 basename
  • 历史模式统一约定

6. 本地联调很痛苦

常见现象:

  • 一个子应用没启动,Host 就白屏
  • 大家端口不统一
  • 本地环境配置一堆手工步骤

建议

  • 统一端口约定
  • 做本地启动脚本
  • Host 在开发环境支持 mock remote
  • remote 不可用时提供本地降级页面

安全/性能最佳实践

模块联邦除了“能跑”,还要考虑“跑得稳、跑得快、别出事”。

安全最佳实践

1. 不要信任任意远程地址

Remote 本质上是远程执行的前端代码。
如果远程地址可被任意篡改,风险很大。

建议:

  • 远程域名走白名单
  • 配置中心变更要审计
  • 生产环境只允许受信 CDN 域名

2. 控制共享模块暴露面

不是所有公共代码都适合暴露。
尽量避免暴露:

  • 带敏感逻辑的内部 SDK
  • 未稳定的业务状态管理模块
  • 可被滥用的权限判断实现

3. 加 CSP 与子资源治理

如果条件允许,至少做这些:

  • 配置 Content Security Policy
  • 限制 script-src
  • 静态资源开启 HTTPS
  • 关键资源走完整性校验策略

性能最佳实践

1. 不要把模块联邦当成“自动优化器”

它解决的是组织与部署问题,不是天然让包更小。

如果拆分不合理,反而会增加:

  • 首次请求数
  • 远程加载等待
  • 共享依赖协商成本

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

别在首页一股脑把所有 remote 都拉下来。
最常见的收益点其实很朴素:访问哪个业务,再加载哪个业务。

3. shared 列表要克制

不是所有库都应该 shared

适合 shared 的:

  • React/Vue
  • 设计系统
  • 路由库
  • 状态库(谨慎)

不适合滥 shared 的:

  • 变化很快的业务包
  • 小体积工具库
  • 版本冲突频繁的依赖

4. 做远程模块失败降级

性能不只是“快”,也是“失败时还能用”。

建议至少做到:

  • 加载超时提示
  • 错误边界兜底
  • 关键流程可回退到静态页或旧版本入口

5. 建立监控指标

我比较建议重点监控这些:

  • remoteEntry.js 加载耗时
  • 远程 chunk 加载失败率
  • Host 页面白屏率
  • 子应用渲染耗时
  • 版本分布与回滚次数

一套比较稳的落地建议

如果你准备在中型项目里落地模块联邦,我建议按这个顺序推进:

第一步:先选一个边界清晰的子系统试点

比如订单中心、营销页后台,而不是首页这种全站核心链路。

第二步:只做“页面级接入”

先让 Host 能挂载 Remote 页面,不急着共享太多业务模块。

第三步:收敛共享依赖

第一批共享通常只放:

  • React / Vue
  • UI 框架
  • 埋点 SDK
  • 请求基础层

第四步:补齐工程能力

包括:

  • 独立 CI/CD
  • 远程地址管理
  • 监控告警
  • 回滚策略
  • 兼容测试

第五步:再考虑更细颗粒度复用

比如共享 Widget、表格组件、筛选面板。
别一开始就把所有东西都 remote 化,那样很容易复杂度失控。


总结

模块联邦在中型项目里的价值,核心不是“炫技”,而是三件事:

  1. 把团队协作从单体发布中解耦
  2. 把业务边界通过运行时模块真正落地
  3. 在保持统一用户体验的前提下支持独立演进

但它也有明确边界:

  • 如果业务边界不清,先别上
  • 如果工程基础不稳,先补 CI/CD 和监控
  • 如果只是想共享代码,优先考虑 Monorepo 和组件库

最后给几个能直接执行的建议:

  • 先按业务域拆,不要按技术层拆
  • Host 做薄壳,Remote 自治
  • React/Vue 这类核心依赖必须单例共享
  • 远程地址必须环境化,最好支持运行时切换
  • 一开始先做页面级接入,再逐步细化共享
  • 上线前一定补齐错误边界、降级和回滚机制

如果你把模块联邦当成“更灵活的发布架构”,它会非常有价值;
如果你把它当成“万能解耦工具”,大概率会失望。

说到底,模块联邦解决的是合适边界上的协作问题。边界清楚,它是利器;边界混乱,它只是把复杂度换了个地方继续存在。


分享到:

上一篇
《Kubernetes 集群高可用架构实战:从控制平面冗余到故障切换设计》
下一篇
《安卓逆向实战:基于 Frida 与 JADX 的登录参数加密流程定位与 Hook 分析》