一、 模块联邦 (Module Federation) 是什么?
模块联邦是 Webpack 5 引入的一项革命性功能,它允许一个 JavaScript 应用(称为 Remote )在运行时 动态地将代码暴露给另一个 JavaScript 应用(称为 Host ),同时可以共享依赖。这使得不同的、独立部署的应用能够像一个整体应用一样平滑地共享和使用代码,是实现微前端 (Micro-Frontends) 架构的一种非常强大且原生的解决方案。
核心思想:将多个独立的构建组成一个应用程序,这些独立的构建之间可以直接引用对方的模块,就像在同一个构建中一样。
二、 模块联邦的使用场景
模块联邦主要解决的是大型应用或多个应用之间的代码共享和集成问题,尤其适用于以下场景:
-
微前端架构: 这是最核心的应用场景。允许将一个大型前端应用拆分成多个更小、更专注、可独立开发、测试和部署的微应用。这些微应用可以由不同的团队维护,甚至可以使用不同的技术栈(只要它们最终都编译成 Webpack 能理解的模块),然后通过模块联邦在运行时无缝集成为一个统一的用户体验。
- 示例: 一个电商平台,可以将首页、商品详情页、购物车、用户中心等拆分成独立的微应用。导航栏/页头页脚可以作为一个公共应用(Remote)暴露组件,供其他微应用(Host)消费。
-
大型单体应用拆分: 对于历史悠久、代码庞大的单体应用,可以使用模块联邦逐步将其拆分为更小的单元,而无需一次性重构。可以先将一部分功能模块化并作为 Remote 暴露,然后在原单体应用 (Host) 中引用,逐步替换。
-
跨团队/项目代码复用: 当多个项目或团队需要共享通用的组件库、工具函数或业务模块时,可以将这些共享代码部署为一个或多个 Remote 应用,其他项目作为 Host 直接引用,避免了传统的 npm 包发布、更新、安装的繁琐流程和版本管理问题(尤其是在快速迭代时)。
-
组件平台/设计系统: 企业内部的 Design System 或组件平台可以作为一个 Remote 应用部署,所有业务线应用 (Hosts) 可以直接引用最新的标准化组件,保证 UI/UX 的一致性。
-
A/B 测试和灰度发布: 可以将新版本的特性或组件部署为一个独立的 Remote,然后在 Host 应用中通过配置或运行时逻辑,动态地将部分用户流量导向新版本的模块,实现灵活的 A/B 测试或灰度发布。
三、 如何使用模块联邦?
使用模块联邦的核心在于配置 Webpack 的 ModuleFederationPlugin
插件。通常,你需要至少两个应用:一个作为 Remote (暴露模块),一个作为 Host (消费模块)。一个应用也可以同时是 Host 和 Remote。
核心配置项:
-
name
:string
- 当前应用的唯一标识符(通常与
package.json
的name
相关)。这个名字将用于在全局作用域中暴露模块,以及被其他应用引用。必须是全局唯一的。
- 当前应用的唯一标识符(通常与
-
filename
:string
- 仅 Remote 需要 : 指定暴露模块的入口文件的名称,例如
remoteEntry.js
。这个文件包含了 Remote 应用暴露的模块列表以及如何加载它们的逻辑。Host 应用会首先加载这个文件。
- 仅 Remote 需要 : 指定暴露模块的入口文件的名称,例如
-
exposes
:object
- 仅 Remote 需要: 定义当前应用需要暴露给外部的模块。
- Key: 外部引用时使用的别名(例如
./Button
)。 - Value: 内部模块的实际路径(例如
./src/components/Button
)。 - exposes: {
- './Button': './src/components/Button',
- './utils': './src/utils/index.js'
- }
-
remotes
:object
-
仅 Host 需要: 定义当前应用需要引用的远程应用。
-
Key: 在当前应用中引用远程模块时使用的别名(例如
app_remote
)。 -
Value: 远程应用的地址,格式为
remoteName@remoteUrl/remoteEntryFilename
。remoteName
: 远程应用的name
。remoteUrl
: 远程应用remoteEntry.js
文件部署的 URL 地址。remoteEntryFilename
: 远程应用配置的filename
。
-
remotes: {
-
// '本地别名': '远程应用name@远程入口URL'
-
'app_remote': 'app_remote@http://localhost:3001/remoteEntry.js',
-
'shared_components': 'shared_components@www.google.com/search?q=ht...'
-
}
-
-
shared
:object | array
-
Host 和 Remote 都需要: 定义需要共享的依赖库,以避免在多个应用间重复加载,并处理版本兼容性。
-
Key: 需要共享的 npm 包名(例如
react
)。 -
Value: 可以是包名字符串,也可以是一个配置对象。
singleton: true
: 强制该共享模块为单例。如果 Host 和 Remote 依赖的版本兼容,则只加载一个实例。如果版本不兼容,会发出警告(或根据requiredVersion
报错)。对于 React、Vue 等需要全局唯一实例的库,通常需要设置为true
。requiredVersion: string | false
: 指定可接受的共享依赖的版本范围 (遵循 semver 规范)。如果设置为false
,则不强制版本要求。通常可以从package.json
动态获取:require('./package.json').dependencies.react
。eager: true
: 如果设置为true
,该共享模块将不 会被异步加载,而是直接打包到初始的 chunk 中。这会增加初始包体积,但可以避免异步加载的开销。通常用于应用核心、立即需要的共享库。默认为false
(异步)。import: string | false
: (较少直接使用) 指定共享模块的导入路径。如果设置为false
,则此共享模块不会被 Webpack 自动提供,需要应用自己确保其可用(例如通过全局变量)。
-
简化配置 : 可以直接使用数组
shared: ['react', 'react-dom']
,Webpack 会尝试自动推断配置。 -
推荐配置:
JavaScriptconst deps = require('./package.json').dependencies; // ... shared: { ...deps, // 将所有 dependencies 共享出去是一种策略,但需谨慎评估 react: { singleton: true, requiredVersion: deps.react, // eager: true // 如果 React 是应用核心,可以考虑 eager 加载 }, 'react-dom': { singleton: true, requiredVersion: deps['react-dom'], }, // 如果某个库不需要共享,可以覆盖 'some-large-lib': false, }
-
四、 模块联邦核心原理 (概念性源码讲解)
模块联邦的魔法发生在构建时和运行时:
1. 构建时 (Build Time):
-
Remote 应用构建:
-
当 Webpack 构建 Remote 应用时,
ModuleFederationPlugin
会做几件事:-
为
exposes
中配置的每个模块创建一个异步加载的 chunk。 -
生成
filename
指定的入口文件 (e.g.,remoteEntry.js
)。这个文件非常关键,它本身不大,主要包含:-
一个清单 (Manifest) :记录了该 Remote 应用的
name
,以及所有exposes
模块的别名到实际异步 chunk 的映射关系。 -
一个容器接口 (Container Interface) :通常是在全局作用域(或特定 scope)下挂载一个对象(以
name
命名),该对象包含两个核心方法:get(module)
: 一个函数,接收暴露的模块别名 (e.g.,./Button
) 作为参数,返回一个 Promise,该 Promise resolve 后会提供一个模块工厂函数 (Module Factory) 。调用这个工厂函数才能真正执行并获取模块实例。init(sharedScope)
: 一个函数,用于初始化共享作用域 (Shared Scope)。Host 应用在加载 Remote 后会调用它,传入 Host 自身的共享依赖信息,Remote 会根据这些信息和自身的shared
配置进行版本协商,决定最终使用哪个版本的共享库,并填充共享作用域。
-
-
为
shared
中配置的、且eager: false
(默认)的共享依赖也创建异步加载的 chunk。
-
-
-
Host 应用构建:
ModuleFederationPlugin
读取remotes
配置,但不直接将 Remote 的代码打包进来。- 它会生成一些代理模块 (Proxy Modules) 。当你写
import('app_remote/Button')
时,实际上是导入了这个代理模块。 - 这个代理模块知道如何根据
remotes
配置找到对应的 Remote 应用的remoteEntry.js
地址。 shared
配置用于在运行时与 Remote 进行依赖协商。
2. 运行时 (Run Time):
-
Host 加载: 用户访问 Host 应用。
-
触发 Remote 模块加载 : 当 Host 的代码执行到
import('app_remote/Button')
时:- Webpack 运行时发现这是一个外部模块引用,查找
remotes
配置,找到app_remote
对应的 URL (http://localhost:3001/remoteEntry.js
)。 - Host 动态地在页面中插入一个
<script>
标签,src
指向remoteEntry.js
的 URL,或者使用其他方式(如Workspace
+eval
)加载并执行这个文件。
- Webpack 运行时发现这是一个外部模块引用,查找
-
remoteEntry.js
执行:remoteEntry.js
中的代码执行,将 Remote 的容器接口(包含get
和init
方法)注册到全局或特定作用域下 (e.g.,window.app_remote
)。
-
共享依赖协商 (
init
) :-
Host 的 Webpack 运行时调用 Remote 容器的
init(sharedScope)
方法。 -
sharedScope
是 Host 提供的共享依赖信息(包括版本、是否已加载等)。 -
Remote 的
init
函数根据 Host 提供的信息和自身shared
配置进行比较:- 版本兼容且
singleton: true
: 如果 Host 已提供兼容版本的单例库(如 React),Remote 会复用 Host 的实例。如果 Host 未提供,Remote 会负责加载(如果配置允许),并放入共享作用域。如果双方都有但不兼容,会报警告。 - 版本兼容但非单例: 通常会优先使用版本号更高的那个,或者各自加载自己的。
- 版本不兼容 : 根据
requiredVersion
决定是报错还是允许各自加载。
- 版本兼容且
-
协商结果(哪个版本的依赖被激活,由谁提供)会被记录在共享作用域中。
-
-
获取模块工厂 (
get
) :- Host 的代理模块调用 Remote 容器的
get('./Button')
方法。 get
方法返回一个模块工厂获取函数 (Promise) 。这个 Promise 内部会负责加载 Button 模块对应的实际代码 chunk(可能是再次动态插入<script>
或Workspace
)。
- Host 的代理模块调用 Remote 容器的
-
执行模块工厂,获取模块:
- 当 Button 的代码 chunk 加载并执行完毕后,
get
方法返回的 Promise 会 resolve,提供 Button 模块的工厂函数。 - Host 的 Webpack 运行时执行这个工厂函数,传入必要的上下文(如
require
函数,指向共享作用域中的依赖),最终得到 Button 模块的导出对象 (exports)。 import('app_remote/Button')
的 Promise resolve,Host 拿到 Button 组件,可以进行渲染。
- 当 Button 的代码 chunk 加载并执行完毕后,
核心原理总结 : 模块联邦通过构建时生成清单和接口 、运行时动态加载远程入口 、依赖协商机制 以及代理模块,实现了跨应用、运行时的模块共享和依赖管理。
五、 模块联邦的优点
- 运行时集成: 无需重新构建整个应用即可使用其他应用的最新代码,部署更加灵活。
- 独立部署与开发: 各个微应用(Remote/Host)可以独立开发、测试、部署,降低了团队间的耦合。
- 更好的依赖管理 :
shared
机制可以有效减少重复加载公共库(如 React, Vue),优化性能,并提供版本冲突的解决方案。 - 技术栈灵活性 (理论上) : 由于是在运行时通过接口交互,理论上不同技术栈构建的应用(只要能打包成 JS 模块并符合 MF 接口)可以集成,但在实践中共享依赖等因素可能使同技术栈集成更容易。
- 增量构建与部署: 更新一个微应用通常只需要重新构建和部署该应用本身,Host 应用下次加载时会自动获取最新版本(如果配置允许)。
- 代码复用更便捷: 相比发布 npm 包,共享代码更直接、更新更快。
六、 模块联邦的缺点
- 配置复杂度 : 相较于单体应用,模块联邦的 Webpack 配置(尤其是
shared
部分)更复杂,需要仔细设计和理解。 - 运行时依赖 : Host 应用的运行依赖于 Remote 应用的可用性。如果某个 Remote 应用挂掉或网络不可达,依赖它的 Host 应用可能会出错(需要做好错误处理,如
React.Suspense
的 fallback)。 - 共享依赖版本管理 : 虽然
shared
提供了机制,但版本策略(singleton
,requiredVersion
)需要团队仔细规划和遵守,否则可能引发难以预料的问题。尤其是singleton
依赖的版本冲突需要特别注意。 - 调试复杂性: 跨应用调试问题(例如共享依赖版本冲突、Remote 加载失败)比单体应用更困难。
- 环境一致性: 需要保证 Host 和 Remote 的构建环境(如 Node 版本、Webpack 版本、相关 Loader/Plugin 版本)大致兼容,避免构建差异导致的问题。
- 对 Webpack 强依赖: 该方案与 Webpack 深度绑定,如果项目想切换到其他构建工具(如 Vite, esbuild),迁移成本较高(尽管社区也在探索其他工具的类似方案)。
- 循环依赖风险: 如果应用间存在复杂的相互依赖(A 依赖 B,B 又依赖 A),可能会导致加载问题或死锁,需要精心设计架构避免。
七、 详细代码示例
我们将创建两个简单的 React 应用:
app_host
(Host): 运行在http://localhost:3000
,消费app_remote
的组件。app_remote
(Remote): 运行在http://localhost:3001
,暴露一个Button
组件。
项目结构:
arduino
module-federation-demo/
├── app_host/
│ ├── public/
│ │ └── index.html
│ ├── src/
│ │ ├── App.js
│ │ └── index.js
│ ├── package.json
│ └── webpack.config.js
└── app_remote/
├── public/
│ └── index.html
├── src/
│ ├── Button.js
│ ├── App.js
│ └── index.js
├── package.json
└── webpack.config.js
1. 通用依赖安装:
在 module-federation-demo 目录下,分别为两个应用安装依赖:
1.初始化
Bash
# 进入 app_host 目录
cd app_host
npm init -y
npm install react react-dom
npm install webpack webpack-cli webpack-dev-server html-webpack-plugin css-loader style-loader babel-loader @babel/core @babel/preset-react --save-dev
# 进入 app_remote 目录
cd ../app_remote
npm init -y
npm install react react-dom
npm install webpack webpack-cli webpack-dev-server html-webpack-plugin css-loader style-loader babel-loader @babel/core @babel/preset-react --save-dev
cd ..
注意:为了演示共享依赖,确保两个应用安装的 react
和 react-dom
版本兼容(最好一致)。
2. app_remote
(Remote) 配置与代码:
-
app_remote/package.json
(部分):JSON{ "name": "app_remote", "version": "1.0.0", "scripts": { "start": "webpack serve --port 3001" // 注意端口号 }, "dependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" }, "devDependencies": { // ... (webpack, babel, loaders etc.) } }
-
app_remote/webpack.config.js
:JavaScriptconst HtmlWebpackPlugin = require('html-webpack-plugin'); const { ModuleFederationPlugin } = require('webpack').container; // 引入模块联邦插件 const path = require('path'); const deps = require('./package.json').dependencies; // 获取依赖 module.exports = { mode: 'development', devServer: { port: 3001, // Remote 应用的端口 // 如果遇到跨域问题,可能需要配置 headers headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization', }, }, entry: './src/index', output: { publicPath: 'auto', // 或者 'http://localhost:3001/' // clean: true // 生产环境建议开启 }, resolve: { extensions: ['.js', '.jsx'], }, module: { rules: [ { test: /.jsx?$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/preset-react'], }, }, ], }, plugins: [ // 重点:配置模块联邦插件 new ModuleFederationPlugin({ name: 'app_remote', // 当前 Remote 应用的唯一名称 filename: 'remoteEntry.js', // 暴露给外部的入口文件名 exposes: { // 定义要暴露的模块 // './Button': './src/Button.js' -> 外部通过 'app_remote/Button' 引用 './src/Button.js' 模块 './Button': './src/Button', // key 是外部引用的别名,value 是内部模块路径 }, shared: { // 定义共享依赖 ...deps, // 可以将所有依赖共享出去 (需要评估) react: { singleton: true, // 确保 React 是单例 requiredVersion: deps.react, // 指定需要的 React 版本 }, 'react-dom': { singleton: true, // 确保 react-dom 是单例 requiredVersion: deps['react-dom'], }, }, }), new HtmlWebpackPlugin({ template: './public/index.html', }), ], };
-
app_remote/public/index.html
:HTML<!DOCTYPE html> <html> <head><title>App Remote</title></head> <body> <div id="root"></div> </body> </html>
-
app_remote/src/Button.js
: (要暴露的组件)JavaScriptimport React from 'react'; const style = { padding: '10px 15px', fontSize: '16px', backgroundColor: 'orange', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer', }; const Button = ({ children, onClick }) => { console.log('[App Remote] Rendering Button'); // 添加日志方便观察 return ( <button style={style} onClick={onClick}> {children || 'Button from Remote'} </button> ); }; export default Button; // 必须使用 export default 或 named export
-
app_remote/src/App.js
: (Remote 应用自身也使用 Button)JavaScriptimport React from 'react'; import LocalButton from './Button'; // 在 Remote 内部直接引用 const App = () => ( <div style={{ border: '2px dashed orange', padding: '20px', margin: '10px' }}> <h2>App Remote (运行在 3001 端口)</h2> <p>这个应用暴露了一个 Button 组件。</p> <p>下面是 Remote 应用自己使用的 Button:</p> <LocalButton onClick={() => alert('Clicked Remote's own button!')}> Click Me (Remote Internal) </LocalButton> </div> ); export default App;
-
app_remote/src/index.js
:JavaScript// 这个文件必须存在,但通常我们只需要加载 remoteEntry.js // 为了能在 3001 端口直接访问看到效果,我们也渲染 Remote App import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render(<App />); // 关键:通常我们认为 import('./bootstrap') 这种方式更好 // 但在此简单示例中,直接在 index.js 中渲染也可以 // 重要的是 remoteEntry.js 被正确生成和提供
3. app_host
(Host) 配置与代码:
-
app_host/package.json
(部分):JSON{ "name": "app_host", "version": "1.0.0", "scripts": { "start": "webpack serve --port 3000" // 注意端口号 }, "dependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" }, "devDependencies": {
2 // ... (webpack, babel, loaders etc.)
-
app_host/webpack.config.js
:JavaScriptconst HtmlWebpackPlugin = require('html-webpack-plugin'); const { ModuleFederationPlugin } = require('webpack').container; const path = require('path'); const deps = require('./package.json').dependencies; module.exports = { mode: 'development', devServer: { port: 3000, // Host 应用的端口 }, entry: './src/index', output: { publicPath: 'auto', // 或者 'http://localhost:3000/' }, resolve: { extensions: ['.js', '.jsx'], }, module: { rules: [ { test: /.jsx?$/, loader: 'babel-loader', exclude: /node_modules/, options: { presets: ['@babel/preset-react'], }, }, ], }, plugins: [ // 重点:配置模块联邦插件 new ModuleFederationPlugin({ name: 'app_host', // 当前 Host 应用的唯一名称 // filename: 'remoteEntry.js', // Host 通常不需要 filename,因为它不暴露模块 remotes: { // 定义要引用的 Remote 应用 // 'app_remote' 是本地使用的别名 // 'app_remote@http://localhost:3001/remoteEntry.js' 指向 Remote 的入口 'app_remote': 'app_remote@http://localhost:3001/remoteEntry.js', }, shared: { // 定义共享依赖,必须与 Remote 的配置兼容 ...deps, react: { singleton: true, requiredVersion: deps.react, }, 'react-dom': { singleton: true, requiredVersion: deps['react-dom'], }, }, }), new HtmlWebpackPlugin({ template: './public/index.html', }), ], };
-
app_host/public/index.html
:HTML
xml<!DOCTYPE html> <html> <head><title>App Host</title></head> <body> <div id="root"></div> </body> </html>
-
app_host/src/App.js
: (Host 应用,消费 Remote 组件)JavaScriptimport React, { Suspense, lazy } from 'react'; // 动态导入 Remote 应用暴露的 Button 组件 // 'app_remote' 是在 webpack.config.js 的 remotes 中定义的别名 // '/Button' 是在 app_remote 的 exposes 中定义的 key (去掉 './') const RemoteButton = lazy(() => import('app_remote/Button')); const App = () => ( <div style={{ border: '2px dashed blue', padding: '20px', margin: '10px' }}> <h1>App Host (运行在 3000 端口)</h1> <p>这个应用消费来自 App Remote (3001) 的 Button 组件。</p> <hr/> <h2>下面是从 Remote 加载的 Button:</h2> {/* 使用 Suspense 包裹异步加载的组件 */} <Suspense fallback={<div>Loading Remote Button...</div>}> <RemoteButton onClick={() => alert('Clicked Remote Button from Host!')}> Click Me (From Remote) </RemoteButton> </Suspense> <hr/> <p>Host 也可以有自己的内容和组件。</p> <button onClick={() => alert('Clicked Host's own button!')}> Host's Own Button </button> </div> ); export default App;
-
app_host/src/index.js
:JavaScript// 重点:为了让模块联邦的动态导入正常工作 // 推荐使用 import('./bootstrap') 的方式来延迟执行应用代码 // 这确保了 Webpack 有机会先初始化共享作用域和加载 remoteEntry import('./bootstrap');
-
app_host/src/bootstrap.js
: (实际的应用启动逻辑)JavaScriptimport React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; console.log('[App Host] Bootstrapping...'); const root = ReactDOM.createRoot(document.getElementById('root')); root.render(<App />);
4. 运行示例:
-
打开两个终端。
-
在第一个终端,进入
app_remote
目录,运行npm start
。等待编译完成,应该能在http://localhost:3001
看到 Remote 应用。 -
在第二个终端,进入
app_host
目录,运行npm start
。等待编译完成,应用应该会自动打开http://localhost:3000
。 -
在
http://localhost:3000
(Host 应用) 页面:- 你会看到 Host 应用的标题和它自己的按钮。
- 你会看到 "Loading Remote Button..." 的提示 (Suspense fallback)。
- 很快,来自
app_remote
的橙色按钮会加载并显示出来。 - 点击橙色按钮,会触发在 Host 中定义的
alert
。 - 打开浏览器开发者工具的 Network 面板,你会看到 Host 应用加载了
http://localhost:3001/remoteEntry.js
,以及之后可能加载了 Button 组件对应的 chunk 文件 (如果它没有被内联或者和 Remote App 的主 chunk 分开)。 - 在 Console 面板,你应该能看到来自 Host (
[App Host] Bootstrapping...
) 和 Remote ([App Remote] Rendering Button
) 的日志。检查 React DevTools,你会发现页面中只有一个 React 实例(如果版本兼容且singleton: true
配置正确)。
这个示例展示了模块联邦的基本工作流程:一个应用暴露模块,另一个应用在运行时动态加载并使用它,同时共享了 React 依赖。代码量虽然不少,但核心配置在于两个 webpack.config.js
中的 ModuleFederationPlugin
部分。
八、 总结
模块联邦是 Webpack 5 带来的一个非常强大的特性,它为微前端架构提供了一种优雅、高效的原生解决方案。通过运行时动态加载和共享依赖,它极大地提升了大型应用和跨团队协作的灵活性和效率。虽然它带来了配置和调试上的新挑战,但其带来的架构优势(独立部署、技术栈解耦、优化依赖)使其成为现代大型前端项目值得考虑的重要技术。理解其核心原理和配置方式对于驾驭复杂的前端工程体系非常有价值。