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
对于一个系统的框架设计来说,业务是一种桎梏,如果在框架中做了太多业务有关的事情,那么这个框架就变得狭隘且难以复用,它变成了你业务逻辑的一部分。在从会写代码开始,许多人就在追求代码上的自由:动态、按需加载你需要的部分。此时框架才满足足够抽象和需求无关的这种条件。所以高度抽象的前提是高度动态,今天我们先来聊聊关于Angular动态加载组件(这里的所有组件均指Component,下同)相关的问题。
Angular如何在组件中声明式加载组件
在开始之前,我们按照管理,通过angular-cli
创建一个工程,并且生成一个a
组件。
ng new dynamic-loader
cd dynamic-loader
ng g component a
使用ng serve
运行这个工程后,我们可以看到一行app works!
的文字。如果我们需要在app.comonent
中加载a.component
,会在app.comonent.html
中加入一行<app-a></app-a>
(这个selector
也是由angular-cli
进行生成),在浏览器中打开http://localhost:4200
,可以看到两行文字:
app works!
a works!
第二行文字(a.component
是由angular-cli
进行生成,通常生成的HTML中是a works!
)就是组件加载成功的标志。
Angular如何在组件中动态加载组件
在Angular中,我们通常需要一个宿主(Host)来给动态加载的组件提供一个容器。这个宿主在Angular中就是<ng-template>
。我们需要找到组件中的容器,并且将目标组件加载到这个宿主中,就需要通过创建一个指令(Directive)来对容器进行标记。
我们编辑app.comonent.html
文件:
app.comonent.html
<h1>
{{title}}
</h1>
<ng-template dl-host></ng-template>
可以看到,我们在<ng-template>
上加入了一个属性dl-host
(为了方便理解,解释一下这其实就是dynamic-load-host的简写),然后我们添加一个用于标记这个属性的指令dl-host.directive
:
dl-host.directive.ts
import { Directive, ViewContainerRef } from '@angular/core';
@Directive({
selector: '[dl-host]'
})
export class DlHostDirective {
constructor(public viewContainerRef: ViewContainerRef) { }
}
我们在这里注入了一个ViewContainerRef
的服务,它的作用就是为组件提供容器,并且提供了一系列的管理这些组件的方法。我们可以在app.component
中通过@ViewChild
获取到dl-host
的实例,因此进而获取到其中的ViewContainerRef
。另外,我们需要为ViewContainerRef
提供需要创建组件A的工厂,所以还需要在app.component
中注入一个工厂生成器ComponentFactoryResolver
,并且在app.module
中将需要生成的组件注册为一个@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.
所有通过类型进行命令式加载的组件都是入口组件。
这时候我们再去验证一下,界面展示应该和声明式加载组件相同。
Angular中如何动态添加宿主
我们不可能在每一个需要动态添加一个宿主组件,因为我们甚至都不会知道一个组件会在哪儿被创建出来并且被添加到页面中——就比如一个模态窗口,你希望在你需要使用的时候就能打开,而并非受限与宿主。在这种需求的前提下,我们就需要动态添加一个宿主到组件中。
现在,我们将app.component
作为宿主的载体,但是并不提供宿主的显式声明,我们动态去生成宿主。那么就先将app.comonent.html
文件改回去。
app.comonent.html
<h1>
{{title}}
</h1>
现在这个界面什么都没有了,就只剩下一个标题。那么接下来我们需要往DOM中注入一个Node,例如一个<div>
节点作为页面上的宿主,再通过工厂生成一个AComponent
并将这个组件的根节点添加到宿主上。这种情况下我们需要通过工厂直接创建组件,而不是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();
}
}
这时候我们再去验证一下,界面展示应该也和声明式加载组件相同。
但是通过这种方式添加的组件有一个问题,那就是无法对数据进行脏检查,比如我们对a.component.html
以及a.component.ts
做点小修改:
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!';
}
这个时候你会发现并不会显示a works!
这行文字。因此我们需要通知应用去处理这个组件的视图,对这个组件进行脏检查:
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();
}
}
如何与动态添加后的组件进行通信
组件间通信在声明式加载组件中通常直接写在了组件的属性中:[]
表示@Input
,()
表示@Output
,动态加载组件也是同理。比如我们期望通过外部传入a.component
的title
,并在title
被单击后由外部可以知道。所以我们先对动态加载的组件本身进行修改:
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!
的文字,点击这行文字,就可以看到console中输入了title clicked
。
写在后面
动态加载这项技术本身的目的是为了完成“框架业务无关化”,在接下来的相关文章中,还会围绕如何使用Angular实现框架设计的业务解耦进行展开。尽情期待。