微前端拆分实践
by Teobler on 13 / 09 / 2021
views
这篇文章是我一次活动分享的讲稿
最近项目上机缘巧合用微前端解决了一些团队问题,借此机会跟大家分享一下。
微前端作为近两年兴起的一种解决方案,也不是什么新东西了,既然是解决方案,那么微前端帮我们解决了什么问题呢?这里我以我们项目组为例子讲讲:
我们为什么需要微前端?
我们的项目整体来看算得上一个比较大型的项目,整个项目规划完成后有 17 条业务线。但是在刚起项目的时候由于种种原因并没有考虑周全,将项目当成一个普通的前端项目来解决,在第一期项目结束,第一条业务上线后,我们紧接着开始了第二和第三条业务线的开发,紧接着我们就遇到了一些问题:
代码冲突
一期项目上线后交由维护团队维护,交付团队继续后面项目的开发。由于所有代码在同一个 repo 中作为一个大型单体被共同维护,两个团队的代码修改常常有冲突,需要小心 merge。同时还需要理解对方的业务,看自己的业务会不会破坏对方的业务。
部署冲突
由于所有的基础设施包括 CI/CD 等都是公用的,任何一个团队想要部署自己的代码,势必会对另外一个团队造成影响,不管是 feature toggle 还是 chunk base 的开发方式都将增大开发人员的心智负担。
技术栈冲突
由于项目比较大,未来团队的数量不确定,我们不能将技术栈限制死,否则就有可能有的团队要使用自己完全不熟练的技术栈,更别说未来还有第三方团队加入的可能性,我们不希望将整个项目绑定在某一个技术栈上。
基于这样的背景,我们发现微前端这套解决方案很好地解决了我们的问题。说白了在我们的项目背景下,我们最希望得到的东西是 -- 团队自治。
我们希望各个业务线的团队能够自由修改自己的代码,不用担心与别的团队产生冲突。她们可以自由选择自己熟悉的技术栈,不必有过多限制。同时任何团队的部署都不会影响其他团队,这也就意味着某一个团队负责的部分如果挂掉了,网站上其他团队维护的部分也是可用的。
最重要的,这样的架构可以让各个团队聚焦在自己的技术和业务上,减少各个团队不必要的无效沟通,提升各个团队的开发效率。
拆分时机
对于微前端的拆分来说,这是一项工作量较大的技术改进,而且它不同于别的技术改进,它没有模版,没有办法按部就班的从网上找个东西过来照抄,必须要结合自己的项目来进行。
另一方面,我们需要达成共识的是,在我们的日常开发中,大多数情况下项目上不可能给开发人员足够的时间来做技术改进,这就意味着大多数技术改进需要同业务开发一同进行。那么找准一个改进的时机就很重要了。
那么这样的时机通常是什么时候呢?
业务有较大的改变或演进
这种情况我想大多数同学都经历过,在开发最初说的好好的需求,由于种种原因需要做一次大的改变。面对这种大的需求变更,通常我们的代码也需要做对应的改变,而这种改变也需要重写一些代码,这个重写的过程就是一个很好的进行拆分的好时机。
在这个期间我们有足够的理由说服项目干系人给我们时间去重新组织项目代码去更好地支持业务的发展。
业务稳定不再有大的改进
此时业务的发展趋于稳定,但目前的架构如果也的确给开发造成了阻碍。那么就可以在这个稳定架构上进行改进。当然此时的业务还在发展,我们可以采取两种策略:
- 一种是以拆分任务为高优先级,新的业务开发基于新的架构
- 一种是先在旧的架构上持续开发,在拆分的过程中由负责拆分的同学将业务和技术一起迁移过去
拆分原则
我们在拆分微前端的时候一定是带有某种目的的,有可能是想对技术栈进行渐进式升级,也有可能像我们一样想提升各个独立团队的自治力,在不同的目的下我们可能会秉持不同的原则,这也是另一个为什么微前端的拆分没办法简单抄作业的原因。
就我们项目来说,我们追求各个团队的最高自治力,那么我们就希望各个独立app尽量减少彼此的通信和依赖,每个app能够尽量独立处理自己的业务。
在这样的大前提下,我们可以按照业务为主模块为辅的方式指导拆分,基于此,我们定义了一些拆分时候的原则:
- 保证业务独立,一条业务线应该由一个独立的app来支撑,使得该业务团队拥有这个app的完全控制权
- 跨业务的页面不应该各个业务各自持有,也应该拆分为一个独立的app
- 通用方法库和通用组件库由大家共同维护以支撑各自的业务
拆分前的准备
前置概念
single-spa
Single-spa 是一个微前端框架,它不限制每一个 app 具体使用怎样的技术栈,主要通过控制 route 的方式在页面上渲染不同的 app。
在开始微前端的拆分前我们进行了一些调研后选择了它作为我们微前端的框架,说是调研其实当时我们并没有过多的了解每一个框架,比如国内比较有名的 qiankun。
这里其实有一个小插曲,我们第一个了解的框架就是 single-spa,当时有一个小需求 single-spa 实现不了,于是我按照官网的文档去 slack 询问,第二天一大早我就收到了回复,算上时差他们一看到我的问题就给了我答复,这个反馈速度加上对国内开源社区的不乐观,我们直接就选择了 single-spa。
In-broswer module vs build time module
在开始实践前,我可能需要给大家介绍两个概念以帮助大家更好地理解接下来的架构设计,第一个概念是 in-broswer module,或者叫做es6 modules,与之对应的是现在用途最广的 build time module,这两个module有什么区别呢?我们先来看一个图:
这个图里两个 js 文件互相引用后最后打包的结果就是 build time module。在写代码的时候虽然你觉得这两个文件是分离的,但是其实在最终打包的时候这两个文件里的内容会被合并,最终变成一个 js 文件,然后这个 js 文件被 html 文件引用。
in-broswer module 则不同,这种模块是浏览器根据你提供的 url 从网络中请求回来的,你的每一个 import 都代表了一次网络请求,各个文件真的变成了独立的模块,通过网络请求相互依赖。
但是这样的模块有一个缺点,就是它没有办法像我们日常开发一样直接给一个名字就能直接引用到对应的模块:
import singleSpa from "single-spa";
由于需要在网络中定位到这个模块在哪里病发送对应的请求,它需要一个完整的url:
import singleSpa from "https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js";
Import-map
这个特性使得大多数程序员都不喜欢它,毕竟大多数人都不想写一串长长的 url 来引用一个模块。为了解决这个问题,WICG 起草了一个新的浏览器规范,这个规范叫做 import map:
<script type="importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js"
}
}
</script>
import map 是一段特殊的 js,它的 type 为 importmap,在这个 script 标签里面的是一个 json object。这个 json object 的 key 就是某一个模块的名字,而它对应的 value 就是这个模块的 url 地址。
当然,既然 import-map 是一个 script 标签,那么理所应当它也可以加上 src 属性,成为一段外部 script:
<script type="importmap" src="https://some.url.to.your.importmap"></script>
在一些情况下,可能你的项目中引用了某一个包的不同版本,这时候可以用 import-map 的 scopes 功能来限制某一个文件的引用:
<script type="importmap">
{
"imports": {
"lodash": "https://unpkg.com/lodash@3"
},
"scopes": {
"/module-a/": {
"lodash": "https://unpkg.com/lodash@4"
}
}
}
</script>
这里的 scopes 代表了如果某一个 module 以 module-a 开头那么里面如果有引用 lodash 的 import,这个 import 将会引用 v4 版本,其他的 import 则都是引用的 v3 版本。
于是根据这个 import-map,我们就能够在代码里像使用正常模块那样使用 in-broswer module 了:
import singleSpa from "single-spa";
Systemjs
然后接下来就是前端传统节目,很显然,这么新的规范大部分浏览器目前都是不支持的,更别提永远也不可能支持的 IE 了,所以我们需要 polyfill - systemjs,它怎么工作的这里为了不扯远就不再赘述了,感兴趣的同学可以通过链接去 github 里面看文档,总的来说这是一个专门为了 es-module 而生的 polyfill。
我们从一个简单的 demo 来看它是怎么让 import-map 工作的:
这是一个很简单的 demo,HTML 页面中留有一段 template,然后导入一份 es-module,这份 module 也很简单,只做了一件事就是导入 vue 然后把 template 里面的 name 换成我们想要的东西。
但是这里有一个细节,我们在导入 vue 的时候必须用一段 url 来导入,如果我们把这段 url 换成我们平时开发时的字符串会发生什么呢?
这里会发生这样的错误是因为我们在 script 标签上标记了这个 script 是一个 es-module,于是里面的 import 关键字是浏览器在运行时执行的,但是因为后面的字符串没办法告诉浏览器 Vue 这个资源到底在哪,浏览器当然也就找不到对应的资源,于是就报错了。
如果我们想要将 url 替换为我们平时开发时候的字符串,就得依赖于 import-map,但是大部分浏览器现在都还不支持这一特性,于是我们需要引入 systemjs:
由于我们使用了 systemjs,为了按照它的规矩来行事,我们需要在原本的规范上修改一些代码:
- 首先是我们需要在开始引入 systemjs
- 然后将 import-map 的 type 从
importmap
改为systemjs-importmap
- 接着把 es-module 的 type 从
module
改为systemjs-module
- 最后是改动最大的地方,在 es-module 中我们不再使用 import 和 export 来导入导出模块,转而使用 systemjs 的语法,不过不用担心, webpack 和 rollup 等打包工具现在都支持将代码打包成 systemjs 风格,所以我们在写代码的时候还是可以按照正常规范来写
架构设计
到这里我们的前置概念就介绍完了,可以准备开始正式的拆分工作了,不过在拆分开始前,我们需要提前设计好我们的基础设施架构和代码组织方式。
基础设施架构
基于 single-spa 加上 import-map,我们最后计划好的基础设施架构大概长这个样子:
- 首先我们前端的所有静态资源都会分别部署在 AWS 的 S3 服务中,其中唯一的一份 HTML 文件存放在 root 容器的 S3 中。
- 当用户访问我们的网站时,流量会从 client 端到达 root 容器的 AWS S3,这个时候用户的浏览器会先加载根路径下的 HTML 页面,而 HTML 页面的 head 标签中有一份 import-map 的 script。
- 这时候 client 会再发送一次请求到我们的 import-map 所在的 S3 拿到 import-map。
- 然后我们在 body 标签中用 systemjs 引入 root 容器,整个 APP 开始运转,之后根据不同的路径去不同的 S3 拿对应的静态文件
部署策略
为了能够达到各个团队独立自治的目的,部署是必不可缺的一环,我们的最终目的是不同的团队部署不会影响其他团队的业务。一个团队的线上代码出了问题,其他团队的业务仍可正常运行,对于一个 to B 的项目来说,这样的规划是有意义的。
基于这个目的,每一个团队自己维护自己的 app 的 CI/CD pipeline。需要特别注意的是,在每一次部署后需要更新 import-map 自己团队对应的 app 地址,这样还可以达到版本管理的目的。只要 S3 中一直存放着某一个版本的静态资源,仅仅更新 import-map 的对应地址即可达到快速部署和回滚的目的。
本地开发策略
在本地开发时有两种策略,一种是直接在本地启动一个 root 容器,然后将本地的 APP 注册到 root 容器中。
但是这样的开发方式需要解决依赖问题,比如 APP 依赖的通用方法库、通用组件库。解决这些依赖问题也有两个办法,一个是直接将对应的依赖打包,在本地进行配置,本地开发时直接引用打包好的依赖;第二个方式是将这些依赖作为一个共享 APP 直接在本地作为一个类似于 server 一样运行,然后通过 import-map 来共享,在开发时直接引用导出的方法和组件,而 single-spa 也提供了这样的方式,感兴趣的读者可以通过这个链接详细了解。
第二种方式则要简单许多,并且开发体验也会好很多。通常我们都有开发环境。我们可以直接在线上开发环境的 import-map 开一个口,利用 import-map-overrides 这个工具把线上的 import-map 对应的那个 APP 地址覆盖成本地地址。这时线上通过 import-map 去寻找这个 APP 的时候就会直接请求你的本地某个地址,然后线上运行的代码其实就已经是你本地的代码了,可以无缝与各种依赖开发。
你可能会觉得有安全问题,但其实这个工具可以做一些配置,比如只在本地和某一个域名下才打开这个口子,在别的地方都不开放这个后门。
实际拆分
讲了这么多,终于开始上手了,但是这个世界上有一句名话叫做理想很丰满,现实很骨感
。当你兴致勃勃准备好了一切计划,现实一般都不会让你如愿。我们这些看起来都还不错的计划有一部分被金主爸爸暂时搁置了,有一部分由于设计不妥开发体验不佳也被改造了。
太贵了
成本永远是和金主爸爸谈判绕不开的话题,我们新的架构设计在单体前端的基础上增加了许多东西:
- 多 repo(当然这个不算钱,也就没啥阻碍,但是最终也没有用多 repo 的方案,这个后面再聊
- 多 pipeline
- 多部署资源(每一个 APP 使用单独的 S3
- 多出来的 import map service
以前 10 块钱就能干完的活,你这么一搞我得出 100 块了吧,你这么玩我的钱包很难办啊
金主爸爸如是说。这种情况下我们就需要和金主爸爸谈判,为什么这些东西是必要的,为什么我们需要加这么多资源。但项目的问题在于,我们没时间谈判了,所以决定采取“架构降级”:
- 先暂时用一条 pipeline 来 build 我们的 app,在下一期项目有足够证据的时候切分 pipeline
- 这一决定在后来验证是完全错误的,设想一下一个内存只有 1G 的 agent,需要 build 一个有 5 个 APP 的前端项目
- 同时由于金主爸爸的钱包问题,我们项目只有一个 agent,请想象一下我们的日常开发hhhhh
- 先暂时将所有 app 部署到同一个地方,以文件夹分隔,如果一段时间后发现能满足需求,就先保持原状
- 每次 build 生成一份 import map,不单独维护 import map 资源,当团队相互影响时再寻求拆分时机
repo 拆分问题
我们一开始的设想是一个单独的 APP 拆分为一个单独的 repo,真正上手的时候仔细一想,有必要吗?
这让我回想起了一期项目时后端的微服务 repo,由于是一期项目,不同微服务之间的调用需要 setup,所以大多数时候本地都打开了三个以上的 Intellij,加上乱七八糟的其他应用,不得不说对 16G 内存的 Macbook 是一个考验。
回到前端这边,极有可能我们在日常的开发过程中会频繁抽取/更改公用代码库,也就意味着我们需要频繁提交更改,更新版本,然后才能使用,想想都不想做了。
再者,目前两个团队的体量其实还不必如此细致的拆分
有必要吗 - 繁琐的开发流程 - 多个本地 idea
公用代码难以维护 - 不同repo 不同更改 - ts类型引用问题
跨业务页面拆分问题
最初的设想是一条业务线是一个单独的 APP,一些跨业务的页面(也就是每一个业务都会有的页面,比如 User Account Management)也会被单独抽取一个 APP。
我们也真的这么做了,然后小伙伴们就戴上了痛苦面具:
- “BA 说这个页面是统一的,这个业务的改动,那个业务也要改。” “抽!”
- “BA 说这个新的页面要独立,所有新功能要在所有业务中生效。” “抽!”
- ......
“这个公共页面的逻辑跟那边的逻辑是一样的,我们是 copy 一份?” “......”
这样的策略导致我们的项目中存在大量 APP,而这些 APP 仔细一想好像没必要啊。增加 build 成本的同时也增加了我们自己的开发和维护成本,这拆的本末倒置了,于是我们做了一个改进 - 将所有公共页面塞进了一个 APP 中。
这个方案咋一听怪怪的,但是真的这么做了以后发现真香。所有的改动都会在所有的业务生效,不同的业务用不同的权限限制,大家维护同意份代码。等一下,你刚刚不是说不想大家维护同一份代码怕冲突吗?
这里的情况恰恰相反,所有的改动和需求都需要在所有地方生效,这样的方式我们就不用维护多份代码,而且也不会造成冲突 - 因为需求方的需求是单向的,如果有冲突,那就是需求冲突了,需要金主爸爸自己内部去掰头了。
可能有的小伙伴会说,怎么不试试后端拆分方式,使用 DDD 来指导拆分呢?巧了么不是,一开始我们就是按照后端 DDD 的方式来指导拆分的,然后就发生了这些问题,至少在我们的实践过程中,微服务的拆分方式不能照搬到前端来。
CSS 冲突问题
这是我们遇到的另一个比较严重的问题。我们在项目中使用了 Material UI,其中的 CSS 使用的是 CSS-in-JS 的方式,又因为有一套自己的 class name 生成规则,在没有控制好 scope 的情况下,多个 APP 的样式名冲突了,导致了严重的互相影响。
这虽然不是 single-spa 的问题,但是 single-spa 也提供了一些解决方案,包括 JS lib 和 CSS 的隔离问题,这些方案可以轻易地在官网或者 github issue 里面搜索到,这里就不过多解释了。解决的关键在于使用不同的 JS 或者 CSS 方案要做好相应的隔离。
写在最后
以上大概就是我们在拆分微前端过程中遇到的还记得住的事情了,从这次拆分中给我最大的益处其实不是技术上的提升,而是让我明白了做项目的两个关键点:
-
所有事情不会原封不动按照你的计划执行,越大的事情越是这样,及时考虑突发事件,灵活应变,不要拘泥于设计,基于现实改变计划才是可行之策。
-
架构的演进应该逐步推进,稳步前行,没有必要在一次架构演进中考虑好未来的所有情况,先不说你能不能考虑周全,谁又能说未来的情况不会发生改变呢,不要以现在的情况去揣度未来的情景,过好当下,灵活设计,提前预防未来可能发生的状况,准备好plan B即可。
引用
[1] single-spa
[2] 迭代开发中的微服务拆分
[3] 微前端——前端开发新体验
[4] systemjs