前提:
老项目是一个多模块的前端项目 ,有一个框架层级的前端服务A ,用来渲染界面的大概样子 ,其余各个功能模块前端定义自己的路由信息与组件 。本地开发时 ,通过依赖框架服务A来启动项目 ,在线上部署时会有一个总前端的应用 ,在整合的时候 ,通过在获取路由信息时批量加载各个功能模块的路由信息 ,来达到服务整合的效果 。
// config.js
// 这个配置文件 定义在收集路由时需要从哪些依赖里收集
modules: [
front-service-B,
front-service-C,
front-service-D,
...
],
痛点
本地联调多个前端服务时比较麻烦 ,需要下载对应服务npm资源 ,并在config.js中配置上需要整合的服务名称 ,并且在debugger时 ,看到的source树中是经过webpack编译后的代码 。
如果本地联调多个服务时,需要修改依赖服务的代码 ,要么直接在node_modules中修改 ,要么将拉取对应服务代码,在源码上修改好了之后通过编译将打出来的包替换node_modules中的源文件 ,或者使用yalc来link本地启动的服务 ,不管是哪种方法都比直接修改动态刷新都要麻烦的多 。
部署线上开发环境时 ,需要将修改好的本地服务提交到代码库 ,跑完一次CI编译后 ,还需要再跑一次总前端应用的CICD才能部署到线上 ,这样发布测试的时间成本大大增加 。
需求
实现真正意义上的微前端 ,各服务的资源可相互引用 ,并且在对应模块编译更新后 ,线上可直接看到效果 ,不需要重新CICD一次总前端 ,在本地开发时 ,引入不同前端服务,可通过线上版本或者本地版本之间的自由切换 。自然而然 ,我们想到Module Federation——模块联邦 。
思路
首先需要明确一下思路 ,既然各个服务是通过路由来驱动的,那我们需要做的 ,简单来说就是将各个服务的路由文件通过模块联邦导出 ,在框架服务A的路由收集里 ,通过监测路由pathname的变化 ,来动态引入对应服务的路由信息来达到微前端的效果 。
实战
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: 这是模块联邦编译后生成的入口文件名 ,增加ModuleFederationPlugin后会在打包出来的dist文件中多生成一个$filename文件 。
name:一个模块的唯一值 ,在这个例子中 ,用不同模块package.json中设置的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: 这是重要的参数之一 ,设置了哪些模块能够导出 。参数为一个对象 ,可设置多个 ,在这里我们最重要的就是导出各个服务的路由文件 ,路径在$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: 模块单例的配置项,由于各个模块单独编译可运行 ,为保证依赖项单例(共享模块) ,通过设置这个参数来配置 。
// 这里的配置项按不同项目需求来编写 主要目的是避免依赖生成多例导致数据不统一的问题
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: 这是引入导出模块的配置项,比如我们配置了一个name为A的exposes模块 ,则可以在这里配置
// ModuleFederationPlugin
remotes: {
A: A@http://localhost:3001/remoteEntry.js,
},
// usage
import CompA from A;
但是在我实际测试中 ,使用remotes导入模块 ,会报各种各样奇奇怪怪的问题 ,不知道是我的版本问题还是哪里配置没对 ,所以这里在导入模块的地方 ,我选择了官方文档中的动态远程容器方法.
2.本地开发测试
本地要完成的需求是 ,单独启动服务A后 ,通过注入服务B的入口文件 ,达到路由整合里有两个服务的路由信息。
在这里我们假设服务A的路由pathname是pathA ,服务B的pathanme是pathB
这个时候我们本地启动两个服务 ,服务A在8080端口 ,服务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 ,请检查服务版本);
}
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服务的pathname ,目的是在加载完B服务的路由文件后设置到Route信息上,通过异步script的方法 ,向head中增加一条src为remoteEntry地址的script标签 。
如果加载文件成功 ,会在window变量下生成一个window.$name的变量,这个name值目前就是服务B的ModuleFederationPlugin配置的name值。通过window.$name.get(./index)就可以拿到我们导出的路由信息了 。
如果一切顺利这时在切换不同服务路由时 ,应该能成功加载路由信息了 。
3.根据路由变化自动加载对应的服务入口
上面我们是写死了一个pathname和remote地址,接下来要做的是在路由变化时 ,自动去加载对应的服务入口 。 这里我们第一步需要将所有的前端服务共享到环境变量中 。在.env(环境变量的方法可以有很多种 ,目的是配置在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 ,请检查服务版本);
}
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变化时 ,通过环境变量找到对应的remoteEntry的地址来加载 。
4.线上部署
在各个分服务的CI中 ,我们需要增加上传打包后文件的操作 ,这里我们选择的是MINIO服务器 ,将各个服务通过webpack打包后的dist文件(当然dist文件中也包含了remoteEntry.js文件)上传在MINIO上,可直接通过url访问到文件内容即可 。
在以前的版本中 ,总前端需要依赖各个服务进行一个装包 ,编译部署的过程
// 总前端的package.json
"dependencies": {
"架构-A": "x.x.x",
"服务B": "x.x.x",
"服务C": "x.x.x",
"服务D": "x.x.x,
...
},
但是现在我们的总前端只需要一个框架类的服务A,其余服务都只通过环境变量的方法来引入就行了 。
// 总前端的.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包裹一下 ,不然会导致编译之后生成的remote地址为变量名而不是字符串 。
如果出现fn is not function错误 ,多半是expose导出写的有问题 ,如果实在解决不了 ,建议使用官方推荐的loadComponent方法 。
webpack的optimization方法貌似与ModuleFederationPlugin不兼容 ,建议去掉 。
如果出现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 模块联邦多模块项目实战详解的详细内容 ,更多关于React 模块联邦多模块的资料请关注本站其它相关文章!
声明:本站所有文章 ,如无特殊说明或标注 ,均为本站原创发布 。任何个人或组织 ,在未征得本站同意时 ,禁止复制 、盗用 、采集 、发布本站内容到任何网站 、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 。