React 模块联邦多模块项目实战详解

目录
  • 前提:
  • 1. 修改webpack增加ModuleFederationPlugin
  • 2.本地开发测试
  • 3.根据路由变化自动加载对应的服务入口
  • 4.线上部署
  • 5.问题记录

前提:

老项目是一个多模块的前端项目,有一个框架层级的前端服务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的路由pathnamepathA,服务B的pathanmepathB

这个时候我们本地启动两个服务,服务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中增加一条srcremoteEntry地址的script标签。

如果加载文件成功,会在window变量下生成一个window.$name的变量,这个name值目前就是服务B的ModuleFederationPlugin配置的name值。通过window.$name.get('./index')就可以拿到我们导出的路由信息了。

如果一切顺利这时在切换不同服务路由时,应该能成功加载路由信息了。

3.根据路由变化自动加载对应的服务入口

上面我们是写死了一个pathnameremote地址,接下来要做的是在路由变化时,自动去加载对应的服务入口。 这里我们第一步需要将所有的前端服务共享到环境变量中。在.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.问题记录

  • 在配置ModuleFederationPluginremotes时,最好用JSON.stringify包裹一下,不然会导致编译之后生成的remote地址为变量名而不是字符串。
  • 如果出现fn is not function错误,多半是expose导出写的有问题,如果实在解决不了,建议使用官方推荐的loadComponent方法。
  • webpackoptimization方法貌似与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 模块联邦多模块的资料请关注我们其它相关文章!

(0)

相关推荐

  • react native 原生模块桥接的简单说明小结

    Android 创建原生模块包 通过继承 ReactPackage 为你的原生模块包创建 Java 类,可以这么写: 覆盖 createNativeModules 和 createViewManagers 方法 public class MyNativePackage implements ReactPackage { @Override public List<NativeModule> createNativeModules(ReactApplicationContext reactCon

  • 模块化react-router配置方法详解

    react-router模块化配置 因为公司的需要最近踏进了react坑,一直在挖坑填坑,在路由这一块折腾得不行. 直接进入主题,配置react-router模块化 1.先下载react-router-dom npm install react-router-dom --save 2.在相应的文件引入react-router-dom相应的模块 import { BrowserRouter as Router, Route, Link } from "react-router-dom";

  • React Native模块之Permissions权限申请的实例相机

    React Native模块之Permissions权限申请的实例详解 前言 对于移动开发,我们知道Android 6.0之后对于权限管理做了很大的升级,其类似于IOS的管理管理方式需要用手动授权是否允许使用当前权限, 在RN开发中同样存在这样一个模块. 处理方法 在RN中提供了一个PermissionsAndroid的模块, 可以访问Android M(也就是6.0)开始提供的权限模型.有一些权限写在AndroidManifest.xml就可以在安装时自动获得.但有一些"危险"的权限

  • react中的axios模块你了解吗

    目录 一.react中axios模块的使用 1.基于Promise的HTTP库用在浏览器和node.js中 2.创建XMLHttpRequest对象: 3.在react中安装axios 4.发起不带参数的get请求 5.带参数的get请求 6.post请求:发送表单数据和文件上传 7.put请求 8.delete请求 例如: 总结 一.react中axios模块的使用 1.基于Promise的HTTP库用在浏览器和node.js中 可以提供以下服务: (1)从浏览器中创建XMLHttpReque

  • react实现一个优雅的图片占位模块组件详解

    前言 发现项目中的图片占位模块写得很不优雅,找了一圈,发现没找到自己想要的图片组件.于是自己写了一个,写了一个还算优雅的图片组件:mult-transition-image-view 截图: 功能简介 首先它是一个比较优雅的组件:用起来不头疼. 第二个它能实现以下场景: 没有图片的时候,显示一个占位图(可以直接用css来写背景,方便自定义) 希望在加载大图的时候,能先占位一张小图,然后再过渡到一张大图.类似上面的截图. 使用方法 安装npm 包 npm install react-mult-tr

  • React中的axios模块及使用方法

    目录 1 axios介绍 2 使用方法 2.1 在React中安装axios 2.2 get请求 2.3 post请求:发送表单数据和文件上传 2.4 put请求:对数据进行全部更新 2.5 patch请求:只对更改过的数据进行更新 2.6 delete请求:删除请求(参数可以放在url上,也可以和post一样放在请求体中) 1 axios介绍 axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中.它可以提供以下服务:1.从浏览器中创建XMLHttpRe

  • React 模块联邦多模块项目实战详解

    目录 前提: 1. 修改webpack增加ModuleFederationPlugin 2.本地开发测试 3.根据路由变化自动加载对应的服务入口 4.线上部署 5.问题记录 前提: 老项目是一个多模块的前端项目,有一个框架层级的前端服务A,用来渲染界面的大概样子,其余各个功能模块前端定义自己的路由信息与组件.本地开发时,通过依赖框架服务A来启动项目,在线上部署时会有一个总前端的应用,在整合的时候,通过在获取路由信息时批量加载各个功能模块的路由信息,来达到服务整合的效果. // config.js

  • vite2.0+vue3移动端项目实战详解

    一.涉及技术点 vite版本 vue3 ts 集成路由 集成vuex 集成axios 配置Vant3 移动端适配 请求代理 二.步骤 vite+ts+vue3只需要一行命令 npm init @vitejs/app my-vue-app --template vue-ts 配置路由 npm install vue-router@4 --save 在src下新建router目录,新建index.ts文件 import { createRouter, createWebHashHistory, Ro

  • SpringBoot集成mqtt的多模块项目配置详解

    前言 近期为了准备毕设,准备使用SpringBoot搭建mqtt后端,本篇主要记录了在IDEA中搭建SpringBoot mqtt的多模块项目的过程 开发工具及系统环境 IDE:IntelliJ IDEA 2020.2 操作系统:Windows 10 2004 Java Version:1.8 SpringBoot Version:2.1.17.RELEASE 项目路径 Study |----study-common # 存放公共类 |----study-mapper # mapper层 |--

  • intellij idea 将模块打jar包的步骤详解

    今天要想在本机开多个java压测进程测试目标程序.所以需要在本机开多个终端执行jar程序.步骤如下: 1.点开Project Structure,点击左侧Artifacts菜单 选择下图中1.后点击2处 +号,选中3 jar的子菜单 点击4处"from modules with dependencise". 2. 进入下图:Module:选择你要打jar包的模块,MainClass:选择你main 函数所在的类 Jar files from libraries: 选中第一个打完包后是一

  • Springcould多模块搭建Eureka服务器端口过程详解

    这篇文章主要介绍了Springcould多模块搭建Eureka服务器端口过程详解,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下 1创建一个普通父maven 在pom修改为因为spring could依赖spring boot所以首先在父maven <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-star

  • 基于python中pygame模块的Linux下安装过程(详解)

    一.使用pip安装Python包 大多数较新的Python版本都自带pip,因此首先可检查系统是否已经安装了pip.在Python3中,pip有时被称为pip3. 1.在Linux和OS X系统中检查是否安装了pip 打开一个终端窗口,并执行如下命令: Python2.7中: zhuzhu@zhuzhu-K53SJ:~$ pip --version pip 8.1.1 from /usr/lib/python2.7/dist-packages (python 2.7) Python3.X中: z

  • linecache模块加载和缓存文件内容详解

    linecache模块 接触到linecache这个模块是因为前两天读attrs源码的时候看到内部代码引用了这个模块来模拟一个假文件,带着一脸疑问顺便读了一下这个模块的源码,发现其实也就那么回事儿,代码不多,在这总结一下. linecache模块可以读取文件并将文件内容缓存起来,方便后面多次读取.这个模块原本被设计用来读取Python模块的源代码,所以当一个文件名不在指定路径下的时候,模块会通过搜索路径(search path)来尝试读取文件. 接口 linecache模块的__all__参数其

  • 对Python中TKinter模块中的Label组件实例详解

    Python2.7.4 OS-W7x86 1. 简介 Label用于在指定的窗口中显示文本和图像.最终呈现出的Label是由背景和前景叠加构成的内容. Label组件定义函数:Label(master=None, cnf={}, **kw) 其中,kw参数是用来自定义lable组件的键值对. 2. 背景自定义 背景的话,有三部分构成:内容区+填充区+边框 <1>内容区参数有:width,length用于指定区域大小,如果显示前景内容是文本,则以单个字符大小为单位:如果显示的是图像,则以像素为单

  • re模块的正则匹配的表达式详解

    一.校验数字的表达式 1.数字 ^[0-9]\*$ 2.n位的数字 ^\d{n}$ 3.至少n位的数字 ^\d{n,}$ 4.m-n位的数字 ^\d{m,n}$ 5.零和非零开头的数字 ^(0|[1-9][0-9]\*)$ 6.非零开头的最多带两位小数的数字 ^([1-9][0-9]\*)+(\.[0-9]{1,2})?$ 7.带1-2位小数的正数或负数 ^(\-)?\d+(\.\d{1,2})$ 8.正数.负数.和小数 ^(\-|\+)?\d+(\.\d+)?$ 9.有两位小数的正实数 ^[0

  • 对python csv模块配置分隔符和引用符详解

    如下所示: file = open('./abc.csv') csv.reader(file, delimiter=',', quotechar='"') 说明:delimiter是分隔符,quotechar是引用符,当一段话中出现分隔符的时候,用引用符将这句话括起来,就能排除歧义. 以上这篇对python csv模块配置分隔符和引用符详解就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持我们.

随机推荐