Angular 中的动态加载

Angular 中的动态加载

本篇主要介绍 Angular 当中的动态加载相关内容

基本原理

Angular 当中的动态加载主要涉及到以下三个 API

  • ViewChild
    • 一个属性装饰器,用来从模版视图中获取对应的元素
    • 可以通过模版变量获取,获取的时候可以通过 read 属性设置查询的条件
    • 简单来说就是可以把视图转为不同的实例
  • ViewContainerRef
    • 一个视图容器,可以在上面创建、插入、删除组件等
  • ComponentFactoryResolver
    • 一个服务,动态加载组件的核心,这个服务可以将一个组件实例呈现到另一个组件视图上

所以总结起来简单来说就是

  • 特定区域就是一个视图容器,可以通过 ViewChild 来实现获取和查询
  • 然后使用 ComponentFactoryResolver 将『已经声明但是未实例化』的组件解析成可以动态加载的 Component
  • 再将此 Component 呈现到之前的视图容器当中

下面我们就来看看具体的实现

动态加载已经声明的组件

引用的是 Angular 组件基础内容 当中的示例,模版文件十分简洁,就是通过创建一个 #dyncomp 句柄,以便获得引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 注意引入的内容,上文提到的所需的三个都要引入
import {
Component,
OnInit,
ViewChild,
ViewContainerRef,
ComponentFactoryResolver,
ComponentRef
} from '@angular/core'
import { Child11Component } from './child11/child11.component'
import { state } from '@angular/animations'

@Component({
selector: 'dynamic-comp',
templateUrl: './dynamic-comp.component.html',
styleUrls: ['./dynamic-comp.component.scss']
})
export class DynamicCompComponent implements OnInit {

// 这里引用模板里面定义的 dyncomp 容器标签
// 通过模版变量名来获取引用,然后可以通过 read 选项设置一个 ViewContainerRef
// 最终在生命周期 ngAfterViewInit 之后便会获取此区域的一个 ViewContainerRef 实例
@ViewChild('dyncomp', { read: ViewContainerRef })
dyncomp: ViewContainerRef

comp1: ComponentRef<Child11Component>
comp2: ComponentRef<Child11Component>

constructor(
private resolver: ComponentFactoryResolver) {
}

ngOnInit() {
}

ngAfterContentInit() {
console.log(`动态创建组件的实例`)

// 这里是主要的加载组件函数
// 通过在 constructor 当中注入的 ComponentFactoryResolver 服务
// 调用其 resolveComponentFactory 来解析一个已经声明的组件并得到一个可动态加载的 componentFactory
// 最后直接调用容器的 createComponent 函数将其解析出来的 componentFactory 动态的呈现到容器视图上
const childComp = this.resolver.resolveComponentFactory(Child11Component)
this.comp1 = this.dyncomp.createComponent(childComp)

// this.comp1.instance.title = `父层设置的新标题`

// this.comp1.instance.btnClick.subscribe((param) => {
// console.log('==>' + param)
// })

// 可以创建多个组件实例出来
// let temp1 = this.dyncomp.createComponent(childComp)
// temp1.instance.title = '第2个动态子组件'

// let temp2 = this.dyncomp.createComponent(childComp)
// temp2.instance.title = '第3个动态子组件'

// let temp3 = this.dyncomp.createComponent(childComp)
// temp3.instance.title = '第4个动态子组件'

// let temp4 = this.dyncomp.createComponent(childComp)
// temp4.instance.title = '第5个动态子组件'

// let temp5 = this.dyncomp.createComponent(childComp)
// temp5.instance.title = '第6个动态子组件'

// createComponent 方法可以调用很多次,会动态创建出多个组件实例,方法有第二个参数,表示组件渲染的顺序
this.comp2 = this.dyncomp.createComponent(childComp, 0)
this.comp2.instance.title = `第二个子组件`
}

public destoryChild(): void {
this.comp1.destroy()
this.comp2.destroy()
}
}

上面特别需要注意的一点就是,对于动态加载的组件必须要声明在特性模块的 entryComponents 中,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...

@NgModule({
declarations: [
AppComponent,
DynamicCompComponent,
Child11Component
],
imports: [
BrowserModule
],
providers: [],
bootstrap: [AppComponent],
entryComponents: [Child11Component]
})

export class AppModule { }

也就是说对于此处声明的组件,Angular 都会创建一个 ComponentFactory 并将其存储在 ComponentFactoryResolve 中,也就是动态加载必须的步骤

动态加载还未创建的组件

在上面我们实现了对于已经创建好的组件的动态加载,那么如果是还未创建的组件呢?其实简单来说这种情况就是我们需要『动态创建』不存在的组件而不是已经声明的组件,不过这种情况一般很少遇见,但是如果遇到可以考虑使用 Compiler,它作用就是用于在运行的时候运行 Angular 编译器来创建 ComponentFactory 的服务,然后可以使用它来创建和呈现组件实例

我们知道,容器创建和呈现组件的函数需要一个 ComponentFactory,而 Compiler 能够在运行的时候动态创建一个 ComponentFactory,而有了 ComponentFactory 以后,我们就可以使用上面的方式来进行动态加载了,下面是一个简单的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 首先引入 Compiler
import { Compiler } from '@angular/core'

// ...

// 然后在构造函数当中进行注入
constructor(
private cp: Compiler
) {}

// ...

// 在创建的时候就不再是指定组件了而是模块,容器的呈现还是一样,直接 createComponent
this.comp1 = this.dyncomp.createComponent(this.createModule())

// 新增一个 createModule 函数
createModule() {

// 通过 Component 和 NgModule 修饰器动态创建新的组件和模块
@Component({
template: '动态组件'
})

class DynamicCom {}

@NgModule({
declarations: [
DynamicCom
]
})

class DynamicModule { }

// 然后调用 Compiler 的 compileModuleAndAllComponentsSync 方法获取一个新的 ComponentFactory
return this.cp.compileModuleAndAllComponentsSync(DynamicModule).componentFactories
.find(comFac => comFac.componentType === DynamicCom)
}

引申出来的问题

在实际开发过程当中,通常的情况下我们不可能仅仅创建了一个动态组件就丢在那里不管它了,一般来说都会有数据的传递,比如要进行更新之类的操作,那么这里就可能会遇到在创建动态组件后,调用 componentRef.instance 后发现不能更新界面数据,下面就是我们尝试在动态组件创建了以后,使用动态组件的实例 componentRef.instance 去改变动态组件的属性,如下

1
componentRef.instance.name = '123'

运行以后可以发现,界面上绑定的 name 属性并不会更新,但是控制台输出发现其中的 name 已经变更,并且如果是在动态组件当中使用函数,可以改变 name 属性的值,并且 name 属性也是可是实时更新,但是如果使用的是 componentRef,这样数据实时更新就不会起作用了,在这种情况下,有两种解决方法,一种是使用 setTimeout,可以解决问题,但是并不怎么优雅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Component({
template: `
我是测试模板 {{data}}
`
})

export class CustomComponent implements OnInit {
@Input() public data: string

public ngOnInit() {
console.log(this.data)
setTimeout(() => {
console.log(this.data)
}, 3000)
}
}

// ----------------------------

export class HomeComponent implements OnInit {
constructor(
private viewContainerRef: ViewContainerRef,
private cfr: ComponentFactoryResolver
) { }

public ngOnInit() {
let factory = this.cfr.resolveComponentFactory(CustomComponent)
let componentRef = this.viewContainerRef.createComponent(factory)
componentRef.instance.data = 'hello'
setTimeout(() => {
componentRef.instance.data = 'bye'
}, 2000)
}
}

第二种解决方式可以采用官方提供的 changeDetectorRef.markForCheck() 来解决这个问题,即手动去触发检测更新,详细见 ChangeDetectorRef

宿主对象

上面介绍了 Angular 中动态加载的一些相关内容,下面来看一些宿主对象相关的知识点,主要包括

  • Angular 中利用指令来指定宿主对象
  • Angular 中如何动态添加宿主
  • 如何与动态添加后的组件进行通信

下面我们就一个一个来进行了解

使用指令来指定宿主对象

Angular 中,我们通常需要一个宿主(Host)来给动态加载的组件提供一个容器,这个宿主在 Angular 中就是 ng-template,我们需要找到组件中的容器,并且将目标组件加载到这个宿主中,就需要通过创建一个指令(Directive)来对容器进行标记,先来看看模版文件

1
2
3
4
5
6
<!-- app.component.html -->
<h1>
{{title}}
</h1>

<ng-template dl-host><ng-template>

然后我们添加一个用于标记这个属性的指令 dl-host.directive

1
2
3
4
5
6
7
8
9
10
11
12
// dl-host.directive.ts
import { Directive, ViewContainerRef } from '@angular/core'

@Directive({
selector: '[dl-host]'
})

export class DlHostDirective {
// 在这里注入了一个 ViewContainerRef 的服务
// 它的作用就是为组件提供容器,并且提供了一系列的管理这些组件的方法
constructor(public viewContainerRef: ViewContainerRef) { }
}

这样一来,我们就可以在 app.component 中通过 @ViewChild 获取到 dl-host 的实例,因此进而获取到其中的 ViewContainerRef,另外,我们还需要为 ViewContainerRef 提供需要创建组件 A 的工厂,所以还需要在 app.component 中注入一个工厂生成器 ComponentFactoryResolver,并且在 app.module 中将需要生成的组件注册为一个 @NgModule.entryComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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)
)
}
}

下面是模块内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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 { }

如何动态添加宿主

我们不可能在每一个需要动态添加组件的时候提供一个宿主组件,因为我们甚至都不会知道一个组件会在哪儿被创建出来并且被添加到页面中,就比如一个模态窗口,你希望在你需要使用的时候就能打开,而并非受限与宿主,在这种需求的前提下,我们就需要动态添加一个宿主到组件中,所以现在我们可以将 app.component 作为宿主的载体,但是并不提供宿主的显式声明,而是由我们动态去生成宿主,我们先将 app.component 还原

1
2
3
4
<!-- app.component.html -->
<h1>
{{title}}
</h1>

然后我们需要往 DOM 中注入一个节点,例如一个 div 节点作为页面上的宿主,再通过工厂生成一个 AComponent 并将这个组件的根节点添加到宿主上,在这种情况下我们需要通过工厂直接创建组件,而不是在使用 ComponentContanerRef

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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()
}
}

这种手动添加 DOM 的方式会有一个问题,那就是无法对数据进行脏检查,如果修改了 a.component.ts 是不会触发更新的,所以我们需要手动的去通知应用处理这个组件的视图,对这个组件进行脏检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 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()
}
}

如何与动态添加后的组件进行通信

简单的方法是动态加载的组件通过 @Output() 向外 emit() 事件,外部组件通过监听事件(subscribe)得到通知

1
2
3
4
<!-- 动态加载的组件 a.component.html,简单的绑定一个点击事件 -->
<p (click)="onTitleClick()">
{{title}}
</p>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// a.component.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()
}
}

下面来看看外部组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 外部组件 app.component.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

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×