React 模块联邦(Module Federation):在分布式开发中实现 React 组件的运行时共享与版本协商

各位同学好,今天咱们不聊玄学,不聊架构图里的饼,咱们聊聊怎么把一个巨大的屎山——哦不,是单体应用——拆成几个小屎山,但还能把它们像乐高积木一样拼起来。

这也就是咱们今天要聊的主角:React 模块联邦

首先,我得承认,当我还是个只会写 import React from 'react' 的小菜鸟时,我对“分布式开发”这个词是充满敬畏的。我的世界观里,代码就是代码,要么写在 A 文件夹里,要么写在 B 文件夹里。至于“运行时共享”?那是服务器干的事,我只需要把 JS 文件塞进 HTML 的 <script> 标签里就行了。

但随着工龄增长,我发现单体应用的痛点简直比我的发际线还要明显:

  1. 构建慢得像蜗牛:改个按钮颜色,得等五分钟构建,编译完还得部署,部署完还得重启服务,简直是折磨。
  2. 版本冲突:UI 团队想用 Tailwind 3.0,后端团队还在用 jQuery 写逻辑,你想把这两个库混在一起用?不好意思,npm install 报错,世界毁灭了。
  3. 团队割裂:A 部门觉得 B 部门的代码丑,B 部门觉得 A 部门的逻辑乱,互不兼容,谁也别想动谁。

于是,微前端 应运而生。但传统的微前端方案,比如 iframe、Web Components,要么太重(iframe),要么太新(Web Components,兼容性是个坑),要么太丑(iframe 里的样式隔离是个噩梦)。

这时候,Webpack 5 出了个大招——Module Federation。这玩意儿简直就是前端界的“万能适配器”。


一、 核心概念:谁是谁的谁?

在 Module Federation 里,我们得重新定义一下“关系”。不再是谁依赖谁,而是谁“邀请”谁。

想象一下你家客厅(Host,宿主应用)和隔壁老王家的厨房(Remote,远程应用)。

  • Host(宿主):你是这个家的主人。你不需要自己买菜做饭,你只需要告诉老王,“我饿了,给我端两盘菜过来”。你拥有客厅,控制着整体布局。
  • Remote(远程):老王家。他有自己的冰箱,里面有 React、React-DOM,甚至还有个写着“Hello World”的按钮组件。他不需要知道你家客厅长什么样,他只需要负责把他的菜做好,等你来端。

这就是模块联邦的精髓:运行时动态加载。你不需要在构建阶段就知道隔壁老王有什么,你只需要在运行时,通过 HTTP 请求把他的代码拉下来,然后像引用本地模块一样引用他。


二、 第一课:如何成为一个“好邻居”(配置 Remote)

假设我们有两个项目:

  1. remote-app:我们要把组件暴露出去。
  2. host-app:我们要去加载别人的组件。

首先,咱们来改造 remote-app。我们要让它不仅能运行,还能把它的组件“借”出去。

remote-apppackage.json 里,你需要安装 React 和 Webpack(Webpack 5+)。然后,重头戏来了——webpack.config.js

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

module.exports = {
  // ... 其他配置
  plugins: [
    new ModuleFederationPlugin({
      name: "remote_app", // 这个名字是关键,老王家的招牌
      filename: "remoteEntry.js", // 招牌上的地址
      exposes: {
        // 这里的 './Button' 是老王家的菜谱路径
        // './Button' 对应 src/Button.js
        './Button': './src/Button', 
        // 还可以暴露更多,比如 './Header', './Footer'
      },
      shared: {
        react: { singleton: true, eager: true },
        "react-dom": { singleton: true, eager: true }
      }
    })
  ]
};

这里有几个参数必须得掰碎了讲讲:

  • name: 这是远程模块的 ID。当你去加载它的时候,你得知道它的名字。比如 import("remote_app/Button")
  • exposes: 这是你想借给别人的东西。./Button 是对外暴露的路径,./src/Button 是本地文件路径。
  • shared: 这是版本协商的核心。老王家的冰箱里有 React 18,你家冰箱里也有 React 18,那咱们就用同一个。如果版本不一致怎么办?咱们后面细说。

别忘了,为了让 Remote 能跑起来,你需要把 public/index.html 里的 <script src="bundle.js"></script> 改成动态加载,或者确保你的开发服务器能处理这个 remoteEntry.js


三、 第二课:如何做个“贪吃鬼”(配置 Host)

现在轮到 host-app 了。作为宿主,你得告诉 Webpack:“嘿,我知道隔壁老王有个店,名字叫 remote_app,店在 http://localhost:3001。”

同样在 host-appwebpack.config.js

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

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "host_app", // 你自己的名字
      remotes: {
        // 告诉 Webpack,remote_app 是谁
        remote_app: "http://localhost:3001/remoteEntry.js", 
        // 或者,如果你不想写死 IP,可以用 CDN 或者动态加载
      },
      shared: {
        react: { singleton: true, eager: true },
        "react-dom": { singleton: true, eager: true }
      }
    })
  ]
};

这时候,你的 host-app 就拥有了“召唤” remote_app 的能力。


四、 第三课:真正的魔法——运行时加载

光配置了还不行,你怎么在代码里用呢?

在 React 组件里,你不能直接写 import { Button } from 'remote_app/Button',因为 Webpack 在构建 host-app 的时候,根本不知道 remote_app 是什么,它怎么知道去哪下载代码?

所以,我们必须用动态导入。这就像你点外卖,不能在点菜的时候直接让厨师给你做,你得先下单,等外卖到了,再拿出来吃。

import React, { useState, useEffect } from 'react';

const RemoteButton = () => {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    // 这里的 import 是 Webpack 提供的魔法函数
    // 它会在运行时去请求 http://localhost:3001/remoteEntry.js
    // 然后解析出 './Button',把它加载进来
    import("remote_app/Button").then((module) => {
      setComponent(module.default);
    });
  }, []);

  if (!Component) return <div>Loading Remote Component...</div>;

  return (
    <div style={{ border: "1px solid red", padding: "10px" }}>
      {/* 注意:这里直接用 <Component />,就像用本地组件一样 */}
      <Component text="我是隔壁老王做的按钮" />
    </div>
  );
};

export default RemoteButton;

看,是不是很简单?你的宿主应用完全不知道这个组件是哪来的,它只管用。


五、 第四课:版本协商——不要打架

这是模块联邦最复杂,但也最迷人的地方。

假设 remote-app 想用 React 18,而 host-app 已经用上了 React 19。如果你们不共享 React,那就没问题,两个独立的版本共存。但如果你们共享了 React,那 Webpack 就得做个决定:到底用哪个?

这就是“版本协商”。

回到 webpack.config.js 里的 shared 配置:

shared: {
  react: { 
    singleton: true,  // 单例模式:只允许一个 React 版本存在
    eager: true,      // 急切加载:构建 host 的时候就把 React 拉下来,不用等运行时
    requiredVersion: "^18.0.0" // 强制要求 React 版本必须大于等于 18
  },
  "react-dom": {
    singleton: true,
    eager: true
  }
}

解释一下 singleton
这就好比你和隔壁老王约定,咱俩都只喝一瓶可乐。如果老王打开了一瓶 18 年的老可乐,那你也不能开一瓶新的,得喝他的。如果老王只有 17 年的,那你就要么拒绝(报错),要么同意降级。

解释一下 requiredVersion
这是你的底线。如果你写了 requiredVersion: "^18.0.0",而老王只有 React 17,Webpack 会直接报错:“哥们,你这版本太老,我不认!”。

如果不写 eager: true 呢?
eager: true 意味着你在构建 Host 的时候,就已经把 Remote 的依赖(比如 React)打包进 Host 的 bundle 了。这虽然让加载更快(不用等第二次请求),但会让你的 bundle 变得巨大,而且很难做到真正的版本隔离。

如果不写 singleton: true 呢?
这就相当于你同意老王用他的版本,你也用你的版本。只要版本号不冲突(比如都是 18.0.0),那就没问题。但如果一个是 18.0.0,一个是 18.1.0,这就麻烦了,因为 React 的 API 在某些版本间是不兼容的。


六、 第五课:异步加载与沙箱

刚才我们说了动态 import,现在来聊聊异步依赖

当你加载一个 Remote 组件时,Webpack 会把那个组件的代码单独打包成一个 chunk。比如 remote_app/Button 可能会变成 remote_app_Button.js

这就引出了第二个大招:作用域隔离

在微前端之前,如果你在 Host 里写了 var a = 1,在 Remote 里也写了 var a = 1,它们会互相覆盖,导致 Bug。这就像两个室友共用一个衣柜,你把衬衫塞进去,他拿走了你的衬衫,第二天你穿着他的脏衬衫去上班。

Webpack 5 的 Module Federation 提供了运行时沙箱

当你动态加载 Remote 模块时,Webpack 会创建一个临时的沙箱环境。Remote 里的全局变量(比如 var 定义的变量)会被隔离在这个沙箱里。Remote 里的代码不会污染 Host 的全局变量,Host 的代码也不会污染 Remote。

// remote-app/src/GlobalComponent.js
export const GlobalComponent = () => {
  // 这里的 global 变量,只有在 remote-app 里能访问到
  // 如果你在 host-app 里也定义了 global,它们互不干扰
  if (typeof window !== 'undefined') {
    window.remoteGlobal = "我是老王的变量";
  }

  return <div>I am Remote Global</div>;
};

在 Host 里,你访问不到 window.remoteGlobal,除非你显式地把 Remote 的作用域暴露出来(通常不建议这么做,这是坑)。


七、 第六课:实战中的那些坑

理论讲完了,咱们来聊聊实战。Module Federation 虽然强大,但它不是银弹,它是一把双刃剑。

1. CORS 问题(跨域)
这是最常见的问题。当你运行 host-app,它去请求 remote-appremoteEntry.js 时,浏览器会检查 CORS 策略。
如果你在开发环境,默认配置通常是允许的。但在生产环境,或者配置不当,你会遇到 404 或者 CORS policy 错误。
解决方法:确保你的 Webpack 开发服务器(DevServer)配置了 headers,允许跨域访问。

// webpack.config.js devServer 部分
devServer: {
  port: 3000,
  headers: {
    "Access-Control-Allow-Origin": "*", // 或者指定 host-app 的域名
    "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
    "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization"
  }
}

2. 热更新(HMR)的痛
有时候你改了 Remote 的代码,Host 的页面并没有刷新。
这是因为动态加载的模块,Webpack 的 HMR 机制有时候处理得不够好。你需要确保你的 host-appremote-app 的 DevServer 配置正确,并且依赖了 webpack-dev-server/client

3. React 18 的自动批处理
如果你在 Remote 里使用了 React 18 的特性,而 Host 使用的是 React 17,可能会导致渲染行为不一致。这就是为什么我们强烈建议在 Module Federation 中使用 singleton: true 来统一 React 版本。

4. 路由冲突
如果 Host 和 Remote 都有自己的路由系统(比如 Host 用的是 React Router,Remote 用的也是 React Router),那当 Remote 组件被加载时,Remote 的路由可能会接管浏览器的 URL,导致 Host 的路由失效。
解决方法:通常 Remote 组件是作为 Host 页面的子组件存在的,所以不需要处理路由,或者使用 HashRouter 来隔离路由。


八、 进阶:Vite 的崛起

写到这里,你可能会想:“Webpack 配置太繁琐了,我不想写 webpack.config.js,我想用 Vite。”

好消息是,Vite 2.0+ 开始支持模块联邦了!

Vite 的配置简直清爽得像刚洗过的脸:

// vite.config.js
export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'remote_app',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/Button',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
  server: {
    port: 3001,
    cors: true, // Vite 默认开启 CORS
  },
});

Host 端的配置也类似:

export default defineConfig({
  plugins: [react()],
  server: {
    port: 3000,
  },
  build: {
    target: 'es2020', // 确保 target 设置正确
  },
  optimizeDeps: {
    include: ['react', 'react-dom'],
  },
  // 指定 Remote
  resolve: {
    alias: {
      'remote_app': 'http://localhost:3001/remoteEntry.js'
    }
  }
});

Vite 的处理速度比 Webpack 快得多,而且配置更少。但是要注意,Vite 的模块联邦目前还在发展中,对于一些非常复杂的 Webpack 插件兼容性,可能不如 Webpack 5 稳定。


九、 总结一下

Module Federation 到底解决了什么问题?

它解决了“大而全”的单体应用“小而美”的分布式应用演进过程中的依赖管理运行时加载问题。

它允许你:

  1. 独立部署:Remote 组件的修改,不需要重新部署整个 Host 应用。
  2. 代码复用:React、AntD 等基础库可以在多个应用间共享,减少包体积。
  3. 团队协作:不同团队负责不同的 Remote 模块,互不干扰,就像在玩乐高。

当然,它也带来了新的挑战:

  1. 调试难度增加:你不仅要调试本地的代码,还要调试网络请求、CORS、版本冲突。
  2. 架构复杂度:你不再只是写一个 App.js,你还要管理模块间的依赖关系。
  3. 学习成本:你需要理解 Webpack 的构建流程,以及运行时模块加载的机制。

最后,我想说,技术选型没有绝对的对错。如果你的项目还在起步阶段,或者团队规模不大,单体应用依然是性价比最高的选择。但如果你正在维护一个 10 年前写的大项目,或者你的团队有 50 个人以上,每个人都想改同一个 App.js,那么,Module Federation 可能就是你通往新世界的门票。

好了,今天的讲座就到这里。如果你们在配置 Module Federation 的时候踩了坑,记得把错误日志截图发在群里,咱们一起吐槽,一起解决。下课!

发表回复

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