承接上文,本文將從一個基本的angular啟動項目開始搭建一個具有基本功能、較通用、低耦合、可擴展的popup彈窗(臉紅),主要分為以下幾步:
- 基本項目結構搭建
- 彈窗服務
- 彈窗的引用對象
- 准備作為模板的彈窗組件
- 使用方法
基本項目結構
因為打算將我們的popup彈窗設計為在npm托管的包,以便其他項目可以下載並使用,所以我們的啟動項目大概包含如下結構:
- package.json // 定義包的基本信息,包括名字、版本號、依賴等
- tsconfig.json // angular項目基於typescript進行搭建,需要此文件來指定ts的編譯規則
- ... // tslint等一些幫助開發的配置文件
- index.ts // 放在根目錄,導出需要導出的模塊、服務等
- /src // 實際模塊的實現
- /src/module.ts // 模塊的定義
- /src/service.ts // 彈窗服務
- /src/templates/* // 作為模板的組件
- /src/popup.ref.ts // 對創建好的組件引用的封裝對象
- /src/animations.ts // 動畫的配置
現在我們只來關心src目錄下的實現。
彈窗服務
彈窗服務的職責是提供一個叫做open的方法,用來創建出組件並顯示,還得對創建好的組件進行良好的控制:
import { Injectable, ApplicationRef, ComponentFactoryResolver,
ComponentRef, EmbeddedViewRef } from '@angular/core';
import { YupRef, ComponentType } from './popup.ref';
@Injectable()
export class DialogService {
private loadRef: YupRef<LoadComponent>;
constructor(
private appRef: ApplicationRef,
private compFactRes: ComponentFactoryResolver
) {}
// 創建一個組件,組件通過泛型傳入以做到通用
public open<T>(component: ComponentType<T>, config: any) {
// 創建組件工廠
const factory = this.compFactRes.resolveComponentFactory(component);
// 創建一個新的彈窗引用
const dialogRef = new YupRef(factory, config);
// 將創建好的組件引用(由彈窗引用創建並返回)append到body標簽下
window.document.body.appendChild(this.getComponentRootNode(dialogRef.componentRef()));
// 加入angular臟檢查
this.appRef.attachView(dialogRef.componentRef().hostView);
// 將創建的彈窗引用返回給外界
return dialogRef;
}
// 參考自Material2,將ComponentRef類型的組件引用轉換為DOM節點
private getComponentRootNode(componentRef: ComponentRef<any>): HTMLElement {
return (componentRef.hostView as EmbeddedViewRef<any>).rootNodes[0] as HTMLElement;
}
}
// 參考自Material2 用於作為傳入組件的類型
export interface ComponentType<T> {
new (...args: any[]): T;
}
彈窗的引用對象
上面服務中的open方法實際上把創建組件的細節通過new一個YupRef即彈窗引用來實現,這是因為考慮到服務本身是單例,如果僅使用open方法直接創建多個彈窗,在使用時會丟失除了最后一個彈窗外的控制能力,筆者這里采用的辦法是將創建的彈窗封裝成一個類即YupRef:
import { ComponentRef, InjectionToken, ReflectiveInjector, ComponentFactory } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
// 用於注入自定義數據到創建的組件中
export const YUP_DATA = new InjectionToken<any>('YUPPopupData');
export class YupRef<T> {
// 彈窗關閉的訂閱
private afterClose$: Subject<any>;
// 彈窗引用變量
private dialogRef: ComponentRef<T>;
constructor(
private factory: ComponentFactory<T>,
private config: any // 傳入的自定義數據
) {
this.afterClose$ = new Subject<any>();
this.dialogRef = this.factory.create(
ReflectiveInjector.resolveAndCreate([
{provide: YUP_DATA, useValue: config}, // 注入自定義數據
{provide: YupRef, useValue: this} // 注入自身,這樣就可以在創建的組件里控制組件的關閉等
])
);
}
// 提供給外界的對窗口關閉的訂閱
public afterClose(): Observable<any> {
return this.afterClose$.asObservable();
}
// 關閉方法,將銷毀組件
public close(data?: any) {
this.afterClose$.next(data);
this.afterClose$.complete();
this.dialogRef.destroy();
}
// 提供給彈窗服務以幫助添加到DOM中
public componentRef() {
return this.dialogRef;
}
}
這樣一來每次調用open方法后都能得到一個YupRef對象,提供了關閉方法以及對關閉事件的訂閱方法。
預制彈窗組件
彈窗服務中的open方法需要兩個參數,第二個是傳入的自定義數據,第一個就是需要創建的組件了,現在我們創建出幾個預制組件,以dialog.component為例:
import { Component, Injector } from '@angular/core';
import { YupRef, YUP_DATA } from '../popup.ref';
import { mask, dialog } from '../animations';
@Component({
template: `
<div class="yup-mask" [@mask]="disp" (click)="!data?.mask && close(false)"></div>
<div class="yup-body" [@dialog]="disp">
<div class="yup-body-head">{{data?.title || '消息'}}</div>
<div class="yup-body-content">{{data?.msg || ' '}}</div>
<div class="yup-body-btns">
<div class="btn default" (click)="close(false)">{{data?.no || '取消'}}</div>
<div class="btn primary" (click)="close(true)">{{data?.ok || '確認'}}</div>
</div>
</div>
`,
styles: [`這里省略一堆樣式`]
animations: [mask, dialog]
})
export class DialogComponent {
public data: {
title?: string,
msg?: string,
ok?: string,
no?: string,
mask?: string
};
public dialogRef: YupRef<DialogComponent>;
public disp: string;
constructor(
private injector: Injector
) {
this.data = this.injector.get(YUP_DATA);
this.dialogRef = this.injector.get(YupRef);
this.disp = 'init';
setTimeout(() => {
this.disp = 'on';
});
}
public close(comfirm: boolean) {
this.disp = 'off';
setTimeout(() => {
this.disp = 'init';
this.dialogRef.close(comfirm);
}, 300);
}
}
用筆者這種方式創建的組件有兩個尷尬的小問題:
- 不能使用隱式的依賴注入了,必須注入Injector服務來手動get到注入的兩個依賴,即代碼中的
this.injector.get(YUP_DATA) 和 this.injector.get(YupRef) 。 - 直接使用angular動畫會失效,因為是暴力添加到DOM中的方式,必須手動setTimeout過等動畫結束再真正銷毀組件。
創建好組件后再服務中添加快捷創建此組件的方法:
public dialog(config: {
title?: string,
msg?: string,
ok?: string,
no?: string,
mask?: boolean
}) {
return this.open(DialogComponent, config);
}
額外需要提一點是雖然這樣創建的組件沒有被一開始就添加到頁面中,仍然需要在所屬模塊的declaration中聲明,並且還得在entryComponent中聲明過,否則angular就會通過報錯的方式讓你這么做,就像下面這個彈窗模塊的定義這樣:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DialogComponent, AlertComponent, ToastComponent, LoadComponent } from './templates';
import { DialogService } from './service';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
declarations: [DialogComponent, AlertComponent, ToastComponent, LoadComponent],
imports: [ NoopAnimationsModule, CommonModule ],
exports: [],
providers: [DialogService],
entryComponents: [DialogComponent, AlertComponent, ToastComponent, LoadComponent]
})
export class YupModule {}
而此彈窗模塊真正需要導出的東西有4個,都列在index.ts中:
export { YupModule } from './module'; // 需要在AppModule中引入
export { DialogService as Yup } from './service'; // 用於發起彈窗
export { YupRef, YUP_DATA } from './popup.ref'; // 用於創建自定義彈窗時提供控制
使用方法
最終在外界的使用方式如下:
constructor(
public yup: Yup // 其實是DialogService,被筆者改了名
) { }
public ngOnInit() {
this.yup.dialog({msg: '彈不彈?', title: '我彈', ok: '彈彈', no: '別彈了', mask: true}).afterClose().subscribe((res) => {
if (res) {
console.log('點擊了確定');
} else {
console.log('點擊了取消');
}
});
}
當不想使用預制的彈窗組件時,大可以自行創建好一個組件,然后使用open方法:
this.yup.open(CustomComponent, '我是自定義數據').afterClose().subscribe((res) => {
console.log(`我已經被關閉了,不過我能攜帶出來數據: 【${res}】`);
});
乍一看是不是有點接近Material2的Dialog的使用呢 😃 ,不過來自Google Inc的Material2版究極Dialog模塊做了極變態的抽象以及組件嵌套,推薦勇士前去研究。
詳細的源代碼筆者托管在Github上,幾個預制組件是參照weui的樣式實現的。