浅说微前端(2020年团队内部分享笔记)
3W 字预警 !
前言
因为目前有时间了,所以在整理一下自己这几年写过的一些东西的相关文档,准备把一些东西改一下发出来,有的内容可能并不复杂,甚至有点浅显,但是也是对自己这几年的一些复盘和总结了
其实笔者是认为,如果非业务实际需要,在考虑新业务新项目的搭建时其实不一定需要使用微前端的框架,实际上微前端框架本身实际上也加重了项目的管理难度和复杂度,在大多数情况下,iframe 和 联邦模式其实就已经能解决很多相关问题 而且笔者认为 monorepo 多包管理架构很适合这种情况下的使用
如果有需要,转载前请向我确认
序言
为什么要做这个分享,为什么选了微前端为主题的分享?
因为大佬说初来乍到做个技术分享可以让更多人认识一下自己,所以就搞一下
至于为什么选择了微前端呢,一是因为我翻了下KB好像每个方面大家都做了很详细的分享,只有微前端这块的资料相对来说比较少,而且自己本身也对这块有一定的了解,所以便选定了这块内容
这个分享会涉及什么样的内容?
这个分享主要是想 1.简单地对微前端进行一个介绍 2.简单地对微前端架构所适用的业务场景进行一个简单的讨论 3.简单地分析一下微前端架构的好与坏 4.简单地探讨一下如果要使用微前端架构需要考虑什么方面的问题 5.简单地分析一下目前流行的微前端实现方案,以及每个方案的特点和好坏 6.简单地探讨一下我们应该如何拆分子项目 7.简单地交流一下微前端架构对我们目前的业务和项目有什么意义 8.简单地说一说qiankun.js这个框架的基础和实践 9.简单地说说微前端一般会遇到什么坑
这个分享想要达到什么目的?
当然是想要大家简单地知道微前端是个什么样的东西啦
什么是微前端
来源
微服务的思想和概念一开始是为了解决后端服务不断扩展带来的一系列例如项目过大导致的复杂度不可控、难以维护、单一进程容错率低,故障可能在进程内扩散导致服务全局上的不可用、扩展困难等等的问题。
而随着前端的不断发展,越来越重的前端工程也面临着同样的问题,于是便自然而然地将微服务的思想应用到了前端,所以便有了微前端的概念。
概念
微服务与微前端,都是希望将某个单一的单体应用,转化为多个可以独立运行、独立开发、独立部署、独立维护的服务或者应用的聚合,从而满足业务快速变化及分布式多团队并行开发的需求。
如果要用一句话来概括,微前端就是将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的小块,而在用户看来仍然是内聚的单个产品的技术或思想。
为什么是微前端
提问(微前端能解决什么问题)
在考虑为什么要在项目中引入某项技术或某种思想的时候,一定是离不开实际应用场景来探讨,那么基于这点,我将会基于此提出几个问题来和大家进行交流。
问题1 如何处理问题众多的远古项目?
如果你忽然面临一个问题,公司由于业务需求需要重启一些已经多年没有维护的项目,并且这些项目存在以下的情况:
- 所使用的技术栈落后、较为小众或学习成本过高,例如Foundation、angular1.x、Easy Framework、svelte
- 基于原生 / jquery 开发 且架构复杂的大型应用,组织、耦合混乱,不敢乱动,牵一发动全身
- 项目强行混用多种技术栈,采用react、angular、jq、vue框架混合开发,功能复杂繁多,导致项目结构复杂、代码量巨大
- 重构不彻底的代码,经历了重构-烂尾,然后又重构-又烂尾的神仙项目
- 项目里面部分模块是反编译出来的代码以及第三方的代码 (别问我为什么能想到,问就是碰过,哭~)
你应该如何保证在逐渐重构的同时,既要保证中间版本能够平滑过渡,同时又要持续交付新的功能?
问题2:如何保证项目的活力?
你们准备开一个新项目,老板看惯了前端的风起云涌技术更迭,只给了架构上一个要求:"如何确保这套技术方案在 3~5 年内还葆有生命力,不会在 3、5 年后变成又一个遗产项目?"
问题3:如何避免代码库不断膨胀而带来的各种问题?
面对一个需要持续迭代新增功能/模块的项目,代码库的膨胀以及应用的复杂度的不断提升是可以直接看到的未来,而这也会带来一些可 预见的问题,例如:
1、过于复杂
由于代码的不断膨胀,使得这个系统会变得过于膨大和复杂,会直接导致任何一个开发者都很难理解它的全部,这样就会使得修复版本中的遗留问题或者新增功能变得非常困难和浪费时间,有可能会导致错过交付的时间,也会使得引入新人的成本变得非常高,更可怕的是这种复杂性也很容易形成一个恶性循环:
最后这个项目就会成为一坨屎山,大家都只能往屎山上堆屎
2、开发速度缓慢
项目越来越大就会导致把IDE搞得非常慢,每一次的启动都需要用很长的时间,构建的时间也变得越来越长。(甚至全局搜索一个字符串都要等好久)
这个周期需要的时间会越来越长,导致团队效率也会直线下降
3、需要更多的开发人员来维护
由于项目的不断扩大以及功能不断增多,就直接导致需要不断增加研发团队规模从而导致团队管理成 本的不断提高以及开发效率持续下降的后果(题外话:这里推荐一下《人月神话》) 那么我们应该如何避免这件事的发生呢?
4、从代码的提交到实际部署的周期越来越长,并且很容易出问题 项目越来越大也会导致把程序更改部署到生产环境的时间变得越来越长,在每次新功能的上线和部署都是头痛的问题
就好像我们的H5项目一样,即使是可以采用只开启开发模块入口的形式来避免 后果2 的出现
但是到了每次打包部署的时候都需要等待较长时间,并且这个时间也会随着模块的不断增加而增长。
并且只多个开发人员同时修改同一代码库的时候很容易会带来非常痛苦的冲突、解决以及分支合并的过程
那我们如何才能每天都能中业务时间内对应用进行多次修改并且能快速完成打包部署呢?
并且如果项目并非H5页面这类模块间耦合度较低的微应用化项目项目
那么每次代码的修改都要意味着测试要进行大量的测试 才能保证应用的可靠性,如果测试出问题,排查和修复也需要大量时间。
5、难以交付可靠的单体应用
系统庞大复杂 -> 无法进行全面而彻底的测试 -> 代码中的错误会进入生产环境 -> 因为程序中的代码都在同一进程中运行,应用缺乏故障隔离 -> 可能出现一些例如:内存泄漏 之类的问题导致用户运行过久后崩溃之类的问题
而node写的中间件的实例则有可能崩溃导致大半夜因为生产环境的问题爬起来改bug
6、需要长期依赖某个可能已经过时的技术栈,并且框架难以升级新的版本
团队不得不长期去使用一套相同的技术栈
想要对原有框架的升级和新技术的尝试和采用都变得不可能,就好像后台管理项目一样 antd 都已经出道4.x的版本了,然而该项目用的还是 antd 2.x ,并且不敢对其进行升级以免影响大量旧有功能,导致了在开发新功能时,很多新的特性无法使用。
又或者某个模块使用vue进行开发的成本远低于react时,无法切换和使用 或者公司一直用的是angular 1.x 或 2.x 但是新的模块期望能用上更新版本的特性
问题4 项目如何多线并行开发
如果有一个项目有多个业务模块需要多个开发团队并行开发,如何避免不同模块间的冲突,以及大量的开发分支合并容易导致的冲突和代码审核的成本?
使用微服务的形式拆分,每个子系统只需要处理自己子系统的分支和并和代码审核,并且每个子系统间可以专注当前功能的开发,与其他子系统间的耦合只需要规定好相应的通讯方式和内容,不需要关注对方的实现。
问题5 应用功能如何自由组合拆分及定制化开发
在我上一家公司有一个这样的需求,领导想开一个新的项目,这个项目基础版本既定了6个大模块系统,每个模块下划分又划分有各子功能模块,用于服务市场、投放、设计、运营等部门,并且后期有模块有不断新增的可能。
领导还要求这个项目要可以对每个模块的内容进行随机自由组合以及定制化开发,以便后期拥有进行出售的可能。 在这种情况下其实就很适合使用微前端了
问题6 如何能更好地处理一些试验性需求?
例如想要对项目的某些模块进行新技术的尝试或框架的升级,又或者想针对某个模块的功能进行试验性的改动,要求要能随时拆装,例如运营部门想要上线一个运营活动,希望能够依靠用户的反馈信息进行调整,在用户反馈不好或者出现非常不好的负面反馈的时候可以快速下线该模块。
又或者想做AB测试,想尝试在同一个模块使用不同方案来测试不同的用户反馈
小结:微前端能解决什么问题?
- 兼容遗留系统
- 可以与时俱进,不断引入优秀的新技术/新框架
- 支持局部/增量升级,避免项目每次发布都是全量发布,即使是上线一个小模块,也可能导致整个项目挂掉
- 代码简洁、解耦、更易维护
- 子应用间可以自由组合拆分
- 技术栈无关,各子应用的开发团队可自行采取熟悉的技术栈进行开发,降低开发时间和成本
- 微前端可以采用组件或者服务的方式进行团队间的技术共享
- 每个应用可以独立部署,独立运行,拥有故障隔离
......
这些核心优势与微服务的优势基本是一致的,除此之外,还有很多实际情况没有列举完毕,不过没关系,只要我们明白了微前端的特点,就能判断任何情况。
使用微前端可能会面临的问题(缺点)
有得必有失,在引入微前端的过程中难免会引入一些新的问题,就像尤雨溪直播的时候说的一样,想要用一种技术/框架/思想解决所有问题是不太现实的事情,如果使用不当,很多时候优势反而有可能会变成劣势,但是我们可以根据实际业务情况来经过评审后选择是否使用微前端这个架构以及相关的思想和技术
以我浅短的经验来说,使用微前端可预见的问题有:
1.重复依赖 不同应用之间依赖的包存在很多重复,由于各应用独立开发、编译和发布,难免会存在重复依赖的情况。导致不同应用之间需要重复下载依赖,额外再增加了流量和服务端压力。 对于这个问题我在后面会介绍公共依赖库怎么提取共享,但是这个操作并不建议
2.团队之间容易分裂 大幅提升的团队自治水平可能会让各个团队的工作愈加分裂。各团队只关注自己的业务或者平台功能,在面向用户的整体交付方面,会导致对用户需求和体现不敏感,和响应不及时。
3.操作复杂 (1)开发体验不太友好,开发时可能需要同时启动多个项目来获得整个流程的完整体验 (2)跟踪和调试问题需要跨全部系统
本质上讲,算是通过前端的简单性换取整个系统(前端/后端)的复杂性。
4.性能处理的挑战性 对于不同的实现方案,性能处理上很难得到一定的保障,特别如果是使用了iframe的方式来实现微前端。
5.拆分的粒度越小,便意味着架构变得复杂、维护成本变高
6.技术栈一旦多样化,便意味着技术栈混乱,系统变得更复杂,更难维护
7.管理版本复杂、依赖复杂
这些问题大多是因为项目拆分成多个项目之后,引发的沟通协作问题。
简单的单体架构与简单的微前端架构间的区别
其实说起架构,很有趣的一件事就是,软件的架构其实对功能性的需求影响不大,甚至完全没有架构设计的一团糟的项目也有可能是一个成功的项目。
这时候我们就不得不说起对项目的非功能性需求(质量属性/其他能力)的考量了,而架构的设计正是为了关注这点而出现的,而这个维度影响最为直接的就是软件交付速度的可维护性、可扩展性、可测试性、可移植性,不过我们这里的讨论重点不在于这块,就不对此展开讨论了。
如果是一个优秀的团队,它的团队成员或许可以通过很努力地维护他们的模块化应用,也可以编写全面的的自动化测试来减缓项目陷入单体地狱的速度,但是他们无法避免大型团队在单体饮用上的协同工作和沟通的问题,也没办法解决日益过时的技术栈的问题,最后大家依旧是容易沦为在屎山上堆屎,所以为了逃避单体地狱的问题,对于大型项目来说迁移到微服务架构就成了一个必要的事情。
不过由于软件的架构不是凭空想象,而是在实践的基础上不断地总结出来的,需要考虑的内容非常多,例如如何定义该系统与其他系统间的关系,如调用关系、依赖关系、如何定义系统内各个子系统间的关系,比如前后端如何通信、应用内架构如何设计,包括应用相关的框架和组件、规范和原则怎么设计,才能更好指导开发人员编写代码,设计层次怎么分层,系统级、应用级、模块级、代码级间的设计需要考虑什么。
而我的经验并不算丰富,这块内容仅以我当前所知与大家进行非常浅显简单的探讨。
下面我们就来简单地说一说他们简单形态下的区别:
传统单体应用
微前端架构
微前端架构有两种架构模式:(注意,我写这份文档的时候是2020年) 1.基座模式:通过一个主应用,来管理其它应用。设计难度小,方便实践,但是通用度低。 2.自组织模式:应用之间是平等的,不存在相互管理的模式。设计难度大,不方便实施,但是通用度高。
基座模式架构
其实在这两者间还有一种类微前端架构,就是前端微应用化,关于这块的内容会在下面进行简单讲述
目前流行的的微前端方案有那些,如何选型?(注意,当时写的时候是2020年)
根据上面介绍的关于微前端的介绍,想要实现相关的特点有很多现成的方案,下面我们就来简单地介绍一下各个方案之间的优缺点以及潜在的问题
流行方案介绍
路由分发式(实际上就只是做了个nginx 的代理转发.....)
路由分发式的微前端解决方案应该是目前为止最简单的一个了,简单来说就是通过路由在多个独立的前端应用之间跳转,一般是用nginx之类的HTTP服务器的反向代理来实现,或者用框架自带路由解决。
http {
server {
listen 80;
server_name care.seewo.com;
location /xx/xx/ {
proxy_pass http://admin-care-dev.test.seewo.com/web/edu/;
}
location /xx/xx {
proxy_pass http://admin-care-dev.test29.seewo.com/web/home/;
}
location /xx/xx {
proxy_pass http://admin-care-dev.test298.seewo.com/web/user/;
}
location /xx/xx {
proxy_pass http://admin-care-dev.test298.seewo.com/web/profile/;
}
location / {
proxy_pass /;
}
}
}
在这个示例里,不同的页面的请求被分发到不同的服务器上。
适用场景
- 不同技术栈之间差异比较大,难以兼容、迁移、改造
- 项目不想花费大量的时间在这个系统的改造上
- 现有的系统在未来将会被取代
- 系统功能已经很完善,基本不会有新需求
优点
- 框架无关
- 独立开发、部署、运行
- 应用间百分百隔离
- 实现简单
缺陷
- 应用间的彻底隔离会导致复用非常困难 例如每个应用的导航栏如果想在其他系统中复用将非常困难
- 用户体验非常差,每个独立应用只在跳转之后加载,时间较长,容易出现白屏
- 如果后续有同屏多应用拓展的需求将会非常困难
思考
1.能否使用history.replaceState(stateObj, title[, url])和history.pushState(state, title, url)这两个HTML5中History对象新增的两个api实现页面无刷新更新,并且url上不会出现#。
2.把主项目和子项目共用资源通过固定的方式引用
iframe
对,不用怀疑,你没看错,iframe也可以是微前端实现方案的一个解,甚至我相信大家在上面刚看完了微前端的定义的时候,脑子里可能跳出的第一反应就是iframe
iframe在MDN上的定义如下:
相信大家作为前端来说,对iframe这个标签一定都不陌生,这个在大家印象中古老且普通标签有时候非常的管用,我相信大家都肯定拿它做过不少骚操作,或者拿它来嵌套一些第三方的应用。
iframe 可以创建一个全新的独立的宿主环境,这意味着我们的前端应用之间可以相互独立运行,那么我们用它来进行微服务化的拆分,设计微服务架构的基础就有了!!!
甚至很多陈年的老前端项目就是这么解决微前端适用的场景的问题的
如果我们要用iframe来设计我们的微前端架构,那么有两点一定是逃不掉的:
1.应用管理机制,包括:
- 生命周期的设计,什么时候加载,什么时候卸载,加载前要做什么,卸载前要做什么,这个过程要怎么过渡才会自然
- 子应用间要如何管理
2.应用通讯机制,包括:
- 父子应用间如何通讯,如何保证通讯时iframe已经加载完成
- 消息总线要如何设计
无论是使用postMessage还是通过 iframeEl.contentWindow 去获取 iFrame 元素的 Window 对象,又或者是直接父页面调用子页面方法:FrameName.window.childMethod(); 子页面调用父页面方法:parent.window.parentMethod();来通讯都并不是太过友好的事情,需要设计一套规范的通讯标准,实在过于麻烦
并且iframe存在着诸多问题,所以在实际的项目中其实我并不是很建议纯粹以iframe来设计你的微前端架构.......
我这里仅仅只是做一个简单的介绍,我希望你不要想不开真的只用它来设计你的微前端架构。
适用场景
- 不用考虑seo
- 拥有相应的应用管理机制
优点
- 框架无关
- 独立开发、部署、运行
- 应用间百分百隔离
- 实现简单
缺陷
1.最恐怖的一点是:一个iframe等于打开一个新的网页,所有的JS/CSS全部加载一遍,内存会*2,无法释放,典型的内存泄露,假如大量子应用同屏,后果怎么样不用我说了吧。
2.iframe和主页面共享连接池,而浏览器对相同域的连接有限制,所以会影响页面的并行加载,出现iframe中的资源占用了可用连接而阻塞了主页面的资源加载
3.iframe会阻塞主页面的Onload事件,及时触发 window 的 onload 事件是非常重要的。onload 事件触发使浏览器的 “忙” 指示器停止,告诉用户当前网页已经加载完毕。当 onload事件加载延迟后,它给用户的感觉就是这个网页非常慢。window的onload 事件需要在所有 iframe 加载完毕后(包含里面的元素)才会触发,就会影响网页加载速度。通过 JavaScript 动态设置 iframe的SRC可以避免这种阻塞情况,但是也很麻烦
4.对移动端极其不友好,多数小型的移动设备(PDA 手机)无法完全显示框架
5.根本没法进行seo优化
6.有着各种奇奇怪怪的特性,在布局上很容易出现各种问题,而且体验不大好
7.资源的共享和功能模块的复用极其困难
前端微服务化(主要)
前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完整的前端应用。
用这个办法可以一个页面上同时存在二个及以上的前端应用在运行。而不像上面的路由分发式方案,一个页面只有一个应用。
因为这个是我们主要要讨论的一种实现方式,所以在这里就不过多描述,在下面再一一细说
微件化
微件(widget),指的是一段可以直接嵌入在应用上运行的代码,它由开发人员预先编译好,在加载时不需要再做任何修改或者编译。而微前端下的微件化则指的是,每个业务团队编写自己的业务代码,并将编译好的代码部署(上传或者放置)到指定的服务器上,在运行时,我们只需要加载相应的业务模块即可。对应的,在更新代码的时候,我们只需要更新对应的模块即可。
大家都是前端,我想大家也都想过,能不能有一个技术可以在不同技术栈之间的子模块可以互相调用不同技术栈的组件,而这部分的内容讲述的就是相关的内容,我们只需要根据我们的需求调用相关功能的组件,我们不需要管他是 react ,vue或者是angular写的.你只管用,只知道他是一个组件就好了,不用关心太多,对于团队的组件积累,无疑是有极大好处的。
设想一个场景
一般情况下,一个公司的前端团队的技术栈都是统一的.但也有前端团队使用不统一技术栈的时候. 比如:
1.时代的变迁,升级技术栈导致内部技术栈不统一
2.项目众多,因为需求不一致,其他的技术栈对于项目更加有力
3.…其他管理原因
当我们已经使用微前端架构来构建我们的项目的时候,我们的子模块有可能因为我们项目的需求导致使用了其他的技术栈
如果我们使用了其他的技术栈,我们原来封装的组件就不能在新的项目中用了,所以我们需要要求组件可以跨框架共享使用,那么我们要怎么做呢?
我们该怎么做?
这里有提到微件仓库模块,这是一个单独的项目。你可以理解是以前的旧项目,当你需要这个旧项目的某一个组件的时候,可以直接从这个项目里面拿。
你也可以做成一个只提供组件的项目,毕竟在运行时一个子模块挂载到我们的项目中来是没有任何资源消耗的。
我们只要知道我们需要的组件从哪里来就行了,然后根据组件还有之前定义好的路由找到这个组件,调用他,使用他就好了。
而我们的组件可以基于web components来封装,达到不同技术栈共用的目的,目前所有的框架都支持让组件以webcomponent的形式存在。
react: react.docschina.org/docs/web-co…
vue : github.com/vuejs/vue-w…
angular: www.angular.cn/guide/eleme…
思考
如果一个页面依赖了很多跨框架的组件,必然出现网络方面的性能问题,那我们可以怎么去处理呢?
很简单的就是,在请求的中间加一层node服务,当页面请求多个跨框架的组件的时候,我们的node就会合并成单个文件,并且保存在硬盘上。
当这个页面被请求过之后,页面零散的组件便会合并在一起,第二次其他用户请求就不会有这种合并文件的处理,直接返回静态资源给客户端。
这种方式也不会对nodejs有太多额外的压力。
微应用化
前端微应用化,简单来说就是在开发和运行时,应用都是以单一、微小应用的形式存在,在开发时都是独立应用,在构建时又可以按照需求单独加载。如果以微前端的单独开发、单独部署、运行时聚合的基本思想来看,微应用化就是微前端的一种实践,但是从上面讲过的微前端的特点来讲,这个方案似乎离微前端又有点远。
微应用化更多的是以软件工程的方式,来完成前端应用的开发,因此又可以称之为组合式集成。对于一个大型的前端应用来说,采用的架构方式,往往会是通过业务作为主目录,而后在业务目录中放置相关的组件,同时拥有一些通用的共享模板。
其实本质上来说,我们的H5项目也可以算作前端微应用化。
web components
作为一个算得上是前沿的技术,目前困扰 Web Components 技术推广的主要因素,在于浏览器的支持程度。
在 Chrome 和 Opera 浏览器上,对于 Web Components 支持良好,而对于 Safari、IE、Firefox 浏览器的支持程度,并没有那么理想。
不过这也有解决方案,我们可以对于不支持的浏览器可以进行polyfill
而如果我们想要只用web components来设计我们的微前端架构需要考虑什么问题呢?
我们可以按照以下的思路去思考:
1、使用自定义元素,不限定使用的框架类型,只要是最终渲染为html即可。
2、如何处理组件的生命周期事件?可以使用自定义元素的生命周期
3、熟悉各个版本的浏览器对web components 的支持情况,对于不支持的浏览器可以进行polyfill
4、了解支持web components的框架,所幸的是我们目前主流框架都有支持
react: react.docschina.org/docs/web-co…
vue : github.com/vuejs/vue-w…
angular: www.angular.cn/guide/eleme…
5、如果有服务端渲染的需求,使用自定义元素配合服务端进行渲染
6、微前端化遇到多页应用时,会有切换页面的问题,一个微应用内的路由切换很容易,但是应用与应用的路由切换就比较麻烦。我们可以全局管理路由,通过一个全局的壳子,进行路由的配置和切换。缺点是需要共享全局代码。
各方案对比(小结)
没写!!!
技术方案选型流程
应该如何拆分应用?
常见的拆分方案
与微服务类似,要划分不同的前端边界,不是一件容易的事。就当前而言,以下几种方式是常见的划分微前端的方式:
按照业务拆分
- 如:一个电商后台,包括商品管理、商家管理、物流管理等。
- 优势是独立出每个业务项目,可以让整个项目结构清晰。
按照权限拆分
- 如:一个运营后台,管理员和普通运营看到的页面是不一样的。
- 优势是独立出不同的权限项目,可以突出每个项目的使用范围。
按照变更的频率拆分
-
如:一个项目中,包含很少改动的祖传项目和经常改动的业务项目。
-
优势是
1.独立出变更频繁的项目,可以避免频繁更新可能导致整体项目挂掉的风险。
2.独立出很少改动的项目,可以让我们在核心业务上大展拳脚。
按照组织结构拆分
- 一个功能复杂的项目后台,由多个团队共同开发而成。
- 优势是独立出不同团队的项目,可以避免开发冲突,部署冲突等问题。
跟随后端微服务拆分
- 如:后端已经做了不同模块的微服务划分,前端可以直接按照后端来就行了。
- 这种方式有利于前后端保持统一。
到了这里,我们已经完成了微前端的拆分,但并不是拆完了就结束了,我们还考虑一些拆分后的问题。
例如:
- 主项目和子项目的样式是否需要复用?
- 项目权限,是分开还是在统一管理?
- 应用之间如何进行通信?
这些问题往往和业务相关,我们在改造时自行判断即可。
关于部署流程的考虑
最后一步,我们需要考虑部署流程。
当微前端开发完成之后,我们的项目由 1 个变成了 1 + n(子项目) 个,部署方式势必会发生变化。
传统的部署方式如下:
微前端项目部署方式如下:
开发环境的部署
开发环境其实不需要部署,通常是前端启动一个 localhost 页面,去调后端的接口进行开发。
需要注意的是:
- 子项目需支持独立启动,而不是在开发时启动所有项目,只需启动与业务相关的子项目即可。
- 需要有办法一键启动想要的项目,以便开发
上线环境部署
对于线上环境来说,运行的是 1 个主项目和 n 个子项目,每个项目都是独立部署且相互独立的,非常适合容器化部署,即:每一个项目都被部署到一个 容器 中,彼此通过主项目进行关联。
如图,所有的子项目都交由主项目管理。
如果公司内部做了持续部署,部署就会更加简单。
qiankun.js 的基础介绍与实践,手把手教你用qiankun.js (2020年版)
qiankun.js是一套完整的微前端解决方案,取名 qiankun,意为统一。是希望通过 qiankun 这种技术手段,让开发者能很方便的将一个巨石应用改造成一个基于微前端架构的系统,并且不再需要去关注各种过程中的技术细节,做到真正的开箱即用和生产可用。
api
qiankun.js 仅仅只有 10个 api !!!
- registerMicroApps(apps, lifeCycles?)
- start(opts?)
- setDefaultMountApp(appLink)
- runAfterFirstMounted(effect)
addErrorHandler/removeErrorHandler
addGlobalUncaughtErrorHandler(handler)
removeGlobalUncaughtErrorHandler(handler)
项目结构
参考官方的examples代码,项目根目录下有基座main和其他子应用sub-vue,sub-react,搭建后的初始目录结构如下:
├── common //公共模块
├── main // 基座
├── sub-react1 // react子应用1
└── sub-vue2 // vue子应用2
基座配置
因为我们的技术栈是以react为主,并且基座的配置大多一致,只是为子应用的挂载提供一个节点,所以即使是不使用框架也没有问题,所以这里仅仅介绍react的基座配置方式,我这里是以create-react-app来配置基座
安装 qiankun
$ yarn add qiankun # 或者 npm i qiankun -S
在主应用中注册微应用
我先在src下新建一个目录micro存放qiankun.js相关配置的文件,在micro新建一个文件app.ts处理子应用相关配置信息,index.js处理基座相关配置信息
/src/micro/app.ts
const domain = 'localhost'
const apps = [
{
name: 'react_child', //子注册应用名 - 具有唯一性
entry: `//${domain}:10100`, //微应用入口 - 通过该地址加载微应用
container: '#app_micro', //微应用挂载节点 - 微应用加载完成后将挂载在该节点上
activeRule: '/', //微应用触发的路由规则 - 触发路由规则后将加载该微应用
props: { //用于传递信息给子应用
msg: 'react_child',
routerBase: '/react_child' //可以下发路由给子应用,子应用根据该值去定义qiankun环境下的路由
}
},
{
name: 'child_vue',
entry: `//${domain}:10101`,
container: '#app_micro_vue',
activeRule: '/',
props: {
msg: 'child_vue' ,
routerBase: '/child_vue'
}
},
]
export default apps
问题:如果想动态添加子应用要怎么办?
/src/micro/index.js
import {
registerMicroApps, //注册微应用
addGlobalUncaughtErrorHandler, //添加全局的未捕获异常处理器
setDefaultMountApp, //设置默认装载子应用
runAfterFirstMounted, //第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本
// initGlobalState, //定义全局状态
// MicroAppStateActions,
start
} from 'qiankun'
function startQiankun(apps = []) {
registerMicroApps(apps, {
beforeLoad: [
app => {
console.log("before load", app);
}
],
beforeMount: [
app => {
console.log("before mount", app);
}
],
afterMount: [
app => {
console.log("afterMount", app);
}
],
beforeUnmount: [
app => {
console.log("before unmount", app);
}
],
afterUnmount: [
app => {
console.log("afterUnmount", app);
}
],
})
addGlobalUncaughtErrorHandler(event => {
const { msg } = event
// 加载失败时提示
if (msg && msg.includes('died in status LOADING_SOURCE_CODE')) {
alert('微应用加载失败,请检查应用是否可运行')
}
})
setDefaultMountApp('/'); //设置默认装载子应用
runAfterFirstMounted(() => { //第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本
console.log('第一个微应用 mount 完毕')
});
start()
/*
Options prefetch - boolean | 'all' | string[] | (( apps: RegistrableApp[] ) => { criticalAppNames: string[]; minorAppsName: string[] }) - 可选,是否开启预加载,默认为 true, 配置为 true 则会在第一个微应用 mount 完成后开始预加载其他微应用的静态资源 配置为 'all' 则主应用 start 后即开始预加载所有微应用静态资源 配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源 配置为 function 则可完全自定义应用的资源加载时机 (首屏应用及次屏应用)
sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true。
singular - boolean | ((app: RegistrableApp<any>) => Promise<boolean>); - 可选,是否为单实例场景,单实例指的是同一时间只会渲染一个微应用。默认为 true。
fetch - Function - 可选,自定义的 fetch 方法。
getPublicPath - (entry: Entry) => string - 可选,参数是微应用的 entry 值。
getTemplate - (tpl: string) => string - 可选。
excludeAssetFilter - (assetUrl: string) => boolean - 可选,指定部分特殊的动态加载的微应用资源(css/js) 不被 qiankun 劫持处理。
*/
}
export default startQiankun
然后在项目的index.tsx里面导入两个配置文件让项目启动的时候加载qiankun.js
app.tsx
import React from 'react';
import './App.css';
function App() {
return (
<div className="App">
<div className="base_app"> 我是基座 </div>
<div className="react_chiled1" id="app_micro"> </div>
<div className="vue_child" id="app_micro_vue"> </div>
</div>
);
}
export default App;
子应用配置
1.通过npx create-react-app sub-react新建一个react应用。
2.新增.env文件添加PORT变量,端口号与父应用配置的保持一致。
.env
PORT=10100
3.为了不eject所有webpack配置,我们用react-app-rewired方案复���webpack就可以了。
- 首先npm install react-app-rewired --save-dev
- 新建sub-react/config-overrides.js
const { name } = require('./package.json'); //注意package.json中的name要和基座中注册的子应用name要一样
module.exports = {
webpack: function override(config, env) {
// 解决主应用接入后会挂掉的问题:https://github.com/umijs/qiankun/issues/340
console.log(config.entry,'config.entry')
// config.entry = config.entry.filter(
// (e) => !e.includes('webpackHotDevClient')
// );
config.output.library = `${name}-[name]`;
config.output.libraryTarget = 'umd';
config.output.jsonpFunction = `webpackJsonp_${name}`;
return config;
},
devServer: (configFunction) => {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.open = false;
config.hot = false;
config.headers = {
'Access-Control-Allow-Origin': '*',
};
return config;
};
},
};
- 修改package.json文件中的启动方式
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject"
},
4.新增src/public-path.js。
if (window.__POWERED_BY_QIANKUN__) { // eslint-disable-next-line __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__; }
5.改造index.js,引入public-path.js,添加生命周期函数等。
import React from 'react';
import ReactDOM from 'react-dom';
import './public-path'
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
// bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
// 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
export async function bootstrap() {
console.log('react app bootstraped');
}
// 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
export async function mount(props) {
console.log(props)
console.log('主应用传递过来的值,在这里通过 props接收')
render();
}
// 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
export async function unmount(props) {
ReactDOM.unmountComponentAtNode(props.container ? props.container.querySelector('#app_micro') : document.getElementById('root'));
}
// 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
export async function update(props) {
console.log('update props', props);
}
function render(props) {
console.log(props);
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
props?props.querySelector('#app_micro'):document.getElementById('react_child')
);
}
// 子应用独立运行判断,如果不想子应用可以独立运行,注释掉render就行
if (!window.__POWERED_BY_QIANKUN_PARENT__) {
// render();
}
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
qiankun.js 一般会需要考虑什么问题
通讯问题
浏览器api
由于qiankunshi采用HTML Entry,localStrage、cookie可共享。
props
主应用通过props,传递给子应用
{
name: "react-sub", // 应用名称
entry: "//localhost:3000", // 应用地址
container: "#subContainer", // 嵌入主应用位置
activeRule: "/sub1", // 匹配规则
props: {
aa: 11,
},
},
子应用在生命周期中获取
export async function bootstrap(props) {
}
export async function mount(props) {
}
export async function unmount(props) {
}
微前端的全局状态管理
qiankun通过initGlobalState, onGlobalStateChange, setGlobalState实现主应用的全局状态管理,然后默认会通过props将通信方法传递给子应用。先看下官方的示例用法:
主应用:
// main/src/main.js
import { initGlobalState } from 'qiankun';
// 初始化 state
const initialState = {
user: {} // 用户信息
};
const actions = initGlobalState(initialState);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
子应用:
// 从生命周期 mount 中获取通信方法,props默认会有onGlobalStateChange和setGlobalState两个api
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
这两段代码不难理解,父子应用通过onGlobalStateChange这个方法进行通信,这其实是一个发布-订阅的设计模式。
如果我们直接使用官方的这个示例,那么数据会比较松散且调用复杂,所有子应用都得声明onGlobalStateChange对状态进行监听,再通过setGlobalState进行更新数据。
因此,我们很有必要对数据状态做进一步的封装设计
但是这里就不做过多的讨论了
子应用切换Loading处理
子应用首次加载时相当于新加载一个项目,还是比较慢的,因此loading是不得不加上的。
import React from 'react';
import ReactDOM from 'react-dom';
/**
* 渲染子应用
*/
function Render(props) {
const { loading } = props;
return (
<>
{loading && <h4 className="subapp-loading">Loading...</h4>}
<div id="subapp-viewport" />
</>
);
}
export default function render({ loading }) {
const container = document.getElementById('subapp-container');
ReactDOM.render(<Render loading={loading} />, container);
}
抽取公共代码
不可避免,有些方法或工具类是所有子应用都需要用到的,每个子应用都copy一份肯定是不好维护的,所以抽取公共代码到一处是必要的一步。
根目录下新建一个common文件夹用于存放公共代码,例如request.js和sdk之类的工具函数等
公共代码抽取后,其他的应用如何使用呢? 可以让common发布为一个npm私包,npm私包有以下几种组织形式:
- npm指向本地file地址:npm install file:../common。直接在根目录新建一个common目录,然后npm直接依赖文件路径。
- npm指向私有git仓库: npm install git+ssh://xxx-common.git。
- 发布到npm私服。
需要注意的是,由于common是不经过babel和pollfy的,所以引用者需要在webpack打包时显性指定该模块需要编译
子应用支持独立开发
微前端一个很重要的概念是拆分,是分治的思想,把所有的业务拆分为一个个独立可运行的模块。
从开发者的角度看,整个系统可能有N个子应用,如果启动整个系统可能会很慢很卡,而产品的某个需求可能只涉及到其中一个子应用,因此开发时只需启动涉及到的子应用即可,独立启动专注开发,因此是很有必要支持子应用的独立开发的。
但是如果要支持,主要会遇到以下几个问题:
1.子应用的登录态怎么维护?
2.基座不启动时,怎么获取到基座下发的数据和能力?
在基座运行时,登录态和用户信息是存放在基座上的,然后基座通过props下发给子应用。但如果基座不启动,只是子应用独立启动,子应用就没法通过props获取到所需的用户信息了。因此,解决办法只能是父子应用都得实现一套相同的登录逻辑。为了可复用,可以把登录逻辑封装在common中,然后在子应用独立运行的逻辑中添加登录相关的逻辑。
import { store as commonStore } from 'common'
import store from './store'
if (!window.__POWERED_BY_QIANKUN__) {
// 这里是子应用独立运行的环境,实现子应用的登录逻辑
// 独立运行时,也注册一个名为global的store module
commonStore.globalRegister(store)
// 模拟登录后,存储用户信息到global module
const userInfo = { name: '我是独立运行时名字叫张三' } // 假设登录后取到的用户信息
store.save('global/setGlobalState', { user: userInfo })
render()
}
// ...
export async function mount (props) {
console.log(' props from main framework', props)
commonStore.globalRegister(store, props)
render(props)
}
// ...
!window.__POWERED_BY_QIANKUN__表示子应用处于非qiankun内的环境,即独立运行时。此时我们依然要注册一个名为global的 module,子应用内部同样可以从global module中获取用户的信息,从而做到抹平qiankun和独立运行时的环境差异。
子应用独立仓库
随着项目发展,子应用可能会越来越多,如果子应用和基座都集合在同一个git仓库,就会越来越臃肿。
若项目有CI/CD,只修改了某个子应用的代码,但代码提交会同时触发所有子应用构建,牵一发动全身,是不合理的。
同时,如果某些业务的子应用的开发是跨部门跨团队的,代码仓库如何分权限管理又是一个问题。
基于以上问题,我们不得不考虑将各个应用迁移到独立的git仓库。
由于我们独立仓库了,项目可能不会再放到同一个目录下,因此前面通过npm i file:../common方式安装的common就不适用了,所以最好还是发布到公司的npm私服或采用git地址形式。
子应用独立仓库后聚合管理
子应用独立git仓库后,可以做到独立启动独立开发了,这时候又会遇到问题:开发环境都是独立的,无法一览整个应用的全貌。
虽然开发时专注于某个子应用时更好,但总有需要整个项目跑起来的时候,比如当多个子应用需要互相依赖跳转时,所以还是要有一个整个项目对所有子应用git仓库的聚合管理才行,该聚合仓库要求做到能够一键install所有的依赖(包括子应用),一键启动整个项目。
这里主要考虑了几种方案:
1.使用git submodule。
2.使用git subtree。
3.单纯地将所有子仓库���到聚合目录下并.gitignore掉。
4.使用lerna管理。
- 使用git submodule。
- 使用git subtree。
单纯地将所有子仓库放到聚合目录下并.gitignore掉
git submodule和git subtree都是很好的子仓库管理方案,但缺点是每次子应用变更后,聚合库还得同步一次变更。
考虑到并不是所有人都会使用该聚合仓库,子仓库独立开发时往往不会主动同步到聚合库,使用聚合库的开发者就得经常做同步的操作,比较耗时耗力,不算特别完美。(而且麻烦 - -)
所以第三种方案或许更加合适。
聚合库相当于是一个空目录,在该目录下clone所有子仓库,并gitignore,子仓库的代码提交都在各自的仓库目录下进行操作,这样聚合库可以避免做同步的操作。
由于ignore了所有子仓库,聚合库clone下来后,仍是一个空目录,此时我们可以写个脚本scripts/clone-all.sh,把所有子仓库的clone命令都写上:
# 子仓库一
git clone git@xxx1.git
# 子仓库二
git clone git@xxx2.git
然后在聚合库也初始化一个package.json,scripts加上:
"scripts": { "clone:all": "bash ./scripts/clone-all.sh", },
这样,git clone聚合库下来后,再npm run clone:all就可以做到一键clone所有子仓库了。
一键install和一键启动整个项目
我们参考qiankun的examples,使用npm-run-all来做这个事情。
1.聚合库安装npm i npm-run-all -D。
2.聚合库的package.json增加install和start命令:
"scripts": {
...
"install": "npm-run-all --serial install:*",
"install:main": "cd main && npm i",
"install:sub-vue": "cd sub-vue && npm i",
"install:sub-react": "cd sub-react && npm i",
"start": "npm-run-all --parallel start:*",
"start:sub-react": "cd sub-react && npm start",
"start:sub-vue": "cd sub-vue && npm start",
"start:main": "cd main && npm start"
},
npm-run-all的--serial表示有顺序地一个个执行,--parallel表示同时并行地运行。
配好以上,一键安装npm i,一键启动npm start。
子应用互相跳转
除了点击页面顶部的菜单切换子应用,我们的需求也要求子应用内部跳其他子应用,这会涉及到顶部菜单active状态的展示问题:sub-vue切换到sub-react,此时顶部菜单需要将sub-react改为激活状态。有两种方案:
- 子应用跳转动作向上抛给父应用,由父应用做真正的跳转,从而父应用知道要改变激活状态,有点子组件$emit事件给父组件的意思。
- 父应用监听history.pushState事件,当发现路由换了,父应用从而知道要不要改变激活状态。
由于qiankun暂时没有封装子应用向父应用抛出事件的api,如iframe的postMessage,所以方案一有些难度,不过可以将激活状态放到状态管理中,子应用通过改变store中的值让父应用同步就行,做法可行但不太好,维护状态在状态管理中有点复杂了。
方案二,子应用跳转是通过history.pushState(null, '/sub-react', '/sub-react')的,因此父应用在mounted时想办法监听到history.pushState就可以了。
由于history.popstate只能监听back/forward/go却不能监听history.pushState,所以需要额外全局写一下history.pushState事件。
如何部署
官方文档 : qiankun.umijs.org/zh/cookbook…
qiankun.js 一般会遇到什么样的坑
react子应用启动后,主应用第一次渲染后会挂掉
子应用的热重载会引得父应用直接挂掉。相关的issues/340,需要在复写react的webpack时禁用掉热重载(加了下面配置禁用后会导致没法热重载,react应用在开发时得手动刷新)
module.exports = {
webpack: function override(config, env) {
// 解决主应用接入后会挂掉的问题:https://github.com/umijs/qiankun/issues/340
config.entry = config.entry.filter(
(e) => !e.includes('webpackHotDevClient')
);
// ...
return config;
}
};
jQuery 老项目的资源加载问题
一般来说,jQuery 项目是不经过 webpack 打包的,所以没法通过修改 publicPath 来注入路径前缀。后面两种方法操作起来比较麻烦,或者说我们应该优先从框架本身解决这个问题,而不是其他方法。
方案一:动态插入 < base > 标签
html 有一个原生标签 ,这个标签只能放在 里面,它的 href 属性是一个 url 值。 mdn 地址: base 文档根 URL 元素
设置了 标签之后,页面上所有的链接和 url 都基于它的 href。例如页面访问地址是 www.taobao.com ,设置 之后,页面中原本的图 < img src="./img/jQuery1.png" alt="" >的实际请求地址会变成 www.baidu.com/img/jQuery1… ,页面上的< a >链接:< a href="/about" />,点击之后,页面会跳转到:www.baidu.com/about
可以看到, 标签和 webpack 的 publicPath 有一样的效果,那么能否在 jQuery 项目加载之前,把 jQuery 项目的地址赋给 标签,然后插入到< head > ?这样就可以解决 jQuery 项目的资源加载问题。
做法也很简单,在 qiankun 提供的 beforeLoad 生命周期,判断当前是否是 jQuery 项目:
beforeLoad: app => {
if(app.name === 'purehtml'){
const baseTag = document.createElement('base');
baseTag.setAttribute('href',app.entry);
console.log(baseTag);
document.head.appendChild(baseTag);
}
},
beforeUnmount: app => {
if(app.name === 'purehtml'){
const baseTag = document.head.querySelector('base');
document.head.removeChild(baseTag);
}
}
这样做子项目资源可以正确加载,但是 < base >标签的威力太强大了,会导致所有的路由无法正常跳转,跳转到其他的子项目时,< a >链接是基于< base > 的,会跳转到 jQuery 子项目的不存在的路由。解决了一个 bug ,又出现了新的 bug ,这样是不行的。所以这个方案可行性特别小
方案二:劫持标签插入函数
这个方案分两步:
1.对于 HTML 中已有的 img/audio/video 等标签,qiankun 支持重写 getTemplate 函数,可以将入口文件 index.html 中的静态资源路径替换掉
2.对于动态插入的 img/audio/video 等标签,劫持 appendChild 、 innerHTML 、insertBefore 等事件,将资源的相对路径替换成绝对路径
前面我们说到,对于子项目是 HTML entry 的,qiankun 拿到入口文件 index.html 之后,会用正则匹配到 标签及其内容, 中的 link/style/script/meta 等标签,然后插入到父项目的容器中。
我们可以传递一个 getTemplate 函数,将图片的相对路径转为绝对路径,它会在处理模板时使用:
start({
getTemplate(tpl,...rest) {
// 为了直接看到效果,所以写死了,实际中需要用正则匹配
return tpl.replace('<img src="./img/jQuery1.png">', '<img src="http://localhost:3333/img/jQuery1.png">');
}
});
对于动态插入的标签,劫持其插入 DOM 的函数,注入前缀。
假如子项目动态插入一张图:
const render = $ => {
$('#purehtml-container').html('<p>Hello, render with jQuery</p><img src="./img/jQuery2.png">');
return Promise.resolve();
};
主项目劫持 jQuery 的 html 方法:
beforeMount: app => {
if(app.name === 'purehtml'){
// jQuery 的 html 方法是一个挺复杂的函数,这里只是为了看效果,简写了
$.prototype.html = function(value){
const str = value.replace('<img src="/img/jQuery2.png">', '<img src="http://localhost:3333/img/jQuery2.png">')
this[0].innerHTML = str;
}
}
}
不过碰到 jquery的老项目,个人建议混合使用 iframe 来嵌套进来比较合适
js 沙箱并不能解决所有的 js 污染
例如我用 onclick 或 addEventListener 给 添加了一个点击事件,js 沙箱并不能消除它的影响,所以说,还得靠代码规范和自己自觉
keep-alive 需求
qiankun 框架不太好实现 keep-alive 需求,因为解决 css/js 污染的办法就是删除子项目插入的 css 标签和劫持 window 对象,卸载时还原成子项目加载前的样子,这与 keep-alive 相悖: keep-alive 要求保留这些,仅仅是样式上的隐藏。
安全和性能的问题
qiankun 将每个子项目的 js/css 文件内容都记录在一个全局变量中,如果子项目过多,或者文件体积很大,可能会导致内存占用过多,导致页面卡顿。
另外,qiankun 运行子项目的 js,并不是通过 script 标签插入的,而是通过 eval 函数实现的,eval 函数的安全和性能是有一些争议的:MDN的eval介绍
qiankun.js核心源码解析
没写
如何设计一个微前端框架(需要考虑什么问题?)
参考
《前端架构:从入门到微前端》
转载自:https://juejin.cn/post/7377357813981429798