简单聊聊微前端
什么是微前端?
微前端是一种前端架构模式,它将一个庞大的前端应用拆分为多个独立、小型的应用,这些小型应用可以独立开发、独立运行、独立部署,但对用户而言,它们仍然是一个统一的整体。这种架构模式主要是为了解决传统单体应用在大型项目中遇到的问题,如代码冗余、开发效率低下、部署风险高等。
为什么要用微前端?
-
模块化与解耦:
微前端强调模块化,每个微应用都是一个独立的模块,这使得代码更加清晰、易于维护。通过将前端应用拆分为多个独立的子应用,可以实现业务逻辑的解耦,降低系统的复杂性。
-
提高开发效率:
微前端架构允许不同团队并行开发各自的微应用,从而缩短了开发周期。由于微应用可以独立部署,因此无需等待其他团队的开发进度,即可快速上线新功能。
-
降低部署风险:
在传统的单体应用中,每次部署都涉及整个应用的更新,风险较高。而微前端架构下,每次只需部署更新的微应用,降低了部署的风险和影响范围。
-
技术栈灵活性:
微前端架构不限制接入的微应用的技术栈,这意味着团队可以根据自身需求和技术储备选择合适的技术栈进行开发。这种灵活性有助于团队尝试新技术、保持技术栈的更新和多样性。
-
渐进式重构与升级:
对于遗留系统或大型项目,微前端提供了一种渐进式重构和升级的策略。通过逐个替换或升级微应用,可以逐步实现整个系统的现代化改造。
-
更好的用户体验:
微前端架构有助于优化前端性能,如减少首次加载时间、提高页面响应速度等,从而提升用户体验。通过动态加载和卸载微应用,可以实现更细粒度的资源管理和优化。
行业解决方案?
-
基于路由分发的微前端方案:
这种方案通过配置路由来分发请求到不同的微应用。每个微应用可以独立开发、测试和部署,而在用户看来仍然是内聚的单个产品。此方案的优点包括简单、快速和易配置,但可能在切换应用时触发浏览器刷新,影响体验。
-
基于iframe的微前端方案:
iframe作为一种古老的技术,可以轻松地从独立的子页面构建页面,提供天然的隔离性。这种方案的优点是实现简单、技术不限制,但缺点是可能存在Bundle大小各异、SEO不友好、URL状态不同步、DOM结构不共享以及全局上下文完全隔离等问题。
-
基于Web Components的微前端方案:
Web Components是浏览器的原生组件,允许创建可重用的用户界面小部件。这种方案的优点包括技术栈无关、独立开发和应用间隔离。然而,由于Web Components的浏览器和框架支持不够广泛,可能需要更多的polyfills,且重写现有的前端应用和系统架构可能较为复杂。MicroApp:
特点:由京东出品,基于WebComponent的思想实现的微前端框架。它轻量、高效,且提供了js沙箱、样式隔离、元素隔离、预加载等一系列完善的功能。优势:使用起来成本较低,不需要修改子应用的渲染逻辑或webpack配置,接入微前端成本较低。此外,它无任何依赖,体积小巧,扩展性高。适用场景:适合需要快速集成不同技术栈子应用的项目。
-
基于Module Federation的微前端方案:
Module Federation是webpack5新增的功能,可以帮助将多个独立的构建组成一个应用程序。这种方案的优点包括开箱即用、独立开发与部署、去中心化和组件共享。但缺点是可能无法提供沙箱隔离、技术单一(仅限使用webpack5以上版本)、代码封闭性高以及拆分粒度需要权衡。EMP(Esm Module Federation):
特点:基于Webpack 5 Module Federation特性进行二次封装,特别优化了对ESM(ECMAScript Modules)的支持。它允许多个应用共享模块,子应用可以在不重新构建的情况下被主应用加载和使用。优势:完全支持ESM模块系统,减少模块解析开销,提高加载效率。相比原生的Module Federation,EMP配置更简便。不足:学习曲线存在,虽然配置简化,但依然需要掌握Module Federation的核心概念。此外,技术栈有限制,需要使用Webpack 5,且社区支持相对较少。
-
中心基座方案(如qiankun等):
中心基座方案是目前主流的微前端采用的技术方案之一。qiankun:
基于single-spa进行二次开发,提供了更加开箱即用的API、样式隔离、JS沙箱和资源预加载等功能。这种方案的优点是技术栈无关、易于集成和管理微应用,但可能需要注意沙箱隔离的完善性和性能优化。Single-spa:
特点:Single-spa是最早的微前端框架,它允许多个前端框架应用(如Vue、React、Angular)同时工作在同一个页面上。每个子应用可以使用不同的框架,技术栈灵活。优势:提供了依赖共享机制,避免多个应用加载相同的依赖包,且生态完善,有丰富的社区插件和工具支持。不足:学习曲线较陡峭,配置较为复杂,需要专门学习,且在同时加载多个子应用时性能可能受影响。Garfish:
特点:字节跳动推出的微前端框架,专注于轻量级和高性能的解决方案。它无需复杂的配置即可使用,适合快速开发,且支持多种前端框架。优势:性能优越,适合对速度有要求的项目。同时提供了技术栈无关的支持,灵活性高。不足:相对于其他成熟的微前端方案,Garfish的社区支持和文档相对较少,且在某些复杂场景下可能需要额外的开发工作。
-
自由框架组合模式
qiankun
一、概念
qiankun,意为“乾坤”,是阿里巴巴开源的一个微前端框架。它通过HTML Entry的方式接入微应用,使得接入过程像使用iframe一样简单。在qiankun中,主应用负责加载和管理子应用,而子应用则是独立的前端应用,可以独立开发、部署和运行。
二、原理
- 路由劫持与应用加载:qiankun基于single-spa实现了路由劫持和应用加载。当浏览器的URL发生变化时,qiankun会匹配到相应的子应用并进行加载。
- 样式隔离:qiankun实现了两种样式隔离方式。一种是严格的样式隔离模式,通过为每个微应用的容器包裹上一个shadow dom节点来实现。另一种是通过动态改写css选择器来实现,类似于css scoped的方式。
- JS沙箱:qiankun的JS沙箱分为两种实现方式。在主流浏览器中(支持Proxy),使用基于Proxy的多实例沙箱实现。在不支持Proxy的浏览器中,则使用基于diff的沙箱实现。这些沙箱确保了子应用的JS执行环境相互隔离,防止了冲突和污染。
- 资源预加载:qiankun实现了资源的预加载策略,即在浏览器空闲时间预加载未打开的微应用资源,从而加速微应用的打开速度。
- 应用间通信:qiankun通过发布订阅模式来实现应用间通信。每个应用在初始化时会生成一套通信方法,用于更改全局状态和注册回调函数。当全局状态发生改变时,会触发各个应用注册的回调函数执行。
三、优缺点
优点:
- 技术栈无关:qiankun允许任意技术栈的应用接入,无论是React、Vue、Angular还是其他框架,都可以轻松集成。
- 简单易用:qiankun提供了开箱即用的API和丰富的生命周期函数,使得微前端的开发和管理变得简单高效。
- 性能优化:通过资源预加载和应用间通信机制,qiankun优化了微应用的加载速度和性能表现。
- 社区支持:作为阿里巴巴开源的项目,qiankun拥有强大的社区支持和广泛的实践案例。
缺点:
- 样式隔离的局限性:虽然qiankun实现了样式隔离,但在某些复杂场景下,仍可能出现样式冲突或覆盖的情况。这需要开发者在使用时注意样式的管理和规划。
- 学习成本:虽然qiankun简化了微前端的开发过程,但对于初次接触微前端的开发者来说,仍需要一定的学习成本来理解和掌握其原理和使用方法。
- 框架依赖:qiankun是基于single-spa进行封装的,因此在使用qiankun时,也需要对single-spa有一定的了解和认识。这可能会增加一些额外的学习负担。
实践 - 伪代码
基座
做一套基座的容器 - vue为例
MicroPage 组件页面内容变化的核心区域
1 <!-- content 微前端 页面内容变化的核心区域 --> 2 <div class="unusual-container" v-show="loadErrorInfo.isError"> 3 <!-- 错误兜底页面 --> 4 <ErrorPage :subTitle="loadErrorInfo.errorMessage" :status="loadErrorInfo.errorStatus" /> 5 </div> 6 <!-- 无报错 资源正常加载展示 MICRO_APP_CONTAINER_ID 微前端容器id--> 7 <div class="micro-view-container" :id="MICRO_APP_CONTAINER_ID" v-show="!loadErrorInfo.isError"> 8 <template v-for="item in microList" :key="`${item.entryKey}`"> 9 <div :id="item.container" v-show="showContainer(item.container)" class="microContainer"></div> 10 </template> 11 <Skeleton active v-show="showContainer('empty')" /> 12 </div> 13
平台主题容器
1 <!-- layouts/content 通用的主题header 菜单等 --> 2 <div :class="[prefixCls, getLayoutContentMode]"> 3 <div v-show="isShowMicroPage"> 4 <MicroPage /> 5 </div> 6 <div v-show="!isShowMicroPage"> 7 <NormalPage /> 8 </div> 9 </div>
基座初始化配置
1 import { loadMicroApp, MicroApp } from 'qiankun' 2 3 4 // 加载并渲染微应用 5 const mountMicroApp = async () => { 6 // store中存了错误信息 7 if (isError) { 8 return 9 } 10 // 没有找到entry 11 if (!entry) { 12 setMicroAppLoadErrorInfo('set错误信息') 13 return 14 } 15 16 // 加载前预检一级路由是否有权限 17 if (!'权限' && !'报错' && !'校验一级路路由是否有权限') { 18 setMicroAppLoadErrorInfo('set错误信息') 19 return 20 } 21 22 // 清空错误信息 23 setMicroAppLoadErrorInfo('清空错误信息') 24 25 26 if (!microItem) { 27 console.log(`没有找到[${entry}]的微应用信息`) 28 return 29 } 30 31 32 const microAppInstance = loadMicroApp( 33 { 34 name, 35 entry: `${entry}/?__v=${new Date().getTime()}`, 36 container: microContainer, 37 props: { 38 // 跳转到指定路径对应的微应用 - 先存后跳, 39 token 40 permissionMap, 41 userInfo, 42 permissionEnum, 43 stationOrgList, 44 defaultStation, 45 homePath, 46 // microLogout: 处理登录逻辑的函数, 47 microEmitter, // 子工程用来通信的emitter 48 logSentryMsg, 49 logSentryError, 50 logBreadCrumb, 51 microDefHttp: defHttp, 52 parentWindow: window, 53 } as unknown as PassToMicroAppProps, 54 }, 55 { 56 excludeAssetFilter: (url) => url.indexOf('.baidu.com') !== -1, 57 58 }, 59 ) 60 61 microAppInstanceMap[entry] = microAppInstance 62 63 await microAppInstance.mountPromise 64 .then(() => { 65 // 权限校验 66 }) 67 .catch((err) => { 68 // ... 69 }) 70 } 71 72 // 切换显示子应用 容器 73 const changeContainer = async (entry, immediate) => { 74 if (!microItem) { 75 console.log(`没有找到[${entry}]的微应用信息`) 76 return 77 } 78 } 79 80 81 // 卸载微应用 82 const unmountMicroApp = async (entry) => { 83 if (!'如果store中和变量中都没有微应用实例,则不需要卸载') { 84 return 85 } 86 87 if (!'上个子工程加载异常不需要卸载') { 88 return 89 } 90 91 // 只有mount成功的app,才执行卸载 92 let mounted = true 93 await needUnmountApp!.mountPromise.catch(() => (mounted = false)) 94 if (!mounted) { 95 return 96 } 97 98 // 卸载失败时打印日志并继续 99 100 } 101 102 // 权限校验 103 const checkMicroAppPermission = (routePath: string) => { 104 if ('如果调试模式开启了,则直接跳过不校验') { 105 return 106 } 107 // 子工程回传的路由列表 108 // microLogger.info('校验权限路由实例:', findRouteInstance) 109 110 if ('如果子工程中回传的路由实例有ignoreAuth则不需要检验权限') { 111 return 112 } 113 if (!'路由权限校验') { 114 setMicroAppLoadErrorInfo(MicroAppErrorType.PAGE_NOT_ACCESS) 115 } 116 } 117 118 // 校验当前路有是否是存在注册列表中的 119 const checkIsExist = (routePath: string): boolean => { 120 if ('如果调试模式开启了,则直接跳过不校验') { 121 return true 122 } 123 124 if (!'当前路由从已注册的子工程列表中查找不存在') { 125 setMicroAppLoadErrorInfo('错误收集') 126 return false 127 } 128 return true 129 } 130 131 watch( 132 () => loadErrorInfo.value.isError, 133 (isError) => { 134 if (isError) { 136 // 重置是否是同一个entry ... 138 return 139 } 140 isLocalError = false 141 }, 142 ) 143 144 // 监听路由变更 145 watch( 146 () => currentRoute.value.path, 147 async (path: string) => {}, 148 ) 149 150 // 监听当前微应用的entry变更 151 watch( 152 () => entry, 153 async (entry: string) => {}, 154 { immediate: true }, 155 ) 156 157 // 监听消息盒子同一路由点击是否需要重新加载微应用 158 watch( 159 () => xxx, 160 async (xxx) => {}, 161 ) 162 163 onMounted(() => { 164 console.log('初始化微应用容器', '++++++++++++++++') 165 microEmitter.on('xxx', (tab) => {}) 166 })
基座内维护的子应用信息集合
1 // 下面例子 以 lol、cf、dnf 三款游戏作为三个子应用 举例 2 export const microBaseConfig = { 3 lol: { 4 entryKey: 'lol', 5 container: 'lolDom', 6 entry: { 7 dev: '', 8 testing: '', 9 staging: '', 10 prod: '', 11 }, 12 }, 13 cf: { 14 entryKey: 'cf', 15 container: 'cfDom', 16 entry: { 17 dev: '', 18 testing: '', 19 staging: '', 20 prod: '', 21 }, 22 }, 23 dnf: { 24 entryKey: 'dnf', 25 container: 'dnfDom', 26 entry: { 27 dev: '', 28 testing: '', 29 staging: '', 30 prod: '', 31 }, 32 }, 33 } 34 35 const microConfigMap: Record<string, MicroConfig> = {} 36 // 遍历对象的键 37 Object.keys(microBaseConfig).forEach((key) => { 38 const originalItem = microBaseConfig[key] 39 // 复制原始对象,避免直接修改 40 const newItem: MicroConfig = { 41 ...originalItem, 42 entryUrl: originalItem.entry[import.meta.env.MODE], 43 } 44 microConfigMap[key] = newItem 45 }) 46 47 export const microConfig = microConfigMap 48 export const microList: MicroConfig[] = Object.values(microConfigMap) 49 export const lolEntry = microConfigMap.lol.entryUrl 50 export const cfEntry = microConfigMap.cf.entryUrl 51 export const dnfEntry = microConfigMap.dnf.entryUrl 52 53 54 // 在基座注册的子应用路由 55 /** 56 * @description: 游戏首页面板 57 */ 58 const gameRoute: MicroAppInfoConfig = { 59 name: 'game', 60 path: '/game/index', 61 entry: lolEntry, 62 isMicroApp: true, 63 meta: { 64 icon: 'home|svg', 65 title: '首页', 66 groupTitle: '分组标题', 67 }, 68 } 69 70 /** 71 * @description: lol游戏中心 72 */ 73 const lolRoute: MicroAppInfoConfig = { 74 name: 'lol', 75 path: '/lol', 76 entry: lolEntry, 77 isMicroApp: true, 78 meta: { 79 icon: 'lolIcon|svg', 80 title: 'lol游戏中心', 81 groupTitle: '分组标题', 82 }, 83 children: [ 84 { 85 name: 'lol-list', 86 path: '/lol/list', 87 entry: lolEntry, 88 isMicroApp: true, 89 meta: { 90 title: '英雄列表', 91 }, 92 }, 93 { 94 path: '/lol/add', 95 name: 'lol-add', 96 entry: lolEntry, 97 isMicroApp: true, 98 meta: { 99 title: '创建英雄', 100 hideMenu: true, 101 currentActiveMenu: '/lol/list', 102 }, 103 }, 104 ], 105 } 106 107 /** 108 * @description: cf游戏中心 109 */ 110 const linkRoute: MicroAppInfoConfig = { 111 path: '/cf', 112 name: 'cf', 113 entry: cfEntry, 114 isMicroApp: true, 115 meta: { 116 icon: 'cf|svg', 117 title: 'cf管理', 118 groupTitle: '分组标题', 119 }, 120 children: [ 121 { 122 path: '/cf/list', 123 name: 'cfList', 124 entry: cfEntry, 125 isMicroApp: true, 126 meta: { 127 title: '武器总览库', 128 }, 129 }, 130 { 131 path: '/cf/detail', 132 name: 'cfDetail', 133 entry: cfEntry, 134 isMicroApp: true, 135 meta: { 136 title: '武器详情', 137 }, 138 }, 139 ], 140 } 141 142 /** 143 * @description: dnf游戏中心 144 */ 145 const dnfRoute: MicroAppInfoConfig = { 146 path: '/dnf/list', 147 name: 'dnf', 148 entry: dnfEntry, 149 isMicroApp: true, 150 meta: { 151 hideChildrenInMenu: true, 152 icon: 'dnf|svg', 153 title: 'dnf发展历史', 154 groupTitle: '分组标题', 155 }, 156 children: [ 157 { 158 path: '/dnf/list', 159 name: 'dnfList', 160 entry: dnfEntry, 161 isMicroApp: true, 162 meta: { 163 title: '发展历史', 164 currentActiveMenu: '/history', 165 }, 166 }, 167 { 168 path: '/dnf/detail', 169 name: 'dnf-detail', 170 entry: dnfEntry, 171 isMicroApp: true, 172 meta: { 173 title: '年况详情', 174 currentActiveMenu: '/history', 175 hideMenu: true, 176 }, 177 }, 178 ], 179 }
子应用代码库 main.js
1 import { qiankunWindow, renderWithQiankun } from 'vite-plugin-qiankun/dist/helper' 2 3 export const isQianKun = (() => { 4 const bool = qiankunWindow.__POWERED_BY_QIANKUN__ || !!qiankunWindow.name 5 console.log('isQianKun', bool) 6 return bool 7 })() 8 9 // 放到项目顶部 10 if (isQianKun) { 11 window['__webpack_public_path__'] = qiankunWindow.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ 12 } 13 14 // 正常逻辑 15 async function render() {} 16 17 // 初始化qiankun 18 const initQianKun = () => { 19 console.log('initQianKun', '子应用初始化') 20 renderWithQiankun({ 21 // @ts-ignore 22 bootstrap(props) { 23 console.log('微应用 vehicle:bootstrap', props) 24 }, 25 mount(props: PassToMicroAppProps) { 26 console.log('微应用 vehicle:mount', props) 27 window.parentWindow = props?.parentWindow 28 // 可以通过props读取基座传过来的数据 29 // ... 30 render(props) 31 props.onGlobalStateChange((state: QiankunStore, prev: QiankunStore) => { 32 console.log('task子应用onGlobalStateChange改变的state: ', state) 33 console.log('task子应用onGlobalStateChange改变的prev: ', prev) 34 }) 35 props.setGlobalState({ 36 currentMicroAppRoutes: dynamicRoutes, 37 }) 38 }, 39 unmount(props) { 40 console.log('微应用 vehicle:unmount', props) 41 app?.unmount() 42 }, 43 update(props) { 44 console.log('微应用 vehicle:update', props) 45 }, 46 }) 47 } 48 isQianKun ? initQianKun() : render()