模块联邦在微前端架构中的实践
译自 https://medium.com/@sercaneraslan/micro-frontend-architecture-with-webpack-module-federation-part-1-9827d436bd1e
原文作者Sercan Eraslan
背景
我们有一个用React编写的管理面板,在这里我们跟踪和管理Trendyol GO(Hızlı Market和Trendyol Yemek)中所有阶段的订单。单一仓库很好地满足了我们的期望,因为一开始我们是唯一的团队,员工数量不多。
单一的仓库并不总是坏的。如果它能满足你的期望,如果它不会给你带来负面影响,那么你可以使用各种方法。重要的是;找到以最有效的方式达到你的目标的方法。
大约1.5年后,当我们的队友人数变得足够多时,我们以领域驱动设计(DDD)的理念将我们的团队划分为多个小团队。在这一点上,我们必须设计微前端的结构,使每个团队可以独立开发应用。
我之前在几个不同的项目中有过微前端的经验。我也曾自己设计过一个微前端架构,但研究所有其他的替代方案并选择最能满足我们需求的方案是更合理的。我们探索了所有的替代方案,权衡了每个方案的利弊(我不会在这篇文章中谈论所有的替代方案,因为那是另一个话题。),在评估的最后,我们发现Webpack 模块联邦可以很好地满足我们的需求。
为什么是Webpack模块联邦?
当我们研究了所有的替代方案后,出于以下原因,选择Webpack模块联邦更有意义。
没有维护成本(如果你自己建立一个架构,会有维护成本) 没有团队特定的学习成本(如果你自己建立一个架构,会有学习成本) 向模块联邦过渡的成本很小 不需要对每个项目进行重新架构 所有的需求都在构建时得到满足 在运行时不需要额外的工作 分享依赖的成本低 库/框架独立 你不需要处理所有的压缩和缓存问题 你不需要处理路由问题 Shell和Micro Apps不是紧耦合的,而是松耦合的
怎么使用模块联邦
有以下三种形式:
域名
通过这种方式,你可以创建尽可能多的微型前端(应用程序),并通过Shell App管理完全独立的域。例如,想象一下,在Shell App中有一个菜单,当链接被点击时,它将在右边带出相关的应用程序。
2.微件
通过这种方式,你可以从任何应用程序中添加任何微件/组件(即一小段代码)到任何应用程序。你可以在产品应用中的用户应用中公开UserDetail组件。
混合型
你可以同时使用第一和第二种方式。
开始实践
首先创建一个app 命名为shell,并且以相同的方式创建应用product & user
npx create-react-app shellcd shellyarn add webpack webpack-cli webpack-server html-webpack-plugin css-loader style-loader babel-loader webpack-dev-server
第一步:初始化项目
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';ReactDOM.render(
<App />,
document.getElementById('root')
);
第二步:配置webpack
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const deps = require('./package.json').dependencies;
module.exports = {
mode: 'development',
devServer: {
port: 3001,
},
module: {
rules: [
{
test: /.js?$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-env',
'@babel/preset-react',
],
},
},
{
test: /.css$/i,
use: ["style-loader", "css-loader"],
},
],
},
plugins: [
new ModuleFederationPlugin(
{
name: 'SHELL',
filename: 'remoteEntry.js',
shared: [
{
...deps,
react: { requiredVersion: deps.react, singleton: true },
'react-dom': {
requiredVersion: deps['react-dom'],
singleton: true,
},
},
],
}
),
new HtmlWebpackPlugin({
template:
'./public/index.html',
}),
],
};
其中关于模块联邦的配置项,解释如下:
name: 我们用它来确定应用程序的名称。我们将通过这个名称与其他应用程序进行交流。 filename: 我们用它作为一个入口文件。在这个例子中,其他应用程序将能够通过输入 "SHELL@http://localhost:3001/remoteEntry.js "访问SHELL应用程序。 shared(共享)。我们用它来指定这个应用程序将与其他应用程序共享哪些依赖。这里需要注意的是 "singleton: true"
。如果你不写"singleton: true"
,每个应用程序将在一个单独的React实例上运行
把同样的文件复制到User
和 Product
项目,但不要忘记增加端口和改变名称字段。
第三步:设计
// app.js
import React from 'react';
import './App.css';const App = () => (
<div className="shell-app">
<h2>Hi from Shell App</h2>
</div>
);export default App;
// app.css
.shell-app {
margin: 5px;
text-align: center;
background: #FFF3E0;
border: 1px dashed #FFB74D;
border-radius: 5px;
color: #FFB74D;
}
上面两个文件都放到src下,Product 以及 User 项目也做同样的更改,只是将src命名为shell。 三个项目依次运行以下的命令
yarn webpack server
现在,我们所有的应用程序都为Micro Frontends架构做好了准备,并且可以相互独立运行。🎉
第四步:整合
是时候提到模块联邦的两个伟大的功能了 :)
expose:它允许你从任何应用程序到另一个应用程序共享一个组件、一个页面或整个应用程序。你所暴露的一切都被创建为一个单独的构建,从而创造了一个自然的tree shaking
。每个构建都以文件的MD5哈希值命名,所以你不必担心缓存的问题。
remote: 它决定了你将从哪些应用程序接收一个组件、一个页面或应用程序本身。 每个应用程序都可以同时暴露和定义一个远程,并多次进行。
现在让我们把Product
应用暴露给Shell
应用,让我们做第一个微前端连接。
让我们打开产品 repo 中的 webpack.config.js
文件,并改变其中传给ModuleFederationPlugin
中的对象,如下。exposes对象中的值决定了它在repo中共享哪个组件,而对象中的key决定了其他应用程序可以访问这个组件的名称。
new ModuleFederationPlugin(
{
name: 'PRODUCT',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App',
},
shared: [
{
...deps,
react: { requiredVersion: deps.react, singleton: true },
'react-dom': {
requiredVersion: deps['react-dom'],
singleton: true,
},
},
],
}
),
让我们打开shell repo
中的webpack.config.js
文件,将其中传给ModuleFederationPlugin
方法的对象做如下修改。remotes对象中的值决定了如何访问Product
(@符号前的名称必须与Product
库中Webpack config
中的名称相同),对象中的key允许我们只用名称来访问Product
。
new ModuleFederationPlugin(
{
name: 'SHELL',
filename: 'remoteEntry.js',
remotes: {
PRODUCT: 'PRODUCT@http://localhost:3002/remoteEntry.js'
},
shared: [
{
...deps,
react: { requiredVersion: deps.react, singleton: true },
'react-dom': {
requiredVersion: deps['react-dom'],
singleton: true,
},
},
],
}
),
我们已经将2个应用程序连接在一起。现在让我们看看如何在Shell
应用程序中使用Product
。让我们在shell
资源库中打开App.js并做如下修改。
import React from 'react';
import './App.css';
const ProductApp = React.lazy(
() => import('PRODUCT/App')
);
const App = () => (
<div className="App">
<h2>Hi from Shell App</h2><React.Suspense fallback='Loading...'>
<ProductApp />
</React.Suspense>
</div>
);
export default App;
我们已经定义了名为 "App "的组件,该组件通过React的lazy方法从Product
中暴露出来,到我们名为ProductApp
的变量。我们需要对我们将从不同的微前端获得的组件使用lazy函数,我们需要使用Suspense在模板部分使用它,这样我们可以确保所有的东西都加载到页面上。
expose
和remotes
共享你想要的组件。
创建模块联邦的Zack Jackson有一个Github repo,叫做module-federation-examples。在这个Repo中,有许多的示例,如React、Vue、Angular、服务器端渲染、共享路由,如果你想的话,你可以查看它们。
需要解决的问题
路由
其中一个重要的问题是,微前端管理自己的路由,以保持它们与Shell的松散耦合关系。
在我们建立的项目中,我们倾向于在Shell的路由安装微前端模块。当到达/mf-a路径时,Shell会懒加载Micro-Frontend-A
应用程序,当用户到达/mf-b路径时,它以同样的方式加载Micro-Frontend-B
。
// shell/src/Shell.jsimport ...const MicroFrontendA = lazy(() => import('MicroFrontendA/MicroFrontendARoutes'));
const MicroFrontendB = lazy(() => import('MicroFrontendB/MicroFrontendBRoutes'));
const Shell = () => {
return (
<Router>
<Menu />
<main>
<Suspense fallback={<div>Yükleniyor...</div>}>
<Switch>
<Route exact path="/">
<Redirect to="/mf-a" />
</Route>
<Route path="/mf-a">
<MicroFrontendA />
</Route>
<Route path="/mf-b">
<MicroFrontendB />
</Route>
</Switch>
</Suspense>
</main>
</Router>
);
};
export default Shell;
之后,控制权转移到微前端。微前端-A处理自己的子模块,并对其进行路由设置。与上面的例子有关,当导航到/mf-a的路径时,PageA被加载,当路径是/mf-a/page-b时,PageB被加载。
// micro-frontend-a/src/pages/MicroFrontendARoutes.jsimport React, { lazy } from 'react';
import { Switch, Route, useRouteMatch } from 'react-router-dom';
import withPermissions from 'Shell/hoc/withPermissions';
const PageA = lazy(() => import('pages/pageA/PageA'));
const PageB = lazy(() => import('pages/pageB/PageB'));
const MicroFrontendARoutes = () => {
const { path } = useRouteMatch();return (
<Switch>
<Route
exact
path={path}
render={() => withPermissions(['VIEW_PAGE_A'])(PageA)}
/>
<Route
exact
path={`${path}/page-b`}
render={() => withPermissions(['VIEW_PAGE_B'])(PageB)}
/>
</Switch>
);
};
export default MicroFrontendARoutes;
共享状态以及hooks
实际上,在模块联邦中共享这些是非常容易的;但它目前有一个有趣的解决方案。
如果你看看我为Shell的webpack.config.js
所举的例子,在共享方面有一个微妙的接触。一个消耗公共状态的hooks也在库下共享。由于应用程序总是在Shell下渲染,所有的context都以正确的顺序加载,当我们像例子中那样共享 hooks时,我们可以在微前端中使用通用context而不会有任何错误。
// shell/webpack.config.js
const { dependencies: deps } = require('./package.json');
const moduleFederationOptions = {
...exposes: {
... './hooks/useToastr': './src/hooks/useToastr',
},
shared: [
{
...
},
'./src/hooks/useToastr', // Here!
],
};
热重载
举例说明我们遇到的问题。如果我们通过Shell访问应用程序,我们在Micro-Frontend-A中做的一个改变不会触发热重载。因此,我们在开发的时候会慢一点,我们必须在每次改变后刷新。
为了解决这个问题,模块联邦团队开发了@module-federation/fmr
包。当它作为插件被包含在Webpack配置中时,你的模块联邦结构的任何变化都会自动运行Live Reload。
部署
在使应用程序上线的过程中,我们遇到了两个主要问题。
在运行时动态地设置publicPath
。
当用模块联邦创建一个复杂的应用程序时,会出现这类问题。Shell将从哪里获得微前端的共享文件?属于Shell的文件将来自微前端的哪些路径?许多文件路径需要被设置。我们通过正确指定publicPath选项来控制这些。
在Trendyol GO中,我们将应用程序作为Docker镜像创建一次,然后我们使它们在不同的环境中通过环境变量接受不同的设置。如果publicPath是在构建时设置的,我们就必须解决大的配置文件问题,这不是一个优化的解决方案。
我们稍微修改了Zack Jackson在这篇文章中提到的方法,使其非常简单地在运行时分配动态publicPath。
在我们使用的方法中,有一个叫做setPublicPath.js的文件。其内容的格式如下。
// shell/src/setPublicPath.js
__webpack_public_path__ = `${new URL(document.currentScript.src).origin}/`;
我们通过在构建时操作Webpack设置中的entry
// shell/webpack.config.js
entry: {
Shell: './src/setPublicPath',
main: './src/index',
},
在运行时,动态设置模块联邦设置中分配的远程URL。我们用External Remotes Plugin
来实现
// shell/webpack.config.js
const moduleFederationOptions = {
...remotes: {
MicroFrontendA: 'MicroFrontendA@[window.MF_A_URL]/remoteEntry.js',
MicroFrontendB: 'MicroFrontendB@[window.MF_B_URL]/remoteEntry.js',
}, ...
};
如何在运行时设置window.MF_A_URL and window.MF_B_URL
// shell/src/index.js
import config from 'config'; // dynamic vars. from an .env file e.g.
window.MF_A_URL = config.MF_A_URL;
window.MF_B_URL = config.MF_B_URL;
import('./bootstrap');
在这个过程的最后,我们实现了一个稳定的应用程序。虽然在我们面前还有许多不同的改进,但从现在开始,每个团队可以开发自己的模块,而不必依赖其他团队。
- END -
关于奇舞团
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。