作者:陈剑冬  历史版本:1  最后编辑:李明骏  更新时间:2024-09-11 09:48

I-BASE 微前端跨工程共享组件指南

1. 什么是微前端?

ㅤㅤ假如我们有一个大型应用,里面会有很多的产品模块,作为这个大型应用的前端团队,我们对接了多个后端团队。假设每个小产品模块和一个后端团队对接,在产品上也会拆分为多个小产品。这个时候,按照传统方案,我们在前端架构的时候有两种选择:第一种是一个单体前端应用,包含所有产品,将不同产品维护在同一仓库的不同目录下,统一打包。这种方案在项目规模不是很大的时候,用起来会很舒服。
ㅤㅤ但是随着不断迭代,项目体积越来越大的时候,就会出现打包越来越慢的问题,同时多项目在一个仓库也增加了并行场景下的冲突风险。这个时候,我们就会选择第二种方案,将不同子产品拆成独立的项目,将公共模块抽成 npm 包,每个子产品单独仓库维护,单独发布。这样就解决了之前打包慢和并行冲突的问题。
ㅤㅤ可是!当我们的应用拆的越来越多,几十个、几百个的时候。我们又遇到了新的问题:公共模块升级了,子应用需要全部升级一遍然后发布。虽然我们可以使用 lerna 等 monorepo 的方案将多个子应用管理在一个大仓库里,同时每个子应用单独打包,但是当你的产品到了更大量级的时候, 显然单仓库不是一个很好的解决方案。正所谓:分久必合,合久必分。于是诞生了微前端。

ㅤㅤ微前端(Micro Frontends)是一种前端架构模式,类似于微服务架构,旨在将大型前端应用拆分成更小、独立的模块,以便不同的团队可以独立开发、测试、部署和扩展各自的模块。每个模块都代表应用的一个功能或页面,它们可以独立开发和部署,同时能够在运行时无缝地集成在一起,形成一个完整的用户界面。
ㅤㅤ微前端的核心思想是将前端应用拆分成更小的部分,这些部分可以独立构建、部署和维护。每个部分被称为微前端,它可以是一个独立的代码库,使用不同的技术栈、框架甚至语言来开发。每个微前端可以有自己的开发团队,有自己的开发流程和部署策略。

2. 模块联邦(Module Federation)是什么?

  • 2.1 简介

    ㅤㅤ在传统的前端应用中,通常使用Webpack等构建工具将整个应用打包成一个或多个bundle文件,然后在浏览器中加载。但是随着应用规模的增大和团队的不断扩展,将整个应用打包成单一的bundle可能会导致文件过大、加载时间长、更新和部署不便等问题。
    ㅤㅤWebpack 5 新增了一项功能–模块联邦(Module Federation),旨在解决微前端架构中的模块共享和应用集成问题。它使得不同的 Webpack 构建可以共享模块,从而实现了在不同的应用之间共享代码和资源的能力。通俗点讲,Module Federation 提供了能在当前应用加载其他应用的能力。
    ㅤㅤ所以,当前模块想要加载其他模块,就要有一个引入动作,同样,如果想让其他模块使用,就需要有一个导出动作。
    因此,就引出webapck配置的两个概念:

    expose:导出应用,被其他应用导入
    remote:引入其他应用

    这与基座模式完全不同,像single-spa和qiankun都是需要一个基座(中心容器)去加载其他子应用。而 Module Federation 任意一个模块都可以引用其他应用和也可以导出被其他应用使用,这就没有了容器中心的概念。

  • 2.2 核心概念

    模块联邦的实现原理如下:

ㅤㅤ主应用(Host)和远程应用(Remote):在模块联邦中,存在一个主应用和一个或多个远程应用。主应用是整个应用的入口,而远程应用是提供独立功能的应用。
ㅤㅤ远程容器(Remote Container):每个远程应用都会创建一个远程容器,它负责加载和管理远程应用的模块。远程容器是一个独立的 Webpack 构建,它包含了远程应用的代码和资源,并将它们封装为可以动态加载的模块。
ㅤㅤ共享模块(Shared Module):模块联邦允许不同的应用共享模块。主应用和远程应用可以声明它们希望共享的模块,以及模块的版本和名称。这样,在构建过程中,Webpack 会将共享模块提取到一个独立的文件中,并在主应用和远程应用之间共享使用。
ㅤㅤ动态远程加载(Dynamic Remote Loading):模块联邦使得主应用可以在运行时动态加载远程应用的模块。主应用可以根据需要动态地加载远程应用的代码和资源,并在主应用中使用这些模块。
ㅤㅤ共享上下文(Shared Context):为了确保共享模块的正确性和一致性,模块联邦提供了共享上下文的概念。通过共享上下文,主应用和远程应用可以共享一些全局的状态和配置,以便共享模块的正确执行。

ㅤㅤ模块联邦使得不同的应用可以以独立的方式开发、构建和部署,同时可以共享和集成代码和资源。它提供了一种更灵活、松耦合的前端架构方式,有助于构建大型复杂应用和微服务体系结构。同时,模块联邦还提供了一些安全性措施,以确保远程应用和共享模块的安全性和可靠性。
ㅤㅤ正如上面所说,我们需要webpack5的模块联邦功能,将公共组件,函数,样式,package包等共享出来,供其他应用使用。大体结构图如下:

  • 2.3 模块联邦的优势

ㅤㅤ模块联邦作为一种前端架构模式,带来了许多好处,特别是在处理大型、复杂的分布式系统中。以下是模块联邦的一些主要优势:

ㅤㅤ模块化开发: 模块联邦鼓励将应用拆分成小块模块,每个模块负责一个特定的功能或特性。这样的模块化开发风格有助于提高代码的可维护性、可测试性和可重用性。
ㅤㅤ独立开发和部署: 不同的模块可以由不同的团队独立开发和部署,从而减少了团队之间的依赖和协调成本。每个模块的更新和发布都不会影响其他模块,实现了更灵活的开发流程。
ㅤㅤ减少打包体积: 传统的单一bundle打包方式可能导致文件过大,增加加载时间。模块联邦可以将共享的依赖项从模块中提取出来,在运行时动态加载,从而减少每个模块的打包体积。
ㅤㅤ共享依赖: 模块联邦确保共享的依赖只会被加载和初始化一次,这样可以减少代码冗余和资源浪费,提高应用的性能。
ㅤㅤ团队协作: 模块联邦使得不同团队能够专注于各自的领域,而不必担心与其他团队的冲突。每个团队可以独立开发和维护自己的模块,降低了团队之间的合作难度。
ㅤㅤ远程模块: 可以将模块部署在不同的服务中,甚至由不同的团队维护。这种灵活性有助于更好地组织代码库,划分边界和责任。
ㅤㅤ动态加载: 模块联邦支持在运行时动态加载模块,这意味着只有在需要时才会加载模块,从而减少初始加载时间。
适用于复杂应用: 模块联邦适用于各种规模的应用,从小型应用到大型、复杂的分布式系统,都可以受益于模块联邦的架构模式。
ㅤㅤ可维护性增强: 将应用拆分成模块可以降低代码库的复杂性,使得问题排查和维护更加容易。
ㅤㅤ
ㅤㅤ总的来说,模块联邦通过解决依赖管理、共享依赖、独立开发等问题,为构建现代前端应用提供了一种强大的架构模式。随着前端应用的不断发展,模块联邦的优势将继续显现,并在应对复杂性和可维护性方面发挥重要作用。

3. 相关改造工作

3.1 主应用(Host)

  • 3.1.1 升级依赖

    由于模块联邦需要webpack5以上版本支持,故需将项目对应依赖升级到指定版本。
    这里以项目 base-core 为例,该项目使用的是 vue-cli 作为构建工具,内置了 webpack,只需将版本升至 vue-cli 5.x 即可。

    同理,相对应的 vue-cli 插件也需一并升级:



    相关 webpack 插件和 loader 也一并升级到支持 webpack5 的版本:

    # 升级命令(具体根据项目决定,这里只是示例)
    npm install \
        @vue/cli-plugin-babel@^5.0.8 \
        @vue/cli-plugin-router@^5.0.8 \
        @vue/cli-plugin-unit-jest@^5.0.8 \
        @vue/cli-plugin-vuex@^5.0.8 \
        @vue/cli-service@^5.0.8 \
        html-webpack-plugin@^5.6.0 \
        less-loader@11.1.0 \
        vue-cli-plugin-i18n@2.3.2 \
        webpack@5.89.0 \
    --save-dev

  • 3.1.2 打包配置修改

    vue/cli-service升级到5.0版本后,会出现打包build两次的情况。
    ㅤ
    这是其实是官方目前为了提升解析速度和运算速度,改善应用的加载性能,推出的“现代模式”,
    ㅤ
    Vue CLI 会产生两个应用的版本:一个现代版的包,面向支持 ES modules 的现代浏览器,另一个旧版的包,面向不支持的旧浏览器。
    现代版的包会通过 <script type="module"> 在被支持的浏览器中加载;它们还会使用 <link rel="modulepreload"> 进行预加载。
    ㅤ
    旧版的包会通过 <script nomodule> 加载,并会被支持 ES modules 的浏览器忽略。

    具体参考文档
    https://cli.vuejs.org/zh/guide/browser-compatibility.html#%E7%8E%B0%E4%BB%A3%E6%A8%A1%E5%BC%8F

    这里我们不需要该模式,否则将导致页面无法正常加载,所以我们增加 –no-module 参数,避免二次打包:

    // package.json
    "scripts": {
      "build": "vue-cli-service build --no-module"
    },

  • 3.1.3 html-webpack-plugin 插件适配

    项目中需要注入 config.js 作为配置文件,这里使用的是 html-webpack-plugin 用于向 HTML 文件中注入配置相关的 JavaScript 文件。

    该插件会在 webpack 编译过程中被调用。在这个方法中,首先获取到 HTMLWebpackPlugin 的 hooks,然后在生成 asset 标签之前执行一个自定义的回调函数,在这个回调函数中,将配置文件的路径加入到已有的 JavaScript 文件数组中。

    // plugins/html-inject-config-plugin.js
    class HtmlInjectConfigPlugin {
    /**
     * 构造函数
     */
    constructor(baseUrl = '') {
      baseUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'
      this.files = [baseUrl + 'config.js?t=' + new Date().getTime()]
    }
    
    apply(compiler) {
      compiler.hooks.compilation.tap('HtmlInjectConfigPlugin', (compilation) => {
        const HtmlWebpackPlugin = require('html-webpack-plugin');
        HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync(
          { name: 'HtmlInjectConfig' },
          (data, callback) => {
            data.assets.js = this.files.concat(data.assets.js);
            callback(null, data);
          }
        );
      });
    }
    }
    ㅤ
    module.exports = HtmlInjectConfigPlugin
    // vue.config.js
    const HtmlInjectConfigPlugin = require('./plugins/html-inject-config-plugin')
    // 基础路径 注意发布之前要先修改这里
    process.env.VUE_APP_PUBLIC_PATH = '/child-core/'
    const publicPath = process.env.VUE_APP_PUBLIC_PATH || './'
    ...
    module.exports = {
      chainWebpack: config => {
          // 在html文件注入配置文件
          config.plugin('HtmlInjectConfigPlugin').use(HtmlInjectConfigPlugin, [publicPath])
      }
    }

  • 3.1.4 入口文件修改

    入口 entry 修改成异步加载方式,让 remoteEntry.js 优先进行加载

    // vue.config.js
    module.exports = {
      index: {
          entry: 'src/pages/index.js', // 这里改成index.js
          template: 'public/index.html',
          filename: 'index.html',
          chunks: [
            'manifest',
            'index',
            'chunk-index',
            'chunk-vendor',
            'chunk-common',
            'chunk-vue',
            'chunk-element',
            'chunk-xlsx',
            'chunk-echarts',
            'chunk-locales'
          ]
      }
    }
    // src/pages/index.js
    import('./main');
    // micro-app不能异步加载,否则无法成功渲染
    import "../plugins/micro-app";
    // src/pages/main.js
    import('./bootstrap');
    import '@/assets/menu-icon/iconfont.css'
    import '@/assets/menu-icon/iconfont.js'
    import '@/assets/fonts/iconfont.css'
    import '@/assets/js/iconSvg.js'
    ...
    // src/pages/bootstrap.js
    import * as Vue from 'vue'
    import App from './App'
    window.$vueApp = Vue.createApp(App)
    window.$vueApp.mount('#app-core')

ㅤㅤ在渲染逻辑执行前,需要增加一层 bootstrap 的 import 逻辑,本质是为了能够让异步加载的 runtime 依赖,完成加载后再执行主逻辑,核心流程如下:
ㅤㅤ
ㅤㅤ
ㅤㅤ
ㅤㅤmain 应用首先会去执行入口文件 main.js,然后加载 bootstrap.js 模块,如果它依赖了远程模块 remoteEntry.js,那么将会去获取 remoteEntry.js 中引入的资源、对应的组件,等远程应用的资源以及 bootstrap.js 资源全部下载完成后再执行 bootstrap.js 模块。

  • 3.1.5 分包配置修改

    注释掉 runtimeChunk 配置

    调整了部分包的优先级 priority 和 chunks。Module Federation 只支持 async 和 all,不支持使用 initial

      // vue.config.js
      // config.optimization.runtimeChunk({
      //   name: 'manifest'
      // })
      config.optimization.splitChunks({
          cacheGroups: {
              libs: {
                  name: 'chunk-vendor',
                  chunks: 'async', // 修改
                  minChunks: 1,
                  test: /[\\/]node_modules[\\/]/,
                  priority: -10, // 修改
                  reuseExistingChunk: true,
                  enforce: true
              }
          }
          ...
      })

  • 3.1.6 暴露共享组件

      // vue.config.js
      const remoteComponents = require('./plugins/remote-components.js')
      // 暴露共享组件
      configNew.plugins.push(
        new webpack.container.ModuleFederationPlugin({
          name: 'baseCore',
          library: { type: "umd", name: "baseCore" },
          filename: 'remoteEntry.js',
          exposes: remoteComponents.exposes,
          shared: remoteComponents.shared,
        })
      )
    // remote-components.js
    module.exports = {
      exposes: {
          // 这里暴露出想要共享的组件
          './userselector.vue': './src/components/core-selector/userselector.vue',
          './orgselector.vue': './src/components/core-selector/orgselector.vue',
          './positionselector.vue': './src/components/core-selector/positionselector.vue',
          './roleselector.vue': './src/components/core-selector/roleselector.vue'
      },
      // 共享库
      shared: {
          vue: {
              singleton: true
          }
      }
    }

  • 3.1.7 动态 publicPath 设置

    ㅤㅤ在完成上面的暴露组件配置后,我们需要向子应用提供一个 host api 以设置 publicPath。
    ㅤㅤ可以允许 主应用(host) 在运行时通过公开远程模块的方法来设置远程模块的 publicPath,当你在 主应用(host) 域的子路径上挂载独立部署的子应用程序时,这种方法特别有用。只需在子应用配置对应的主应用地址,即可实现动态指向至主应用(host)

    ㅤㅤ入口文件配置:

    // vue.config.js
    module.exports = {
      ...
      // 用于子应用设置远程共享组件的地址,以修正路径不正确问题
      baseCore: {
          entry: 'plugins/setup-public-path.js',
      }
    }
    // setup-public-path.js
    export function set(value) {
      __webpack_public_path__ = value;
    }
    // remote-components.js
    module.exports = {
      exposes: {
          // 用于子应用设置远程共享组件的地址,以修正路径不正确问题
          './public-path': './plugins/setup-public-path.js',
         ...
      }
    }

  • 3.1.8 共享 config.js 配置

    将主应用中的配置共享到 window 中,防止子应用无法获取到一些重要的变量(如 request 所需要的 token 前缀)

    import microApp from '@micro-zoe/micro-app'
    ㅤ
    microApp.start({
      tagName: 'micro-app-core',
      lifeCycles: {
          mounted() {
              // 设置全局数据
              if (window.rawWindow) {
                  window.rawWindow.__CORE_CONFIG__ = window.__CORE_CONFIG__
              }
          }
      }
    })

  • 3.1.9 相关配置修改

    基础地址修改:

    export const BASE_URL = window.__MICRO_APP_ENVIRONMENT__ ? window.__MICRO_APP_PUBLIC_PATH__ : env.VUE_APP_PUBLIC_PATH || '/'

    旧的 devServer 配置:

    module.exports = {
      ...
      devServer: {
          publicPath, // 和 publicPath 保持一致
          port, // 端口
          disableHostCheck: isDev, // 关闭 host check,方便使用 ngrok 之类的内网转发工具
      }
      ...
    }

    新的 devServer 配置:

    module.exports = {
      ...
      devServer: {
          hot: true, // 开启webpack HRM(模块热替换)
          historyApiFallback: {
              // 将以 xxx 开头的请求重定向到根路径
              rewrites: [{ from: new RegExp(`^\/child-core`), to: '/' }],
          },
          port, // 端口
          allowedHosts: 'auto', // 关闭 host check,方便使用 ngrok 之类的内网转发工具
      }
      ...
    }

    判断是否处于 微前端 环境:

    if (microApp) {
      ...
    }

    改为

    if (__MICRO_APP_BASE_APPLICATION__) {
      ...
    }

3.2 子应用

  • 3.2.1 升级依赖

    同主应用 3.1.1 升级依赖

  • 3.2.2 打包配置修改

    增加 coreUrl 参数,指定主应用的地址

    根据场景的不同,开发环境指定主应用地址,生产环境直接使用相对路径即可

      // package.json
      "scripts": {
          "dev": "vue-cli-service serve --coreUrl=\"http://localhost:6529/child-core/\"",
          "build": "vue-cli-service build --no-module --coreUrl=\"/child-core/\""
      }

    设置 coreUrl 作为全局变量 VUE_APP_BASE_CORE_URL ,方便后续使用

      // vue.config.js
      const coreUrl = process.argv.filter((x) => x.startsWith('--coreUrl='))
      if (coreUrl.length > 0) {
        process.env.VUE_APP_BASE_CORE_URL = coreUrl[0].replace('--coreUrl=', '')
      }

    这里对 public-path 作用不理解的同学,可以跳转到上文提到的3.1.7 动态 publicPath 设置

      // 子应用,调用主应用暴露的方法
      // src/index.js
      import env from '@/env'
      import '@/plugins/micro-app'
      import('baseCore/public-path').then((res) => {
        // 设置远程共享组件的地址,以修正路径不正确问题
        res.set(env.VUE_APP_BASE_CORE_URL);
        import('./main');
      });
  • 3.2.3 html-webpack-plugin 插件适配

    同主应用 3.2.3 html-webpack-plugin 插件适配

  • 3.2.4 入口文件修改

    同主应用 3.2.4 入口文件修改

  • 3.2.5 分包配置修改

    同主应用 3.2.5 分包配置修改

  • 3.2.6 获取共享组件

    这里通过重写 container,实现了通过 VUE_APP_BASE_CORE_URL 变量,动态修改获取 remoteEntry 的路径,方便适应生产环境与开发环境不同的地址。

    // 获取共享组件
      if (process.env.VUE_APP_BASE_CORE_URL) {
        configNew.plugins.push(
          new webpack.container.ModuleFederationPlugin({
            name: 'child-app',
            filename: 'remoteEntry.js',
            remotes: {
              baseCore: `promise new Promise(resolve => {
                const timeStamp = Date.now();
                const remoteUrlWithTimeStamp = '${process.env.VUE_APP_BASE_CORE_URL}remoteEntry.js?time=' + timeStamp;
                const script = document.createElement('script')
                script.src = remoteUrlWithTimeStamp
                script.onload = () => {
                  // the injected script has loaded and is available on window
                  // we can now resolve this Promise
                  const proxy = {
                    get: (request) => window.baseCore.get(request),
                    init: (arg) => {
                      try {
                        return window.baseCore.init(arg)
                      } catch(e) {
                        console.log('remote container already initialized')
                      }
                    }
                  }
                  resolve(proxy)
                }
                // inject this script with the src set to the versioned remoteEntry.js
                document.head.appendChild(script);
              })
              ` 
            },
            shared: {
              vue: {
                singleton: true,
              }
            },
          }),
        )
      }

4. 共享组件使用

具体使用移步至:http://doc.bpmhome.cn/docs/base_development_manual/base_development_manual-1fkv6gu31e3va

5. 共享组件使用规范

使用共享组件时需遵守如下规则:

5.1 应用别名固定

  • baseCore 是提供组件的应用别名,一般不进行修改,固定。

5.2 this 指向问题

  • 主应用使用公共方法时需注意 this 指向问题
    // this.$util获取的是当前子应用的方法库,并不是主应用。
    this.$util.isString(value)
    // 如需使用主应用的公共方法,请按如下引入
    import Util from '@/utils/util'
    Util.isString(value)

5.3 注意变量定义顺序

  • 在共享组件中,需要注意变量定义顺序

    不正确的:Cannot read properties of undefined (reading ‘modelValue’)
    const dialogFormVisible = ref(props.modelValue)
    const props = defineProps({
     modelValue: {
         type: Boolean,
         default: false
     }
    })
    正确的:
    const props = defineProps({
     modelValue: {
         type: Boolean,
         default: false
     }
    })
    const dialogFormVisible = ref(props.modelValue)

5.4 不能引入 vue

  • 禁止在组件中直接引用 vue
    // 直接引入将导致循环引入问题
    import * as Vue from 'vue'

5.5 不能使用 vuex

  • 两个项目使用状态管理,将导致数据混淆,不好操控,这里不建议使用!

    如需使用,可按如下配置:

    主应用:
    // plugins/remote-components.js
    module.exports = {
     exposes: {
         "./store": "./src/store/index.js"
         ...
     }
    }
    子应用:
    // bootstrap.js
    import * as Vue from 'vue'
    import App from './App'
    // 引入远程 baseCore 的 store
    import remoteStore from 'baseCore/store';
    window.$vueApp = Vue.createApp(App)
    window.$vueApp.use(remoteStore)

5.6 样式需要写在组件内

  • 组件中的公共样式不会携带过来,需要写在组件内的 style 标签中

5.7 注意样式前缀问题

  • 如插件中定义了前缀,例如 elementUI 定义了 class 的前缀,需要注意引入到子应用中时前缀不一致的问题。

6. 相关文档

6.1 插件 module-federation-plugin 官方文档

https://webpack.js.org/plugins/module-federation-plugin/#root

6.2 webpack5模块联邦实战及原理解析

https://juejin.cn/post/7286376634403192844

6.3 一文通透讲解webpack5 module federation

https://juejin.cn/post/7048125682861703181