如上图所示,原始解决方案的三端由各自独立开发和维护,各自包含所有的业务线,而我们的业务开发情况是:
在这种特殊的业务场景下,就会出现一个有关开发效率的抉择问题。即我们希望能复用的部分只开发一次,而不是三次。那么接下来,就有两个问题摆在我们面前:
我们这里重点看一下物理层面的复用,即:如何在物理空间上使得各自独立的三端系统(不同仓库)引入我们的复用层?我们尝试了NPM包、Gitsubtree等类“共享文件”的方式后发现,最有效率的复用方式是把三个系统放在一个仓库里,去消除物理空间上的隔离,而不是去连接不同的物理空间。当然,我们三端系统的技术栈是一致的,所以就进行了如下图的改造:
可以看到,当我们把三端系统放在一个仓库中时,通过common文件夹提供了物理层面可复用的土壤,不再需要“共享文件”式地进行频繁地拉取操作,直接引用复用即可。不过,在带来物理层面复用效率提升的同时,也加速了整个工程出现了爆炸式发展的问题,随着产品线从最初的几个发展到现在的几十个之多,工程管理成本也在迅速增长。具体来说,包括如下四个方面:
结合现有工程的状况,我们进行了深度的分析。不过,在进行微前端方案确定前,我们先确定了需求点及期望收益,如下表所示:
经过以上的需求分析,我们调研了业界及公司周边的微前端方案,并总结了以下几种方案以及它们各自主要的特点:
经过上面的调研对比之后,我们确定采用了特定中心路由基座式的开发方案,并命名为:基于React的中心路由基座式微前端。这种方案的优点包括以下几个方面:
通过对方案的分析及技术方向上的梳理,我们确定了微前端的整体方案,如下图所示:
可以看到,整个方案非常简单明确,即按照业务线进行了路由级别的拆分。整个系统可分为两个部分:
基座工程和子工程联系起来的桥梁则是子工程的入口文件地址和路由地址的映射信息。这些映射信息可以让基座工程准确地发现子工程资源的路径从而进行加载。
经过微前端实践的改造,我们的业务在结构上发生了如下的变化:
如上图所示,我们进行了微前端式的业务线拆分:
新的拆分使得子工程能够按照业务线进行划分,独立维护。在解决复用层的同时保证了子工程大小可控,即子工程只有单个业务线的代码。而单个业务线的复杂度并不高,也降低了工程维护的复杂度。
采用微前端拆分的方案,使得我们的业务不仅在纵向上保有了复用的能力,更重要的是拥有了横向扩展的能力,无论产品业务线如何膨胀,我们都可以更轻松地应对。那么为了实现以上的能力,我们做了哪些工作呢?下文我们会详细进行说明。
微前端拆分的方案,我们命名为:基于React技术栈的中心路由基座式微前端。在具体实现上,我们会分为动态化方案、路由配置信息设计、子工程接口设计、复用方案设计和流程方案设计等几个模块来逐一进行说明。
首先,我们需要路由的管理方案,使得子工程之间有能力互通切换。其次,我们需要Store层的方案,让子工程有能力使用全局Store。并且,我们还需要CSS的加载方案,来加载子工程的样式布局。下面来详细说明这三个方案。
动态路由
动态路由方案是想要进行路由级别的拆分,首先我们要确定用什么来管理路由?很多实现方案倾向于使用特制路由来管理模块。例如开源框架Single-Spa,实现了自己的一套路由监听来切换子工程,并且需要子工程实现特定的注册、挂载、卸载等接口来完成子工程和基座工程的动态对接,还需要特定的模块管理系统,例如systemjs来辅助完成这一过程。毋庸置疑,这对我们原有工程的改造成本很大,还需要添加额外库,进而造成包体积大小上的开销。并且子工程的开发者需要熟悉这些特定的接口,学习成本也比较高。显然,这对于我们的业务场景和需求来说很不划算。
那么,我们选择什么来做路由管理呢?最终我们使用了React-Router,这样能够保持我们原来的技术栈不变,同时对于工程的侵入也是最低,几乎可以忽略不计。此外,React-Router能完全可以满足我们的需求,而且自动会帮助我们管理页面的加载与卸载,而不是每次切换路由都重新初始化整个子应用,所以在加载速度体验上也是最优的,跟单页应用体验一致。
实现上也很简单,如下图:
上面这个流程图,展示了我们在基座工程中切换到子工程路由时,加载子工程并进行展示的过程。这里的重点步骤是加载子工程入口文件,并动态注册子工程路由的过程。由于我们使用的是React-Router,显然要使用其提供的动态能力来完成。这一过程也非常轻量,由于React-Router从版本4开始有了“破坏级”的升级,于是我们就调研了两种方式进行动态加载路由(目前我们使用的是React-Router版本5),如下表所示:
React-Router版本3中,实现的基本代码思路如下:
//react-routerV3用于接收子工程的路由exportdefault()=>(
动态Store
对于Store层,我们原工程使用的是Redux,子工程通过路由动态注册进来天然就可以访问到全局Store,所以对于Store的访问能够自动支持。那么,如果子工程想要注册自己的全局Store该怎么办呢?而且我们还用了redux-saga来作为异步处理方案。redux-saga如何动态注册呢?还是利用它们各自的API就可以达到我们的目的?从下图中可以看到,支持动态Store也是花费很小的改造成本就可以完成。
动态CSS
同样的对应子工程的样式布局,我们也需要通过某种途径加载到基座工程中来。这个很自然地用异步加载CSS文件通过style标签注入来完成,不过这里需要注意两个问题:
一个问题是,加载子工程的JS入口文件和CSS文件可以同时发起请求,但是需要保证CSS文件加载完成后再进行JS入口文件的路由注册。因为如果路由先注册了页面就会显示出来,如果这时CSS文件还没有加载完毕,就会出现页面样式闪动的问题。我们通过先加载CSS再加载JS的策略来避免这个问题的发生。
另一个问题是,怎么保证子工程的CSS不会和其他子工程冲突。我们利用PostCSS插件在编译子工程时,按照分配给子工程的唯一业务线标识,为每一组CSS规则生成了命名空间来解决这个问题。而子业务线开发者是没有感知的,可以没有“心智负担”地书写子工程的样式。
在动态加载方案确定之后,基座工程怎么才能知道子工程的资源路径,进而加载对应的JS和CSS资源呢?我们需要一组映射信息。如下图所示,业务线唯一标识为Key,相应的静态资源地址为Value。这样的话,当基座工程切换到子工程时就可以拉取这个配置信息,在路由切换时准确地找到对应的子工程,进而进行后续的资源加载过程。这里可能会遇到的一个问题,即如果JS和CSS过大,是否能进行拆分?
根据我们业务的实际情况,目前静态资源的大小是可控的,无需注册多个,单一入口地址完全能够满足我们的业务需求,并且由于我们的改造完全基于现有技术栈。如果业务很复杂,完全可以在子工程中通过webpack的动态import进行路由懒加载,也就是说,子工程完全可以按照路由再次切分成chunks来减少JS的包体积。至于CSS本身就很小,长期也不会有进行切分的需要。
子工程需要暴露它要注册给基座工程的对象,来进行基座工程加载子工程的过程。在子工程入口文件中定义registerApp来传递注册的对象,主要代码如下:
importreducersfrom'common/store/labor/reducer';importsagasfrom'common/store/labor/saga';importroutesfrom'./routes/index';functionregisterApp(dep:any={}):any{return{routes,//子工程路由组件reducers,//子工程Redux的reducersagas,//子工程的Redux副作用处理saga};}exportdefaultregisterApp我们这里暴露了子工程的三个对象:这里最重要的就是routes路由组件,就是在写React-Router(版本4及以上)的路由。子工程开发者只需要配置routes对象即可,没有任何学习成本,其代码如下:
import*asReactfrom'react';import*asReactDOMfrom'react-dom';import*asReactRouterDOMfrom'react-router-dom';import*asAxiosfrom'axios';import*asHistoryfrom'history';import*asReactReduxfrom'react-redux';import*asImmutablefrom'immutable';import*asReduxSagaEffectsfrom'redux-saga/effects';importEchartsfrom'echarts';importReactSlickfrom'react-slick';functionregisterGlobal(root:any,deps:any){Object.keys(deps).forEach((key)=>{root[key]=deps[key];});}registerGlobal(window,{//在这里注册暴露给子工程的全局变量React,ReactDOM,ReactRouterDOM,Axios,History,ReactRedux,Immutable,ReduxSagaEffects,Echarts,ReactSlick,});exportdefaultregisterGlobal;流程方案在确定了程序拆分运行的整体衔接之后,我们还要确定开发方案、部署方案以及回滚方案。我们如何开始开发一个子工程?以及我们如何部署我们的子工程?
开发流程
有两种开发方案可以满足独立开发的目的:第一种是提供一个基座工程的Dev环境,子工程在本地启动后在Dev环境进行开发,这种开发方式要求有一套基座工程的更新机制,例如基座工程更新后要同步部署到Dev环境。第二种是子工程开发者拉取基座工程到本地并启动本地开发环境,然后拉取子工程到本地,再启动子工程本地开发环境进行开发,这种开发方式是目前我们使用的方式。如下图所示,我们提供了子工程脚手架来快速创建子工程,开发者无需做任何配置和额外学习成本,就可以像开发React应用一样进行开发。
热更新
在开发过程中,我们希望我们的开发体验和开发单页应用的体验一致,也要支持热更新。由于我们的拆分,实际上有两个服务,即基座和子工程,所以我们以上图的方式完成了热更新的支持:在子工程的module.hot中通过再次触发基座工程中的JSONP钩子来通知基座工程,来再次触发renderApp达到子工程更新代码则页面热刷新的目的。主要代码如下:
//在子工程入口文件importroutesfrom'./routes/index';functionregisterApp(dep:any={}):any{return{routes,};}if((moduleasany).hot){(moduleasany).hot.accept('./routes/index',():any=>{window.wmadSubapp(registerApp,true);//支持子工程热加载的信息传递});}exportdefaultregisterAppMock数据
子工程目前Mock数据的方式有三种:一是在基座本地Mock,这种Mock方式天然支持,因为基座工程基于外卖工程化Nine脚手架进行开发,本身支持本地Mock。二是支持子工程本地Mock。三是使用公共Mock服务YAPI。目前子工程开发的Mock功能结合第一种方式和第三种方式进行。
最后是部署方案,我们达成了独立部署上线的目的,即子工程发布不需要基座工程的参与。之前所有子业务线都在一个工程中,打包速度随着业务线的膨胀越来越慢,而如下的方案使得子工程的开发和部署完全独立,单个业务线的打包速度会非常快,从之前的分钟级别降到了秒级别。如下图所示,可以看到,子工程部署只需要把子工程打包,并在上传CDN之后,把配置信息更新即可,因为配置信息中有子工程新的资源地址,这样就达到了发布上线的目的。
整个部署过程我们是托管到Talos(美团内部自研的部署工具)上的,配置信息我们是托管到Portm(美团内部自研的文件存储)上的(通过我们开发的Talos的插件UpdatePubInfo-To-Portm来更新我们的配置信息)。在静态资源上传到CDN之后,就可以更新配置信息,供主工程调用,也就完成了子工程上线的过程。利用美团现有服务,我们很迅速地完成了子工程单独部署上线的整个流程。
在部署方案中,我们通过Talos进行部署,它本身就带有回滚功能。得益于子工程的发布和普通工程的发布并没什么本质不同,都是将静态资源放置到CDN上,通过静态资源的的contenthash值来区分不同版本,所以回滚的时候,Talos取到上个版本(或者某个前版本)的静态资源,再通过Portm更新我们的配置信息即可完成。整个过程和普通工程没有区别,发版人员只需简单地点下回滚按钮即可。
改变了原有的开发模式后,我们还对几个关键节点进行了监控报警的埋点。利用美团CAT(已经在GitHub上开源)和天网(美团内部的监控系统),我们分别在子工程的配置信息、静态资源加载等节点上进行了埋点上报,统计子工程加载成功率,及时发现可能出现的子工程切换问题。具体情况如下图所示:
上方左图是按照端维度进行统计的示例,上方右图是PC端按照产品线统计加载成功数的示例。默认都是统计当天的数据,显示‘-’的表明当前没有数据。对资源加载的监控目前有三种类型:JSON、JS和CSS,资源加载失败的统计也包含这三种类型。天网的监控按照分钟级进行,每分钟内如果有加载失败就会发出报警,偶尔的报警可能是用户网络的问题,如果出现大批量的报警就要引起重视了。