各位同学好,今天咱们不聊玄学,不聊架构图里的饼,咱们聊聊怎么把一个巨大的屎山——哦不,是单体应用——拆成几个小屎山,但还能把它们像乐高积木一样拼起来。
这也就是咱们今天要聊的主角:React 模块联邦。
首先,我得承认,当我还是个只会写 import React from 'react' 的小菜鸟时,我对“分布式开发”这个词是充满敬畏的。我的世界观里,代码就是代码,要么写在 A 文件夹里,要么写在 B 文件夹里。至于“运行时共享”?那是服务器干的事,我只需要把 JS 文件塞进 HTML 的 <script> 标签里就行了。
但随着工龄增长,我发现单体应用的痛点简直比我的发际线还要明显:
- 构建慢得像蜗牛:改个按钮颜色,得等五分钟构建,编译完还得部署,部署完还得重启服务,简直是折磨。
- 版本冲突:UI 团队想用 Tailwind 3.0,后端团队还在用 jQuery 写逻辑,你想把这两个库混在一起用?不好意思,
npm install报错,世界毁灭了。 - 团队割裂: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)
假设我们有两个项目:
remote-app:我们要把组件暴露出去。host-app:我们要去加载别人的组件。
首先,咱们来改造 remote-app。我们要让它不仅能运行,还能把它的组件“借”出去。
在 remote-app 的 package.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-app 的 webpack.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-app 的 remoteEntry.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-app 和 remote-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 到底解决了什么问题?
它解决了“大而全”的单体应用向“小而美”的分布式应用演进过程中的依赖管理和运行时加载问题。
它允许你:
- 独立部署:Remote 组件的修改,不需要重新部署整个 Host 应用。
- 代码复用:React、AntD 等基础库可以在多个应用间共享,减少包体积。
- 团队协作:不同团队负责不同的 Remote 模块,互不干扰,就像在玩乐高。
当然,它也带来了新的挑战:
- 调试难度增加:你不仅要调试本地的代码,还要调试网络请求、CORS、版本冲突。
- 架构复杂度:你不再只是写一个
App.js,你还要管理模块间的依赖关系。 - 学习成本:你需要理解 Webpack 的构建流程,以及运行时模块加载的机制。
最后,我想说,技术选型没有绝对的对错。如果你的项目还在起步阶段,或者团队规模不大,单体应用依然是性价比最高的选择。但如果你正在维护一个 10 年前写的大项目,或者你的团队有 50 个人以上,每个人都想改同一个 App.js,那么,Module Federation 可能就是你通往新世界的门票。
好了,今天的讲座就到这里。如果你们在配置 Module Federation 的时候踩了坑,记得把错误日志截图发在群里,咱们一起吐槽,一起解决。下课!