查看原文
其他

【第2839期】模块联邦在微前端架构中的实践

Ashley 前端早读课 2023-01-28

前言

假期 Flag 没完成,只在初二看了一天,其他时间都被安排了。不过一天的时间也划到不少的高亮句,后续在早说分享给大家。2023 年,前端早读课继续每日陪你。今日前端早读课文章由 @Ashley 翻译分享,公号:奇舞精选授权。

正文从这开始~~

背景

我们有一个用 React 编写的管理面板,在这里我们跟踪和管理 Trendyol GO(Hızlı Market 和 Trendyol Yemek)中所有阶段的订单。单一仓库很好地满足了我们的期望,因为一开始我们是唯一的团队,员工数量不多。

单一的仓库并不总是坏的。如果它能满足你的期望,如果它不会给你带来负面影响,那么你可以使用各种方法。重要的是;找到以最有效的方式达到你的目标的方法。

大约 1.5 年后,当我们的队友人数变得足够多时,我们以领域驱动设计(DDD)的理念将我们的团队划分为多个小团队。在这一点上,我们必须设计微前端的结构,使每个团队可以独立开发应用。

我之前在几个不同的项目中有过微前端的经验。我也曾自己设计过一个微前端架构,但研究所有其他的替代方案并选择最能满足我们需求的方案是更合理的。我们探索了所有的替代方案,权衡了每个方案的利弊(我不会在这篇文章中谈论所有的替代方案,因为那是另一个话题。),在评估的最后,我们发现 Webpack 模块联邦可以很好地满足我们的需求。

为什么是 Webpack 模块联邦?

当我们研究了所有的替代方案后,出于以下原因,选择 Webpack 模块联邦更有意义。

  • 没有维护成本(如果你自己建立一个架构,会有维护成本)

  • 没有团队特定的学习成本(如果你自己建立一个架构,会有学习成本)

  • 向模块联邦过渡的成本很小

  • 不需要对每个项目进行重新架构

  • 所有的需求都在构建时得到满足

  • 在运行时不需要额外的工作

  • 分享依赖的成本低

  • 库 / 框架独立

  • 你不需要处理所有的压缩和缓存问题

  • 你不需要处理路由问题

  • Shell 和 Micro Apps 不是紧耦合的,而是松耦合的

怎么使用模块联邦

有以下三种形式:

1、域名

通过这种方式,你可以创建尽可能多的微型前端(应用程序),并通过 Shell App 管理完全独立的域。例如,想象一下,在 Shell App 中有一个菜单,当链接被点击时,它将在右边带出相关的应用程序。

2、微件

通过这种方式,你可以从任何应用程序中添加任何微件 / 组件(即一小段代码)到任何应用程序。你可以在产品应用中的用户应用中公开 UserDetail 组件。

3、混合型

你可以同时使用第一和第二种方式。

开始实践

首先创建一个 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 在模板部分使用它,这样我们可以确保所有的东西都加载到页面上。

如果你现在想,你可以在 User 应用程序中添加一个组件,并尝试在产品应用程序中使用这个组件:) 你可以使用 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');

在这个过程的最后,我们实现了一个稳定的应用程序。虽然在我们面前还有许多不同的改进,但从现在开始,每个团队可以开发自己的模块,而不必依赖其他团队。

关于本文
译者:@Ashley
译文:https://mp.weixin.qq.com/s/WXeUuUdgF_3djqBhh1siQA
作者:@Sercan Eraslan
原文:https://medium.com/@sercaneraslan/micro-frontend-architecture-with-webpack-module-federation-part-1-9827d436bd1e

关于【模块联邦】相关推荐,欢迎读者自荐投稿,前端早读课等你来。+v:zhgb_f2er

【第2774期】基于 Module Federation 的模块化跨栈方案探索

【第2636期】从"微前端"到“微模块”

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存