Angular9 高级教程(五)
十三、使用内置指令
在这一章中,我描述了内置指令,这些指令负责创建 web 应用的一些最常见的必需功能:有选择地包含内容,在不同的内容片段之间进行选择,以及重复内容。我还描述了 Angular 对用于单向数据绑定的表达式和提供这些表达式的指令的一些限制。表 13-1 将内置模板指令放在上下文中。
表 13-1。
将内置指令放在上下文中
|问题
|
回答
|
| --- | --- |
| 它们是什么? | 本章中描述的内置指令负责选择性地包含内容、在内容片段之间进行选择,以及为数组中的每个项目重复内容。还有设置元素样式和类成员的指令,如第十三章所述。 |
| 它们为什么有用? | 使用这些指令可以执行的任务是 web 应用开发中最常见和最基本的任务,它们为根据应用中的数据调整显示给用户的内容提供了基础。 |
| 它们是如何使用的? | 这些指令应用于模板中的 HTML 元素。这一章(以及本书的其余部分)都有例子。 |
| 有什么陷阱或限制吗? | 使用内置模板指令的语法要求您记住,其中一些指令(包括ngIf和ngFor)必须以星号为前缀,而其他指令(包括ngClass、ngStyle和ngSwitch)必须用方括号括起来。我在“理解微模板指令”边栏中解释了为什么这是必需的,但是这很容易忘记并得到意想不到的结果。 |
| 有其他选择吗? | 你可以编写你自己的定制指令——这个过程我在第 15 和 16 章中描述过——但是内置指令写得很好并且经过了全面的测试。对于大多数应用,最好使用内置指令,除非它们不能准确提供所需的功能。 |
表 13-2 总结了本章内容。
表 13-2。
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 基于数据绑定表达式有条件地显示内容 | 使用ngIf指令 | 1–3 |
| 基于数据绑定表达式的值在不同内容之间进行选择 | 使用ngSwitch指令 | 4, 5 |
| 为由数据绑定表达式产生的每个对象生成一段内容 | 使用ngFor指令 | 6–12 |
| 重复内容块 | 使用ngTemplateOutlet指令 | 13–14 |
| 防止模板错误 | 避免将修改应用状态作为数据绑定表达式的副作用 | 15–19 |
| 避免上下文错误 | 确保数据绑定表达式仅使用模板组件提供的属性和方法 | 20–22 |
准备示例项目
本章依赖于在第十一章中创建并在第十二章中修改的example项目。为了准备本章的主题,清单 13-1 显示了对组件类的更改,删除了不再需要的特性,并添加了新的方法和属性。
Tip
你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
constructor(ref: ApplicationRef) {
(<any>window).appRef = ref;
(<any>window).model = this.model;
}
getProductByPosition(position: number): Product {
return this.model.getProducts()[position];
}
getProduct(key: number): Product {
return this.model.getProduct(key);
}
getProducts(): Product[] {
return this.model.getProducts();
}
getProductCount(): number {
return this.getProducts().length;
}
targetName: string = "Kayak";
}
Listing 13-1.Changes in the component.ts File in the src/app Folder
清单 13-2 显示了模板文件的内容,它通过调用组件的新getProductCount方法显示了数据模型中的产品数量。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
</div>
Listing 13-2.The Contents of the template.html File in the src/app Folder
从命令行的example文件夹中运行以下命令,启动 TypeScript 编译器和开发 HTTP 服务器:
ng serve
打开一个新的浏览器窗口并导航至http://localhost:4200以查看如图 13-1 所示的内容。
图 13-1。
运行示例应用
使用内置指令
Angular 附带了一组内置指令,提供了 web 应用中通常需要的特性。表 13-3 描述了可用的指令,我将在接下来的章节中演示这些指令(除了ngClass和ngStyle指令,它们将在第十二章中介绍)。
表 13-3。
内置指令
|例子
|
描述
|
| --- | --- |
| <div *ngIf="expr"></div> | 如果表达式的计算结果为true,则ngIf指令用于在 HTML 文档中包含一个元素及其内容。指令名称前的星号表示这是一个微模板指令,如“了解微模板指令”侧栏中所述。 |
| <div [ngSwitch]="expr"> <span *ngSwitchCase="expr"></span> <span *ngSwitchDefault></span></div> | ngSwitch指令用于根据表达式的结果在 HTML 文档中包含的多个元素之间进行选择,然后将表达式的结果与使用ngSwitchCase指令定义的各个表达式的结果进行比较。如果没有一个ngSwitchCase值匹配,那么将使用已经应用了ngSwitchDefault指令的元素。ngSwitchCase和ngSwitchDefault指令前的星号表示它们是微模板指令,如“理解微模板指令”侧栏中所述。 |
| <div *ngFor="#item of expr"></div> | ngFor指令用于为数组中的每个对象生成相同的元素集。指令名称前的星号表示这是一个微模板指令,如“了解微模板指令”侧栏中所述。 |
| <ng-template [ngTemplateOutlet]="myTempl"></ngtemplate> | ngTemplateOutlet指令用于重复模板中的一块内容。 |
| <div ngClass="expr"></div> | 如第十二章所述,ngClass指令用于管理类成员。 |
| <div ngStyle="expr"></div> | 如第十二章所述,ngStyle指令用于管理直接应用于元素的样式(与通过类应用样式相反)。 |
使用 ngIf 指令
ngIf是最简单的内置指令,用于在表达式的值为true时在文档中包含一段 HTML,如清单 13-3 所示。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<div *ngIf="getProductCount() > 4" class="bg-info p-2 mt-1">
There are more than 4 products in the model
</div>
<div *ngIf="getProductByPosition(0).name != 'Kayak'" class="bg-info p-2 mt-1">
The first product isn't a Kayak
</div>
</div>
Listing 13-3.Using the ngIf Directive in the template.html File in the src/app Folder
ngIf指令已经应用于两个div元素,表达式检查模型中Product对象的数量以及第一个Product的名称是否为Kayak。
第一个表达式求值为true,表示div元素及其内容将包含在 HTML 文档中;第二个表达式的值为false,这意味着第二个div元素将被排除。图 13-2 显示了结果。
Note
指令在 HTML 文档中添加和删除元素,而不仅仅是显示或隐藏它们。如果您想保留元素并控制它们的可见性,可以使用第十二章中描述的属性或样式绑定,方法是将hidden元素属性设置为true或将display样式属性设置为none。
图 13-2。
使用 ngIf 指令
Understanding Micro-Template Directives
一些指令,如ngFor、ngIf以及与ngSwitch一起使用的嵌套指令,都带有星号前缀,如*ngFor、*ngIf和*ngSwitch。星号是使用依赖于作为模板一部分提供的内容的指令的简写,称为微模板。使用微模板的指令被称为结构指令,我在第十六章向您展示如何创建它们时会再次提到这个描述。
清单 13-3 将ngIf指令应用于div元素,告诉指令使用div元素及其内容作为它处理的每个对象的微模板。在幕后,Angular 扩展了微模板和指令,如下所示:
...
<ng-template ngIf="model.getProductCount() > 4">
<div class="bg-info p-2 mt-1">
There are more than 4 products in the model
</div>
</ng-template>
...
您可以在模板中使用这两种语法,但是如果您使用紧凑语法,那么您必须记住使用星号。我在第十四章中解释了如何创建你自己的微模板指令。
像所有指令一样,用于ngIf的表达式将被重新计算,以反映数据模型中的变化。在浏览器的 JavaScript 控制台中运行以下语句,删除第一个数据对象并运行更改检测流程:
model.products.shift()
appRef.tick()
修改模型的效果是删除第一个div元素,因为现在的Product对象太少,添加第二个div元素,因为数组中第一个Product的name属性不再是Kayak。图 13-3 显示了变化。
图 13-3。
重新计算指令表达式的影响
使用 nsswitch 指令
ngSwitch指令根据表达式结果选择几个元素中的一个,类似于 JavaScript switch语句。清单 13-4 显示了用于根据模型中对象的数量选择元素的ngSwitch指令。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<div class="bg-info p-2 mt-1" [ngSwitch]="getProductCount()">
<span *ngSwitchCase="2">There are two products</span>
<span *ngSwitchCase="5">There are five products</span>
<span *ngSwitchDefault>This is the default</span>
</div>
</div>
Listing 13-4.Using the ngSwitch Directive in the template.html File in the src/app Folder
ngSwitch指令语法使用起来可能会很混乱。应用了ngSwitch指令的元素总是包含在 HTML 文档中,并且指令名没有前缀星号。它必须在方括号中指定,如下所示:
...
<div class="bg-info p-2 mt-1" [ngSwitch]="getProductCount()">
...
每个内部元素,在本例中是span元素,是一个微模板,指定目标表达式结果的指令以星号为前缀,如下所示:
...
<span *ngSwitchCase="5">There are five products</span>
...
ngSwitchCase指令用于指定表达式结果。如果ngSwitch表达式计算出指定的结果,那么该元素及其内容将包含在 HTML 文档中。如果表达式没有计算出指定的结果,那么元素及其内容将从 HTML 文档中排除。
ngSwitchDefault指令应用于一个 fallback 元素——相当于 JavaScript switch语句中的default标签——如果表达式结果与ngSwitchCase指令指定的任何结果都不匹配,该元素就会包含在 HTML 文档中。
对于应用中的初始数据,清单 13-4 中的指令产生以下 HTML:
...
<div class="bg-info p-2 mt-1" ng-reflect-ng-switch="5">
<span>There are five products</span>
</div>
...
已经应用了ngSwitch指令的div元素总是包含在 HTML 文档中。对于模型中的初始数据,其ngSwitchCase指令的结果为5的span元素也被包括在内,产生如图 13-4 左侧所示的结果。
图 13-4。
使用 nsswitch 指令
ngSwitch绑定响应数据模型中的变化,您可以通过在浏览器的 JavaScript 控制台中执行以下语句来测试:
model.products.shift()
appRef.tick()
这些语句从模型中删除第一个项目,并强制 Angular 运行变化检测过程。两个ngSwitchCase指令的结果都不匹配getProductCount表达式的结果,因此ngSwitchDefault元素被包含在 HTML 文档中,如图 13-4 的右图所示。
避免文字值问题
当使用ngSwitchCase指令指定文字字符串值时,会出现一个常见的问题,必须注意得到正确的结果,如清单 13-5 所示。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<div class="bg-info p-2 mt-1" [ngSwitch]="getProduct(1).name">
<span *ngSwitchCase="targetName">Kayak</span>
<span *ngSwitchCase="'Lifejacket'">Lifejacket</span>
<span *ngSwitchDefault>Other Product</span>
</div>
</div>
Listing 13-5.Component and String Literal Values in the template.html File in the src/app Folder
分配给ngSwitchCase指令的值也是表达式,这意味着您可以调用方法、执行简单的内联操作以及读取属性值,就像您对基本数据绑定所做的那样。
例如,当评估ngSwitch表达式的结果与组件定义的targetName属性的值匹配时,该表达式告诉 Angular 包含指令应用到的span元素:
...
<span *ngSwitchCase="targetName">Kayak</span>
...
如果你想比较一个结果和一个特定的字符串,你必须用双引号引起来,就像这样:
...
<span *ngSwitchCase="'Lifejacket'">Lifejacket</span>
...
当ngSwitch表达式的值等于文字字符串值Lifejacket时,该表达式告诉 Angular 包含span元素,产生如图 13-5 所示的结果。
图 13-5。
在 nsswitch 指令中使用表达式和文字值
使用 ngFor 指令
ngFor指令为数组中的每个对象重复一段内容,提供了相当于foreach循环的模板。在清单 13-6 中,我使用了ngFor指令来填充一个表格,为模型中的每个Product对象生成一行。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<table class="table table-sm table-bordered mt-1 text-dark">
<tr><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts()">
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
</div>
Listing 13-6.Using the ngFor Directive in the template.html File in the src/app Folder
与ngFor指令一起使用的表达式比其他内置指令更复杂,但是当您看到不同部分如何组合在一起时,它就开始有意义了。下面是我在示例中使用的指令:
...
<tr *ngFor="let item of getProducts()">
...
名称前的星号是必需的,因为该指令正在使用微模板,如“了解微模板指令”侧栏中所述。随着您对 Angular 的熟悉,这将变得更有意义,但是首先,您只需要记住这个指令需要一个星号(或者,正如我经常做的,直到您看到浏览器的 JavaScript 控制台中显示一个错误,然后然后记住)。
对于表达式本身,有两个不同的部分,用关键字of连接。表达式的右边部分提供将被枚举的数据源。
...
<tr *ngFor="let item of getProducts()">
...
这个例子指定组件的getProducts方法作为数据源,这允许内容是模型中每个Product对象的。右边是一个独立的表达式,这意味着您可以在模板中准备数据或执行简单的操作。
ngFor表达式的左侧定义了一个模板变量,由let关键字表示,这就是数据在 Angular 模板内的元素之间传递的方式。
...
<tr *ngFor="let item of getProducts()">
...
ngFor指令将变量分配给数据源中的每个对象,以便嵌套元素可以使用该变量。示例中的本地模板变量称为item,它用于访问td元素的Product对象的属性,如下所示:
...
<td>{{item.name}}</td>
...
总之,示例中的指令告诉 Angular 枚举由组件的getProducts方法返回的对象,将每个对象分配给一个名为item的变量,然后生成一个tr元素及其td子元素,评估它们包含的模板表达式。
对于清单 13-6 中的例子,结果是一个表格,其中ngFor指令用于为模型中的每个Product对象生成表格行,并且每个表格行包含显示Product对象的name、category和price属性的值的td元素,如图 13-6 所示。
图 13-6。
使用 ngFor 指令创建表行
使用其他模板变量
最重要的模板变量是引用正在处理的数据对象的变量,在前面的例子中是item。但是ngFor指令支持一系列其他的值,这些值也可以被赋给变量,然后在嵌套的 HTML 元素中被引用,如表 13-4 中所描述的,并在接下来的章节中演示。
表 13-4。
本地模板值的 NGF
|名字
|
描述
|
| --- | --- |
| index | 该number值被分配给当前对象的位置。 |
| odd | 如果当前对象在数据源中的位置是奇数,这个boolean值将返回true。 |
| even | 如果当前对象在数据源中的位置是偶数,那么这个boolean值返回true。 |
| first | 如果当前对象是数据源中的第一个对象,这个boolean值返回true。 |
| last | 如果当前对象是数据源中的最后一个对象,这个boolean值返回true。 |
使用索引值
index值被设置为当前数据对象的位置,并针对数据源中的每个对象递增。在清单 13-7 中,我定义了一个使用ngFor指令填充的表,该表将index值赋给一个名为i的本地模板变量,然后在字符串插值绑定中使用该变量。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<table class="table table-sm table-bordered mt-1 text-dark">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index">
<td>{{i +1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
</div>
Listing 13-7.Using the Index Value in the template.html File in the src/app Folder
一个新术语被添加到ngFor表达式中,用分号(;字符)与现有术语分开。新表达式使用let关键字将index值赋给一个名为i的本地模板变量,如下所示:
...
<tr *ngFor="let item of getProducts(); let i = index">
...
这允许使用绑定在嵌套元素中访问该值,如下所示:
...
<td>{{i + 1}}</td>
...
index值是从零开始的,给value加 1 创建一个简单的计数器,产生如图 13-7 所示的结果。
图 13-7。
使用索引值
使用奇数值和偶数值
当数据项的index值为odd时,odd值为true。相反,当数据项的索引值为even时,even值为true。一般来说,你只需要使用odd或even值,因为它们是boolean值,当even是false时odd是true,反之亦然。在清单 13-8 中,odd值用于管理表中tr元素的类成员。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<table class="table table-sm table-bordered mt-1">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index; let odd = odd"
class="text-white" [class.bg-primary]="odd" [class.bg-info]="!odd">
<td>{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
</div>
Listing 13-8.Using the odd Value in the template.html File in the src/app Folder
我使用了一个分号,并在ngFor表达式中添加了另一个术语,将odd值赋给一个本地模板变量,该变量也称为odd。
...
<tr *ngFor="let item of getProducts(); let i = index; let odd = odd"
class="text-white" [class.bg-primary]="odd" [class.bg-info]="!odd">
...
这似乎是多余的,但是你不能直接访问ngFor值,必须使用一个本地变量,即使它有相同的名字。我使用了class绑定来将替换行分配给bg-primary和bg-info类,这两个类是引导背景色类,它们将表格行分成条纹,如图 13-8 所示。
图 13-8。
使用奇数值
Expanding the *ngFor Directive
注意,在清单 13-8 中,我可以在表达式中使用模板变量,该变量应用于定义它的同一个tr元素。这是可能的,因为ngFor是一个微模板指令——由名字前面的*表示——所以 Angular 扩展了 HTML,看起来像这样:
...
<table class="table table-sm table-bordered mt-1">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<ng-template ngFor let-item [ngForOf]="getProducts()"
let-i="index" let-odd="odd">
<tr [class.bg-primary]="odd" [class.bg-info]="!odd">
<td>{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</ng-template>
</table>
...
您可以看到,ng-template元素定义了变量,使用了有些笨拙的let-<name>属性,然后由其中的tr和td元素访问这些属性。正如 Angular 中的许多内容一样,一旦你理解了幕后发生的事情,看似通过魔法发生的事情就会变得简单明了,我将在第十六章中详细解释这些特性。使用*ngFor语法的一个很好的理由是,它提供了一种更优雅的方式来表达指令表达式,尤其是当有多个模板变量时。
使用第一个和最后一个值
只有数据源提供的序列中的第一个对象的first值为true,其他所有对象的first值为false。相反,仅对于序列中的最后一个对象,last值为true。清单 13-9 使用这些值来区别对待序列中的第一个和最后一个对象。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<table class="table table-sm table-bordered mt-1">
<tr class="text-dark">
<th></th><th>Name</th><th>Category</th><th>Price</th>
</tr>
<tr *ngFor="let item of getProducts(); let i = index; let odd = odd;
let first = first; let last = last" class="text-white"
[class.bg-primary]="odd" [class.bg-info]="!odd"
[class.bg-warning]="first || last">
<td>{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td *ngIf="!last">{{item.price}}</td>
</tr>
</table>
</div>
Listing 13-9.Using the first and last Values in the template.html File in the src/app Folder
ngFor表达式中的新术语将first和last值分配给模板变量first和last。这些变量随后被绑定在tr元素上的class使用,当其中一个为true时,它将该元素分配给bg-warning类,并被td元素之一上的ngIf指令使用,这将排除数据源中last项的元素,产生如图 13-9 所示的效果。
图 13-9。
使用第一个和最后一个值
最小化元素操作
当数据模型发生变化时,ngFor指令评估其表达式,并更新代表其数据对象的元素。更新过程可能会很昂贵,尤其是当数据源被替换为包含表示相同数据的不同对象的数据源时。替换数据源似乎是一件奇怪的事情,但这在 web 应用中经常发生,尤其是当数据是从 web 服务中检索时,就像我在第二十四章中描述的那些。相同的数据值由新的对象表示,这对于 Angular 来说存在效率问题。为了演示这个问题,我向组件添加了一个方法来替换数据模型中的一个Product对象,如清单 13-10 所示。
import { Product } from "./product.model";
import { SimpleDataSource } from "./datasource.model";
export class Model {
private dataSource: SimpleDataSource;
private products: Product[];
private locator = (p:Product, id:number) => p.id == id;
constructor() {
this.dataSource = new SimpleDataSource();
this.products = new Array<Product>();
this.dataSource.getData().forEach(p => this.products.push(p));
}
// ...other methods omitted for brevity...
swapProduct() {
let p = this.products.shift();
this.products.push(new Product(p.id, p.name, p.category, p.price));
}
}
Listing 13-10.Replacing an Object in the repository.model.ts File in the src/app Folder
swapProduct方法从数组中删除第一个对象,并添加一个新对象,该对象的id、name、category和price属性具有相同的值。这是一个用新对象表示数据值的例子。
使用浏览器的 JavaScript 控制台运行以下语句来修改数据模型并运行更改检测流程:
model.swapProduct()
appRef.tick()
当ngFor指令检查它的数据源时,它发现要执行两个操作来反映数据的变化。第一个操作是销毁表示数组中第一个对象的 HTML 元素。第二个操作是创建一组新的 HTML 元素来表示数组末尾的新对象。
Angular 无法知道它正在处理的数据对象是否具有相同的值,也无法知道它是否可以通过简单地移动 HTML 文档中的现有元素来更有效地完成工作。
在这个例子中,这个问题只影响到两个元素,但是当应用中的数据使用 Ajax 从外部数据源刷新时,这个问题就更加严重了,每次收到响应时,所有的数据模型对象都可以被替换。因为它不知道真正的变化很少,ngFor指令必须销毁所有的 HTML 元素并重新创建它们,这可能是一个昂贵且耗时的操作。
为了提高更新的效率,您可以定义一个组件方法来帮助 Angular 确定何时两个不同的对象表示相同的数据,如清单 13-11 所示。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
// ...constructor and methods omitted for brevity...
getKey(index: number, product: Product) {
return product.id;
}
}
Listing 13-11.Adding the Object Comparison Method in the component.ts File in the src/app Folder
该方法必须定义两个参数:数据源中对象的位置和数据对象。方法的结果唯一标识一个对象,如果两个对象产生相同的结果,则认为它们是相等的。
如果两个Product对象具有相同的id值,它们将被视为相等。告诉ngFor表达式使用比较方法是通过给表达式添加一个trackBy项来完成的,如清单 13-12 所示。
<div class="text-white m-2">
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
<table class="table table-sm table-bordered mt-1">
<tr class="text-dark">
<th></th><th>Name</th><th>Category</th><th>Price</th>
</tr>
<tr *ngFor="let item of getProducts(); let i = index; let odd = odd;
let first = first; let last = last; trackBy:getKey" class="text-white"
[class.bg-primary]="odd" [class.bg-info]="!odd"
[class.bg-warning]="first || last">
<td>{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td *ngIf="!last">{{item.price}}</td>
</tr>
</table>
</div>
Listing 13-12.Providing an Equality Method in the template.html File in the src/app Folder
有了这个变化,ngFor指令将知道使用清单 13-12 中定义的swapProduct方法从数组中删除的Product等同于添加到数组中的那个,尽管它们是不同的对象。可以移动现有的元素,而不是删除和创建元素,这是一个执行起来更简单、更快速的任务。
仍然可以对元素进行更改——例如通过ngIf指令,该指令将删除一个td元素,因为新对象将是数据源中的last项,但即使这样也比单独处理对象要快。
Testing the Equality Method
检查等式方法是否有效果有点棘手。我发现最好的方法是使用浏览器的 F12 开发工具,在这种情况下使用 Chrome 浏览器。
应用加载后,在浏览器窗口中右键单击包含单词 Kayak 的td元素,并从弹出菜单中选择 Inspect。这将打开开发者工具窗口并显示元素面板。
单击左边的省略号按钮(标有...)并从菜单中选择添加属性。添加一个值为old的id属性。这将产生如下所示的元素:
<td id="old">Kayak</td>
添加一个id属性使得使用 JavaScript 控制台访问表示 HTML 元素的对象成为可能。切换到控制台面板,输入以下语句:
window.old
当您点击 Return 时,浏览器将通过元素的id属性值来定位元素,并显示以下结果:
<td id="old">Kayak</td>
现在在 JavaScript 控制台中执行以下语句,每执行一个语句后按 Return 键:
model.swapProduct()
appRef.tick()
一旦处理了对数据模型的更改,在 JavaScript 控制台中执行以下语句将确定添加了id属性的td元素是否已被移动或销毁:
window.old
如果元素已被移动,那么您将会在控制台中看到该元素,如下所示:
<td id="old">Kayak</td>
如果元素已经被破坏,那么就不会有id属性为old的元素,浏览器会显示undefined字样。
使用 ngTemplateOutlet 指令
ngTemplateOutlet指令用于在指定的位置重复一个内容块,当您需要在不同的地方生成相同的内容并希望避免重复时,这个指令会很有用。清单 13-13 显示了正在使用的指令。
<ng-template #titleTemplate>
<h4 class="p-2 bg-success text-white">Repeated Content</h4>
</ng-template>
<ng-template [ngTemplateOutlet]="titleTemplate"></ng-template>
<div class="bg-info p-2 m-2 text-white">
There are {{getProductCount()}} products.
</div>
<ng-template [ngTemplateOutlet]="titleTemplate"></ng-template>
Listing 13-13.Using the ngTemplateOutlet Directive in the template.html File in the src/app Folder
第一步是使用指令定义包含要重复的内容的模板。这是通过使用ng-template元素并使用引用变量为其命名来完成的,如下所示:
...
<ng-template #titleTemplate let-title="title">
<h4 class="p-2 bg-success text-white">Repeated Content</h4>
</ng-template>
...
当 Angular 遇到一个引用变量时,它将它的值设置为它所定义的元素,在本例中是ng-template元素。第二步是使用ngTemplateOutlet指令将内容插入 HTML 文档,如下所示:
...
<ng-template [ngTemplateOutlet]="titleTemplate"></ng-template>
...
表达式是分配给应该插入的内容的引用变量的名称。该指令用指定的ng-template元素的内容替换主机元素。HTML 文档中既不包含包含重复内容的ng-template元素,也不包含作为绑定宿主元素的元素。图 13-10 显示了指令是如何使用重复内容的。
图 13-10。
使用 ngTemplateOutlet 指令
提供上下文数据
ngTemplateOutlet指令可用于为重复内容提供上下文对象,该对象可用于在ng-template元素中定义的数据绑定,如清单 13-14 所示。
<ng-template #titleTemplate let-text="title">
<h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>
<div class="bg-info p-2 m-2 text-white">
There are {{getProductCount()}} products.
</div>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>
Listing 13-14.Providing Context Data in the template.html File in the src/app Folder
为了接收上下文数据,包含重复内容的ng-template元素定义了一个指定变量名称的let-属性,类似于用于ngFor指令的扩展语法。表达式的值给let-变量赋值,如下所示:
...
<ng-template #titleTemplate let-text="title">
...
本例中的let-属性创建了一个名为text的变量,该变量通过计算表达式title来赋值。为了提供计算表达式所依据的数据,应用了ngTemplateOutletContext指令的ng-template元素提供了一个 map 对象,如下所示:
...
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>
...
这个新绑定的目标是ngTemplateOutletContext,它看起来像另一个指令,但实际上是一个输入属性的例子,一些指令用它来接收数据值,我在第十五章中对此进行了详细描述。绑定的表达式是一个 map 对象,其属性名对应于另一个ng-template元素上的let-属性。结果是可以使用绑定来定制重复的内容,如图 13-11 所示。
图 13-11。
为重复内容提供上下文数据
了解单向数据绑定限制
尽管单向数据绑定和指令中使用的表达式看起来像 JavaScript 代码,但您不能使用所有的 JavaScript(或 TypeScript)语言功能。我将在接下来的章节中解释这些限制及其原因。
使用幂等表达式
单向数据绑定必须是幂等的,这意味着它们可以在不改变应用状态的情况下被重复评估。为了说明原因,我向组件的getProductCount方法添加了一个调试语句,如清单 13-15 所示。
Note
Angular 不支持修改应用状态,但是必须使用我在第十四章中描述的技术。
...
getProductCount(): number {
console.log("getProductCount invoked");
return this.getProducts().length;
}
...
Listing 13-15.Adding a Statement in the component.ts File in the src/app Folder
当保存更改并且浏览器重新加载页面时,您将在浏览器的 JavaScript 控制台中看到一长串类似这样的消息:
...
getProductCount invoked
getProductCount invoked
getProductCount invoked
getProductCount invoked
...
如消息所示,在浏览器中显示内容之前,Angular 对绑定表达式进行了多次评估。如果一个表达式修改了应用的状态,比如从队列中删除一个对象,那么当模板显示给用户时,您不会得到预期的结果。为了避免这个问题,Angular 限制了表达式的使用方式。在清单 13-16 中,我向组件添加了一个counter属性来帮助演示。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
// ...constructor and methods omitted for brevity...
targetName: string = "Kayak";
counter: number = 1;
}
Listing 13-16.Adding a Property in the component.ts File in the src/app Folder
在清单 13-17 中,我添加了一个绑定,当它被求值时,它的表达式增加计数器。
<ng-template #titleTemplate let-text="title">
<h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>
<div class="bg-info p-2 m-2 text-white">
There are {{getProductCount()}} products.
</div>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>
<div class="bg-info p-2">
Counter: {{counter = counter + 1}}
</div>
Listing 13-17.Adding a Binding in the template.html File in the src/app Folder
当浏览器加载页面时,您会在 JavaScript 控制台中看到一个错误,如下所示:
...
ERROR in Template parse errors:
Parser Error: Bindings cannot contain assignments at column 11 in [ Counter: {{counter = counter + 1}} ] in C:/example/src/app/template.html@17:4 ("
<div class="bg-info p-2">
[ERROR ->]Counter: {{counter = counter + 1}}
</div>"): C:/Users/example/src/app/template.html@17:4
...
如果数据绑定表达式包含可用于执行赋值的运算符,如=、+=、-+、++和--,Angular 将报告错误。此外,当 Angular 在开发模式下运行时,它会执行额外的检查,以确保单向数据绑定在计算完表达式后没有被修改。为了演示,清单 13-18 向组件添加了一个属性,该属性从模型数组中移除并返回一个Product对象。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
// ...constructor and methods omitted for brevity...
counter: number = 1;
get nextProduct(): Product {
return this.model.getProducts().shift();
}
}
Listing 13-18.Modifying Data in the component.ts File in the src/app Folder
在清单 13-19 中,您可以看到我用来读取nextProduct属性的数据绑定。
<ng-template #titleTemplate let-text="title">
<h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>
<div class="bg-info p-2 m-2 text-white">
There are {{getProductCount()}} products.
</div>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>
<div class="bg-info p-2 text-white">
Next Product is {{nextProduct.name}}
</div>
Listing 13-19.Binding to a Property in the template.html File in the src/app Folder
保存模板时的响应取决于 F12 开发人员工具是否打开。如果是,那么调试器将暂停应用的执行,因为检测变化的代码包含一个debugger语句。如果您关闭 F12 工具,重新加载浏览器窗口,然后再次打开这些工具,您将在 JavaScript 控制台中看到以下错误:
...
Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'null: 4'. Current value: 'null: 3'.
...
理解表达式上下文
当 Angular 对表达式求值时,它是在模板组件的上下文中进行的,这就是模板能够在没有任何前缀的情况下访问方法和属性的方式,如下所示:
...
<div class="bg-info p-2">
There are {{getProductCount()}} products.
</div>
...
当 Angular 处理这些表达式时,组件提供了getProductCount方法,Angular 使用指定的参数调用该方法,然后将结果合并到 HTML 文档中。据说该组件提供了模板的表达式上下文。
表达式上下文意味着不能访问模板组件之外定义的对象,尤其是模板不能访问全局名称空间。全局名称空间用于定义常见的实用程序,例如console对象,它定义了我一直用来将调试信息写出到浏览器的 JavaScript 控制台的log方法。全局名称空间还包括Math对象,它提供了对一些有用的算术方法的访问,比如min和max。
为了演示这种限制,清单 13-20 向模板添加了一个字符串插值绑定,它依赖于Math.floor方法将number值向下舍入到最接近的整数。
<ng-template #titleTemplate let-text="title">
<h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>
<div class="bg-info p-2 m-2 text-white">
There are {{getProductCount()}} products.
</div>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>
<div class='bg-info p-2'>
The rounded price is {{Math.floor(getProduct(1).price)}}
</div>
Listing 13-20.Accessing the Global Namespace in the template.html File in the src/app Folder
Angular 处理模板时,会在浏览器的 JavaScript 控制台中产生以下错误:
error TS2339: Property 'Math' does not exist on type 'ProductComponent'.
错误消息没有特别提到全局名称空间。相反,Angular 试图使用组件作为上下文来评估表达式,但未能找到一个Math属性。
如果您想要访问全局命名空间中的功能,那么它必须由组件提供,作为模板的代表。在这个例子中,组件可以只定义一个分配给全局对象的Math属性,但是模板表达式应该尽可能的清晰和简单,所以一个更好的方法是定义一个为模板提供它所需要的特定功能的方法,如清单 13-21 所示。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
// ...constructor and methods omitted for brevity...
counter: number = 1;
get nextProduct(): Product {
return this.model.getProducts().shift();
}
getProductPrice(index: number): number {
return Math.floor(this.getProduct(index).price);
}
}
Listing 13-21.Defining a Method in the component.ts File in the src/app Folder
在清单 13-22 中,我已经更改了模板中的数据绑定,以使用新定义的方法。
<ng-template #titleTemplate let-text="title">
<h4 class="p-2 bg-success text-white">{{text}}</h4>
</ng-template>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Header'}">
</ng-template>
<div class="bg-info p-2 m-2 text-white">
There are {{getProductCount()}} products.
</div>
<ng-template [ngTemplateOutlet]="titleTemplate"
[ngTemplateOutletContext]="{title: 'Footer'}">
</ng-template>
<div class="bg-info p-2 text-white">
The rounded price is {{getProductPrice(1)}}
</div>
Listing 13-22.Access Global Namespace Functionality in the template.html File in the src/app Folder
Angular 处理模板时,会调用getProductPrice方法,间接利用全局名称空间中的Math对象,产生如图 13-12 所示的结果。
图 13-12。
访问全局名称空间功能
摘要
在本章中,我解释了如何使用内置模板指令。我向您展示了如何使用ngIf和ngSwitch指令选择内容,以及如何使用ngFor指令重复内容。我解释了为什么有些指令名称带有星号前缀,并描述了这些指令和一般单向数据绑定使用的模板表达式的限制。在下一章,我将描述数据绑定是如何用于事件和表单元素的。
十四、使用事件和表单
在这一章中,我继续描述基本的 Angular 功能,重点是响应用户交互的特性。我将解释如何创建事件绑定,以及如何使用双向绑定来管理模型和模板之间的数据流。web 应用中用户交互的主要形式之一是使用 HTML 表单,我将解释如何使用事件绑定和双向数据绑定来支持它们并验证用户提供的内容。表 14-1 将事件和表单放在上下文中。
表 14-1。
将事件绑定和表单放在上下文中
|问题
|
回答
| | --- | --- | | 它们是什么? | 事件绑定在事件被触发时计算表达式,例如用户按键、移动鼠标或提交表单。更广泛的与表单相关的功能在此基础上构建,以创建自动验证的表单,从而确保用户提供有用的数据。 | | 它们为什么有用? | 这些特性允许用户更改应用的状态,更改或添加模型中的数据。 | | 它们是如何使用的? | 每种功能都有不同的使用方式。有关详细信息,请参见示例。 | | 有什么陷阱或限制吗? | 与所有 Angular 绑定一样,主要缺陷是使用错误的括号来表示绑定。请密切注意本章中的例子,并在没有得到预期结果时检查您应用绑定的方式。 | | 还有其他选择吗? | 不。这些功能是 Angular 的核心部分。 |
表 14-2 总结了本章内容。
表 14-2。
章节总结
|问题
|
解决办法
|
列表
|
| --- | --- | --- |
| 启用表单支持 | 将@angular/forms模块添加到应用 | 1–3 |
| 对事件做出反应 | 使用事件绑定 | 4–6 |
| 获取事件的详细信息 | 使用$event对象 | seven |
| 引用模板中的元素 | 定义模板变量 | eight |
| 允许数据在元素和组件之间双向流动 | 使用双向数据绑定 | 9, 10 |
| 捕捉用户输入 | 使用 HTML 表单 | 11, 12 |
| 验证用户提供的数据 | 执行表单验证 | 13–22 |
| 使用 JavaScript 代码定义验证信息 | 使用基于模型的表单 | 23–28 |
| 扩展内置的表单验证功能 | 定义自定义表单验证类 | 29–30 |
准备示例项目
对于这一章,我将继续使用我在第十一章中创建的示例项目,并在此后的章节中进行修改。
Tip
你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。
导入表单模块
本章中演示的特性依赖于 Angular forms 模块,该模块必须导入 Angular 模块,如清单 14-1 所示。
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ProductComponent } from "./component";
import { FormsModule } from "@angular/forms";
@NgModule({
declarations: [ProductComponent],
imports: [BrowserModule, FormsModule],
providers: [],
bootstrap: [ProductComponent]
})
export class AppModule { }
Listing 14-1.Declaring a Dependency in the app.module.ts File in the src/app Folder
NgModule装饰器的imports属性指定了应用的依赖关系。将FormsModule添加到依赖项列表中可以启用表单功能,并使它们可以在整个应用中使用。
准备组件和模板
清单 14-2 从组件类中移除了构造函数和一些方法,以保持代码尽可能简单,并添加了一个名为selectedProduct的新属性。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
getProduct(key: number): Product {
return this.model.getProduct(key);
}
getProducts(): Product[] {
return this.model.getProducts();
}
selectedProduct: Product;
}
Listing 14-2.Simplifying the Component in the component.ts File in the src/app Folder
清单 14-3 简化了组件的模板,只留下一个使用ngFor指令填充的表格。
<div class="m-2">
<table class="table table-sm table-bordered">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index">
<td>{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
</div>
Listing 14-3.Simplifying the Template in the template.html File in the src/app Folder
要启动开发服务器,请打开命令提示符,导航到example文件夹,然后运行以下命令:
ng serve
打开一个新的浏览器窗口并导航至http://localhost:4200以查看如图 14-1 所示的表格。
图 14-1。
运行示例应用
使用事件绑定
事件绑定用于响应主机元素发送的事件。清单 14-4 展示了事件绑定,它允许用户与 Angular 应用交互。
<div class="m-2">
<div class="bg-info text-white p-2">
Selected Product: {{selectedProduct || '(None)'}}
</div>
<table class="table table-sm table-bordered m-2">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index">
<td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
</div>
Listing 14-4.Using the Event Binding in the template.html File in the src/app Folder
当您保存对模板的更改时,您可以通过将鼠标指针移动到 HTML 表格的第一列上来测试绑定,该表格显示一系列数字。当鼠标从一行移动到另一行时,该行显示的产品名称显示在页面顶部,如图 14-2 所示。
图 14-2。
使用事件绑定
这是一个简单的例子,但它显示了事件绑定的结构,如图 14-3 所示。
图 14-3。
事件绑定的剖析
事件绑定包含以下四个部分:
-
主机元素是绑定的事件源。
-
圆括号告诉 Angular 这是一个事件绑定,这是一种单向绑定的形式,数据从元素流向应用的其余部分。
-
事件指定绑定用于哪个事件。
-
当事件被触发时,表达式被求值。
查看清单 14-4 中的绑定,您可以看到主机元素是一个td元素,这意味着这是将成为事件源的元素。绑定指定了mouseover事件,当鼠标指针移动到主机元素占据的屏幕部分时,该事件被触发。
与单向绑定不同,事件绑定中的表达式可以改变应用的状态,并且可以包含赋值操作符,比如=。绑定的表达式将值item.name赋给一个名为selectedProduct的变量。selectedProduct变量用于模板顶部的字符串插值绑定,如下所示:
...
<div class="bg-info text-white p-2">
Selected Product: {{selectedProduct || '(None)'}}
</div>
...
当selectedProduct变量的值被事件绑定改变时,字符串插值绑定显示的值被更新。不再需要使用ApplicationRef.tick方法手动启动变更检测过程,因为本章中的绑定和指令会自动处理该过程。
Working with DOM Events
如果您不熟悉 HTML 元素可以发送的事件,那么在developer.mozilla.org/en-US/docs/Web/Events有一个很好的总结。然而,有许多事件,并不是所有浏览器都广泛或一致地支持它们。一个很好的起点是mozilla.org页面的“DOM Events”和“HTML DOM Events”部分,它们定义了用户与元素的基本交互(点击、移动指针、提交表单等等),并且可以在大多数浏览器中使用。
如果您使用不太常见的事件,那么您应该确保它们在您的目标浏览器中可用并按预期工作。优秀的 http://caniuse.com 提供了不同浏览器实现哪些特性的细节,但是你也应该进行彻底的测试。
显示所选产品的表达式使用 null 合并运算符来确保用户始终看到一条消息,即使没有选择产品也是如此。一个更简洁的方法是定义一个执行这个检查的方法,如清单 14-5 所示。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
getProduct(key: number): Product {
return this.model.getProduct(key);
}
getProducts(): Product[] {
return this.model.getProducts();
}
selectedProduct: string;
getSelected(product: Product): boolean {
return product.name == this.selectedProduct;
}
}
Listing 14-5.Enhancing the Component in the component.ts File in the src/app Folder
我定义了一个名为getSelected的方法,它接受一个Product对象,并将其名称与selectedProduct属性进行比较。在清单 14-6 中,getSelected方法被一个类绑定用来控制bg-info类的成员资格,这个类是一个引导类,为一个元素分配背景颜色。
<div class="m-2">
<div class="bg-info text-white p-2">
Selected Product: {{selectedProduct || '(None)'}}
</div>
<table class="table table-sm table-bordered m-2">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index"
[class.bg-info]="getSelected(item)">
<td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
</div>
Listing 14-6.Setting Class Membership in the template.html File in the src/app Folder
结果是当selectedProduct属性值与用于创建它们的Product对象的name属性相匹配时,tr元素被添加到bg-info类中,当mouseover事件被触发时,事件绑定会改变这些属性,如图 14-4 所示。
图 14-4。
通过事件绑定突出显示表行
这个例子展示了用户交互如何将新数据驱动到应用中,并启动变化检测过程,导致 Angular 重新评估字符串插值和类绑定所使用的表达式。这种数据流是 Angular 应用的生命所在:第 12 和 13 章中描述的绑定和指令动态响应应用状态的变化,创建完全在浏览器中生成和管理的内容。
What Happened to Dynamically Created Properties?
早期版本的 Angular 允许模板使用在运行时创建的、没有在组件中定义的属性。这种技术利用了 JavaScript 的动态特性,尽管在应用被编译用于生产时它被标记为错误。Angular 9 引入了新的构建工具来防止这种把戏,确保模板使用的工具必须由组件定义。
使用事件数据
前面的例子使用事件绑定来连接组件提供的两段数据:当mouseevent被触发时,绑定的表达式使用由组件的getProducts方法提供给ngfor指令的数据值来设置selectedProduct属性。
事件绑定也可用于使用浏览器提供的详细信息,将新数据从事件本身引入应用。清单 14-7 向模板中添加了一个input元素,并使用事件绑定来监听input事件,当input元素的内容发生变化时就会触发该事件。
<div class="m-2">
<div class="bg-info text-white p-2">
Selected Product: {{selectedProduct || '(None)'}}
</div>
<table class="table table-sm table-bordered m-2">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index"
[class.bg-info]="getSelected(item)">
<td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
<div class="form-group">
<label>Product Name</label>
<input class="form-control" (input)="selectedProduct=$event.target.value" />
</div>
</div>
Listing 14-7.Using an Event Object in the template.html File in the src/app Folder
当浏览器触发一个事件时,它会提供一个描述它的对象。不同类别的事件(鼠标事件、键盘事件、表单事件等)有不同类型的事件对象,但所有事件都共享表 14-3 中描述的三个属性。
表 14-3。
所有 DOM 事件对象共有的属性
|名字
|
描述
|
| --- | --- |
| type | 该属性返回一个string,它标识已经触发的事件的类型。 |
| target | 该属性返回触发事件的object,它通常是表示 DOM 中 HTML 元素的对象。 |
| timeStamp | 该属性返回一个包含事件触发时间的number,以 1970 年 1 月 1 日以来的毫秒数表示。 |
事件对象被分配给一个名为$event的模板变量,清单 14-7 中的绑定表达式使用这个变量来访问事件对象的target属性。
在 DOM 中,input元素由一个HTMLInputElement对象表示,该对象定义了一个value属性,可以用来获取和设置input元素的内容。绑定表达式通过将组件的selectedProduct属性的值设置为input元素的 value 属性的值来响应input事件,如下所示:
...
<input class="form-control" (input)="selectedProduct=$event.target.value" />
...
当用户编辑input元素的内容时会触发input事件,因此组件的selectedProduct属性会在每次击键后用input元素的内容更新。当用户键入input元素时,使用字符串插值绑定,输入的文本显示在浏览器窗口的顶部。
当selectedProduct属性与它们代表的产品名称匹配时,应用于tr元素的ngClass绑定设置表格行的背景颜色。而且,现在selectedProduct属性的值是由input元素的内容驱动的,键入一个产品的名称将导致相应的行被高亮显示,如图 14-5 所示。
图 14-5。
使用事件数据
使用不同的绑定协同工作是有效的 Angular 开发的核心,它使得创建能够立即响应用户交互和数据模型变化的应用成为可能。
使用模板引用变量
在第十三章中,我解释了如何使用模板变量在模板内传递数据,比如使用ngFor指令时为当前对象定义一个变量。模板引用变量是模板变量的一种形式,可以用来引用模板中的元素*,如清单 14-8 所示。*
<div class="m-2">
<div class="bg-info text-white p-2">
Selected Product: {{product.value || '(None)'}}
</div>
<table class="table table-sm table-bordered m-2">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index"
(mouseover)="product.value=item.name"
[class.bg-info]="product.value==item.name">
<td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
<div class="form-group">
<label>Product Name</label>
<input #product class="form-control" (input)="false" />
</div>
</div>
Listing 14-8.Using a Template Variable in the template.html File in the src/app Folder
参考变量使用#字符定义,后跟变量名。在清单中,我像这样定义了一个名为product的变量:
...
<input #product class="form-control" (input)="false" />
...
当 Angular 遇到模板中的引用变量时,它会将其值设置为它所应用到的元素。在这个例子中,product引用变量被赋予了代表 DOM 中的input元素的对象,即HTMLInputElement对象。同一模板中的其他绑定可以使用引用变量。字符串插值绑定演示了这一点,它也使用了product变量,如下所示:
...
Selected Product: {{product.value || '(None)'}}
...
如果value属性返回null,则该绑定显示由已经分配给产品变量或字符串(None)的HTMLInputElement定义的value属性。模板变量也可用于更改元素的状态,如以下绑定所示:
...
<tr *ngFor="let item of getProducts(); let i = index"
(mouseover)="product.value=item.name"
[class.bg-info]="product.value==item.name">
...
事件绑定通过在已经分配给product变量的HTMLInputElement上设置value属性来响应mouseover事件。结果是将鼠标移动到其中一个tr元素上将会更新input元素的内容。
这个例子有一个尴尬的地方,那就是在input元素上绑定input事件。
...
<input #product class="form-control" (input)="false" />
...
当用户编辑input元素的内容时,Angular 不会更新模板中的数据绑定,除非该元素上有事件绑定。将绑定设置为false给 Angular 一些东西来评估,这样更新过程就会开始,并在整个模板中分发input元素的当前内容。这是将模板引用变量的角色扩展得太远的一种怪癖,在大多数实际项目中您不需要这样做。正如您将在后面的例子和章节中看到的,大多数数据绑定依赖于模板组件定义的变量。
Filtering Key Events
每当input元素中的内容发生变化时,就会触发input事件。这提供了一组即时响应的更改,但这并不是每个应用都需要的,尤其是当更新应用状态涉及到昂贵的操作时。
事件绑定具有内置支持,在绑定到键盘事件时更具选择性,这意味着只有在按下特定键时才会执行更新。下面是一个响应每次击键的绑定:
...
<input #product class="form-control" (keyup)="selectedProduct=product.value" />
...
keyup事件是一个标准的 DOM 事件,其结果是当用户在输入input元素时释放每个键,应用就会更新。我可以通过将名称指定为事件绑定的一部分来更具体地确定我感兴趣的键,如下所示:
...
<input #product class="form-control"
(keyup.enter)="selectedProduct=product.value" />
...
绑定将响应的键是通过在 DOM 事件名称后附加一个句点,后跟键名来指定的。这个绑定是针对回车键的,结果是在按下该键之前,input元素中的更改不会被推送到应用的其余部分。
使用双向数据绑定
可以组合绑定来为单个元素创建双向数据流,允许 HTML 文档在应用模型改变时做出响应,也允许应用在元素发出事件时做出响应,如清单 14-9 所示。
<div class="m-2">
<div class="bg-info text-white p-2">
Selected Product: {{selectedProduct || '(None)'}}
</div>
<table class="table table-sm table-bordered m-2">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index"
[class.bg-info]="getSelected(item)">
<td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
<div class="form-group">
<label>Product Name</label>
<input class="form-control" (input)="selectedProduct=$event.target.value"
[value]="selectedProduct || ''" />
</div>
<div class="form-group">
<label>Product Name</label>
<input class="form-control" (input)="selectedProduct=$event.target.value"
[value]="selectedProduct || ''" />
</div>
</div>
Listing 14-9.Creating a Two-Way Binding in the template.html File in the src/app Folder
每个input元素都有一个事件绑定和一个属性绑定。事件绑定通过更新组件的selectedProduct属性来响应input事件。属性绑定将selectedProduct属性的值绑定到元素的value属性。
结果是两个input元素的内容被同步,编辑其中一个会导致另一个也被更新。而且,由于模板中还有其他依赖于selectedProduct属性的绑定,编辑input元素的内容也会改变字符串插值绑定显示的数据,并改变高亮显示的表格行,如图 14-6 所示。
图 14-6。
创建双向数据绑定
当您在浏览器中试验它时,这是一个最有意义的例子。在其中一个input元素中输入一些文本,您会看到同样的文本显示在另一个input元素和div元素中,它们的内容由字符串插值绑定管理。如果您在其中一个输入元素中输入一个产品的名称,比如 Kayak 或 Lifejacket,那么您还会看到表中相应的行被突出显示。
mouseover事件的事件绑定仍然生效,这意味着当您将鼠标指针移动到表中的第一行时,selectedProduct值的变化将导致input元素显示产品名称。
使用 ngModel 指令
ngModel指令用于简化双向绑定,这样您就不必对同一个元素同时应用事件和属性绑定。清单 14-10 展示了如何用ngModel指令替换单独的绑定。
<div class="m-2">
<div class="bg-info text-white p-2">
Selected Product: {{selectedProduct || '(None)'}}
</div>
<table class="table table-sm table-bordered m-2">
<tr><th></th><th>Name</th><th>Category</th><th>Price</th></tr>
<tr *ngFor="let item of getProducts(); let i = index"
[class.bg-info]="getSelected(item)">
<td (mouseover)="selectedProduct=item.name">{{i + 1}}</td>
<td>{{item.name}}</td>
<td>{{item.category}}</td>
<td>{{item.price}}</td>
</tr>
</table>
<div class="form-group">
<label>Product Name</label>
<input class="form-control" [(ngModel)]="selectedProduct" />
</div>
<div class="form-group">
<label>Product Name</label>
<input class="form-control" [(ngModel)]="selectedProduct" />
</div>
</div>
Listing 14-10.Using the ngModel Directive in the template.html File in the src/app Folder
使用ngModel指令需要结合属性和事件绑定的语法,如图 14-7 所示。
图 14-7。
双向数据绑定的剖析
方括号和圆括号的组合用于表示双向数据绑定,圆括号放在方括号内:[(和)]。Angular 开发团队称之为盒中香蕉绑定,因为这就是括号和圆括号像这样放置时的样子[()]。嗯,算是吧。
绑定的目标是ngModel指令,它包含在 Angular 中是为了简化在表单元素上创建双向数据绑定,比如本例中使用的input元素。
双向数据绑定的表达式是属性的名称,用于在后台设置各个绑定。当input元素的内容改变时,新的内容将用于更新selectedProduct属性的值。同样,当selectedProduct的值改变时,它将被用来更新元素的内容。
指令知道标准 HTML 元素定义的事件和属性的组合。在幕后,一个事件绑定被应用到input事件,一个属性绑定被应用到value属性。
Tip
记住在ngModel绑定中使用括号和圆括号是很重要的。如果您只使用括号—(ngModel)—那么您正在为一个不存在的名为ngModel的事件设置一个事件绑定。结果是一个元素不会被更新或者不会更新应用的其余部分。您可以使用带有方括号的ngModel指令—[ngModel]—Angular 将设置元素的初始值,但不会监听事件,这意味着用户所做的更改不会自动反映在应用模型中。
使用表单
大多数 web 应用依赖表单从用户那里接收数据,上一节描述的双向ngModel绑定为在 angle 应用中使用表单提供了基础。在本节中,我将创建一个表单,允许创建新产品并将其添加到应用的数据模型中,然后描述 Angular 提供的一些更高级的表单特性。
向示例应用添加表单
清单 14-11 显示了创建表单时将使用的组件的一些增强,并删除了一些不再需要的特性。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
getProduct(key: number): Product {
return this.model.getProduct(key);
}
getProducts(): Product[] {
return this.model.getProducts();
}
newProduct: Product = new Product();
get jsonProduct() {
return JSON.stringify(this.newProduct);
}
addProduct(p: Product) {
console.log("New Product: " + this.jsonProduct);
}
}
Listing 14-11.Enhancing the Component in the component.ts File in the src/app Folder
清单添加了一个名为newProduct的新属性,用于存储用户输入表单的数据。还有一个带有 getter 的jsonProduct属性,它返回newProduct属性的 JSON 表示,并将在模板中用于显示双向绑定的效果。(我不能直接在模板中创建对象的 JSON 表示,因为 JSON 对象是在全局名称空间中定义的,正如我在第十三章中解释的,不能直接从模板表达式中访问。)
最后添加的是一个addProduct方法,它将jsonProduct方法的值写出到控制台;这将让我演示一些基本的与表单相关的特性,然后在本章的后面添加对更新数据模型的支持。
在清单 14-12 中,模板内容已经被一系列由Product类定义的属性的input元素所取代。
<div class="m-2">
<div class="bg-info text-white mb-2 p-2">Model Data: {{jsonProduct}}</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" [(ngModel)]="newProduct.name" />
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" [(ngModel)]="newProduct.category" />
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" [(ngModel)]="newProduct.price" />
</div>
<button class="btn btn-primary" (click)="addProduct(newProduct)">Create</button>
</div>
Listing 14-12.Adding Input Elements in the template.html File in the src/app Folder
每个input元素用一个label分组在一起,并包含在一个div元素中,该元素使用 Bootstrap form-group类进行样式化。单个的input元素被分配给 Bootstrap form-control类来管理布局和风格。
ngModel绑定已经应用于每个input元素,以创建一个与组件的newProduct对象上的相应属性的双向绑定,如下所示:
...
<input class="form-control" [(ngModel)]="newProduct.name" />
...
还有一个button元素,它有一个调用组件的addProduct方法的click事件的绑定,将newProduct值作为参数传入。
...
<button class="btn btn-primary" (click)="addProduct(newProduct)">Create</button>
...
最后,字符串插值绑定用于在模板顶部显示组件的newProduct属性的 JSON 表示,如下所示:
...
<div class="bg-info text-white mb-2 p-2">Model Data: {{jsonProduct}}</div>
...
如图 14-8 所示,总体结果是一组input元素,它们更新由组件管理的Product对象的属性,这些属性立即反映在 JSON 数据中。
图 14-8。
使用表单元素在数据模型中创建新对象
当单击 Create 按钮时,组件的newProduct属性的 JSON 表示被写入浏览器的 JavaScript 控制台,产生如下结果:
New Product: {"name":"Running Shoes","category":"Running","price":"120.23"}
添加表单数据验证
此时,任何数据都可以输入到表单的input元素中。数据验证在 web 应用中是必不可少的,因为用户会输入范围惊人的数据值,这可能是错误的,也可能是因为他们希望尽可能快地结束该过程,并输入垃圾值以继续。
Angular 基于 HTML5 标准使用的方法,提供了一个可扩展的系统来验证表单元素的内容。您可以向input元素添加四个属性,每个属性定义一个验证规则,如表 14-4 中所述。
表 14-4。
内置的 Angular 验证属性
|属性
|
描述
|
| --- | --- |
| required | 此属性用于指定必须提供的值。 |
| minlength | 此属性用于指定最小字符数。 |
| maxlength | 此属性用于指定最大字符数。这种类型的验证不能直接应用于表单元素,因为它与同名的 HTML5 属性冲突。它可以与基于模型的表单一起使用,这将在本章的后面介绍。 |
| pattern | 此属性用于指定用户提供的值必须匹配的正则表达式。 |
您可能对这些属性很熟悉,因为它们是 HTML 规范的一部分,但是 Angular 在这些属性的基础上构建了一些额外的特性。清单 14-13 删除了除了一个input元素之外的所有元素,以尽可能简单地演示向表单添加验证的过程。(我将在本章的后面部分恢复缺失的元素。)
<div class="m-2">
<div class="bg-info p-2 mb-2">Model Data: {{jsonProduct}}</div>
<form novalidate (ngSubmit)="addProduct(newProduct)">
<div class="form-group">
<label>Name</label>
<input class="form-control"
name="name"
[(ngModel)]="newProduct.name"
required
minlength="5"
pattern="^[A-Za-z ]+$" />
</div>
<button class="btn btn-primary" type="submit">
Create
</button>
</form>
</div>
Listing 14-13.Adding Form Validation in the template.html File in the src/app Folder
Angular 要求被验证的元素定义name属性,该属性用于在验证系统中标识元素。因为这个input元素被用来捕获Product.name属性的值,所以元素上的name属性被设置为name。
这个清单向input元素添加了四个验证属性中的三个。required属性指定用户必须提供一个值,minlength属性指定应该至少有三个字符,pattern属性指定只允许字母字符和空格。
Angular 使用的验证属性与 HTML 5 规范使用的属性相同,所以我在form元素中添加了novalidate属性,它告诉浏览器不要使用它的原生验证特性,这些特性在不同的浏览器中实现不一致,通常会造成障碍。因为 Angular 将提供验证,所以不需要浏览器自己实现这些特性。
最后,注意一个form元素已经被添加到模板中。虽然您可以单独使用input元素,但是只有当存在form元素时,Angular 验证特性才起作用,如果您将ngControl指令添加到不包含在form中的元素,Angular 将报告一个错误。
当使用一个form元素时,惯例是为一个叫做ngSubmit的特殊事件使用一个事件绑定,如下所示:
...
<form novalidate (ngSubmit)="addProduct(newProduct)">
...
绑定处理form元素的submit事件。如果您愿意,您可以在form中的单个button元素上绑定到click事件来实现相同的效果。
使用验证类设计元素的样式
一旦您保存了清单 14-13 中的模板更改,并且浏览器重新加载了 HTML,在浏览器窗口中右键单击input元素并从弹出窗口中选择检查或检查元素。浏览器将在开发者工具窗口中显示元素的 HTML 表示,您将看到input元素已经被添加到三个类中,如下所示:
...
<input class="form-control ng-pristine ng-invalid ng-touched" minlength="5"
name="name" pattern="^[A-Za-z ]+$" required="" ng-reflect-name="name">
...
一个input元素被分配到的类提供了它的验证状态的细节。有三对验证类,如表 14-5 所述。元素总是每对中的一个类的成员,总共三个类。相同的类被应用于form元素,以显示它包含的所有元素的整体验证状态。随着input元素状态的改变,ngControl指令会自动切换单个元素和表单元素的类。
表 14-5。
Angular 形式验证类
|名字
|
描述
|
| --- | --- |
| ng-untouchedng-touched | 如果用户没有访问过一个元素,那么这个元素就被分配给ng-untouched类,这通常是通过在表单域中跳转来完成的。一旦用户访问了一个元素,它就会被添加到ng-touched类中。 |
| ng-pristineng-dirty | 如果一个元素的内容没有被用户改变,则该元素被分配给ng-pristine类,否则被分配给ng-dirty类。一旦内容被编辑,一个元素会保留在ng-dirty类中,即使用户返回到之前的内容。 |
| ng-validng-invalid | 如果一个元素的内容满足应用于它的验证规则所定义的标准,则该元素被分配给ng-valid类,否则被分配给ng-invalid类。 |
这些类可以用来设计表单元素的样式,为用户提供验证反馈。清单 14-14 向模板添加了一个style元素,并定义了指示用户何时输入了无效或有效数据的样式。
Tip
在实际应用中,样式应该在单独的样式表中定义,并通过index.html文件或使用组件的装饰器设置(我在第十七章中描述)包含在应用中。为了简单起见,我将样式直接包含在模板中,但是这使得实际的应用更难维护,因为当使用多个模板时,很难弄清楚样式来自哪里。
<style>
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
</style>
<div class="m-2">
<div class="bg-info p-2 mb-2">Model Data: {{jsonProduct}}</div>
<form novalidate (ngSubmit)="addProduct(newProduct)">
<div class="form-group">
<label>Name</label>
<input class="form-control"
name="name"
[(ngModel)]="newProduct.name"
required
minlength="5"
pattern="^[A-Za-z ]+$" />
</div>
<button class="btn btn-primary" type="submit">
Create
</button>
</form>
</div>
Listing 14-14.Providing Validation Feedback in the template.html File in the src/app Folder
这些样式为内容已被编辑且有效(因此属于ng-dirty和ng-valid类)和内容无效(因此属于ng-dirty和ng-invalid类)的input元素设置绿色和红色边框。使用ng-dirty类意味着元素的外观不会改变,直到用户输入一些内容。
Angular 在每次击键或焦点改变后验证内容并改变input元素的类成员。浏览器检测到元素的变化并动态应用样式,这在用户向表单输入数据时为用户提供验证反馈,如图 14-9 所示。
图 14-9。
提供验证反馈
当我开始输入时,input元素显示为无效,因为没有足够的字符来满足minlength属性。一旦有五个字符,边框为绿色,表示数据有效。当我键入字符2时,边框再次变成红色,因为pattern属性被设置为只允许字母和空格。
Tip
如果您查看图 14-9 中页面顶部的 JSON 数据,您会看到数据绑定仍然在更新,即使数据值无效。验证与数据绑定一起运行,在没有检查整个表单是否有效的情况下,您不应该处理表单数据,如“验证整个表单”一节中所述。
显示字段级验证消息
使用颜色来提供验证反馈告诉用户有问题,但是并没有提供用户应该做什么的任何指示。ngModel指令提供了对它所应用的元素的验证状态的访问,这可以用来向用户显示指导。清单 14-15 使用ngModel指令提供的支持,为应用于input元素的每个属性添加验证消息。
<style>
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
</style>
<div class="m-2">
<div class="bg-info p-2 mb-2">Model Data: {{jsonProduct}}</div>
<form novalidate (ngSubmit)="addProduct(newProduct)">
<div class="form-group">
<label>Name</label>
<input class="form-control"
name="name"
[(ngModel)]="newProduct.name"
#name="ngModel"
required
minlength="5"
pattern="^[A-Za-z ]+$" />
<ul class="text-danger list-unstyled" *ngIf="name.dirty && name.invalid">
<li *ngIf="name.errors.required">
You must enter a product name
</li>
<li *ngIf="name.errors.pattern">
Product names can only contain letters and spaces
</li>
<li *ngIf="name.errors.minlength">
Product names must be at least
{{name.errors.minlength.requiredLength}} characters
</li>
</ul>
</div>
<button class="btn btn-primary" type="submit">
Create
</button>
</form>
</div>
Listing 14-15.Adding Validation Messages in the template.html File in the src/app Folder
为了让验证工作,我必须创建一个模板引用变量来访问表达式中的验证状态,我是这样做的:
...
<input class="form-control" name="name" [(ngModel)]="newProduct.name"
#name="ngModel" required minlength="5" pattern="^[A-Za-z ]+$"/>
...
我创建了一个名为name的模板引用变量,并将其值设置为ngModel。使用ngModel值有点令人困惑:这是由ngModel指令提供的一个特性,用于访问验证状态。一旦你阅读了第 15 和第十六章,这将更有意义,在这两章中,我解释了如何创建自定义指令,你将看到它们如何提供对其特性的访问。对于本章来说,知道为了显示验证消息,您需要创建一个模板引用变量并将其分配给ngModel来访问input元素的验证数据就足够了。分配给模板参考变量的对象定义了表 14-6 中描述的属性。
表 14-6。
验证对象属性
|名字
|
描述
|
| --- | --- |
| path | 此属性返回元素的名称。 |
| valid | 如果元素的内容有效,该属性返回true,否则返回false。 |
| invalid | 如果元素的内容无效,该属性返回true,否则返回false。 |
| pristine | 如果元素的内容没有改变,这个属性返回true。 |
| dirty | 如果元素的内容已经改变,这个属性返回true。 |
| touched | 如果用户已经访问了元素,这个属性返回true。 |
| untouched | 如果用户没有访问过元素,这个属性返回true。 |
| errors | 此属性返回一个对象,该对象的属性对应于存在验证错误的每个属性。 |
| value | 该属性返回元素的value,它在定义自定义验证规则时使用,如“创建自定义表单验证器”一节所述。 |
清单 14-15 以列表形式显示验证信息。只有当至少有一个验证错误时,才应该显示该列表,所以我对ul元素应用了ngIf指令,并使用了一个使用dirty和invalid属性的表达式,如下所示:
...
<ul class="text-danger list-unstyled" *ngIf="name.dirty && name.invalid">
...
在ul元素中,有一个li元素对应于每个可能发生的验证错误。每个li元素都有一个使用表 14-6 中描述的errors属性的ngIf指令,如下所示:
...
<li *ngIf="name.errors.required">You must enter a product name</li>
...
只有当元素的内容没有通过required验证检查时,才会定义errors.required属性,这将li元素的可见性与验证检查的结果联系起来。
Using the Safe Navigation Property With Forms
只有当存在验证错误时,才会创建errors属性,这就是为什么我要在ul元素的表达式中检查invalid属性的值。另一种方法是使用安全导航属性,该属性在模板中用于导航一系列属性,如果其中一个属性返回null,则不会产生错误。下面是定义清单 14-15 中模板的另一种方法,它不检查valid属性,而是依赖于安全导航属性:
...
<ul class="text-danger list-unstyled" *ngIf="name.dirty">
<li *ngIf="name.errors?.required">
You must enter a product name
</li>
<li *ngIf="name.errors?.pattern">
Product names can only contain letters and spaces
</li>
<li *ngIf="name.errors?.minlength">
Product names must be at least
{{name.errors.minlength.requiredLength}} characters
</li>
</ul>
...
如果属性是null或undefined,在属性名后添加一个?字符告诉 Angular 不要试图访问任何后续的属性或方法。在这个例子中,我在errors属性后应用了?字符,这意味着如果error属性没有被定义,Angular 不会尝试读取required、pattern或minlength属性。
由errors对象定义的每个属性返回一个对象,该对象的属性提供了为什么内容没有通过属性验证检查的细节,这可以用来使验证消息对用户更有帮助。表 14-7 描述了为每个属性提供的error属性。
表 14-7。
Angular 形式验证错误描述属性
|名字
|
描述
|
| --- | --- |
| required | 如果required属性已经应用于 input 元素,则该属性返回true。这不是特别有用,因为这可以从required属性存在的事实中推断出来。 |
| minlength.requiredLength | 该属性返回满足minlength属性所需的字符数。 |
| minlength.actualLength | 该属性返回用户输入的字符数。 |
| pattern.requiredPattern | 该属性返回使用pattern属性指定的正则表达式。 |
| pattern.actualValue | 此属性返回元素的内容。 |
这些属性不会直接显示给用户,用户不太可能理解包含正则表达式的错误消息,尽管它们在开发过程中对解决验证问题很有用。例外情况是minlength.requiredLength属性,它有助于避免重复分配给元素上的minlength属性的值,如下所示:
...
<li *ngIf="name.errors.minlength">
Product names must be at least {{name.errors.minlength.requiredLength}} characters
</li>
...
总的结果是一组验证消息,一旦用户开始编辑input元素,这些消息就会显示出来,并且会改变以反映每个新的击键,如图 14-10 所示。
图 14-10。
显示验证消息
使用组件显示验证消息
在复杂的表单中,为所有可能的验证错误包含单独的元素很快就会变得冗长。一个更好的方法是向组件添加一些逻辑来准备方法中的验证消息,然后可以通过模板中的ngFor指令向用户显示这些消息。清单 14-16 显示了一个组件方法的添加,该方法接受一个input元素的验证状态并产生一个验证消息数组。
import { ApplicationRef, Component } from "@angular/core";
import { Model } from "./repository.model";
import { Product } from "./product.model";
@Component({
selector: "app",
templateUrl: "template.html"
})
export class ProductComponent {
model: Model = new Model();
getProduct(key: number): Product {
return this.model.getProduct(key);
}
getProducts(): Product[] {
return this.model.getProducts();
}
newProduct: Product = new Product();
get jsonProduct() {
return JSON.stringify(this.newProduct);
}
addProduct(p: Product) {
console.log("New Product: " + this.jsonProduct);
}
getValidationMessages(state: any, thingName?: string) {
let thing: string = state.path || thingName;
let messages: string[] = [];
if (state.errors) {
for (let errorName in state.errors) {
switch (errorName) {
case "required":
messages.push(`You must enter a ${thing}`);
break;
case "minlength":
messages.push(`A ${thing} must be at least
${state.errors['minlength'].requiredLength}
characters`);
break;
case "pattern":
messages.push(`The ${thing} contains
illegal characters`);
break;
}
}
}
return messages;
}
}
Listing 14-16.Generating Validation Messages in the component.ts File in the src/app Folder
getValidationMessages方法使用表 14-6 中描述的属性为每个错误生成验证消息,并以字符串数组的形式返回。为了使这段代码尽可能广泛地适用,该方法接受一个值,该值描述了一个input元素打算从用户那里收集的数据项,然后该数据项用于生成错误消息,如下所示:
...
messages.push(`You must enter a ${thing}`);
...
这是 JavaScript 字符串插值特性的一个例子,它允许像模板一样定义字符串,而不必使用+操作符来包含数据值。注意,模板字符串是用反斜杠字符表示的(字符```ts,而不是普通的 JavaScript 字符')。如果调用方法时没有收到参数,getValidationMessages方法默认使用path属性作为描述性字符串,如下所示:
...
let thing: string = state.path || thingName;
...
```ts
清单 14-17 展示了如何在模板中使用`getValidationMessages`为用户生成验证错误消息,而不需要为每个消息定义单独的元素和绑定。
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
- {{error}}
Listing 14-17.Getting Validation Messages in the template.html File in the src/app Folder
没有视觉上的变化,但是可以使用相同的方法为多个元素生成验证消息,这导致了一个更简单的模板,更易于阅读和维护。
### 验证整个表单
显示单个字段的验证错误消息非常有用,因为它有助于强调需要修复的问题。但是验证整个表单也很有用。在用户尝试提交表单之前,一定要注意不要让错误消息淹没用户,此时任何问题的摘要都是有用的。在准备阶段,清单 14-18 向组件添加了两个新成员。
import { ApplicationRef, Component } from "@angular/core"; import { NgForm } from "@angular/forms"; import { Model } from "./repository.model"; import { Product } from "./product.model";
@Component({ selector: "app", templateUrl: "template.html" }) export class ProductComponent { model: Model = new Model();
// ...other methods omitted for brevity...
formSubmitted: boolean = false;
submitForm(form: NgForm) {
this.formSubmitted = true;
if (form.valid) {
this.addProduct(this.newProduct);
this.newProduct = new Product();
form.reset();
this.formSubmitted = false;
}
}
}
Listing 14-18.Enhancing the Component in the component.ts File in the src/app Folder
`formSubmitted`属性将用于指示表单是否已经提交,并将用于防止整个表单的验证,直到用户尝试提交。
当用户提交表单并接收一个`NgForm`对象作为参数时,将调用`submitForm`方法。此对象表示表单并定义一组验证属性;这些属性用于描述表单的整体验证状态,例如,如果表单包含的任何元素存在验证错误,那么`invalid`属性将为`true`。除了 validation 属性,`NgForm`还提供了`reset`方法,该方法重置表单的验证状态,并将其返回到原始状态。
其效果是,当用户执行提交时,整个表单将被验证,如果没有验证错误,在表单被重置之前,一个新的对象将被添加到数据模型中,以便可以再次使用它。清单 14-19 显示了利用这些新特性和实现表单范围验证所需的模板更改。
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
<div class="bg-danger text-white p-2 mb-2"
*ngIf="formSubmitted && form.invalid">
There are problems with the form
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control"
name="name"
[(ngModel)]="newProduct.name"
#name="ngModel"
required
minlength="5"
pattern="^[A-Za-z ]+$" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || name.dirty) && name.invalid">
<li *ngFor="let error of getValidationMessages(name)">
{{error}}
</li>
</ul>
</div>
<button class="btn btn-primary" type="submit">
Create
</button>
Listing 14-19.Performing Form-Wide Validation in the template.html File in the src/app Folder
`form`元素现在定义了一个名为`form`的引用变量,它已经被赋值给`ngForm`。这就是`ngForm`指令如何通过我在第十五章中描述的过程提供对其功能的访问。然而,现在重要的是要知道整个表单的验证信息可以通过`form`引用变量来访问。
清单还更改了`ngSubmit`绑定的表达式,以便它调用控制器定义的`submitForm`方法,传入模板变量,如下所示:
...
...
这个对象作为`submitForm`方法的参数接收,用于检查表单的验证状态,并重置表单,以便可以再次使用。
清单 14-19 还添加了一个`div`元素,该元素使用组件的`formSubmitted`属性和`valid`属性(由`form`模板变量提供),以便在表单包含无效数据时显示一条警告消息,但仅在表单提交之后。
此外,`ngIf`绑定已经更新,可以显示字段级验证消息,这样当表单提交后,即使元素本身没有被编辑,它们也会显示出来。结果是一个验证摘要,只有当用户提交包含无效数据的表单时才会显示,如图 14-11 所示。

图 14-11。
显示验证摘要消息
#### 显示摘要验证消息
在复杂的表单中,向用户提供必须解决的所有验证错误的摘要会很有帮助。分配给`form`模板引用变量的`NgForm`对象通过名为`controls`的属性提供对单个元素的访问。此属性返回一个对象,该对象具有表单中每个单独元素的属性。例如,示例中有一个代表`input`元素的`name`属性,该属性被分配了一个代表该元素的对象,并定义了可用于单个元素的相同验证属性。在清单 14-20 中,我为组件添加了一个方法,该方法接收分配给表单元素的模板引用变量的对象,并使用其`controls`属性为整个表单生成一个错误消息列表。
import { ApplicationRef, Component } from "@angular/core"; import { NgForm } from "@angular/forms"; import { Model } from "./repository.model"; import { Product } from "./product.model";
@Component({ selector: "app", templateUrl: "template.html" }) export class ProductComponent { model: Model = new Model();
// ...other methods omitted for brevity...
getFormValidationMessages(form: NgForm): string[] {
let messages: string[] = [];
Object.keys(form.controls).forEach(k => {
this.getValidationMessages(form.controls[k], k)
.forEach(m => messages.push(m));
});
return messages;
}
}
Listing 14-20.Generating Form-Wide Validation Messages in the component.ts File in the src/app Folder
对于表单中的每个控件,`getFormValidationMessages`方法通过调用清单 14-16 中定义的`getValidationMessages`方法来构建它的消息列表。`Object.keys`方法从由`controls`属性返回的对象定义的属性中创建一个数组,该属性使用`forEach`方法枚举。
在清单 14-21 中,我使用了这种方法在表单的顶部包含单独的消息,一旦用户单击 Create 按钮,就可以看到这些消息。
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
<div class="bg-danger text-white p-2 mb-2"
*ngIf="formSubmitted && form.invalid">
There are problems with the form
<ul>
<li *ngFor="let error of getFormValidationMessages(form)">
{{error}}
</li>
</ul>
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control"
name="name"
[(ngModel)]="newProduct.name"
#name="ngModel"
required
minlength="5"
pattern="^[A-Za-z ]+$" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || name.dirty) && name.invalid">
<li *ngFor="let error of getValidationMessages(name)">
{{error}}
</li>
</ul>
</div>
<button class="btn btn-primary" type="submit">
Create
</button>
Listing 14-21.Displaying Form-Wide Validation Messages in the template.html File in the src/app Folder
结果是验证消息显示在`input`元素旁边,并在提交后收集在表单顶部,如图 14-12 所示。

图 14-12。
显示整体验证摘要
#### 禁用提交按钮
这一部分的最后一个调整是在用户提交表单后禁用按钮,防止用户再次单击它,直到所有的验证错误都得到解决。这是一种常用的技术,尽管它对应用没有什么影响,应用不会接受来自包含无效值的表单的数据,但它向用户提供了有用的提示,即在验证问题解决之前,他们不能继续操作。
在清单 14-22 中,我在`button`元素上使用了属性绑定,并为`price`属性添加了一个`input`元素,以展示这种方法如何扩展表单中的多个元素。
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
<div class="bg-danger text-white p-2 mb-2"
*ngIf="formSubmitted && form.invalid">
There are problems with the form
<ul>
<li *ngFor="let error of getFormValidationMessages(form)">
{{error}}
</li>
</ul>
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control"
name="name"
[(ngModel)]="newProduct.name"
#name="ngModel"
required
minlength="5"
pattern="^[A-Za-z ]+$" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || name.dirty) && name.invalid">
<li *ngFor="let error of getValidationMessages(name)">
{{error}}
</li>
</ul>
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" name="price" [(ngModel)]="newProduct.price"
#price="ngModel" required pattern="^[0-9\.]+$" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || price.dirty) && price.invalid">
<li *ngFor="let error of getValidationMessages(price)">
{{error}}
</li>
</ul>
</div>
<button class="btn btn-primary" type="submit"
[disabled]="formSubmitted && form.invalid"
[class.btn-secondary]="formSubmitted && form.invalid">
Create
</button>
</form>
Listing 14-22.Disabling the Button and Adding an Input Element in the template.html File in the src/app Folder
为了特别强调,当表单已经提交并且包含无效数据时,我使用类绑定将`button`元素添加到`btn-secondary`类中。该类应用了一个引导 CSS 样式,如图 14-13 所示。

图 14-13。
禁用提交按钮
## 使用基于模型的表单
上一节中的表单依赖 HTML 元素和属性来定义组成表单的字段,并应用验证约束。这种方法的优点是熟悉和简单。缺点是大型表单变得复杂且难以维护,每个字段都需要自己的内容块来管理其布局和验证要求,并显示任何验证消息。
Angular 提供了另一种方法,称为*基于模型的表单*,其中表单的细节及其验证是在代码中定义的,而不是在模板中。这种方法可以更好地扩展,但是它需要一些前期工作,并且结果不如在模板中定义一切那样自然。在接下来的小节中,我将建立并应用一个模型来描述表单及其所需的验证。
### 启用基于模型的表单功能
对基于模型的表单的支持需要在应用的 Angular 模块中声明一个新的依赖项,如清单 14-23 所示。
import { NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; import { ProductComponent } from "./component"; import { FormsModule, ReactiveFormsModule } from "@angular/forms";
@NgModule({ imports: [BrowserModule, FormsModule, ReactiveFormsModule], declarations: [ProductComponent], bootstrap: [ProductComponent] }) export class AppModule {}
Listing 14-23.Enabling Model-Based Forms in the app.module.ts File in the src/app Folder
基于模型的表单特性是在名为`ReactiveFormsModule`的模块中定义的,该模块是在本章开始时添加到项目中的`@angular/forms` JavaScript 模块中定义的。
### 定义表单模型类
我将从定义描述表单的类开始,这样我就可以让模板尽可能简单。您不必完全遵循这种方法,但是如果您打算采用基于模型的表单,在模型中处理尽可能多的表单并最小化模板的复杂性是有意义的。我在`src/app`文件夹中添加了一个名为`form.model.ts`的文件,并添加了清单 14-24 中所示的代码。
import { FormControl, FormGroup, Validators } from "@angular/forms";
export class ProductFormControl extends FormControl { label: string; modelProperty: string;
constructor(label:string, property:string, value: any, validator: any) {
super(value, validator);
this.label = label;
this.modelProperty = property;
}
}
export class ProductFormGroup extends FormGroup {
constructor() {
super({
name: new ProductFormControl("Name", "name", "", Validators.required),
category: new ProductFormControl("Category", "category", "",
Validators.compose([Validators.required,
Validators.pattern("^[A-Za-z ]+$"),
Validators.minLength(3),
Validators.maxLength(10)])),
price: new ProductFormControl("Price", "price", "",
Validators.compose([Validators.required,
Validators.pattern("^[0-9\.]+$")]))
});
}
}
Listing 14-24.The Contents of the form.model.ts File in the src/app Folder
清单中定义的两个类扩展了 Angular 用来在后台管理表单及其内容的类。`FormControl`类用于表示表单中的单个元素,比如`input`元素,`FormGroup`类用于管理`form`元素及其内容。
新的子类增加了一些特性,使得以编程方式生成 HTML 表单变得更加容易。`ProductFormControl`类用属性扩展了`FormControl`类,这些属性指定了与`input`元素相关联的`label`元素的文本,以及`input`元素将表示的`Product`类属性的名称。
`ProductFormGroup`类扩展了`FormGroup`。这个类的重要部分是`ProductFormGroup`类的构造函数,它负责建立用于创建和验证表单的模型。`FormGroup`类的构造函数是`ProductFormGroup`的超类,它接受一个对象,该对象的属性名对应于模板中`input`元素的名称,每个元素被分配一个`ProductFormControl`对象来表示它,并指定所需的验证检查。传递给超级构造函数的对象中的第一个属性是最简单的。
... name: new ProductFormControl("Name", "name", "", Validators.required), ...
这个属性叫做`name`,它告诉 Angular 它对应于模板中一个叫做`name`的`input`元素。`ProductFormControl`构造函数的参数指定了将与`input`元素(`Name`)关联的`label`元素的内容、`input`元素将绑定到的`Product`类属性的名称(`name`)、数据绑定的初始值(空字符串)以及所需的验证检查。Angular 在`@angular/forms`模块中定义了一个名为`Validators`的类,该类具有每个内置验证检查的属性,如表 14-8 所述。
表 14-8。
验证程序属性
<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup>
|
名字
|
描述
|
| --- | --- |
| `Validators.required` | 该属性对应于`required`属性,并确保输入一个值。 |
| `Validators.minLength` | 该属性对应于`minlength`属性,并确保最少的字符数。 |
| `Validators.maxLength` | 该属性对应于`maxlength`属性,并确保最大字符数。 |
| `Validators.pattern` | 该属性对应于`pattern`属性,并匹配一个正则表达式。 |
可以使用`Validators.compose`方法组合验证器,以便对单个元素执行多次检查,如下所示:
... category: new ProductFormControl("Category", "category", "", Validators.compose([Validators.required, Validators.pattern("^[A-Za-z ]+$"), Validators.minLength(3), Validators.maxLength(10)])), ...
`Validators.compose`方法接受一组验证器。由`pattern`、`minLength`和`maxLength`验证器定义的构造函数参数对应于属性值。这个元素的总体效果是值是必需的,必须只包含字母字符和空格,并且必须在 3 到 10 个字符之间。
下一步是将生成验证错误消息的方法从组件移到新的表单模型类中,如清单 14-25 所示。这将所有与表单相关的代码放在一起,有助于保持组件尽可能简单。(我还在`ProductFormControl`类的`getValidationMessages`方法中添加了对`maxLength`验证器的验证消息支持。)
import { FormControl, FormGroup, Validators } from "@angular/forms";
export class ProductFormControl extends FormControl { label: string; modelProperty: string;
constructor(label:string, property:string, value: any, validator: any) {
super(value, validator);
this.label = label;
this.modelProperty = property;
}
getValidationMessages() {
let messages: string[] = [];
if (this.errors) {
for (let errorName in this.errors) {
switch (errorName) {
case "required":
messages.push(`You must enter a ${this.label}`);
break;
case "minlength":
messages.push(`A ${this.label} must be at least
${this.errors['minlength'].requiredLength}
characters`);
break;
case "maxlength":
messages.push(`A ${this.label} must be no more than
${this.errors['maxlength'].requiredLength}
characters`);
break;
case "pattern":
messages.push(`The ${this.label} contains
illegal characters`);
break;
}
}
}
return messages;
}
}
export class ProductFormGroup extends FormGroup {
constructor() {
super({
name: new ProductFormControl("Name", "name", "", Validators.required),
category: new ProductFormControl("Category", "category", "",
Validators.compose([Validators.required,
Validators.pattern("^[A-Za-z ]+$"),
Validators.minLength(3),
Validators.maxLength(10)])),
price: new ProductFormControl("Price", "price", "",
Validators.compose([Validators.required,
Validators.pattern("^[0-9\.]+$")]))
});
}
get productControls(): ProductFormControl[] {
return Object.keys(this.controls)
.map(k => this.controls[k] as ProductFormControl);
}
getValidationMessages(name: string): string[] {
return (this.controls['name'] as ProductFormControl).getValidationMessages();
}
getFormValidationMessages() : string[] {
let messages: string[] = [];
Object.values(this.controls).forEach(c =>
messages.push(...(c as ProductFormControl).getValidationMessages()));
return messages;
}
}
Listing 14-25.Moving the Validation Message Methods in the form.model.ts File in the src/app Folder
验证消息的生成方式与以前相同,只是做了一些小的调整,以反映代码现在是表单模型而不是组件的一部分这一事实。
### 使用模型进行验证
现在我有了一个表单模型,我可以用它来验证表单。清单 14-26 显示了组件类是如何更新的,以支持基于模型的表单,并使表单模型类可用于模板。它还删除了生成验证错误消息的方法,这些方法被移到了清单 14-25 中的表单模型类中。
import { ApplicationRef, Component } from "@angular/core"; import { NgForm, FormGroup } from "@angular/forms"; import { Model } from "./repository.model"; import { Product } from "./product.model"; import { ProductFormGroup, ProductFormControl } from "./form.model";
@Component({ selector: "app", templateUrl: "template.html" }) export class ProductComponent { model: Model = new Model(); formGroup: ProductFormGroup = new ProductFormGroup();
getProduct(key: number): Product {
return this.model.getProduct(key);
}
getProducts(): Product[] {
return this.model.getProducts();
}
newProduct: Product = new Product();
get jsonProduct() {
return JSON.stringify(this.newProduct);
}
addProduct(p: Product) {
console.log("New Product: " + this.jsonProduct);
}
formSubmitted: boolean = false;
submitForm() {
Object.keys(this.formGroup.controls)
.forEach(c => this.newProduct[c] = this.formGroup.controls[c].value);
this.formSubmitted = true;
if (this.formGroup.valid) {
this.addProduct(this.newProduct);
this.newProduct = new Product();
this.formGroup.reset();
this.formSubmitted = false;
}
}
}
Listing 14-26.Using a Form Model in the component.ts File in the src/app Folder
清单从`form.model`模块导入了`ProductFormGroup`类,并使用它来定义一个名为`form`的属性,这使得定制表单模型类可以在模板中使用。
清单 14-27 更新模板以使用基于模型的特性来处理验证,替换模板中定义的基于属性的验证配置。
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
<div class="bg-danger text-white p-2 mb-2"
*ngIf="formSubmitted && formGroup.invalid">
There are problems with the form
<ul>
<li *ngFor="let error of formGroup.getFormValidationMessages()">
{{error}}
</li>
</ul>
</div>
<div class="form-group">
<label>Name</label>
<input class="form-control" name="name" formControlName="name" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || formGroup.controls['name'].dirty) &&
formGroup.controls['name'].invalid">
<li *ngFor="let error of formGroup.getValidationMessages('name')">
{{error}}
</li>
</ul>
</div>
<div class="form-group">
<label>Category</label>
<input class="form-control" name="name" formControlName="category" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || formGroup.controls['category'].dirty) &&
formGroup.controls['category'].invalid">
<li *ngFor="let error of formGroup.getValidationMessages('category')">
{{error}}
</li>
</ul>
</div>
<div class="form-group">
<label>Price</label>
<input class="form-control" name="price" formControlName="price" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || formGroup.controls['price'].dirty) &&
formGroup.controls['price'].invalid">
<li *ngFor="let error of formGroup.getValidationMessages('price')">
{{error}}
</li>
</ul>
</div>
<button class="btn btn-primary" type="submit"
[disabled]="formSubmitted && formGroup.invalid"
[class.btn-secondary]="formSubmitted && formGroup.invalid">
Create
</button>
Listing 14-27.Using a Form Model in the template.html File in the src/app Folder
第一个变化是对`form`元素的。使用基于模型的验证需要使用`formGroup`指令,如下所示:
...
...
分配给`formGroup`指令的值是组件的`form`属性,它返回`ProductFormGroup`对象,这是表单验证信息的来源。
接下来的变化是对`input`元素的。单个验证属性和被赋予特殊值`ngForm`的模板变量已经被删除。添加了一个新的`forControlName`属性,使用清单 14-24 中的`ProductFormGroup`中使用的名称,将`input`元素标识到基于模型的表单系统中。
... ...
这个属性允许 Angular 添加和删除`input`元素的验证类。在这种情况下,`formControlName`属性已经被设置为`name`,这告诉 Angular 应该使用特定的验证器来验证这个元素。
... name: new ProductFormControl("Name", "name", "", Validators.required), ...
`FormGroup`类提供了一个`controls`属性,该属性返回它所管理的`FormControl`对象的集合,按名称进行索引。可以从集合中检索单个的`FormControl`对象,或者检查这些对象以获得验证状态,或者用来生成验证消息。
作为清单 14-27 中更改的一部分,我添加了获取数据所需的所有三个`input`元素来创建新的`Product`对象,每个元素都使用验证模型进行检查,如图 14-14 所示。

图 14-14。
使用基于模型的表单验证
### 从模型中生成元素
清单 14-27 中有很多重复。验证属性已经被移到代码中,但是每个`input`元素仍然需要一个内容支持框架来处理它的布局并向用户显示它的验证消息。
下一步是通过使用表单模型生成表单中的元素来简化模板,而不仅仅是验证它们。清单 14-28 展示了如何将标准 Angular 指令与表单模型相结合,以编程方式生成表单。
input.ng-dirty.ng-invalid { border: 2px solid #ff0000 }
input.ng-dirty.ng-valid { border: 2px solid #6bc502 }
<div class="bg-danger text-white p-2 mb-2"
*ngIf="formSubmitted && formGroup.invalid">
There are problems with the form
<ul>
<li *ngFor="let error of formGroup.getFormValidationMessages()">
{{error}}
</li>
</ul>
</div>
<div class="form-group" *ngFor="let control of formGroup.productControls">
<label>{{control.label}}</label>
<input class="form-control"
name="{{control.modelProperty}}"
formControlName="{{control.modelProperty}}" />
<ul class="text-danger list-unstyled"
*ngIf="(formSubmitted || control.dirty) && control.invalid">
<li *ngFor="let error of control.getValidationMessages()">
{{error}}
</li>
</ul>
</div>
<button class="btn btn-primary" type="submit"
[disabled]="formSubmitted && formGroup.invalid"
[class.btn-secondary]="formSubmitted && formGroup.invalid">
Create
</button>
Listing 14-28.Using the Model to Generate the Form in the template.html File in the src/app Folder
这个清单使用了`ngFor`指令来创建表单元素,表单元素使用了由`ProductFormControl`和`ProductFormGroup`模型类提供的描述。每个元素都配置了与清单 14-27 中相同的属性,但是它们的值取自模型描述,这允许模板被简化并且依赖于模型来定义表单元素和它们的验证。
一旦有了一个基本的表单模型,就可以对它进行扩展,以反映应用的需求。例如,您可以添加新元素,扩展`FormControl`子类以包含附加信息(例如`input`元素的`type`属性的值),为字段生成`select`元素,并提供占位符值来帮助指导用户。
## 创建自定义表单验证器
Angular 支持定制表单验证器,它可以用来执行特定于应用的验证策略,而不是内置验证器提供的通用验证。为了演示,我在`src/app`文件夹中添加了一个名为`limit.formvalidator.ts`的文件,并用它来定义清单 14-29 中所示的类。
import { FormControl } from "@angular/forms";
export class LimitValidator {
static Limit(limit:number) {
return (control:FormControl) : {[key: string]: any} => {
let val = Number(control.value);
if (val != NaN && val > limit) {
return {"limit": {"limit": limit, "actualValue": val}};
} else {
return null;
}
}
}
}
Listing 14-29.The Contents of the limit.formvalidator.ts File in the src/app Folder
自定义验证器是创建用于执行验证的函数的工厂。在这种情况下,`LimitValidator`类定义了`Limit`方法,它是`static`,是返回验证函数的工厂。`Limit`方法的参数是应该允许通过验证的最大值。
当 Angular 调用由`Limit`方法返回的验证函数时,它提供一个`FormControl`方法作为参数。清单中的定制验证函数使用`value`属性获取用户输入的值,将其转换为`number`,并与允许的限制进行比较。
验证函数为有效值返回`null`,并为无效值返回一个包含错误详细信息的对象。为了描述验证错误,该对象定义了一个属性,指定哪个验证规则失败了,在本例中是`limit`,并为该属性分配了另一个提供详细信息的对象。`limit`属性返回一个对象,该对象的`limit`属性设置为验证限制,而`actualValue`属性设置为用户输入的值。
### 应用自定义验证程序
清单 14-30 展示了表单模型如何被扩展以支持新的定制验证器类,并将其应用于产品的`price`属性的`input`元素。
import { FormControl, FormGroup, Validators } from "@angular/forms"; import { LimitValidator } from "./limit.formvalidator";
export class ProductFormControl extends FormControl { label: string; modelProperty: string;
constructor(label:string, property:string, value: any, validator: any) {
super(value, validator);
this.label = label;
this.modelProperty = property;
}
getValidationMessages() {
let messages: string[] = [];
if (this.errors) {
for (let errorName in this.errors) {
switch (errorName) {
case "required":
messages.push(`You must enter a ${this.label}`);
break;
case "minlength":
messages.push(`A ${this.label} must be at least
${this.errors['minlength'].requiredLength}
characters`);
break;
case "maxlength":
messages.push(`A ${this.label} must be no more than
${this.errors['maxlength'].requiredLength}
characters`);
break;
case "pattern":
messages.push(`The ${this.label} contains
illegal characters`);
break;
case "limit":
messages.push(`A ${this.label} cannot be more
than ${this.errors['limit'].limit}`);
break;
}
}
}
return messages;
}
}
export class ProductFormGroup extends FormGroup {
constructor() {
super({
name: new ProductFormControl("Name", "name", "", Validators.required),
category: new ProductFormControl("Category", "category", "",
Validators.compose([Validators.required,
Validators.pattern("^[A-Za-z ]+$"),
Validators.minLength(3),
Validators.maxLength(10)])),
price: new ProductFormControl("Price", "price", "",
Validators.compose([Validators.required,
LimitValidator.Limit(100),
Validators.pattern("^[0-9\.]+$")]))
});
}
get productControls(): ProductFormControl[] {
return Object.keys(this.controls)
.map(k => this.controls[k] as ProductFormControl);
}
getValidationMessages(name: string): string[] {
return (this.controls['name'] as ProductFormControl).getValidationMessages();
}
getFormValidationMessages() : string[] {
let messages: string[] = [];
Object.values(this.controls).forEach(c =>
messages.push(...(c as ProductFormControl).getValidationMessages()));
return messages;
}
}
Listing 14-30.Applying a Custom Validator in the form.model.ts File in the src/app Folder
结果是输入到`Price`域的值有一个限制`100`,更大的值显示验证错误信息,如图 14-15 所示。

图 14-15。
自定义验证消息
## 摘要
在这一章中,我介绍了 Angular 使用事件和表单支持用户交互的方式。我解释了如何创建事件绑定,如何创建双向绑定,以及如何使用`ngModel`指令简化它们。我还描述了 Angular 为管理和验证 HTML 表单提供的支持。在下一章,我将解释如何创建自定义指令。