深入Angular:組件(Component)動態(tài)加載

Felt like the weight of the world was on my shoulders…

Pressure to break or retreat at every turn;

Facing the fear that the truth I discovered;

No telling how all this will work out;

But I've come too far to go back now.

~I am looking for freedom,

Looking for freedom…

And to find it cost me everything I have.

Well I am looking for freedom,

Looking for freedom...

And to find it may take everything I have!

—— Freedom by Anthony Hamilton

對于一個(gè)系統(tǒng)的框架設(shè)計(jì)來說,業(yè)務(wù)是一種桎梏,如果在框架中做了太多業(yè)務(wù)有關(guān)的事情,那么這個(gè)框架就變得狹隘且難以復(fù)用,它變成了你業(yè)務(wù)邏輯的一部分。在從會寫代碼開始,許多人就在追求代碼上的自由:動態(tài)、按需加載你需要的部分。此時(shí)框架才滿足足夠抽象和需求無關(guān)的這種條件。所以高度抽象的前提是高度動態(tài),今天我們先來聊聊關(guān)于Angular動態(tài)加載組件(這里的所有組件均指Component,下同)相關(guān)的問題。

Angular如何在組件中聲明式加載組件

在開始之前,我們按照管理,通過angular-cli創(chuàng)建一個(gè)工程,并且生成一個(gè)a組件。

ng new dynamic-loader
cd dynamic-loader
ng g component a

使用ng serve運(yùn)行這個(gè)工程后,我們可以看到一行app works!的文字。如果我們需要在app.comonent中加載a.component,會在app.comonent.html中加入一行<app-a></app-a>(這個(gè)selector也是由angular-cli進(jìn)行生成),在瀏覽器中打開http://localhost:4200,可以看到兩行文字:

app works!
a works!

第二行文字(a.component是由angular-cli進(jìn)行生成,通常生成的HTML中是a works!)就是組件加載成功的標(biāo)志。

Angular如何在組件中動態(tài)加載組件

在Angular中,我們通常需要一個(gè)宿主(Host)來給動態(tài)加載的組件提供一個(gè)容器。這個(gè)宿主在Angular中就是<ng-template>。我們需要找到組件中的容器,并且將目標(biāo)組件加載到這個(gè)宿主中,就需要通過創(chuàng)建一個(gè)指令(Directive)來對容器進(jìn)行標(biāo)記。

我們編輯app.comonent.html文件:

app.comonent.html

<h1>
    {{title}}
</h1>
<ng-template dl-host></ng-template>

可以看到,我們在<ng-template>上加入了一個(gè)屬性dl-host(為了方便理解,解釋一下這其實(shí)就是dynamic-load-host的簡寫),然后我們添加一個(gè)用于標(biāo)記這個(gè)屬性的指令dl-host.directive

dl-host.directive.ts

import { Directive, ViewContainerRef } from '@angular/core';
@Directive({
    selector: '[dl-host]'
})
export class DlHostDirective {
    constructor(public viewContainerRef: ViewContainerRef) { }
}

我們在這里注入了一個(gè)ViewContainerRef的服務(wù),它的作用就是為組件提供容器,并且提供了一系列的管理這些組件的方法。我們可以在app.component中通過@ViewChild獲取到dl-host的實(shí)例,因此進(jìn)而獲取到其中的ViewContainerRef。另外,我們需要為ViewContainerRef提供需要創(chuàng)建組件A的工廠,所以還需要在app.component中注入一個(gè)工廠生成器ComponentFactoryResolver,并且在app.module中將需要生成的組件注冊為一個(gè)@NgModule.entryComponent:

app.comonent.ts

import { Component, ViewChild, ComponentFactoryResolver } from '@angular/core';
import { DlHostDirective } from './dl-host.directive';
import { AComponent } from './a/a.component';
@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent {
    title = 'app works!';
    @ViewChild(DlHostDirective) dlHost: DlHostDirective;
    constructor(private componentFactoryResolver: ComponentFactoryResolver) { }
    
    ngAfterViewInit() {
        this.dlHost.viewContainerRef.createComponent(
            this.componentFactoryResolver.resolveComponentFactory(AComponent)
        );
    }
}

app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { AComponent } from './a/a.component';
import { DlHostDirective } from './dl-host.directive';

@NgModule({
    declarations: [AppComponent, AComponent, DlHostDirective],
    imports: [BrowserModule, FormsModule, HttpModule],
    entryComponents: [AComponent],
    providers: [],
    bootstrap: [AppComponent]
})
export class AppModule { }

這里就不得不提到一句什么是entry component。以下是文檔原文:

An entry component is any component that Angular loads imperatively by type.
所有通過類型進(jìn)行命令式加載的組件都是入口組件。

這時(shí)候我們再去驗(yàn)證一下,界面展示應(yīng)該和聲明式加載組件相同。

Angular中如何動態(tài)添加宿主

我們不可能在每一個(gè)需要動態(tài)添加一個(gè)宿主組件,因?yàn)槲覀兩踔炼疾粫酪粋€(gè)組件會在哪兒被創(chuàng)建出來并且被添加到頁面中——就比如一個(gè)模態(tài)窗口,你希望在你需要使用的時(shí)候就能打開,而并非受限與宿主。在這種需求的前提下,我們就需要動態(tài)添加一個(gè)宿主到組件中。

現(xiàn)在,我們將app.component作為宿主的載體,但是并不提供宿主的顯式聲明,我們動態(tài)去生成宿主。那么就先將app.comonent.html文件改回去。

app.comonent.html

<h1>
    {{title}}
</h1>

現(xiàn)在這個(gè)界面什么都沒有了,就只剩下一個(gè)標(biāo)題。那么接下來我們需要往DOM中注入一個(gè)Node,例如一個(gè)<div>節(jié)點(diǎn)作為頁面上的宿主,再通過工廠生成一個(gè)AComponent并將這個(gè)組件的根節(jié)點(diǎn)添加到宿主上。這種情況下我們需要通過工廠直接創(chuàng)建組件,而不是ComponentContanerRef。

app.comonent.ts

import {
    Component, ComponentFactoryResolver, Injector, ElementRef,
    ComponentRef, AfterViewInit, OnDestroy
} from '@angular/core';

import { AComponent } from './a/a.component';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})

export class AppComponent implements OnDestroy {
    title = 'app works!';
    component: ComponentRef<AComponent>;
    
    constructor(
        private componentFactoryResolver: ComponentFactoryResolver,
        private elementRef: ElementRef,
        private injector: Injector
    ) {
        this.component = this.componentFactoryResolver
            .resolveComponentFactory(AComponent)
            .create(this.injector);
    }

    ngAfterViewInit() {
        let host = document.createElement("div");
        host.appendChild((this.component.hostView as any).rootNodes[0]);
        this.elementRef.nativeElement.appendChild(host);
    }
    
    ngOnDestroy() {
        this.component.destroy();
    }
}

這時(shí)候我們再去驗(yàn)證一下,界面展示應(yīng)該也和聲明式加載組件相同。

但是通過這種方式添加的組件有一個(gè)問題,那就是無法對數(shù)據(jù)進(jìn)行臟檢查,比如我們對a.component.html以及a.component.ts做點(diǎn)小修改:

a.comonent.html

<p>
    {{title}}
</p>

a.comonent.ts

import { Component } from '@angular/core';

@Component({
    selector: 'app-a',
    templateUrl: './a.component.html',
    styleUrls: ['./a.component.css']
})

export class AComponent {
    title = 'a works!';
}

這個(gè)時(shí)候你會發(fā)現(xiàn)并不會顯示a works!這行文字。因此我們需要通知應(yīng)用去處理這個(gè)組件的視圖,對這個(gè)組件進(jìn)行臟檢查:

app.comonent.ts

import {
    Component, ComponentFactoryResolver, Injector, ElementRef,
    ComponentRef, ApplicationRef, AfterViewInit, OnDestroy
} from '@angular/core';

import { AComponent } from './a/a.component';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})

export class AppComponent implements OnDestroy {
    title = 'app works!';
    component: ComponentRef<AComponent>;
    
    constructor(
        private componentFactoryResolver: ComponentFactoryResolver,
        private elementRef: ElementRef,
        private injector: Injector,
        private appRef: ApplicationRef
    ) {
        this.component = this.componentFactoryResolver
            .resolveComponentFactory(AComponent)
            .create(this.injector);
        appRef.attachView(this.component.hostView);
    }

    ngAfterViewInit() {
        let host = document.createElement("div");
        host.appendChild((this.component.hostView as any).rootNodes[0]);
        this.elementRef.nativeElement.appendChild(host);
    }
    
    ngOnDestroy() {
        this.appRef.detachView(this.component.hostView);
        this.component.destroy();
    }
}

如何與動態(tài)添加后的組件進(jìn)行通信

組件間通信在聲明式加載組件中通常直接寫在了組件的屬性中:[]表示@Input,()表示@Output,動態(tài)加載組件也是同理。比如我們期望通過外部傳入a.componenttitle,并在title被單擊后由外部可以知道。所以我們先對動態(tài)加載的組件本身進(jìn)行修改:

a.comonent.html

<p (click)="onTitleClick()">
    {{title}}
</p>

a.comonent.ts

import { Component, Output, Input, EventEmitter } from '@angular/core';

@Component({
    selector: 'app-a',
    templateUrl: './a.component.html',
    styleUrls: ['./a.component.css']
})

export class AComponent {

    @Input() title = 'a works!';
    @Output() onTitleChange = new EventEmitter<any>();
    
    onTitleClick() {
        this.onTitleChange.emit();
    }
    
}

然后再來修改外部組件:

app.comonent.ts

import {
    Component, ComponentFactoryResolver, Injector, ElementRef,
    ComponentRef, ApplicationRef, AfterViewInit, OnDestroy
} from '@angular/core';

import { AComponent } from './a/a.component';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})

export class AppComponent implements OnDestroy {
    title = 'app works!';
    component: ComponentRef<AComponent>;
    
    constructor(
        private componentFactoryResolver: ComponentFactoryResolver,
        private elementRef: ElementRef,
        private injector: Injector,
        private appRef: ApplicationRef
    ) {
        this.component = this.componentFactoryResolver
            .resolveComponentFactory(AComponent)
            .create(this.injector);
        appRef.attachView(this.component.hostView);
        (<AComponent>this.component.instance).onTitleChange
            .subscribe(() => {
                console.log("title clicked")
            });
        (<AComponent>this.component.instance).title = "a works again!";
    }

    ngAfterViewInit() {
        let host = document.createElement("div");
        host.appendChild((this.component.hostView as any).rootNodes[0]);
        this.elementRef.nativeElement.appendChild(host);
    }
    
    ngOnDestroy() {
        this.appRef.detachView(this.component.hostView);
        this.component.destroy();
    }
}

查看頁面可以看到界面就顯示了a works again!的文字,點(diǎn)擊這行文字,就可以看到console中輸入了title clicked。

寫在后面

動態(tài)加載這項(xiàng)技術(shù)本身的目的是為了完成“框架業(yè)務(wù)無關(guān)化”,在接下來的相關(guān)文章中,還會圍繞如何使用Angular實(shí)現(xiàn)框架設(shè)計(jì)的業(yè)務(wù)解耦進(jìn)行展開。盡情期待。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容