React 模塊聯(lián)邦多模塊項目實戰(zhàn)詳解
前提:
老項目是一個多模塊的前端項目,有一個框架層級的前端服務(wù)A,用來渲染界面的大概樣子,其余各個功能模塊前端定義自己的路由信息與組件。本地開發(fā)時,通過依賴框架服務(wù)A來啟動項目,在線上部署時會有一個總前端的應(yīng)用,在整合的時候,通過在獲取路由信息時批量加載各個功能模塊的路由信息,來達到服務(wù)整合的效果。
// config.js // 這個配置文件 定義在收集路由時需要從哪些依賴?yán)锸占? modules: [ 'front-service-B', 'front-service-C', 'front-service-D', ... ],
痛點
- 本地聯(lián)調(diào)多個前端服務(wù)時比較麻煩,需要下載對應(yīng)服務(wù)npm資源,并在
config.js
中配置上需要整合的服務(wù)名稱,并且在debugger
時,看到的source
樹中是經(jīng)過webpack
編譯后的代碼。 - 如果本地聯(lián)調(diào)多個服務(wù)時,需要修改依賴服務(wù)的代碼,要么直接在
node_modules
中修改,要么將拉取對應(yīng)服務(wù)代碼,在源碼上修改好了之后通過編譯將打出來的包替換node_modules
中的源文件,或者使用yalc
來link本地啟動的服務(wù),不管是哪種方法都比直接修改動態(tài)刷新都要麻煩的多。 - 部署線上開發(fā)環(huán)境時,需要將修改好的本地服務(wù)提交到代碼庫,跑完一次
CI
編譯后,還需要再跑一次總前端應(yīng)用的CICD
才能部署到線上,這樣發(fā)布測試的時間成本大大增加。
需求
實現(xiàn)真正意義上的微前端,各服務(wù)的資源可相互引用,并且在對應(yīng)模塊編譯更新后,線上可直接看到效果,不需要重新CICD
一次總前端,在本地開發(fā)時,引入不同前端服務(wù),可通過線上版本或者本地版本之間的自由切換。自然而然,我們想到Module Federation
——模塊聯(lián)邦。
思路
首先需要明確一下思路,既然各個服務(wù)是通過路由來驅(qū)動的,那我們需要做的,簡單來說就是將各個服務(wù)的路由文件通過模塊聯(lián)邦導(dǎo)出,在框架服務(wù)A的路由收集里,通過監(jiān)測路由pathname
的變化,來動態(tài)引入對應(yīng)服務(wù)的路由信息來達到微前端的效果。
實戰(zhàn)
1. 修改webpack增加ModuleFederationPlugin
import webpack, { container } from 'webpack'; const { ModuleFederationPlugin,} = container; new ModuleFederationPlugin({ filename: 'remoteEntry.js', name: getPackageRouteName(), library: { type: 'var', name: getPackageRouteName(), }, exposes: getExpose(), shared: getShared(), // remotes: getRemotes(envStr, modules), }),
- filename: 這是模塊聯(lián)邦編譯后生成的入口文件名,增加
ModuleFederationPlugin
后會在打包出來的dist文件中多生成一個$filename
文件。 - name:一個模塊的唯一值,在這個例子中,用不同模塊
package.json
中設(shè)置的routeName
值來作為唯一值。
function getPackageRouteName() { const packagePath = path.join(cwd, 'package.json'); const packageData = fs.readFileSync(packagePath); const parsePackageData = JSON.parse(packageData.toString()); return parsePackageData.routeName; }
- library: 打包方式,此處與
name
值一致就行. - exposes: 這是重要的參數(shù)之一,設(shè)置了哪些模塊能夠?qū)С?。參?shù)為一個對象,可設(shè)置多個,在這里我們最重要的就是導(dǎo)出各個服務(wù)的路由文件,路徑在
$packageRepo/react/index.js
中,
function getExpose() { const packagePath = path.join(cwd, 'package.json'); const packageData = fs.readFileSync(packagePath); const parsePackageData = JSON.parse(packageData.toString()); let obj = {}; obj['./index'] = './react/index.js'; return { ...obj }; }
- shared: 模塊單例的配置項,由于各個模塊單獨編譯可運行,為保證依賴項單例(共享模塊),通過設(shè)置這個參數(shù)來配置。
// 這里的配置項按不同項目需求來編寫 主要目的是避免依賴生成多例導(dǎo)致數(shù)據(jù)不統(tǒng)一的問題 function getShared() { const obj = { ckeditor: { singleton: true, eager: true, }, react: { singleton: true, requiredVersion: '16.14.0', }, 'react-dom': { singleton: true, requiredVersion: '16.14.0', }, 'react-router-dom': { singleton: true, requiredVersion: '^5.1.2', }, 'react-router': { singleton: true, requiredVersion: '^5.1.2', }, axios: { singleton: true, requiredVersion: '^0.16.2', }, 'react-query': { singleton: true, requiredVersion: '^3.34.6', }, }; Object.keys(dep).forEach((item) => { obj[item] = { singleton: true, requiredVersion: dep[item], }; if (eagerList.includes(item)) { obj[item] = { ...obj[item], eager: true, }; } }); return obj; }
- remotes: 這是引入導(dǎo)出模塊的配置項,比如我們配置了一個
name
為A的exposes
模塊,則可以在這里配置
// ModuleFederationPlugin remotes: { A: 'A@http://localhost:3001/remoteEntry.js', }, // usage import CompA from 'A';
但是在我實際測試中,使用remotes
導(dǎo)入模塊,會報各種各樣奇奇怪怪的問題,不知道是我的版本問題還是哪里配置沒對,所以這里在導(dǎo)入模塊的地方,我選擇了官方文檔中的動態(tài)遠程容器
方法.
2.本地開發(fā)測試
本地要完成的需求是,單獨啟動服務(wù)A后,通過注入服務(wù)B的入口文件,達到路由整合里有兩個服務(wù)的路由信息。
在這里我們假設(shè)服務(wù)A的路由pathname
是pathA
,服務(wù)B的pathanme
是pathB
這個時候我們本地啟動兩個服務(wù),服務(wù)A在8080
端口,服務(wù)B在9090
端口,啟動后,如果你的ModuleFederationPlugin
配置正確,可以通過localhost:9090/remoteEntry.js
來查看是否生成了入口文件。
這個時候我們來到路由收集文件
import React, { Suspense, useEffect, useState } from 'react'; import { Route, useLocation } from 'react-router-dom'; import CacheRoute, { CacheSwitch } from 'react-router-cache-route'; import NoMacth from '@/components/c7n-error-pages/404'; import Skeleton from '@/components/skeleton'; const routes:[string, React.ComponentType][] = __ROUTES__ || []; const AutoRouter = () => { const [allRoutes, setAllRoutes] = useState(routes); const { pathname } = useLocation(); function loadComponent(scope, module, onError) { return async () => { // Initializes the share scope. This fills it with known provided modules from this build and all remotes await __webpack_init_sharing__('default'); const container = window[scope]; // or get the container somewhere else // Initialize the container, it may provide shared modules if (!container) { throw new Error('加載了錯誤的importManifest.js,請檢查服務(wù)版本'); } try { await container.init(__webpack_share_scopes__.default); const factory = await window[scope].get(module); const Module = factory(); return Module; } catch (e) { if (onError) { return onError(e); } throw e; } }; } const loadScrip = (url, callback) => { let script = document.createElement('script'); if (script.readyState) { // IE script.onreadystatechange = function () { if (script.readyState === 'loaded' || script.readyState === 'complete') { script.onreadystatechange = null; callback(); } } } else { // 其他瀏覽器 script.onload = function () { callback(); } } script.src = url; script.crossOrigin = 'anonymous'; document.head.appendChild(script); } const asyncGetRemoteEntry = async (path, remoteEntry) => new Promise((resolve) => { loadScrip(remoteEntry, () => { if (window[path]) { const lazyComponent = loadComponent(path, './index'); resolve([`/${path}`, React.lazy(lazyComponent)]) } else { resolve(); } }); }) const callbackWhenPathName = async (path) => { let arr = allRoutes; const remoteEntry = 'http://localhost:9090/remoteEntry'; const result = await asyncGetRemoteEntry(path, remoteEntry); if (result) { arr.push(result) setAllRoutes([].concat(arr)); } } useEffect(() => { callbackWhenPathName('pathB') }, []) return ( <Suspense fallback={<Skeleton />}> <CacheSwitch> {allRoutes.map(([path, component]) => <Route path={path} component={component} />)} <CacheRoute path="*" component={NoMacth} /> </CacheSwitch> </Suspense> ); } export default AutoRouter;
這里來解釋一下,callbackWhenPathName
方法引入了B服務(wù)的pathname
,目的是在加載完B服務(wù)的路由文件后設(shè)置到Route
信息上,通過異步script
的方法,向head
中增加一條src
為remoteEntry
地址的script
標(biāo)簽。
如果加載文件成功,會在window
變量下生成一個window.$name
的變量,這個name
值目前就是服務(wù)B的ModuleFederationPlugin
配置的name
值。通過window.$name.get('./index')
就可以拿到我們導(dǎo)出的路由信息了。
如果一切順利這時在切換不同服務(wù)路由時,應(yīng)該能成功加載路由信息了。
3.根據(jù)路由變化自動加載對應(yīng)的服務(wù)入口
上面我們是寫死了一個pathname
和remote
地址,接下來要做的是在路由變化時,自動去加載對應(yīng)的服務(wù)入口。 這里我們第一步需要將所有的前端服務(wù)共享到環(huán)境變量中。在.env
(環(huán)境變量的方法可以有很多種,目的是配置在window
變量中,可直接訪問)中配置如下:
remote_A=http://localhost:9090/remoteEntry.js remote_B=http://localhost:9091/remoteEntry.js remote_C=http://localhost:9092/remoteEntry.js remote_D=http://localhost:9093/remoteEntry.js remote_E=http://localhost:9094/remoteEntry.js ...
修改一下上面的路由收集方法:
import React, { Suspense, useEffect, useState } from 'react'; import { Route, useLocation } from 'react-router-dom'; import CacheRoute, { CacheSwitch } from 'react-router-cache-route'; import NoMacth from '@/components/c7n-error-pages/404'; import Skeleton from '@/components/skeleton'; // @ts-expect-error const routes:[string, React.ComponentType][] = __ROUTES__ || []; const AutoRouter = () => { const [allRoutes, setAllRoutes] = useState(routes); const { pathname } = useLocation(); function loadComponent(scope, module, onError) { return async () => { // Initializes the share scope. This fills it with known provided modules from this build and all remotes await __webpack_init_sharing__('default'); const container = window[scope]; // or get the container somewhere else // Initialize the container, it may provide shared modules if (!container) { throw new Error('加載了錯誤的importManifest.js,請檢查服務(wù)版本'); } try { await container.init(__webpack_share_scopes__.default); const factory = await window[scope].get(module); const Module = factory(); return Module; } catch (e) { if (onError) { return onError(e); } throw e; } }; } const loadScrip = (url, callback) => { let script = document.createElement('script'); if (script.readyState) { // IE script.onreadystatechange = function () { if (script.readyState === 'loaded' || script.readyState === 'complete') { script.onreadystatechange = null; callback(); } } } else { // 其他瀏覽器 script.onload = function () { callback(); } } script.src = url; script.crossOrigin = 'anonymous'; document.head.appendChild(script); } const asyncGetRemoteEntry = async (path, remoteEntry) => new Promise((resolve) => { loadScrip(remoteEntry, () => { if (window[path]) { const lazyComponent = loadComponent(path, './index'); resolve([`/${path}`, React.lazy(lazyComponent)]) } else { resolve(); } }); }) const callbackWhenPathName = async (path) => { let arr = allRoutes; const env: any = window._env_; const envList = Object.keys(env); if (window[path] && allRoutes.find(i => i[0].includes(path))) { return; } else { const remoteEntry = env[`remote_${path}`]; if (remoteEntry) { if (window[path]) { const lazyComponent = loadComponent(path, './index'); arr.push([`/${path}`, React.lazy(lazyComponent)]); setAllRoutes([].concat(arr)); } else { const result = await asyncGetRemoteEntry(path, remoteEntry); if (result) { arr.push(result) setAllRoutes([].concat(arr)); } } } } } useEffect(() => { const path = pathname.split('/')[1]; callbackWhenPathName(path) }, [pathname]) return ( <Suspense fallback={<Skeleton />}> <CacheSwitch> {allRoutes.map(([path, component]) => <Route path={path} component={component} />)} <CacheRoute path="*" component={NoMacth} /> </CacheSwitch> </Suspense> ); } export default AutoRouter;
唯一的變化就是在pathname
變化時,通過環(huán)境變量找到對應(yīng)的remoteEntry
的地址來加載。
4.線上部署
在各個分服務(wù)的CI中,我們需要增加上傳打包后文件的操作,這里我們選擇的是MINIO
服務(wù)器,將各個服務(wù)通過webpack
打包后的dist
文件(當(dāng)然dist
文件中也包含了remoteEntry.js
文件)上傳在MINIO
上,可直接通過url
訪問到文件內(nèi)容即可。
在以前的版本中,總前端需要依賴各個服務(wù)進行一個裝包,編譯部署的過程
// 總前端的package.json "dependencies": { "架構(gòu)-A": "x.x.x", "服務(wù)B": "x.x.x", "服務(wù)C": "x.x.x", "服務(wù)D": "x.x.x, ... },
但是現(xiàn)在我們的總前端只需要一個框架類的服務(wù)A,其余服務(wù)都只通過環(huán)境變量的方法來引入就行了。
// 總前端的.env文件 remote_B=$MINIO_URL/remoteB/$版本號/remoteEntry.js remote_C=$MINIO_URL/remoteC/$版本號/remoteEntry.js remote_D=$MINIO_URL/remoteD/$版本號/remoteEntry.js remote_E=$MINIO_URL/remoteE/$版本號/remoteEntry.js
5.問題記錄
- 在配置
ModuleFederationPlugin
的remotes
時,最好用JSON.stringify
包裹一下,不然會導(dǎo)致編譯之后生成的remote
地址為變量名而不是字符串。 - 如果出現(xiàn)
fn is not function
錯誤,多半是expose
導(dǎo)出寫的有問題,如果實在解決不了,建議使用官方推薦的loadComponent
方法。 webpack
的optimization
方法貌似與ModuleFederationPlugin
不兼容,建議去掉。- 如果出現(xiàn)shared模塊共享問題,可通過增加一個
bootStrap
方法。
import("./bootstrap.js")
import App from './App.jsx' import ReactDOM from 'react-dom'; import React from 'react'; ReactDOM.render(<App />, document.getElementById("app"));
以上就是React 模塊聯(lián)邦多模塊項目實戰(zhàn)詳解的詳細內(nèi)容,更多關(guān)于React 模塊聯(lián)邦多模塊的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
react 實現(xiàn)圖片正在加載中 加載完成 加載失敗三個階段的原理解析
這篇文章主要介紹了react 實現(xiàn)圖片正在加載中 加載完成 加載失敗三個階段的,通過使用loading的圖片來占位,具體原理解析及實現(xiàn)代碼跟隨小編一起通過本文學(xué)習(xí)吧2021-05-05ReactHook使用useState更新變量后,如何拿到變量更新后的值
這篇文章主要介紹了ReactHook使用useState更新變量后,如何拿到變量更新后的值問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2024-03-03concent漸進式重構(gòu)react應(yīng)用使用詳解
這篇文章主要為大家介紹了concent漸進式重構(gòu)react應(yīng)用的使用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2022-11-11React?Router?v6路由懶加載的2種方式小結(jié)
React?Router?v6?的路由懶加載有2種實現(xiàn)方式,1是使用react-router自帶的?route.lazy,2是使用React自帶的?React.lazy,下面我們就來看看它們的具體實現(xiàn)方法吧2024-04-04