各位观众老爷们,晚上好!我是你们的老朋友,今天咱们来聊聊微前端这个话题,更具体地说,是用Webpack Module Federation来搞定微前端架构下的模块共享和版本兼容问题。保证让各位听完之后,感觉打开了新世界的大门,以后再也不怕微前端带来的各种奇葩问题了。
啥是微前端?——别告诉我你还不知道!
先简单过一下微前端的概念。想象一下,你正在做一个超大型的网站,功能多到爆炸,一个人根本搞不定。传统的做法是,整个团队一起维护一个巨大的代码库,然后每天都在merge代码的时候互相伤害。
微前端就是把这个庞然大物拆分成若干个小的、自治的前端应用,每个应用都可以独立开发、独立部署、独立运行。就像一个航母战斗群,每艘船各司其职,但又能协同作战。
Webpack Module Federation:微前端的瑞士军刀
Module Federation 是 Webpack 5 引入的一个强大的功能,它可以让不同的 Webpack 构建的应用之间共享代码,而不需要将这些代码打包到同一个 bundle 中。 简单来说,它可以让一个应用“暴露”自己的部分模块,让其他应用“消费”这些模块。
它就像一个模块共享平台,各个微前端应用可以把自己需要的模块拿过来用,而不需要重复造轮子。
Module Federation 的基本概念
在深入代码之前,我们需要先了解几个关键概念:
- Host (宿主): 宿主应用,它会加载和运行其他应用提供的远程模块。
- Remote (远程): 提供可共享模块的应用。
- Shared Modules (共享模块): 被 Host 和 Remote 共享的模块,例如 React, Vue, 或者一些工具库。
实战演练:搭建一个简单的微前端架构
为了更好地理解 Module Federation,我们来搭建一个简单的微前端架构,包含一个 Host 应用和两个 Remote 应用。
项目结构
micro-frontends/
├── host/ # 宿主应用
├── remote1/ # 远程应用 1
└── remote2/ # 远程应用 2
1. Host 应用 (host/)
- webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');
module.exports = {
mode: 'development',
devServer: {
port: 3000,
},
entry: './src/index.js',
output: {
publicPath: 'http://localhost:3000/', // 重点:需要设置 publicPath
},
module: {
rules: [
{
test: /.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react'],
},
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'host', // 必须唯一
remotes: {
remote1: 'remote1@http://localhost:3001/remoteEntry.js', // 远程应用 1
remote2: 'remote2@http://localhost:3002/remoteEntry.js', // 远程应用 2
},
shared: {
react: { singleton: true, requiredVersion: '17.0.2' },
'react-dom': { singleton: true, requiredVersion: '17.0.2' },
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
resolve: {
extensions: ['.js', '.jsx'],
},
};
- src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
- src/App.js
import React, { Suspense } from 'react';
const RemoteComponent1 = React.lazy(() => import('remote1/Component'));
const RemoteComponent2 = React.lazy(() => import('remote2/Component'));
const App = () => (
<div>
<h1>Host Application</h1>
<Suspense fallback={<div>Loading Remote Component 1...</div>}>
<RemoteComponent1 />
</Suspense>
<Suspense fallback={<div>Loading Remote Component 2...</div>}>
<RemoteComponent2 />
</Suspense>
</div>
);
export default App;
- public/index.html
<!DOCTYPE html>
<html>
<head>
<title>Host Application</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
2. Remote 应用 1 (remote1/)
- webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');
module.exports = {
mode: 'development',
devServer: {
port: 3001,
},
entry: './src/index.js',
output: {
publicPath: 'http://localhost:3001/', // 重点:需要设置 publicPath
},
module: {
rules: [
{
test: /.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react'],
},
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'remote1', // 必须唯一
filename: 'remoteEntry.js',
exposes: {
'./Component': './src/Component', // 暴露的模块
},
shared: {
react: { singleton: true, requiredVersion: '17.0.2' },
'react-dom': { singleton: true, requiredVersion: '17.0.2' },
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
resolve: {
extensions: ['.js', '.jsx'],
},
};
- src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Component from './Component';
ReactDOM.render(<Component />, document.getElementById('root'));
- src/Component.js
import React from 'react';
const Component = () => (
<div>
<h2>Remote Component 1</h2>
<p>This component is from remote1.</p>
</div>
);
export default Component;
- public/index.html
<!DOCTYPE html>
<html>
<head>
<title>Remote Application 1</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
3. Remote 应用 2 (remote2/)
- webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');
module.exports = {
mode: 'development',
devServer: {
port: 3002,
},
entry: './src/index.js',
output: {
publicPath: 'http://localhost:3002/', // 重点:需要设置 publicPath
},
module: {
rules: [
{
test: /.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react'],
},
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: 'remote2', // 必须唯一
filename: 'remoteEntry.js',
exposes: {
'./Component': './src/Component', // 暴露的模块
},
shared: {
react: { singleton: true, requiredVersion: '17.0.2' },
'react-dom': { singleton: true, requiredVersion: '17.0.2' },
},
}),
new HtmlWebpackPlugin({
template: './public/index.html',
}),
],
resolve: {
extensions: ['.js', '.jsx'],
},
};
- src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import Component from './Component';
ReactDOM.render(<Component />, document.getElementById('root'));
- src/Component.js
import React from 'react';
const Component = () => (
<div>
<h2>Remote Component 2</h2>
<p>This component is from remote2.</p>
</div>
);
export default Component;
- public/index.html
<!DOCTYPE html>
<html>
<head>
<title>Remote Application 2</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
运行项目
- 分别进入
host
,remote1
,remote2
目录,运行npm install
或yarn install
安装依赖。 - 分别启动三个应用:
cd host && npm start
(访问http://localhost:3000
)cd remote1 && npm start
(访问http://localhost:3001
)cd remote2 && npm start
(访问http://localhost:3002
)
如果一切顺利,你将在 http://localhost:3000
看到 Host 应用,其中包含了 Remote 应用 1 和 Remote 应用 2 的组件。
模块共享:shared
配置
shared
配置是 Module Federation 的核心之一,它允许我们指定哪些模块应该在 Host 和 Remote 之间共享。
在上面的例子中,我们共享了 react
和 react-dom
。这样做的好处是:
- 避免重复加载: 如果 Host 和 Remote 都打包了
react
,那么浏览器就需要加载两份react
代码,浪费带宽和内存。 - 保持状态一致: 如果 Host 和 Remote 使用不同的
react
版本,可能会导致状态管理出现问题。
shared
配置的详细选项:
singleton
: 如果设置为true
,则只允许加载一个版本的共享模块。这对于像react
这样的库非常重要,因为多个版本的react
可能会导致冲突。requiredVersion
: 指定共享模块的最低版本要求。如果 Host 或 Remote 提供的版本低于这个要求,Module Federation 会抛出一个错误。strictVersion
: 如果设置为true
,则必须使用完全相同的版本。否则,Module Federation 会抛出一个错误。eager
: 默认情况下,共享模块是按需加载的。如果设置为true
,则会立即加载共享模块。import
: 允许指定共享模块的实际导入路径。
版本兼容:requiredVersion
的威力
版本兼容性是微前端架构中一个非常重要的问题。如果不同的微前端应用使用了不兼容的模块版本,可能会导致各种奇怪的问题。
Module Federation 的 requiredVersion
配置可以帮助我们解决这个问题。它可以让我们指定共享模块的最低版本要求。
例如,如果 Remote 应用 1 使用了 [email protected]
,而 Host 应用使用了 [email protected]
,那么 Host 应用在加载 Remote 应用 1 的模块时,会抛出一个错误,提示版本不兼容。
更灵活的版本控制策略
除了 requiredVersion
,Module Federation 还提供了一些更灵活的版本控制策略,例如:
- 版本范围 (Version Ranges): 可以使用版本范围来指定允许的版本范围。例如,
react: { requiredVersion: '^16.0.0' }
表示允许使用[email protected]
或更高版本。 - 语义化版本控制 (Semantic Versioning): Module Federation 支持语义化版本控制,可以根据语义化版本规则来选择合适的版本。
- 自定义版本解析器 (Custom Version Resolver): 可以自定义版本解析器,根据自己的需求来选择合适的版本。
高级技巧:动态加载远程模块
上面的例子中,我们是在 Host 应用的 webpack.config.js
文件中静态地指定了 Remote 应用的地址。这种方式的缺点是,如果 Remote 应用的地址发生变化,我们就需要修改 Host 应用的 webpack.config.js
文件,并重新构建 Host 应用。
为了解决这个问题,我们可以使用动态加载远程模块的方式。
// 动态加载远程模块
const loadRemoteModule = (remoteUrl, scope, module) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = remoteUrl;
script.type = 'text/javascript';
script.async = true;
script.onload = () => {
const proxy = {
get: (request) => window[scope].get(request),
init: (arg) => {
try {
return window[scope].init(arg);
} catch (e) {
console.log('remote container already initialized');
}
},
};
resolve(proxy.get(module).then((factory) => factory()));
};
script.onerror = (error) => {
console.error('Failed to load remote module:', error);
reject(error);
};
document.head.appendChild(script);
});
};
// 在组件中使用
import React, { useState, useEffect, Suspense } from 'react';
const DynamicRemoteComponent = ({ remoteUrl, scope, module }) => {
const [Component, setComponent] = useState(null);
useEffect(() => {
loadRemoteModule(remoteUrl, scope, module)
.then((RemoteComponent) => {
setComponent(() => RemoteComponent);
})
.catch((error) => {
console.error('Failed to load remote component:', error);
});
}, [remoteUrl, scope, module]);
if (!Component) {
return <div>Loading...</div>;
}
return <Component />;
};
// 使用 DynamicRemoteComponent
const App = () => (
<div>
<h1>Host Application</h1>
<Suspense fallback={<div>Loading Remote Component...</div>}>
<DynamicRemoteComponent remoteUrl="http://localhost:3001/remoteEntry.js" scope="remote1" module="./Component" />
</Suspense>
</div>
);
export default App;
使用动态加载远程模块的方式,我们可以将 Remote 应用的地址配置在 Host 应用的运行时环境中,而不需要修改 webpack.config.js
文件。
Module Federation 的优缺点
优点:
- 代码共享: 避免重复造轮子,减少代码冗余。
- 独立部署: 每个微前端应用都可以独立部署,互不影响。
- 技术栈无关: 可以使用不同的技术栈来开发不同的微前端应用。
- 增量升级: 可以逐步升级微前端应用,而不需要一次性升级整个系统。
缺点:
- 配置复杂: Module Federation 的配置比较复杂,需要一定的学习成本。
- 运行时依赖: 需要在运行时加载远程模块,可能会影响性能。
- 版本管理: 需要仔细管理共享模块的版本,避免版本冲突。
- 类型定义共享: 需要额外的方式共享typescript 类型定义, 比如使用 npm link 或者 publish 到 npm.
Module Federation 的适用场景
Module Federation 适用于以下场景:
- 大型的、复杂的 Web 应用。
- 需要独立部署和独立维护的多个前端应用。
- 需要共享代码的多个前端应用。
- 需要使用不同技术栈的前端应用。
总结
Module Federation 是一个强大的工具,可以帮助我们构建灵活、可扩展的微前端架构。但是,它也需要一定的学习成本和配置成本。在选择使用 Module Federation 之前,我们需要仔细评估其优缺点,并根据自己的实际情况做出决定。
希望今天的讲座能够帮助大家更好地理解 Module Federation,并在实践中灵活运用它。如果大家还有什么问题,欢迎随时提问。
感谢大家的观看! 下次再见!