從源碼看angular/material2 中 dialog模塊的實現(xiàn)方法
本文將探討material2中popup彈窗即其Dialog模塊的實現(xiàn)。
使用方法
- 引入彈窗模塊
- 自己準備作為模板的彈窗內(nèi)容組件
- 在需要使用的組件內(nèi)注入 MatDialog 服務(wù)
- 調(diào)用 open 方法創(chuàng)建彈窗,并支持傳入配置、數(shù)據(jù),以及對關(guān)閉事件的訂閱
深入源碼
進入material2的源碼,先從 MatDialog 的代碼入手,找到這個 open 方法:
open<T>(
componentOrTemplateRef: ComponentType<T> | TemplateRef<T>,
config?: MatDialogConfig
): MatDialogRef<T> {
// 防止重復打開
const inProgressDialog = this.openDialogs.find(dialog => dialog._isAnimating());
if (inProgressDialog) {
return inProgressDialog;
}
// 組合配置
config = _applyConfigDefaults(config);
// 防止id沖突
if (config.id && this.getDialogById(config.id)) {
throw Error(`Dialog with id "${config.id}" exists already. The dialog id must be unique.`);
}
// 第一步:創(chuàng)建彈出層
const overlayRef = this._createOverlay(config);
// 第二步:在彈出層上添加彈窗容器
const dialogContainer = this._attachDialogContainer(overlayRef, config);
// 第三步:把傳入的組件添加到創(chuàng)建的彈出層中創(chuàng)建的彈窗容器中
const dialogRef = this._attachDialogContent(componentOrTemplateRef, dialogContainer, overlayRef, config);
// 首次彈窗要添加鍵盤監(jiān)聽
if (!this.openDialogs.length) {
document.addEventListener('keydown', this._boundKeydown);
}
// 添加進隊列
this.openDialogs.push(dialogRef);
// 默認添加一個關(guān)閉的訂閱 關(guān)閉時要移除此彈窗
// 當是最后一個彈窗時觸發(fā)全部關(guān)閉的訂閱并移除鍵盤監(jiān)聽
dialogRef.afterClosed().subscribe(() => this._removeOpenDialog(dialogRef));
// 觸發(fā)打開的訂閱
this.afterOpen.next(dialogRef);
return dialogRef;
}
總體看來彈窗的發(fā)起分為三部曲:
- 創(chuàng)建一個彈出層(其實是一個原生DOM,起宿主和入口的作用)
- 在彈出層上創(chuàng)建彈窗容器組件(負責提供遮罩和彈出動畫)
- 在彈窗容器中創(chuàng)建傳入的彈窗內(nèi)容組件(負責提供內(nèi)容)
彈出層的創(chuàng)建
對于其他組件,僅僅封裝模板以及內(nèi)部實現(xiàn)就足夠了,最多還要增加與父組件的數(shù)據(jù)、事件交互,所有這些事情,單使用angular Component就足夠?qū)崿F(xiàn)了,在何處使用就將組件選擇器放到哪里去完事。
但對于彈窗組件,事先并不知道會在何處使用,因此不適合實現(xiàn)為一個組件后通過選擇器安放到頁面的某處,而應(yīng)該將其作為彈窗插座放置到全局,并通過服務(wù)來調(diào)用。
material2也要面臨這個問題,這個彈窗插座是避免不了的,那就在內(nèi)部實現(xiàn)它,在實際調(diào)用彈窗方法時動態(tài)創(chuàng)建這個插座就可以了。要實現(xiàn)效果是:對用戶來說只是在單純調(diào)用一個 open 方法,由material2內(nèi)部來創(chuàng)建一個彈出層,并在這個彈出層上創(chuàng)建彈窗。
找到彈出層的創(chuàng)建代碼如下:
create(config: OverlayConfig = defaultConfig): OverlayRef {
const pane = this._createPaneElement(); // 彈出層DOM 將被添加到宿主DOM中
const portalHost = this._createPortalHost(pane); // 宿主DOM 將被添加到<body>末端
return new OverlayRef(portalHost, pane, config, this._ngZone); // 彈出層的引用
}
private _createPaneElement(): HTMLElement {
let pane = document.createElement('div');
pane.id = `cdk-overlay-${nextUniqueId++}`;
pane.classList.add('cdk-overlay-pane');
this._overlayContainer.getContainerElement().appendChild(pane); // 將創(chuàng)建好的帶id的彈出層添加到宿主
return pane;
}
private _createPortalHost(pane: HTMLElement): DomPortalHost {
// 創(chuàng)建宿主
return new DomPortalHost(pane, this._componentFactoryResolver, this._appRef, this._injector);
}
其中最關(guān)鍵的方法其實是 getContainerElement() , material2把最"丑"最不angular的操作放在了這里面,看看其實現(xiàn):
getContainerElement(): HTMLElement {
if (!this._containerElement) { this._createContainer(); }
return this._containerElement;
}
protected _createContainer(): void {
let container = document.createElement('div');
container.classList.add('cdk-overlay-container');
document.body.appendChild(container); // 在body下創(chuàng)建頂層的宿主 姑且稱之為彈出層容器(OverlayContainer)
this._containerElement = container;
}
彈窗容器的創(chuàng)建
跳過其他細節(jié),現(xiàn)在得到了一個彈出層引用 overlayRef。material2接下來給它添加了一個彈窗容器組件,這個組件是material2自己寫的一個angular組件,打開彈窗時的遮罩部分以及彈窗的外輪廓其實就是這個組件,對于為何要再套這么一層容器,有其一些考慮。
動畫效果的保護
這樣動態(tài)創(chuàng)建的組件有一個缺點,那就是其銷毀是無法觸發(fā)angular動畫的,因為一瞬間就銷毀掉了,所以material2為了實現(xiàn)動畫效果,多加了這么一個容器來實現(xiàn)動畫,在關(guān)閉彈窗時,實際上是在播放彈窗的關(guān)閉動畫,然后監(jiān)聽容器的動畫狀態(tài)事件,在完成關(guān)閉動畫后才執(zhí)行銷毀彈窗的一系列代碼,這個過程與其為難用戶來實現(xiàn),不如自己給封裝了。
注入服務(wù)的保護
目前版本的angular關(guān)于在動態(tài)創(chuàng)建的組件中注入服務(wù)還存在一個注意點,就是直接創(chuàng)建出的組件無法使用隱式的依賴注入,也就是說,直接在組件的 constructor 中聲明服務(wù)對象的實例是不起作用的,而必須先注入 Injector ,再使用這個 Injector 把注入的服務(wù)都 get 出來:
private 服務(wù);
constructor(
private injector: Injector
// private 服務(wù): 服務(wù)類 // 這樣是無效的
) {
this.服務(wù) = injector.get('服務(wù)類名');
}
解決的辦法是不直接創(chuàng)建出組件來注入服務(wù),而是先創(chuàng)建一個指令,再在這個指令中創(chuàng)建組件并注入服務(wù)使用,這時隱式的依賴注入就又有效了,material2就是這么干的:
<ng-template cdkPortalHost></ng-template>
其中的 cdkPortalHost 指令就是用來后續(xù)創(chuàng)建組件的。
所以創(chuàng)建這么一個彈窗容器組件,用戶就感覺不到這一點,很順利的像普通組件一樣注入服務(wù)并使用。
創(chuàng)建彈窗容器的核心方法在 dom-portal-host.ts 中:
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
// 創(chuàng)建工廠
let componentFactory = this._componentFactoryResolver.resolveComponentFactory(portal.component);
let componentRef: ComponentRef<T>;
if (portal.viewContainerRef) {
componentRef = portal.viewContainerRef.createComponent(
componentFactory,
portal.viewContainerRef.length,
portal.injector || portal.viewContainerRef.parentInjector);
this.setDisposeFn(() => componentRef.destroy());
// 暫不知道為何有指定宿主后面還要把它添加到宿主元素DOM中
} else {
componentRef = componentFactory.create(portal.injector || this._defaultInjector);
this._appRef.attachView(componentRef.hostView);
this.setDisposeFn(() => {
this._appRef.detachView(componentRef.hostView);
componentRef.destroy();
});
// 到這一步創(chuàng)建出了經(jīng)angular處理的DOM
}
// 將創(chuàng)建的彈窗容器組件直接append到彈出層DOM中
this._hostDomElement.appendChild(this._getComponentRootNode(componentRef));
// 返回組件的引用
return componentRef;
}
所做的事情無非就是動態(tài)創(chuàng)建組件的四步曲:
- 創(chuàng)建工廠
- 使用工廠創(chuàng)建組件
- 將組件整合進AppRef(同時設(shè)置一個移除的方法)
- 在DOM中插入這個組件的原始節(jié)點
彈窗內(nèi)容
從上文可以知道,得到的彈窗容器組件中存在一個宿主指令,實際上是在這個宿主指令中創(chuàng)建彈窗內(nèi)容組件。進入宿主指令的代碼可以找到 attachComponentPortal 方法:
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
portal.setAttachedHost(this);
// If the portal specifies an origin, use that as the logical location of the component
// in the application tree. Otherwise use the location of this PortalHost.
// 如果入口已經(jīng)有宿主則使用那個宿主
// 否則使用 PortalHost 作為宿主
let viewContainerRef = portal.viewContainerRef != null ?
portal.viewContainerRef :
this._viewContainerRef;
// 在宿主上動態(tài)創(chuàng)建組件的代碼
let componentFactory = this._componentFactoryResolver.resolveComponentFactory(portal.component);
let ref = viewContainerRef.createComponent( // 使用 ViewContainerRef 動態(tài)創(chuàng)建組件到當前視圖容器(也就是彈窗容器指令)
componentFactory, viewContainerRef.length,
portal.injector || viewContainerRef.parentInjector
);
super.setDisposeFn(() => ref.destroy());
this._portal = portal;
return ref;
}
最后這一步就非常明了了,正是官方文檔中使用的動態(tài)創(chuàng)建組件的方式(ViewContainerRef),至此彈窗已經(jīng)成功彈出到界面中了。
彈窗的關(guān)閉
還有最后一個要注意的點就是彈窗如何關(guān)閉,從上文可以知道應(yīng)該要先執(zhí)行關(guān)閉動畫,然后才能銷毀彈窗,material2的彈窗容器組件添加了一堆節(jié)點:
host: {
'class': 'mat-dialog-container',
'tabindex': '-1',
'[attr.role]': '_config?.role',
'[attr.aria-labelledby]': '_ariaLabelledBy',
'[attr.aria-describedby]': '_config?.ariaDescribedBy || null',
'[@slideDialog]': '_state',
'(@slideDialog.start)': '_onAnimationStart($event)',
'(@slideDialog.done)': '_onAnimationDone($event)',
}
其中需要關(guān)注的就是material2在容器組件中添加了一個動畫叫 slideDialog ,并為其設(shè)置了動畫事件,現(xiàn)在關(guān)注動畫完成事件的回調(diào):
_onAnimationDone(event: AnimationEvent) {
if (event.toState === 'enter') {
this._trapFocus();
} else if (event.toState === 'exit') {
this._restoreFocus();
}
this._animationStateChanged.emit(event);
this._isAnimating = false;
}
這里發(fā)射了這個事件,并在 MatDialogRef 中訂閱:
constructor(
private _overlayRef: OverlayRef,
private _containerInstance: MatDialogContainer,
public readonly id: string = 'mat-dialog-' + (uniqueId++)
) {
// 添加彈窗開啟的訂閱 這里的 RxChain 是material2自己對rxjs的工具類封裝
RxChain.from(_containerInstance._animationStateChanged)
.call(filter, event => event.phaseName === 'done' && event.toState === 'enter')
.call(first)
.subscribe(() => {
this._afterOpen.next();
this._afterOpen.complete();
});
// 添加彈窗關(guān)閉的訂閱,并且需要在收到回調(diào)后銷毀彈窗
RxChain.from(_containerInstance._animationStateChanged)
.call(filter, event => event.phaseName === 'done' && event.toState === 'exit')
.call(first)
.subscribe(() => {
this._overlayRef.dispose();
this._afterClosed.next(this._result);
this._afterClosed.complete();
this.componentInstance = null!;
});
}
/**
* 這個也就是實際使用時的關(guān)閉方法
* 所做的事情是添加beforeClose的訂閱并執(zhí)行 _startExitAnimation 以開始關(guān)閉動畫
* 底層做的事是 改變了彈窗容器中 slideDialog 的狀態(tài)值
*/
close(dialogResult?: any): void {
this._result = dialogResult; // 把傳入的結(jié)果賦值給私有變量 _result 以便在上面的 this._afterClosed.next(this._result) 中使用
// Transition the backdrop in parallel to the dialog.
RxChain.from(this._containerInstance._animationStateChanged)
.call(filter, event => event.phaseName === 'start')
.call(first)
.subscribe(() => {
this._beforeClose.next(dialogResult);
this._beforeClose.complete();
this._overlayRef.detachBackdrop();
});
this._containerInstance._startExitAnimation();
}
總結(jié)
以上就是整個material2 dialog能力走通的過程,可見即使是 angular 這么完善又龐大的框架,想要完美解耦封裝彈窗能力也不能完全避免原生DOM操作。
除此之外給我的感覺還有——無論是angular還是material2,它們對TypeScript的使用都讓我自嘆不如,包括但不限于抽象類、泛型等裝逼技巧,把它們的源碼慢慢看下來,著實能學到不少東西。
相關(guān)文章
使用angular-cli webpack創(chuàng)建多個包的方法
這篇文章主要介紹了使用angular-cli webpack創(chuàng)建多個包的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-10-10
AngularJS基礎(chǔ) ng-switch 指令簡單示例
本文主要講解AngularJS ng-switch 指令,這里對ng-switch 指令的基礎(chǔ)資料做了詳細整理,并附代碼示例,有興趣的小伙伴可以參考下2016-08-08
AngularJs unit-testing(單元測試)詳解
本文主要介紹AngularJs unit-testing(單元測試)的內(nèi)容,這里整理了單元測試的知識,及示例代碼,有興趣的朋友可以參考下2016-09-09
對angularjs框架下controller間的傳值方法詳解
今天小編就為大家分享一篇對angularjs框架下controller間的傳值方法詳解,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2018-10-10
Angular限制input框輸入金額(是小數(shù)的話只保留兩位小數(shù)點)
最近做項目遇到這樣的需求輸入框要求輸入金額,只能輸入數(shù)字,可以是小數(shù),必須保留小數(shù)點后兩位。下面分為兩部分代碼給大家介紹實現(xiàn)代碼,需要的的朋友參考下吧2017-07-07

