當一個公司有多個開發團隊時,咱們可能會遇到這樣一些問題:css
固然,解決方式有很多。如下就來說解下咱們這邊的一種解決方案。前端
Angualrwebpack
Angular
(注:非AngularJS) 是流行的前端 MVVM
框架之一,配合 TypeScript
,很是適合用來作後臺管理系統。因爲咱們曾今的一套 Angularjs
開發框架,咱們繼續選擇 Angular
來進行實現,並儘量的兼容 AngularJS
的模塊。git
SPAangularjs
選 SPA
仍是多頁?多餘 Mvvm
來講,多頁並非標配。並且多頁開發中,咱們勢必會關注更多的內容,包括通用header,footer,而不只僅是頁面的核心內容。github
模塊化web
爲何要模塊化呢?當有多個團隊開發時(或者項目較大時),咱們但願各個團隊開發出來的東西都是 模塊
(不只限於JS模塊),這樣可讓咱們獨立發佈、更新、刪除模塊,也能讓咱們的關注點集中在特定模塊下,提升開發效率和可維護性。express
平臺化gulp
咱們須要有一個運行平臺(Website站點),容許在裏面運行指定的模塊。這樣就能夠實現單一入口,也容易實現通用邏輯,模塊共享機制等等。api
兼容 AngularJS 模塊
在考慮將框架切換到 Angular
時,咱們無可避免的會遇到如何兼容當前已有模塊的問題。大體可選的方案以下:
AngualrJS -> Angular
官方升級指南,一步步將模塊切換爲 Angular
的實現。(工做量大,須要開發團隊調整不少東西)iframe嵌入
,會有必定的體驗差別,但對開發團隊來講,基本無縫升級,也不須要作什麼改動。(無疑,咱們選擇了這套方案)模塊打包
咱們須要將單個的模塊打包爲資源包,進行更新。這樣才能作到模塊獨立發佈,及時生效。
CSS衝突
在大型 SPA
中,CSS衝突是很大的一個問題。咱們指望經過技術手段,可以根據當前使用的模塊,加載和卸載CSS。
跨頁面共享數據
因爲涉及到iframe兼容舊有模塊,咱們無可避免,須要考慮跨窗口的頁面共享。
公共模塊
當一個團隊的模塊較多時,就會有一些公共的東西被抽取出來,這個過程,框架是沒法知道的,因此這個時候,咱們就須要考慮支持公共模塊。(模塊之間也有依賴關係)
基於以上的一些思考,咱們首先須要實現一個基礎的平臺網站,這個沒什麼難度,直接用 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方法來實現,以下:
const loadModule = (moduleName) => { return () => { return ModuleLoaderService.load(moduleName); }; }; const dynamicRoutes = []; modules.forEach(item => { dynamicRoutes.push({ path: item.path, canActivate: [AuthGuard], canActivateChild: [AuthGuard], loadChildren: loadModule(item.module) }); }); const appRoutes: 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<any> { let module = window['xxx'][moduleName]; if (module) { return Promise.resolve(module); } return new Promise((resolve, reject) => { let path = `${root}${moduleName}/app.js?rnd=${Math.random()}`; this._loadCss(moduleName); this.http.get(path) .toPromise() .then(res => { let code = res.text(); this._DomEval(code); return window['xxx'][moduleName]; }) .then(mod => { window['xxx'][moduleName] = mod; let AppModule = mod.AppModule; // route change will call useModuleStyles function. // this.useModuleStyles(moduleName, isDepModule); resolve(AppModule); }) .catch(err => { console.error('Load module failed: ', err); resolve(EmptyModule); }); }); } // 取自jQuery _DomEval(code, doc?) { doc = doc || document; let script = doc.createElement('script'); script.text = code; doc.head.appendChild(script).parentNode.removeChild(script); }
CSS的動態加載相對比較簡單,代碼以下:
_loadCss(moduleName: string): void { let cssPath = `${root}${moduleName}/app.css?rnd=${Math.random()}`; let link = 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 { let xxxModuleStyles = [].slice.apply(document.querySelectorAll('.xxx-module-style')); let moduleDeps = this._getModuleAndDeps(moduleName); moduleDeps.push(moduleName); xxxModuleStyles.forEach(link => { let disabled = true; for (let i = 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實現),有了它以後,咱們就能夠很方便的兩邊通訊了。
按照 Angular
官方的 Aot
編譯流程便可。
在後臺系統中,多Tab頁是比較經常使用了。可是多Tab頁,在單頁中使用,會有必定的性能風險,這個依據實際的狀況,進行使用。實現多Tab頁的核心就是如何動態加載組件以及如何獲取到要加載的組件。
多Tab頁面,實際就是一個 Tabset
組件,只是在 tab-item
的實現稍顯特別一些,相關動態加載的源碼:
@ViewChild('dynamicComponentContainer', { read: ViewContainerRef }) dynamicComponentContainer: ViewContainerRef; constructor( private elementRef: ElementRef, private renderer: Renderer2, private tabset: TabsetComponent, private resolver: ComponentFactoryResolver, private parentContexts: ChildrenOutletContexts ) { } public destroy() { let el = this.elementRef.nativeElement as HTMLElement; // tslint:disable-next-line:no-unused-expression el.parentNode && (el.parentNode.removeChild(el)); } private loadComponent(component: any) { let context = this.parentContexts.getContext(PRIMARY_OUTLET); let injector = ReflectiveInjector.fromResolvedProviders([], this.dynamicComponentContainer.injector); const resolver = context.resolver || this.resolver; let factory = resolver.resolveComponentFactory(component); // let componentIns = factory.create(injector); // this.dynamicComponentContainer.insert(componentIns.hostView); this.dynamicComponentContainer.createComponent(factory); }
注意:要考慮組件卸載方法,如 destroy()
爲了獲取到當前要渲染的組件,咱們能夠借用路由來抓取:
this.router.events.subscribe(evt => { if (evt instanceof NavigationEnd) { let pageComponent; let pageName; try { let nextRoute = this.route.children[0].children[0]; pageName = this.location.path(); pageComponent = nextRoute.component; } catch (e) { pageName = '$$notfound'; pageComponent = NotFoundComponent; } let idx = 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; }); } });
以上就是大概的實現思路以及部分相關的細節。其餘細節就須要根據實際的狀況,進行酌情處理。
該思路並不只限於 Angular
框架,使用 Vue、React
也能夠作到相似的效果。同時,這套東西也比較適合中小企業的後臺平臺(不必定非要多團隊,一個團隊按模塊開發也是不錯的)。
如須要了解更多細節,能夠參考:ngx-modular-platform,能給個 star
就更好了。
在此拋磚引玉,但願能集思廣益,提煉出更好的方案。歡迎討論和 提Issue
, 發PR
。