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

《前端开发中的微前端落地实践:基于 Module Federation 的应用拆分、共享依赖与部署策略》

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

前端开发中的微前端落地实践:基于 Module Federation 的应用拆分、共享依赖与部署策略

当一个前端项目从“几个人维护的单体应用”长成“多个团队同时开发的平台型系统”时,问题往往不是代码能不能跑,而是能不能持续演进

我在实际项目里遇到过很典型的场景:一个管理后台最开始只有几个页面,后来逐步扩展成用户中心、订单、报表、营销、权限等多个业务域。所有代码都堆在一个仓库里,发布一次要过整站回归,任意一个模块出问题都可能拖慢全局上线。这个阶段继续靠路由分目录、组件分文件夹,已经很难解决团队协作和独立交付的问题了。

这时候,微前端就不只是一个“架构名词”,而是一个现实的工程选择。本文我会围绕 Module Federation,讲清楚它在前端开发里的落地方式:怎么拆应用、怎么共享依赖、怎么部署、以及上线后常见问题怎么排查


背景与问题

单体前端走到后期会遇到什么

常见痛点通常集中在这几个方面:

  • 团队协作冲突大:多人同时改动同一个仓库、同一套依赖和构建配置
  • 发布链路过长:一个小功能上线,可能需要整个站点重新构建和验证
  • 技术栈升级困难:某个模块想单独升级 React、Vue、路由或构建工具,牵一发动全身
  • 业务边界不清:组件、状态、接口调用互相穿透,后期很难治理
  • 性能不可控:首屏加载越来越重,按业务拆包也不一定能真正做到团队自治

微前端不是银弹,但很适合这类场景

微前端最适合的,不是“小而美”的应用,而是:

  • 多团队并行开发
  • 业务域边界相对清晰
  • 希望子系统独立发布
  • 希望宿主应用统一接入、统一登录、统一导航
  • 允许一定架构复杂度换取组织协作效率

如果你的项目只有 5 个页面、2 个人维护,那微前端大概率是过度设计。
但如果你已经有多个业务线、频繁发版、并且常常因为“别人模块阻塞我上线”而头疼,那么 Module Federation 值得认真考虑。


先说结论:Module Federation 解决了什么

Module Federation(模块联邦) 是 Webpack 5 提供的能力,核心目标是:

  1. 让应用之间在运行时共享代码
  2. 让一个应用可以动态加载另一个应用暴露出的模块
  3. 让依赖库在多个子应用之间尽量复用,而不是重复打包

它和传统“每个子应用各自打包再通过 iframe 或 script 标签拼接”最大的区别在于:

  • 不是单纯把页面拼起来
  • 而是把模块级别的能力开放出来
  • 并且在运行时协商共享依赖的版本与实例

这也是它特别适合现代前端工程体系的原因。


核心原理

1. 基本角色:Host 与 Remote

在 Module Federation 中,最常见的是两个角色:

  • Host(宿主应用):主入口,负责路由、导航、壳应用能力
  • Remote(远程应用):暴露页面、组件、工具函数等模块给宿主消费

比如:

  • shell:平台主应用
  • user-app:用户中心子应用
  • order-app:订单子应用

宿主应用不需要在构建时把远程模块打进自己的 bundle,而是在运行时通过远程入口加载它们。

2. 关键配置项

Module Federation 的核心配置一般长这样:

  • name:当前应用名称
  • filename:远程入口文件名,常见是 remoteEntry.js
  • exposes:当前应用对外暴露哪些模块
  • remotes:当前应用依赖哪些远程应用
  • shared:哪些依赖在多个应用之间共享

3. 共享依赖是落地成败的关键

如果只会配 exposesremotes,项目大概率能跑;
但如果不会配 shared,项目上线后很容易出现:

  • React 重复实例
  • Context 失效
  • Hooks 报错
  • 路由对象不是同一个实例
  • UI 组件库样式重复注入
  • 包体积失控

尤其是 React 项目,reactreact-dom 通常都要设置为单例。

4. 运行时加载流程

下面用一个流程图看清楚宿主加载远程模块的过程。

flowchart LR
  A[用户访问 Host] --> B[加载 Host 主包]
  B --> C[根据路由匹配远程模块]
  C --> D[请求 remoteEntry.js]
  D --> E[初始化共享作用域 shared scope]
  E --> F[加载 Remote 暴露模块]
  F --> G[渲染页面或组件]

5. 共享依赖协商过程

这个过程很多人平时不太关注,但问题经常出在这里。

sequenceDiagram
  participant Host
  participant RemoteEntry
  participant SharedScope
  participant RemoteModule

  Host->>RemoteEntry: 加载 remoteEntry.js
  Host->>SharedScope: 初始化 shared scope
  RemoteEntry->>SharedScope: 注册/读取共享依赖
  Host->>RemoteModule: 请求具体暴露模块
  RemoteModule->>SharedScope: 获取 react/react-dom 等共享实例
  SharedScope-->>RemoteModule: 返回可用版本
  RemoteModule-->>Host: 导出组件

应用拆分:别一上来按页面拆,先按业务域拆

这是我很想强调的一点。很多团队第一次做微前端,容易犯一个错误:
看见页面多,就按页面拆。

比如:

  • /user/list 一个子应用
  • /user/detail 一个子应用
  • /user/edit 一个子应用

这样拆出来的结果通常很糟糕:

  • 共享逻辑散落
  • 通信复杂
  • 远程加载次数增多
  • 团队职责边界依旧模糊

更合理的拆分方式

建议优先按业务域拆:

  • 用户域:用户信息、组织关系、角色管理
  • 订单域:订单列表、详情、售后、对账
  • 营销域:活动、优惠券、投放
  • 平台壳:导航、登录态、权限、埋点、全局通知

一个可落地的划分标准

如果一个模块同时满足下面 3 条,通常适合拆成独立微应用:

  1. 有相对稳定的业务边界
  2. 能由相对独立的团队负责
  3. 可以独立发布而不强依赖其他业务模块

一个经验性的边界条件

不要把“所有公共组件”都抽成一个远程应用。
原因很简单:公共组件库应该优先作为 npm 包或 workspace 包管理,而不是远程运行时模块
远程模块适合承载的是“需要独立上线的业务能力”,不是所有复用代码。


实战代码(可运行)

下面我们用一个最小可运行示例来走一遍:

  • host:宿主应用
  • remote:远程子应用
  • 技术栈:React + Webpack 5

目录结构示意:

mf-demo/
  host/
  remote/

1. remote:暴露一个按钮组件

remote/package.json

{
  "name": "remote",
  "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.0",
    "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"
  }
}

remote/src/Button.jsx

import React from "react";

export default function Button() {
  return (
    <button
      style={{
        padding: "8px 16px",
        background: "#1677ff",
        color: "#fff",
        border: "none",
        borderRadius: "6px",
        cursor: "pointer"
      }}
      onClick={() => alert("Hello from Remote Button")}
    >
      Remote Button
    </button>
  );
}

remote/src/index.jsx

import React from "react";
import { createRoot } from "react-dom/client";

function App() {
  return <div>Remote app is running</div>;
}

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

remote/public/index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Remote</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

remote/webpack.config.js

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

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

2. host:动态加载远程组件

host/package.json

{
  "name": "host",
  "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.0",
    "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"
  }
}

host/src/App.jsx

import React, { Suspense } from "react";

const RemoteButton = React.lazy(() => import("remote/Button"));

export default function App() {
  return (
    <div style={{ padding: 24 }}>
      <h1>Host App</h1>
      <p>下面这个按钮来自远程子应用:</p>
      <Suspense fallback={<div>Loading remote component...</div>}>
        <RemoteButton />
      </Suspense>
    </div>
  );
}

host/src/index.jsx

import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

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

host/public/index.html

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

host/webpack.config.js

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

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

3. 运行方式

先启动 remote:

cd remote
npm install
npm run start

再启动 host:

cd host
npm install
npm run start

访问:

http://localhost:3000

如果一切正常,你会在宿主页面看到来自远程应用的按钮。


再往前一步:真实项目中的拆分结构

上面的例子只是证明“能跑”。真实项目里,更常见的结构是下面这样:

flowchart TD
  A[Shell 宿主应用] --> B[用户中心 Remote]
  A --> C[订单中心 Remote]
  A --> D[营销中心 Remote]
  A --> E[报表中心 Remote]

  B --> F[用户列表]
  B --> G[角色权限]
  C --> H[订单列表]
  C --> I[售后处理]
  D --> J[活动配置]
  E --> K[数据看板]

宿主应用通常负责什么

宿主应用建议承载这些能力:

  • 顶部导航、侧边栏
  • 登录态校验
  • 权限过滤
  • 路由分发
  • 埋点、监控、日志上报
  • 全局主题、国际化
  • 错误兜底和降级页

子应用负责什么

子应用应该专注:

  • 自己的业务页面
  • 自己的状态管理
  • 自己的接口封装
  • 自己的业务组件

通信原则

在微前端里,通信越少越好。
我更推荐这几种顺序:

  1. URL 传参
  2. 宿主下发 props / context
  3. 事件总线
  4. 共享状态仓库

如果一上来就做全局共享状态,后期很容易重新变回“逻辑耦合的单体”。


部署策略:真正上线时该怎么做

Module Federation 的价值,很大一部分体现在独立部署
但到了部署环节,很多问题才真正开始暴露。

常见部署模式

模式一:固定地址部署

例如:

  • https://app.example.com/ → host
  • https://user.example.com/remoteEntry.js
  • https://order.example.com/remoteEntry.js

优点:

  • 简单直观
  • 本地到线上路径映射清晰

缺点:

  • 版本切换不灵活
  • 回滚粒度受限

模式二:带版本号的静态资源部署

例如:

  • https://cdn.example.com/user-app/1.3.2/remoteEntry.js
  • https://cdn.example.com/order-app/2.1.0/remoteEntry.js

优点:

  • 可以灰度、回滚
  • 资源可长期缓存
  • 发布记录清晰

缺点:

  • 宿主应用需要一套远程地址配置中心

模式三:远程清单驱动

宿主应用先请求一个 manifest:

{
  "userApp": "https://cdn.example.com/user-app/1.3.2/remoteEntry.js",
  "orderApp": "https://cdn.example.com/order-app/2.1.0/remoteEntry.js"
}

再动态加载对应 remote。

这是我更推荐的方式,因为它更适合:

  • 灰度发布
  • 多环境切换
  • 热更新 remote 地址
  • 紧急回滚

一个简单的动态 remote 加载思路

有些项目不把 remote 写死在 webpack 配置里,而是运行时注入:

function loadRemoteScript(url, scope) {
  return new Promise((resolve, reject) => {
    const existing = document.querySelector(`script[data-remote="${scope}"]`);
    if (existing) {
      resolve();
      return;
    }

    const script = document.createElement("script");
    script.src = url;
    script.type = "text/javascript";
    script.async = true;
    script.dataset.remote = scope;

    script.onload = () => resolve();
    script.onerror = () => reject(new Error(`Failed to load ${url}`));

    document.head.appendChild(script);
  });
}

这种方式的价值在于:remote 地址不再跟宿主构建强绑定


常见坑与排查

这部分我建议你在落地时重点看,因为真正耗时间的,往往不是搭起来,而是“为什么线上偶发报错”。

1. Invalid hook call

这是 React 微前端里最经典的问题之一。

现象

页面加载后报错:

Invalid hook call. Hooks can only be called inside of the body of a function component.

常见原因

  • host 和 remote 各自加载了不同实例的 React
  • react 没有配置 singleton: true
  • 某个子应用打包进了自己的 React 副本

排查方向

  • 检查 shared.react.singleton
  • 检查 react-dom 是否也共享
  • 检查版本是否差异过大
  • 检查 lockfile 是否导致安装了多份 React

2. 路由跳转异常或刷新 404

现象

  • 子应用内部路由跳转后刷新页面 404
  • 宿主和子应用抢路由
  • 浏览器前进后退行为异常

原因

  • historyApiFallback 未配置
  • 宿主和子应用都使用 BrowserRouter,但基路径没有隔离
  • 路由前缀设计不清晰

建议

  • 明确每个子应用的路由前缀,例如 /user/*/order/*
  • 宿主统一做一级路由分发
  • 子应用内部尽量基于自己的 basename

3. 远程资源加载失败

现象

  • 本地能跑,线上偶发白屏
  • 控制台提示 remoteEntry.js 404 或跨域错误

原因

  • remote 地址发布后未同步到宿主
  • CDN 缓存未刷新
  • 跨域头没配
  • publicPath 错误

排查建议

先看网络面板:

  • remoteEntry.js 是否返回 200
  • 远程 chunk 是否加载成功
  • 是否存在 CORS 报错
  • chunk URL 是否拼接错误

很多时候根因不是联邦本身,而是静态资源路径配置不一致。


4. 样式污染

现象

  • 一个子应用的全局样式覆盖了另一个应用
  • UI 库 reset 样式互相影响

建议

  • 尽量避免全局样式
  • 使用 CSS Modules、BEM 或 CSS-in-JS
  • 对设计系统和 UI 组件库做统一约束
  • 不同微应用避免重复注入多套 reset.css

5. 共享依赖版本不兼容

现象

本地正常,某次上线后突然只有部分页面异常。

原因

  • 某个 remote 升级了依赖版本
  • 共享协商后拿到了“能用但不完全兼容”的版本

建议

  • 核心共享依赖设定明确版本策略
  • 对 React、路由、状态管理、UI 库进行兼容性评估
  • 不要随意让所有包都 shared

一套实用的排查路径

如果线上微前端页面白屏,我通常按这个顺序查:

flowchart TD
  A[页面白屏] --> B{Host 是否正常加载}
  B -->|否| C[检查宿主构建与静态资源]
  B -->|是| D{remoteEntry.js 是否成功加载}
  D -->|否| E[检查地址/CORS/CDN/缓存]
  D -->|是| F{共享依赖是否冲突}
  F -->|是| G[检查 singleton 与版本]
  F -->|否| H{远程模块是否导出正确}
  H -->|否| I[检查 exposes 路径与模块名]
  H -->|是| J[检查路由/样式/运行时错误]

这套路径不一定覆盖全部情况,但能帮你避免“上来就怀疑框架”的低效排查。


安全/性能最佳实践

微前端项目上线后,最怕两件事:远程资源不可信,以及首屏性能被拖垮


安全最佳实践

1. 只加载可信来源的 remote

不要让 remote 地址完全由前端随意拼接。
建议:

  • 地址来源于受控配置中心
  • 限制域名白名单
  • 区分正式、预发、测试环境

2. 谨慎对待动态脚本注入

运行时加载 remote 的本质就是加载外部脚本。
如果配置源不可信,会有 XSS 风险。

建议:

  • 配置中心鉴权
  • 使用 HTTPS
  • 配置 CSP(Content Security Policy)
  • 对远程来源做白名单校验

3. 不把敏感能力直接暴露给子应用

例如:

  • 用户令牌原文
  • 高权限操作方法
  • 全局调试接口

更稳妥的做法是由宿主封装能力,再以受控 API 方式提供给子应用。


性能最佳实践

1. 不要为了“微前端”而过度拆分

微应用越多,运行时加载和治理成本越高。
一般建议:

  • 按业务域拆,不按零碎页面拆
  • 一个 remote 承载一类完整业务能力

2. 共享依赖要克制

不是所有依赖都应该 shared。
适合共享的通常是:

  • react
  • react-dom
  • 路由库
  • 核心状态管理库
  • 统一 UI 设计系统

不太建议共享的:

  • 业务私有工具包
  • 变化频繁的小库
  • 容易产生版本耦合的非核心依赖

3. 做好懒加载和预加载

对于不在首屏使用的 remote,不要提前全部拉下来。
可以结合:

  • 路由级懒加载
  • 用户即将访问某模块时预加载 remoteEntry
  • 空闲时段预取热门子应用资源

4. 给远程模块加降级兜底

如果 remote 加载失败,宿主应用不能直接白屏。

可以做:

  • loading 占位
  • error boundary
  • 降级提示页
  • 回退到旧版本 remote

5. 监控要细到 remote 级别

至少监控:

  • remoteEntry 加载耗时
  • 远程 chunk 加载失败率
  • 子应用初始化时间
  • 页面级 JS 错误率
  • 版本号与发布批次

没有这些监控,出了问题就只能靠猜。


方案取舍:Module Federation 适合你吗

在做架构选型时,我建议把它当成一种工程协作机制,而不只是技术炫技。

适合的情况

  • 多团队并行开发
  • 业务边界明确
  • 希望独立部署
  • 愿意投入治理成本
  • 已经具备 CI/CD、监控、版本管理基础

不太适合的情况

  • 项目规模还小
  • 团队人数少
  • 页面之间强耦合
  • 还没有清晰的模块边界
  • 当前主要痛点不是发布和协作,而是业务需求本身变化太快

一个比较务实的落地策略

不要一开始就“全站微前端化”。
我更推荐:

  1. 先挑一个边界清晰的业务域试点
  2. 跑通 shared、路由、发布、监控
  3. 形成脚手架和规范
  4. 再逐步扩展到更多子应用

这样踩坑成本最低,也更容易说服团队。


总结

Module Federation 真正有价值的地方,不只是“技术上能动态加载远程组件”,而是它为前端项目提供了一种更适合大团队协作的拆分方式:

  • 按业务域拆分应用
  • 在运行时共享核心依赖
  • 支持子应用独立发布
  • 让宿主统一承接平台能力

但它也天然带来新的复杂度:

  • 共享依赖治理
  • 路由边界设计
  • 样式隔离
  • 远程资源部署与回滚
  • 运行时故障排查

如果你准备在项目里落地,我的建议很直接:

  1. 先按业务域而不是页面拆
  2. React、ReactDOM 这类核心依赖务必单例共享
  3. 宿主只承载平台能力,子应用专注业务
  4. 远程地址尽量走 manifest 或配置中心
  5. 先补齐监控、降级、回滚,再谈大规模推广

最后再补一句边界条件:
微前端不是为了让架构图更漂亮,而是为了让系统在业务增长、团队增长之后,依然能稳定迭代。
如果它不能帮你减少协作阻塞、缩短交付链路,那就说明拆分方式或者落地时机还不对。


分享到:

上一篇
《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:一致性、穿透与热点 Key 优化》
下一篇
《Java 中使用 CompletableFuture 构建高并发异步任务编排的实战指南-426》