也许这就是你想要的微前端解决方案
时间:2023-03-13 04:59:10
科技观察
前言微前端是当下的前端热词,稍微大一点的团队会做技术探索,作为一个不甘落后的团队,我们也在做。可能你见过Single-Spa、qiankun等业界成熟的解决方案,非常强大:JS沙箱隔离、多栈支持、子应用并行、子应用嵌套,但仔细想想,是不是真的适合你吗?对我来说,它太沉重,概念太多,难以理解。先说说背景吧。我们之所以要对我们的小贷管理后台进行微前端改造,主要是基于以下需求:系统从接手时的30多页,到后来发展到150多页一年多。页面,并且还在增长;项目体积变大,带来开发体验差,打包构建速度很慢(初始构建,1分钟多);小贷系统的开发占整个web组50%的人力,每个Iterations都有两三个需求在这个系统上开发,代码合并冲突,上线时间穿越。带来的是开发过程管理的复杂性;业务人员被分类,没有人会使用所有的功能,每个业务人员只有30%甚至更少的功能。但是你必须加载所有的业务代码才能看到你想要的页面;所以,不像市面上很多前端团队为了引入微前端,我们是在拆,更多的团队在联合。所以这个方案适合和我一样目的的前端团队。将自身维护的巨婴系统打散,再通过微前端“框架”进行聚合,降低项目管理难度,提升开发体验和业务体验。聚影系统技术栈:Dva+Antd方案参考了美团的一篇文章:微前端在美团外卖中的实践。在做这个项目的按需预加载设计时,深入到webpack构建的项目代码运行逻辑。还有更多收获:webpack打包后的代码如何在浏览器中运行?,不懂的可以看方案设计。基于业务角色,我们将巨婴系统拆分为一个基础系统和四个子系统(子系统可根据需要扩展),如下图所示:除了提供基础功能外,即系统登录、权限采集、子系统加载、公共组件共享、公共库共享,还提供了一个基本所有业务人员都会用到的业务功能:用户授权(guan)信(li)。子系统以静态资源的形式提供注册函数,函数的返回值是一个Switch-wrapped组件和子系统的所有模型。布线设计子系统以组件的形式加载到基础系统中,因此布线是整个设计的入口和第一步。为了区分基础系统页面和子系统页面,在路由上约定如下形式://子系统路由匹配,伪代码functionLayout(layoutProps){useEffect(()=>{constapps=getIncludeSubAppMap();//加载按需分项;apps.forEach(subKey=>startAsyncSubapp(subKey));},[]);return({/*企业用户管理*/>{/*...省略一百行*/>}即只要以subPage路径开头,默认路由对应的组件是子项目,所以子项目组件是通过AsyncComponent组件异步获取的。异步加载组件设计路由设计完成,接下来异步加载组件是本方案的灵魂,流程如下:通过路由,匹配要访问的子项;通过子项id,获取对应的manifest.json文件;通过获取manifest.json,识别对应的静态资源(js、css),加载静态资源。加载完成后,子工程执行注册,动态加载模型。更新子项目组件,直接上传代码。简单明了,后面是资源加载的逻辑,具体需要注意model和component的加载顺序:exportdefaultfunctionAsyncComponent({location}){//子项目资源是否加载const[ayncLoading,setAyncLoaded]=useState(true);//加载和访问子项目组件const[ayncComponent,setAyncComponent]=useState(null);const{pathname}=location;//获取路径中标识子项目前缀的部分,如'/subPage/xxx/home'其中xxx为子系统路由标识constid=pathname.split('/')[2];useEffect(()=>{if(!subAppMapInfo[id]){//这个子系统不存在,直接跳转到首页goBackToIndex();}conststatus=subAppRegisterStatus[id];if(status!=='finish'){//加载子项loadAsyncSubapp(id).then(({routes,models})=>{loadModule(id,models);setAyncComponent(routes);setAyncLoaded(false);//已经loaded,makeamarksubAppRegisterStatus[id]='finish';}).catch((error={})=>{//如果加载失败,显示错误信息setAyncLoaded(false);setAyncComponent({error.message||'加载失败'}
);});}else{constmodels=subappModels[id];loadModule(id,models);//如果能匹配到前缀,则加载对应的子项目模块setAyncLoaded(false);setAyncComponent(subappRoutes[id]);}},[id]);return(
{ayncComponent});}子项目设计子项目作为静态资源加载到基础项目中,需要暴露子系统的所有页面组件和数据模型;然后打包构建和之前略有不同,需要多生成一个manifest.json来收集子项目的静态资源信息子项目暴露自己的自愿代码是这样的://sub-projectresourceoutputcodeimportroutesfrom'./layouts';constmodels={};functionimportAll(r){r.keys().forEach(key=>models[key]=r(key).default);}//收集所有页面modelimportAll(require.context('./pages',true,/model\.js$/));functionregisterApp(dep){return{routes,//子项目路由组件模型,//子项目数据模型集合};}//数组第一个参数为子项目id,第二个参数为子项目模块获取函数(window["registerApp"]=window["registerApp"]||[]).push(['collection',registerApp]);子项目页面组件集合:importmenusfrom'configs/menus';import{Switch,Redirect,Route}from'react-router-dom';importpagesfrom'pages';functionflattenMenu(menus){constresult=[];menus.forEach((menu)=>{if(menu.children){result.push(...flattenMenu(menu.children));}else{menu.Component=pages[menu.component];result.push(menu);}});returnresult;}//子项自身路径+/subpage/xxxconstprefixRoutes=flattenMenu(menus);exportdefault({prefixRoutes.map(child=>)}切换>);静态资源加载逻辑设计开始做方案的时候,只设计了一个按需加载的交互体验:即当业务切换到子项目路径时,开始加载子项目的资源,然后渲染页面,后来觉得这个改动影响了业务体验。以前他们只需要在加载数据的时候加载,现在他们还需要承担子项目的加载。因此,为了让业务尽可能小的感知到系统的重构,提前将按需加载替换为按需加载。简单来说,就是在业务登录的时候,我们会遍历他所有的权限菜单,获取他拥有的那些子项的访问权限,然后提前加载这些资源。遍历菜单,提前加载子项目资源://本地开发环境不按需提前加载if(getDeployEnv()!=='local'){constapps=getIncludeAppMap();//加载子项目按需提前提供资源;应用。forEach(subKey=>startAsyncSubapp(subKey));}然后是显示代码的时候了。思路是引用webpackJsonp,即通过拦截一个全局数组的push操作,知道子项已经加载:import{subAppMapInfo}from'./menus';//子项目静态资源映射表存储:/***状态定义:*'':尚未加载*'start':静态资源映射表已存在;*'map':静态资源映射表已经存在;*'init':静态资源已经加载;*'wait':资源加载完成,等待注入;*'finish':模块已经注入;*/exportconstsubAppRegisterStatus={};exportconstsubappSourceInfo={};//项目加载挂起Promisehash表constdefferPromiseMap={};//项目加载挂起错误哈希表consterrorInfoMap={};//加载css,js资源functionloadSingleSource(url){//此处省略代码returnnewPromise((resolove,reject)=>{link.onload=()=>{resolove(true);};link.onerror=()=>{reject(false);};});}//加载json中包含的所有静态资源asyncfunctionloadSource(json){constkeys=Object.keys(json);constisOk=awaitPromise.all(keys.map(key=>loadSingleSource(json[key])));if(!isOk||isOk.filter(res=>res===true)response.json())。catch(()=>false);subAppRegisterStatus[subKey]='map';returnjson;}//子项目按需提前加载入口exportasyncfunctionstartAsyncSubapp(moduleName){subAppRegisterStatus[moduleName]='start';//开始加载constjson=awaitgetManifestJson(moduleName);const[,reject]=defferPromiseMap[moduleName]||[];if(json===false){subAppRegisterStatus[moduleName]='error';errorInfoMap[moduleName]=newError(`module:${moduleName},manifest.json加载错误`);reject&&reject(errorInfoMap[moduleName]);return;}subAppRegisterStatus[moduleName]='map';//加载jsonconstisOk=awaitloadSource(json);if(isOk){subAppRegisterStatus[moduleName]]='init';return;}errorInfoMap[moduleName]=newError(`Module:${moduleName},静态资源加载错误`);reject&&reject(errorInfoMap[moduleName]);subAppRegisterStatus[moduleName]='error';}//回调处理函数ccheckDeps(moduleName){if(!defferPromiseMap[moduleName]){return;}//有pending,开始处理;const[resolove,reject]=defferPromiseMap[moduleName];constregisterApp=subappSourceInfo[moduleName];try{constmoduleExport=registerApp();resolove(moduleExport);}catch(e){reject(e);}finally{//清理defferPromiseMap[moduleName]=null;subAppRegisterStatus[moduleName]='finish';}}//window.registerApp.push(['collection',registerApp])//这个是子项目注册的核心,受webpack启发,即拦截window.registerAppexportfunctioninitSubAppLoader(){window.registerApp=[];constoriginPush=window.registerApp.push.bind(window.registerApp);//eslint-disable-next-lineno-use-before-definewindow.registerApp.push=registerPushCallback;functionregisterPushCallback(module=[]){const[moduleName,register]=module;subappSourceInfo[moduleName]=register;originPush(module);checkDeps(moduleName);}}//加载按需提前进入exportfunctionloadAsyncSubapp(moduleName){constsubAppInfo=subAppRegisterStatus[moduleName];//错误处理优先级if(subAppInfo==='错误'){consterror=errorInfoMap[moduleName]||newError(`Module:${moduleName},资源加载错误`);returnPromise.reject(error);}//已经提前加载,等待注入if(typeofsubappSourceInfo[moduleName]==='function'){returnPromise.resolve(subappSourceInfo[moduleName]());}//如果有尚未加载,它将开始加载。如果已经开始加载,则直接返回if(!subAppInfo){startAsyncSubapp(moduleName);}returnnewPromise((resolve,reject=(error)=>{throwerror;})=>{//添加到pendingmap;defferPromiseMap[moduleName]=[resolve,reject];});}这里需要强调的是有两个子项加载场景:从基页路径进入系统,那么就是按需提前加载的场景,然后先执行startAsyncSubapp,资源提前缓存;从子项目页面路径进入系统,也就是按需加载的场景,有loadAsyncSubapp先执行,使用Promise完成发布订阅至于为什么startAsyncSubapp先执行后执行,是因为useEffect是仅在安装组件时执行;至此,框架的大体逻辑解释清楚了,剩下的就是优化了。其他的难点其实都不难,只是怪我太优秀了,不过这几点真的很值得记录和分享,互相鼓励。公共依赖共享由于基础项目和子项目技术栈是一致的,而系统是拆分的,共享公共库依赖和优化打包是尤为重要的一点。本来以为用外部的webpack就可以搞定,其实要复杂很多。antd构建antd3.x支持esm,即按需导入,但是由于我们的构建工具没有相应升级,使用了插件babel-plugin-import,导致两个问题,冗余打包和无法导出在完整的antd模块中。单独说一下:打包冗余是通过BundleAnalyzer插件发现的。一个模块既有commonJs代码也有esm代码;不能全量导出,因为基础工程不知道子工程会用到哪个模块,所以只能暴力导出antd的所有模块,但是babel-plugin-import插件有个优化,会分析导入,然后删除无用的依赖,但是我们的需求和它的目的是冲突的;结论:使用babel-plugin-import这个插件打包commonJs代码已经过时了,它唯一的价值在于还可以帮助我们按需引入css代码;projectpubliccomponentsshare项目中公共组件的共享,我们开始尝试将常用的组件添加到公司的组件库中来解决问题,但发现这种方案并不理想。第一:很多组件都是和业务场景强相关的。添加公共组件库会导致组件库臃肿;第二:没必要。所以我们最终采用baseproject来收集组件并统一暴露:functioncombineCommonComponent(){constcontexts=require.context('./components/common',true,/\.js$/);returncontexts.keys()。reduce((next,key)=>{//合并组件下的components/commonconstcompName=key.match(/\w+(?=\/index\.js)/)[0];next[compName]=contexts(key).default;returnnext;},{});}webpackJsonpglobalvariablepollution对webpack构建后的代码不熟悉的可以看开头提到的那篇文章。webpack构建时,modules是开发环境中的一个对象,文件路径作为module的key;在正式环境下,modules是一个数组,索引作为module的key。由于我的基础项目和子项目没有沙箱隔离,即窗口是共享的,所以出现了webpackJsonp全局变量污染的情况。在开发环境下,由于文件Key是唯一的,所以没有暴露出这种污染,但是在官方打包的时候,发现无法加载qa环境子项目,最后分析发现window.webpackJsonp环境变量的一个bug污染。最终的解决方案是子项目打包有自己独立的webpackJsonp变量,即重命名webpackJsonp,写个简单的webpack插件就搞定了://RenamewebpackJsonptowebpackJsonpCollectconfig.plugins.push(newRenameWebpack({replace:'webpackJsonpCollect'}));子项目开发热加载基础项目之所以成为基础项目,是因为其迭代次数少、稳定的特殊性。但是在开发过程中,由于子项目不能独立运行,需要依赖基础项目的联调。但是做一个需求,需要同时打开两个vscode同时运行两个项目。对于那个开发来说,这是一个不好的开发体验,所以我们希望以dev环境为基础,支持本地开发和联调。那是最好的体验。将dev环境的构建参数改成开发环境后,发现子工程在线上基础工程上可以运行,但是webSocket通信一直失败。最后发现原因是webpack-dev-sever有一个hostcheck逻辑,叫做hostcheck,是一个安全选项,我们这里可以确认一下,所以直接注释掉就好了。