0、前言
當一個公司有多個開發團隊時,我們可能會遇到這樣一些問題:
- 技術選項雜亂,大家各玩各
- 業務重復度高,各種通用api,登錄注銷,權限管理都需要重復實現(甚至一個團隊都需要重復實現)
- 業務壁壘,業務之間的互通變得比較麻煩
- 部署方式復雜,多個域名(或IP地址)訪問,給用戶造成較大的記憶難度
- 多套系統,風格難以統一
- 等等...
當然,解決方式有不少。以下就來講解下我們這邊的一種解決方案。
1、思路
Angualr
Angular
(注:非AngularJS) 是流行的前端 MVVM
框架之一,配合 TypeScript
,非常適合用來做后台管理系統。由於我們曾今的一套 Angularjs
開發框架,我們繼續選擇 Angular
來進行實現,並盡可能的兼容 AngularJS
的模塊。
SPA
選 SPA
還是多頁?多余 Mvvm
來說,多頁並不是標配。而且多頁開發中,我們勢必會關注更多的內容,包括通用header,footer,而不僅僅是頁面的核心內容。
模塊化
為什么要模塊化呢?當有多個團隊開發時(或者項目較大時),我們希望各個團隊開發出來的東西都是 模塊
(不僅限於JS模塊),這樣可以讓我們獨立發布、更新、刪除模塊,也能讓我們的關注點集中在特定模塊下,提高開發效率和可維護性。
平台化
我們需要有一個運行平台(Website站點),允許在里面運行指定的模塊。這樣就可以實現單一入口,也容易實現通用邏輯,模塊共享機制等等。
兼容 AngularJS 模塊
在考慮將框架切換到 Angular
時,我們無可避免的會遇到如何兼容當前已有模塊的問題。大致可選的方案如下:
- 參考
AngualrJS -> Angular
官方升級指南,一步步將模塊切換為Angular
的實現。(工作量大,需要開發團隊調整很多東西) 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方法來實現,如下:
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實現),有了它之后,我們就可以很方便的兩邊通信了。
AOT編譯
按照 Angular
官方的 Aot
編譯流程即可。
多Tab頁
在后台系統中,多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;
});
}
});
3、總結
以上就是大概的實現思路以及部分相關的細節。其他細節就需要根據實際的情況,進行酌情處理。
該思路並不僅限於 Angular
框架,使用 Vue、React
也可以做到類似的效果。同時,這套東西也比較適合中小企業的后台平台(不一定非要多團隊,一個團隊按模塊開發也是不錯的)。
如需要了解更多細節,可以參考:ngx-modular-platform,能給個 star
就更好了。
在此拋磚引玉,希望能集思廣益,提煉出更好的方案。歡迎討論和 提Issue
, 發PR
。