React 模块联邦 Module Federation 共享机制

各位同学,搬砖界的各位老铁,大家好。

今天我们不谈什么“面向对象编程的三大特性”,也不谈什么“设计模式六大原则”,那些东西就像是食堂里的白米饭,虽然管饱,但有时候你真想加点肉。今天我们要聊的是 Webpack 5 的一个“黑科技”,一个能让前端架构师从“单体地狱”里爬出来,还能在工位上喝着咖啡看着同事们加班的神奇功能——模块联邦

听起来很高大上对吧?其实翻译成人话,这就是“微前端”的官方正牌亲儿子

在 Webpack 5 之前,如果你想搞微前端,你得用 iframe,那玩意儿就像是在你网页里嵌了一个浏览器,丑陋、慢、还不好控制样式。或者用 Web Components,那是原生 JS 的玩法,现在 React/Vue 谁还写原生啊?所以,Webpack 5 的 Module Federation 就像是专门为 React/Vue 生态量身定制的“乐高积木”。

今天这堂课,我们就把这层窗户纸捅破。我会带大家从零开始,用代码和逻辑,把这个“模块联邦”的五脏六腑都给你扒个干净。

准备好了吗?把你的代码编辑器打开,把你的心态调整到“学习模式”。


第一部分:我们为什么要搞这个?

想象一下,你在一个大公司。公司大了,事儿就多了。

前端团队负责展示界面,后端团队负责数据接口,还有个独立的数据团队负责维护数据模型。以前呢,前端团队得等后端把 API 写好,还得等数据团队把数据模型搭好,大家像捆在一根绳上的蚂蚱,谁也别想跑。

现在,Module Federation 允许你把这些应用拆开。

  • Host(主应用): 就像是超市的收银台,负责卖东西,负责收钱。
  • Remote(远程应用): 就像是超市里的各个货架。货架 A 卖零食,货架 B 卖饮料。
  • Shared(共享): 就像是超市里的公共调料台,酱油和醋大家都能用。

最牛的地方在于,Remote 应用可以独立部署,Host 应用也可以独立部署。你只要改了饮料货架的代码,只需要刷新一下饮料货架的页面,Host 应用里的饮料就会自动更新,不需要重启整个超市,也不需要重新下载整个 Host 的包。

这就是“独立部署”


第二部分:核心概念与配置

Module Federation 的核心在于 Webpack 的配置文件 webpack.config.js。这里面有一个插件叫 ModuleFederationPlugin,它是整个联邦体系的灵魂。

1. 基础配置:Remote 端

假设我们要做一个“饮料货架”。这个货架要对外展示它的商品。

remote-appwebpack.config.js 中,我们需要这样写:

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const deps = require("./package.json").dependencies;

module.exports = {
  // ... 其他配置
  plugins: [
    new ModuleFederationPlugin({
      name: "remote_app", // 这个 Remote 的名字,也就是“饮料货架”的名字
      filename: "remoteEntry.js", // 这个文件是货架的“说明书”,Host 会来读它
      exposes: { // 货架上有哪些商品可以卖
        "./ProductList": "./src/ProductList", // 商品名 -> 文件路径
      },
      shared: deps, // 共享依赖,比如 React, React-DOM
    }),
  ],
};

解析一下:

  • name: 这是给 Module Federation 内部用的 ID,不是网页标题。
  • filename: 当 Host 应用来加载这个 Remote 时,它会去请求这个 JS 文件。这个文件里包含了这个 Remote 的所有信息。
  • exposes: 这是最重要的。它告诉 Webpack:“嘿,把 src/ProductList 这个组件暴露出去,让外面的 Host 能看见。”
  • shared: 这里我们直接把 package.json 里的依赖全扔进去了。这是为了后面解决“版本冲突”打基础。

2. 基础配置:Host 端

Host 应用就像是收银台,它需要去“饮料货架”进货。在 host-appwebpack.config.js 中:

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "host_app",
      remotes: { // Host 需要声明有哪些货架可以访问
        remote_app: "remote_app@http://localhost:3001/remoteEntry.js", // 货架名 -> 货架地址
      },
      shared: { // Host 也要声明自己有哪些公共资源
        react: { singleton: true, eager: true }, // 单例模式,且立即加载
        "react-dom": { singleton: true, eager: true },
      },
    }),
  ],
};

解析一下:

  • remotes: 这里是动态加载的关键。Host 并不会一开始就加载这个 JS。它只是定义了一个变量 remote_app。当 Host 真的想用这个 Remote 的商品时,它才会去请求那个 URL。
  • shared: 这里也是重点。Host 声明了 React 是单例。这意味着,如果 Remote 也有 React,Host 必须使用自己手里的那个版本,或者 Remote 必须使用 Host 里的版本。

3. 代码中的使用

配置好了,Host 怎么用呢?就像你从货架拿东西一样。

import React from 'react';
import { remote_app } from './remoteEntry'; // 导入 remoteEntry

// 这里有个坑,需要动态加载
// React.lazy 和 React.lazy(async) 是关键
const RemoteComponent = React.lazy(() => 
  // 这里的语法是:Remote名 + 货架暴露的路径
  remote_app("./ProductList") 
);

function App() {
  return (
    <div className="App">
      <h1>我是主应用</h1>
      <hr />
      <React.Suspense fallback={<div>正在从货架进货...</div>}>
        <RemoteComponent />
      </React.Suspense>
    </div>
  );
}

export default App;

看到了吗?React.lazy(async) 这个 API 就是 Module Federation 的专用 API。它接受一个函数,这个函数返回一个 Promise,Promise 里是你要加载的模块。Webpack 会自动把这个 Promise 拆解,把里面的代码下载下来,然后挂载到 DOM 上。


第三部分:共享机制——这是最复杂的地方

如果只是简单地把代码拉过来,那 Module Federation 也没什么稀奇的。它的核心价值在于共享依赖

想象一下,Host 用的是 React 18,Remote 用的是 React 17。这时候如果 Host 加载 Remote,会发生什么?如果 Remote 再加载 Host,又会发生什么?如果它们各自加载各自的 React,页面里岂不是会有两个 React 实例?这会导致整个应用崩溃。

Module Federation 的共享机制就是为了解决这个问题。

1. 共享配置详解

webpack.config.jsshared 配置中,有几个关键的属性:

  • singleton (单例模式): 如果为 true,那么在所有共享的模块中,只能有一个实例。比如 React,整个应用(Host + Remote)只能有一个 React 实例。
  • eager (立即加载): 如果为 true,Webpack 会在构建 Host 的时候,就把 Remote 的代码(包括 Remote 的依赖)一起打包进去,而不是等到运行时按需加载。这会牺牲一些加载速度,换取构建速度和稳定性。
  • requiredVersion (强制版本): 如果 Remote 依赖的 React 版本高于 Host 里的,Host 会拒绝加载 Remote,报错。

2. 代码演示:版本冲突

假设 Host 有 [email protected],Remote 有 [email protected]

Host 的配置:

shared: {
  react: { singleton: true, requiredVersion: deps.react }, // 18.2.0
}

Remote 的配置:

shared: {
  react: { singleton: true, requiredVersion: deps.react }, // 17.0.2
}

当 Host 运行时,它会检查 Remote。Host 说:“嘿,Remote,你想要 React 17,但我只有 18。而且我是单例,我只能有一个 React。”

这时候,Module Federation 的运行时逻辑就会介入。它会把 Host 里的 React 18 传递给 Remote。Remote 也会检查自己的版本,发现不够,于是它就卸载了本地的 React 17,加载了 Host 传过来的 React 18。

这就是版本协商

3. 手动控制共享(进阶技巧)

有时候,默认的自动协商不够用。比如,你想在 Remote 里使用 Host 里的某些状态,或者你想自定义加载逻辑。

这时候,我们可以在代码里手动调用 init 函数。

在 Host 应用中,我们需要在挂载 React 之前,初始化共享作用域。

// 在 host-app 的 main.js 或者 index.js 里
import { initShared } from 'module-federation-runtime'; // 假设这是运行时库

async function init() {
  // 调用运行时,传入共享的依赖
  await initShared("react", "react-dom", {
    singleton: true,
    eager: true,
  });

  // ... 其他初始化逻辑
}

init().then(() => {
  // 启动 React 应用
  const root = ReactDOM.createRoot(document.getElementById("root"));
  root.render(<App />);
});

这个 initShared 函数就像是海关的“验货员”。它负责把 Host 的依赖准备好,等待 Remote 的调用。


第四部分:循环依赖——达摩克利斯之剑

Module Federation 最大的噩梦,不是版本冲突,而是循环依赖

什么意思呢?Host 引用了 Remote,Remote 又引用了 Host。或者 Remote A 引用了 Remote B,Remote B 又引用了 Remote A。

这就像是你和你的同事互相借钱,你借他 100 块,他借你 100 块,最后银行一看,余额是 0。Webpack 的模块系统也会死机。

1. 循环依赖的原理

在 Webpack 5 之前,循环依赖很难处理。但在 Module Federation 中,我们可以利用异步加载Promise来解决这个问题。

当 Remote A 加载 Remote B 时,Remote A 并不直接把 B 的代码拿过来,而是返回一个 Promise。Remote B 也在加载 Host 的依赖时,返回一个 Promise。通过这种异步的、延迟的加载,Webpack 可以把依赖关系理顺。

2. 代码示例:A 引用 B,B 引用 A

假设 remoteA 暴露了一个组件 ComponentAremoteB 暴露了 ComponentB

remoteA 的代码里:

// remoteA/src/index.js
import React from 'react';
// 假设我们动态加载 remoteB
const loadB = () => import('remoteB/ComponentB');

export const ComponentA = () => {
  const [mounted, setMounted] = React.useState(false);

  React.useEffect(() => {
    // 这里使用了动态导入,避免了同步的死锁
    loadB().then(() => {
      console.log("B 加载成功了");
      setMounted(true);
    });
  }, []);

  return <div>A Component: {mounted ? "B is here" : "Loading B..."}</div>;
};

remoteB 的代码里:

// remoteB/src/index.js
import React from 'react';

export const ComponentB = () => {
  return <div>B Component: I am here!</div>;
};

注意,在 remoteA 中,我们用了 import() 语法。这是解决循环依赖的关键。如果我们在 remoteA 里直接 import { ComponentB } from 'remoteB',Webpack 在打包时就会卡住,因为它不知道先打包谁。

通过使用 React.lazy(async) 或者 import(),Webpack 会生成一个异步 Chunk。这个 Chunk 只在运行时才会被请求。这就给了 Webpack 的运行时系统足够的时间去处理依赖图,从而打破死锁。


第五部分:运行时机制——幕后黑手

如果你想知道 Module Federation 到底是怎么把代码“运”过来的,我们就得聊聊运行时。

Module Federation 不仅仅是一个构建工具,它是一个运行时系统

1. 远程加载流程

  1. 发现: Host 应用启动。Webpack 的运行时脚本开始执行。它读取 remotes 配置。
  2. 加载: 当 Host 试图加载 remote_app/./ProductList 时,运行时脚本会生成一个 URL:http://localhost:3001/remoteEntry.js
  3. 解析: 它去请求这个 JS 文件。
  4. 容器化: remoteEntry.js 是一个特殊的模块。它导出了一个 init 函数。Host 调用这个 init 函数,传入它自己的共享依赖(比如 React 18)。
  5. 暴露: init 函数执行后,Remote 的暴露模块(ProductList)就被注册到了 Module Federation 的容器中。
  6. 加载: Host 现在可以异步加载 ProductList 的代码了。它生成一个新的 URL,请求 ProductList 的 JS 文件(比如 main_1.chunk.js)。
  7. 合并: Webpack 把这个 JS 文件加载进来,解析成模块,然后 Host 就可以像使用普通组件一样使用它了。

2. 共享作用域

这就是为什么 Module Federation 能共享 React 的原因。

Webpack 定义了几个共享作用域

  • react
  • react-dom
  • rxjs
  • 等等。

当模块被加载时,它会检查当前作用域里有没有它需要的依赖。如果没有,它就去加载;如果有,它就复用。如果版本不对,它就会执行版本协商逻辑。

你可以通过 __webpack_init_sharing____webpack_share_scopes__ 这两个全局变量来调试运行时行为。


第六部分:实战中的坑与解决方案

理论讲完了,我们来聊聊实战。Module Federation 看着简单,真上手全是坑。

坑 1:白屏

症状:页面打开是白的,控制台报错 Uncaught TypeError: __webpack_share_scopes__.default.get is not a function

原因:Host 和 Remote 的共享配置不匹配。比如 Host 设置了 eager: true,但 Remote 没有设置,或者 Host 和 Remote 的 React 版本差得太远。

解决:

  1. 检查 package.json 的版本。
  2. shared 配置里加上 requiredVersion: deps.react
  3. 确保所有应用的 Webpack 版本一致(建议都在 5.0 以上)。

坑 2:样式丢失

症状:Remote 组件加载了,但是样式是乱的,或者根本没样式。

原因:

  1. CSS Modules 没有被正确打包进 Remote 的 Chunk。
  2. CSS-in-JS 库(如 styled-components, emotion)没有正确配置。

解决:
对于 CSS Modules,确保 Webpack 配置里没有忽略 CSS 文件。
对于 CSS-in-JS,确保这些库支持 Module Federation 的运行时加载。通常,你需要把 styled-components 的配置改成动态导入,或者确保它被包含在 Remote 的 exposes 中。

坑 3:循环依赖导致的构建失败

症状:Module not found: Error: Can't resolve './module'

原因:在代码里使用了同步的 import 来引用循环依赖的模块。

解决:
把所有的 import 改成 import() 或者 React.lazy(async)。这是唯一的解药。

坑 4:跨域问题

症状:加载 Remote 报 404,或者 CORS policy 错误。

原因:Host 和 Remote 运行在不同的端口,且 Remote 的服务器没有配置允许跨域。

解决:
在 Remote 的服务器配置(比如 Webpack Dev Server)中,设置 headers: { "Access-Control-Allow-Origin": "*" }
remotes 配置中,使用完整的 URL,而不是相对路径。

remotes: {
  remote_app: "remote_app@http://localhost:3001/remoteEntry.js",
}

第七部分:架构演进与未来

让我们把镜头拉远一点。

Module Federation 的出现,标志着前端架构从“构建时打包”“运行时组合”的进化。

以前,我们写代码,编译,打包,生成一个巨大的 JS 文件。我们是在编译时就把所有的关系都定死了。

现在,我们写代码,编译,生成一堆小的 JS 文件。这些文件在运行时才见面。这种松耦合的特性,给了我们极大的灵活性。

1. 代码分割的极致

Module Federation 本质上也是一种代码分割。但它比普通的路由懒加载更高级。普通的懒加载只能按路由分割,而 Module Federation 可以按业务模块分割。

2. 团队协作的新模式

想象一下,你是一个团队的头儿,你负责“用户中心”模块。你完全可以把“用户中心”做成一个 Remote 应用。

别的团队(比如“订单中心”、“商品中心”)想用你的“用户中心”里的登录组件,他们只需要在配置里加一行 remotes,然后像写普通组件一样写代码。你改了代码,他们刷新页面就拿到了最新的组件。

这种“即插即用”的能力,彻底改变了团队协作的方式。

3. 技术债务的偿还

很多老项目,技术栈陈旧,代码耦合严重,改不动。Module Federation 可以帮你做技术栈迁移

比如,你的老项目是用 jQuery 写的,新项目是用 React 写的。你可以把老项目做成一个 Remote,新项目通过 Module Federation 引用老项目。这样,你就可以在新项目里逐步替换老项目,甚至完全替换,而老项目依然可以运行。


第八部分:总结

好了,今天的讲座就要结束了。我们来回顾一下 Module Federation 的核心要点。

  1. 它是什么? 它是 Webpack 5 的一个插件,允许构建独立的应用并在运行时组合。
  2. 它解决了什么? 它解决了微前端中的“代码共享”和“独立部署”问题。
  3. 怎么用?
    • Remote 端:配置 ModuleFederationPluginexposesshared
    • Host 端:配置 ModuleFederationPluginremotesshared
    • 加载:使用 React.lazy(async) 动态加载。
  4. 难点在哪? 循环依赖、版本冲突、样式问题。

Module Federation 就像是一个强大的“模块外交官”。它让我们的代码不再是一潭死水,而是变成了一条流动的河。它允许我们在不破坏现有系统的情况下,不断注入新的活力。

当然,技术是把双刃剑。用得好,你是架构师,指挥若定;用不好,你就是“缝合怪”,维护起来能让你头发掉光。

最后,送给大家一句话:
“不要为了用 Module Federation 而用 Module Federation。当你的单体应用变成了几百个文件,当你的构建时间变成了半个小时,当你的同事因为改一行代码导致全量重新部署而骂娘的时候,你再回头看看 Module Federation,你会发现,它就是你那根救命稻草。”

好了,下课!代码写完了吗?写完赶紧跑个 npm start 看看效果!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注