组件(Component)

组件是 Angular 应用的主要构造块。每个组件包括如下部分:

  • 一个 HTML 模板,用于声明页面要渲染的内容

  • 一个用于定义行为的 TypeScript 类

  • 一个 CSS 选择器,用于定义组件在模板中的使用方式

  • (可选)要应用在模板上的 CSS 样式

创建一个组件

运行 ng generate component <component-name> 命令,其中 <component-name> 是新组件的名字。

指定组件的 CSS 选择器

每个组件都需要一个 CSS 选择器。选择器会告诉 Angular:当在模板 HTML 中找到相应的标签时,就把该组件实例化在那里。
比如,考虑一个组件 hello-world.component.ts,它的选择器定义为 app-hello-world。当 出现在模板中时,这个选择器就会让 Angular 实例化该组件。

在 @Component 装饰器中添加一个 selector 语句来指定组件的选择器。

1
2
3
@Component({
selector: 'app-component-overview',
})

定义一个组件的模板

模板是一段 HTML,它告诉 Angular 如何在应用中渲染组件。可以通过以下两种方式之一为组件定义模板:引用外部文件,或直接写在组件内部。

要把模板定义为外部文件,就要把 templateUrl 添加到 @Component 装饰器中。

1
2
3
4
@Component({
selector: 'app-component-overview',
templateUrl: './component-overview.component.html',
})

要在组件中定义模板,就要把一个 template 属性添加到 @Component 中,该属性的内容是要使用的 HTML。

1
2
3
4
5
6
7
@Component({
selector: 'app-component-overview',
template: `
<h1>Hello World!</h1>
<p>This template definition spans multiple lines.</p>
`
})

Angular 组件需要一个用 template 或 templateUrl 定义的模板。但你不能在组件中同时拥有这两个语句。

声明组件的样式

有两种方式可以为组件的模板声明样式:引用一个外部文件,或直接写在组件内部。

要在单独的文件中声明组件的样式,就要把 styleUrls 属性添加到 @Component 装饰器中。

1
2
3
4
5
@Component({
selector: 'app-component-overview',
templateUrl: './component-overview.component.html',
styleUrls: ['./component-overview.component.css']
})

要想在组件内部声明样式,就要把 styles 属性添加到 @Component,该属性的内容是你要用的样式。

1
2
3
4
5
@Component({
selector: 'app-component-overview',
template: '<h1>Hello World!</h1>',
styles: ['h1 { font-weight: normal; }']
})

组件生命周期

当 Angular 实例化组件类并渲染组件视图及其子视图时,组件实例的生命周期就开始了。生命周期一直伴随着变更检测,Angular 会检查数据绑定属性何时发生变化,并按需更新视图和组件实例。当 Angular 销毁组件实例并从 DOM 中移除它渲染的模板时,生命周期就结束了。当 Angular 在执行过程中创建、更新和销毁实例时,指令就有了类似的生命周期。

应用可以使用生命周期钩子方法来触发组件或指令生命周期中的关键事件,以初始化新实例,需要时启动变更检测,在变更检测过程中响应更新,并在删除实例之前进行清理。

生命周期的顺序

当你的应用通过调用构造函数来实例化一个组件或指令时,Angular 就会调用那个在该实例生命周期的适当位置实现了的那些钩子方法。

Angular 会按以下顺序执行钩子方法。可以用它来执行以下类型的操作。

ngOnChanges()

时机

如果组件绑定过输入属性,那么在 ngOnInit() 之前以及所绑定的一个或多个输入属性的值发生变化时都会调用。

如果你的组件没有输入属性,或者你使用它时没有提供任何输入属性,那么框架就不会调用 ngOnChanges()。

用途

当 Angular 设置或重新设置数据绑定的输入属性时响应。该方法接受当前和上一属性值的 SimpleChanges 对象

注意:这发生得比较频繁,所以你在这里执行的任何操作都会显著影响性能。

ngOnInit()

时机

在第一轮 ngOnChanges() 完成之后调用,只调用一次。而且即使没有调用过 ngOnChanges(),也仍然会调用 ngOnInit()(比如当模板中没有绑定任何输入属性时)。

用途

在 Angular 第一次显示数据绑定和设置指令/组件的输入属性之后,初始化指令/组件。

ngDoCheck()

时机

紧跟在每次执行变更检测时的 ngOnChanges() 和 首次执行变更检测时的 ngOnInit() 后调用。

用途

检测,并在发生 Angular 无法或不愿意自己检测的变化时作出反应。

ngAfterContentInit()

时机

第一次 ngDoCheck() 之后调用,只调用一次。

用途

当 Angular 把外部内容投影进组件视图或指令所在的视图之后调用。

ngAfterContentChecked()

时机

ngAfterContentInit() 和每次 ngDoCheck() 之后调用。

用途

每当 Angular 检查完被投影到组件或指令中的内容之后调用。

ngAfterViewInit()

时机

第一次 ngAfterContentChecked() 之后调用,只调用一次。

用途

当 Angular 初始化完组件视图及其子视图或包含该指令的视图之后调用。

ngAfterViewChecked()

时机

ngAfterViewInit() 和每次 ngAfterContentChecked() 之后调用。

用途

每当 Angular 做完组件视图和子视图或包含该指令的视图的变更检测之后调用。

ngOnDestroy()

时机

在 Angular 销毁指令或组件之前立即调用。

用途

每当 Angular 每次销毁指令/组件之前调用并清扫。在这儿反订阅可观察对象和分离事件处理器,以防内存泄漏。

所有生命周期事件的顺序和频率

顺序 事件
1 OnChanges
2 OnInit
3 DoCheck
4 AfterContentInit
5 AfterContentChecked
6 AfterViewInit
7 AfterViewChecked
8 DoCheck
9 AfterContentChecked
10 AfterViewChecked
11 OnDestroy

视图封装

在 Angular 中,组件的样式可以封装在组件的宿主元素中,这样它们就不会影响应用程序的其余部分。

Component 的装饰器提供了 encapsulation 选项,可用来控制如何基于每个组件应用视图封装。

ViewEncapsulation.ShadowDom

Angular 使用浏览器内置的 Shadow DOM API 将组件的视图包含在 ShadowRoot(用作组件的宿主元素)中,并以隔离的方式应用所提供的样式。

ViewEncapsulation.Emulated

Angular 会修改组件的 CSS 选择器,使它们只应用于组件的视图,不影响应用程序中的其他元素(模拟 Shadow DOM 行为)

ViewEncapsulation.None

Angular 不应用任何形式的视图封装,这意味着为组件指定的任何样式实际上都是全局应用的,并且可以影响应用程序中存在的任何 HTML 元素。这种模式本质上与将样式包含在 HTML 本身中是一样的。

组件之间的交互

Angular 中的一个常见模式就是在父组件和一个或多个子组件之间共享数据。可以用 @Input() 和 @Output() 来实现这个模式。

考虑以下层次结构:

1
2
3
<parent-component>
<child-component></child-component>
</parent-component>

@Input() 允许父组件更新子组件中的数据。相反,@Output() 允许子组件向父组件发送数据。

把数据发送到子组件

子组件或指令中的 @Input() 装饰器表示该属性可以从其父组件中获取值。

配置子组件

要使用 @Input() 装饰器,首先要导入 Input,然后用 @Input() 装饰该属性,如下例所示。

src/app/item-detail/item-detail.component.ts

1
2
3
4
import { Component, Input } from '@angular/core'; // First, import Input
export class ItemDetailComponent {
@Input() item = ''; // decorate the property with @Input()
}

在这个例子中,@Input() 会修饰属性 item,它的类型为 string,但 @Input() 属性可以是任意类型,比如 number、string、boolean 或 object。item 的值来自父组件。

接下来,在子组件模板中添加以下内容:

src/app/item-detail/item-detail.component.html

1
2
3
<p>
Today's item: {{item}}
</p>

配置父组件

使用属性绑定把子组件的 item 属性绑定到父组件的 currentItem 属性上。

src/app/app.component.html

1
<app-item-detail [item]="currentItem"></app-item-detail>

在父组件类中,为 currentItem 指定一个值:
src/app/app.component.ts

1
2
3
export class AppComponent {
currentItem = 'Television';
}

通过 @Input(),Angular 把 currentItem 的值传给子组件,以便 item 渲染为 Television。

监视 @Input() 的变更

要想监视 @Input() 属性的变化,可以用 Angular 的生命周期钩子OnChanges 或 使用一个输入属性的 setter,以拦截父组件中值的变化。

通过 ngOnChanges() 来截听输入属性值的变化

VersionParentComponent 提供 minor 和 major 值,把修改它们值的方法绑定到按钮上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Component } from '@angular/core';

@Component({
selector: 'app-version-parent',
template: `
<h2>Source code version</h2>
<button type="button" (click)="newMinor()">New minor version</button>
<button type="button" (click)="newMajor()">New major version</button>
<app-version-child [major]="major" [minor]="minor"></app-version-child>
`
})
export class VersionParentComponent {
major = 1;
minor = 23;

newMinor() {
this.minor++;
}

newMajor() {
this.major++;
this.minor = 0;
}
}

这个 VersionChildComponent 会监测输入属性 major 和 minor 的变化,并把这些变化编写成日志以报告这些变化。

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
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({
selector: 'app-version-child',
template: `
<h3>Version {{major}}.{{minor}}</h3>
<h4>Change log:</h4>
<ul>
<li *ngFor="let change of changeLog">{{change}}</li>
</ul>
`
})
export class VersionChildComponent implements OnChanges {
@Input() major = 0;
@Input() minor = 0;
changeLog: string[] = [];

ngOnChanges(changes: SimpleChanges) {
const log: string[] = [];
for (const propName in changes) {
const changedProp = changes[propName];
const to = JSON.stringify(changedProp.currentValue);
if (changedProp.isFirstChange()) {
log.push(`Initial value of ${propName} set to ${to}`);
} else {
const from = JSON.stringify(changedProp.previousValue);
log.push(`${propName} changed from ${from} to ${to}`);
}
}
this.changeLog.push(log.join(', '));
}
}

通过 setter 截听输入属性值的变化

使用一个输入属性的 setter,以拦截父组件中值的变化,并采取行动。

NameParentComponent 展示了各种名字的处理方式,包括一个全是空格的名字。

1
2
3
4
5
6
7
8
9
10
11
12
import { Component } from '@angular/core';

@Component({
selector: 'app-name-parent',
template: `
<h2>Master controls {{names.length}} names</h2>
<app-name-child *ngFor="let name of names" [name]="name"></app-name-child>`
})
export class NameParentComponent {
// Displays 'Dr. IQ', '<no name set>', 'Bombasto'
names = ['Dr. IQ', ' ', ' Bombasto '];
}

子组件 NameChildComponent 的输入属性 name 上的这个 setter,会 trim 掉名字里的空格,并把空值替换成默认字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { Component, Input } from '@angular/core';

@Component({
selector: 'app-name-child',
template: '<h3>"{{name}}"</h3>'
})
export class NameChildComponent {
@Input()
get name(): string { return this._name; }
set name(name: string) {
this._name = (name && name.trim()) || '<no name set>';
}
private _name = '';
}

把数据发送到父组件

子组件或指令中的 @Output() 装饰器允许数据从子组件传给父组件。

@Output() 在子组件中标记了一个属性,作为数据从子组件传递到父组件的途径。

子组件使用 @Output() 属性来引发事件,以通知父组件这一变化。为了引发事件,@Output() 必须是 EventEmitter 类型,它是 @angular/core 中用来发出自定义事件的类。

配置子组件

子组件的模板有两个控件。第一个是带有模板引用变量 #newItem 的 ,用户可在其中输入条目名称。#newItem 变量的 value 属性存储了用户输入到 中的值。

1
2
3
<label for="item-input">Add an item:</label>
<input type="text" id="item-input" #newItem>
<button type="button" (click)="addNewItem(newItem.value)">Add to parent's list</button>

(click) 事件绑定到了子组件类中 addNewItem() 方法。addNewItem() 方法接受一个 #newItem.value 属性的值作为参数。

在子组件类中导入 Output 和 EventEmitter,用 @Output() 装饰一个属性。下面的例子中 newItemEvent 这个 @Output() 的类型为 EventEmitter,这意味着它是一个事件。

1
2
3
4
5
6
7
8
export class ItemOutputComponent {

@Output() newItemEvent = new EventEmitter<string>();

addNewItem(value: string) {
this.newItemEvent.emit(value);
}
}

addNewItem() 函数使用 newItemEvent 这个 @Output() 来引发一个事件,该事件带有用户输入;

配置父组件

在父模板中,把父组件的方法绑定到子组件的事件上。

1
<app-item-output (newItemEvent)="addItem($event)"></app-item-output>

事件绑定 (newItemEvent)=’addItem($event)’ 会把子组件中的 newItemEvent 事件连接到父组件的 addItem() 方法。

$event 中包含用户在子组件模板上的 中键入的数据。

1
2
3
4
5
6
7
export class AppComponent {
items = ['item1', 'item2', 'item3', 'item4'];

addItem(newItem: string) {
this.items.push(newItem);
}
}

addItem() 方法接受一个字符串形式的参数,然后把该字符串添加到 items 数组中。

父组件与子组件通过本地变量互动

父组件不能使用数据绑定来读取子组件的属性或调用子组件的方法。但可以在父组件模板里,新建一个本地变量来代表子组件,然后利用这个变量来读取子组件的属性和调用子组件的方法,如下例所示。

宿主组件 CountdownLocalVarParentComponent 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { Component } from '@angular/core';
import { CountdownTimerComponent } from './countdown-timer.component';

@Component({
selector: 'app-countdown-parent-lv',
template: `
<h3>Countdown to Liftoff (via local variable)</h3>
<button type="button" (click)="timer.start()">Start</button>
<button type="button" (click)="timer.stop()">Stop</button>
<div class="seconds">{{timer.seconds}}</div>
<app-countdown-timer #timer></app-countdown-timer>
`,
styleUrls: ['../assets/demo.css']
})
export class CountdownLocalVarParentComponent { }

子组件 CountdownTimerComponent 进行倒计时,归零时发射一个导弹。start 和 stop 方法负责控制时钟并在模板里显示倒计时的状态信息。

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
import { Component, OnDestroy } from '@angular/core';

@Component({
selector: 'app-countdown-timer',
template: '<p>{{message}}</p>'
})
export class CountdownTimerComponent implements OnDestroy {

intervalId = 0;
message = '';
seconds = 11;

ngOnDestroy() { this.clearTimer(); }

start() { this.countDown(); }
stop() {
this.clearTimer();
this.message = `Holding at T-${this.seconds} seconds`;
}

private clearTimer() { clearInterval(this.intervalId); }

private countDown() {
this.clearTimer();
this.intervalId = window.setInterval(() => {
this.seconds -= 1;
if (this.seconds === 0) {
this.message = 'Blast off!';
} else {
if (this.seconds < 0) { this.seconds = 10; } // reset
this.message = `T-${this.seconds} seconds and counting`;
}
}, 1000);
}
}

父组件不能通过数据绑定使用子组件的 start 和 stop 方法,也不能访问子组件的 seconds 属性。

把本地变量(#timer)放到()标签中,用来代表子组件。这样父组件的模板就得到了子组件的引用,于是可以在父组件的模板中访问子组件的所有属性和方法。

这个例子把父组件的按钮绑定到子组件的 start 和 stop 方法,并用插值来显示子组件的 seconds 属性。

父级调用 @ViewChild()

这个本地变量方法是个简单明了的方法。但是它也有局限性,因为父组件-子组件的连接必须全部在父组件的模板中进行。父组件本身的代码对子组件没有访问权。如果父组件的类需要依赖于子组件类,就不能使用本地变量方法。

当父组件类需要访问时子组件类时,可以把子组件作为 ViewChild,注入到父组件里面。

下面是父组件 CountdownViewChildParentComponent:

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
import { AfterViewInit, ViewChild } from '@angular/core';
import { Component } from '@angular/core';
import { CountdownTimerComponent } from './countdown-timer.component';

@Component({
selector: 'app-countdown-parent-vc',
template: `
<h3>Countdown to Liftoff (via ViewChild)</h3>
<button type="button" (click)="start()">Start</button>
<button type="button" (click)="stop()">Stop</button>
<div class="seconds">{{ seconds() }}</div>
<app-countdown-timer></app-countdown-timer>
`,
styleUrls: ['../assets/demo.css']
})
export class CountdownViewChildParentComponent implements AfterViewInit {

@ViewChild(CountdownTimerComponent)
private timerComponent!: CountdownTimerComponent;

seconds() { return 0; }

ngAfterViewInit() {
// Redefine `seconds()` to get from the `CountdownTimerComponent.seconds` ...
// but wait a tick first to avoid one-time devMode
// unidirectional-data-flow-violation error
setTimeout(() => this.seconds = () => this.timerComponent.seconds, 0);
}

start() { this.timerComponent.start(); }
stop() { this.timerComponent.stop(); }
}

把子组件的视图插入到父组件类需要做一点额外的工作。

首先,你必须导入对装饰器 ViewChild 以及生命周期钩子 AfterViewInit 的引用。

接着,通过 @ViewChild 属性装饰器,将子组件 CountdownTimerComponent 注入到私有属性 timerComponent 里面。

组件元数据里就不再需要 #timer 本地变量了。而是把按钮绑定到父组件自己的 start 和 stop 方法,使用父组件的 seconds 方法的插值来展示秒数变化。

这些方法可以直接访问被注入的计时器组件。

ngAfterViewInit() 生命周期钩子是非常重要的一步。被注入的计时器组件只有在 Angular 显示了父组件视图之后才能访问,所以它先把秒数显示为 0。

然后 Angular 会调用 ngAfterViewInit 生命周期钩子,但这时候再更新父组件视图的倒计时就已经太晚了。Angular 的单向数据流规则会阻止在同一个周期内更新父组件视图。应用在显示秒数之前会被迫再等一轮。

使用 setTimeout() 来等下一轮,然后改写 seconds() 方法,这样它接下来就会从注入的这个计时器组件里获取秒数的值。

父组件和子组件通过服务来通讯

父组件和它的子组件共享同一个服务,利用该服务在组件家族内部实现双向通讯。

该服务实例的作用域被限制在父组件和其子组件内。这个组件子树之外的组件将无法访问该服务或者与它们通讯。

这个 MissionService 把 MissionControlComponent 和多个 AstronautComponent 子组件连接起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable()
export class MissionService {

// Observable string sources
private missionAnnouncedSource = new Subject<string>();
private missionConfirmedSource = new Subject<string>();

// Observable string streams
missionAnnounced$ = this.missionAnnouncedSource.asObservable();
missionConfirmed$ = this.missionConfirmedSource.asObservable();

// Service message commands
announceMission(mission: string) {
this.missionAnnouncedSource.next(mission);
}

confirmMission(astronaut: string) {
this.missionConfirmedSource.next(astronaut);
}
}

MissionControlComponent 提供服务的实例,并将其共享给它的子组件(通过 providers 元数据数组),子组件可以通过构造函数将该实例注入到自身。

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
import { Component } from '@angular/core';

import { MissionService } from './mission.service';

@Component({
selector: 'app-mission-control',
template: `
<h2>Mission Control</h2>
<button type="button" (click)="announce()">Announce mission</button>

<app-astronaut
*ngFor="let astronaut of astronauts"
[astronaut]="astronaut">
</app-astronaut>

<h3>History</h3>
<ul>
<li *ngFor="let event of history">{{event}}</li>
</ul>
`,
providers: [MissionService]
})
export class MissionControlComponent {
astronauts = ['Lovell', 'Swigert', 'Haise'];
history: string[] = [];
missions = ['Fly to the moon!',
'Fly to mars!',
'Fly to Vegas!'];
nextMission = 0;

constructor(private missionService: MissionService) {
missionService.missionConfirmed$.subscribe(
astronaut => {
this.history.push(`${astronaut} confirmed the mission`);
});
}

announce() {
const mission = this.missions[this.nextMission++];
this.missionService.announceMission(mission);
this.history.push(`Mission "${mission}" announced`);
if (this.nextMission >= this.missions.length) { this.nextMission = 0; }
}
}

AstronautComponent 也通过自己的构造函数注入该服务。由于每个 AstronautComponent 都是 MissionControlComponent 的子组件,所以它们获取到的也是父组件的这个服务实例。

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
import { Component, Input, OnDestroy } from '@angular/core';

import { MissionService } from './mission.service';
import { Subscription } from 'rxjs';

@Component({
selector: 'app-astronaut',
template: `
<p>
{{astronaut}}: <strong>{{mission}}</strong>
<button
type="button"
(click)="confirm()"
[disabled]="!announced || confirmed">
Confirm
</button>
</p>
`
})
export class AstronautComponent implements OnDestroy {
@Input() astronaut = '';
mission = '<no mission announced>';
confirmed = false;
announced = false;
subscription: Subscription;

constructor(private missionService: MissionService) {
this.subscription = missionService.missionAnnounced$.subscribe(
mission => {
this.mission = mission;
this.announced = true;
this.confirmed = false;
});
}

confirm() {
this.confirmed = true;
this.missionService.confirmMission(this.astronaut);
}

ngOnDestroy() {
// prevent memory leak when component destroyed
this.subscription.unsubscribe();
}
}

History 日志证明了:在父组件 MissionControlComponent 和子组件 AstronautComponent 之间,信息通过该服务实现了双向传递。