如何分三步来探索微前端架构落地
正文
本文是前端早早聊的第 38 位讲师,也是第七届 - 前端微前端专场,来自宋小菜 团队伟林 的分享 - 讲稿简要整理版(完整版含演示请看录播视频和 PPT)
主持人介绍
这一场分享是来自宋小菜前端总架构组的伟林,那么伟林是宋小菜前端团队基础架构组得核心成员,长期从事与前端基建方面的工作,特别是中后台的脚手架,配套研发设施,以及微前端方面的工程体系架构升级等工作。
主要内容
分享大纲
微前端(概念)认知史 为什么需要微前端 调研到落地实践
主要内容
多应用集成 - Qiankun(乾坤) 单体拆分 - Federation 的探索
微前端(概念)认知史
17 年我才听到微前端这个概念。当时是一位 Java 同事分享了一篇关于微前端的 ThoughtWorks 文章。后来发现 16 年便开始流传这个概念了。
18 年 microfrontend.org 的站长在一场 JS 大会上做了演讲,实际上那时我没怎么细看,没有案例空讲实在太虚了。那段时间 Dan Ambramov 还发推表示对微前端不解,觉得它的价值没有被捧得那么的大。甚至有人吐槽单页应用本身都写不好,还想着微前端这个花里胡哨的东西。再后来便出现了 single-spa 这个库,慢慢地它的周边生态开始涌现。
19 年在知乎第一次了解到乾坤。它是基于 single-spa 封装,加上独辟蹊径的隔离方案和沙箱机制解决了 single-spa 的不足,让人眼前一亮。
20 年在自己公司遇到了一部分需求和痛点,在调研多个库和框架(分别是 Luigi、feature-hub、Single-spa、qiankun 还有 Webpack5 的 federation)后便开始正式实施。就是在五一之后开始做的,如果再晚点就没这场分享了。最近我看到 Dan 的一条 Twitter 表示明白了微前端能带来什么。
开始前也先总结一句“微前端不是银弹,它并没有多么高深莫测”。
为什么需要微前端
不服务于业务的技术是没有价值的,我们需要以终为始。微前端给我们公司带来了一些特殊的价值,抽象看就是两类价值。
业务价值
在用户这一侧,偏向产品和业务价值,尤其体现在多应用集成时带来的好处。
我们遇到了以下痛点:
新的运营同学觉得中后台应用过多,记不住。 UED 觉得很多的应用交互体验不一致。常见的就是菜单和头部宽高不一致。虽然都是用的 Ant-design,但各个系统的全局的 UI 都是不一致的。 运营在多个应用中操作时有时存在断层,他们期望有工作台的感觉(我们也期望给他们这种感觉)。同时在未来我们需要接入统一的工单通知,我们想把这块维护一个应用中,不用每个应用都接入一次。
通过微前端能力整合多个应用就可以解决上述的痛点。
工程价值
在开发者这一侧,则偏向工程价值。在多应用集成场景里,我们可以做到统一管理应用,使得应用的申请入驻、获得应用配置、发布平台对接等等的生命周期都可以被维护起来。在之前较为混乱,存在口头索要 appKey、SysID 这种情况。如果能有一个统一的入驻平台,并且用工作台的方式将他们管理起来,这些问题都可以被解决。
利用微前端拆分单体巨大应用,则会带来更大的工程价值,我个人其实觉得工程价值更大。将一个巨大的应用按模块拆分,可以使得团队分开维护,使得工程师能踏出部分泥潭模块。在拆解后,按需要分开独立发布模块也使得发布速度也得到提升,产品和业务方会减少焦虑感,用户自然也是受益的。(在我们内部有一个应用就因为某个模块太过巨大,经常发布失败。)
调研到落地实践
开发前我还没有具体实践过 single-spa 和 qiankun 以及 liuji 等等。但我们可以幻想一下,多个应用或者模块集成在一起会遇到什么问题。
可能遇到的问题
第一,全局样式的冲突。我们最先想到的是 Shadow DOM,但是它是有兼容性问题的,而且在 React 上也有事件代理的坑,对于子应用来说会有巨大的改造成本。另外我们还能想到利用 CSS 前缀的方式,给样式加上 Scope 或者利用 css-in-js 的方案去处理,不过有一定的改造成本,但也是可以接受的。
第二,JS 可能存在全局污染的问题。tc39 的 realms 沙盒提案浏览器还没支持,可能需要自己实现。其实,我觉得这点毛病不是特别特别得强烈,毕竟现在大多数包都是 bundle 过得。
第三,某些库多版本。例如 React 存在多版本,过去某些系统可能想打包提速,会利用 Webpack externals 的能力,但我们不能一下统一所有应用的 React 版本,所以利用需要利用 DLL,同时达到复用目的。
利用 iframe 去解决?
针对前两个问题,我们可能会想到利用 iframe 去解决。它似乎可以解决以上污染和冲突问题。但我们可以可以想象一种情景:在 iframe 内打开某个应用,并导航了几次,此时刷新一下页面,iframe 的状态就没了。有人会说持久化记录一下不行吗?可以,但是还有其他的问题,比如它加载速度的缺陷、父子通信的问题,总之使用成本很大。对此方案没抱多大希望。
在想了一些比较细节的问题后,在思考一下拆分集成的粒度问题,说到底微前端就是一种拆解类的问题。
这是我做拆解类问题喜欢用的一张图。在我们公司的场景里,我们的第一诉求是应用的集成,少部分较大应用才会涉及单应用的拆解。多应用的集成就是将从前 App 这一层的应用降到 Module 这一侧,再利用 Host 应用(主应用)加载进来,而单体的拆分则是将单个 APP 按照上面不同的维度,拆解出不同的 Module 或是 Class 级别的包。
应用集成
前面说到技术调研,SAP 开源的 Luigi 是基于 Iframe 的,把玩了下就直接排除了。Feature-hub 是模块级别的,更符合单体拆分的场景,但是改造成本很大,有学习成本。那么只剩下 single-spa 和 qiankun 的对比了。
single-spa 是较为简陋的,只是劫持了单页应用的路由变换(感兴趣的同学可以去看它的测试用例),没有考虑到样式的隔离和 JS 执行的沙盒,这两点需要我们自己实现集成。同时,模块加载的能力,它也不具备,一般辅以 SystemJS,当然还可以结合我们后面会提到的 federation 。
相比之下,qiankun 帮我们实现了沙盒机制和一套另辟蹊径的样式隔离方案,在多应用集成场景下非常适合。为什么说是另辟蹊径呢?正常的思路,也就是在我幻想阶段时,我想可能要利用 Webpack 打包出的 manifest 加载,但是这样 HTML 的部分是照顾不到的。
当然 qiankun 也不是完美的,因为独辟蹊径的方式就是利用 import-html-entry 加载解析 HTML。这是有一定损耗的,但在权衡下,乾坤最为简单直接,还有 preload 机制预加载,对我们而言,其实完全是可以接受的。
它的大致用法也是非常简单的,主应用只需要注册一下子应用的信息,比如挂载节点、应用名(注意应用名称唯一)、路由规则,然后设置一下默认加载的应用,最后 start 一下就可以了。对于子应用而言就更简单了,只需要更改自己 Webpack 打包配置,将自身打包成 umd 的模块,再暴露出主应用需要的生命周期即可。
遇到的实际问题
在正式实施开发时,我们遇到了一些问题。
第一,重复配置。我们将自应用重复的配置抽成了一个解决方案插件。
第二,dll 配置。除了要将加载应用的 libraryTarget 设置为 umd 还需要设置 dll 的,不然会加载失败。可以看到 qiankun 沙盒内部是靠 eval 去执行的,它没有义务帮你解析这个 var (dll 默认是 var 类型的导出方式),同时严格模式下也不允许。
第三,Ant Design modal 销毁问题。可以利用 getContainer 指定局部渲染的节点。新版 Ant Design 中简单 false 一下即可,旧版本的则需要你指定特定的 DOM 节点,可以自己包装一个 Modal 出来达到复用目的。
第四,父子通讯。一开始我以为需要自己想办法,例如利用原生事件或者约定在 window 上的某个模块进行通信。这里 qiankun 和 single-spa 是一样的,可以利用 props 通过生命周期参数注入。
第五,我们将部分公用的 Redux model 提升到主应用,子应用就不要去再重复加载。此时注入给子应用就会存在多个 store 。可以利用 react-redux connect 的高级用法,在新版中是利用 context,我们有些还是 5.x,可以用 storeKey 的方式解决。
集成也分简单模式和精细模式。
简单模式就是改造完子应用,主应用提供空白的整页给其渲染,导航则利用一个浮层导航器(fixed),但这非常简单粗暴。
精细模式则是指主应用仅仅提供 Content 区域给子应用。同时再做一些额外的改造,协定菜单的数据结构,将定制菜单的管理类传给子应用,在子应用加载完毕时进行增删,这份数据是可以缓存的,所以下次就可以更快的展现。对于那些不标准的 Header 和 Footer,我们也提供定制的接口给它们,在 unmount 时复原即可。对于接口加载上,我们统一请求库,再利用 LRU 的能力去缓存接口,以达到多应用间复用接口的目的。
以上就是多应用集成时我们遇到的实际问题和解决办法,下面我们讲讲单体拆分。
单体拆分
单体拆分简单说就是要把一个大应用拆出一部分出来,然后远程加载它们。一般来说,比起多应用集成,单个应用的拆分更适合大模块分活干和分开管理维护的情况。这样的应用本身就有一定约束。比如,统一的技术栈(React),使用同一个 UI 库 (Ant Design)一般也有统一的交互标准。从 single-spa 文档上取了一个拆解方式的对比图,简单对比一下,只有动态加载模块的方式才能满足分开构建、分开部署且是代码仓库独立的需求。
单体拆分-动态加载
因为我们其中一个系统较大(用户模块、微信管理、报表分析模块、内容管理等等),发布非常慢,偶尔还会发布失败。因此需要将一部分划分出去,单独管理。
一开始我是使用的 qiankun@1.6.x,它无法满足我们这边的需求。因为当我们按照领域划分出业务模块后,有的业务模块目前页面数量很少,没有特殊的理由去说服用户去改变菜单的使用习惯。但如果是那种块很多,菜单也很臃肿的应用,那么多抽离一级业务域的菜单或是切换器,qiankun 完全是可以用。那么采用 single-spa 的 parcel 和 SystemJS 吗?或者 qiankun@2.0(2.0支持了 parcel) 吗?其实它们都是可行的,但还不够完美。
这是 Webpack 作者最近的一条 Twitter。关注 Webpack 动态的同学,可能知道 federation 已经支持好一段时间了,但也就是在那天 federation 才有了一个 concept 的文档,API 文档目前还没有。那我们看下 federation 相比过去的拆解方案强在哪里。
federation
externals 简单粗暴,无法处理多版本共存时的问题。dll 将所有包都打入,模块间无法共享代码。而 federation 既可以做到版本控制,还可以动态判断是否存在缺失的 vendor 包并加载。同时,它还克服了用 npm 包开发公共模块时的不便利。
这两张图是从 federation 作者文章里盗来的,哈哈。可以说他们非常形象地描述了 federation 的作用。例如,landing site 的摸个模块是可以被 media site 拿到的,同理,landing site 也可以共享模块给 media site 。对于那些公共的模块,则可以直接抽离到单独的仓库,按需引入,并且在某个模块加载后,别的模块如何也引入了此公共模块,这时不会发生资源请求。具体大家可以看左下角这个仓库中的例子。
我们看下例子中是如何使用的。
第一步,在 Webpack5 中加入 federation 的插件。我们先从插件参数看,第一个参数 name 是声明这个 federation 的名称,第二个参数 libarary 是声明暴露成 libarary 时的类型和名字,第三个参数 filename 是申明打包出的运行时配置文件的名称。第四个参数 exposes 是指出你要暴露出的具体文件(模块)有哪些。第五个参数 remotes 是指定从远程加载的应用名称以及它在引入时的名字。第六个参数 shared 是指定需要共享的模块(包)。
第二步,在应用的 HTML 中引入 运行时配置,这点和 SystemJS 很像。
第三步,在应用中,我们按照 remotes 中配置的远程模块,进行加载即可,和正常的代码无异。
对我们而言就是将公共模块和业务域模块进行拆分 exposed ,接着 shared 出来。
还有一点建议提给大家。这个巨大的单体应用经过多个人开发,所以二级菜单和二级页面放置比较混乱,在拆分不同模块时花费了不少时间。平时开发时就要注意按领域去划分二级子页面,做到未雨绸缪。
实施的最后一块是发布问题。简答模式就是什么都不用考虑,正常发布即可。精细模式则要考虑平滑上线的问题。
让我们仔细想象一种场景:子模块先发布成功,但是其依赖了还未发布的主应用的 api,那么应用就会崩溃,反之亦然。这点在可用性上的要求和后端是一样的。
那如何解决呢?首先,我们要 hash 化子应用的 HTML。但 HTML 的版本机制意味着配置动态化。大家想想一想前面乾坤那边的配置,我们要改动主应用中注册的子应用的地址。这份配置动态就需要从服务端获取,因此还需要开发一个配置中心。同时子应用发布时需要通知配置中心发布成功了,并将 index_[hash].html 或是 federation 的runtime 配置地址传给配置中心。最后,发布平台上还要保证发布的顺序。主应用如果有更改,必须编排在最后一个发布。
总结和规划
简单事简单做,让你的架构随着你的业务规模而改动,适合的才是最好的。也许不一定每个人都需要多应用集成这种场景,但是单体拆分确实是微前端的带来的巨大的工程价值点。
最后,说下我们整体的规划吧。我们想做一个入驻平台,衔接和管理应用的生命周期。简单说就是将应用从申请入驻 => [配置中心注册、配置中心分发配置、监控平台对接、服务端配置对接] => 基础框架获取配置初始化项目 => 运行时获取配置 => 发布平台对接 => [用户侧配置获取、通知机制、增量发布] 的完整流程都管理到。
说了很多,五月份开发的时间不长,部分还在规划实施中。目前现在发布方案还是简单模式,灰度方案和通知机制也没有集成。在多应用集成时,对监控平台也造成了开发量,因为域名变成了子 path 。