详解利用Angular实现多团队模块化SPA开发框架
0、前言
当一个公司有多个开发团队时,我们可能会遇到这样一些问题:
1.技术选项杂乱,大家各玩各
2.业务重复度高,各种通用api,登录注销,权限管理都需要重复实现(甚至一个团队都需要重复实现)
3.业务壁垒,业务之间的互通变得比较麻烦
4.部署方式复杂,多个域名(或IP地址)访问,给用户造成较大的记忆难度
5.多套系统,风格难以统一
6.等等...
当然,解决方式有不少。以下就来讲解下我们这边的一种解决方案。
1、思路
Angualr
Angular(注:非AngularJS)是流行的前端MVVM框架之一,配合TypeScript,非常适合用来做后台管理系统。由于我们曾今的一套Angularjs开发框架,我们继续选择Angular来进行实现,并尽可能的兼容AngularJS的模块。
SPA
选SPA还是多页?多余Mvvm来说,多页并不是标配。而且多页开发中,我们势必会关注更多的内容,包括通用header,footer,而不仅仅是页面的核心内容。
模块化
为什么要模块化呢?当有多个团队开发时(或者项目较大时),我们希望各个团队开发出来的东西都是模块(不仅限于JS模块),这样可以让我们独立发布、更新、删除模块,也能让我们的关注点集中在特定模块下,提高开发效率和可维护性。
平台化
我们需要有一个运行平台(Website站点),允许在里面运行指定的模块。这样就可以实现单一入口,也容易实现通用逻辑,模块共享机制等等。
兼容AngularJS模块
在考虑将框架切换到Angular时,我们无可避免的会遇到如何兼容当前已有模块的问题。大致可选的方案如下:
1.参考AngualrJS->Angular官方升级指南,一步步将模块切换为Angular的实现。(工作量大,需要开发团队调整很多东西)
2.iframe嵌入,会有一定的体验差异,但对开发团队来说,基本无缝升级,也不需要做什么改动。(无疑,我们选择了这套方案)
模块打包
我们需要将单个的模块打包为资源包,进行更新。这样才能做到模块独立发布,及时生效。
CSS冲突
在大型SPA中,CSS冲突是很大的一个问题。我们期望通过技术手段,能够根据当前使用的模块,加载和卸载CSS。
跨页面共享数据
由于涉及到iframe兼容旧有模块,我们无可避免,需要考虑跨窗口的页面共享。
公共模块
当一个团队的模块较多时,就会有一些公共的东西被抽取出来,这个过程,框架是无法知道的,所以这个时候,我们就需要考虑支持公共模块。(模块之间也有依赖关系)
3、实现
基于以上的一些思考,我们首先需要实现一个基础的平台网站,这个没什么难度,直接用Angular实现即可。有了这一套东西,我们的登录注销,基本的菜单权限管理,也就实现了。
在这个基础之上,我们也能实现公共服务、公共组件了(封装一系列常用的玩意)。
如何模块化?如何打包?
注意:此模块并非Angular本身的模块。我们通过约定,在modules/下的每一个目录都是一个业务模块。一个业务模块一般会包含,静态资源、CSS以及JS。根据这个思路,我们的打包策略就是:遍历modules/的所有目录,对每一个目录进行单独打包(webpack多entry打包+CSS抽取),另外使用gulp来处理相关的静态资源(在我看来,gulp才是构建工具,webpack是打包工具,所以混合使用,物尽其用)。
一般来说,webpack会把所有相关依赖打包在一起,A、B模块都依赖了@angular/core识别会重复打包,而且框架中,也已经打包了@angular相关组件。这个时候,常规的打包配置就不太合适了。那该如何做呢?
考虑到Angular也提供了CDN版本,所以我们将Angular的组件通过文件合并,作为全局全量访问,如ng.core、ng.common等。
既然这样,那我们打包的时候,就可以利用webpack的externals功能,把相关依赖替换为全局变量。
externals:[{ 'rxjs':'Rx', '@angular/common':'ng.common', '@angular/compiler':'ng.compiler', '@angular/core':'ng.core', '@angular/http':'ng.http', '@angular/platform-browser':'ng.platformBrowser', '@angular/platform-browser-dynamic':'ng.platformBrowserDynamic', '@angular/router':'ng.router', '@angular/forms':'ng.forms', '@angular/animations':'ng.animations' }
这样处理之后,我们打包后的文件,也就不会有Angular框架代码了。
注:这个对引入资源的方式也有一定要求,就不能直接引入内层资源了。
如何动态加载模块
打包完成之后,这个时候就要考虑平台如何加载这些模块了(发布过程就不说了,放到指定位置即可)。
什么时候决定加载模块呢?其实是访问特定路由的时候,所以我们的顶级路由,会使用Promise方法来实现,如下:
constloadModule=(moduleName)=>{ return()=>{ returnModuleLoaderService.load(moduleName); }; }; constdynamicRoutes=[]; modules.forEach(item=>{ dynamicRoutes.push({ path:item.path, canActivate:[AuthGuard], canActivateChild:[AuthGuard], loadChildren:loadModule(item.module) }); }); constappRoutes:Routes=[{ path:'login',component:LoginComponent },{ path:'logout',component:LogoutComponent },{ path:'',component:LayoutComponent,canActivate:[AuthGuard], children:[ {path:'',component:HomeComponent}, ...dynamicRoutes, {path:'**',component:NotFoundComponent}, ] }];
我们把每个模块,按照umd的格式进行打包。然后再需要使用该模块的时候,使用动态构建script来运行脚本。
load(moduleName,isDepModule=false):Promise{ letmodule=window['xxx'][moduleName]; if(module){ returnPromise.resolve(module); } returnnewPromise((resolve,reject)=>{ letpath=`${root}${moduleName}/app.js?rnd=${Math.random()}`; this._loadCss(moduleName); this.http.get(path) .toPromise() .then(res=>{ letcode=res.text(); this._DomEval(code); returnwindow['xxx'][moduleName]; }) .then(mod=>{ window['xxx'][moduleName]=mod; letAppModule=mod.AppModule; //routechangewillcalluseModuleStylesfunction. //this.useModuleStyles(moduleName,isDepModule); resolve(AppModule); }) .catch(err=>{ console.error('Loadmodulefailed:',err); resolve(EmptyModule); }); }); } //取自jQuery _DomEval(code,doc?){ doc=doc||document; letscript=doc.createElement('script'); script.text=code; doc.head.appendChild(script).parentNode.removeChild(script); }
CSS的动态加载相对比较简单,代码如下:
_loadCss(moduleName:string):void{ letcssPath=`${root}${moduleName}/app.css?rnd=${Math.random()}`; letlink=document.createElement('link'); link.setAttribute('rel','stylesheet'); link.setAttribute('href',cssPath); link.setAttribute('class',`xxx-module-style${moduleName}`); document.querySelector('head').appendChild(link); }
为了能够在模块切换时卸载,还需要提供一个方法,供路由切换时使用:
useModuleStyles(moduleName:string):void{ letxxxModuleStyles=[].slice.apply(document.querySelectorAll('.xxx-module-style')); letmoduleDeps=this._getModuleAndDeps(moduleName); moduleDeps.push(moduleName); xxxModuleStyles.forEach(link=>{ letdisabled=true; for(leti=moduleDeps.length-1;i>=0;i--){ if(link.className.indexOf(moduleDeps[i])>=0){ disabled=false; moduleDeps.splice(i,1); break; } } link.disabled=disabled; }); }
公共模块依赖
为了处理模块依赖,我们可以借鉴AMD规范以及使用requirejs作为加载器。当前在我的实现里,是自定义了一套加载器,后期应该会切换到AMD规范上去。
如何兼容AngularJS模块?
为了兼容AngularJS的模块,我们引入了iframe,iframe会先加载一套曾今的AngularJS宿主,然后再这个宿主中,运行AngularJS模块。为了实现通信,我们需要两套平台程序中,都引入一个基于postMessage实现的跨窗口通信库(因为默认跨域,所以用postMessage实现),有了它之后,我们就可以很方便的两边通信了。
AOT编译
按照Angular官方的Aot编译流程即可。
多Tab页
在后台系统中,多Tab页是比较常用了。但是多Tab页,在单页中使用,会有一定的性能风险,这个依据实际的情况,进行使用。实现多Tab页的核心就是如何动态加载组件以及如何获取到要加载的组件。
多Tab页面,实际就是一个Tabset组件,只是在tab-item的实现稍显特别一些,相关动态加载的源码:
@ViewChild('dynamicComponentContainer',{read:ViewContainerRef})dynamicComponentContainer:ViewContainerRef; constructor( privateelementRef:ElementRef, privaterenderer:Renderer2, privatetabset:TabsetComponent, privateresolver:ComponentFactoryResolver, privateparentContexts:ChildrenOutletContexts ){ } publicdestroy(){ letel=this.elementRef.nativeElementasHTMLElement; //tslint:disable-next-line:no-unused-expression el.parentNode&&(el.parentNode.removeChild(el)); } privateloadComponent(component:any){ letcontext=this.parentContexts.getContext(PRIMARY_OUTLET); letinjector=ReflectiveInjector.fromResolvedProviders([],this.dynamicComponentContainer.injector); constresolver=context.resolver||this.resolver; letfactory=resolver.resolveComponentFactory(component); //letcomponentIns=factory.create(injector); //this.dynamicComponentContainer.insert(componentIns.hostView); this.dynamicComponentContainer.createComponent(factory); }
注意:要考虑组件卸载方法,如destroy()
为了获取到当前要渲染的组件,我们可以借用路由来抓取:
this.router.events.subscribe(evt=>{ if(evtinstanceofNavigationEnd){ letpageComponent; letpageName; try{ letnextRoute=this.route.children[0].children[0]; pageName=this.location.path(); pageComponent=nextRoute.component; }catch(e){ pageName='$$notfound'; pageComponent=NotFoundComponent; } letidx=this.pageList.length+1; if(!this.pageList.find(x=>x.name===pageName)){ this.pageList.push({ header:`页面${idx}`, comp:pageComponent, name:pageName, closable:true }); } setTimeout(()=>{ this.selectedPage=pageName; }); } });
3、总结
以上就是大概的实现思路以及部分相关的细节。其他细节就需要根据实际的情况,进行酌情处理。
该思路并不仅限于Angular框架,使用Vue、React也可以做到类似的效果。同时,这套东西也比较适合中小企业的后台平台(不一定非要多团队,一个团队按模块开发也是不错的)。
如需要了解更多细节,可以参考:ngx-modular-platform,能给个star就更好了。
本文github地址
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。