示例代码仓库:
yl-qiankun-base:https://gitee.com/dongche/yl-qiankun-base.git
yl-qiankun-child-vue:https://gitee.com/dongche/yl-qiankun-child-vue.git
微前端概念起源
微前端概念最早其实是借鉴了微服务的概念,最早是出现在2016年的ThoughtWorks Technology Radar(ThoughtWorks技术雷达)
什么是微前端
MicroFrontends 官方解释:用来构建能够让 多个团队 独立交付项目代码的 现代web app 技术,策略以及实践方法
MicroFrontends 官网:https://swearer23.github.io/micro-frontends/
所以微前端并不是一个单纯的技术点,而是一个为了解决复杂应用,特别时便于以后维护的思路和方法。
核心思路:根据功能模块拆解应用,然后根据需求组装,应用之间可通信
特点: 1.模块可以使用不同技术栈
2.应用隔离,各应用可独立部署,可独立运行
3.应用之间松耦合
4.可渐进式迁移
用图做个简单说明:
扩展理解:组件化的延伸,由项目内的组件化扩展到项目之间的组件化
微前端的应用场景
1.老系统的迭代:复杂的老系统,可能由于各种原因,代码风格不一,代码冗杂,质量参差不齐,然后还要不断加入新功能。
2.复杂应用功能模块的拆分和细化,便于各团队同步进行开发和维护
3.有比较大功能模块需要在不同的项目中使用
4.需求不定,经常有对项目进行拆分和融合
5.平台型应用。方便删减功能模块和外部接入
目前市面上的主流微前端方案及框架
微前端模式
- 自由组织模式
没有形式,自由嵌套 - 基座模式
有一个父级主应用为容器基座,其他应用需要注册接入 - 去中心模式
各应用之间各自为政又可以彼此分享资源,没有基座不存在主次关系
微前端方案
-
iframe 方案
- 优点:html 提供的标签,天然隔离,任意嵌套,能加载任意web应用。适合自由组织自由嵌套模式,无需配置,简单易用
缺点:参考qiankun技术圆桌的 Why Not Iframe
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。
-
web Components 方案
Web Components 是一套 浏览器原生组件,由google发起。
web Components 三大技术:- Custom elements(自定义元素):一组JavaScript API,允许您定义custom elements及其行为,然后可以在您的用户界面中按照需要使用它们。
- Shadow DOM(影子DOM):一组JavaScript API,用于将封装的“影子”DOM树附加到元素(与主文档DOM分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
- HTML templates(HTML模板): <template> 和 <slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用
具备实现微前端的特点:
1.技术栈无关:浏览器原生支持,和框架没有关系
2.独立运行,应用隔离:Shadow DOM 特性- web Componets组件独立开发,相互之间没有依赖关联,但是又可以作为组件自由引入组装
最大缺点:目前浏览器兼容性差
参照 can i use
web Components 偏向基座模式。
目前国内京东的 micro-app 借鉴了web components 的思想
-
基座模式的基于single-spa 的路由劫持方案
这是目前主流的微前端方案,single-spa 也是最早成熟的微前端方案,特别适用于单页面应用。single-spa是通过监听 url change 事件,在路由变化时匹配到渲染的子应用并进行渲染,这个思路也是目前实现微前端的主流方式。同时single-spa要求子应用修改渲染逻辑并暴露出三个方法:bootstrap、mount、unmount,分别对应初始化、渲染和卸载,这也导致子应用需要对入口文件进行修改。single-spa 最大问题是配置麻烦
qiankun出自阿里,基于single-spa进行封装,继承single-spa特性,但是使用简单,上手相对容易 -
去中心化模式的 ESM 方案
ESM 是 ES Module 的缩写,是 Ecma script 2015 中提出的一种前端模块化手段。ESM方案webpack5模块联邦,多个应用可以互相嵌套,可以深入到组件导入导出
ESM 方案可视作 ES6 模块化的扩展,使用远程加载ESM模块方式。成熟方案参考:
EMP官网
EMP github
基座模式:主应用基于vue及qiankun 的微前端实践
qiankun 基于 single-spa 封装,是目前市面上最主流的微前端方案,出自阿里。
qiankun 官网:https://qiankun.umijs.org/zh/guide
qinkun 技术圆桌:https://www.yuque.com/kuitos/gky7yw/nwgk5a
简单说下qiankun
这里我们不对qiankun做什么深入点剖析,想详细了解qiankun的可去官网,或者研究下源码。
我们拿一个做好的微前端项目,用浏览器打开控制台,查看ajax请求,会发现有个请求子应用的ajax请求,这个请求返回了子页面内容
这时候我们点击能访问子应用页面的按钮或菜单,能看到如下内容
查看html
简单来说,qiankun就是拿到子应用的访问路径(entry配置),并且给每个子应用都起一个独一无二的名字(name配置),然后通过ajax请求子应用的数据,再解析到主应用设定好的容器里面(比如#micro-app)。至于具体拿到数据后是如何进行解析,如何劫持路由的,这个就不做深讨论,主要是我也不知道。
下面我们来做基于qiankun 的 微前端实践
项目搭建
按照 qiankun 的说法,接入qiankun时,react,vue等单页面应用使用history模式最好,所以我们这里主应用和子应用暂时都使用 history模式
- 创建主应用
这里的主应用我们用vue-ant-design-pro 创建,应用名 yl-qiankun-basegit clone --depth=1 https://github.com/vueComponent/ant-design-vue-pro.git yl-qiankun-base
- 创建vue子应用,也是基于vue-ant-design-pro 创建,子应用名 yl-qiankun-child-vue
git clone --depth=1 https://github.com/vueComponent/ant-design-vue-pro.git yl-qiankun-child-vue
主应用改造
下载qiankun
npm i qiankun --save
首先将主应用的id="app"改掉,以免和子应用冲突,将public/index.html 里面的id="app"改为独有的id。这里用 ylQiankunBase
第二,我们这里假设业务确定为希望从菜单加载子应用,即点击菜单才加载子应用。那么这里把项目中的 BasicLayout改掉。ant-design-pro 有些东西封装得比较死,不符合要求的就去掉。
这里首先说下基本思路:主应用除了加载子应用,还要加载自己本身应用里面的路由,所以BasicLayout是不能满足需求的。除了改造BasicLayout外,我们还要加个装载子应用的容器组件ChildAppLayout。
1)改造BasicLayout
A.重写BasicLayout,理由是我们需要在点击菜单时做更多的事情,而pro-layout没有提供菜单点击api,并且a-menu @select 事件 返回的数据也不符合要求,我们可能希望得到点击的整个菜单数据,而@select事件只是给了key。
B.考虑到菜单组件以后有可能会重用,这里吧菜单组件抽离除了,命名 Slider
改造后的BasicLayout 如下:
<template>
<a-layout id="components-layout-demo-fixed-sider">
<slider/>
<a-layout :style="{ marginLeft: '200px' }">
<a-layout-header :style="{ background: '#fff', padding: 0 }" />
<a-layout-content :style="{ margin: '24px 16px 0', overflow: 'initial' }">
<router-view/>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script>
import Slider from "../components/slider/slider"
export default {
components:{
Slider
},
}
</script>
Slider 菜单组件如下:
<template>
<a-layout-sider :style="{ overflow: 'auto', height: '100vh', position: 'fixed', left: 0 }">
<div class="logo" >
yl-qiankun-base
</div>
<a-menu theme="dark" mode="inline" :default-selected-keys="['4']">
<a-sub-menu v-for='(menu,index) in menus' :key='menu.name'>
<span slot="title"><a-icon :type="menu.meta.icon" /><span>{{ menu.meta.title }}</span></span>
<a-menu-item v-for='(child,index) in menu.children' :key='child.name' @click.native='select(child)'>
<a-icon :type="child.meta.icon" />
<span class="nav-text">{{ child.meta.title }}</span>
</a-menu-item>
</a-sub-menu>
</a-menu>
</a-layout-sider>
</template>
<script>
import { mapState } from 'vuex'
export default {
name: 'Slider',
data() {
return {
menus:[],//菜单
}
},
computed: {
...mapState({
// 动态主路由
mainMenu: state => state.permission.addRouters
}),
},
mounted() {
const routes = this.mainMenu.find(item => item.path === '/')
this.menus = (routes && routes.children) || []
},
methods: {
/**
* 选中菜单
* @param menuInfo
*/
select(menuInfo){
console.log(menuInfo)
const { name } = menuInfo
this.$router.push({
name
})
},
}
}
</script>
<style>
#components-layout-demo-fixed-sider .logo {
height: 32px;
background: rgba(255, 255, 255, 0.2);
margin: 16px;
font-size: 20px;
color: #ffffff;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
}
</style>
ChildAppLayout.vue,这里我们将注册微应用的代码抽成函数registerChildApp放到util中
<template>
<div>
<div id="micro-app"></div>
</div>
</template>
<script>
import Slider from '@/components/slider/slider'
import {registerChildApp} from '@/utils/util'
export default {
components:{
Slider
},
created () {
registerChildApp()
},
}
</script>
在util.js中写registerChildApp函数。
这里说下几个参数:
name: 子应用名称,这个注意必须和 子应用package.json里面的name一致,生产环境打包后,qiankun将会根据这个name查找子应用
entry:子应用入口地址。实际使用时本地开发可使用localhost,生产环境需要更改为子应用的实际访问路径,这里可以使用Node环境变量根据环境赋值。在本示例中,子应用访问不仅不是根路径,是yl-qiankun-child-vue下,因此entry要携带/yl-qiankun-child-vue
container: 子应用加载容器,全局唯一id值
activeRule: 加载子应用的跟路径。这里确保和子应用访问路径的yl-qiankun-child-vue一致。否则有坑
import { initGlobalState, registerMicroApps, runAfterFirstMounted, start } from 'qiankun'
import Vue from 'vue'
/**
* 注册子应用
*/
export const registerChildApp = () => {
// // 注册子应用
registerMicroApps(
[
{
name: 'ylQiankunChildVue', // 微应用应用名称,同微应用的package.json中的name一致
entry: '//localhost:6532/yl-qiankun-child-vue/',
container: '#micro-app', //父级应用装载子应用的容器id
activeRule: '/yl-qiankun-child-vue', //加载子应用的跟路径
},
],
{
beforeLoad: [
app => {
console.log('beforeLoad');
}
],
beforeMount: [
app => {
console.log('beforeMount');
}
],
beforeUnmount: [
app => {
console.log('beforeUnmount');
}
],
afterUnmount: [
app => {
console.log('afterUnmount');
}
]
}
)
// setDefaultMountApp(firstApp)
// 第一个子应用加载完毕后回调
runAfterFirstMounted(()=>{
console.log('第一个子应用加载完毕后的回调')
})
// 通讯
// 将action对象绑到Vue原型上,为了项目中其他地方使用方便
Vue.prototype.$qiankunActions = initGlobalState({menuClick:'init',})
start({prefetch :'all'})
}
子应用改造
子应用不需要安装qiankun
- 基于vue 的子应用改造
注意这里的子应用基于 vue-ant-design-pro创建,所以带有些默认的文件。另外子应用访问路径放在 yl-qiankun-child-vue 下,这里需要配合配置vue.config.js
基于vue的子应用的改造主要有:
添加public-path.js 文件;改写router路由导出文件;权限文件permission.js;main.js文件;组件容器BasicLayout.vue文件
首先添加public-path.js。在和main.js同级处新建public-path.js,内容如下
if (window.__POWERED_BY_QIANKUN__) {
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}
如果这段代码引发了你的eslient报错,请将相关规则关闭,我这边就将camelcase设为了off
第二,改造main.js
这里说下 main.js改造的关键地方:
1)顶部引入 public-path.js
2)将路由注册移植到main.js中。这里影响可能比较大,这样的话,以前引入router文件的地方,都得改造,比如permission.js
3)增加qiankun 需要导出的三个生命周期函数 bootstrap,mount,unmount
详细代码如下:(这里ant-design-pro创建携带的代码无关的已忽略删除)
// with polyfills
import './public-path' // 引入公共路径
import Vue from 'vue'
import App from './App.vue'
import store from './store/'
import i18n from './locales'
import Router from 'vue-router'
import antBootstrap from './core/bootstrap'
import { constantRouterMap } from '@/config/router.config'
import routerEach from '@/permission'
const originalPush = Router.prototype.push
Router.prototype.push = function push (location, onResolve, onReject) {
if (onResolve || onReject) return originalPush.call(this, location, onResolve, onReject)
return originalPush.call(this, location).catch(err => err)
}
Vue.use(Router)
Vue.config.productionTip = false
let router = null
let instance = null
function render() {
router = new Router({
base: '/yl-qiankun-child-vue/',
routes:[...constantRouterMap],
mode: 'history',
})
routerEach(router) // 路由监听要在 new Vue 之前
instance = new Vue({
store,
router,
i18n,
created: antBootstrap,
render:h=>h(App),
}).$mount('#app')
}
// 生命周期 - 挂载前
export async function bootstrap (props) {
console.log('one bootstrap')
console.log(props)
}
// 生命周期 - 挂载后
export async function mount(props) {
// console.log('one mount');
props.onGlobalStateChange((state,prev)=>{
console.log(state)
})
// 渲染
render()
}
// 生命周期 - 解除挂载
export async function unmount(){
instance.$destroy()
instance.$el.innerHTML = ""
instance = null
router = null
}
// 本地调试
if(!window.__POWERED_BY_QIANKUN__){
render()
}
关于base: '/yl-qiankun-child-vue/'这行,如果不使用qiankun和使用qiankun,路径不一致,则需判断环境,可以写成base: window.POWERED_BY_QIANKUN ? '/yl-qiankun-child-vue/' : '/' 类似
关于routerEach,由于permission.js 已经无法直接引入router文件获取router对象,这里改为导出函数,传递router方式。详见permission.js改造
第三,改造permission.js(无关的忽略)
import store from './store'
import storage from 'store'
import NProgress from 'nprogress' // progress bar
import '@/components/NProgress/nprogress.less' // progress bar custom style
import notification from 'ant-design-vue/es/notification'
import { setDocumentTitle, domTitle } from '@/utils/domUtil'
import { ACCESS_TOKEN } from '@/store/mutation-types'
import { i18nRender } from '@/locales'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
const allowList = ['login', 'register', 'registerResult'] // no redirect allowList
const loginRoutePath = '/user/login'
const defaultRoutePath = '/list'
const routerEach = (router) => {
router.beforeEach((to, from, next) => {
// 全局路由守卫逻辑代码,太占位置,省略。。。
})
router.afterEach(() => {
NProgress.done() // finish progress bar
})
}
export default routerEach
重点就是将router.beforeEach和afterEach部分抽成一个导出的函数routerEach,并且传入router对象参数
第四,改造vue.config.js
这里改造的重点:
配置输出
config.output.library = name
config.output.libraryTarget = 'umd' // 把微应用打包成 umd 库格式
config.output.jsonpFunction = `webpackJsonp_${name}`
允许跨域
headers: {
'Access-Control-Allow-Origin':'*'
},
完整代码:
const path = require('path')
const webpack = require('webpack')
const {name} = require('./package') //引入包名
module.exports = {
publicPath:'/yl-qiankun-child-vue/',
configureWebpack: config => {
// preview.pro.loacg.com only do not use in your production;
if (process.env.VUE_APP_PREVIEW === 'true') {
// add `ThemeColorReplacer` plugin to webpack plugins
config.plugins.push(createThemeColorReplacerPlugin())
}
// 为了让主应用能正确识别微应用暴露出来的一些信息,微应用的打包工具需要增加如下配置
config.output.library = name
config.output.libraryTarget = 'umd' // 把微应用打包成 umd 库格式
config.output.jsonpFunction = `webpackJsonp_${name}`
// if prod, add externals
config.externals = isProd ? assetsCDN.externals : {}
},
devServer: {
port:6532, // 子应用的端口最好设置特别点,不容易被覆盖
// 允许被主应用跨域fetch请求到
headers: {
'Access-Control-Allow-Origin':'*'
},
open: false, //配置自动启动浏览器
https: false,
hotOnly: false,
},
}
第五,改造BasicLayout.vue
由于本示例中,子应用是点击菜单加载的,也就是说在微前端模式下运行,子应用是不需要菜单和顶部的,我们根据qiankun环境变量window.POWERED_BY_QIANKUN来做更改。微前端模式下,去掉菜单和顶部,独自运行时需要菜单和顶部。
改造代码如下(这里其实改动很小,只需要一个v-if 和 v-else根据环境判断,为了不浪费篇幅,其他代码省略)
<template>
<router-view v-if="isQiankun"/>
<pro-layout
:menus="menus"
:collapsed="collapsed"
:mediaQuery="query"
:isMobile="isMobile"
:handleMediaQuery="handleMediaQuery"
:handleCollapse="handleCollapse"
:i18nRender="i18nRender"
v-bind="settings"
v-else
>
<!--里面代码省略-->
</pro-layout>
</template>
<script>
//引用及其他代码均省略,只展示isQiankun变量
export default {
name: 'BasicLayout',
data () {
return {
isQiankun:window.__POWERED_BY_QIANKUN__,// 是否时微前端qiankun环境
}
},
}
</script>
当然,如此改造比较粗糙,你会发现子应用独立运行和在微应用下运行时界面表现可能会有些不同。想要保持一致,还是得去掉 pro-layout,自己重构这个容器
一些报错和坑及注意事项及小知识
下面列举一些实践过程中可能出现的报错和坑
- 下面是子应用为 vue项目的报错
1.按照qiankun官网配置 webpack 的 output,报错 Invalid options in vue.config.js: "output" is not allowed。官网配置示例是这样的
[图片上传失败...(image-7f071a-1649416618198)]
这是因为比较高版本的webpack已经不允许这种写法,这个配置要放在configureWebpack中
const {name} = require('./package')
module.exports = {
configureWebpack: config=>{
// console.log(config)
config.output.library=name
config.output.libraryTarget= 'umd'
config.output.jsonpFunction=`webpackJsonp_${name}`
},
}
2.Error: single-spa minified message #20
[图片上传失败...(image-b71d38-1649416618198)]
这个问题多半是主应用 注册 子应用时,即调用 registerMicroApps 时第一个参数穿得不对,对比官方demo检查下第一个参数数据
3.跨越 Access to fetch at 'http://localhost:6532/' from origin 'http://localhost:8000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
Uncaught (in promise) TypeError: application 'ylQiankunChildVue' died in status LOADING_SOURCE_CODE: Failed to fetch
ncaught TypeError: application 'ylQiankunChildVue' died in status LOADING_SOURCE_CODE: Failed to fetch
[图片上传失败...(image-8c9ada-1649416618198)]
1)配置子应用的时候,要运行父级应用跨域访问子应用。否则父级应用拿不到数据无法读取子应用文件。这个在qiankun快速上手部分没有说,但是在 qiankun官网 项目实战部分有配置。要加上devServer 配置 headers配置,设置为 Access-Control-Allow-Origin: '*'
const { name } = require('./package');
module.exports = {
devServer: {
headers: {
'Access-Control-Allow-Origin': '*',
},
},
};
2)检查子应用是否有public-path文件,main.js里面是否引入pablic-path.js
[图片上传失败...(image-721b16-1649416618198)]
main.js 最顶部,记得要放最顶部
import './public-path' // 引入公共路径
如果做了两点配置还有跨越报错,那就很可能是主应用的entry url 设置不对
4.开发环境下,子应用反向代理无效
因为在微前端模式下,子应用的资源是主应用通过http请求获取后在主应用解析的,所以开发环境下的反向代理,只有主应用的配置生效。如果子应用的方向代理配置和主应用原本的不一致,需要在主应用配置子应用需要的代理
5.application 'ylQiankunChildVue' died in status NOT_MOUNTED: [qiankun]: Target container with #micro-app not existed after ylQiankunChildVue mounted!
[图片上传失败...(image-4d11f5-1649416618198)]
这个报错的意思是注册的时候,容器 #micro-app 不存在,子应用死亡。这个错误真的也很常见,造成的原因可能不止一个。反正也很坑。
1)对于主应用是vue的项目,等dom加载完成后再注册,this.$nextTick里面注册
-
由于vue-cli项目创建的时候,都会有个 id="app",这个是个坑,记得把父级的所有id="app"去掉。否则一直报这个错误让你无解。有些项目并不是直接用vue-cli创建的,而是基于一些做好的布局框架,比如本文的主应用就是基于vue-ant-design-pro 创建点,这个库创建的项目在App.vue里面还有个id="app",记得去掉,或者改为和main.js里面使用的一致。否则坑的让你找不着北
[图片上传失败...(image-c1f435-1649416618198)]
3)上面两点都检查过了,那就看看 容器id ,比如本文的 #micro-app 是否存在重复总的一点就是确保各注册应用id唯一,子应用容器id唯一
更多问题解决参考 qiankun 常见问题,里面有很多踩过的坑,会有不少帮助
6.主应用和子应用使用同一套开发框架,比如都用基于vue 的element-ui,如何保持主题一致?
答案就是如果主应用设定了自己的主题(非element-ui默认主题),那么子应用则不需要设置主题,那么子应用就会使用主应用的主题样式。当然这个时候如果子应用单独运行,那主题就是element-ui的默认主题了,想要单独运行时不使用默认主题,接入微应用时使用主应用的主题,可以根据window.POWERED_BY_QIANKUN判断环境来加载主题
7.从子项目页面跳转到主项目自身的页面时,主项目页面的 css 未加载
在全局路由守卫router.beforeEach处做处理,示例:
/**
* 解决从子项目页面跳转到主项目自身的页面时,主项目页面的 css 未加载的 bug
* 产生这个问题的原因是:在子项目跳转到父项目时,子项目的卸载需要一点点的时间,在这段时间内,父项目加载了,插入了 css,但是被子项目的 css 沙箱记录了,然后被移除了。父项目的事件监听也是一样的,所以需要在子项目卸载完成之后再跳转。
*解决方案:先复制 HTMLHeadElement.prototype.appendChild 和 window.addEventListener ,路由钩子函数 beforeEach 中判断一下,如果当前路由是子项目,并且去的路由是父项目的,则还原这两个对象
*/
const rawAppendChild = HTMLHeadElement.prototype.appendChild
const rawAddEventListener = window.addEventListener
router.beforeEach((to, from, next) => {
if(/^\/yl-qiankun-child-vue\//.test(from.path) && !/^\/yl-qiankun-child-vue\//.test(to.path)){
// 用路径来判断是否是从子项目跳转到主项目
HTMLHeadElement.prototype.appendChild = rawAppendChild
window.addEventListener = rawAddEventListener
}
next()
})
你真的需要微前端吗
最后,还是想谈下,我们真的要使用微前端吗?
个人觉得技术是要选则最合适自身业务的,不是为了使用技术而选技术,也不是为了追随潮流而使用技术。必须得考虑目前业务,技术团队情况,以及以后业务和技术团队可能的发展。拒绝教科书式照搬,还是得实事求是。
参照qiankun技术圆桌:你可能并不需要微前端中的总结:
满足以下几点,你可能就不需要微前端
- 你/你的团队 具备系统内所有架构组件的话语权
简单来说就是,系统里的所有组件都是由一个小的团队开发的。 - 你/你的团队 有足够动力去治理、改造这个系统中的所有组件
直接改造存量系统的收益大于新老系统混杂带来的问题。 - 系统及组织架构上,各部件之间本身就是强耦合、自洽、不可分离的
系统本身就是一个最小单元的「架构量子」,拆分的成本高于治理的成本。 - 极高的产品体验要求,对任何产品交互上的不一致零容忍
不允许交互上不一致的情况出现,这基本上从产品上否决了渐进式升级的技术策略
文章参考:
《对比多种微前端方案》
MicroFrontends