譯自 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中有一個菜單,當連結被點選時,它将在右邊帶出相關的應用程式。
image.png
2.微件
通過這種方式,你可以從任何應用程式中添加任何微件/元件(即一小段代碼)到任何應用程式。你可以在産品應用中的使用者應用中公開UserDetail元件。
image.png
- 混合型
你可以同時使用第一和第二種方式。
開始實踐
首先建立一個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
image.png
現在,我們所有的應用程式都為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');
在這個過程的最後,我們實作了一個穩定的應用程式。雖然在我們面前還有許多不同的改進,但從現在開始,每個團隊可以開發自己的子產品,而不必依賴其他團隊。