React 模塊聯(lián)邦多模塊項(xiàng)目實(shí)戰(zhàn)詳解
前提:
老項(xiàng)目是一個(gè)多模塊的前端項(xiàng)目,有一個(gè)框架層級(jí)的前端服務(wù)A,用來(lái)渲染界面的大概樣子,其余各個(gè)功能模塊前端定義自己的路由信息與組件。本地開(kāi)發(fā)時(shí),通過(guò)依賴(lài)框架服務(wù)A來(lái)啟動(dòng)項(xiàng)目,在線上部署時(shí)會(huì)有一個(gè)總前端的應(yīng)用,在整合的時(shí)候,通過(guò)在獲取路由信息時(shí)批量加載各個(gè)功能模塊的路由信息,來(lái)達(dá)到服務(wù)整合的效果。
// config.js
// 這個(gè)配置文件 定義在收集路由時(shí)需要從哪些依賴(lài)?yán)锸占?
modules: [
'front-service-B',
'front-service-C',
'front-service-D',
...
],
痛點(diǎn)
- 本地聯(lián)調(diào)多個(gè)前端服務(wù)時(shí)比較麻煩,需要下載對(duì)應(yīng)服務(wù)npm資源,并在
config.js中配置上需要整合的服務(wù)名稱(chēng),并且在debugger時(shí),看到的source樹(shù)中是經(jīng)過(guò)webpack編譯后的代碼。 - 如果本地聯(lián)調(diào)多個(gè)服務(wù)時(shí),需要修改依賴(lài)服務(wù)的代碼,要么直接在
node_modules中修改,要么將拉取對(duì)應(yīng)服務(wù)代碼,在源碼上修改好了之后通過(guò)編譯將打出來(lái)的包替換node_modules中的源文件,或者使用yalc來(lái)link本地啟動(dòng)的服務(wù),不管是哪種方法都比直接修改動(dòng)態(tài)刷新都要麻煩的多。 - 部署線上開(kāi)發(fā)環(huán)境時(shí),需要將修改好的本地服務(wù)提交到代碼庫(kù),跑完一次
CI編譯后,還需要再跑一次總前端應(yīng)用的CICD才能部署到線上,這樣發(fā)布測(cè)試的時(shí)間成本大大增加。
需求
實(shí)現(xiàn)真正意義上的微前端,各服務(wù)的資源可相互引用,并且在對(duì)應(yīng)模塊編譯更新后,線上可直接看到效果,不需要重新CICD一次總前端,在本地開(kāi)發(fā)時(shí),引入不同前端服務(wù),可通過(guò)線上版本或者本地版本之間的自由切換。自然而然,我們想到Module Federation——模塊聯(lián)邦。
思路
首先需要明確一下思路,既然各個(gè)服務(wù)是通過(guò)路由來(lái)驅(qū)動(dòng)的,那我們需要做的,簡(jiǎn)單來(lái)說(shuō)就是將各個(gè)服務(wù)的路由文件通過(guò)模塊聯(lián)邦導(dǎo)出,在框架服務(wù)A的路由收集里,通過(guò)監(jiān)測(cè)路由pathname的變化,來(lái)動(dòng)態(tài)引入對(duì)應(yīng)服務(wù)的路由信息來(lái)達(dá)到微前端的效果。
實(shí)戰(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后會(huì)在打包出來(lái)的dist文件中多生成一個(gè)$filename文件。 - name:一個(gè)模塊的唯一值,在這個(gè)例子中,用不同模塊
package.json中設(shè)置的routeName值來(lái)作為唯一值。
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ù)為一個(gè)對(duì)象,可設(shè)置多個(gè),在這里我們最重要的就是導(dǎo)出各個(gè)服務(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: 模塊單例的配置項(xiàng),由于各個(gè)模塊單獨(dú)編譯可運(yùn)行,為保證依賴(lài)項(xiàng)單例(共享模塊),通過(guò)設(shè)置這個(gè)參數(shù)來(lái)配置。
// 這里的配置項(xiàng)按不同項(xiàng)目需求來(lái)編寫(xiě) 主要目的是避免依賴(lài)生成多例導(dǎo)致數(shù)據(jù)不統(tǒng)一的問(wèn)題
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)出模塊的配置項(xiàng),比如我們配置了一個(gè)
name為A的exposes模塊,則可以在這里配置
// ModuleFederationPlugin
remotes: {
A: 'A@http://localhost:3001/remoteEntry.js',
},
// usage
import CompA from 'A';
但是在我實(shí)際測(cè)試中,使用remotes導(dǎo)入模塊,會(huì)報(bào)各種各樣奇奇怪怪的問(wèn)題,不知道是我的版本問(wèn)題還是哪里配置沒(méi)對(duì),所以這里在導(dǎo)入模塊的地方,我選擇了官方文檔中的動(dòng)態(tài)遠(yuǎn)程容器方法.
2.本地開(kāi)發(fā)測(cè)試
本地要完成的需求是,單獨(dú)啟動(dòng)服務(wù)A后,通過(guò)注入服務(wù)B的入口文件,達(dá)到路由整合里有兩個(gè)服務(wù)的路由信息。
在這里我們假設(shè)服務(wù)A的路由pathname是pathA,服務(wù)B的pathanme是pathB
這個(gè)時(shí)候我們本地啟動(dòng)兩個(gè)服務(wù),服務(wù)A在8080端口,服務(wù)B在9090端口,啟動(dòng)后,如果你的ModuleFederationPlugin配置正確,可以通過(guò)localhost:9090/remoteEntry.js來(lái)查看是否生成了入口文件。

這個(gè)時(shí)候我們來(lái)到路由收集文件
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('加載了錯(cuò)誤的importManifest.js,請(qǐng)檢查服務(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;
這里來(lái)解釋一下,callbackWhenPathName方法引入了B服務(wù)的pathname,目的是在加載完B服務(wù)的路由文件后設(shè)置到Route信息上,通過(guò)異步script的方法,向head中增加一條src為remoteEntry地址的script標(biāo)簽。
如果加載文件成功,會(huì)在window變量下生成一個(gè)window.$name的變量,這個(gè)name值目前就是服務(wù)B的ModuleFederationPlugin配置的name值。通過(guò)window.$name.get('./index')就可以拿到我們導(dǎo)出的路由信息了。
如果一切順利這時(shí)在切換不同服務(wù)路由時(shí),應(yīng)該能成功加載路由信息了。
3.根據(jù)路由變化自動(dòng)加載對(duì)應(yīng)的服務(wù)入口
上面我們是寫(xiě)死了一個(gè)pathname和remote地址,接下來(lái)要做的是在路由變化時(shí),自動(dòng)去加載對(duì)應(yīng)的服務(wù)入口。 這里我們第一步需要將所有的前端服務(wù)共享到環(huán)境變量中。在.env(環(huán)境變量的方法可以有很多種,目的是配置在window變量中,可直接訪問(wèn))中配置如下:
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('加載了錯(cuò)誤的importManifest.js,請(qǐng)檢查服務(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變化時(shí),通過(guò)環(huán)境變量找到對(duì)應(yīng)的remoteEntry的地址來(lái)加載。
4.線上部署
在各個(gè)分服務(wù)的CI中,我們需要增加上傳打包后文件的操作,這里我們選擇的是MINIO服務(wù)器,將各個(gè)服務(wù)通過(guò)webpack打包后的dist文件(當(dāng)然dist文件中也包含了remoteEntry.js文件)上傳在MINIO上,可直接通過(guò)url訪問(wèn)到文件內(nèi)容即可。
在以前的版本中,總前端需要依賴(lài)各個(gè)服務(wù)進(jìn)行一個(gè)裝包,編譯部署的過(guò)程
// 總前端的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)在我們的總前端只需要一個(gè)框架類(lèi)的服務(wù)A,其余服務(wù)都只通過(guò)環(huán)境變量的方法來(lái)引入就行了。
// 總前端的.env文件 remote_B=$MINIO_URL/remoteB/$版本號(hào)/remoteEntry.js remote_C=$MINIO_URL/remoteC/$版本號(hào)/remoteEntry.js remote_D=$MINIO_URL/remoteD/$版本號(hào)/remoteEntry.js remote_E=$MINIO_URL/remoteE/$版本號(hào)/remoteEntry.js
5.問(wèn)題記錄
- 在配置
ModuleFederationPlugin的remotes時(shí),最好用JSON.stringify包裹一下,不然會(huì)導(dǎo)致編譯之后生成的remote地址為變量名而不是字符串。 - 如果出現(xiàn)
fn is not function錯(cuò)誤,多半是expose導(dǎo)出寫(xiě)的有問(wèn)題,如果實(shí)在解決不了,建議使用官方推薦的loadComponent方法。 webpack的optimization方法貌似與ModuleFederationPlugin不兼容,建議去掉。- 如果出現(xiàn)shared模塊共享問(wèn)題,可通過(guò)增加一個(gè)
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)邦多模塊項(xiàng)目實(shí)戰(zhàn)詳解的詳細(xì)內(nèi)容,更多關(guān)于React 模塊聯(lián)邦多模塊的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
react 實(shí)現(xiàn)圖片正在加載中 加載完成 加載失敗三個(gè)階段的原理解析
這篇文章主要介紹了react 實(shí)現(xiàn)圖片正在加載中 加載完成 加載失敗三個(gè)階段的,通過(guò)使用loading的圖片來(lái)占位,具體原理解析及實(shí)現(xiàn)代碼跟隨小編一起通過(guò)本文學(xué)習(xí)吧2021-05-05
ReactHook使用useState更新變量后,如何拿到變量更新后的值
這篇文章主要介紹了ReactHook使用useState更新變量后,如何拿到變量更新后的值問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2024-03-03
concent漸進(jìn)式重構(gòu)react應(yīng)用使用詳解
這篇文章主要為大家介紹了concent漸進(jìn)式重構(gòu)react應(yīng)用的使用詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-11-11
使用reactjs優(yōu)化了進(jìn)度條頁(yè)面性能提高70%
這篇文章主要介紹了使用reactjs優(yōu)化了進(jìn)度條后頁(yè)面性能提高了70%的操作技巧,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-04-04
React?Router?v6路由懶加載的2種方式小結(jié)
React?Router?v6?的路由懶加載有2種實(shí)現(xiàn)方式,1是使用react-router自帶的?route.lazy,2是使用React自帶的?React.lazy,下面我們就來(lái)看看它們的具體實(shí)現(xiàn)方法吧2024-04-04

