- I-BASE 微前端跨工程共享组件指南
- 1. 什么是微前端?
- 2. 模块联邦(Module Federation)是什么?
- 2.1 简介
- 2.2 核心概念
- 2.3 模块联邦的优势
- 3. 相关改造工作
- 3.1 主应用(Host)
- 3.1.1 升级依赖
- 3.1.2 打包配置修改
- 3.1.3 html-webpack-plugin 插件适配
- 3.1.4 入口文件修改
- 3.1.5 分包配置修改
- 3.1.6 暴露共享组件
- 3.1.7 动态 publicPath 设置
- 3.1.8 共享 config.js 配置
- 3.1.9 相关配置修改
- 3.2 子应用
- 3.2.1 升级依赖
- 3.2.2 打包配置修改
- 3.2.3 html-webpack-plugin 插件适配
- 3.2.4 入口文件修改
- 3.2.5 分包配置修改
- 3.2.6 获取共享组件
- 4. 共享组件使用
- 5. 共享组件使用规范
- 5.1 应用别名固定
- 5.2 this 指向问题
- 5.3 注意变量定义顺序
- 5.4 不能引入 vue
- 5.5 不能使用 vuex
- 5.6 样式需要写在组件内
- 5.7 注意样式前缀问题
- 6. 相关文档
- 6.1 插件 module-federation-plugin 官方文档
- 6.2 webpack5模块联邦实战及原理解析
- 6.3 一文通透讲解webpack5 module federation
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包等共享出来,供其他应用使用。大体结构图如下:
ㅤㅤ模块联邦作为一种前端架构模式,带来了许多好处,特别是在处理大型、复杂的分布式系统中。以下是模块联邦的一些主要优势:
ㅤㅤ模块化开发: 模块联邦鼓励将应用拆分成小块模块,每个模块负责一个特定的功能或特性。这样的模块化开发风格有助于提高代码的可维护性、可测试性和可重用性。
ㅤㅤ独立开发和部署: 不同的模块可以由不同的团队独立开发和部署,从而减少了团队之间的依赖和协调成本。每个模块的更新和发布都不会影响其他模块,实现了更灵活的开发流程。
ㅤㅤ减少打包体积: 传统的单一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.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