InfoPool

私人信息记录

0%

文章原出处:
Exploring Angular DOM manipulation techniques using ViewContainerRef

翻译原文:使用 ViewContainerRef 探索Angular DOM操作 · Issue #19 · giscafer/giscafer.github.io

Angular 版本运行在不同的平台上——在浏览器上,在移动平台上,或者在 web worker 中。因此,需要在平台特定API 和框架接口之间进行抽象级别的抽象。从 Angular 来看,这些抽象的形式有以下的参考类型: ElementRef, TemplateRef, ViewRef, ComponentRef 和 ViewContainerRef。在本文中,我们将详细介绍每个引用类型,并展示如何使用它们来操作DOM。

@ViewChild

在探索 DOM 抽象层之前,先了解下如何在组件/指令中访问它们。Angular 提供了一种叫做 DOM querys 的技术,它们以 @ViewChild 和 @ViewChildren 装饰器(decorators)的形式出现。两者功能类似,唯一区别是 @ViewChild 返回单个引用,@ViewChildren 返回由 QueryList 对象包装好的多个引用。本文示例中主要以 ViewChild 装饰器为例,后面描述时将省略 @ 符号。

通常这些装饰器与模板引用变量(template reference variable)配合使用,模板引用变量可以理解为 DOM 元素的引用标识,类似于 html 元素的 id 属性。你可以使用模板引用(template reference)来标记一个 DOM 元素(译者注:下面示例中的#tref),并在组件/指令中使用 ViewChild 装饰器查询到它,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component({
selector: 'sample',
template: `
<span #tref>I am span</span>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("tref", {read: ElementRef}) tref: ElementRef;

ngAfterViewInit(): void {
// outputs `I am span`
console.log(this.tref.nativeElement.textContent);
}
}

ViewChild 装饰器基本语法是:

1
@ViewChild([reference from template], {read: [reference type]});

上例中你可以看到,我把 tref 作为模板引用名称,并将 ElementRef 与该元素联系起来。

第二个参数 read 是可选的,因为 Angular 会根据 DOM 元素的类型推断出该引用类型。例如,如果它(#tref)挂载的是类似 span 的简单 html 元素,Angular 推断为 ElementRef 类型;

如果它挂载的是 template 元素,Angular 推断为 TemplateRef 类型。

一些引用类型如 ViewContainerRef 就不可以被 Angular 推断出来,所以必须在 read 参数中显式声明。其他的如 ViewRef 不可以挂载在 DOM 元素中,所以必须手动在构造函数中编码构造出来。

现在,让我们看看应该如何获取这些引用,一起去探索吧。

ElementRef

这是最基本的抽象类,如果你查看它的类结构,你会发现它仅仅包含与原生元素交互的方法,这对访问原生 DOM 元素很有用,比如:

1
2
// outputs `I am span`
console.log(this.tref.nativeElement.textContent);

然而,Angular 团队不鼓励这种写法,不仅因为这种方式会存在安全风险,而且还会让你的程序与渲染层(rendering layers)紧耦合,这样就很难实现在多平台运行相同的应用程序。我认为这个问题并不是使用 nativeElement 导致的,而是使用特定的 DOM API 造成的,例如使用了 textContent。但是后文你会看到,Angular 实现了操作 DOM 的整体思路模型,这样将不用必须调用平台指定的低层次抽象的 API,如textContent。

使用 ViewChild 装饰器可以返回任何 DOM 元素对应的 ElementRef,但是由于组件挂载在自定义 DOM 元素中;指令也应用在 DOM 元素上,所以组件和指令都可以通过 DI(依赖注入)获取宿主元素的 ElementRef 对象。

比如:

1
2
3
4
5
6
7
8
9
@Component({
selector: 'sample',
...
export class SampleComponent{
constructor(private hostElement:ElementRef) {
//outputs <sample>...</sample>
console.log(this.hostElement.nativeElement.outerHTML);
}
...

所以组件通过 DI(Dependency Injection)可以访问到它的宿主元素,但 ViewChild 装饰器经常被用来获取模板视图中的 DOM 元素。然而指令却相反,因为指令没有视图模板,所以主要用来获取挂载指令的宿主元素。

TemplateRef

通过 template 标签,浏览器可以解析这段 html 代码,并创建对应的 DOM 树,但不会渲染它,该 DOM 树可以通过 content 属性访问:

1
2
3
4
5
6
7
8
9
<script>
let tpl = document.querySelector('#tpl');
let container = document.querySelector('.insert-after-me');
insertAfter(container, tpl.content);
</script>
<div class="insert-after-me"></div>
<ng-template id="tpl">
<span>I am span in template</span>
</ng-template>

Angular 拥抱了标准,使用 TemplateRef 类来操作 template 元素,看看它是如何使用的(译者注:ng-template 是 Angular 提供的类似于 template 原生 html 标签):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component({
selector: 'sample',
template: `
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("tpl") tpl: TemplateRef<any>;

ngAfterViewInit() {
let elementRef = this.tpl.elementRef;
// outputs `template bindings={}`
console.log(elementRef.nativeElement.textContent);
}
}

Angular 框架从 DOM 中移除 template 元素,并在其位置插入注释,这是渲染后的样子:

1
2
3
<sample>
<!--template bindings={}-->
</sample>

TemplateRef 是一个结构简单的抽象类,它的 elementRef 属性是对其宿主元素的引用,它还有一个 createEmbeddedView 方法。createEmbeddedView 方法非常有用,因为它可以创建一个视图(view)并返回该视图的引用对象 ViewRef。

ViewRef

该抽象类型表示一个 Angular 视图(View),在 Angular 世界里,视图(View)是构建应用中 UI 的基础单元。它是可以同时创建与销毁的最小元素组合。Angular 鼓励开发者把 UI 作为一堆视图(View)的组合,而不仅仅是 html 标签组成的树。

Angular 支持两种视图类型:

  • 内嵌视图(Embedded View),与 Template 关联
  • 宿主视图(Host View),与 Component 关联
    创建内嵌视图

模板仅仅是视图的蓝图,可以通过之前提到的 createEmbeddedView 方法创建视图,比如:

1
2
3
ngAfterViewInit() {
let view = this.tpl.createEmbeddedView(null);
}

创建宿主视图

宿主视图是在组件动态实例化时创建的,一个动态组件(dynamic component)可以通过 ComponentFactoryResolver 创建:

1
2
3
4
5
6
constructor(private injector: Injector,
private r: ComponentFactoryResolver) {
let factory = this.r.resolveComponentFactory(ColorComponent);
let componentRef = factory.create(injector);
let view = componentRef.hostView;
}

在 Angular 中,每个组件都绑定着一个指定的注入器(Injector)实例,所以创建 ColorComponent 组件时传入当前组件(即 SampleComponent)的注入器。另外,别忘了,动态创建的组件,需要在 ngModule 中或者宿主组件中增加 EntryComponents 配置。

现在,我们已经看到内嵌视图和宿主视图是如何被创建的,一旦视图被创建,它就可以使用 ViewContainer 插入 DOM 树中。下文主要探索这个功能。

ViewContainerRef

视图容器可以挂载一个或多个视图。

首先需要说的是,任何 DOM 元素都可以作为视图容器,然而有趣的是,对于绑定 ViewContainer 的 DOM 元素,Angular 不会把视图插入该元素的内部,而是追加到该元素后面,这类似于 router-outlet 中插入组件的方式。

通常,比较好的方式是把 ViewContainer 绑定在 ng-container 元素上,因为 ng-container 元素会被渲染为注释,从而不会在 DOM 中引入多余的 html 元素。下面示例描述在组建模板中如何创建 ViewContainer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container #vc></ng-container>
<span>I am last span</span>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;

ngAfterViewInit(): void {
// outputs `template bindings={}`
console.log(this.vc.element.nativeElement.textContent);
}
}

如同其他 DOM 抽象类一样,ViewContainer 绑定到特殊的 DOM 元素,并可以通过 element 访问到。例如上例中,它绑定到 ng-container 元素上,并且渲染为 HTML 注释,所以输出会是 template bindings={}。

操作视图

ViewContainer 提供了一些操作视图 API:

1
2
3
4
5
6
7
8
9
class ViewContainerRef {
...m
clear() : void
insert(viewRef: ViewRef, index?: number) : ViewRef
get(index: number) : ViewRef
indexOf(viewRef: ViewRef) : number
detach(index?: number) : ViewRef
move(viewRef: ViewRef, currentIndex: number) : ViewRef
}

从上文我们已经知道内嵌视图和宿主视图的创建方式,当创建视图后,就可以通过 insert 方法插入 DOM 中。下面示例描述如何通过 ng-template 创建内嵌视图,并在 ng-container 中插入该视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container #vc></ng-container>
<span>I am last span</span>
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent implements AfterViewInit {
@ViewChild("vc", {read: ViewContainerRef}) vc: ViewContainerRef;
@ViewChild("tpl") tpl: TemplateRef<any>;

ngAfterViewInit() {
let view = this.tpl.createEmbeddedView(null);
this.vc.insert(view);
}
}

通过上面的实现,最后的 html 看起来是:

1
2
3
4
5
6
7
8
<sample>
<span>I am first span</span>
<!--template bindings={}-->
<span>I am span in template</span>

<span>I am last span</span>
<!--template bindings={}-->
</sample>

(译者注:从上文中知道是追加到 ng-container 后面,而不是插入到该 DOM 元素内部,因为在 Angular 中 ng-container 元素不会生成真实的 DOM 元素,所以在结构中不会发现这个 “追加” 的痕迹。如果把 ng-container 替换成其他元素,则可以明显地看到视图是追加在 viewContainer 之后的:

1
<div _ngcontent-c4=""></div><span _ngcontent-c4>I am span in template</span>

此外,可以通过 detach 方法从 DOM 移除视图,其他的方法可以很容易通过方法名知道其含义,如通过 index 方法获得对应索引的视图引用,move 方法移动视图位置次序,或者使用 remove 方法从移除所有的视图。

创建视图

ViewContainer 也提供了手动创建视图 API :

1
2
3
4
5
6
7
8
class ViewContainerRef {
element: ElementRef
length: number

createComponent(componentFactory...): ComponentRef<C>
createEmbeddedView(templateRef...): EmbeddedViewRef<C>
...
}

上面两个方法是对上文中我们手动操作的封装,可以传入模板引用对象或组件工厂对象来创建视图,并将该视图插入视图容器中特定位置。

ngTemplateOutlet 和 ngComponentOutlet

尽管知道 Angular 操作 DOM 的内部机制是好事,但是如果存在某种便捷的方式就更好了。Angular 提供了两种快捷指令:ngTemplateOutlet 和 ngComponentOutlet。写作本文时这两个指令都是实验性的,ngComponentOutlet 也将在Angular4.0 版本中可用。如果你读完了上文,就很容易知道这两个指令是做什么的。

ngTemplateOutlet

该指令会把 DOM 元素标记为 ViewContainer,并插入由模板创建的内嵌视图,从而不需要在组件类中显式创建该内嵌视图。这意味着,上面实例中创建内嵌视图并插入 #vc DOM 元素的代码就可以重写为:

1
2
3
4
5
6
7
8
9
10
11
12
@Component({
selector: 'sample',
template: `
<span>I am first span</span>
<ng-container [ngTemplateOutlet]="tpl"></ng-container>
<span>I am last span</span>
<ng-template #tpl>
<span>I am span in template</span>
</ng-template>
`
})
export class SampleComponent {}

从上面示例看到我们不需要在组件类中写任何实例化视图的代码,非常方便。

ngComponentOutlet

这个指令与 ngTemplateOutlet 很相似,区别是 ngComponentOutlet 创建的是由组件实例化生成的宿主视图,不是内嵌视图。你可以这么使用:

1
<ng-container *ngComponentOutlet="ColorComponent"></ng-container>

总结

看似有很多新知识需要消化啊,但实际上 Angular 通过视图操作 DOM 的思路模型是很清晰和连贯的。你可以使用 ViewChild 查询模板引用变量来获得 Angular DOM 元素的引用对象;DOM 元素的最简单封装是 ElementRef;而对于模板,你可以使用 TemplateRef 来创建内嵌视图;而对于组件,可以使用 ComponentRef 来创建宿主视图,同时又可以使用 ComponentFactoryResolver 创建 ComponentRef。这两个创建的视图(即内嵌视图和宿主视图)又会被 ViewContainerRef 管理。最后,Angular 又提供了两个快捷指令自动化这个过程:ngTemplateOutlet 指令使用模板创建内嵌视图;ngComponentOutlet 使用动态组件创建宿主视图。

ViewChild与ContentChild的联系和区别

ViewChild和ContentChild其实都是从子组件中获取内容的装饰器

它们本质的区别其实就只是在于方法调用的时机以及获取内容的地方:

  1. 时机:

ViewChild在ngAfterViewInit的回调中调用

ContentChild在ngAfterContentInit的回调用调用

  1. 获取内容的地方

ViewChild从模板中获取内容

ContentChild需要从ng-content中投影的内容中获取内容,也就是没有使用ng-content投影就无法获取内容

在 ng-template 标签中,我们可以访问与外部模板中相同的上下文变量,例如变量 lesson。这是因为所有 ng-template 实例都可以访问它们所嵌入的同一个上下文。

1
2
3
4
5
6
7
8
<ng-container *ngIf="lessons">
<div class="lesson" *ngFor="let lesson of lessons">
<div class="lesson-detail">
<ng-container *ngIf="lesson else empty"> {{lesson | json}} </ng-container>
<ng-template #empty> {{lesson | json}} </ng-template>
</div>
</div>
</ng-container>

但是每个模板也可以定义自己的一组输入变量! 实际上,每个模板都关联了一个上下文对象,该对象包含所有特定于模板的输入变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component({
selector: "app-root",
template: `
<ng-template #estimateTemplate let-lessonsCounter="estimate">
<div>Approximately {{ lessonsCounter }} lessons ...</div>
</ng-template>
<ng-container *ngTemplateOutlet="estimateTemplate; context: ctx">
</ng-container>
`,
})
export class AppComponent {
totalEstimate = 10;
ctx = { estimate: this.totalEstimate };
}

以下是对这个例子的分析:

与前面的模板不同,这个模板有一个输入变量(它也可以有几个)
通过 ng-template 上前缀为 let- 的属性来定义了一个输入变量 lessonsCounter
lessonsCounter 变量只能在 ng-template 内部可见
该变量的内容由赋给 let-lessonsCounter 属性的表达式 estimate 决定
estimate 表达式根据上下文对象 context 求值,并将其与要实例化的模板一起传递给 ngTemplateOutlet
上下文对象 context 有一个名为 estimate 的属性,以便在模板中显示该值
还可以给 ctx 对象添加其他属性,然后通过 ngTemplateOutlet 的 context 输入到 ng-template,从而可以拿到更多的输入变量
最终在屏幕上展示的结果是:

Approximately 10 lessons …
这让我们对如何定义和实例化自己的模板有了一个很好的概览。使用这样的方式还可以在 component 中通过模板进行编码,接下来就会介绍。

指令是为 Angular 应用程序中的元素添加额外行为的类。使用 Angular 的内置指令,你可以管理表单、列表、样式以及要让用户看到的任何内容。

Angular 指令的不同类型如下:

指令类型 详情
组件 带有模板的指令。这种指令类型是最常见的指令类型。
属性型指令 更改元素、组件或其他指令的外观或行为的指令。
结构型指令 通过添加和删除 DOM 元素来更改 DOM 布局。

内置属性型指令

属性型指令会监听并修改其它 HTML 元素和组件的行为、Attribute 和 Property。

通用指令 详情
NgClass 添加和删除一组 CSS 类。
NgStyle 添加和删除一组 HTML 样式。
NgModel 将双向数据绑定添加到 HTML 表单元素。

内置结构型指令

结构型指令的职责是 HTML 布局。它们塑造或重塑 DOM 的结构,这通常是通过添加、移除和操纵它们所附加到的宿主元素来实现的。

常见的内置结构型指令 详情
NgIf 有条件地从模板创建或销毁子视图。
NgFor 为列表中的每个条目重复渲染一个节点。
NgSwitch 一组在备用视图之间切换的指令。

属性型指令

使用属性型指令,可以更改 DOM 元素和 Angular 组件的外观或行为。

建立属性型指令

  1. 要创建指令,请使用 CLI 命令 ng generate directive

    CLI 创建 src/app/highlight.directive.ts 以及相应的测试文件 src/app/highlight.directive.spec.ts,并在 AppModule 中声明此指令类。
    CLI 生成默认的 src/app/highlight.directive.ts,如下所示:

1
2
3
4
5
6
7
8
9
 import { Directive } from '@angular/core';

@Directive({
selector: '[appHighlight]'
})

export class HighlightDirective {
constructor() { }
}

@Directive() 装饰器的配置属性会指定指令的 CSS 属性选择器 [appHighlight]。

  1. 从 @angular/core 导入 ElementRef。ElementRef 的 nativeElement 属性会提供对宿主 DOM 元素的直接访问权限。

  2. 在指令的 constructor() 中添加 ElementRef 以注入对宿主 DOM 元素的引用,该元素就是 appHighlight 的作用目标。

  3. 向 HighlightDirective 类中添加逻辑,将背景设置为黄色。

1
2
3
4
5
6
7
8
9
10
import { Directive, ElementRef } from '@angular/core';

@Directive({
selector: '[appHighlight]'
})
export class HighlightDirective {
constructor(private el: ElementRef) {
this.el.nativeElement.style.backgroundColor = 'yellow';
}
}

应用属性型指令

  1. 要使用 HighlightDirective,请将

    元素添加到 HTML 模板中,并以伪指令作为属性。

1
<p appHighlight>Highlight me!</p>

Angular 会创建 HighlightDirective 类的实例,并将 <p> 元素的引用注入到该指令的构造函数中,它会将 <p> 元素的背景样式设置为黄色。

处理用户事件

添加两个事件处理程序,它们会在鼠标进入或离开时做出响应,每个事件处理程序都带有 @HostListener() 装饰器。

1
2
3
4
5
6
7
8
9
10
11
@HostListener('mouseenter') onMouseEnter() {
this.highlight('yellow');
}

@HostListener('mouseleave') onMouseLeave() {
this.highlight('');
}

private highlight(color: string) {
this.el.nativeElement.style.backgroundColor = color;
}

要订阅本属性型指令宿主 DOM 元素上的事件(在本例中是 <p>),可以使用 @HostListener() 装饰器。

完整的指令如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Directive({
selector: '[appHighlight]'
})
export class HighlightDirective {

constructor(private el: ElementRef) { }

@HostListener('mouseenter') onMouseEnter() {
this.highlight('yellow');
}

@HostListener('mouseleave') onMouseLeave() {
this.highlight('');
}

private highlight(color: string) {
this.el.nativeElement.style.backgroundColor = color;
}

}

当指针悬停在 p 元素上时,背景颜色就会出现;而当指针移出时,背景颜色就会消失。

将值传递给属性型指令

本节将引导你在应用 HighlightDirective 时设置突出显示颜色。

  1. 在 highlight.directive.ts 中,从 @angular/core 导入 Input。
1
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
  1. 添加一个 appHighlight 的 @Input() 属性。

    1
    @Input() appHighlight = '';

    @Input() 装饰器会将元数据添加到此类,以便让该指令的 appHighlight 属性可用于绑定。

  2. 在 app.component.ts,将 color 属性添加到 AppComponent。

1
2
3
export class AppComponent {
color = 'yellow';
}

要同时应用指令和颜色,请通过 appHighlight 指令选择器使用属性绑定,将其设置为 color。

1
<p [appHighlight]="color">Highlight me!</p>

[appHighlight] 属性绑定执行两项任务:

将突出显示指令应用于 <p> 元素

通过属性绑定设置指令的突出显示颜色

通过用户输入来设置值
本节指导你添加单选按钮,将你选择的颜色绑定到 appHighlight 指令。

将标记添加到 app.component.html 以选择颜色,如下所示:

1
2
3
4
5
6
7
8
9
<h1>My First Attribute Directive</h1>

<h2>Pick a highlight color</h2>
<div>
<input type="radio" name="colors" (click)="color='lightgreen'">Green
<input type="radio" name="colors" (click)="color='yellow'">Yellow
<input type="radio" name="colors" (click)="color='cyan'">Cyan
</div>
<p [appHighlight]="color">Highlight me!</p>

修改 AppComponent.color,使其没有初始值。

1
2
3
export class AppComponent {
color = '';
}

在 highlight.directive.ts 中,修改 onMouseEnter 方法,让它首先尝试使用 appHighlight 进行高亮显示,如果 appHighlight 是 undefined,则回退为 red。

1
2
3
@HostListener('mouseenter') onMouseEnter() {
this.highlight(this.appHighlight || 'red');
}

启动本应用的开发服务器,以验证用户可以通过单选按钮选择颜色。

绑定到第二个属性

本节将指导你配置应用程序,以便开发人员可以设置默认颜色。

  1. 将第二个 Input() 属性 defaultColor 添加到 HighlightDirective。
1
@Input() defaultColor = '';
  1. 修改指令的 onMouseEnter,使其首先尝试使用 appHighlight 进行突出显示,然后尝试 defaultColor,如果两个属性都 undefined,则变回 red。
    1
    2
    3
    @HostListener('mouseenter') onMouseEnter() {
    this.highlight(this.appHighlight || this.defaultColor || 'red');
    }
    若要绑定到 AppComponent.color 并回退为默认颜色“紫罗兰(violet)”,请添加以下 HTML。在这里,defaultColor 绑定没有使用方括号 [],因为它是静态的。
    1
    2
    3
    <p [appHighlight]="color" defaultColor="violet">
    Highlight me too!
    </p>
    与组件一样,你可以将指令的多个属性绑定添加到宿主元素上。

如果没有默认颜色(defaultColor)绑定,则默认为红色。当用户选择一种颜色时,所选的颜色将成为突出显示的颜色。

ElementRef

对视图中某个原生元素的包装器。

ElementRef 的背后是一个可渲染的具体元素。在浏览器中,它通常是一个 DOM 元素。

视图

组件 (component) 类及其关联的模板 (template)定义了一个视图。

视图是可显示元素的最小分组单位,它们会被同时创建和销毁。 Angular 在一个或多个指令 (directive) 的控制下渲染视图。

具体实现上,视图由一个与该组件相关的 ViewRef 实例表示。 直属于某个组件的视图叫做宿主视图。 通常会把视图组织成一些视图树(view hierarchies)。

ViewRef

表示一个 Angular 视图

视图树(View hierarchy)

一棵相关视图的树,它们可以作为一个整体行动。其根视图就是组件的宿主视图。宿主视图可以是内嵌视图树的根,它被收集到了宿主组件上的一个视图容器(ViewContainerRef)中。视图树是 Angular 变更检测的关键部件之一。

视图树和组件树并不是一一对应的。那些嵌入到指定视图树上下文中的视图也可能是其它组件的宿主视图。那些组件可能和宿主组件位于同一个 NgModule 中,也可能属于其它 NgModule。

EmbeddedViewRef

表示视图容器中的 Angular 视图。嵌入视图可以从在模板中定义它的宿主组件之外的组件中引用,也可以由 TemplateRef 进行独立定义。

使用说明

以下模板分为两个单独的 TemplateRef 实例,一个外部实例和一个内部实例。

1
2
3
4
Count: {{items.length}}
<ul>
<li *ngFor="let item of items">{{item}}</li>
</ul>

这是外部 TemplateRef :

1
2
3
4
Count: {{items.length}}
<ul>
<ng-template ngFor let-item [ngForOf]="items"></ng-template>
</ul>

这是内部的 TemplateRef :

1
<li>{{item}}</li>

外部和内部 TemplateRef 实例按如下方式组装到视图中:

1
2
3
4
5
6
7
8
<!-- ViewRef: outer-0 -->
Count: 2
<ul>
<ng-template view-container-ref></ng-template>
<!-- ViewRef: inner-1 --><li>first</li><!-- /ViewRef: inner-1 -->
<!-- ViewRef: inner-2 --><li>second</li><!-- /ViewRef: inner-2 -->
</ul>
<!-- /ViewRef: outer-0 -->

TemplateRef

表示一个内嵌模板,它可用于实例化内嵌的视图。 要想根据模板实例化内嵌的视图,请使用 ViewContainerRef 的 createEmbeddedView() 方法。

通过把一个指令放在 <ng-template> 元素(或一个带 * 前缀的指令)上,可以访问 TemplateRef 的实例。 内嵌视图的 TemplateRef 实例会以 TemplateRef 作为令牌,注入到该指令的构造函数中。

你还可以使用 Query 来找出与某个组件或指令相关的 TemplateRef。

ViewContainerRef

表示可以将一个或多个视图附着到组件中的容器。

可以包含宿主视图(当用 createComponent() 方法实例化组件时创建)和内嵌视图(当用 createEmbeddedView() 方法实例化 TemplateRef 时创建)。

视图容器的实例还可以包含其它视图容器,以创建层次化视图。

可以在元素上放置注入了 ViewContainerRef 的 Directive 来访问元素的 ViewContainerRef。也可以使用 ViewChild 进行查询。

ViewChild

属性装饰器,用于配置一个视图查询。 变更检测器会在视图的 DOM 中查找能匹配上该选择器的第一个元素或指令。 如果视图的 DOM 发生了变化,出现了匹配该选择器的新的子节点,该属性就会被更新。

结构型指令

结构型指令简写形式

应用结构指令时,它们通常以星号 * 为前缀,例如 *ngIf。本约定是 Angular 解释并转换为更长形式的速记。Angular 会将结构指令前面的星号转换为围绕宿主元素及其后代的 <ng-template>

创建结构型指令

本节将指导你创建 UnlessDirective 以及如何设置 condition 值。UnlessDirective 与 NgIf 相反,并且 condition 值可以设置为 true 或 false。NgIf 为 true 时显示模板内容;而 UnlessDirective 在这个条件为 false 时显示内容。

以下是应用于 p 元素的 UnlessDirective 选择器 appUnless 当 condition 为 false,浏览器将显示该句子。

1
<p *appUnless="condition">Show this sentence unless the condition is true.</p>

使用 Angular CLI,运行以下命令,其中 unless 是伪指令的名称:

1
ng generate directive unless

Angular 会创建指令类,并指定 CSS 选择器 appUnless,它会在模板中标识指令。

导入 Input、TemplateRef 和 ViewContainerRef。

1
2
3
4
5
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';

@Directive({ selector: '[appUnless]'})
export class UnlessDirective {
}

在指令的构造函数中将 TemplateRef 和 ViewContainerRef 注入成私有变量。

1
2
3
constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef) { }

UnlessDirective 会通过 Angular 生成的 <ng-template> 创建一个嵌入的视图,然后将该视图插入到该指令的原始<p>宿主元素紧后面的视图容器中。

TemplateRef可帮助你获取 <ng-template> 的内容,而 ViewContainerRef 可以访问视图容器。

添加一个带 setter 的 @Input() 属性 appUnless。

1
2
3
4
5
6
7
8
9
@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}

每当条件的值更改时,Angular 都会设置 appUnless 属性。

如果条件是假值,并且 Angular 以前尚未创建视图,则此 setter 会导致视图容器从模板创建出嵌入式视图。

如果条件为真值,并且当前正显示着视图,则此 setter 会清除容器,这会导致销毁该视图。

完整的指令如下:

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

/**
* Add the template content to the DOM unless the condition is true.
*/
@Directive({ selector: '[appUnless]'})
export class UnlessDirective {
private hasView = false;

constructor(
private templateRef: TemplateRef<any>,
private viewContainer: ViewContainerRef) { }

@Input() set appUnless(condition: boolean) {
if (!condition && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (condition && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}

在 Angular 中,模板是用户界面 (UI) 片段的蓝图。模板是用 HTML 编写的,可以在模板中使用特殊语法来构建 Angular 的许多特性。

为了消除脚本注入攻击的风险,Angular 不支持模板中使用
script 元素。Angular 会忽略 script 标记,并向浏览器控制台输出一条警告。

插值语法

插值是指将表达式嵌入到被标记的文本中。默认情况下,插值使用双花括号 作为定界符。

在以下示例中,Angular 会求出 title 和 itemImageUrl 属性的值,以显示一些标题文本和图像。

1
2
<p>{{title}}</p>
<div><img alt="item" src="{{itemImageUrl}}"></div>

模板语句

模板语句是可在 HTML 中用于响应用户事件的方法或属性。

在以下示例中,模板语句 deleteHero() 出现在 = 号右侧的引号中,(event)=”statement”。

1
<button type="button" (click)="deleteHero()">Delete hero</button>

当用户单击 Delete hero 按钮时,Angular 就会调用组件类中 deleteHero() 方法。

可以将模板语句与元素、组件或指令一起使用以响应事件。

语句的上下文

语句上下文还可以引用模板自身的上下文属性。在下面的示例中,组件的事件处理方法 onSave() 将模板自己的 $event 对象用作参数。在接下来的两行中,deleteHero() 方法接收了模板输入变量 hero 作为参数,而 onSubmit() 接收了模板引用变量 #heroForm 作为参数。

1
2
3
<button type="button" (click)="onSave($event)">Save</button>
<button type="button" *ngFor="let hero of heroes" (click)="deleteHero(hero)">{{hero.name}}</button>
<form #heroForm (ngSubmit)="onSubmit(heroForm)"> ... </form>

在这个例子中,$event 对象、hero 和 #heroForm 的上下文都是其模板。

模板上下文中的名称优先于组件上下文中的名称。前面 deleteHero(hero) 中的 hero 是模板输入变量,而不是组件的 hero 属性。

模板语句的上下文可以是组件类实例或模板。因此,模板语句无法引用全局名称空间中的任何内容,比如 window 或 document。比如,模板语句不能调用 console.log() 或 Math.max()。

绑定

在 Angular 模板中,绑定会在从模板创建的一部分 UI(DOM 元素、指令或组件)与模型(模板所属的组件实例)之间创建实时连接。此连接可用于将视图与模型同步、在视图中发生事件或用户操作时通知模型,或两者兼而有之。Angular 的变更检测算法负责保持视图和模型的同步。

模板表达式类似于 JavaScript 表达式。许多 JavaScript 表达式都是合法的模板表达式,但以下例外。

你不能使用那些具有或可能引发副作用的 JavaScript 表达式,包括:

  • 赋值 (=, +=, -=, …)

  • 运算符,比如 new、typeof 或 instanceof 等。

  • 链接表达式;或,

  • 自增和自减运算符:++ 和 –

  • 一些 ES2015+ 版本的运算符

和 JavaScript 语法的其它显著差异包括:

  • 不支持位运算,比如 | 和 &

  • 新的模板表达式运算符,比如 |

表达式上下文

插值表达式具有上下文 —— 表达式所属应用中的特定部分。通常,此上下文就是组件实例。

在下面的代码片段中,表达式 recommended 和 itemImageUrl2 表达式所引用的都是 AppComponent 中的属性。

1
2
<h4>{{recommended}}</h4>
<img alt="item 2" [src]="itemImageUrl2">

表达式还可以引用模板上下文中的属性,比如模板输入变量或模板引用变量。

下面的例子就使用了模板输入变量 customer。

1
2
3
<ul>
<li *ngFor="let customer of customers">{{customer.name}}</li>
</ul>

接下来的例子使用了模板引用变量 #customerInput。

1
2
3
<label>Type something:
<input #customerInput>{{customerInput.value}}
</label>

防止命名冲突

表达式估算的上下文是模板变量、指令的上下文对象(如果有)和组件成员的并集。如果你引用的名称属于这些命名空间之一,则 Angular 会应用以下优先逻辑来确定上下文:

模板变量的名称。

指令上下文中的名称。

组件成员的名称。

为避免变量遮盖另一个上下文中的变量,请保持变量名称唯一。在以下示例中,AppComponent 模板在问候 customer Padma。

然后,一个 ngFor 列出了 customers 数组中的每个 customer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component({
template: `
<div>
<!-- Hello, Padma -->
<h1>Hello, {{customer}}</h1>
<ul>
<!-- Ebony and Chiho in a list-->
<li *ngFor="let customer of customers">{{ customer.value }}</li>
</ul>
</div>
`
})
class AppComponent {
customers = [{value: 'Ebony'}, {value: 'Chiho'}];
customer = 'Padma';
}

ngFor 中的 customer 处于一个 的上下文中,所以它指向的是 customers 数组中的 customer,在这里是 Ebony 和 Chiho。此列表中不包含 Padma,因为那个 customer 位于 ngFor 以外的另一个上下文中。反之,

中的 customer 不包括 Ebony 或 Chiho,因为该 customer 的上下文是组件类,而这个类中 customer 的值是 Padma。

属性绑定(Property)

Angular 中的属性绑定可帮助你设置 HTML 元素或指令的属性值。使用属性绑定,可以执行诸如切换按钮、以编程方式设置路径,以及在组件之间共享值之类的功能。

绑定到属性

要绑定到元素的属性 ,请将其括在方括号 [] 内,这会将此属性标为目标属性。目标属性就是你要对其进行赋值的 DOM 属性 。

要为 image 元素的目标属性(src)赋值,请键入以下代码:

1
<img alt="item" [src]="itemImageUrl">

在大多数情况下,目标名称是 Property(属性)名称,即使它看起来是 Attribute(属性)名称。

在这个例子中,src 就是 元素的 Property 名称。

方括号 [] 使 Angular 将等号的右侧看作动态表达式进行求值。

如果不使用方括号,Angular 就会将右侧视为字符串字面量并将此属性设置为该静态值。

要将字符串赋值给属性,请键入以下代码:

1
<app-item-detail childItem="parentItem"></app-item-detail>

省略方括号就会渲染出字符串 parentItem,而不是 parentItem 的值。

Attribute 绑定

Attribute 绑定语法类似于 Property 绑定,但不是直接在方括号之间放置元素的 Property,而是在 Attribute 名称前面加上前缀 attr,后跟一个点 .。然后,使用解析为字符串的表达式设置 Attribute 值。

1
<p [attr.attribute-you-are-targeting]="expression"></p>

当表达式解析为 null 或 undefined 时,Angular 会完全删除该 Attribute。

绑定到 colspan

Attribute 绑定的另一个常见用例是绑定到表格中的 colspan Attribute。colspan Attribute 可帮助你以编程方式让表格保持动态。根据应用中用来填充表的数据量,某一行要跨越的列数可能会发生变化。

要将 Attribute 绑定到 的 colspan Attribute

使用以下语法指定 colspan:[attr.colspan]。

将 [attr.colspan] 设置为等于某个表达式。

在下面的示例中,我们将 colspan Attribute 绑定到表达式 1 + 1。

1
2
<!--  expression calculates colspan=2 -->
<tr><td [attr.colspan]="1 + 1">One-Two</td></tr>

此绑定会导致 跨越两列。

样式(class和style)绑定

使用类和样式绑定从元素的 class 属性中添加和删除 CSS 类名,以及动态设置样式。

绑定到单个 CSS class

要创建单个类绑定,请键入以下内容:

[class.sale]=”onSale”

当绑定表达式 onSale 为真值时,Angular 会添加类,当表达式为假值时,它会删除类 —— undefined 除外。

绑定到多个 CSS 类

要绑定到多个类,请键入以下内容:

[class]=”classExpression”

表达式可以是以下之一:

  • 用空格分隔的类名字符串。

  • 以类名作为键名并将真或假表达式作为值的对象。

  • 类名的数组。

对于对象格式,Angular 会在其关联的值为真时才添加类。

如果同一类名有多个绑定,Angular 会根据样式优先级来确定要使用的绑定。

下表是各种类绑定语法的小结。

绑定类型 语法 输入属性 范例输入值
单一类绑定 [class.sale]=”onSale” boolean,undefined,null true, false
多重类绑定 [class]=”classExpression” string “my-class-1 my-class-2 my-class-3”
多重类绑定 [class]=”classExpression” Record<string, ‘boolean,undefined,null’> {foo: true, bar: false}
多重类绑定 [class]=”classExpression” Array<string> [‘foo’, ‘bar’]

绑定到单一样式

要创建单个样式绑定,请使用 style 前缀,后跟一个点和 CSS 样式的名称。

比如,要设置 ‘width’ 样式,请键入以下内容:[style.width]=”width”

Angular 将该属性设置为绑定表达式的值,这通常是一个字符串。(可选)你可以添加单位扩展名,比如 em 或 %,这需要数字类型。

绑定到多个样式

要切换多个样式,请绑定到 [style] Attribute,比如 [style]=”styleExpression”。styleExpression 可以是如下格式之一:

样式的字符串列表,比如 “width: 100px; height: 100px; background-color: cornflowerblue;”。

一个对象,其键名是样式名,其值是样式值,比如 {width: ‘100px’, height: ‘100px’, backgroundColor: ‘cornflowerblue’}。

注意,不支持把数组绑定给 [style]

当把 [style] 绑定到对象表达式时,该对象的引用必须改变,这样 Angular 才能更新这个类列表。在不改变对象引用的情况下更新其属性值是不会生效的。

单样式和多样式绑定示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component({
selector: 'app-nav-bar',
template: `
<nav [style]='navStyle'>
<a [style.text-decoration]="activeLinkStyle">Home Page</a>
<a [style.text-decoration]="linkStyle">Login</a>
</nav>`
})
export class NavBarComponent {
navStyle = 'font-size: 1.2rem; color: cornflowerblue;';
linkStyle = 'underline';
activeLinkStyle = 'overline';
/* . . . */
}

下表是各种样式绑定语法的小结。

绑定类型 语法 输入属性 范例输入值
单一样式绑定 [style.width]=”width” string, undefined,null “100px”
带单位的单一样式绑定 [style.width.px]=”width” number,undefined,null 100
多重样式绑定 [style]=”styleExpression” string “width: 100px; height: 100px”
多重样式绑定 [style]=”styleExpression” Record<string, string,undefined,null> {width: ‘100px’, height: ‘100px’}

事件绑定

通过事件绑定,你可以侦听并响应用户操作,比如按键、鼠标移动、点击和触摸。

绑定到事件

要绑定到事件,你可以使用 Angular 事件绑定语法。此语法由等号左侧括号中的目标事件名称和右侧带引号的模板语句组成。

创建以下示例;目标事件名是 click,模板语句是 onSave()。

1
<button (click)="onSave()">Save</button>

事件绑定侦听按钮的单击事件,并在发生单击时调用组件的 onSave()。

绑定到被动事件

这是一项高级技术,对于大多数应用程序来说不是必需的。如果你想优化导致性能问题的频繁发生的事件,可能会发现这很有用。

Angular 还支持被动事件侦听器。比如,使用以下步骤使滚动事件变为被动的。

在 src 目录下创建一个文件 zone-flags.ts。

将以下行添加到此文件中。

1
(window as any)['__zone_symbol__PASSIVE_EVENTS'] = ['scroll'];

在 src/polyfills.ts 文件中,在导入 zone.js 之前,先导入新创建的 zone-flags。

1
2
import './zone-flags';
import 'zone.js'; // Included with Angular CLI.

在这些步骤之后,如果你为 scroll 事件添加事件侦听器,侦听器就会是 passive 的。

绑定到键盘事件

你可以用 Angular 的绑定语法绑定到键盘事件。你可以指定要绑定到键盘事件的键值或代码。它们的 key 和 code 字段是浏览器键盘事件对象的原生部分。默认情况下,事件绑定假定你要使用键盘事件上的 key 字段。你还可以用 code 字段。

键的组合可以用点(.)分隔。例如, keydown.enter 将允许你将事件绑定到 enter 键。你还可以用修饰键,例如 shift 、 alt 、 control 和 Mac 中的 command 键。以下示例展示了如何将键盘事件绑定到 keydown.shift.t 。

1
<input (keydown.shift.t)="onKeydown($event)" />

根据操作系统的不同,某些组合键可能会创建特殊字符,而不是你期望的组合键。例如,当你同时使用 option 和 shift 键时,MacOS 会创建特殊字符。如果你绑定到 keydown.shift.alt.t ,在 macOS 上,该组合会生成 ˇ 而不是 t ,它与绑定不匹配,也不会触发你的事件处理程序。要绑定到 macOS 上的 keydown.shift.alt.t ,请使用 code 键盘事件字段来获取正确的行为,例如此示例中显示的 keydown.code.shiftleft.altleft.keyt 。

1
<input (keydown.code.shiftleft.altleft.keyt)="onKeydown($event)" />

code 字段比 key 字段更具体。 key 字段总是会报告 shift ,而 code 字段将指明 leftshift 或 rightshift 。使用 code 字段时,你可能需要添加单独的绑定以捕获你想要的所有行为。使用 code 字段时无需处理操作系统特有的行为,例如 macOS 上的 shift + option 行为。

有关更多信息,请访问键值和键码的完整参考,以帮助你构建事件字符串。

双向绑定(组件)

双向绑定为应用中的组件提供了一种共享数据的方式。使用双向绑定绑定来侦听事件并在父组件和子组件之间同步更新值。

添加双向数据绑定

Angular 的双向绑定语法是方括号和圆括号的组合 [()]。[] 进行属性绑定,() 进行事件绑定,如下所示。

1
<app-sizer [(size)]="fontSizePx"></app-sizer>

双向绑定工作原理

为了使双向数据绑定有效,@Output() 属性的名字必须遵循 inputChange 模式,其中 input 是相应 @Input() 属性的名字。比如,如果 @Input() 属性为 size,则 @Output() 属性必须为 sizeChange。

后面的 sizerComponent 具有值属性 size 和事件属性 sizeChange。size 属性是 @Input(),因此数据可以流入 sizerComponent。sizeChange 事件是一个 @Output(),它允许数据从 sizerComponent 流出到父组件。

接下来,有两个方法,dec() 用于减小字体大小,inc() 用于增大字体大小。这两种方法使用 resize() 在最小/最大值的约束内更改 size 属性的值,并发出带有新 size 值的事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
export class SizerComponent {

@Input() size!: number | string;
@Output() sizeChange = new EventEmitter<number>();

dec() { this.resize(-1); }
inc() { this.resize(+1); }

resize(delta: number) {
this.size = Math.min(40, Math.max(8, +this.size + delta));
this.sizeChange.emit(this.size);
}
}

sizerComponent 模板有两个按钮,分别将 click 事件绑定到 inc() 和 dec() 方法。当用户单击按钮之一时,sizerComponent 调用相应的方法。inc() 和 dec() 这两个方法分别使用 +1 或 -1 调用 resize() 方法,它使用新的 size 值引发 sizeChange 事件。

1
2
3
4
5
<div>
<button type="button" (click)="dec()" title="smaller">-</button>
<button type="button" (click)="inc()" title="bigger">+</button>
<span [style.font-size.px]="size">FontSize: {{size}}px</span>
</div>

在 AppComponent 模板中,fontSizePx 被双向绑定到 SizerComponent。

1
2
<app-sizer [(size)]="fontSizePx"></app-sizer>
<div [style.font-size.px]="fontSizePx">Resizable Text</div>

在 AppComponent 中,通过将 fontSizePx 的值设置为 16 来设置初始 SizerComponent.size 值。

1
fontSizePx = 16;

单击这些按钮将更新 AppComponent.fontSizePx。修改后的 AppComponent.fontSizePx 值将更新样式绑定,从而使显示的文本变大或变小。

双向绑定语法是属性绑定和事件绑定的组合的简写形式。拆成单独的属性绑定和事件绑定形式的 SizerComponent 代码如下。

1
<app-sizer [size]="fontSizePx" (sizeChange)="fontSizePx=$event"></app-sizer>

$event 变量包含 SizerComponent.sizeChange 事件的数据。当用户单击按钮时,Angular 将 $event 赋值给 AppComponent.fontSizePx。

表单中的双向绑定

因为没有任何原生 HTML 元素遵循了 x 值和 xChange 事件的命名模式,所以与表单元素进行双向绑定需要使用 NgModel。

模板变量

模板变量可以帮助你在模板的另一部分使用这个部分的数据。使用模板变量,你可以执行某些任务,比如响应用户输入或微调应用的表单。

模板变量可以引用这些东西:

  • 模板中的 DOM 元素

  • 指令或组件

  • 来自 ng-template 的 TemplateRef

  • Web 组件

在模板中,要使用井号 # 来声明一个模板变量。下列模板变量 #phone 声明了一个名为 phone 的变量,其值为此 <input> 元素。

1
<input #phone placeholder="phone number" />

可以在组件模板中的任何地方引用某个模板变量。这里的 <button> 就引用了 phone 变量。

1
2
3
4
5
6
<input #phone placeholder="phone number" />

<!-- lots of other elements -->

<!-- phone refers to the input element; pass its `value` to an event handler -->
<button type="button" (click)="callPhone(phone.value)">Call</button>

Angular 是如何为模板变量赋值的

Angular 根据你所声明的变量的位置给模板变量赋值:

  • 如果在组件上声明变量,该变量就会引用该组件实例。

  • 如果在标准的 HTML 标记上声明变量,该变量就会引用该元素。

  • 如果你在 元素上声明变量,该变量就会引用一个 TemplateRef 实例来代表此模板。

指定名称的变量

如果该变量在右侧指定了一个名字,比如 #var=”ngModel”,那么该变量就会引用所在元素上具有这个 exportAs 名字的指令或组件。

将 NgForm 与模板变量一起使用

在大多数情况下,Angular 会把模板变量的值设置为它所在的元素。在前面的例子中,phone 引用的是电话号码 <input>。该按钮的 click 处理程序会把这个 <input> 的值传给该组件的 callPhone() 方法。

这里的 NgForm 指令演示了如何通过引用指令的的 exportAs 名字来引用不同的值。在下面的例子中,模板变量 itemForm 在 HTML 中分别出现了三次。

1
2
3
4
5
6
7
8
9
<form #itemForm="ngForm" (ngSubmit)="onSubmit(itemForm)">
<label for="name">Name</label>
<input type="text" id="name" class="form-control" name="name" ngModel required />
<button type="submit">Submit</button>
</form>

<div [hidden]="!itemForm.form.valid">
<p>{{ submitMessage }}</p>
</div>

如果没有 ngForm 这个属性值,itemForm 引用的值将是 HTMLFormElement 也就是 <form> 元素。如果某元素是一个 Angular 组件,则不带属性值的引用会自动引用此组件的实例。否则,不带属性值的引用会引用此 DOM 元素,而不管此元素上有一个或多个指令。

模板变量的作用域

就像 JavaScript 或 TypeScript 代码中的变量一样,模板变量的范围为声明它们的模板。

同样,诸如 *ngIf 和 *ngFor 类的结构指令或 <ng-template> 声明会创建一个新的嵌套模板范围,就像 JavaScript 的控制流语句(例如 if 和 for 创建新的词法范围。你不能从边界外访问这些结构指令之一中的模板变量。

在嵌套模板中访问

内部模板可以访问外模板定义的模板变量。

在下面的例子中,修改 <input> 中的文本值也会改变 <span> 中的值,因为 Angular 会立即通过模板变量 ref1 来更新这种变化。

1
2
<input #ref1 type="text" [(ngModel)]="firstExample" />
<span *ngIf="true">Value: {{ ref1.value }}</span>

在这种情况下,<span> 上的 *ngIf 会创建一个新的模板范围,其中包括其父范围中的 ref1 变量。

但是,从外部的父模板访问子范围中的变量是行不通的。

1
2
<input *ngIf="true" #ref2 type="text" [(ngModel)]="secondExample" />
<span>Value: {{ ref2?.value }}</span> <!-- doesn't work -->

在这里,ref2 是在 *ngIf 创建的子范围中声明的,并且无法从父模板访问。

模板输入变量

模板输入变量是一个具有在创建该模板实例时设置的值的变量。

可以在 NgFor 的长格式用法中看到模板输入变量的作用:

1
2
3
4
5
<ul>
<ng-template ngFor let-hero [ngForOf]="heroes">
<li>{{hero.name}}
</ng-template>
</ul>

NgFor 指令将实例化此为 hero 数组中的每个 heroes 一次,并将为每个实例相应地设置 hero 变量。

实例化 <ng-template> 时,可以传递多个命名值,这些值可以绑定到不同的模板输入变量。输入变量的 let- 声明的右侧可以指定应该用于该变量的值。

例如,NgFor 还提供了对数组中每个英雄的 index 的访问:

1
2
3
4
5
<ul>
<ng-template ngFor let-hero let-i="index" [ngForOf]="heroes">
<li>Hero number {{i}}: {{hero.name}}
</ng-template>
</ul>

组件是 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 之间,信息通过该服务实现了双向传递。

在创建 LINQ 查询时,通常使用 Lambda 表达式编写函数参数。 在典型的 LINQ 查询中,这些函数参数会被转换为编译器创建的委托。

当想要进行更丰富的交互时,需要使用表达式树。 表达式树将代码表示为可以检查、修改或执行的结构。 这些工具让你能够在运行时操作代码。 可以编写检查正在运行的算法的代码,或插入新的功能。 在更加高级的方案中,你可以修改正在运行的算法,甚至可以将 C# 表达式转换为另一种形式从而可在另一环境中执行。

表达式树说明

让我们来举一个简单的示例。 以下是一个代码行:

1
var sum = 1 + 2;

直观地看,整个语句是一棵树:应从根节点开始,浏览到树中的每个节点,以查看构成该语句的代码:

  • 具有赋值 (var sum = 1 + 2;) 的变量声明语句
    • 隐式变量类型声明 (var sum)
      • 隐式 var 关键字 (var)
      • 变量名称声明 (sum)
    • 赋值运算符 (=)
    • 二进制加法表达式 (1 + 2)
      • 左操作数 (1)
      • 加法运算符 (+)
      • 右操作数 (2)

表达式树的结构非常一致。 了解基础知识后,你甚至可以理解以表达式树形式表示的最复杂的代码。 优美的数据结构说明了 C# 编译器如何分析最复杂的 C# 程序并从该复杂的源代码创建正确的输出。

不能执行的操作是修改表达式树。 表达式树是不可变的数据结构。 如果想要改变(更改)表达式树,则必须创建基于原始树副本但包含所需更改的新树。

支持表达式树的框架类型

这一切都始于 System.Linq.Expression

1
2
3
4
5
6
7
8
9
10
11
Expression<Func<int, int>> addFive = (num) => num + 5;

if (addFive.NodeType == ExpressionType.Lambda)
{
var lambdaExp = (LambdaExpression)addFive;

var parameter = lambdaExp.Parameters.First();

Console.WriteLine(parameter.Name);
Console.WriteLine(parameter.Type);
}

例如,此代码将打印变量访问表达式的变量的名称。 我的做法是,先查看节点类型,再转换为变量访问表达式,然后查看特定表达式类型的属性:

创建表达式树

System.Linq.Expression 类还包含许多创建表达式的静态方法。

1
2
3
4
// Addition is an add expression for "1 + 2"
var one = Expression.Constant(1, typeof(int));
var two = Expression.Constant(2, typeof(int));
var addition = Expression.Add(one, two);

导航 API

存在映射到 C# 语言的几乎所有语法元素的表达式节点类型。 每种类型都有针对该种语言元素的特定方法。 需要一次性记住的内容很多。 我不会记住所有内容,而是会采用有关使用表达式树的技巧,如下所示:

  • 查看 ExpressionType 枚举的成员以确定应检查的可能节点。 如果想要遍历和理解表达式树,这将非常有用。
  • 查看 Expression 类的静态成员以生成表达式。 这些方法可以从其子节点集生成任何表达式类型。
  • 查看 ExpressionVisitor 类,以生成一个经过修改的表达式树。

执行表达式树

表达式树是表示一些代码的数据结构。 它不是已编译且可执行的代码。 如果想要执行由表达式树表示的 .NET 代码,则必须将其转换为可执行的 IL 指令。

Lambda 表达式到函数

可以将任何 LambdaExpression 或派生自 LambdaExpression 的任何类型转换为可执行的 IL。
其他表达式类型不能直接转换为代码。 此限制在实践中影响不大。
Lambda 表达式是你可通过转换为可执行的中间语言 (IL) 来执行的唯一表达式类型。

LambdaExpression 类型包含用于将表达式树转换为可执行代码的 Compile 和 CompileToMethod 成员。 Compile 方法创建委托。 CompileToMethod 方法通过表示表达式树的已编译输出的 IL 更新 MethodBuilder 对象。

请注意,CompileToMethod 仅在完整的桌面框架中可用,不能用于 .NET Core。

使用下面的代码将表达式转换为委托:

1
2
3
4
Expression<Func<int>> add = () => 1 + 2;
var func = add.Compile(); // Create Delegate
var answer = func(); // Invoke Delegate
Console.WriteLine(answer);

请注意,该委托类型基于表达式类型。 如果想要以强类型的方式使用委托对象,则必须知道返回类型和参数列表。 LambdaExpression.Compile() 方法返回 Delegate 类型。 必须将其转换为正确的委托类型,以便使任何编译时工具检查参数列表或返回类型。

在此提醒你不要通过避免不必要的编译调用尝试创建用于提高性能的任何更复杂的缓存机制。 比较两个任意的表达式树,以确定如果它们表示相同的算法,是否也会花费很长的时间来执行。 你可能会发现,通过避免对 LambdaExpression.Compile() 的任何额外调用所节省的计算时间将多于执行代码(该代码确定可导致相同可执行代码的两个不同表达式树)所花费的时间。

注意事项

将 lambda 表达式编译为委托并调用该委托是可对表达式树执行的最简单的操作之一。 但是,即使是执行这个简单的操作,也存在一些必须注意的事项。

Lambda 表达式将对表达式中引用的任何局部变量创建闭包。 必须保证作为委托的一部分的任何变量在调用 Compile 的位置处和执行结果委托时可用。

一般情况下,编译器会确保这一点。 但是,如果表达式访问实现 IDisposable 的变量,则代码可能在表达式树仍保留有对象时释放该对象。

例如,此代码工作正常,因为 int 不实现 IDisposable:

1
2
3
4
5
6
7
private static Func<int, int> CreateBoundFunc()
{
var constant = 5; // constant is captured by the expression tree
Expression<Func<int, int>> expression = (b) => constant + b;
var rVal = expression.Compile();
return rVal;
}

委托已捕获对局部变量 constant 的引用。 在稍后执行 CreateBoundFunc 返回的函数之后,可随时访问该变量。

但是,请考虑实现 IDisposable 的此(人为设计的)类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Resource : IDisposable
{
private bool isDisposed = false;
public int Argument
{
get
{
if (!isDisposed)
return 5;
else throw new ObjectDisposedException("Resource");
}
}

public void Dispose()
{
isDisposed = true;
}
}

如果将其用于如下所示的表达式中,则在执行 Resource.Argument 属性引用的代码时将出现 ObjectDisposedException:

1
2
3
4
5
6
7
8
9
private static Func<int, int> CreateBoundResource()
{
using (var constant = new Resource()) // constant is captured by the expression tree
{
Expression<Func<int, int>> expression = (b) => constant.Argument + b;
var rVal = expression.Compile();
return rVal;
}
}

从此方法返回的委托已对释放了的 constant 对象闭包。 (它已被释放,因为它已在 using 语句中进行声明。)

现在,在执行从此方法返回的委托时,将在执行时引发 ObjectDisposedException。

定义表达式时,请谨慎访问局部变量,且在创建可由公共 API 返回的表达式树时,谨慎访问当前对象(由 this 表示)中的状态。

表达式中的代码可能引用其他程序集中的方法或属性。 对表达式进行定义、编译或在调用结果委托时,该程序集必须可访问。 在它不存在的情况下,将遇到 ReferencedAssemblyNotFoundException。

生成表达式树

到目前为止,你所看到的所有表达式树都是由 C# 编译器创建的。 你所要做的是创建一个 lambda 表达式,将其分配给一个类型为 Expression<Func> 或某种相似类型的变量。 这不是创建表达式树的唯一方法。 很多情况下,可能需要在运行时在内存中生成一个表达式。

由于这些表达式树是不可变的,所以生成表达式树很复杂。 不可变意味着必须以从叶到根的方式生成表达式树。 用于生成表达式树的 API 体现了这一点:用于生成节点的方法将其所有子级用作参数。 让我们通过几个示例来了解相关技巧。

创建节点

让我们再次从相对简单的内容开始。 我们将使用在这些部分中一直使用的加法表达式:

生成树

这是在内存中生成表达式树的基础知识。 更复杂的树通常意味着更多的节点类型,并且树中有更多的节点。 让我们再浏览一个示例,了解通常在创建表达式树时创建的其他两个节点类型:参数节点和方法调用节点。

生成一个表达式树以创建此表达式:

1
2
Expression<Func<double, double, double>> distanceCalc =
(x, y) => Math.Sqrt(x * x + y * y);

首先,创建 x 和 y 的参数表达式:

1
2
var xParameter = Expression.Parameter(typeof(double), "x");
var yParameter = Expression.Parameter(typeof(double), "y");

按照你所看到的模式创建乘法和加法表达式:

1
2
3
var xSquared = Expression.Multiply(xParameter, xParameter);
var ySquared = Expression.Multiply(yParameter, yParameter);
var sum = Expression.Add(xSquared, ySquared);

接下来,需要为调用 Math.Sqrt 创建方法调用表达式。

1
2
var sqrtMethod = typeof(Math).GetMethod("Sqrt", new[] { typeof(double) });
var distance = Expression.Call(sqrtMethod, sum);

最后,将方法调用放入 Lambda 表达式,并确保定义 Lambda 表达式的参数:

1
2
3
4
var distanceLambda = Expression.Lambda(
distance,
xParameter,
yParameter);

在这个更复杂的示例中,你看到了创建表达式树通常使用的其他几种技巧。

首先,在使用它们之前,需要创建表示参数或局部变量的对象。 创建这些对象后,可以在表达式树中任何需要的位置使用它们。

其次,需要使用反射 API 的一个子集来创建 MethodInfo 对象,以便创建表达式树以访问该方法。 必须仅限于 .NET Core 平台上提供的反射 API 的子集。 同样,这些技术将扩展到其他表达式树。

深度生成代码

不仅限于使用这些 API 可以生成的代码。 但是,要生成的表达式树越复杂,代码就越难以管理和阅读。

让我们生成一个与此代码等效的表达式树:

1
2
3
4
5
6
7
8
9
10
Func<int, int> factorialFunc = (n) =>
{
var res = 1;
while (n > 1)
{
res = res * n;
n--;
}
return res;
};

请注意上面我未生成表达式树,只是生成了委托。 使用 Expression 类不能生成语句 lambda。 下面是生成相同的功能所需的代码。 它很复杂,这是因为没有用于生成 while 循环的 API,而是需要生成一个包含条件测试的循环和一个用于中断循环的标签目标。

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
var nArgument = Expression.Parameter(typeof(int), "n");
var result = Expression.Variable(typeof(int), "result");

// Creating a label that represents the return value
LabelTarget label = Expression.Label(typeof(int));

var initializeResult = Expression.Assign(result, Expression.Constant(1));

// This is the inner block that performs the multiplication,
// and decrements the value of 'n'
var block = Expression.Block(
Expression.Assign(result,
Expression.Multiply(result, nArgument)),
Expression.PostDecrementAssign(nArgument)
);

// Creating a method body.
BlockExpression body = Expression.Block(
new[] { result },
initializeResult,
Expression.Loop(
Expression.IfThenElse(
Expression.GreaterThan(nArgument, Expression.Constant(1)),
block,
Expression.Break(label, result)
),
label
)
);

用于生成阶乘函数的表达式树的代码相对更长、更复杂,它充满了标签和 break 语句以及我们在日常编码任务中想要避免的其他元素。

转换表达式树

通过使用 LINQ to SQL,可以使用 LINQ 技术访问 SQL 数据库,就像访问内存中的集合一样。

@Injectable() 装饰器把一个类定义为 Angular 中的服务,并且允许 Angular 把它作为依赖注入到组件中。 类似的,@Injectable() 装饰器会标记出某个组件、类、管道或 NgModule 具有对某个服务的依赖。

  • Angular 会在启动过程中为你创建全应用级注入器以及所需的其它注入器。你不用自己创建注入器。
  • 该注入器会创建依赖、维护一个容器来管理这些依赖,并尽可能复用它们。
  • 提供者(provider)是一个对象,用来告诉注入器应该如何获取或创建依赖

你的应用中所需的任何依赖,都必须使用该应用的注入器来注册一个提供者,以便注入器可以使用这个提供者来创建新实例。对于服务,该提供者通常就是服务类本身。

依赖不一定是服务 —— 它还可能是函数或值。

当 Angular 创建组件类的新实例时,它会通过查看该组件类的构造函数,来决定该组件依赖哪些服务或其它依赖项。比如 HeroListComponent 的构造函数中需要 HeroService:

1
constructor(private service: HeroService) { }

当 Angular 发现某个组件依赖某个服务时,它会首先检查是否该注入器中已经有了那个服务的任何现有实例。如果所请求的服务尚不存在,注入器就会使用以前注册的服务提供者来制作一个,并把它加入注入器中,然后把该服务返回给 Angular。

Angular 如何知道 class 有哪些依赖?

JS 没有反射,那 Angular 怎么能从 ServiceB 的 constructor 感知到其依赖 ServiceA 呢?

1
2
3
class ServiceB {
constructor(serviceA: ServiceA) {}
}

答案是黑魔法 Compilation。 ServiceB 经过 compile 后会变成这样

图 0

它多了一个 ɵfac 静态方法。

从代码上可以推测出 injector.get(ServiceB),其实并不是直接执行了 new ServiceB(new ServiceA()),它只是调用了 ServiceB.ɵfac()。

而 ɵfac 内容才是 new ServiceB( inject(ServiceA) )。这句代码便是 compiler 透过反射 constructor 得知 ServiceB 依赖 ServiceA 后写出来的。

另外,inject(ServiceA) 是一个递归实例化依赖函数,里面一定是调用了 ServiceA.ɵfac()。以此类推,一直到所有的依赖全部被实例化。

简而言之,虽然 JS 没有反射,但是 Angular compiler 可以反射,然后自动编写出实例化依赖的代码。这就是 Angular DI 的实现秘诀啦。

提供服务范围

对于要用到的任何服务,你必须至少注册一个提供者。服务可以在自己的元数据中把自己注册为提供者,这样可以让自己随处可用。或者,你也可以为特定的模块或组件注册提供者。要注册提供者,就要在服务的 @Injectable() 装饰器中提供它的元数据,或者在 @NgModule() 或 @Component() 的元数据中。

  • 默认情况下,Angular CLI 的 ng generate service 命令会在 @Injectable() 装饰器中提供元数据来把它注册到根注入器中。
1
2
3
  @Injectable({
providedIn: 'root',
})

当你在根一级提供服务时,Angular 会为 HeroService 创建一个单一的共享实例,并且把它注入到任何想要它的类中。

这种在 @Injectable 元数据中注册提供者的方式还让 Angular 能够通过移除那些从未被用过的服务来优化大小,这个过程称为摇树优化(tree-shaking)。

  • 当你使用特定的 NgModule 注册提供者时,该服务的同一个实例将会对该 NgModule 中的所有组件可用。要想在这一层注册,请用 @NgModule() 装饰器中的 providers 属性:
1
2
3
4
5
6
7
@NgModule({
providers: [
BackendService,
Logger
],

})
  • 当你在组件级注册提供者时,你会为该组件的每一个新实例提供该服务的一个新实例。要在组件级注册,就要在 @Component() 元数据的 providers 属性中注册服务提供者。
    1
    2
    3
    4
    5
    @Component({
    selector: 'app-hero-list',
    templateUrl: './hero-list.component.html',
    providers: [ HeroService ]
    })

抽象理解 provider 和 injector

provider 的特性

抽象的看,provider 是一个 key value pair 对象。

key 的作用是为了识别。

value 则是一个提供最终值的 factory 函数。

只要能满足这 2 点,那它就可以被作为 provider。

Injector 的特性

Injector 不仅仅是实例化机器。

抽象的看,injector 第一个任务是通过 key 查找出指定的 provider,这个 key 只要具备可识别性就可以了。比如:string,class,symbol 等等都具备识别性。

第二个任务是通过 provider value factory 生产出最终的值。当然如果这个 factory 需要依赖,injector 会先查找它所需要的依赖,注入给 factory 函数。

Provider & StaticProvider

Injector.create 的 interface 长这样
图 1

Angular 有多种不同形态的 Provider.
Provider 和 StaticProvider 是所有 Provider 的抽象。

1
2
3
type Provider = TypeProvider | ValueProvider | ClassProvider | ConstructorProvider | ExistingProvider | FactoryProvider | any[];

type StaticProvider = ValueProvider | ExistingProvider | StaticClassProvider | ConstructorProvider | FactoryProvider | any[];

它俩是有重叠的,总的来说是 TypeProvider、ClassProvider、StaticClassProvider、ConstructorProvider、FactoryProvider、ValueProvider、ExistingProvider。

配置依赖提供者

你还可以用其他值作为依赖项,例如 Boolean、字符串、日期和对象。 Angular DI 提供了一些必要的 API 来让依赖的配置方式更加灵活,以便你可以把这些值在 DI 中可用。

指定提供者令牌

如果你用服务类作为提供者令牌,则其默认行为是注入器使用 new 运算符实例化该类。

在下面这个例子中,Logger 类提供了 Logger 的实例。

1
providers: [Logger]

但是,你可以将 DI 配置为使用不同的类或任何其他不同的值来与 Logger 类关联。因此,当注入 Logger 时,会改为使用这个新值。

实际上,类提供者语法是一个简写表达式,可以扩展为由 Provider 接口定义的提供者配置信息。

在这种情况下,Angular 将 providers 值展开为完整的提供者对象,如下所示:

1
[{ provide: Logger, useClass: Logger }]

展开后的提供者配置是一个具有两个属性的对象字面量:

  • provide 属性包含一个令牌,该令牌会作为定位依赖值和配置注入器时的键。

  • 第二个属性是一个提供者定义对象,它会告诉注入器如何创建依赖值。 提供者定义对象中的键可以是以下值之一:

    • useClass -此选项告诉 Angular DI 在注入依赖项时要实例化这里提供的类

    • useExisting - 允许你为令牌起一个别名,并引用任意一个现有令牌。

    • useFactory - 允许你定义一个用来构造依赖项的函数

    • useValue - 提供了一个应该作为依赖项使用的静态值。

下面的部分介绍如何使用这里所说的“提供者定义”键。

类提供者:useClass

useClass 能让你创建并返回指定类的新实例。

你可以用这种类型的提供者来作为通用类或默认类的替代实现。
例如,替代实现可以实现不同的策略、扩展默认类或模拟测试用例中真实类的行为。在以下示例中,当在组件或任何其他类中请求 Logger 依赖项时,将转而实例化 BetterLogger 类。

1
[{ provide: Logger, useClass: BetterLogger }]

如果替代类提供者有自己的依赖项,请在父模块或组件的 providers 元数据属性中指定这两个提供者。

1
2
[ UserService,
{ provide: Logger, useClass: EvenBetterLogger }]

在这个例子中,EvenBetterLogger 会在日志信息里显示用户名。这个 logger 要从注入的 UserService 实例中来获取该用户。

1
2
3
4
5
6
7
8
9
@Injectable()
export class EvenBetterLogger extends Logger {
constructor(private userService: UserService) { super(); }

override log(message: string) {
const name = this.userService.user.name;
super.log(`Message to ${name}: ${message}`);
}
}

别名提供者:useExisting

useExisting 允许你将一个令牌映射到另一个。实际上,第一个令牌是与第二个令牌关联的服务的别名,创建了两种访问同一个服务对象的方式。

在下面的例子中,当组件请求新的或旧的记录器时,注入器都会注入一个 NewLogger 的实例。通过这种方式,OldLogger 就成了 NewLogger 的别名。

1
2
3
[ NewLogger,
// Alias OldLogger w/ reference to NewLogger
{ provide: OldLogger, useExisting: NewLogger}]

确保你没有使用 OldLogger 将 NewLogger 别名为 useClass ,因为这会创建两个不同 NewLogger 实例。

工厂提供者:useFactory

useFactory 允许你通过调用工厂函数来创建依赖对象。使用这种方法,你可以根据 DI 和应用程序中其他地方的可用信息创建动态值。

在下面的例子中,只有授权用户才能看到 HeroService 中的秘密英雄。授权可能在单个应用会话期间发生变化,比如改用其他用户登录。

要想在 UserService 和 HeroService 中保存敏感信息,就要给 HeroService 的构造函数传一个逻辑标志来控制秘密英雄的显示。

hero.service.ts

1
2
3
4
5
6
7
8
9
constructor(
private logger: Logger,
private isAuthorized: boolean) { }

getHeroes() {
const auth = this.isAuthorized ? 'authorized > ' : 'unauthorized';
this.logger.log(`Getting heroes for ${auth} user.`);
return HEROES.filter(hero => this.isAuthorized || !hero.isSecret);
}

要实现 isAuthorized 标志,可以用工厂提供者来为 HeroService 创建一个新的 logger 实例。

1
2
const heroServiceFactory = (logger: Logger, userService: UserService) =>
new HeroService(logger, userService.user.isAuthorized);

这个工厂函数可以访问 UserService。你可以同时把 Logger 和 UserService 注入到工厂提供者中,这样注入器就可以把它们传给工厂函数了。

1
2
3
4
5
export const heroServiceProvider =
{ provide: HeroService,
useFactory: heroServiceFactory,
deps: [Logger, UserService]
};
  • useFactory 字段指定该提供者是一个工厂函数,其实现代码是 heroServiceFactory。

  • deps 属性是一个提供者令牌的数组。 Logger 和 UserService 类作为它们自己的类提供者的令牌。注入器会解析这些令牌,并将相应的服务注入到匹配的 heroServiceFactory 工厂函数参数中。

通过把工厂提供者导出为变量 heroServiceProvider,就能让工厂提供者变得可复用。

值提供者:useValue

useValue 允许你将固定值与某个 DI 令牌相关联。可以用此技术提供运行时配置常量,例如网站基址和特性标志。你还可以在单元测试中使用值提供者来提供模拟数据以代替生产级数据服务。

使用 InjectionToken 对象

可以定义和使用一个 InjectionToken 对象来为非类的依赖选择一个提供者令牌。下列例子定义了一个类型为 InjectionToken 的 APP_CONFIG。

1
2
3
import { InjectionToken } from '@angular/core';

export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');

可选的参数 和令牌描述 app.config 指明了此令牌的用途。

接着,用 APP_CONFIG 这个 InjectionToken 对象在组件中注册依赖提供者。

1
providers: [{ provide: APP_CONFIG, useValue: HERO_DI_CONFIG }]

现在,借助参数装饰器 @Inject(),你可以把这个配置对象注入到构造函数中。

1
2
3
constructor(@Inject(APP_CONFIG) config: AppConfig) {
this.title = config.title;
}

使用参数装饰器来限定依赖查找方式

默认情况下,DI 框架会在注入器树中查找一个提供者,从该组件的局部注入器开始,如果需要,则沿着注入器树向上冒泡,直到根注入器。

  • 第一个配置过该提供者的注入器就会把依赖(服务实例或值)提供给这个构造函数

  • 如果在根注入器中也没有找到提供者,则 DI 框架将会抛出一个错误

通过在类的构造函数中对服务参数使用参数装饰器,可以提供一些选项来修改默认的搜索行为。

用 @Optional 来让依赖是可选的,以及使用 @Host 来限定搜索方式

某些情况下,你需要限制搜索,或容忍依赖项的缺失。你可以使用组件构造函数参数上的 @Host 和 @Optional 这两个限定装饰器来修改 Angular 的搜索行为。

  • @Optional 属性装饰器告诉 Angular 当找不到依赖时就返回 null

  • @Host 属性装饰器会禁止在宿主组件以上的搜索。宿主组件通常就是请求该依赖的那个组件。不过,当该组件投影进某个父组件时,那个父组件就会变成宿主。

1
2
3
4
template: `
<app-hero-bio [heroId]="1"> <app-hero-contact></app-hero-contact> </app-hero-bio>
<app-hero-bio [heroId]="2"> <app-hero-contact></app-hero-contact> </app-hero-bio>
<app-hero-bio [heroId]="3"> <app-hero-contact></app-hero-contact> </app-hero-bio>`,

标签中是一个新的 元素。Angular 就会把相应的 HeroContactComponent投影(transclude)进 HeroBioComponent 的视图里,将它放在 HeroBioComponent 模板的 标签槽里。

1
2
3
4
template: `
<h4>{{hero.name}}</h4>
<ng-content></ng-content>
<textarea cols="25" [(ngModel)]="hero.description"></textarea>`,

使用 @Inject 指定自定义提供者

自定义提供者让你可以为隐式依赖提供一个具体的实现,比如内置浏览器 API。下面的例子使用 InjectionToken 来提供 localStorage,将其作为 BrowserStorageService 的依赖项。

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
  import { Inject, Injectable, InjectionToken } from '@angular/core';

export const BROWSER_STORAGE = new InjectionToken<Storage>('Browser Storage', {
providedIn: 'root',
factory: () => localStorage
});

@Injectable({
providedIn: 'root'
})
export class BrowserStorageService {
constructor(@Inject(BROWSER_STORAGE) public storage: Storage) {}

get(key: string) {
return this.storage.getItem(key);
}

set(key: string, value: string) {
this.storage.setItem(key, value);
}

remove(key: string) {
this.storage.removeItem(key);
}

clear() {
this.storage.clear();
}
}

factory 函数返回 window 对象上的 localStorage 属性。Inject 装饰器修饰一个构造函数参数,用于为某个依赖提供自定义提供者。

使用 @Self 和 @SkipSelf 来修改提供者的搜索方式

注入器也可以通过构造函数的参数装饰器来指定范围。下面的例子就在 Component 类的 providers 中使用浏览器的 sessionStorage API 覆盖了 BROWSER_STORAGE 令牌。同一个 BrowserStorageService 在构造函数中使用 @Self 和 @SkipSelf 装饰器注入了两次,来分别指定由哪个注入器来提供依赖。

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
import { Component, OnInit, Self, SkipSelf } from '@angular/core';
import { BROWSER_STORAGE, BrowserStorageService } from './storage.service';

@Component({
selector: 'app-storage',
template: `
Open the inspector to see the local/session storage keys:

<h3>Session Storage</h3>
<button type="button" (click)="setSession()">Set Session Storage</button>

<h3>Local Storage</h3>
<button type="button" (click)="setLocal()">Set Local Storage</button>
`,
providers: [
BrowserStorageService,
{ provide: BROWSER_STORAGE, useFactory: () => sessionStorage }
]
})
export class StorageComponent implements OnInit {

constructor(
@Self() private sessionStorageService: BrowserStorageService,
@SkipSelf() private localStorageService: BrowserStorageService,
) { }

ngOnInit() {
}

setSession() {
this.sessionStorageService.set('hero', 'Dr Nice - Session');
}

setLocal() {
this.localStorageService.set('hero', 'Dr Nice - Local');
}
}

使用 @Self 装饰器时,注入器只在该组件的注入器中查找提供者。@SkipSelf 装饰器可以让你跳过局部注入器,并在注入器树中向上查找,以发现哪个提供者满足该依赖。sessionStorageService 实例使用浏览器的 sessionStorage 来跟 BrowserStorageService 打交道,而 localStorageService 跳过了局部注入器,使用根注入器提供的 BrowserStorageService,它使用浏览器的 localStorage API。

注入器层次结构的类型

Angular 中有两个注入器层次结构:

注入器层次结构 详情
ModuleInjector 层次结构 使用 @NgModule() 或 @Injectable() 注解在此层次结构中配置 ModuleInjector。
ElementInjector 层次结构 在每个 DOM 元素上隐式创建。默认情况下,ElementInjector 是空的,除非你在 @Directive() 或 @Component() 的 providers 属性中配置它。

ModuleInjector

可以通过以下两种方式之一配置 ModuleInjector :

  • 使用 @Injectable() 的 providedIn 属性引用 @NgModule() 或 root

  • 使用 @NgModule() 的 providers 数组

摇树优化与 @Injectable()

使用 @Injectable() 的 providedIn 属性优于 @NgModule() 的 providers 数组。使用 @Injectable() 的 providedIn 时,优化工具可以进行摇树优化,从而删除你的应用程序中未使用的服务,以减小捆绑包尺寸。

平台注入器

在 root 之上还有两个注入器,一个是额外的 ModuleInjector,一个是 NullInjector()。

思考下 Angular 要如何通过 main.ts 中的如下代码引导应用程序:

1
platformBrowserDynamic().bootstrapModule(AppModule).then(ref => {…})

bootstrapModule() 方法会创建一个由 AppModule 配置的注入器作为平台注入器的子注入器。也就是 root ModuleInjector。

platformBrowserDynamic() 方法创建一个由 PlatformModule 配置的注入器,该注入器包含特定平台的依赖项。这允许多个应用共享同一套平台配置。比如,无论你运行多少个应用程序,浏览器都只有一个 URL 栏。你可以使用 platformBrowser() 函数提供 extraProviders,从而在平台级别配置特定平台的额外提供者。

层次结构中的下一个父注入器是 NullInjector(),它是树的顶部。如果你在树中向上走了很远,以至于要在 NullInjector() 中寻找服务,那么除非使用 @Optional(),否则将收到错误消息,因为最终所有东西都将以 NullInjector() 结束并返回错误,或者对于 @Optional(),返回 null。

NullInjector()

(always throws an error unless you use @Optional)

ModuleInjector

(configured by PlatformModule)
has special things like DomSanitizer=>platformBrowser()


root ModuleInjector
(configured by YourAppModule)
has things for your app=>bootstrapModule(YourAPPModule)

虽然 root 是一个特殊的别名,但其它 ModuleInjector 都没有别名。每当创建动态加载组件时,你还会创建 ModuleInjector,比如路由器,它还会创建子 ModuleInjector。

无论是使用 bootstrapModule() 的方法配置它,还是将所有提供者都用 root 注册到其自己的服务中,所有请求最终都会转发到 root 注入器。

如果你在 AppModule 的 @NgModule() 中配置应用级提供者,它就会覆盖一个在 @Injectable() 的 root 元数据中配置的提供者。你可以用这种方式,来配置供多个应用共享的服务的非默认提供者。

ElementInjector

Angular 会为每个 DOM 元素隐式创建 ElementInjector。

可以用 @Component() 装饰器中的 providers 或 viewProviders 属性来配置 ElementInjector 以提供服务。比如,下面的 TestComponent 通过提供此服务来配置 ElementInjector

1
2
3
4
5
@Component({

providers: [{ provide: ItemService, useValue: { name: 'lamp' } }]
})
export class TestComponent

@Directive() 和 @Component()

组件是一种特殊类型的指令,这意味着 @Directive() 具有 providers 属性,@Component() 也同样如此。 这意味着指令和组件都可以使用 providers 属性来配置提供者。当使用 providers 属性为组件或指令配置提供者时,该提供程商就属于该组件或指令的 ElementInjector。同一元素上的组件和指令共享同一个注入器。

解析规则

当为组件/指令解析令牌时,Angular 分为两个阶段来解析它:

  • 针对 ElementInjector 层次结构中它的父级。

  • 针对 ModuleInjector 层次结构中它的父级。

当组件声明依赖项时,Angular 会尝试使用它自己的 ElementInjector 来满足该依赖。 如果组件的注入器缺少提供者,它将把请求传给其父组件的 ElementInjector。

这些请求将继续转发,直到 Angular 找到可以处理该请求的注入器或用完祖先 ElementInjector。

如果 Angular 在任何 ElementInjector 中都找不到提供者,它将返回到发起请求的元素,并在 ModuleInjector 层次结构中进行查找。如果 Angular 仍然找不到提供者,它将引发错误。

如果你已在不同级别注册了相同 DI 令牌的提供者,则 Angular 会用遇到的第一个来解析该依赖。比如,如果提供者已经在需要此服务的组件中本地注册了,则 Angular 不会再寻找同一服务的其它提供者。

模板的逻辑结构

1
2
3
<app-root>
<app-child></app-child>
</app-root>

下面是如何将 视图树组合为单个逻辑树的范例:

1
2
3
4
5
6
7
8
9
<app-root>
<#VIEW>
<app-child>
<#VIEW>
…content goes here…
</#VIEW>
</app-child>
</#VIEW>
</app-root>

当你在组件类中配置服务时,了解这种 <#VIEW> 划界的思想尤其重要。

使用 viewProviders 数组

使用 viewProviders 数组是在 @Component() 装饰器中提供服务的另一种方法。使用 viewProviders 使服务在 <#VIEW> 中可见。

除了使用 viewProviders 数组外,其它步骤与使用 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
<app-root @NgModule(AppModule)
@Inject(AnimalService) animal=>"🐳">
<#VIEW>
<app-child>
<#VIEW @Provide(AnimalService="🐶")
@Inject(AnimalService=>"🐶")>
<!-- ^^using viewProviders means AnimalService is available in <#VIEW>-->
<p>Emoji from AnimalService: {{animal.emoji}} (🐶)</p>

<div class="container">
<h3>Content projection</h3>
<app-inspector @Inject(AnimalService) animal=>"🐳">
<p>Emoji from AnimalService: {{animal.emoji}} (🐳)</p>
</app-inspector>
</div>

</#VIEW>
<app-inspector>
<#VIEW>
<p>Emoji from AnimalService: {{animal.emoji}} (🐶)</p>
</#VIEW>
</app-inspector>
</app-child>
</#VIEW>
</app-root>

这四个绑定说明了 providers 和 viewProviders 之间的区别。由于🐶(小狗)在 <#VIEW> 中声明,因此投影内容不可见。投影的内容中会看到🐳(鲸鱼)。

但是下一部分,InspectorComponent 是 ChildComponent 的子组件,InspectorComponent 在 <#VIEW> 内部,因此当它请求 AnimalService 时,它会看到🐶(小狗)。

的投影内容中看到了🐳(鲸鱼),而不是🐶(小狗),因为🐶(小狗)在 的 <#VIEW> 中。如果 也位于 <#VIEW> 则只能看到🐶(小狗)。

@Host() 和 viewProviders

@Host() 将搜索的上限限制为父节点的 <#VIEW>

1
2
3
4
5
6
7
8
9
10
11
12
13
<app-root @NgModule(AppModule)
@Inject(AnimalService=>"🐳")>
<#VIEW @Provide(AnimalService="🦔")
@Inject(AnimalService, @Optional)=>"🦔">
<!-- ^^@SkipSelf() starts here, @Host() stops here^^ -->
<app-child>
<#VIEW @Provide(AnimalService="🐶")
@Inject(AnimalService, @SkipSelf, @Host, @Optional)=>"🦔">
<!-- Add @SkipSelf ^^-->
</#VIEW>
</app-child>
</#VIEW>
</app-root>

@SkipSelf() 导致注入器从 而不是 处开始对 AnimalService 进行搜索,而 @Host() 会在 的 <#VIEW> 处停止搜索。 由于 AnimalService 是通过 viewProviders 数组提供的,因此注入程序会在 <#VIEW> 找到🦔(刺猬)。

@Inject()、Provider.deps、inject

@Inject()

@inject 主要的使用场景是在 class constructor 注入 token。

1
2
3
4
5
6
7
8
9
10
11
const VALUE_TOKEN = new InjectionToken<string>('Value');
@Injectable()
class ServiceA {
constructor(@Inject(VALUE_TOKEN) value: string) {}
}

const injector = Injector.create({
providers: [ServiceA, { provide: VALUE_TOKEN, useValue: 'Hello World' }],
});

const serviceA = injector.get(ServiceA);

注: 要搭配 @Injectable 指令

inject 函数可以完全取代 @Inject(),上面代码可以改成这样

1
2
3
4
5
class ServiceA {
constructor() {
const value = inject(VALUE_TOKEN);
}
}

连 @Injectable() 也可以省略掉

Provider.deps

除了 @Inject(),还有一种注入方式是通过 Provider.deps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const VALUE_1_TOKEN = new InjectionToken<string>('Value1');
const VALUE_2_TOKEN = new InjectionToken<string>('Value2');

const injector = Injector.create({
providers: [
{ provide: VALUE_1_TOKEN, useValue: 'value 1' },
{
provide: VALUE_2_TOKEN,
useFactory: (value1: string) => `${value1} and value2`,
deps: [VALUE_1_TOKEN],
},
],
});

const value2 = injector.get(VALUE_2_TOKEN);

这个同样可以被 inject 函数替代。

1
2
3
4
5
6
7
providers: [
{ provide: VALUE_1_TOKEN, useValue: 'value 1' },
{
provide: VALUE_2_TOKEN,
useFactory: (value1: string) => `${inject(VALUE_1_TOKEN)} and value2`,
},
],

inject 函数

显然,inject 函数就是用来替代 @Inject 和 Provider.deps 的,所以尽量用 inject 少用 @Inject 和 Provider.deps。

在模板驱动表单中验证输入

为了往模板驱动表单中添加验证机制,你要添加一些验证属性,就像原生的 HTML 表单验证器一样。 Angular 会用指令来匹配这些具有验证功能的指令。

每当表单控件中的值发生变化时,Angular 就会进行验证,并生成一个验证错误的列表(对应着 INVALID 状态)或者 null(对应着 VALID 状态)。

你可以通过把 ngModel 导出成局部模板变量来查看该控件的状态。 比如下面这个例子就把 NgModel 导出成了一个名叫 name 的变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<input type="text" id="name" name="name" class="form-control"
required minlength="4" appForbiddenName="bob"
[(ngModel)]="hero.name" #name="ngModel">

<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="alert">

<div *ngIf="name.errors?.['required']">
Name is required.
</div>
<div *ngIf="name.errors?.['minlength']">
Name must be at least 4 characters long.
</div>
<div *ngIf="name.errors?.['forbiddenName']">
Name cannot be Bob.
</div>

</div>

注意这个例子讲解的如下特性。

  • <input> 元素带有一些 HTML 验证属性:required 和 minlength。它还带有一个自定义的验证器指令 forbiddenName。欲知详情,参阅自定义验证器一节。

  • #name=”ngModel” 把 NgModel 导出成了一个名叫 name 的局部变量。NgModel 把自己控制的 FormControl 实例的属性映射出去,让你能在模板中检查控件的状态,比如 valid 和 dirty。要了解完整的控件属性,参阅 API 参考手册中的AbstractControl。

    • <div> 元素的 *ngIf 展示了一组嵌套的消息 div,但是只在有“name”错误和控制器为 dirty 或者 touched 时才出现。

    • 每个嵌套的 <div> 为其中一个可能出现的验证错误显示一条自定义消息。比如 required、minlength 和 forbiddenName。

为防止验证程序在用户有机会编辑表单之前就显示错误,你应该检查控件的 dirty 状态或 touched 状态。

  • 当用户在被监视的字段中修改该值时,控件就会被标记为 dirty(脏)
  • 当用户的表单控件失去焦点时,该控件就会被标记为 touched(已接触)

在响应式表单中验证输入

在响应式表单中,事实之源是其组件类。不应该通过模板上的属性来添加验证器,而应该在组件类中直接把验证器函数添加到表单控件模型上(FormControl)。然后,一旦控件发生了变化,Angular 就会调用这些函数。

验证器(Validator)函数

验证器函数可以是同步函数,也可以是异步函数。

验证器类型 详细信息
同步验证器 这些同步函数接受一个控件实例,然后返回一组验证错误或 null。可以在实例化一个 FormControl 时把它作为构造函数的第二个参数传进去。
异步验证器 这些异步函数接受一个控件实例并返回一个 Promise 或 Observable,它稍后会发出一组验证错误或 null。在实例化 FormControl 时,可以把它们作为第三个参数传入。

出于性能方面的考虑,只有在所有同步验证器都通过之后,Angular 才会运行异步验证器。当每一个异步验证器都执行完之后,才会设置这些验证错误。

内置验证器函数

在模板驱动表单中用作属性的那些内置验证器,比如 required 和 minlength,也都可以作为 Validators 类中的函数使用。

  • min(min:number) 此验证器要求控件的值大于或等于指定的数字。 它只有函数形式,没有指令形式。
  • max(max: number) 此验证器要求控件的值小于等于指定的数字。 它只有函数形式,没有指令形式。
  • required 此验证器要求控件具有非空值。
  • requiredTrue 此验证器要求控件的值为真。它通常用来验证检查框。
  • email 此验证器要求控件的值能通过 email 格式验证。
  • minLength 此验证器要求控件值的长度大于等于所指定的最小长度。当使用 HTML5 的 minlength 属性时,此验证器也会生效。
  • maxLength 此验证器要求控件值的长度小于等于所指定的最大长度。当使用 HTML5 的 maxlength 属性时,此验证器也会生效。
  • pattern 此验证器要求控件的值匹配某个正则表达式。当使用 HTML5 的 pattern 属性时,它也会生效。
  • nullValidator 此验证器什么也不做。
  • compose 把多个验证器合并成一个函数,它会返回指定控件的各个错误映射表的并集。
  • composeAsync 把多个异步验证器合并成一个函数,它会返回指定控件的各个错误映射表的并集。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ngOnInit(): void {
this.heroForm = new FormGroup({
name: new FormControl(this.hero.name, [
Validators.required,
Validators.minLength(4),
forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
]),
alterEgo: new FormControl(this.hero.alterEgo),
power: new FormControl(this.hero.power, Validators.required)
});

}

get name() { return this.heroForm.get('name'); }

get power() { return this.heroForm.get('power'); }

所有这些验证器都是同步的,所以它们作为第二个参数传递。注意,你可以通过把这些函数放到一个数组中传入来支持多个验证器。

如果你到模板中找到 name 输入框,就会发现它和模板驱动的例子很相似。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<input type="text" id="name" class="form-control"
formControlName="name" required>

<div *ngIf="name.invalid && (name.dirty || name.touched)"
class="alert alert-danger">

<div *ngIf="name.errors?.['required']">
Name is required.
</div>
<div *ngIf="name.errors?.['minlength']">
Name must be at least 4 characters long.
</div>
<div *ngIf="name.errors?.['forbiddenName']">
Name cannot be Bob.
</div>
</div>

这个表单与模板驱动的版本不同,它不再导出任何指令。相反,它使用组件类中定义的 name 读取器(getter)。

自定义验证器

内置的验证器并不是总能精确匹配应用中的用例,因此有时你需要创建一个自定义验证器。

1
2
3
4
5
6
7
/** A hero's name can't match the given regular expression */
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const forbidden = nameRe.test(control.value);
return forbidden ? {forbiddenName: {value: control.value}} : null;
};
}

forbiddenNameValidator 工厂函数返回配置好的验证器函数。 该函数接受一个 Angular control 对象,并在control值有效时返回 null,或无效时返回验证错误信息。 验证错误信息通常有一个名为 验证器名称(forbiddenName)的属性。其值为 K-V形式的字典,你可以插入错误信息。

自定义异步验证器和同步验证器很像,只是它们必须返回一个稍后会输出 null 或“验证错误对象”的承诺(Promise)或可观察对象,如果是可观察对象,那么它必须在某个时间点被完成(complete),那时候这个表单就会使用它输出的最后一个值作为验证结果。

把自定义验证器添加到响应式表单中

1
2
3
4
5
6
7
8
9
this.heroForm = new FormGroup({
name: new FormControl(this.hero.name, [
Validators.required,
Validators.minLength(4),
forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
]),
alterEgo: new FormControl(this.hero.alterEgo),
power: new FormControl(this.hero.power, Validators.required)
});

为模板驱动表单中添加自定义验证器

在模板驱动表单中,要为模板添加一个指令,该指令包含了一个 validator 函数。比如,ForbiddenValidatorDirective 指令中 包含了 forbiddenNameValidator 验证函数。

Angular 在验证过程中会识别出该指令的作用,因为该指令把自己注册成了 NG_VALIDATORS 提供者,如下例所示。NG_VALIDATORS 是一个带有可扩展验证器集合的预定义提供者。

1
2
3
4
5
6
7
8
9
10
11
@Directive({
selector: '[appForbiddenName]',
providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]
})
export class ForbiddenValidatorDirective implements Validator {
@Input('appForbiddenName') forbiddenName = '';

validate(control: AbstractControl): ValidationErrors | null {
return this.forbiddenName ? forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control): null;
}
}

一旦 ForbiddenValidatorDirective 写好了,你只要把 选择器 forbiddenName 添加到输入框上就可以激活这个验证器了。比如:

1
2
3
<input type="text" id="name" name="name" class="form-control"
required minlength="4" appForbiddenName="bob"
[(ngModel)]="hero.name" #name="ngModel">

注意,自定义验证指令是用 useExisting 而不是 useClass 来实例化的。注册的验证程序必须是 ForbiddenValidatorDirective 实例本身 - 表单中的实例,也就是表单中 forbiddenName 属性被绑定到了”bob”的那个。

如果用 useClass 来代替 useExisting,就会注册一个新的类实例,而它是没有 forbiddenName 的。

表示控件状态的 CSS 类

Angular 会自动把很多控件属性作为 CSS 类映射到控件所在的元素上。你可以使用这些类来根据表单状态给表单控件元素添加样式。目前支持下列类:

  • .ng-valid

  • .ng-invalid

  • .ng-pending

  • .ng-pristine

  • .ng-dirty

  • .ng-untouched

  • .ng-touched

  • .ng-submitted (只对 form 元素添加)

在下面的例子中,这个英雄表单使用 .ng-valid 和 .ng-invalid 来设置每个表单控件的边框颜色。

1
2
3
4
5
6
7
.ng-valid[required], .ng-valid.required  {
border-left: 5px solid #42A948; /* green */
}

.ng-invalid:not(form) {
border-left: 5px solid #a94442; /* red */
}

跨字段交叉验证

跨字段交叉验证器是一种自定义验证器,可以对表单中不同字段的值进行比较,并针对它们的组合进行接受或拒绝。

为响应式表单添加交叉验证

该表单具有以下结构:

1
2
3
4
5
const heroForm = new FormGroup({
'name': new FormControl(),
'alterEgo': new FormControl(),
'power': new FormControl()
});

name 和 alterEgo 是兄弟控件。要想在单个自定义验证器中计算这两个控件,你就必须在它们共同的祖先控件中执行验证:FormGroup。

要想给 FormGroup 添加验证器,就要在创建时把一个新的验证器传给它的第二个参数。

1
2
3
4
5
const heroForm = new FormGroup({
'name': new FormControl(),
'alterEgo': new FormControl(),
'power': new FormControl()
}, { validators: identityRevealedValidator });

验证器的代码如下。

1
2
3
4
5
6
7
/** A hero's name can't match the hero's alter ego */
export const identityRevealedValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
const name = control.get('name');
const alterEgo = control.get('alterEgo');

return name && alterEgo && name.value === alterEgo.value ? { identityRevealed: true } : null;
};

该验证器通过调用 FormGroup 的 get 方法来检索这些子控件,然后比较 name 和 alterEgo 控件的值。

为了提供更好的用户体验,当表单无效时,模板还会显示一条恰当的错误信息。

1
2
3
<div *ngIf="heroForm.errors?.['identityRevealed'] && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert alert-danger">
Name cannot match alter ego.
</div>

如果 FormGroup 中有一个由 identityRevealed 验证器返回的交叉验证错误,*ngIf 就会显示错误,但只有当该用户已经与表单进行过交互的时候才显示。

为模板驱动表单添加交叉验证

对于模板驱动表单,你必须创建一个指令来包装验证器函数。你可以使用NG_VALIDATORS 令牌来把该指令提供为验证器,如下例所示。

1
2
3
4
5
6
7
8
9
@Directive({
selector: '[appIdentityRevealed]',
providers: [{ provide: NG_VALIDATORS, useExisting: IdentityRevealedValidatorDirective, multi: true }]
})
export class IdentityRevealedValidatorDirective implements Validator {
validate(control: AbstractControl): ValidationErrors | null {
return identityRevealedValidator(control);
}
}

你必须把这个新指令添加到 HTML 模板中。由于验证器必须注册在表单的最高层,因此下列模板会把该指令放在 form 标签上。

1
<form #heroForm="ngForm" appIdentityRevealed>

为了提供更好的用户体验,当表单无效时,我们要显示一个恰当的错误信息。

1
2
3
<div *ngIf="heroForm.errors?.['identityRevealed'] && (heroForm.touched || heroForm.dirty)" class="cross-validation-error-message alert">
Name cannot match alter ego.
</div>

这在模板驱动表单和响应式表单中都是一样的。

创建异步验证器

异步验证器实现了 AsyncValidatorFn 和 AsyncValidator 接口。它们与其同步版本非常相似,但有以下不同之处。

validate() 函数必须返回一个 Promise 或可观察对象,

返回的可观察对象必须是有尽的,这意味着它必须在某个时刻完成(complete)。要把无尽的可观察对象转换成有尽的,可以在管道中加入过滤操作符,比如 first、last、take 或 takeUntil。

异步验证在同步验证完成后才会发生,并且只有在同步验证成功时才会执行。如果更基本的验证方法已经发现了无效输入,那么这种检查顺序就可以让表单避免使用昂贵的异步验证流程(比如 HTTP 请求)。

异步验证开始之后,表单控件就会进入 pending 状态。可以检查控件的 pending 属性,并用它来给出对验证中的视觉反馈。

一种常见的 UI 模式是在执行异步验证时显示 Spinner(转轮)。下面的例子展示了如何在模板驱动表单中实现这一点。

1
2
<input [(ngModel)]="name" #model="ngModel" appSomeAsyncValidator>
<app-spinner *ngIf="model.pending"></app-spinner>

实现自定义异步验证器

下面的代码创建了一个验证器类 UniqueAlterEgoValidator,它实现了 AsyncValidator 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
@Injectable({ providedIn: 'root' })
export class UniqueAlterEgoValidator implements AsyncValidator {
constructor(private heroesService: HeroesService) {}

validate(
control: AbstractControl
): Observable<ValidationErrors | null> {
return this.heroesService.isAlterEgoTaken(control.value).pipe(
map(isTaken => (isTaken ? { uniqueAlterEgo: true } : null)),
catchError(() => of(null))
);
}
}

构造函数中注入了 HeroesService,它定义了如下接口。

1
2
3
interface HeroesService {
isAlterEgoTaken: (alterEgo: string) => Observable<boolean>;
}

与任何验证器一样,如果表单有效,该方法返回 null,如果无效,则返回 ValidationErrors。这个验证器使用 catchError 操作符来处理任何潜在的错误。在这个例子中,验证器将 isAlterEgoTaken() 错误视为成功的验证,因为未能发出验证请求并不一定意味着这个第二人格无效。你也可以用不同的方式处理这种错误,比如返回 ValidationError 对象。

一段时间过后,这条可观察对象链完成,异步验证也就完成了。pending 标志位也设置为 false,该表单的有效性也已更新。

将异步验证器添加到响应式表单

要以响应式表单使用异步验证器,请首先将验证器注入组件类的构造函数。

1
constructor(private alterEgoValidator: UniqueAlterEgoValidator) {}

然后,将验证器函数直接传递给 FormControl 以应用它。

1
2
3
4
const alterEgoControl = new FormControl('', {
asyncValidators: [this.alterEgoValidator.validate.bind(this.alterEgoValidator)],
updateOn: 'blur'
});

将异步验证器添加到模板驱动表单

要在模板驱动表单中使用异步验证器,请创建一个新指令并在其上注册 NG_ASYNC_VALIDATORS 提供者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Directive({
selector: '[appUniqueAlterEgo]',
providers: [
{
provide: NG_ASYNC_VALIDATORS,
useExisting: forwardRef(() => UniqueAlterEgoValidatorDirective),
multi: true
}
]
})
export class UniqueAlterEgoValidatorDirective implements AsyncValidator {
constructor(private validator: UniqueAlterEgoValidator) {}

validate(
control: AbstractControl
): Observable<ValidationErrors | null> {
return this.validator.validate(control);
}
}

然后,与使用同步验证器一样,将指令的选择器添加到输入以激活它。

1
2
3
4
5
6
7
<input type="text"
id="alterEgo"
name="alterEgo"
#alterEgo="ngModel"
[(ngModel)]="hero.alterEgo"
[ngModelOptions]="{ updateOn: 'blur' }"
appUniqueAlterEgo>

默认情况下,所有验证程序在每次表单值更改后都会运行。对于同步验证器,这通常不会对应用性能产生明显的影响。但是,异步验证器通常会执行某种 HTTP 请求来验证控件。每次按键后调度一次 HTTP 请求都会给后端 API 带来压力,应该尽可能避免。

你可以把 updateOn 属性从 change(默认值)改成 submit 或 blur 来推迟表单验证的更新时机。

使用模板驱动表单时,可以在模板中设置该属性。

1
<input [(ngModel)]="name" [ngModelOptions]="{updateOn: 'blur'}">

使用响应式表单时,可以在 FormControl 实例中设置该属性。

1
new FormControl('', {updateOn: 'blur'});

Angular 提供了两种不同的方法来通过表单处理用户输入:响应式表单和模板驱动表单。 两者都从视图中捕获用户输入事件、验证用户输入、创建表单模型、修改数据模型,并提供跟踪这些更改的途径。

选择一种方法

响应式表单和模板驱动表单以不同的方式处理和管理表单数据。每种方法都有各自的优点。

表单 详情
响应式表单 提供对底层表单对象模型直接、显式的访问。它们与模板驱动表单相比,更加健壮:它们的可扩展性、可复用性和可测试性都更高。如果表单是你的应用程序的关键部分,或者你已经在使用响应式表单来构建应用,那就使用响应式表单。
模板驱动表单 依赖模板中的指令来创建和操作底层的对象模型。它们对于向应用添加一个简单的表单非常有用,比如电子邮件列表注册表单。它们很容易添加到应用中,但在扩展性方面不如响应式表单。如果你有可以只在模板中管理的非常基本的表单需求和逻辑,那么模板驱动表单就很合适。

建立表单模型

响应式表单和模板驱动型表单都会跟踪用户与之交互的表单输入元素和组件模型中的表单数据之间的值变更。这两种方法共享同一套底层构建块,只在如何创建和管理常用表单控件实例方面有所不同。

常用表单基础类

响应式表单和模板驱动表单都建立在下列基础类之上。

基础类 详情
FormControl 追踪单个表单控件的值和验证状态。
FormGroup 追踪一个表单控件组的值和状态。
FormArray 追踪表单控件数组的值和状态。
ControlValueAccessor 在 Angular 的 FormControl 实例和内置 DOM 元素之间创建一个桥梁

建立响应式表单

对于响应式表单,你可以直接在组件类中定义表单模型。[formControl] 指令会通过内部值访问器来把显式创建的 FormControl 实例与视图中的特定表单元素联系起来。

下面的组件使用响应式表单为单个控件实现了一个输入字段。在这个例子中,表单模型是 FormControl 实例。

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

@Component({
selector: 'app-reactive-favorite-color',
template: `
Favorite Color: <input type="text" [formControl]="favoriteColorControl">
`
})
export class FavoriteColorComponent {
favoriteColorControl = new FormControl('');
}

建立模板驱动表单

在模板驱动表单中,表单模型是隐式的,而不是显式的。指令 NgModel 为指定的表单元素创建并管理一个 FormControl 实例。

下面的组件使用模板驱动表单为单个控件实现了同样的输入字段。

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

@Component({
selector: 'app-template-favorite-color',
template: `
Favorite Color: <input type="text" [(ngModel)]="favoriteColor">
`
})
export class FavoriteColorComponent {
favoriteColor = '';
}

响应式表单

响应式表单使用显式的、不可变的方式,管理表单在特定的时间点上的状态。对表单状态的每一次变更都会返回一个新的状态,这样可以在变化时维护模型的整体性。响应式表单是围绕 Observable 流构建的,表单的输入和值都是通过这些输入值组成的流来提供的,它可以同步访问。

添加基础表单控件

下面的例子展示了如何添加一个表单控件。在这个例子中,用户在输入字段中输入自己的名字,捕获其输入值,并显示表单控件的当前值。

动作 详情
注册响应式表单模块 要使用响应式表单控件,就要从 @angular/forms 包中导入 ReactiveFormsModule,并把它添加到你的 NgModule 的 imports 数组中。
1
2
3
4
5
6
7
8
9
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
imports: [
// other imports ...
ReactiveFormsModule
],
})
export class AppModule { }
生成新的 FormControl 可以用 FormControl 的构造函数设置初始值,这个例子中它是空字符串。通过在你的组件类中创建这些控件,你可以直接对表单控件的状态进行监听、修改和校验。
1
2
3
4
5
6
7
8
9
10
11
import { Component } from '@angular/core';
import { FormControl } from '@angular/forms';

@Component({
selector: 'app-name-editor',
templateUrl: './name-editor.component.html',
styleUrls: ['./name-editor.component.css']
})
export class NameEditorComponent {
name = new FormControl('');
}
在模板中注册该控件 在组件类中创建了控件之后,你还要把它和模板中的一个表单控件关联起来。修改模板,为表单控件添加 formControl 绑定,formControl 是由 ReactiveFormsModule 中的 FormControlDirective 提供的。
1
2
<label for="name">Name: </label>
<input id="name" type="text" [formControl]="name">

使用这种模板绑定语法,把该表单控件注册给了模板中名为 name 的输入元素。这样,表单控件和 DOM 元素就可以互相通讯了:视图会反映模型的变化,模型也会反映视图中的变化。

显示该组件 把该组件添加到模板时,将显示指派给 name 的表单控件。
1
<app-name-editor></app-name-editor>

显示表单控件的值

你可以用下列方式显示它的值。

  • 通过可观察对象 valueChanges,你可以在模板中使用 AsyncPipe 或在组件类中使用 subscribe() 方法来监听表单值的变化。
  • 使用 value 属性。它能让你获得当前值的一份快照。

一旦你修改了表单控件所关联的元素,这里显示的值也跟着变化了。

替换表单控件的值

FormControl实例提供了一个setValue()方法,它会修改这个表单控件的值,并且验证与控件结构相对应的值的结构。比如,当从后端 API 或服务接收到了表单数据时,可以通过setValue()方法来把原来的值替换为新的值。

下列的例子往组件类中添加了一个方法,它使用setValue() 方法来将控件的值修改为 Nancy。

1
2
3
updateName() {
this.name.setValue('Nancy');
}

在这个例子中,你只使用单个控件,但是当调用 FormGroup 或 FormArray 实例的 setValue() 方法时,传入的值就必须匹配控件组或控件数组的结构才行。

把表单控件分组

就像 FormControl 的实例能让你控制单个输入框所对应的控件一样,FormGroup 的实例也能跟踪一组 FormControl 实例(比如一个表单)的表单状态。当创建 FormGroup 时,其中的每个控件都会根据其名字进行跟踪。下面的例子展示了如何管理单个控件组中的多个 FormControl 实例。

生成一个 ProfileEditor 组件并从 @angular/forms 包中导入 FormGroup 和 FormControl 类。

import { FormGroup, FormControl } from '@angular/forms';

  1. 创建一个 FormGroup 实例。

    在组件类中创建一个名叫 profileForm 的属性,并设置为 FormGroup 的一个新实例。要初始化这个 FormGroup,请为构造函数提供一个由控件组成的对象,对象中的每个名字都要和表单控件的名字一一对应。对此个人档案表单,要添加两个 FormControl 实例,名字分别为 firstName 和 lastName。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import { Component } from '@angular/core';
    import { FormGroup, FormControl } from '@angular/forms';

    @Component({
    selector: 'app-profile-editor',
    templateUrl: './profile-editor.component.html',
    styleUrls: ['./profile-editor.component.css']
    })
    export class ProfileEditorComponent {
    profileForm = new FormGroup({
    firstName: new FormControl(''),
    lastName: new FormControl(''),
    address: new FormGroup({
    street: new FormControl(''),
    city: new FormControl(''),
    state: new FormControl(''),
    zip: new FormControl('')
    })
    });
    }

    这些独立的表单控件被收集到了一个控件组中。这个 FormGroup 用对象的形式提供了它的模型值,这个值来自组中每个控件的值。FormGroup 实例拥有和 FormControl 实例相同的属性(比如 value、untouched)和方法(比如 setValue())。

  2. 把这个 FormGroup 模型关联到视图。
    这个表单组还能跟踪其中每个控件的状态及其变化,所以如果其中的某个控件的状态或值变化了,父控件也会发出一次新的状态变更或值变更事件。该控件组的模型来自它的所有成员。在定义了这个模型之后,你必须更新模板,来把该模型反映到视图中。

    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
    <form [formGroup]="profileForm">

    <label for="first-name">First Name: </label>
    <input id="first-name" type="text" formControlName="firstName">

    <label for="last-name">Last Name: </label>

    <input id="last-name" type="text" formControlName="lastName">

    <div formGroupName="address">
    <h2>Address</h2>

    <label for="street">Street: </label>
    <input id="street" type="text" formControlName="street">

    <label for="city">City: </label>
    <input id="city" type="text" formControlName="city">

    <label for="state">State: </label>
    <input id="state" type="text" formControlName="state">

    <label for="zip">Zip Code: </label>
    <input id="zip" type="text" formControlName="zip">
    </div>
    </form>

    就像 FormGroup 所包含的那控件一样,profileForm 这个 FormGroup 也通过 FormGroup 指令绑定到了 form 元素,在该模型和表单中的输入框之间创建了一个通讯层。

    由 FormControlName 指令提供的 formControlName 属性把每个输入框和 FormGroup 中定义的表单控件绑定起来。这些表单控件会和相应的元素通讯,它们还把更改传给 FormGroup,这个 FormGroup 是模型值的事实之源。

  3. 保存表单数据
    ProfileEditor 组件从用户那里获得输入,但在真实的场景中,你可能想要先捕获表单的值,等将来在组件外部进行处理。FormGroup 指令会监听 form 元素发出的 submit 事件,并发出一个 ngSubmit 事件,让你可以绑定一个回调函数。把 onSubmit() 回调方法添加为 form 标签上的 ngSubmit 事件监听器。

    1
    <form [formGroup]="profileForm" (ngSubmit)="onSubmit()">

    ProfileEditor 组件上的 onSubmit() 方法会捕获 profileForm 的当前值。要保持该表单的封装性,就要使用 EventEmitter 向组件外部提供该表单的值。下面的例子会使用 console.warn 把这个值记录到浏览器的控制台中。

    1
    2
    3
    4
    onSubmit() {
    // TODO: Use EventEmitter with form value
    console.warn(this.profileForm.value);
    }
  4. 显示此组件

    要显示包含此表单的 ProfileEditor 组件,请把它添加到组件模板中。

    1
    <app-profile-editor></app-profile-editor>

    ProfileEditor 让你能管理 FormGroup 中的 firstName 和 lastName 等 FormControl 实例。

更新部分数据模型

当修改包含多个 FormGroup 实例的值时,你可能只希望更新模型中的一部分,而不是完全替换掉。
有两种更新模型值的方式:

方法 详情
setValue() 使用 setValue() 方法来为单个控件设置新值。setValue() 方法会严格遵循表单组的结构,并整体性替换控件的值。
patchValue() 用此对象中定义的任意属性对表单模型进行替换。

setValue() 方法的严格检查可以帮助你捕获复杂表单嵌套中的错误,而 patchValue() 在遇到那些错误时可能会默默的失败。

1
2
3
4
5
6
7
8
updateProfile() {
this.profileForm.patchValue({
firstName: 'Nancy',
address: {
street: '123 Drew Street'
}
});
}

profileForm 模型中只有 firstName 和 street 被修改了。注意,street 是在 address 属性的对象中被修改的。这种结构是必须的,因为 patchValue() 方法要针对模型的结构进行更新。patchValue() 只会更新表单模型中所定义的那些属性。

使用 FormBuilder 服务生成控件

手动创建多个表单控件实例会非常繁琐。FormBuilder 服务提供了一些便捷方法来生成表单控件。FormBuilder 在幕后也使用同样的方式来创建和返回这些实例,只是用起来更简单。
通过下列步骤可以利用这项服务。

  1. 导入 FormBuilder 类。

    从 @angular/forms 包中导入 FormBuilder 类。

    1
    import { FormBuilder } from '@angular/forms';
  2. 注入这个 FormBuilder 服务。

    FormBuilder 是一个可注入的服务提供者,它是由 ReactiveFormModule 提供的。只要把它添加到组件的构造函数中就可以注入这个依赖。
    constructor(private fb: FormBuilder) { }

  3. 生成表单内容。

    FormBuilder 服务有三个方法:control()、group() 和 array()。这些方法都是工厂方法,用于在组件类中分别生成 FormControl、FormGroup 和 FormArray。

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

    @Component({
    selector: 'app-profile-editor',
    templateUrl: './profile-editor.component.html',
    styleUrls: ['./profile-editor.component.css']
    })
    export class ProfileEditorComponent {
    profileForm = this.fb.group({
    firstName: [''],
    lastName: [''],
    address: this.fb.group({
    street: [''],
    city: [''],
    state: [''],
    zip: ['']
    }),
    });

    constructor(private fb: FormBuilder) { }
    }

    在上面的例子中,你可以使用 group() 方法,用和前面一样的名字来定义这些属性。这里,每个控件名对应的值都是一个数组,这个数组中的第一项是其初始值。

响应式表单 API 汇总

下表给出了用于创建和管理响应式表单控件的基础类和服务。要了解完整的语法,请参阅 API 文档中的 Forms 包。

详情
AbstractControl 所有三种表单控件类(FormControl、FormGroup 和 FormArray)的抽象基类。它提供了一些公共的行为和属性。
FormControl 管理单体表单控件的值和有效性状态。它对应于 HTML 的表单控件,比如
FormGroup 管理一组 AbstractControl 实例的值和有效性状态。该组的属性中包括了它的子控件。组件中的顶层表单就是 FormGroup。
FormArray 管理一些 AbstractControl 实例数组的值和有效性状态。
FormBuilder 一个可注入的服务,提供一些用于提供创建控件实例的工厂方法。
FormRecord 跟踪 FormControl 实例集合的值和有效性状态,每个实例都具有相同的值类型。

指令

指令 详情
FormControlDirective 把一个独立的 FormControl 实例绑定到表单控件元素。
FormControlName 把一个现有 FormGroup 中的 FormControl 实例根据名字绑定到表单控件元素。
FormGroupDirective 把一个现有的 FormGroup 实例绑定到 DOM 元素。
FormGroupName 把一个内嵌的 FormGroup 实例绑定到一个 DOM 元素。
FormArrayName 把一个内嵌的 FormArray 实例绑定到一个 DOM 元素。

类型化表单

自动无类型表单迁移

升级到 Angular 14 时,包含的迁移将自动使用相应的无类型版本替换代码中的所有表单类。例如,上面的代码段将变为:

1
2
3
4
const login = new UntypedFormGroup({
email: new UntypedFormControl(''),
password: new UntypedFormControl(''),
});

每个 Untyped 符号都与以前的 Angular 版本具有完全相同的语义,因此你的应用程序应该像以前一样继续编译。通过删除 Untyped 前缀,你可以增量启用这些类型。

FormControl :入门

1
const email = new FormControl('angularrox@gmail.com');

此控件将被自动推断为 FormControl<string|null> 类型。TypeScript 会在整个FormControl API中自动强制执行此类型,例如 email.value 、 email.valueChanges 、 email.setValue(…) 等。

可空性

你可能想知道:为什么此控件的类型包含 null ?这是因为控件可以随时通过调用 reset 变为 null

1
2
3
const email = new FormControl('angularrox@gmail.com');
email.reset();
console.log(email.value); // null

TypeScript 将强制你始终处理控件已变为 null 的可能性。如果要使此控件不可为空,可以用 nonNullable 选项。这将导致控件重置为其初始值,而不是 null :

1
2
3
const email = new FormControl('angularrox@gmail.com', {nonNullable: true});
email.reset();
console.log(email.value); // angularrox@gmail.com

指定显式类型

可以指定类型,而不是依赖推理。考虑一个初始化为 null 的控件。因为初始值为 null,所以 TypeScript 将推断 FormControl,这比我们想要的要窄。

1
2
const email = new FormControl(null);
email.setValue('angularrox@gmail.com'); // Error!

为防止这种情况,我们将类型显式指定为 string|null

1
2
const email = new FormControl<string|null>(null);
email.setValue('angularrox@gmail.com');

FormArray :动态的、同质的集合

FormArray 包含一个开放式控件列表。type 参数对应于每个内部控件的类型:

1
2
const names = new FormArray([new FormControl('Alex')]);
names.push(new FormControl('Jess'));

此 FormArray 将具有内部控件类型 FormControl<string|null>。

如果你想在数组中有多个不同的元素类型,则必须使用 UntypedFormArray,因为 TypeScript 无法推断哪种元素类型将出现在哪个位置。

FormGroup 和 FormRecord

Angular 为具有枚举键集的表单提供了 FormGroup 类型,并为开放式或动态组提供了一种名为 FormRecord 的类型。

1
2
3
4
const login = new FormGroup({
email: new FormControl('', {nonNullable: true}),
password: new FormControl('', {nonNullable: true}),
});

在任何 FormGroup 上,都可以禁用控件。任何禁用的控件都不会出现在组的值中。

因此,login.value 的类型是 Partial<{email: string, password: string}>。这种类型的 Partial 意味着每个成员可能是未定义的。

更具体地说,login.value.email 的类型是 string|undefined,TypeScript 将强制你处理可能 undefined 的值(如果你启用了 strictNullChecks)。

如果你想访问包括禁用控件的值,从而绕过可能的 undefined 字段,可以用 login.getRawValue()。

FormBuilder 和 NonNullableFormBuilder

FormBuilder 类已升级为支持新类型,方式与上面的示例相同。

此外,还有一个额外的构建器:NonNullableFormBuilder。它是在所有控件都上指定 {nonNullable: true} 的简写,用来在大型非空表单中消除主要的样板代码。你可以用 FormBuilder 上的 nonNullable 属性访问它:

1
2
3
4
5
const fb = new FormBuilder();
const login = fb.nonNullable.group({
email: '',
password: '',
});

在上面的示例中,两个内部控件都将不可为空

表单常用 元素

NgForm

类型:DIRECTIVE

选择器:
form:not([ngNoForm]):not([formGroup])
ng-form
[ngForm]

属性:
submitted: boolean 返回是否已触发表单提交。

form: FormGroup 为此表单创建的 FormGroup

@Output()ngSubmit: EventEmitter “ngSubmit” 的事件发射器

@Input(‘ngFormOptions’) options NgForm 实例的选项。接受下列属性:updateOn:为所有子级的 NgModel 设置 updateOn 的默认值(除非子 NgModel 通过 ngModelOptions 显式指定了这个值)。可能的值有:’change’ | ‘blur’ | ‘submit’.

formDirective: Form 指令实例。

control: FormGroup 内部 FormGroup 实例。

path: string[] 返回表示该组路径的数组。由于此指令始终位于调用表单的顶层,因此它始终是一个空数组。

controls: {[key: string]: AbstractControl;} 返回此组中控件的映射表。

#myTemplateVar=”ngForm”

说明

创建一个顶级的 FormGroup 实例,并把它绑定到一个表单,以跟踪表单的聚合值及其验证状态。

只要你导入了 FormsModule,该指令就会默认在所有

标签上生效。你不需要再添加任何特殊的选择器。

你可以以 ngForm 作为 key 把该指令导出到一个局部模板变量(如 #myForm=”ngForm”)。这是可选的,但很有用。来自本指令背后的 FormGroup 实例的很多属性,都被复制到了指令自身,所以拿到一个对该指令的引用就可以让你访问此表单的聚合值和验证状态, 还有那些用户交互类的属性,比如 dirty 和 touched。

要使用该表单注册的子控件,请使用带有 name 属性的 NgModel。你可以使用 NgModelGroup 在表单中创建子组。

如果需要,还可以监听该指令的 ngSubmit 事件,以便当用户触发了一次表单提交时得到通知。发出 ngSubmit 事件时,会携带原始的 DOM 表单提交事件。

在模板驱动表单中,所有

标签都会自动应用上 NgForm 指令。 如果你只想导入 FormsModule 而不想把它应用于某些表单中,比如,要想使用 HTML5 验证,你可以添加 ngNoForm 属性,这样标签就不会在 上创建 NgForm 指令了。在响应式表单中,则不需要用 ngNoForm,因为 NgForm 指令不会自动应用到 标签上,你只要别主动添加 formGroup 指令就可以了。

NgModelGroup

类型:DIRECTIVE

选择器:[ngModelGroup]

属性: @Input(‘ngModelGroup’) name: string 跟踪绑定到指令 NgModelGroup 的名称。该名称对应于父 NgForm 中的键名。

模板变量参考手册: #myTemplateVar=”ngModelGroup”

说明

创建 FormGroup 的实例并将其绑定到 DOM 元素。

此指令只能用作 NgForm 的子级(在

标记内)。

使用此指令可以独立于表单的其余部分来验证表单的子组,或者当把领域模型中的某些值和嵌套对象一起使用更有意义时。

在表单组中使用控件

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
@Component({
selector: 'example-app',
template: `
<form #f="ngForm" (ngSubmit)="onSubmit(f)">
<p *ngIf="nameCtrl.invalid">Name is invalid.</p>

<div ngModelGroup="name" #nameCtrl="ngModelGroup">
<input name="first" [ngModel]="name.first" minlength="2">
<input name="middle" [ngModel]="name.middle" maxlength="2">
<input name="last" [ngModel]="name.last" required>
</div>

<input name="email" ngModel>
<button>Submit</button>
</form>

<button (click)="setValue()">Set value</button>
`,
})
export class NgModelGroupComp {
name = {first: 'Nancy', middle: 'J', last: 'Drew'};

onSubmit(f: NgForm) {
console.log(f.value); // {name: {first: 'Nancy', middle: 'J', last: 'Drew'}, email: ''}
console.log(f.valid); // true
}

setValue() {
this.name = {first: 'Bess', middle: 'S', last: 'Marvin'};
}
}

NgModel

类型:DIRECTIVE

选择器:
[ngModel]:not([formControlName]):not([formControl])

属性:
@Input(‘ngModelOptions’) options: {
name?: string;
standalone?: boolean;
updateOn?: FormHooks;
}

跟踪该 ngModel 实例的配置项。name:用来设置表单控件元素的 name 属性的另一种方式。参见把 ngModel 用作独立控件的那个例子。

standalone:如果为 true,则此 ngModel 不会把自己注册进它的父表单中,其行为就像没在表单中一样。默认为 false。

updateOn: 用来定义该何时更新表单控件的值和有效性。默认为 ‘change’。可能的取值为:’change’ | ‘blur’ | ‘submit’。

模板变量参考手册: #myTemplateVar=”ngModel”

说明

根据领域对象创建一个 FormControl 实例,并把它绑定到一个表单控件元素上。

当在

标签中使用 ngModel 时,你还需要提供一个 name 属性,以便该控件可以使用这个名字把自己注册到父表单中。

如果你只是要为表单设置初始值,对 ngModel 使用单向绑定就够了。在提交时,你可以使用从表单导出的值,而不必使用领域模型的值。

FormControl

类型:CLASS

说明

追踪单个表单控件的值和验证状态。

这是 Angular 表单的四个基本构建块之一,与 FormGroup、FormArray 和 FormRecord。它扩展了 AbstractControl 类,该类实现了用于访问值、验证状态、用户交互和事件的大多数基础特性。

FormControl 接受一个通用参数,该参数描述其值的类型。此参数始终隐式包含 null,因为控件可以重置。要更改此行为,请设置 nonNullable 或查看下面的使用说明。

FormGroup

类型:CLASS

说明

跟踪一组 FormControl 实例的值和有效状态。

FormGroup 是用于在 Angular 中定义表单的四个基本构建块之一,与 FormControl、FormArray 和 FormRecord。

当实例化 FormGroup 时,请传入子控件的集合作为第一个参数。每个子项的键都会注册控件的名称。

FormGroup 适用于提前知道密钥的用例。如果你需要动态添加和删除控件,请改用 FormRecord。

FormGroup 接受一个可选的类型参数 TControl,它是一种以内部控件类型作为值的对象类型。

FormRecord

类型:CLASS

说明

跟踪 FormControl 实例集合的值和有效性状态,每个实例都具有相同的值类型。

FormRecord 与 FormGroup 非常相似,除了它可以与动态键一起使用,并根据需要添加和删除控件。

FormRecord 接受一个通用参数,该参数描述了它包含的控件的类型。

使用说明

1
2
3
let numbers = new FormRecord({bill: new FormControl('415-123-456')});
numbers.addControl('bob', new FormControl('415-234-567'));
numbers.removeControl('bill');

一个非常简单的例子如下:如何在事先不知道key的情况下,动态地将控件添加到现有的表单中?

对 FormGroup 类进行严格的类型化,这种工作可能会很复杂。

Angular 添加了一个新的 API 来解决这个问题,FormRecord

1
2
3
4
5
6
7
8
9
10

const languages = new FormRecord({
french: new FormControl(false, { nonNullable: true }),
english: new FormControl(false, { nonNullable: true })
});

languages.addControl('italian', new FormControl(0, { nonNullable: true }); // error

languages.addControl('italian', new FormControl(false, { nonNullable: true }); // no error

FormRecord 类允许您动态添加其值必须具有相同类型的控件。

FormArray

类型:CLASS

说明

跟踪 FormControl、FormGroup 或 FormArray 实例的数组的值和有效状态。

FormArray 是 FormGroup 之外的另一个选择,用于管理任意数量的匿名控件。像 FormGroup 实例一样,你也可以往 FormArray 中动态插入和移除控件,并且 FormArray 实例的值和验证状态也是根据它的子控件计算得来的。不过,你不需要为每个控件定义一个名字作为 key,因此,如果你事先不知道子控件的数量,这就是一个很好的选择。

使用说明

  1. 定义 FormArray 控件:
    为 profileForm 添加一个 aliases 属性,把它定义为 FormArray 类型。
    使用 FormBuilder.array() 方法来定义该数组,并用 FormBuilder.control() 方法来往该数组中添加一个初始控件。
1
2
3
4
5
6
7
8
9
10
11
12
13
profileForm = this.fb.group({
firstName: ['', Validators.required],
lastName: [''],
address: this.fb.group({
street: [''],
city: [''],
state: [''],
zip: ['']
}),
aliases: this.fb.array([
this.fb.control('')
])
});
  1. 访问 FormArray 控件
    使用 getter 语法创建类属性 aliases,以从父表单组中接收表示绰号的表单数组控件。
1
2
3
get aliases() {
return this.profileForm.get('aliases') as FormArray;
}

因为返回的控件的类型是 AbstractControl,所以你要为该方法提供一个显式的类型声明来访问 FormArray 特有的语法。

  1. 在模板中显示表单数组
    要想为表单模型添加 aliases,你必须把它加入到模板中供用户输入。和 FormGroupNameDirective 提供的 formGroupName 一样,FormArrayNameDirective 也使用 formArrayName 在这个 FormArray 实例和模板之间建立绑定。
    在 formGroupName
    元素的结束标签下方,添加一段模板 HTML。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <div formArrayName="aliases">
    <h2>Aliases</h2>
    <button type="button" (click)="addAlias()">+ Add another alias</button>

    <div *ngFor="let alias of aliases.controls; let i=index">
    <!-- The repeated alias template -->
    <label for="alias-{{ i }}">Alias:</label>
    <input id="alias-{{ i }}" type="text" [formControlName]="i">
    </div>
    </div>

*ngFor 指令对 aliases FormArray 提供的每个 FormControl 进行迭代。因为 FormArray 中的元素是匿名的,所以你要把索引号赋值给 i 变量,并且把它传给每个控件的 formControlName 输入属性。

每当新的 alias 加进来时,FormArray 的实例就会基于这个索引号提供它的控件。这将允许你在每次计算根控件的状态和值时跟踪每个控件。

标准查询运算符是组成 LINQ 模式的方法。 这些方法中的大多数都作用于序列;其中序列指其类型实现 IEnumerable 接口或 IQueryable 接口的对象。 标准查询运算符提供包括筛选、投影、聚合、排序等在内的查询功能。

共有两组 LINQ 标准查询运算符,一组作用于类型 IEnumerable 的对象,另一组作用于类型 IQueryable 的对象。

构成每个集合的方法分别是 Enumerable 和 Queryable 类的静态成员。 这些方法被定义为作为方法运行目标的类型的扩展方法。

各个标准查询运算符在执行时间上有所不同,具体情况取决于它们是返回单一值还是值序列。 返回单一实例值的这些方法(例如 Average 和 Sum)立即执行。 返回序列的方法会延迟查询执行,并返回一个可枚举的对象。

对于在内存中集合上运行的方法(即扩展 IEnumerable 的那些方法),返回的可枚举对象将捕获传递到方法的参数。 在枚举该对象时,将使用查询运算符的逻辑,并返回查询结果。

相反,扩展 IQueryable 的方法不会实现任何查询行为。 它们生成一个表示要执行的查询的表达式树。 源 IQueryable 对象执行查询处理。

查询语法和方法语法在语义上是相同的,但是许多人发现查询语法更简单且更易于阅读。 某些查询必须表示为方法调用。

下面的示例演示一个简单查询表达式以及编写为基于方法的查询的语义上等效的查询。

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
class QueryVMethodSyntax
{
static void Main()
{
int[] numbers = { 5, 10, 8, 3, 6, 12};

//Query syntax:
IEnumerable<int> numQuery1 =
from num in numbers
where num % 2 == 0
orderby num
select num;

//Method syntax:
IEnumerable<int> numQuery2 = numbers.Where(num => num % 2 == 0).OrderBy(n => n);

foreach (int i in numQuery1)
{
Console.Write(i + " ");
}
Console.WriteLine(System.Environment.NewLine);
foreach (int i in numQuery2)
{
Console.Write(i + " ");
}

// Keep the console open in debug mode.
Console.WriteLine(System.Environment.NewLine);
Console.WriteLine("Press any key to exit");
Console.ReadKey();
}
}
/*
Output:
6 8 10 12
6 8 10 12
*/

这两个示例的输出是相同的。 可以看到查询变量的类型在两种形式中是相同的:IEnumerable

标准查询运算符的查询表达式语法

方法 查询表达式
Cast 使用显式类型化范围变量,例如:from int i in numbers
GroupBy group … by - 或 - group … by … into
GroupJoin<TOuter,TInner,TKey,TResult>(IEnumerable, IEnumerable, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,IEnumerable, TResult>) join … in … on … equals … into …
Join<TOuter,TInner,TKey,TResult>(IEnumerable, IEnumerable, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,TInner,TResult>) join … in … on … equals …
OrderBy<TSource,TKey>(IEnumerable, Func<TSource,TKey>) orderby
OrderByDescending<TSource,TKey>(IEnumerable, Func<TSource,TKey>) orderby … descending
Select select
SelectMany 多个 from 子句
ThenBy<TSource,TKey>(IOrderedEnumerable, Func<TSource,TKey>) orderby …, …
ThenByDescending<TSource,TKey>(IOrderedEnumerable, Func<TSource,TKey>) orderby …, … descending
Where where

标准查询运算符按执行方式的分类

标准查询运算符方法的 LINQ to Objects 实现主要通过两种方法之一执行:立即执行和延迟执行。 使用延迟执行的查询运算符可以进一步分为两种类别:流式处理和非流式处理。

即时

立即执行指的是读取数据源并执行一次运算。 返回标量结果的所有标准查询运算符都立即执行。

可以使用 Enumerable.ToList 或 Enumerable.ToArray 方法强制查询立即执行。

立即执行可重用查询结果,而不是查询声明。 结果被检索一次,然后存储以供将来使用。

推迟

延迟执行指的是不在代码中声明查询的位置执行运算。 仅当对查询变量进行枚举时才执行运算,例如通过使用 foreach 语句执行。

这意味着,查询的执行结果取决于执行查询而非定义查询时的数据源内容。

如果多次枚举查询变量,则每次结果可能都不同。

几乎所有返回类型为 IEnumerable 或 IOrderedEnumerable 的标准查询运算符皆以延迟方式执行。

延迟执行提供了查询重用功能,因为在每次循环访问查询结果时,查询都会从数据源中提取更新的数据。

流式处理

流式处理运算符不需要在生成元素前读取所有源数据。 在执行时,流式处理运算符一边读取每个源元素,一边对该源元素执行运算,并在可行时生成元素。

流式处理运算符将持续读取源元素直到可以生成结果元素。 这意味着可能要读取多个源元素才能生成一个结果元素。

非流式处理

非流式处理运算符必须先读取所有源数据,然后才能生成结果元素。

排序或分组等运算均属于此类别。 在执行时,非流式处理查询运算符将读取所有源数据,将其放入数据结构,执行运算,然后生成结果元素。

分类表

标准查询运算符 返回类型 立即执行 延迟的流式处理执行 延迟非流式处理执行
Aggregate TSource X
All Boolen X
Any Boolen X
AsEnumerable IEnumerable<T> X
Average 单个数值 X
Cast IEnumerable<T> X
Concat IEnumerable<T> X
Contains Boolen X
Count Int32 X
DefaultIfEmpty IEnumerable<T> X
Distinct IEnumerable<T> X
ElementAt TSource X
ElementAtOrDefault TSource X
Empty TSource X
Except TSource X X
First TSource X
FirstOrDefault TSource X
GroupBy TSource X
GroupJoin TSource X X

Aggregate

1
public static TResult Aggregate<TSource,TAccumulate,TResult> (this System.Collections.Generic.IEnumerable<TSource> source, TAccumulate seed, Func<TAccumulate,TSource,TAccumulate> func, Func<TAccumulate,TResult> resultSelector);

对序列应用累加器函数。 将指定的种子值用作累加器的初始值,并使用指定的函数选择结果值。

1
public static TAccumulate Aggregate<TSource,TAccumulate> (this System.Collections.Generic.IEnumerable<TSource> source, TAccumulate seed, Func<TAccumulate,TSource,TAccumulate> func);

对序列应用累加器函数。 将指定的种子值用作累加器初始值。

1
public static TSource Aggregate<TSource> (this System.Collections.Generic.IEnumerable<TSource> source, Func<TSource,TSource,TSource> func);

对序列应用累加器函数。

ALL

1
public static bool All<TSource> (this System.Collections.Generic.IEnumerable<TSource> source, Func<TSource,bool> predicate);

确定序列中的所有元素是否都满足条件。如果源序列中的每个元素都通过指定谓词中的测试,或者序列为空,则为 true;否则为 false。

Any

1
public static bool Any<TSource> (this System.Collections.Generic.IEnumerable<TSource> source);

确定序列是否包含任何元素。
此方法不返回集合中的任何一个元素, 而是确定集合是否包含任何元素。

一旦可以确定结果,就会停止枚举 source 。

1
public static bool Any<TSource> (this System.Collections.Generic.IEnumerable<TSource> source, Func<TSource,bool> predicate);

确定序列中是否存在任意一个元素满足条件。

如果源序列不为空,并且至少有一个元素通过指定谓词中的测试,则为 true;否则为 false。

AsEnumerable

1
public static System.Collections.Generic.IEnumerable<TSource> AsEnumerable<TSource> (this System.Collections.Generic.IEnumerable<TSource> source);

返回类型化为 IEnumerable<T> 的输入。

Average

1
public static float Average (this System.Collections.Generic.IEnumerable<float> source);

计算 Single 值序列的平均值。

1
public static double? Average<TSource> (this System.Collections.Generic.IEnumerable<TSource> source, Func<TSource,int?> selector);

计算可以为 null 的 Int32 值序列的平均值,这些值可通过对输入序列的每个元素调用转换函数获得。

如果源序列为空或仅包含为 null 的值,则为null;否则为值序列的平均值。

1
2
3
4
5
6
7
8
9
string[] fruits = { "apple", "banana", "mango", "orange", "passionfruit", "grape" };

double average = fruits.Average(s => s.Length);

Console.WriteLine("The average string length is {0}.", average);

// This code produces the following output:
//
// The average string length is 6.5.

Cast

1
2
public static System.Collections.Generic.IEnumerable<TResult> Cast<TResult> (this System.Collections.IEnumerable source);

将 IEnumerable 的元素强制转换为指定的类型。

Concat

连接两个序列。

ElementAtOrDefault

返回序列中指定索引处的元素;如果索引超出范围,则返回默认值。

Empty

返回具有指定类型参数的空 IEnumerable<T>

Except

生成两个序列的差集。

First

返回序列中的第一个元素。

FirstOrDefault

返回序列中的第一个元素;如果未找到该元素,则返回默认值。

GroupBy

1
public static System.Collections.Generic.IEnumerable<TResult> GroupBy<TSource,TKey,TElement,TResult> (this System.Collections.Generic.IEnumerable<TSource> source, Func<TSource,TKey> keySelector, Func<TSource,TElement> elementSelector, Func<TKey,System.Collections.Generic.IEnumerable<TElement>,TResult> resultSelector);

根据指定的键选择器函数对序列中的元素进行分组,并且从每个组及其键中创建结果值。 通过使用指定的函数对每个组的元素进行投影。

1
GroupBy<TSource,TKey,TElement,TResult>(IEnumerable<TSource>, Func<TSource, TKey>, Func<TSource,TElement>, Func<TKey,IEnumerable<TElement>, TResult>, IEqualityComparer<TKey>)

GroupJoin

1
GroupJoin<TOuter,TInner,TKey,TResult>(IEnumerable<TOuter>, IEnumerable<TInner>, Func<TOuter,TKey>, Func<TInner,TKey>, Func<TOuter,IEnumerable<TInner>, TResult>, IEqualityComparer<TKey>)

基于键值等同性对两个序列的元素进行关联,并对结果进行分组。使用指定的 IEqualityComparer<T> 对键进行比较。