Angular 学习手册第二版(四)
原文:
zh.annas-archive.org/md5/6C06861E49CB1AD699C8CFF7BAC7E048译者:飞龙
第十章:Angular 中的表单
使用表单通常是我们从网络收集数据的方式,以便稍后持久化。我们对表单体验有期望,比如:
-
轻松声明不同类型的输入字段
-
设置不同类型的验证并向用户显示任何验证错误
-
支持不同的策略来阻止提交,如果表单包含错误
处理表单有两种方法:模板驱动表单和响应式表单。没有一种方法被认为比另一种更好;你只需要选择最适合你情况的方法。两种方法之间的主要区别是谁负责什么:
-
在模板驱动的方法中,模板负责创建元素、表单,并设置验证规则,同步是通过双向数据绑定实现的
-
在响应式方法中,
Component类负责创建表单、其元素,并设置验证。
在本章中,我们将:
-
了解模板驱动表单
-
绑定数据模型和表单和输入控件的接口类型
-
使用响应式表单方法设计表单
-
深入了解输入验证的替代方法
-
构建我们自己的自定义验证器
模板驱动表单
模板驱动表单是使用 Angular 设置表单的两种不同方式之一。这种方法完全是在模板中进行设置,非常类似于 AngularJS 中使用的方法。因此,如果您有 AngularJS 的背景,这种方法对您来说将非常熟悉。
将简单表单转换为模板驱动表单
我们定义了以下表单,包括一个form标签,两个input字段和一个button,如下所示:
<form>
<input id="name" name="name" placeholder="first name" required>
<input id="surname" name="surname" placeholder="surname" required>
<button>Save</button>
</form>
在这里,我们明显有两个需要的input字段,因此input元素有required属性。我们还有一个保存按钮。我们对这样一个表单的要求是,在所有必填字段填写完毕之前,不应提交其数据。为了实现这一点,我们需要做两件事:
-
将输入字段的值保存到一个对象中,使用
[(ngModel)] -
只有在没有错误时才提交表单,使用
ngForm指令
现在我们将表单更改为如下所示:
<form #formPerson="ngForm">
<input [(ngModel)]="person.firstName" id="name" name="name"
placeholder="first name" required>
<input [(ngModel)]="person.surname" id="surname" name="surname"
placeholder="surname" required>
<button (click)="submit()" *ngIf="formPerson.form.valid">Save</button> </form>
让我们谈谈我们所做的更改。首先,我们有以下代码片段:
<form (ngSubmit)="save()" #formPerson="ngForm">
我们创建了一个名为formPerson的视图引用,其值为ngForm。这意味着我们有一个对表单的引用。表单视图引用现在包含了许多有趣的属性,这些属性将帮助我们确定表单是否准备好提交。
至于我们所做的第二个改变,我们将输入数据连接到了ngModel:
<input [(ngModel)]="person.name" id="name" name="name"
placeholder="first name" required>
ngModel允许我们对属性创建双向绑定。它被称为香蕉在盒子里,这实际上是一个记忆规则,让你能够记住如何输入它。我们分两步创建它。首先是ngModel,然后我们添加香蕉,括号,就像这样:(ngModel)。之后我们把香蕉放在盒子里。方括号将作为我们的盒子,这意味着我们最终有了[(ngModel)]。记住,它被称为香蕉在盒子里,而不是盒子在香蕉里。
在这里,我们通过使用ngModel指令,确保了输入的值被保存到person.name。
最后,我们使用*ngIf指令装饰了我们的按钮元素,就像这样:
<button *ngIf="formHero.form.valid">Save</button>
我们使用了*ngIf指令来隐藏按钮,如果表单被证明是无效的。正如你所看到的,我们正在利用我们的表单视图引用及其有效属性。如果表单有效,则显示按钮;否则,隐藏它。
这是设置模板驱动表单的基础知识。让我们通过查看一下来深入了解一下:
-
正在呈现的 CSS 是什么,这样我们就可以根据表单状态适当地进行呈现
-
如何检测输入元素上的特定错误
输入字段错误-从 CSS 的角度来看
根据输入元素所处的状态,会分配不同的 CSS 类。让我们看看一个具有必填属性的输入元素,在我们输入任何数据之前。我们期望它告诉我们有什么地方出错了,因为input字段为空,并且我们已经为其添加了required属性:
<input id="name" name="name" placeholder="first name" required ng-reflect-required ng-reflect-name="name" ng-reflect-model class="ng-untouched ng-pristine ng-invalid">
我们可以看到已设置以下类:
-
ng-untouched,这意味着还没有人尝试按提交按钮 -
ng-pristine,这基本上意味着尚未尝试向该字段输入数据。如果您输入一个字符并删除该字符,则它将被设置为false。 -
ng-invalid,这意味着验证器正在反应并指出有错误
在字段中输入一个字符,我们看到ng-pristine消失了。在两个字段中输入一些字符并点击提交,我们看到ng-untouched变成了ng-touched。这也导致ng-invalid变成了ng-valid。
好的,现在我们更好地了解了 CSS 在什么时候会变成什么样,并且可以适当地为我们的组件设置样式。
检测具有命名引用的输入字段上的错误
到目前为止,当我们想知道我们的表单是否有效时,我们一直在查看表单引用。我们可以做得更好,我们可以检测特定输入控件是否有错误。输入控件可能有多个验证器,这意味着我们可能有多个验证错误要显示。那么我们如何检测呢?要完成这个任务,需要采取一些步骤:
我们需要:
-
为每个输入元素创建一个视图引用,并为其分配值
ngModel。 -
给每个元素添加一个
name属性。
让我们更新我们的表单代码,并根据前面的步骤添加视图引用和name属性:
<form #formPerson="ngForm">
<input #firstName="ngModel" [(ngModel)]="person.name" id="name"
name="name" placeholder="first name" required>
<input #surName="ngModel" [(ngModel)]="person.surname" id="surname"
name="surname" placeholder="surname" required>
<button *ngIf="formPerson.form.valid">Save</button> </form>
一旦我们完成了前期工作,就是时候谈谈我们可以检测到哪些错误了。感兴趣的错误有两种类型:
-
一般错误,即指示输入控件有问题,但不指定具体问题是什么
-
特定错误,将指示确切的错误类型,例如,值太短
让我们从一般错误开始:
<input #firstName="ngModel" [(ngModel)]="person.name" id="name"
name="name" placeholder="first name" required> {{ firstName.valid }} // an empty field sets this to false
我们使用我们的视图引用firstName并查询其 valid 属性,该属性指示是否存在错误。
现在来看看其他更详细的错误。要检测更详细的错误,我们使用视图引用上的 errors 对象,并使用 JSON 管道输出整个对象:
{{ firstName.errors | json }} // outputs { required: true }
这意味着我们突然可以知道是否设置了特定错误,因此我们可以决定基于特定错误的存在来显示条件文本,就像这样:
<div *ngIf="firstName.errors && firstName.errors.required">
First name is a required field
</div>
其他特定错误将填充 errors 对象,你需要做的唯一的事情就是知道错误的名称。如果有疑问,可以使用 JSON 管道输出 errors 对象,以找出特定验证器的验证错误名称以及相应的验证错误值。
改进表单
到目前为止,我们已经涵盖了了解表单何时出错以及如何根据特定错误显示文本的基本机制。让我们通过一些更多的例子来扩展这些知识。首先,我们将向我们的输入字段添加更多的验证类型:
<input minlength="3" required #name="ngModel" name="name">
{{ name.errors | json }}
现在我们已经将minlength添加为我们元素的验证规则,除了现有的 required 规则。Required 是优先错误,所以它会首先显示。如果我们输入一些字符,那么 required 错误就会消失。现在它应该显示以下内容:
{"minlength": { "requiredLength": 3, "actualLength": 1 } }
就像 required 错误一样,我们可以仅为此错误显示错误文本,如下所示:
<div *ngIf="name.errors && name.errors.minlength" >
Name value is too short
</div>
已经为我们编写了一些验证规则:
-
required,要求值不能为空 -
requiredTrue,特别要求值为true -
minlength,表示值需要具有一定的最小长度 -
maxlength,表示值不能超过一定长度 -
pattern,强制值遵循RegEx模式 -
nullValidator,检查值不为空 -
compose,如果您想将多个验证器组合成一个,验证规则是取所有提供的验证器的并集的结果
尝试看看这些是否符合您的情况。您可能会发现一些验证规则缺失。如果是这种情况,那么可以通过创建自定义验证器来解决。我们将在本章后面介绍如何构建自定义验证器规则。
在正确的时间显示错误
到目前为止,我们的表单在至少存在一个错误时不显示提交按钮。这里有一些替代方法。有时,当按钮不存在或显示为禁用时,可能会被认为 UI 出现了问题。这与您在其他地方构建 UI 的方式有关。一致的方法更好。因此,我们可以控制表单如何提交的不同方式。
以下是主要方法:
-
当表单中没有错误时显示提交按钮,我们已经知道如何做到这一点。这种方法可能看起来像我们忘记正确设计表单,因为当表单出现错误时,按钮似乎完全消失了。
-
在表单存在错误时禁用提交按钮。如果伴随着显示验证错误,这样做会很好,以避免任何误解为什么它被禁用。
-
只有当没有错误时才启用提交调用,这里的主要区别是提交按钮是可点击的,但提交操作不会发生。这个版本的缺点是让用户感觉好像什么都没有发生。这种方法需要配合显示阻止表单提交的验证错误。
这是你会编写第一种方法的方式。在这里,如果表单无效,我们会隐藏按钮:
<button *ngIf="form.valid">Save</button>
第二种方法涉及将按钮设置为禁用状态。我们可以通过绑定到disabled属性来实现:
<button [disabled]="form.valid">Save</button>
第三种和最后一种方法是创建一个布尔条件,需要返回true才能执行其他语句:
<button (ngSubmit)="form.valid && submit()">Save</button>
响应式表单
对于响应式表单,我们有一种程序化的方法来创建表单元素并设置验证。我们在Component类中设置所有内容,只需在模板中指出我们创建的结构。
在这种方法中涉及的关键类包括:
-
FormGroup,它是一个包含一到多个表单控件的分组 -
FormControl,表示一个输入元素
AbstractControl
FormGroup和FormControl都继承自AbstractControl,其中包含许多有趣的属性,我们可以查看并根据某个状态以不同的方式渲染 UI。例如,您可能希望在从未与表单交互过和已经交互过的表单之间在 UI 上有所区别。还有可能想知道某个控件是否已经被交互过,以了解哪些值将成为更新的一部分。可以想象,有很多情况下了解特定状态是很有趣的。
以下列表包含所有可能的状态:
-
controls,一个通过构造函数new FormGroup(group)添加的FormControl实例列表。 -
value,表示键值对的字典。键是你在创建时给FormControl的引用,值是你在输入控件中输入的内容{ :'<reference>', <value entered> }。 -
dirty,一旦我们在表单中输入了内容,它就被认为是脏的。 -
disabled,表单可以被禁用。 -
pristine,一个没有任何控件被交互的表单。 -
status,一个表示它是否有效的字符串表示,如果无效则显示无效。 -
touched,提交按钮至少被按下一次。 -
untouched,提交按钮尚未被按下。 -
启用,布尔值,表示表单是否启用。 -
有效,如果没有错误,这个是true。 -
无效,与有效相反。
程序化和动态的方法
我们对事情的处理方式是程序化的,我们有两种可能的方法:
-
我们可以创建具有 N 个元素的表单。这意味着我们可以生成完全动态的表单,包括输入控件的种类和数量,以及应该使用的表单。一个典型的例子是创建一个内容管理系统,其中页面和它们的内容完全可以从配置文件或数据库中配置。
-
我们可以创建深层结构。通常我们有一个表单和其中的 N 个元素,但是响应式表单允许我们在表单中嵌套表单。
注意这里FormGroup被称为组而不是Form。这是因为你应该把它看作只是一种分组,而不一定是唯一的。你可以很容易地有这样的结构:
-
人:FormGroup -
姓名:FormControl -
姓氏:FormControl -
年龄:FormControl -
地址:FormGroup -
城市:FormControl -
国家:FormControl
这里我们有一个Person的表示,我们可以看到我们想要单独处理这个人的地址输入,因此有了这种层次结构。
将表单转换为动态表单
FormGroup是由许多表单控件组成的结构。要创建这样的结构,我们需要做以下事情:
-
导入响应式
Forms模块。 -
通过代码实例化尽可能多的
FormControls。 -
将控件放在一个字典中。
-
将字典分配为
FormGroup的输入。 -
将我们的
Form组实例与[formGroup]指令关联。 -
将每个
FormControl实例与[formControlName]指令关联。
第一步是导入模块:
@NgModule({
imports: [ReactiveFormsModule]
})
第二步是创建表单控件。让我们创建两个不同的控件,一个带有验证,一个没有:
const control = new FormControl('some value');
const control2 = new FormControl('other value', Validators.required);
第三步是为此创建一个字典:
const group = {};
group['ctrl1'] = control;
group['ctrl2'] = control2;
第四步是将组分配给formGroup实例:
const formGroup = new FormGroup(group);
你的完整代码应该看起来像这样:
import { FormControl, FormGroup } from '@angular/forms'; import { Component, OnInit } from '@angular/core';
@Component({
selector: 'dynamic', template: ` dynamic
<div [formGroup]="form">
dynamic <input [formControl]="group['ctrl1']" placeholder="name"> </div>`
})
export class DynamicComponent implements OnInit { form:FormGroup; group; constructor() { this.group = {}; this.group['ctrl1'] = new FormControl('start value'); this.form = new FormGroup(this.group); }
ngOnInit() { } }
你的表单 UI 应该看起来像这样。你可以看到,你的起始值被设置为输入控件:
添加带有验证规则的控件
让我们给一个表单控件添加一个验证器:
this.group['ctrl2'] = new FormControl('',Validators.required)
如果你调查一下这个新添加的表单的标记,你会发现它的 CSS 类确实被设置为ng-invalid,因为它的值为空。
接下来的紧要问题是,我如何引用单个元素,以便知道它们可能具有或不具有哪些错误?答案很简单,在您的表单成员下,类型为FormGroup,有一个包含控件的控件字典。其中一个这些控件就像模板表单中的视图引用一样工作:
ctrl2 valid {{ form.controls['ctrl2'].valid }} {{ form.controls['ctrl2'].errors | json }}
如前面的代码片段中所示,我们可以通过form.controls['key']引用单个控件。它具有 valid 和 errors 属性,因此我们可以显示单个错误,就像这样:
<div *ngIf="form.controls['ctrl2'].errors.required">This field is required</div>
重构 - 使代码更加动态
到目前为止,我们已经了解了FormGroup和FormControl以及相关指令的基本机制,但是我们的代码看起来非常静态,让我们来解决这个问题。我们需要有一种数据集,通过循环创建我们的Form控件:
this.questions = [{ Question : 'What is Supermans real name', Key : '1' },{
Question : 'Who is Lukes father', Key : '2' }];
this.questionGroup = {}; this.questions.forEach( qa => { this.questionGroup[qa.Key] = new FormControl('',Validators.required) });
this.dynamicForm = new FormGroup( this.questionGroup );
现在来定义 UI。我们有一个问题列表,我们使用*ngFor来显示:
<form (ngSubmit)="submit()" [formGroup]="dynamicForm"> <div *ngFor="let q of questions"> {{ q.Question }} <input [formControl]="questionGroup[q.Key]" placeholder="fill in answer"> </div>
<button>Save</button>
</form>
我们遍历问题数组,并为[formControl]指令分配适当的控件。从我们的问题实例中,我们还能够输出问题本身。这看起来更加动态。
现在我们只剩下一步,那就是访问用户实际填写的值:
submit() {
console.log( this.dynamicForm.value ) // { "1" : "", "2" : "Darth" }
}
这给了我们一个控件引用的字典,以及用户在按下提交按钮时输入的任何值。
更新我们的组件表单模型 - 使用 setValue 和 patchValue
首先,让我们稍微回顾一下如何以编程方式创建表单。我们过去使用字典变量并将其传递给FormGroup构造函数,但我们也可以跳过该变量并在内联中定义字典,就像以下代码中一样:
const form = new FormGroup({
name: new FormControl(''),
surname: new FormControl(''),
age: new FormControl
})
要更改表单中的任何值,我们可以使用两种方法之一:
-
setValue(),它将替换所有值 -
patchValue(),它只会更新提到的控件
setValue
使用此方法完全替换所有值。只要提到表单创建时的所有值,那么就没问题,就像这样:
form.setValue({
name: 'chris',
surname: 'noring',
age: 37
})
然而,如果您忘记了一个字段,您将收到一个错误,指示您必须为所有字段指定一个值:
form.setValue({
name: 'chris',
surname: 'noring'
})
如果您只想进行部分更新,那么patchValue()函数就是为您准备的。
patchValue
使用patchValue()就像输入以下内容一样简单:
form.patchValue({
name: 'chris',
surname: 'noring'
})
例如,如果在调用patchValue()之前的值如下:
{
name: 'christoffer',
surname: 'n',
age: 36
}
然后应用form.patchValue(),之前定义的,将导致生成的表单包含以下内容:
{
name: 'chris',
surname: 'noring',
age: 36
}
仔细检查后,我们可以看到姓和名已经更新,但年龄属性保持不变。
清理我们的表单创建并引入 FormBuilder
到目前为止,我们一直是这样创建我们的表单的:
const form = new FormGroup({
name: new FormControl(''),
surname: new FormControl(''),
age: new FormControl,
address: new FormGroup({
city: 'London',
country: 'UK'
})
})
然而,这构成了很多噪音。我们可以使用一个叫做FormBuilder的结构来消除很多噪音。要使用FormBuilder,我们需要执行以下操作:
-
从
@angular/forms导入它。 -
将它注入到构造函数中。
-
使用实例并在
FormBuilder实例上调用 group 函数。
让我们在以下代码片段中展示这一点:
import { FormBuilder } from '@angular/forms'
@Component({
})
export class FormComponent {
formGroup: FormGroup;
constructor(private formBuilder: FormBuilder) {
this.formGroup = this.formBuilder.group({
name :'',
surname :'',
age: 0,
address : this.formBuilder.group({
city: 'London',
country : 'UK'
})
});
}
}
这看起来更容易阅读,我们不必明确处理FormGroup和FormControl数据类型,尽管这是隐式创建的。
有三种不同的方式来为我们的元素指定值:
-
elementName:'',这里默认值被设置为原始值 -
elementName:{value:'',disabled:false},在这里我们将elementName分配给整个对象,对象中的 value 属性是默认值将变为的值 -
elementName:['默认值',<可选验证器>],在这里我们为它分配一个完整的数组,数组中的第一项是默认值,第二到第 N 个值是验证器
以下是使用所有三种方法的代码的样子:
this.dynamicForm2 = this.formBuilder.group({
// set to a primitive fullname: 'chris'**,
** // setting a default value age: { value : 37, disabled: true **},** // complex type 'address' address : this.formBuilder.group({
// default value + x number of validators
**city: ['', Validators.required, Validators.minLength],**
**country: [''] // default value, no validators**
}) });
在这里,我们在前面的后备代码中呈现了提到的字段。正如您所看到的,组对象中的键名称对应于标记中的formControlName属性:
<form (ngSubmit)="submit(dynamicForm2)" [formGroup]="dynamicForm2"> <input formControlName="fullname"> <input formControlName="age"> <div formGroupName='address'>
<input **formControlName="city"**>
<input f**ormControlName="country"**>
</div> <button>Save</button> </form>
但是如何显示特定的错误呢?这很容易,看起来像这样:
<div *ngIf="dynamicForm2.get('address').hasError('required')">
请注意,我们如何通过类dynamicForm2的属性名称引用表单,我们调用get()方法并指定键作为参数,最后,我们调用hasError并要求特定的错误。在这种特殊情况下,地址属性在代码中被定义为由城市和国家组成。像这样指定错误只会告诉我们城市或国家中有一个错误,或者两者都有错误。
构建自定义验证器
有时默认验证器可能无法涵盖应用程序中可能出现的所有情况。幸运的是,编写自定义验证器非常容易。
自定义验证器只是一个需要返回指定错误对象或 null 的函数。Null 表示我们没有错误。
开始定义这样一个函数很容易:
import { AbstractControl, ValidatorFn } from '@angular/forms'; export function minValueValidator(compareToThisValue: number): ValidatorFn { return (control: AbstractControl): {[key: string]: any} => { const lessThan = parseInt( control.value ) < compareToThisValue; return lessThan ? {'minValue'</span>: {value: control.value}} : null; };
}
在这种情况下,我们正在构建一个minValue验证器。外部函数接受我们将要比较的参数。我们返回一个测试控件值与我们比较值的内部函数。如果我们的条件为true,我们会引发一个错误,其中我们返回一个错误结构{ 'minValue' : { value : control.value } },或者如果为false,我们返回 null。
要使用这个新的验证器,我们只需要在我们的组件文件中导入它并输入以下内容:
formBuilder.group({
age : [0, minValueValidator(18)]
})
要在模板中显示错误消息,如果出现此错误,我们只需写入:
<div *ngIf="form.get('age').hasError('minValue')">
You must be at least 18
</div>
观察状态变化和响应
到目前为止,我们已经看到了如何使用FormBuilder以编程方式创建表单,以及如何在代码中指定所有字段及其验证。我们还没有真正讨论为什么响应式表单被称为reactive。事实是,当表单中的输入字段发生更改时,我们可以监听并做出相应的反应。适当的反应可能是禁用/启用控件,提供视觉提示或其他操作。你明白了。
这是如何实现的呢?这是通过我们声明的字段与它们连接的两个可观察对象statusChanges和valueChanges的事实而实现的。通过订阅它们,我们能够监听更改并进行前面段落中提到的建议更改。
一个有趣的案例,用于演示我们如何观察状态变化的情况是登录。在登录场景中,我们希望用户输入他们的用户名和密码,然后按下按钮。在这种情况下,我们应该能够支持用户:
-
如果输入的用户名有问题,可能为空或以不允许的方式输入,显示提示
-
如果没有输入所有必填字段,则禁用登录按钮。
如果用户名没有正确构造,我们选择显示提示。除非用户已经开始输入值,我们不想显示提示。
让我们分步进行。首先构建我们的组件,如下所示:
@Component({
template: `
<div class="form-group" [formGroup]="loginForm">
<input type="text"
class="form-control"
placeholder="Your username">
<p *ngIf="showUsernameHint"class="help-block">
That does not look like a proper username
</p>
</div>
`
})
export class LoginComponent {
loginForm: FormGroup;
notValidCredentials: boolean = false;
showUsernameHint: boolean = false;
constructor(
formBuilder: FormBuilder,
private router: Router
) {
this.loginForm = formBuilder.group({
username: ['', Validators.compose([
Validators.required,
Validators.email])],
password: ['', Validators.required]
});
}
}
在这里,我们设置了一个具有两个输入字段的表单,一个username字段和一个password字段。我们还声明了这两个字段是必填的,通过我们设置的验证规则的方式。下一步是设置对用户名字段的订阅,以便我们可以收到有关其更改的通知。需要进行的更改已用粗体标出:
@Component({
template : `
<div class="form-group">
<input type="text"
class="form-control"
placeholder="Your username"
[formControlName]="username">
<p *ngIf="showUsernameHint"class="help-block">
That does not look like a proper username
</p>
</div>`
})
export class LoginComponent {
loginForm: FormGroup;
notValidCredentials: boolean = false;
showUsernameHint: boolean = false;
constructor(
formBuilder: FormBuilder,
private router: Router
) {
this.loginForm = formBuilder.group({
username: ['', Validators.compose([
Validators.required,
Validators.email])],
password: ['', Validators.required]
});
const username:AbstractControl = this.loginForm.get('username');
username.valueChanges.subscribe(value => {
this.showUsernameHint = (username.dirty &&
value.indexOf('@') < 0);
});
}
}
我们可以看到,我们分两步来做这件事。首先,我们通过向loginForm请求来创建一个对用户名字段的引用,如:this.loginForm.controls['username']。然后,我们通过调用username.subscribe(...)来设置对表单控件引用username:FormControl的订阅。在.subscribe()内部,我们评估是否将this.showUsernameHint变量设置为true或false。逻辑是,如果缺少@字符并且用户已经开始输入,则显示视觉提示。将提示设置为true将触发模板显示提示文本,如下所示:
<p *ngIf="showUsernameHint"class="help-block">
That does not look like a proper username
</p>
当然,创建登录组件还有更多内容,比如将用户名和密码发送到端点并将用户重定向到适当的页面等,但这段代码展示了响应式的特性。希望这清楚地传达了如何利用表单的响应式特性并做出相应的响应。
总结
在本节中,我们已经了解到 Angular 为创建表单提供了两种不同的方式,即模板驱动和响应式表单,并且不能说其中任何一种方法比另一种更好。我们还介绍了不同类型的验证存在,并且现在知道如何创建自己的验证。
在下一章中,我们将看看如何利用 Angular Material 框架来美化我们的应用程序,使其看起来更加美观。Angular Material 带有许多组件和样式,可以直接在你的下一个项目中使用。所以,让我们给你的 Angular 项目一些应有的关注。
第十一章:角材料
当您开发应用程序时,您需要一个清晰的策略来创建您的用户界面。该策略应包括使用良好的对比色;具有一致的外观和感觉;它应该在不同的设备和浏览器上运行良好;以及许多其他要求。简而言之,在今天的 Web 平台上构建应用程序时,对用户界面和用户体验有很多要求。难怪大多数开发人员认为 UI/UX 是一项艰巨的任务,因此转向可以减轻大部分工作的 UI 框架。有一些框架比其他框架更常用,即:
-
Twitter Bootstrap
-
基础
-
HTML5 快速入门
然而,有一个新的设计语言,Material Design。本章将尝试解释什么是 Material Design,并将查看哪些框架实现了 Material Design 的原则,我们将特别关注为 Angular 特别制作的 Angular Material。
在本章中,我们将:
-
了解 Material Design 是什么以及它的一点历史
-
了解更多已知的实现
-
深入了解 Angular Material 及其组成部分
-
使用 Angular Material 构建 Angular 应用程序
Material Design
Material Design 是谷歌在 2014 年开发的设计语言。谷歌表示,他们的新设计语言是基于纸张和墨水的。Material Design 的创作者试图用以下引用来解释他们试图达到的目标:
“我们挑战自己为我们的用户创建一种视觉语言,将好设计的经典原则与技术和科学的创新和可能性相结合。”
他们进一步解释了目标:
-
开发一个统一的基础系统,使跨平台和设备尺寸的体验统一
-
移动规则是基本的,但触摸、语音、鼠标和键盘都是一流的输入方法
很明显,设计语言希望在各种设备上对用户界面和用户交互的外观和感觉只有一个看法。此外,输入在用户界面的整体体验中起着重要作用。
Material Design 基于三个原则:
-
材料是隐喻
-
大胆、图形、有意
-
动作赋予意义
总的来说,可以说设计语言背后有很多理论,而且关于这个主题有很好的文档,如果你希望深入了解,可以在官方文档网站material.io/.找到更多信息。
现在,如果你是一名设计师并且关心图形理论,这一切可能非常有趣。我们猜想你正在阅读这本书的人是一名开发者,现在你可能会问自己一个问题。那又怎样,为什么我要在意呢?
每当谷歌着手构建某物时,它都会变得很大。并非所有东西都能经受时间的考验,但是这背后有足够的实力,谷歌已经在许多自己的产品上使用了这一设计,如 Firebase、Gmail、Google Plus 等。
当然,单独的设计语言并不那么有趣,至少对于开发者来说是这样,这就引出了我们下一节的内容,即基于 Material Design 原则的多种实现。在接下来的部分中会详细介绍。
已知的实现
对于开发者来说,设计是为了理清你的代码并为用户提供良好的视觉和可用性体验。目前,Material Design 存在三种主要的实现。
它们是:
-
Materialize,
materializecss.com/about.html.GitHub 上的 24,000 多个星星告诉你它被广泛使用。它可以作为独立使用,但也可以与 AngularJS 和 React 等框架进行绑定。它提供导航元素、组件等,是一个不错的选择。 -
AngularJS Material,
material.angularjs.org/latest/,是谷歌专为 AngularJS 开发的实现。它非常强大,包括主题、导航元素、组件和指令。 -
Angular Material,
material.angular.io/,是谷歌专为 Angular 构建的实现。我们将在本章的其余部分重点介绍这个实现。
如果你是 Angular 开发者,那么 AngularJS Material 或 Materialize 都是有效的选择,因为后者具有 AngularJS 绑定,可以在krescruz.github.io/angular-materialize/找到。Materialize 可以被许多其他应用程序框架使用,是这三种选择中最通用的。Angular Material 专为 Angular 而设计。
现在是时候详细了解 Angular Material 了。
Angular Material
该库是为新的 Angular 实现 Material Design 而开发的。它仍在不断发展中,但已经有足够的组件可以使用。您应该知道它仍处于 Beta 阶段,因此如果考虑采用它,需要一定的谨慎。官方文档可在material.angular.io找到,存储库可在github.com/angular/material2找到。这是一个相当受欢迎的库,拥有 10,000 多个星标。
Angular Material 通过以下要点来宣传自己:
-
从零到应用的冲刺:目的是让您作为应用开发者能够轻松上手。目标是尽量减少设置的工作量。
-
快速一致:这意味着性能是一个主要关注点,同时也保证在所有主要浏览器上运行良好。
-
多功能:这强调了两个主要点,应该有大量易于定制的主题,还有很好的本地化和国际化支持。
-
为 Angular 优化:它是由 Angular 团队自己构建的,这意味着对 Angular 的支持是一个重要的优先事项。
该框架包括以下部分:
-
组件:这意味着有大量的构件可帮助您取得成功,如不同类型的输入、按钮、布局、导航、模态框和展示表格数据的不同方式。
-
主题:该库预装了主题,但也很容易引用外部主题。还有一个主题指南,如果您想创建自定义主题,可以在
material.angular.io/guide/theming.找到。 -
图标:Material Design 带有超过 900 个图标,因此您很可能会找到所需的图标。要查看所有图标,请访问
material.io/icons/. -
手势:UI 中并非所有操作都是按钮点击。由于 Material Design 支持移动端,因此通过 HammerJs 库支持移动手势。
安装
我知道你可能迫不及待地想要尝试一下,所以让我们不要再拖延了。首先,我们需要安装它。让我们首先确保我们有一个准备好安装它的 Angular 项目,通过告诉 Angular CLI 为我们搭建一个项目。
ng new AngularMaterialDemo
现在是时候安装 Angular Material 所需的依赖项了:
npm install --save @angular/material @angular/cdk
现在让我们也安装支持动画。这对它的工作并不是绝对必要的,但我们想要一些很酷的动画,对吧?
需要安装以下内容:
npm install @angular/animations
因此,我们已经安装了 Angular Material,并准备在我们的应用程序中使用它。正如我们从之前的章节中学到的,要使用外部的 Angular 模块,我们需要导入它们。一旦完成了这一步,我们就可以开始使用这些模块公开导出的构造。实际上,有许多要导入的模块,取决于我们的需求,例如,每个控件都有自己的模块,但动画只有一个。
我们的第一个 Angular Material 应用程序
到目前为止,您已经使用 Angular CLI 搭建了一个 Angular 应用程序。您已经安装了必要的节点模块,并迫不及待地想要在 Angular Material 中使用这些构造。我们期望我们的 Angular Material 应用程序有两个方面,一些漂亮的渲染以及一些漂亮的动画。要开始使用 UI 控件,比如按钮或复选框,我们需要导入它们对应的模块。为了获得 UI 渲染和动画行为,我们需要添加必要的模块并选择要使用的主题。
让我们从我们需要的模块开始,即BrowserAnimationsModule。要开始使用它,我们导入它并在我们的根模块中注册它,就像这样:
import {
BrowserAnimationsModule
} from '@angular/platform-browser/animations'; @NgModule({
imports: [ BrowserAnimationsModule ]
})
export class AppModule {}
在这一点上,我们实际上还没有添加要使用的 UI 元素,所以让我们把这作为下一个业务顺序。我们的第一个示例将是关于按钮。要使用 Angular Material 按钮,我们需要将MatButtonModule添加到我们的根模块中:
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MatButtonModule } from '@angular/material'; @NgModule({
imports: [
BrowserAnimationsModule,
MatButtonModule
]
})
export class AppModule {}
我们还需要一件事,即主题。如果我们不添加主题,我们将得到一个看起来很无聊的灰色按钮。然而,如果我们有一个主题,我们将得到与 Material Design 相关的所有漂亮的动画。
要添加主题,我们需要在styles.css文件中添加一个条目。这个文件用于为整个应用程序设置 CSS 样式。所以让我们在styles.css中添加必要的行:
@import "~@angular/material/prebuilt-themes/indigo-pink.css";
波浪号运算符~通知 webpack,即为 Angular CLI 提供动力的底层引擎,应将此路径视为 webpack 处理的别名路径,而不仅仅是常规字段路径或 URL
现在我们准备使用我们的第一个 Angular Material UI 元素。我们选择的是 Material Design 按钮。要使用它,我们需要在要在其上实现 Material Design 渲染和行为的元素上添加mat-button属性。
我们从根模块app.module.ts开始,添加以下条目:
@Component({
template : `
<button mat-button>Click me!</button>
`
})
在模板中,通过添加mat-button属性,普通按钮变成了 Material Design 按钮。mat-button是一个指令,为我们的按钮提供了新的外观以及相关的动画。现在点击按钮应该会产生一个漂亮的动画。
这展示了使用 Angular Material 是多么简单,但还有更多,远远不止这些。让我们在接下来的部分讨论大多数组件。
组件概述
Angular Material 包括许多不同类型的组件,包括:
-
表单控件:通过表单控件,我们指的是我们用来从表单收集数据的任何类型的控件,比如自动完成、复选框、普通输入、单选按钮、选择列表等。
-
导航:通过导航,我们指的是菜单、侧边栏或工具栏等。
-
布局:布局指的是我们如何在页面上放置数据,比如使用列表、卡片或选项卡。
-
按钮:这些就是它们听起来的样子,你可以按的按钮。但是你可以使用许多不同的按钮,比如图标按钮、凸起按钮等。
-
弹出窗口和模态框:这些是特定的窗口,阻止任何用户交互,直到您与弹出窗口或模态框进行交互为止。
-
数据表:这只是以表格方式显示数据。您需要什么样的表格取决于您的数据是庞大的并且需要分页,还是需要排序,或者两者兼而有之。
按钮
到目前为止,我们的应用程序只包括一个简单的按钮,我们是这样声明的:
<button mat-button>simple button</button>
然而,还有很多其他类型的按钮,包括:
-
mat-button,这是一个普通的按钮 -
mat-raised-button,这是一个带有阴影显示的凸起按钮,以表示其凸起状态 -
mat-icon-button,这个按钮是用来与图标一起使用的 -
mat-fab,这是一个圆形按钮 -
mat-button-toggle,这是一个指示是否已按下的按钮,具有按下/未按下状态
按钮的标记如下:
<button mat-button>Normal button</button> <button mat-raised-button>Raised button</button> <button mat-fab>Fab button</button> <button mat-icon-button>
<mat-icon class="mat-icon material-icons" role="img" aria-hidden="true">home</mat-icon>
Icon button
</button>
<mat-button-toggle>Button toggle</mat-button-toggle>
值得注意的是,我们需要导入MatButtonToggleModule才能使用mat-button-toggle按钮。按钮看起来像下面这样:
要使用这些按钮,我们需要确保导入和注册它们所属的模块。让我们更新我们的根模块,使其看起来像下面这样:
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import {
MatButtonModule,
MatIconModule,
MatButtonToggleModule
} from '@angular/material'**;** import { AppComponent } from './app.component';
@NgModule({
declarations: [ AppComponent
],
imports: [ BrowserModule, BrowserAnimationsModule, MatButtonModule, MatIconModule,
MatButtonToggleModule
], bootstrap: [AppComponent] })
export class AppModule { }
我们可以看到我们需要注册MatIconModule来支持使用mat-icon指令,并且我们还需要注册MatButtonToggleModule来使用<mat-button-toggle> UI 元素,一个切换按钮。
表单控件
表单控件是关于以不同的方式收集输入数据,以便您可以通过调用 HTTP 端点来持久化数据。
Material Design 中有许多不同类型的控件,包括:
-
自动完成:此控件使用户可以在输入字段中开始输入并在输入时显示建议列表。这有助于缩小输入可以接受的可能值。
-
复选框:这是一个经典的复选框,表示一个处于选中或未选中状态的状态。
-
日期选择器:这是一个控件,使用户可以在日历中选择日期。
-
输入:这是一个经典的输入控件。Material Design 通过有意义的动画增强了控件,因此您可以清楚地看到您何时正在输入或不在输入。
-
单选按钮:这是一个经典的单选按钮,就像输入控件一样,Material Design 对此的处理是在编辑时添加动画和过渡,以创造更好的用户体验。
-
选择:这是一个经典的选择列表,提示用户从列表中选择一个或多个项目。
-
滑块:滑块使您可以通过拖动滑块按钮向右或向左增加或减少值。
-
滑动切换:这只是一个复选框,但是一个更好的版本,其中滑块被滑向左边或右边。
输入
输入字段是一个经典的输入字段,您可以在其中设置不同的验证规则。但是,您可以很容易地添加在输入字段上以一种漂亮和反应灵敏的方式显示错误的能力。
为了实现这一点,我们需要:
-
将
formControl与我们的输入字段关联 -
将我们的输入定义为
MatInput并添加验证规则 -
定义一个
mat-error元素和一个何时应该显示的规则
对于第一个项目,我们执行以下操作:
<mat-form-field>
<input matInput placeholder="Name" [formControl]="nameInput">
</mat-form-field>
这为我们设置了一个输入控件和一个formControl的引用,这样我们就可以监听输入的变化。这需要与我们在app.component.ts文件中添加一个引用的代码一起使用,就像这样:
nameInput:FormControl;
constructor() {
this.nameInput = new FormControl();
}
然后,我们需要向输入添加matInput指令,并添加一个验证规则,使其看起来像这样:
<mat-form-field>
<input [formControl]="nameInput" required matInput >
</mat-form-field>
最后,我们添加mat-error元素,并将mat-input-container包装在一个表单元素中。在这一点上,我们需要记住在根模块中包含FormsModule。我们还需要设置一个规则,用*ngIf来确定mat-error元素何时显示:
<form name="person-form">
<mat-input-container>
<input [formControl]="nameInput" required matInput >
<mat-error *ngIf="nameInput.hasError('required')">
Name field is required
</mat-error>
</mat-input-container>
</form>
前面的标记设置了输入元素和何时显示验证规则,但正如前面提到的,我们需要在根模块中包含FormsModule作为最后一步,让我们看看它是什么样子的:
import {FormsModule} from '@angular/forms';
@NgModule({
imports: [FormsModule]
})
export class AppModule {}
所有这些都汇总成以下内容:
当验证错误被触发时,它看起来是这样的:
我们已经介绍了 Angular Material 包含的所有表单控件的一个子集,即自动完成、复选框、日期选择器,最后是展示验证错误的普通输入。还有其他表单控件,如单选按钮、选择器、滑块和滑动切换,我们鼓励您按照自己的节奏进行探索。
自动完成
自动完成的想法是帮助用户缩小输入字段可能具有的可能值。在普通的输入字段中,您只需输入一些内容,希望验证会告诉您输入的内容是否不正确。使用自动完成,您在输入时会看到一个列表。随着您的输入,列表会被缩小,您可以随时决定停止输入,而是从列表中选择一个项目。这是一个时间节省者,因为您不必输入整个项目的名称,它还增强了正确性,因为用户被要求从列表中选择,而不是输入整个内容。
由于这是自动更正的完整行为,这意味着我们需要提供一个可能答案的列表,还需要一个输入框来接收输入。
我们需要按照五个步骤设置这个控件:
-
导入并在根模块中注册所有必要的模块。
-
定义一个包含输入控件的
mat-form-field。 -
定义一个
mat-autocomplete控件,这是可能选项的列表。 -
通过视图引用链接这两个控件。
-
添加一个过滤器,当用户输入时,可以缩小自动完成控件的范围。
让我们从第一步开始,导入所有必要的内容。在这里,我们需要自动完成功能,但由于我们将使用表单,特别是响应式表单,我们还需要该模块。我们还需要一些表单来支持我们打算使用的输入字段:
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { MatButtonModule } from '@angular/material'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatAutocompleteModule } from '@angular/material'; import { ReactiveFormsModule } from '@angular/forms'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'**;**
@NgModule({
declarations: [ AppComponent
],
imports: [ BrowserModule, BrowserAnimationsModule, MatButtonModule, MatIconModule, MatButtonToggleModule, MatAutocompleteModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule
],
providers: [], bootstrap: [AppComponent] })
export class AppModule { }
现在我们准备向app.component.html文件模板添加一些标记:
<mat-form-field>
<input type="text" **matInput** placeholder="jedis" [formControl]="myControl" >
</mat-form-field>
此时,我们已经定义了输入控件并添加了matInput指令。我们还添加了一个formControl引用。我们添加这个引用是为了以后能够监听输入的变化。输入的变化很有趣,因为我们能够对其做出反应并过滤我们的列表,这本质上就是自动完成所做的事情。下一个要做的事情是定义一组值,一旦用户开始输入,我们就需要向他们建议这些值,所以让我们接着做吧:
<mat-autocomplete #auto="matAutocomplete">
<mat-option *ngFor="let jedi of jedis" [value]="jedi"> {{ jedi }}
</mat-option>
</mat-autocomplete>
我们有了列表,但缺少输入字段和建议列表之间的任何连接。在修复之前,我们首先需要查看我们的组件类,并向其添加一些代码以支持先前的标记:
export class AppComponent { myControl: FormControl; jedis = [ 'Luke', 'Yoda', 'Darth Vader', 'Palpatine', 'Dooku', 'Darth Maul'
];
constructor() { this.myControl = new FormControl();
}
}
到目前为止,我们已经分别定义了matInput和mat-autocomplete,现在是将两者连接起来的时候了。我们通过向mat-autocomplete添加一个视图引用,以便matInput可以引用它,就像这样:
<mat-autocomplete #auto="matAutocomplete">
<mat-option *ngFor="let jedi of jedis" [value]="jedi"> {{ jedi }}
</mat-option>
</mat-autocomplete>
并且为了在matInput中引用它,我们引入MatAutocomplete指令,就像这样:
<form action="">
<mat-input-container name="container">
<mat-form-field hintLabel="Max 30 characters"> <input name="input" type="text"
#input
matInput
placeholder="type the name of the jedi" [formControl]="jediControl"
**[matAutocomplete]= "auto"**>
<mat-hint align="end">{{input.value?.length || 0}}/30</mat-hint>
</mat-form-field> </mat-input-container>
</form>
正如您所看到的,matAutocomplete指向auto视图引用,因此当我们将焦点设置到输入字段并开始输入时,列表就会被触发。
在前面的代码中,我们添加了另一个有用的东西,即提示。向输入添加提示是向用户传达应在输入字段中输入什么的好方法。通过添加属性hintLabel,我们能够告诉用户应该输入什么。您甚至可以通过使用mat-hint元素在用户输入时介绍一些提示,让他们知道他们的输入情况如何。让我们仔细看一下刚才完成了我们所描述的工作的前面的代码:
<mat-form-field **hintLabel="Max 30 characters"**>
<input name="input" type="text"
#input
matInput
placeholder="type the name of the jedi" [formControl]="jediControl"
[matAutocomplete]= "auto">
**<mat-hint align="end">{{input.value?.length || 0}}/30</mat-hint>**
</mat-form-field>
尝试在适用的地方使用hintLabel和mat-hint元素,这将极大地帮助您的用户。
如果您正确输入了所有内容,您应该在 UI 中看到类似于这样的东西:
看起来不错!当你将输入聚焦时,列表会显示出来。然而,你会注意到随着你的输入,列表并没有真正被过滤掉。这是因为我们没有捕捉到你在输入控件中输入时的事件。所以让我们接下来做这个。
监听输入变化意味着我们监听我们的表单控件及其valueChanges属性,如下所示:
myControl.valueChanges
如果你仔细看,你会发现这是一个 Observable。这意味着我们可以使用操作符来过滤掉我们不想要的内容。我们对所需内容的定义是以我们在输入框中输入的文本开头的jedis。这意味着我们可以将其完善为如下所示的样子:
import { Component } from '@angular/core'; import { FormControl } from "@angular/forms"; import { Observable } from "rxjs/Observable"; import 'rxjs/add/operator/map'; @Component({
selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] })
export class AppComponent { title = 'app'; myControl: FormControl; jedis = [ 'Luke', 'Yoda', 'Darth Vader', 'Palpatine', 'Dooku', 'Darth Maul'
];
filteredJedis$: Observable<string[]>; constructor() { this.myControl = new FormControl(); this.filteredJedis$ = this.myControl .valueChanges .map(input => this.filter(input**));** }
filter(key: string): Array<string> { return this.jedis.filter(jedi => jedi.startsWith(key)); }
}
现在我们只需要改变我们的模板,让mat-option看向filteredJedis而不是jedis数组,如下所示:
<mat-autocomplete #auto="matAutocomplete">
<mat-option *ngFor="let jedi of **filteredJedis$ | async**" [value]="jedi"> {{ jedi }}
</mat-option>
</mat-autocomplete>
测试一下,我们看到它似乎是有效的。
复选框
这是一个经典的复选框,包含选中、未选中和未确定的状态。使用起来非常简单,但你需要导入一些模块来使用它,如下所示:
import { MatCheckboxModule } from @angular/material/checkbox;
@NgModule({
imports: [MatCheckboxModule]
})
标记应该是这样的:
<mat-checkbox [checked]="propertyOnTheComponent" >Check me<mat-checkbox>
因此,基本上,只需将<mat-checkbox>添加为元素名称,并确保将checked属性绑定到我们组件上的属性。
日期选择器
通常情况下,使用日期选择器,你可以做的远不止从弹出日历中选择日期。你可以禁用日期范围,格式化日期,按年度和月度显示日期等等。我们只会探讨如何开始并运行它,但我们鼓励你在material.angular.io/components/datepicker/overview探索此控件的文档。
首先,我们需要导入必要的模块:
import {
MatDatepickerModule,
MatNativeDateModule } from '@angular/material';
@NgModule({
imports: [MatDatepickerModule, MatNativeDateModule]
})
对于标记,我们需要做以下事情:
-
定义一个带有
matInput指令的输入。选定的日期将放在这里。 -
定义一个
<mat-datepicker>元素。这是弹出式日历。 -
创建两个控件之间的连接。
对于第一个要点,我们在标记中声明它,如下所示:
<mat-form-field>
<input matInput placeholder="Choose a date"> </mat-form-field>
我们可以看到,我们通过使用formControl指令指出了在我们组件中称为 input 的formControl实例。我们还添加了matInput指令,以赋予我们的输入字段漂亮的材料外观和感觉。
对于第二个任务,我们定义<mat-datepicker>元素,如下所示:
<mat-datepicker></mat-datepicker>
现在我们需要建立它们之间的连接,就像我们在自动完成控件中所做的那样,我们在<mat-datepicker>元素中定义一个视图引用picker,并通过将该引用分配给输入元素中的matDatepicker指令来引用它,所以它看起来像下面这样:
<div>
<mat-form-field>
<input matInput [matDatepicker]="picker"> <mat-datepicker-toggle matSuffix [for]="picker">
</mat-datepicker-toggle> <mat-datepicker #picker></mat-datepicker> </mat-form-field>
</div>
因此,总之,我们在mat-datepicker元素中添加了一个视图引用,并通过将其分配给输入元素中的[matDatePicker]指令来引用该引用。
我们还添加了一个按钮,用于切换日历的可见性。我们通过使用<mat-datepicker-toggle>元素并将其分配给picker视图引用来实现这一点:
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
最后,您的创建现在应该看起来像下面这样:
导航
导航是我们在应用程序中移动的方式。我们有不同的方式来做到这一点,比如点击链接或者点击菜单项。Angular Material 为此提供了三个组件:
-
菜单:这是一个弹出列表,您可以从中选择许多不同的菜单选项
-
侧边栏:这个组件就像一个停靠在页面左侧或右侧的菜单,并以应用程序内容的遮罩形式呈现在应用程序上
-
工具栏:这是用户可以使用的常用操作的典型工具栏
在这一部分,我们将展示使用菜单的完整示例,但我们鼓励您继续探索,学习如何使用侧边栏(material.angular.io/components/sidenav/overview)以及工具栏组件(material.angular.io/components/toolbar/overview)。
菜单
菜单组件就是它听起来的样子,它是为了让您轻松地向用户呈现菜单。它使用三个主要指令,mat-menu,mat-menu-item,最后,MatMenuTriggerFor。每个菜单只有一个mat-menu,以及尽可能多的mat-menu-items。MatMenuTriggerFor用于触发菜单,通常将其附加到按钮上。
使菜单工作可以分为三个步骤:
-
定义一个
mat-menu控件。 -
添加尽可能多的
mat-menu-items。 -
通过添加
MatMenuTriggerFor指令将触发器添加到按钮。
在我们执行任何操作之前,我们需要导入MatMenuModule以便能够使用先前提到的构造,所以让我们这样做:
import {MatMenuModule} from '@angular/material';
@NgModule({
imports: [MatMenuModule]
})
现在我们准备定义我们的菜单,如下所示:
<mat-menu>
</mat-menu>
之后,我们添加所需的项目:
<mat-menu>
<button mat-menu-item >Item1</button>
<button mat-menu-item >Item2</button>
</mat-menu>
最后,我们通过添加一个按钮来触发matMenuTriggerFor指令来添加触发器,就像这样:
<button [matMenuTriggerFor]="menu">Trigger menu</button>
<mat-menu #menu>
<button mat-menu-item >Item1</button>
<button mat-menu-item >Item1</button>
</mat-menu>
注意matMenuTriggerFor指向menu视图引用。
您的最终结果应该看起来像这样:
当然,并非所有菜单都是这么简单。迟早您会遇到需要嵌套菜单的情况。Material UI 很容易支持这一点。支持这一点的整体方法在于为您需要的每个菜单定义mat-menu,然后连接它们。然后您需要定义什么操作导致触发哪个子菜单。听起来困难吗?其实并不是。让我们从我们的顶级菜单,我们的根菜单开始。让我们给菜单项一些有意义的名称,就像这样:
<button [matMenuTriggerFor]="menu">Trigger menu</button>
<mat-menu #menu>
<button mat-menu-item >File</button>
<button mat-menu-item >Export</button>
</mat-menu>
在这一点上,我们有两个菜单项,最后一个wxport需要一些子选项。想象一下我们在程序中处理表格数据,支持将数据导出为 CSV 或 PDF 是有意义的。让我们添加一个子菜单,就像这样:
<button [matMenuTriggerFor]="rootMenu">Trigger menu</button>
<mat-menu #rootMenu>
<button mat-menu-item>File</button>
<button mat-menu-item>Export</button>
</mat-menu>
<mat-menu #subMenu>
<button mat-menu-item>CSV</button>
<button mat-menu-item>PDF</button>
</mat-menu>
好的,现在我们有两个不同的菜单,但我们需要添加连接,使rootMenu项触发subMenu显示。让我们再次使用matMenutriggerFor指令来添加,就像这样:
<button [matMenuTriggerFor]="rootMenu">Trigger menu</button>
<mat-menu #rootMenu>
<button mat-menu-item >File</button>
<button mat-menu-item [matMenuTriggerFor]="subMenu">Export</button>
</mat-menu>
<mat-menu #subMenu>
<button mat-menu-item>CSV</button>
<button mat-menu-item>PDF</button>
</mat-menu>
这应该呈现一个看起来像下面这样的菜单:
菜单有更多的用途,不仅仅是渲染一些菜单项并通过按钮触发它们。其他需要考虑和尝试的事情包括通过添加图标使其看起来更专业,或者迎合无障碍。现在您已经了解了如何创建简单菜单以及嵌套菜单的基础知识,去探索吧。
布局
布局是关于定义如何在页面上放置内容。Angular Material 为此目的提供了不同的组件,即:
-
列表:这是一种将内容呈现为项目列表的方式。列表可以用链接、图标来丰富,甚至可以是多行的。
-
网格列表:这是一个帮助你将内容排列成块的控件。您需要定义列数,组件将确保填充视觉空间。
-
卡片:这是一个包装内容并添加阴影的组件。您也可以为其定义一个标题。
-
选项卡:这让您可以在不同的选项卡之间划分内容。
-
步进器:这是一个将您的组件分成向导式步骤的组件。
-
展开面板:这个组件的工作方式基本上类似于手风琴,它使您能够以列表的方式布置组件,并为每个项目添加标题。每个项目都可以展开,一次只能展开一个项目。
在本节中,我们将介绍列表和网格列表组件。我们建议您自行探索卡片组件,material.angular.io/components/card/overview,选项卡组件,material.angular.io/components/tabs/overview,步进器,material.angular.io/components/stepper/overview,以及展开面板,material.angular.io/components/expansion/overview。
列表
列表控件由一个mat-list元素和一些mat-list-items组成。其标记如下:
<mat-list>
<mat-list-item>Item1</mat-list-item>
<mat-list-item>Item1</mat-list-item>
</mat-list>
就是这样,就是这样。为了你的努力,你将获得一个看起来像这样的列表:
当然,列表可以更加复杂,包含链接、图标等。一个更有趣的例子可能是这样的:
我想你已经明白了,这里有列表项,我可以在其中放入任何我想要的东西。要了解更多关于功能的信息,请点击以下链接查看列表文档:material.angular.io/components/list/overview.
网格列表
网格列表用于以行和列的列表形式显示内容,同时确保填充视口。如果您希望最大限度地自由决定如何显示内容,这是一个非常好的组件。这是一个名为MatGridListModule的单独模块。我们需要将其添加到我们导入的模块列表中,就像这样:
import { MatGridListModule } from '@angular/material';
@NgModule({
imports: [MatGridListModule]
})
该组件由一个mat-grid-list元素和一些mat-grid-tile元素组成。
让我们首先添加mat-grid-list元素:
<mat-grid-list cols=4 rowHeight="300px">
</mat-grid-list>
值得注意的是我们如何设置列数和每行的高度。现在是添加内容的时候了。我们通过添加一些mat-grid-tile实例来实现:
<mat-grid-list cols=4 rowHeight="300px">
<mat-grid-tile *ngFor="let tile of tiles" [colspan]="tile.cols" [rowspan]="tile.rows" [style.background]="tile.color"> {{ tile.text }}
</mat-grid-tile>
</mat-grid-list>
在这里,我们正在定义一个*ngFor,指向我们的瓷砖列表。我们还绑定到[colspan],决定它应该占用多少列空间,[rowspan],确定它应该占用多少行,最后,我们绑定到我们样式中的背景属性。
该组件如下所示:
tiles = [ {text: 'One', cols: 3, rows: 1, color: 'lightblue'}, {text: 'Two', cols: 1, rows: 2, color: 'lightgreen'}, {text: 'Three', cols: 1, rows: 1, color: 'lightpink'}, {text: 'Four', cols: 2, rows: 1, color: '#DDBDF1'}, ];
我们鼓励您探索卡片和选项卡组件,以了解更多关于剩余布局组件的信息。
弹出窗口和模态
有不同的方式可以吸引用户的注意。一种方法是在页面内容上显示对话框,并提示用户采取行动。另一种方法是在用户悬停在特定部分时显示该部分的信息。
Angular Material 为此提供了三种不同的组件:
-
对话框:这只是一个简单的模态对话框,显示在内容的顶部。
-
Tooltip:当您悬停在指定区域时,它会显示一段文本。
-
Snackbar:这在页面底部显示信息消息。信息消息只在短时间内可见。它旨在向用户传达由于某种操作(例如保存表单)而发生的事情。
对话框
对话框组件非常强大,因为它帮助我们创建一个模态框。它可以根据您的喜好进行定制,并且设置起来有点棘手。但不用担心,我们会指导您完成整个过程。我们需要做的是:
-
导入对话框模块。
-
创建一个作为我们对话框的组件。
-
创建一个组件和一个按钮,触发该模块。
-
将我们的对话框添加到模块的
entryComponents属性中。
首先,我们导入必要的模块,如下所示:
import { MatDialogModule } from '@angular/material';
@NgModule({
imports: [MatDialogModule]
})
接下来,我们创建一个将容纳我们对话框的组件。它是一个普通的组件,有模板和后台类,但它确实需要注入一个MatDialogRef。它应该看起来像这样:
import { MatDialogRef } from "@angular/material"; import { Component } from "@angular/core"; @Component({
selector: 'my-dialog', template: ` <h1 mat-dialog-title>Perform action?</h1> <mat-dialog-content>Save changes to Jedi?</mat-dialog-content> <mat-dialog-actions>
<button mat-button [mat-dialog-close]="true">Yes</button>
<button mat-button mat-dialog-close>No</button> </mat-dialog-actions>
` })
export class DialogComponent { constructor(public dialogRef: MatDialogRef<DialogComponent>) { console.log('dialog opened'); }
}
我们在模板中定义了以下一般结构:
<h1 mat-dialog-title>Save changes to Jedi?</h1>
<mat-dialog-content>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button [mat-dialog-close]>Yes</button>
<button mat-button mat-dialog-close >No</button>
</mat-dialog-actions>
乍一看,我们定义了一个标题、一个内容和一个操作字段,其中定义了按钮。为了发送不同的值回来,我们使用[mat-dialog-close]并为其分配一个值。
至于代码部分,我们注入了一个类型为MyDialog的MatDialogRef实例,这正是我们所在的组件。
我们需要做的第三件事是设置一个宿主组件,在其中有一个按钮,当点击时将启动一个对话框。所以让我们用以下代码来做到这一点:
import { Component } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { DialogComponent } from "./dialog.component";
@Component({
selector: 'dialog-example', template: ` <button (click)="openDialog()">Open Dialog</button> `
})
export class DialogExampleComponent { selectedOption; constructor(private dialog: MatDialog) { }
openDialog() { let dialogRef = this.dialog.open(DialogComponent); dialogRef.afterClosed().subscribe(result => {
// do something with 'result' });
}
}
在这里,我们做了两件事,我们使用类型调用dialog.open(),这是我们的对话框组件。此外,通过监听调用dialogRef.afterClosed()时返回的 Observable,我们能够检查来自对话框的结果。在这一点上,没有太多结果可以查看,但在下一节中,我们将看一个更高级的对话框示例,我们将使用这种方法。
最后,我们需要转到我们的app.module.ts文件,并将我们的DialogComponent对话框添加到entryComponents数组中,如下所示:
@NgModule({
entryComponents: [DialogComponent]
})
因此,在 Angular 模块的entryComponents数组中添加内容对我们来说是一个全新的概念,它实际上是做什么的?当我们将组件添加到该列表中时,我们告诉编译器这个组件需要被编译,并且需要一个ComponentFactory,以便我们可以动态创建它。因此,将任何组件放在这里的标准是,我们希望动态加载组件或按类型加载组件。这正是我们的DialogComponent的情况。在调用this.dialog.open(DialogComponent)之前,它实际上并不存在。在那时,它会在幕后运行一个名为ViewContainerRef.createComponent()的方法。简而言之,我们需要在每次打开对话框时实例化DialogComponent。因此,不要忘记entryComponents,否则它将无法工作。您可以在angular.io/guide/ngmodule-faq#what-is-an-entry-component上阅读更多关于entryComponents的信息。
您的对话框最终会看起来像这样:
一个更高级的例子-向对话框发送数据和从对话框发送数据
之前,我们介绍了一个简单的对话框示例,我们学会了如何打开对话框并关闭它。那只是皮毛。真正有趣的是我们如何向对话框发送数据,以便它预先加载一些数据,并且我们如何将在对话框内收集的数据发送回打开它的宿主组件。我们将研究这两种情况。
向对话框发送数据的业务案例是,这样它就可以从一些数据开始,例如,显示现有记录并在对话框中进行更新。
通过向dialog.open()方法添加第二个参数,我们可以向对话框组件发送数据,以便它可以显示:
// jedi.model.ts
interface Jedi {
name: string; }
import { Component } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { DialogComponent } from "./dialog.component";
@Component({
selector: 'dialog-example', template: ` <button (click)="openDialog()">Open Dialog</button> `
})
export class DialogExampleComponent { selectedOption; jedi: Jedi;
constructor(private dialog: MatDialog) {
this.jedi = { name: 'Luke' };
}
openDialog() {
let dialogRef = this.dialog.open(DialogComponent, {
data: { jedi: this.jedi }
});
dialogRef.afterClosed().subscribe(result => {
console.log(result);
});
}
}
在对话框组件方面,我们需要告诉它我们发送的数据。我们通过注入MAT_DIALOG_DATA来实现这一点,所需的更改如下所示:
import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material"; import { Component, Inject } from "@angular/core"; @Component({
selector: 'my-dialog',
template: `
<h1 mat-dialog-title>Save changes to jedi?</h1>
<mat-dialog-content>
<input matInput [(ngModel)]="data.jedi.name" **/>**
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="saveAndClose()">Yes</button> <button mat-button mat-dialog-close>No</button>
</mat-dialog-actions>
`, })
export class DialogComponent { constructor(
public dialogRef: MatDialogRef<DialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {
console.log('dialog opened');
}
saveAndClose() {
this.dialogRef.close('save');
}
}
现在,因为我们已经从host类发送了数据绑定的jedi实例,所以我们在Dialog类中对其进行的任何更改都将反映在host类中。这解决了从host类发送数据到对话框的问题,但是如果我们想要从对话框发送数据回来怎么办?我们可以通过在dialogRef.close()方法调用中发送一个参数来轻松实现,就像这样:
export class DialogComponent { constructor(
public dialogRef: MatDialogRef<DialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {
console.log('dialog opened');
}
saveAndClose() {
this.dialogRef.close('save'**);**
}
}
要对数据进行操作,我们只需订阅从调用afterClose()得到的 Observable。如下所示加粗说明:
import { Component } from "@angular/core"; import { MatDialog } from "@angular/material/dialog"; import { DialogComponent } from "./dialog.component";
@Component({
selector: 'dialog-example', template: ` <button (click)="openDialog()">Open Dialog</button> `
})
export class DialogExampleComponent { selectedOption;
jedi: Jedi;
constructor(private dialog: MatDialog) {
this.jedi = { name: 'Luke' }; }
openDialog() {
let dialogRef = this.dialog.open(DialogComponent, {
data: { jedi: this.jedi } });
dialogRef
.afterClosed()
.subscribe(result => {
// will print 'save' if we pressed 'Yes' button
console.log(result);
});
}}
数据表
我们可以以不同的方式显示数据。以行和列的形式显示数据是快速获得概览的有效方式。但是,您可能需要按列对数据进行排序,以便快速聚焦于感兴趣的数据。此外,数据量可能非常大,需要通过分页的方式显示。Angular Material 通过提供以下组件来解决这些问题:
-
表格:这以行和列的形式布置数据,并带有标题
-
排序表格:这允许您对数据进行排序
-
分页器:这允许您将数据分成页面,并在页面之间导航
应该说,在大多数情况下,当尝试向应用程序添加表格时,预期表格可以进行排序,并且数据可以进行分页,以免完全压倒用户。因此,让我们逐步看看如何实现所有这些。
表格
表格组件能够让我们以列和行的形式呈现数据。我们需要做以下工作才能让表格组件正常运行:
-
在我们的根模块中导入和注册
MatTableModule。 -
构建我们打算显示的数据。
-
定义我们表格的标记。
首先要做的是导入必要的模块,可以通过以下代码轻松完成:
import {MatTableModule} from '@angular/material';
@NgModule({
imports: [MatTableModule]
})
在这一点上,我们开始构建我们的数据并创建MatTableDataSource类的一个实例。代码如下:
// app/jedi.model.ts
export class interface Jedi {
name: string;
side: string;
}
// app/table.example.component.ts
@Component({
selector: 'example-table',
template : `
<div>
<mat-table #table [dataSource]="tableSource" matSort>
// header 'Name' <ng-container matColumnDef="name"> <mat-header-cell *matHeaderCellDef mat-sort-header> Name</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}}
</mat-cell>
</ng-container>
// header 'Side'
<ng-container matColumnDef="side">
<mat-header-cell *matHeaderCellDef mat-sort-header> Side </mat-header-cell>
<mat-cell *matCellDef="let element"> {{element.side}}
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
<mat-paginator #paginator [pageSize]="2" [pageSizeOptions]="[1, 5, 10]">
</mat-paginator>
</div>
`
})
export class ExampleTableComponent {
jediSource: Array<Jedi>; tableSource: MatTableDataSource<Jedi>; displayedColumns: string[];
constructor() { this.displayedColumns = ['name', 'side']; this.jediSource = [{ name: 'Yoda', side: 'Good' }, {
name: 'Darth', side: 'Evil' }, {
name: 'Palpatine', side: 'Evil' }];
this.tableSource = new MatTableDataSource<Jedi>(this.jediSource**);**
} }
值得注意的是,我们如何从对象数组构建了一个MatTableDataSource实例。我们将在标记中使用这个实例,并将其指定为数据源。接下来要做的是构建支持这个表格的标记。代码如下:
<mat-table #table [dataSource]="tableSource">
// header 'Name'
<ng-container matColumnDef="name"> <mat-header-cell *matHeaderCellDef> Name </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}} **</mat-cell>** </ng-container>
// header 'Side'
<ng-container matColumnDef="side"> <mat-header-cell *matHeaderCellDef> Side </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}} </mat-cell> </ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-row *matRowDef="let row; columns: displayedColumns;"**></mat-row>** </mat-table>
我们在先前的代码中指出了几个值得关注的地方。表格的列是通过创建一个包含mat-header-cell的ng-container元素来构建的,其中定义了标题,以及一个mat-cell,我们在其中说明了应该放入哪些数据。在代码中稍后的mat-header-row元素使我们能够指出列应该出现的顺序。我们可以在先前的代码片段中看到,这实际上只是一个字符串数组。最后,通过mat-row元素,我们简单地显示表格的所有行。最终结果应该是这样的:
排序
先前的图表构成了一个漂亮的表格,但缺少一个非常标准的功能,即排序功能。我们期望通过点击标题,它将分别按升序和降序排序,并且能够识别常见的数据类型,如字符串和整数,并正确排序这些数据。好消息是,这非常容易实现。我们需要做以下工作来确保我们的表格可以排序:
-
导入并注册
MatSortModule。 -
创建一个类型为
MatSort的ViewChild并将其分配给dataSources的 sort 属性。 -
将
matSortHeader指令添加到应该能够排序的标题上。
我们通过向根模块添加以下代码来完成第一步:
import { MatSortModule } from '@angular/material/sort'; @NgModule({
imports: [MatSortModule]
})
然后,我们进入我们的组件,并添加MatSort ViewChild并将其分配给 sort 属性,如前所述:
import { Component, ViewChild } from '@angular/core'; import { MatTableDataSource, MatSort } from "@angular/material";
@Component({
selector: 'table-demo', templateUrl: './table.demo.component.html', styleUrls: ['./table.demo.component.css'] })
export class AppComponent { @ViewChild(MatSort) sort: MatSort**;** jediSource: Array<Jedi>; tableSource: MatTableDataSource<Jedi>; displayedColumns: string[];
constructor() { this.displayedColumns = ['name', 'side']; this.jediSource = [{ name: 'Yoda', side: 'Good' }, {
name: 'Darth', side: 'Evil' },
{
name: 'Palpatine', side: 'Evil' }];
this.tableSource = new MatTableDataSource<Jedi>(this.jediSource);
}
ngAfterViewInit() { this.tableSource.sort = this.sort; }
在这一点上,我们需要修复标记,然后排序应该可以工作。我们需要对标记进行的更改只是简单地将matSort指令应用到整个表格,以及对每个应该可以排序的标题应用mat-sort-header。现在标记的代码如下:
<mat-table #table [dataSource]="tableSource" **matSort**>
// header 'Name'
<ng-container matColumnDef="name"> <mat-header-cell *matHeaderCellDef mat-sort-header> Name </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}} </mat-cell**>** </ng-container>
// header 'Side'
<ng-container matColumnDef="side"> <mat-header-cell *matHeaderCellDef **mat-sort-header**> Side </mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}} </mat-cell> </ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row> </mat-table>
现在 UI 应该通过列Name旁边的箭头指示数据排序的方向,如下图所示:
分页
到目前为止,我们的表格看起来相当不错。除了显示数据外,它甚至可以进行排序。不过,我们意识到在大多数情况下,表格的数据通常相当长,这导致用户要么不得不滚动,要么逐页浏览数据。我们可以通过分页元素来解决后一种选项。要使用它,我们需要做以下工作:
-
导入并注册
MatPaginatorModule。 -
将
paginator ViewChild实例分配给数据源的 paginator 属性。 -
在标记中添加一个
mat-paginator元素。
从我们列表中的第一项开始,我们需要将以下代码添加到我们的根模块中:
import {MatPaginatorModule} from '@angular/material/paginator';
@NgModule({
imports: [MatPaginatorModule]
})
之后,我们需要将paginator属性分配给我们的tableSource.paginator,就像之前描述的那样。代码如下所示:
import { Component, ViewChild } from '@angular/core'; import { MatTableDataSource, MatSort } from "@angular/material";
@Component({
selector: 'table-demo', template: ` <mat-table #table [dataSource]="tableSource" **matSort**>
// header 'Name'
<ng-container matColumnDef="name"> <mat-header-cell *matHeaderCellDef mat-sort-header> Name</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}}
</mat-cell**>** </ng-container>
// header 'Side'
<ng-container matColumnDef="side"> <mat-header-cell *matHeaderCellDef **mat-sort-header**> Side</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}}</mat-cell> </ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
`, styleUrls: ['./table.demo.component.css'] })
export class AppComponent { @ViewChild(MatSort) sort: MatSort; **@ViewChild(MatPaginator) paginator: MatPaginator;** jediSource: Array<Jedi>; tableSource: MatTableDataSource<Jedi>; displayedColumns: string[];
constructor() { this.displayedColumns = ['name', 'side']; this.jediSource = [{ name: 'Yoda', side: 'Good' }, {
name: 'Darth', side: 'Evil' },
{
name: 'Palpatine', side: 'Evil' }];
this.tableSource = new MatTableDataSource<Jedi>(this.jediSource);
}
ngAfterViewInit() {
this.tableSource.sort = this.sort; this.tableSource.paginator = paginator; }
我们剩下的部分就是改变标记,应该有以下改变(加粗的变化):
<div>
<mat-table #table [dataSource]="tableSource" matSort>
// header 'Name'
<ng-container matColumnDef="name"> <mat-header-cell *matHeaderCellDef mat-sort-header> Name</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.name}}</mat-cell> </ng-container>
// header 'Side'
<ng-container matColumnDef="side"> <mat-header-cell *matHeaderCellDef mat-sort-header> Side</mat-header-cell> <mat-cell *matCellDef="let element"> {{element.side}} </mat-cell> </ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row> <mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
<mat-paginator #paginator [pageSize]="2" [pageSizeOptions]="[1, 5, 10]">
</mat-paginator>
</div>
在这里,我们清楚地表明,我们标记的唯一添加是底部的mat-paginator元素。在这里,我们指定了我们的视图引用,还有页面大小以及我们应该能够切换到的页面。
总结
我们努力解释了什么是 Material Design,这是一种以纸张和墨水为主题的设计语言。之后,我们提到了最著名的 Material Design 实现。
接下来,我们把大部分注意力放在了 Angular Material 上,这是专为 Angular 设计的 Material Design 实现,以及它由不同的组件组成。我们亲自动手解释了如何安装它,设置它,甚至如何使用不同的表单控件和输入按钮。
我们还花了一些时间来介绍组件的其他方面,比如布局、导航、模态框和表格数据。希望你已经阅读了本章,并发现你现在对 Material Design 有了一般的了解,特别是对 Angular Material,你可以确定它是否适合你的下一个 Angular 应用程序。
第十二章:使用 Angular 为组件添加动画
如今,动画是现代用户体验设计的基石之一。远非仅仅是用来美化 UI 的视觉点缀,它们已经成为视觉叙事的重要组成部分。动画为以非侵入方式传达信息铺平了道路,成为了一个廉价但强大的工具,用来告知用户在与我们的应用程序交互时发生的基础过程和事件。一旦一个动画模式变得普遍,并且受众接受它作为现代标准,我们就获得了一个无价的工具,用来增强我们应用程序的用户体验。动画是与语言无关的,不一定绑定在单一设备或环境(Web、桌面或移动),并且当明智地使用时,它们对于观看者来说是令人愉悦的。换句话说,动画是不可或缺的,而 Angular 2 对现代视觉开发的这一方面有着强烈的承诺。
随着所有现代浏览器都支持 CSS3 的新特性来处理动画,Angular 2 提供了支持通过一个简单但强大的 API 来实现命令式动画脚本。本章将涵盖几种实现动画效果的方法,从利用纯粹的 CSS 来应用基于类的动画,到实现脚本例程,其中 Angular 完全负责处理 DOM 转换。
在这一章中,我们涵盖以下主题:
-
使用纯粹的 CSS 创建动画
-
利用
ngClass指令来更好地使用类命名动画
处理转换
- 查看 Angular 内置的 CSS 钩子,为每个定义样式
转换状态
-
引入动画触发器,并在模板中声明性地将这些动画附加到元素上
-
使用AnimationBuilder API 来为组件添加动画
-
设计处理动画的指令
使用纯粹的 CSS 创建动画
基于 CSS 的动画的诞生是现代网页设计中的重要里程碑。在那之前,我们过去常常依赖 JavaScript 来通过复杂和繁琐的脚本来操作 DOM 元素,通过间隔、超时和各种循环来实现我们网页应用中的动画。不幸的是,这既不可维护也不可扩展。
然后,现代浏览器采用了最近引入的 CSS 变换、过渡、关键帧和动画属性带来的功能。这在 Web 交互设计的背景下成为了一个改变游戏规则的因素。虽然像Microsoft Internet Explorer这样的浏览器对这些技术的支持远非理想,但其他可用的浏览器(包括微软自己的 Edge)对这些 CSS API 提供了全面的支持。
MSIE 仅在版本 10 及以上提供对这些动画技术的支持。
我们假设您对 CSS 动画的工作原理有广泛的了解,因此本书的范围显然不包括这些技术的覆盖。总之,我们可以强调 CSS 动画通常是通过这些方法之一或两者的组合来实现的:
-
过渡属性将作为 DOM 元素应用的所有或部分 CSS 属性的观察者。每当这些 CSS 属性中的任何一个发生变化时,DOM 元素不会立即采用新值,而是会经历一个稳定的过渡到新状态。
-
命名关键帧动画,我们在一个唯一的名称下定义了一个或多个 CSS 属性演变的不同步骤,稍后将在给定选择器的动画属性中填充,能够设置额外的参数,如延迟、动画缓动的持续时间或动画的迭代次数。
正如我们在前面提到的两种情况中所看到的,使用带有动画设置的 CSS 选择器是与动画相关的一切的起点,这就是我们现在要做的。让我们构建一个花哨的脉冲动画,以模拟装饰我们的番茄钟的位图中的心跳样式效果。
这次我们将使用基于关键帧的动画,因此我们将首先在单独的样式表中构建实际的 CSS 例程。整个动画基于一个简单的插值,我们将一个对象放大 10%,然后再缩小到初始状态。然后将这个基于关键帧的缓动命名并包装在一个名为pulse的 CSS 类中,它将在一个无限循环中执行动画,每次迭代需要 1 秒完成。
所有用于实现此动画的 CSS 规则将存储在外部样式表中,作为计时器小部件组件的一部分,位于timer feature文件夹内:
// app/timer/timer.widget.component.css
@keyframes pulse {
0% {
transform: scale3d(1, 1, 1);
}
50% {
transform: scale3d(1.1, 1.1, 1.1);
}
100% {
transform: scale3d(1, 1, 1);
}
}
.pulse {
animation: pulse 1s infinite;
}
.task { background: red;
width: 30px;
height: 30px;
border-radius: 50%; }
从这一点开始,任何带有此类名称的 DOM 元素都将像心脏一样跳动。这种视觉效果实际上是一个很好的提示,表明元素正在进行某种操作,因此在倒计时进行时将其应用于计时器小部件中的主图标位图将有助于传达当前正在以生动的方式进行某种活动的感觉。
谢天谢地,我们有一个很好的方法,只在倒计时活动时应用这样的效果。我们在TimerWidgetComponent模板中使用isPaused绑定。将其值绑定到NgClass指令,以便仅在组件暂停时渲染类名,这样就可以打开计时器小部件代码单元文件,并添加对我们刚刚创建的样式表的引用,并按照之前描述的方式应用指令:
// app/timer/timer.widget.component.ts
import { Component } from "@angular/core";
@Component({
selector: 'timer-widget',
styleUrls: ['timer.widget.component.css'],
template: `
<div class="text-center">
<div class="task" [ngClass]="{ pulse: !isPaused }"></div>
<h3><small>{{ taskName }}</small></h3>
<h1> {{ minutes }}:{{ seconds | number: '2.0' }} </h1>
<p>
<button (click)="togglePause()" class="btn btn-danger">
Toggle
</button>
</p>
</div>` })
export class TimerWidgetComponent { taskName: string = 'task';
minutes = 10;
seconds = 20;
isPaused = true;
togglePause() {
this.isPaused = !this.isPaused;
}
}
就是这样!运行我们的番茄钟应用程序,点击顶部的Timer链接,进入计时器组件页面,并在启动倒计时后实时检查视觉效果。停止并再次恢复,以查看效果仅在倒计时活动时应用。
介绍 Angular 动画
动画触发器的想法是,当某个属性从一个状态变化到另一个状态时,您可以显示动画。要定义触发器,我们首先需要安装和导入我们需要的库,具体来说是BrowserAnimationsModule,所以让我们这样做。
我们通过输入以下命令来安装库:
npm install @angular/animations --save
现在让我们导入并设置带有BrowsersAnimationsModule的模块:
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@NgModule({
imports: [BrowserAnimationsModule]
})
之后,是时候导入一堆我们需要设置触发器本身的结构:
import { trigger, state, style, animate, transition } from '@angular/animations';
导入的结构具有以下功能:
-
trigger:这定义了组件中动画目标的属性;它需要一个名称作为第一个参数,以及作为第二个参数的状态和转换数组 -
state:这定义了属性值以及它应该具有的 CSS 属性;您需要为属性可以假定的每个值定义一个这样的属性 -
transition:这定义了当您从一个属性值转到另一个属性值时动画应该如何播放 -
animate:当我们从一个状态值转移到下一个状态时,执行定义的动画
我们的第一个触发器
让我们快速看一下动画触发器可能是什么样子,然后解释各个部分:
animations: [
trigger('sizeAnimation', [
state('small', style({
transform:'scale(1)',
backgroundColor: 'green'
})),
state('large', style({
transform: '(1.4)',
backgroundColor: 'red'
})),
transition('small => large', animate('100ms ease-in')),
transition('large => small', animate('100ms ease-out'))
])
]
animations数组是我们添加到组件对象中的内容,比如模板或styleUrls。在animations数组中有许多触发器定义。trigger需要一个名称和一个项目数组,就像这样:
trigger('name', [ ... items ])
这些项目要么是状态定义,要么是过渡。有了这个知识,更容易理解我们正在看的是什么。目前,我们选择将触发器称为animationName。它定义了两个状态和两个过渡。状态表示值已更改为此状态,我们相应地通过执行样式来做出反应,这就是为什么代码应该被理解为以下内容:
state(
'when I change to this value',
style({ /*apply these style changes*/ }))
请注意,样式属性是驼峰式命名,而不是短横线命名,例如,写backgroundColor而不是background-color,就像你在 CSS 中习惯的那样。
看看我们的例子,我们是在说以下内容:
-
如果有人触发
sizeAnimation并且值设置为small,那么应用这个变换:scale(1)和backgroundColor: 'green' -
如果有人触发
sizeAnimation并且值设置为large,那么应用这个变换:scale(1.4)和backgroundColor: 'red'
剩下的两个项目是两个transition调用。这指示动画如何以平滑的方式应用动画。您可以这样阅读过渡定义:
transition(' when I go from this state > to this state ', animate( 100ms ease-in))
因此,当我们从一个状态转换到另一个状态时,我们应用一个缓动函数,并且还定义了动画应该执行多长时间。让我们回顾一下我们的代码:
transition('small => large', animate('100ms ease-in')),
transition('large => small',animate('100ms ease-out'))
我们这样解释它:
-
当我们从值
small到large时,执行100ms的动画并使用ease-in函数 -
当我们从值
large到small时,执行100ms的动画并使用ease-out函数
连接部分
现在我们已经完全解析了我们的trigger语句,我们还有最后一件事要做,那就是将触发器连接到它需要查看的属性。所以,让我们在模板中添加一些代码:
@Component({
selector: 'example', template: `
<button (click)="makeBigger()">Make bigger</button>
<button (click)="makeSmaller()">Make smaller</button>
<p class="animate" [@sizeAnimation]="state">some text</p>
`
,
animations: [
trigger('sizeAnimation', [
state('small', style({
transform:'scale(1)',
backgroundColor: 'green'})),
state('large', style({
transform: 'scale(1.4)',
backgroundColor : 'red'
})),
transition('small => large', animate('100ms ease-in')),
transition('large => small',animate('100ms ease-out'))
])
],
styles: [`
.animate {
background: green;
width: 100px;
}
`] })
export class ExampleComponent {
state: string;
makeBigger() {
this.state = 'large';
}
makeSmaller() {
this.state = 'small';
}
}
现在,要注意的关键是[@animationName]='state';这是我们说触发器应该查看组件state属性,我们已经知道state应该具有哪些值才能触发动画。
通配符状态
我们为触发器定义的状态不仅仅是两个。在某些情况下,无论我们来自什么状态值,应用转换都更有意义。对于这些情况,有通配符状态。使用通配符状态很容易。您只需转到转换定义并用*替换状态值,如下所示:
transition('* => larger')
这意味着无论我们之前处于什么状态,当我们的state属性假定一个larger值时,转换都会发生。
空状态
void状态不同于通配符状态。Void 与说如果一个元素之前不存在,那么它就有void值是一样的。在退出时,它假定一个值。因此,转换调用的定义如下:
transition(' void => *')
通过向我们的模板添加一些代码,让我们使其更真实:
<button (click)="abraCadabra()">Abracadabra</button> <button (click)="poof()">Poof</button> <p class="elem" [@flyInOut]="state" *ngIf="showMe">
Show me
</p>
在这里,我们添加了一个按钮,设置为调用abraCadabra()来显示元素,以及一个调用poof()的按钮,它将隐藏元素。现在让我们向组件添加一些代码:
trigger('flyInOut', [
state('in', style({transform: 'translateX(0)'})), transition('void => *', [ style({transform: 'translateX(-100%)'}), animate(500) ]),
transition('* => void', [ animate(500, style({transform: 'translateX(200%)'})) ])
])
这个触发器定义如下,如果一个元素从不存在到存在,void => *,那么从-100%到x位置0进行动画。当从存在到不存在时,将其移出画面,将其移动到x位置200%。
现在是最后一部分,我们的组件代码:
abraCadabra() { this.state = 'in'; this.showMe = true; }
poof() {
this.showMe = false; }
在这里,我们可以看到调用abraCadabra()方法将触发状态'in',并将布尔值showMe设置为true将触发转换void => *。这解释了void状态的主要目的,即在先前元素不存在时使用。
动画回调
有时候,您可能想要知道何时启动动画以及动画何时完成。在这里有好消息;我们可以找出这一点,并执行我们需要的任何代码。
我们需要做的是监听触发器的start和done属性,如下所示:
[@sizeAnimation.start]=animationStarted($event)
[@sizeAnimation.done]="animationDone($event)"
[@sizeAnimation]="state"
当然,我们需要向我们的组件添加代码,使其看起来像这样:
animationStarted() {
// animation started, execute code
}
animationDone() {
// animation ended, execute code
}
使用 AnimationBuilder 对组件进行动画处理
到目前为止,我们已经介绍了如何使用纯 CSS 进行动画处理,或者通过定义一个触发器来连接到我们的标记。还有另一种更程序化的动画处理方法。这种方法使用一个名为AnimationBuilder的服务。使这种方法起作用涉及一些关键因素,即:
-
AnimationBuilder:这是一个我们注入的服务;它有一个名为build的方法,当调用时创建一个AnimationFactory的实例 -
AnimationFactory:这是在AnimationBuilder实例上调用build()的结果;它已经被赋予了许多样式转换和一个或多个动画 -
AnimationPlayer:播放器需要一个元素来应用动画指令
让我们解释这些要点,这样我们就能理解发生了什么,什么时候发生,以及对哪个元素发生了什么。首先,我们需要将AnimationBuilder注入到组件的构造函数中,并且还需要注入一个elementRef实例,这样我们就有了动画的目标,就像这样:
import { AnimationBuilder } from '@angular/animations';
@Component({})
export class Component {
constructor(
private animationBuilder:AnimationBuilder,
private elementRef: ElementRef
) {
}
}
在这一点上,我们可以访问animationBuilder的一个实例,并准备好设置我们的样式转换和动画,所以让我们接着做:
ngOnInit() {
const animationFactory = this.animationBuilder.build([
style({ width : '0px' }), // set starter value
animate(1000, style({ width: '100px' })) // animate to this new value ])
}
在这里,我们定义了一个将宽度初始设置为0px的转换,以及一个将宽度在1秒内设置为100px的动画。我们还将调用animationBuilder.build()的结果分配给了一个名为 animation 的变量,它的类型是AnimationFactory。下一步是创建一个动画播放器的实例,并决定要将此动画应用到哪个元素:
const elem = this.elementRef.nativeElement.querySelector('.text'); const animationPlayer = animationFactory.create(elem);
我们在这里做了两件事;首先,我们指出了模板中我们想要应用动画的元素。接下来,我们通过调用animation.create(elem)并将我们的元素作为输入来创建一个动画播放器的实例。现在缺少的是在 UI 中创建元素,这样我们的querySelector()才能找到它。我们需要创建一个带有 CSS 类文本的元素,这正是我们在下面的代码中所做的:
@Component({
template : `
<p class="text">Animate this text</p>
`
})
export class ExampleComponent {}
最后一步是在我们的动画播放器实例上调用play()方法:
animationPlayer.play();
在浏览器中播放动画。您可以通过向我们的style({})方法调用添加更多属性来轻松扩展动画,就像这样:
ngOnInit() {
const animation = this.builder.build([
style({
width : '0px',
height : '0px'
}), // set starter values
animate(1000, style({
width: '100px',
height: '40px' })) ])
}
总之,AnimationBuilder是一种强大的方式,可以创建可重用的动画,您可以轻松地将其应用到您选择的元素上。
创建一个可重用的动画指令
到目前为止,我们已经看到了如何创建AnimationBuilder以及如何使用它来随意地以编程方式创建和应用动画。使其可重用的一种方法是将其包装在一个指令中。创建指令是一件相当简单的事情,我们已经做过几次了;我们需要记住的是,我们的指令将被应用到一个元素上,而这个元素就是我们的动画将要被应用到的东西。让我们总结一下我们需要在列表中做的事情:
-
创建一个指令。
-
注入
AnimationBuilder。 -
创建我们的动画。
-
创建一个动画播放器。
-
播放动画。
这个事情清单与我们解释AnimationBuilder的工作原理非常相似,而且应该是这样的;毕竟,指令是这里唯一的新东西。让我们定义我们的指令和动画;实际上并没有太多要做的。
@Directive({
selector : '[highlight]'
})
export class HighlightDirective implements OnInit {
constructor(
private elementRef: ElementRef,
private animationBuilder: AnimationBuilder
) {}
ngOnInit() {
const animation = this.animationBuilder.build([
style({ width: '0px' }),
animate(1000, style({ width : '100px' }))
]);
const player = animation.create( this.elementRef.nativeElement );
player.play();
}
}
这就是我们需要的一切。现在我们可以将我们的指令应用到任何元素上,就像这样:
<p highlight>animate me</p>
总结
我们只是触及了处理动画的表面。要了解你可以做的一切,请阅读官方文档angular.io/guide/animations。
在本章中,我们开始学习如何定义原始的 CSS 动画。然后,我们解释了动画触发器以及如何以声明方式将定义的动画附加到元素上。然后,我们看了如何以编程方式定义动画并随意将其附加到元素上。我们最后做的事情就是将我们的程序化动画打包到一个指令中。关于动画还有很多要学习的,但现在你应该对存在的 API 有基本的了解以及何时使用它们。走出去,让你的应用充满生机,但记住,少即是多。
第十三章:Angular 中的单元测试
前几章的辛勤工作已经变成了一个我们可以引以为傲的工作应用程序。但是,我们如何确保未来的可维护性?一套全面的自动化测试层将成为我们的生命线,一旦我们的应用程序开始扩展,我们就必须减轻由新功能与已经存在的功能相冲突而引起的错误的影响。
测试(更具体地说,单元测试)应该由开发人员在项目开发过程中进行。然而,在本章中,我们将简要介绍测试 Angular 模块的所有复杂性,因为项目已经处于成熟阶段。
在本章中,您将看到如何实现测试工具,以对应用程序的类和组件进行适当的单元测试。
在本章中,我们将:
-
看看测试的重要性,更具体地说,单元测试
-
构建测试管道的测试规范
-
为具有或不具有依赖项的组件设计单元测试
-
对我们的路由进行测试
-
为服务实现测试,模拟依赖项和存根
-
拦截 XHR 请求并提供模拟响应以进行精细控制
-
了解如何测试指令作为没有视图的组件
-
介绍其他概念和工具,如 Karma、代码覆盖工具
和端到端(E2E)测试
为什么我们需要测试?
什么是单元测试?如果您已经熟悉单元测试和测试驱动开发,可以安全地跳过下一节。如果不熟悉,让我们说单元测试是工程哲学的一部分,它支持高效和敏捷的开发过程,通过在代码开发之前为代码添加一层自动化测试。核心概念是每一段代码都有自己的测试,并且这两段代码都是由正在开发该代码的开发人员构建的。首先,我们设计针对我们要交付的模块的测试,检查其输出和行为的准确性。由于模块还没有实现,测试将失败。因此,我们的工作是以使模块通过自己的测试的方式构建模块。
单元测试是相当有争议的。虽然人们普遍认为测试驱动开发对于确保代码质量和随时间的维护是有益的,但并不是每个人在日常实践中都进行单元测试。为什么呢?嗯,在开发代码的同时构建测试有时可能会感觉像是一种负担,特别是当测试的规模比它旨在测试的功能部分还要大时。
然而,支持测试的论点比反对它的论点多得多:
-
构建测试有助于更好的代码设计。我们的代码必须符合测试要求,而不是相反。在这种意义上,如果我们试图测试现有的一段代码,并且在某个时候发现自己被阻止了,那么这段代码很可能设计不良,并展示出需要重新思考的复杂接口。另一方面,构建可测试的模块可以帮助早期发现对其他模块的副作用。
-
重构经过测试的代码是防止在后期引入错误的生命线。任何开发都意味着随着时间的推移而发展,每次重构都会引入错误的风险,这些错误可能只会在我们应用程序的另一个部分中出现。单元测试是确保我们在早期捕捉错误的好方法,无论是在引入新功能还是更新现有功能时。
-
构建测试是记录我们的代码 API 和功能的好方法。当一个不熟悉代码库的人接手开发工作时,这将成为无价的资源。
这只是一些论点,但你可以在网上找到无数关于测试代码好处的资源。如果你还不感到满意,不妨试一试。否则,让我们继续我们的旅程,看看测试的整体形式。
单元测试的解剖结构
有许多不同的方法来测试一段代码,但在本章中,我们将看看测试的解剖结构,它由什么组成。测试任何代码的第一件事是测试框架。测试框架应该提供用于构建测试套件的实用函数,每个套件包含一个或多个测试规范。那么这些概念是什么呢?
-
测试套件:套件为一组测试创建了一个逻辑分组。例如,一个套件可以是产品页面的所有测试。
-
测试规范:这是单元测试的另一个名称。
以下显示了一个测试文件的样子,我们在其中使用了一个测试套件,并放置了许多相关的测试。我们选择的框架是 Jasmine。在 Jasmine 中,describe()函数帮助我们定义一个测试套件。describe()方法以名称作为第一个参数,以函数作为第二个参数。在describe()函数内部有许多对it()方法的调用。it()函数是我们的单元测试;它以测试名称作为第一个参数,以函数作为第二个参数:
// Test suite
describe('A math library', () => {
// Test spec
it('add(1,1,) should return 2', () => {
// Test spec implementation goes here
});
});
每个测试规范检查套件描述参数中描述的功能的特定功能,并在其主体中声明一个或多个期望。每个期望都取一个值,我们称之为期望值,并通过匹配器函数与实际值进行比较,该函数检查期望值和实际值是否相匹配。这就是我们所说的断言,测试框架将根据这些断言的结果通过或失败规范。代码如下:
// Test suite
describe('A math library', () => {
// Test spec
it('add(1,1) should return 2', () => {
// Test assertion
expect(add(1,1,)).toBe(2);
});
it('subtract(2,1)', () =>{
//Test assertion
expect(subtract(2,1)).toBe(1);
})
});
在前面的例子中,add(1,1)将返回实际值,这个值应该与toBe()匹配器函数中声明的期望值相匹配。
在前面的例子中值得注意的是添加了第二个测试,测试了我们的subtract()函数。我们可以清楚地看到,这个测试处理了另一个数学运算,因此将这两个测试分组在一个套件下是有意义的。
到目前为止,我们已经了解了测试套件以及如何根据其功能对测试进行分组。此外,我们已经了解了调用要测试的代码并断言它是否按照你所想的那样做的概念。然而,单元测试还有更多值得了解的概念,即设置和拆卸功能。设置功能是在测试运行之前设置代码的功能。这是一种使代码更清晰的方式,因此您可以专注于调用代码和断言。拆卸功能是设置功能的相反,专门用于拆卸最初设置的内容;本质上,这是一种在测试后进行清理的方式。让我们看看这在实践中是什么样子,使用 Jasmine 框架的代码示例。在 Jasmine 中,beforeEach()方法用于设置功能;它在每个单元测试之前运行。afterEach()方法用于运行拆卸逻辑。代码如下:
describe('a Product service', () => {
let productService;
beforeEach(() => {
productService = new ProductService();
});
it('should return data', () => {
let actual = productService.getData();
assert(actual.length).toBe(1);
});
afterEach(() => {
productService = null;
});
});
我们可以在前面的代码中看到beforeEach()函数负责实例化productService,这意味着测试只需要关心调用生产代码和断言结果。这使得测试看起来更清晰。不过,实际上,测试往往需要进行大量的设置,有一个beforeEach()函数可以使测试看起来更清晰;最重要的是,它往往使添加新测试变得更容易,这是很棒的。最终你想要的是经过充分测试的代码;编写和维护这样的代码越容易,对你的软件就越好。
在 Angular 中进行测试的介绍
在单元测试的解剖部分,我们熟悉了单元测试及其一般概念,比如测试套件、测试规范和断言。掌握了这些知识,现在是时候深入了解在 Angular 中进行单元测试了。不过,在我们开始为 Angular 编写测试之前,我们将首先介绍 Angular CLI 中存在的工具,以使单元测试成为一种愉快的体验。在 Angular 中进行单元测试时,了解它由哪些主要部分组成是很重要的。在 Angular 中,这些部分包括:
-
Jasmine,测试框架
-
Angular 测试工具
-
Karma,一个用于运行单元测试的测试运行器,还有其他功能
-
Protractor,Angular 的 E2E 测试框架
配置和设置
在配置方面,当使用 Angular CLI 时,你不需要做任何事情来使其工作。一旦你搭建一个项目,就可以运行你的第一个测试,它就会工作。当你深入研究 Angular 中的单元测试时,你需要了解一些概念,这些概念可以提高你测试不同构造的能力,比如组件和指令。Angular CLI 使用 Karma 作为测试运行器。关于 Karma 我们需要知道的是它使用一个karma.conf.js文件,一个配置文件,其中指定了很多东西,比如:
-
增强你的测试运行器的各种插件。
-
在哪里找到要运行的测试?应该说的是,通常在这个文件中有一个 files 属性,指定了在哪里找到应用程序和测试。然而,对于 Angular CLI,这个规范是在另一个名为
src/tscconfig-spec.json的文件中找到的。 -
你选择的覆盖工具的设置,一个衡量你的测试覆盖生产代码程度的工具。
-
报告者,在控制台窗口、浏览器或其他方式中报告每个执行的测试。
-
用于运行测试的浏览器:例如,Chrome 或 PhantomJS。
使用 Angular CLI,您很可能不需要自己更改或编辑此文件。知道它的存在以及它为您做了什么是很好的。
Angular 测试工具
Angular 测试工具有助于创建一个测试环境,使得为各种构造编写测试变得非常容易。它由TestBed类和各种辅助函数组成,位于@angular/core/testing命名空间下。随着本章的进行,我们将学习这些是什么以及它们如何帮助我们测试各种构造。我们将很快介绍最常用的概念,以便在我们进一步介绍它们时您对它们有所了解:
-
TestBed类是最重要的概念,它创建自己的测试模块。实际上,当您测试一个构造以将其从所在的模块中分离出来并重新连接到TestBed创建的测试模块时。TestBed类有一个configureTestModule()辅助方法,我们用它来设置所需的测试模块。TestBed还可以实例化组件。 -
ComponentFixture是一个包装组件实例的类。这意味着它具有一些功能,并且它有一个成员,即组件实例本身。 -
DebugElement,就像ComponentFixture一样,充当包装器。但是,它包装的是 DOM 元素,而不是组件实例。它还有一个注入器,允许我们访问已注入到组件中的服务。稍后会详细介绍这个主题。
这是对我们的测试环境、使用的框架和库以及一些重要概念的简要概述,我们将在接下来的部分中大量使用它们。
组件测试简介
到目前为止,我们进行任何 Angular 操作的通常方法是使用 Angular CLI。处理测试也不例外。Angular CLI 让我们创建测试,调试它们并运行它们;它还让我们了解我们的测试覆盖了代码及其许多场景的程度。让我们快速看一下如何使用 Angular CLI 进行单元测试,并尝试理解默认情况下给我们的内容。
如果您想跟着本章的代码进行编写,可以使用旧的 Angular 项目并为其添加测试,或者创建一个新的独立项目,如果您只想专注于实践测试。选择权在您。
如果您选择创建一个新项目,然后键入以下内容进行搭建:
ng new AngularTestDemo
// go make coffee :)
cd AngularTestDemo
ng serve
Angular CLI 已经设置好了测试,所以我们需要做的唯一的事情就是跟随它的步伐并添加更多的测试,但让我们首先检查一下我们已经得到了什么,并学习一些很棒的命令,以使测试工作更容易。
我们想要做的第一件事是:
-
调查 Angular CLI 给我们的测试
-
运行测试
通过查看搭建的directory/app,我们看到了以下内容:
app.component.ts
app.component.spec.ts
我们看到一个组件被声明,连同一个单元测试。这意味着我们可以对我们的组件进行测试,这是非常好的消息,因为它节省了我们一些输入。
让我们看一下给我们的测试:
import { TestBed, async } from '@angular/core/testing'; import { AppComponent } from './app.component';
describe('AppComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent
],
}).compileComponents();
}));
it('should create the app', async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app).toBeTruthy();
}));
it(`should have as title 'app works!'`, async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app.title).toEqual('app works!'); }));
it('should render title in a h1 tag', async(() => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); const compiled = fixture.debugElement.nativeElement;
const actual = compiled.querySelector('h1').textContent; expect(actual).toContain('app works!');
}));
});
这是很多代码,但我们会逐步分解它。我们看到在文件的开头有测试设置,编写了三个不同的测试。让我们先看一下设置阶段:
beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ AppComponent
],
}).compileComponents(); }));
在这里我们调用beforeEach(),就像我们在 Jasmine 测试中通常做的那样,以便在每个测试实际发生之前运行代码。在beforeEach()中,我们调用TestBed.configureTestingModule()方法,带有一个对象作为参数。这个对象类似于我们给NgModule作为参数的对象。这意味着我们可以利用我们对NgModule以及如何设置 Angular 模块的知识,并将其应用到如何设置测试模块,因为它实际上是一样的。从代码中可以看出,我们指定了一个包含AppComponent的声明数组。对于NgModule来说,这意味着AppComponent属于该模块。最后,我们调用了compileComponents()方法,设置完成。
那么compileComponents()是做什么的呢?根据它的名称,它编译了在测试模块中配置的组件。在编译过程中,它还内联外部 CSS 文件以及外部模板。通过调用compileComponents(),我们也关闭了进一步配置测试模块实例的可能性。
我们测试文件的第二部分是测试。看一下第一个测试:
it('should create the app', async(() => {
> const fixture = TestBed.createComponent(AppComponent); const app = fixture.componentInstance; expect(app).toBeTruthy(); }));
我们看到我们调用了TestBed.createComponent(AppComponent),这返回一个类型为ComponentFixture<AppComponent>的对象。我们可以通过调用这个对象进一步进行交互:
const app = fixture.debugElement.componentInstance;
这给了我们一个组件实例,这就是当我们从以下类实例化一个对象时得到的东西:
@Component({})
export class AppComponent {
title: string;
}
第一个测试只是想验证我们能否创建一个组件,expect条件测试的就是这个,即expect(app)是真实的,意思是它是否被声明;而事实上它是。
对于第二个测试,我们实际上是要调查我们的组件是否包含我们认为的属性和值;所以测试看起来像这样:
it(`should have as title 'app works!'`, async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app.title).toEqual('app works!'); }));
现在,这个测试创建了一个组件,但它也调用了fixture.detectChanges,这告诉 Angular 强制进行变更检测。这将确保构造函数中的代码和任何ngInit()(如果存在)都被执行。
通过组件规范,我们期望在创建组件时title属性应该被设置,就像这样:
@Component({})
export class AppComponent {
title: string = 'app works!'
}
这正是第二个测试正在测试的:
expect(app.title).toEqual('app works!');
让我们看看如何通过在app.component.ts中添加一个字段来扩展它的功能:
@Component({})
export class AppComponent {
title: string;
description: string;
constructor() {
this.title = 'app works'
this.description ='description';
}
}
我们添加了描述字段,并用一个值进行了初始化;我们将测试这个值是否设置为我们的属性。因此,我们需要在我们的测试中添加额外的expect条件,所以测试现在看起来像这样:
it(`should have as title 'app works!'`, async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app.title).toEqual('app works!');
**expect(app.description).toEqual('description');** }));
正如你所看到的,我们有了额外的expect条件,测试通过了,这正是应该的。不过,不要只听我们的话;让我们使用 node 命令运行我们的测试运行程序。我们通过输入以下内容来做到这一点:
npm test
这将执行测试运行程序,应该看起来像这样:
这意味着我们知道如何扩展我们的组件并对其进行测试。作为奖励,我们现在也知道如何运行我们的测试。让我们看看第三个测试。它有点不同,因为它测试模板:
it('should render title in a h1 tag', async(() => { const fixture = TestBed.createComponent(AppComponent); fixture.detectChanges(); const compiled = fixture.debugElement.nativeElement; expect(compiled.querySelector('h1').textContent).toContain('app works!'); }));
我们不再与fixture.debugElement.componentInstance交谈,而是与fixture.debugElement.nativeElement交谈。这将允许我们验证预期的 HTML 标记是否与我们认为的一样。当我们可以访问nativeElement时,我们可以使用querySelector并找到我们在模板中定义的元素并验证它们的内容。
通过查看我们得到的测试,我们获得了很多见解。我们现在知道以下内容:
-
我们通过调用
TestBed.configureTestingModule()并传递一个类似于我们传递给NgModule的对象来设置测试 -
我们调用
TestBed.createComponent(<Component>)来获取对组件的引用 -
我们调用
debugElement.componentInstance来获取到实际的组件,我们可以测试组件对象上应该存在的属性的存在和值 -
我们调用
debugElement.nativeElement来获取对nativeElement的引用,现在可以开始验证生成的 HTML -
我们还学会了如何通过输入
npm test在浏览器中运行我们的测试
fixture.debugElement.nativeElement指向 HTML 元素本身。当我们使用querySelector()方法时,实际上使用的是 Web API 中可用的方法;这不是 Angular 方法。
具有依赖关系的组件测试
我们已经学到了很多,但让我们面对现实,我们构建的任何组件都不会像我们在前面的部分中编写的那样简单。几乎肯定会至少有一个依赖项,看起来像这样:
@Component({})
export class ExampleComponent {
constructor(dependency:Dependency) {}
}
我们有不同的方法来处理测试这样的情况。不过有一点是清楚的:如果我们正在测试组件,那么我们不应该同时测试服务。这意味着当我们设置这样的测试时,依赖项不应该是真正的东西。在进行单元测试时,处理这种情况有不同的方法;没有一种解决方案比另一种严格更好:
-
使用存根意味着我们告诉依赖注入器注入我们提供的存根,而不是真正的东西
-
注入真正的东西,但附加一个间谍,调用我们组件中的方法
无论采用何种方法,我们都确保测试不会执行诸如与文件系统交谈或尝试通过 HTTP 进行通信等副作用;使用这种方法,我们是隔离的。
使用存根来替换依赖项
使用存根意味着我们完全替换了以前的东西。指导TestBed进行这样的操作就像这样简单:
TestBed.configureTestingModule({
declarations: [ExampleComponent]
providers: [{
provide: DependencyService,
useClass: DependencyServiceStub
}]
});
我们像在NgModule中那样定义一个providers数组,并给它一个指出我们打算替换的定义的列表项,然后给它替换;那就是我们的存根。
现在让我们构建我们的DependencyStub看起来像这样:
class DependencyServiceStub {
getData() { return 'stub'; }
}
就像使用@NgModule一样,我们能够用我们自己的存根覆盖我们的依赖的定义。想象一下我们的组件看起来像下面这样:
import { Component } from '@angular/core'; import { DependencyService } from "./dependency.service";
@Component({
selector: 'example',
template: `
<div>{{ title }}</div>
`
})
export class ExampleComponent { title: string;
constructor(private dependency: DependencyService) {
this.title = this.dependency.getData();
}
}
在构造函数中传递依赖的一个实例。通过正确设置我们的测试模块,使用我们的存根,我们现在可以编写一个像这样的测试:
it(`should have as title 'stub'`, async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app.title).toEqual('stub'**);** }));
测试看起来正常,但在组件代码中调用依赖项时,我们的存根会代替它并做出响应。我们的依赖应该被覆盖,正如你所看到的,expect(app.title).toEqual('stub')假设存根会回答,而它确实会回答。
对依赖方法进行间谍监视
前面提到的使用存根的方法并不是在单元测试中隔离自己的唯一方法。我们不必替换整个依赖项,只需替换组件正在使用的部分。替换某些部分意味着我们指出依赖项上的特定方法,并对其进行间谍监视。间谍是一个有趣的构造;它有能力回答你想要的问题,但你也可以看到它被调用了多少次以及使用了什么参数,因此间谍可以为你提供更多关于发生了什么的信息。让我们看看我们如何设置一个间谍:
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ExampleComponent],
providers: [DependencyService]
});
dependency = TestBed.get(DependencyService);
spy = spyOn( dependency,'getData');
fixture = TestBed.createComponent(ExampleComponent);
})
现在你可以看到,实际的依赖项被注入到了组件中。之后,我们获取了组件的引用,即我们的 fixture 变量。然后,我们使用TestBed.get('Dependency')来获取组件内的依赖项。在这一点上,我们通过spyOn( dependency,'getData')来对其getData()方法进行间谍监视。
然而,这还不够;我们还需要指示间谍在被调用时如何回应。让我们来做到这一点:
spyOn(dependency,'getData').and.returnValue('spy value');
现在我们可以像往常一样编写我们的测试:
it('test our spy dependency', () => {
var component = fixture.debugElement.componentInstance;
expect(component.title).toBe('spy value');
});
这符合预期,我们的间谍回应得当。还记得我们说过间谍不仅能够回应一个值,还能够检查它们是否被调用以及使用了什么吗?为了展示这一点,我们需要稍微改进我们的测试,并检查这个扩展功能,就像这样:
it('test our spy dependency', () => {
var component = fixture.debugElement.componentInstance;
expect(spy.calls.any()).toBeTruthy();
})
您还可以检查它被调用的次数,使用spy.callCount,或者它是否被调用以及具体的参数:spy.mostRecentCalls.args或spy.toHaveBeenCalledWith('arg1', 'arg2')。请记住,如果您使用间谍,请确保它通过您需要进行这些检查来支付自己的代价;否则,您可能还不如使用存根。
间谍是 Jasmine 框架的一个特性,而不是 Angular。建议感兴趣的读者在tobyho.com/2011/12/15/jasmine-spy-cheatsheet/上进一步研究这个主题。
异步服务
很少有服务是良好且行为端正的,就是它们是同步的意义上。大部分时间,您的服务将是异步的,而从中返回的最可能是一个 observable 或一个 promise。如果您正在使用 RxJS 与Http服务或HttpClient,它将是一个 observable,但如果使用fetchAPI,它将是一个 promise。这两种处理 HTTP 的方法都很好,但 Angular 团队将 RxJS 库添加到 Angular 中,以使开发人员的生活更轻松。最终由您决定,但我们建议使用 RxJS。
Angular 已经准备好了两种构造来处理测试时的异步场景。
-
async()和whenStable():这段代码确保任何承诺都会立即解决;尽管看起来更同步 -
fakeAsync()和tick():这段代码做了 async 的事情,但在使用时看起来更同步
让我们描述一下async()和whenStable()的方法。当我们调用服务时,我们的服务现在已经成熟并且正在执行一些异步操作,比如超时或 HTTP 调用。无论如何,答案不会立即传达给我们。然而,通过结合使用async()和whenStable(),我们可以确保任何承诺都会立即解决。想象一下我们的服务现在是这样的:
export class AsyncDependencyService {
getData(): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => { resolve('data') }, 3000);
})
}
}
我们需要更改我们的 spy 设置,以返回一个 promise 而不是返回一个静态字符串,就像这样:
spy = spyOn(dependency,'getData')
.and.returnValue(Promise.resolve('spy data'));
我们确实需要在我们的组件内部进行更改,就像这样:
import { Component, OnInit } from '@angular/core'; import { AsyncDependencyService } from "./async.dependency.service"; @Component({
selector: 'async-example',
template: `
<div>{{ title }}</div>
`
})
export class AsyncExampleComponent { title: string;
constructor(private service: AsyncDependencyService) {
this.service.getData().then(data => this.title = data);
}
}
此时,是时候更新我们的测试了。我们需要做两件事。我们需要告诉我们的测试方法使用async()函数,就像这样:
it('async test', async() => {
// the test body
})
我们还需要调用fixture.whenStable(),以确保 promise 有足够的时间来解决,就像这样:
import { TestBed } from "@angular/core/testing"; import { AsyncExampleComponent } from "./async.example.component"; import { AsyncDependencyService } from "./async.dependency.service";
describe('test an component with an async service', () => { let fixture;
beforeEach(() => { TestBed.configureTestingModule({
declarations: [AsyncExampleComponent],
providers: [AsyncDependencyService]
});
fixture = TestBed.createComponent(AsyncExampleComponent);
});
it('should contain async data', async () => { const component = fixture.componentInstance;
fixture.whenStable.then(() => {
fixture.detectChanges();
expect(component.title).toBe('async data');
});
});
});
这种做法可以正常工作,但感觉有点笨拙。还有另一种方法,使用fakeAsync()和tick()。基本上,fakeAsync()替换了async()调用,我们摆脱了whenStable()。然而,最大的好处是我们不再需要将断言语句放在 promise 的then()回调中。这给我们提供了看起来是同步的代码。回到fakeAsync(),我们需要调用tick(),它只能在fakeAsync()调用内部调用,就像这样:
it('async test', fakeAsync() => {
let component = fixture.componentInstance;
fixture.detectChanges();
fixture.tick();
expect(component.title).toBe('spy data');
});
正如您所看到的,这看起来更清晰;您想要使用哪个版本进行异步测试取决于您。
测试管道
管道基本上是实现PipeTransform接口的类,因此公开了通常是同步的transform()方法。因此,管道非常容易测试。我们将从测试一个简单的管道开始,创建一个测试规范,就像我们提到的,紧挨着它的代码单元文件。代码如下:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'formattedpipe' })
export class FormattedPipe implements PipeTransform { transform(value: any, ...args: any[]): any { return "banana" + value; }
}
我们的代码非常简单;我们取一个值并添加banana。为它编写一个测试同样简单。我们需要做的唯一一件事就是导入管道并验证两件事:
-
它是否有一个 transform 方法
-
它产生了预期的结果
以下代码为前面列出的每个要点编写了一个测试:
import FormattedTimePipe from './formatted-time.pipe';
import { TestBed } from '@angular/core/testing';
describe('A formatted time pipe' , () => {
let fixture;
beforeEach(() => {
fixture = new FormattedTimePipe();
}) // Specs with assertions
it('should expose a transform() method', () => {
expect(typeof formattedTimePipe.transform).toEqual('function');
});
it('should produce expected result', () => {
expect(fixture.transform( 'val' )).toBe('bananaval');
})
});
在我们的beforeEach()方法中,我们通过实例化管道类来设置 fixture。在第一个测试中,我们确保transform()方法存在。接着是我们的第二个测试,断言transform()方法产生了预期的结果。
使用 HttpClientTestingController 模拟 HTTP 响应
一旦你理解了如何开始模拟 HTTP,就会变得非常简单。让我们首先看一下我们打算测试的服务:
import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; @Injectable() export class JediService { apiUrl: string = 'something'; constructor(private http: HttpClient) {} getJedis() { return this.http.get(`/api/jedis`); }
}
在测试我们的服务时,有两个重要的参与者:
-
HttpTestingController,我们可以指示这个类监听特定的 URL 以及在被调用时如何做出响应 -
我们要测试的是我们的服务;我们真正想要做的唯一一件事就是调用它
与所有测试一样,我们有一个设置阶段。在这里,我们需要导入包含我们的HttpTestingController的模块HttpClientTestingModule。我们还需要告诉它为我们提供服务,就像这样:
import {
HttpClientTestingModule,
HttpTestingController
} from '@angular/common/http/testing';
import { JediService } from './jedi.service'; describe('testing our service', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [JediService]
});
});
});
下一步是设置测试,通过设置我们需要获取我们的服务的实例以及HttpTestingController来设置。我们还需要指示后者期望的 API 调用类型,并提供适当的模拟数据以做出响应:
it('testing getJedis() and expect a list of jedis back', () => {
// get an instance of a Jedi service and HttpTestingController
const jediService = TestBed.get(JediService);
const http = TestBed.get(HttpTestingController);
// define our mock data
const expected = [{ name: 'Luke' }, { name: 'Darth Vader' }]; let actual = [];
// we actively call getJedis() on jediService,
// we will set that response to our 'actual' variable jediService.getJedis().subscribe( data => { expect(data).toEqual(expected**);** });
/*
when someone calls URL /api/jedis
we will resolve that asynchronous operation
with .flush() while also answering with
'expected' variable as response data
*/
http.expectOne('/api/jedis').flush(expected); });
我们为前面的代码片段提供了内联注释,但只是为了再次描述发生了什么,我们的测试有三个阶段:
-
安排:这是我们获取
JediService实例以及HttpTestingController实例的地方。我们还通过设置expected变量来定义我们的模拟数据。 -
行动:我们通过调用
jediService.getJedis()来执行测试。这是一个 observable,所以我们需要订阅它的内容。 -
断言:我们通过调用
flush(expected)来解析异步代码,并断言我们通过进行断言expect(actual).toEqual(expected)得到了正确的数据。
如您所见,伪造对 HTTP 的调用非常容易。让我们展示整个单元测试代码:
import { HttpTestingController,
HttpClientTestingModule } from '@angular/common/http/testing/'; import { TestBed } from '@angular/core/testing'; import { JediService } from './jedi-service'; describe('a jedi service', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [HttpClientTestingModule], providers: [JediService] }));
it('should list the jedis', () => { const jediService = TestBed.get(JediService); const http = TestBed.get(HttpTestingController); // fake response
const expected = [{ name: 'Luke' }, { name: 'Darth Vader' }]; let actual = []; jediService.getJedis().subscribe( data => { expect(data).toEqual(expected); });
http.expectOne('/api/jedis').flush(expected); });
});
输入和输出
到目前为止,我们已经测试了组件,即我们已经测试了组件上的简单属性以及如何处理依赖项,同步和异步,但组件还有更多内容。组件还可以具有应该进行测试的输入和输出。因为我们的上下文是绝地武士,我们知道绝地武士通常有方法可以转向光明面或黑暗面。想象一下我们的组件在绝地管理系统的上下文中使用;我们希望能够将绝地武士转向黑暗面,也能够将其带回光明面。我们讨论的当然是切换功能。
因此,想象一下我们有一个看起来像这样的组件:
@Component({
selector : 'jedi-detail'
template : `
<div class="jedi"
(click)="switchSide.emit(jedi)">
{{ jedi.name }} {{ jedi.side }}
</div>
`
})
export class JediComponent {
@Input() jedi:Jedi;
@Output() switchSide = new EventEmitter<Jedi>();
}
测试这样一个组件应该以两种方式进行:
-
我们应该验证我们的输入绑定是否正确设置
-
我们应该验证我们的输出绑定是否正确触发,以及接收到的内容
从@Input开始,对其进行测试如下:
describe('A Jedi detail component', () => {
it('should display the jedi name Luke when input is assigned a Jedi object', () => {
const component = fixture.debugElement.componentInstance;
component.jedi = new Jedi(1, 'Luke', 'Light');
fixture.detectChanges();
expect(component.jedi.name).toBe('Luke');
});
});
这里值得注意的是我们对fixture.detectChanges()的调用,这确保了绑定发生在组件中。
让我们现在来看看如何测试@Output。我们需要做的是以某种方式触发它。我们需要点击模板中定义的 div。为了接收switchSide属性发出的值,我们需要订阅它,所以我们需要做两件事:
-
找到
div元素并触发点击 -
订阅数据的发射并验证我们是否收到了
jedi对象
至于获取 div 的引用,可以很容易地完成,如下所示:
const elem = fixture.debugElement.query(By.css('.jedi'));
elem.triggerEventHandler('click', null);
对于第二部分,我们需要订阅switchSide Observable 并捕获数据,如下所示:
it('should invoke switchSide with the correct Jedi instance, () => {
let selectedJedi;
// emitting data
component.switchSide.subscribe(data => {
expect(data.name).toBe('Luke');
});
const elem = fixture.debugElement.query(By.css('.jedi'));
elem.triggerEventHandler('click', null);
})
通过这段代码,我们能够间接触发输出的发射,通过点击事件监听输出,通过订阅。
测试路由
就像组件一样,路由在我们的应用程序提供高效用户体验方面发挥着重要作用。因此,测试路由变得至关重要,以确保无缝的性能。我们可以对路由进行不同的测试,并且需要针对不同的场景进行测试。这些场景包括:
-
确保导航指向正确的路由地址
-
确保正确的参数可用,以便您可以为组件获取正确的数据,或者过滤组件需要的数据集
-
确保某个路由最终加载预期的组件
测试导航
让我们看看第一个要点。要加载特定路由,我们可以在Router类上调用navigateToUrl(url)方法。一个很好的测试是确保当组件中发生某种状态时,会调用这样的方法。例如,可能会有一个创建组件页面,在保存后应该导航回到列表页面,或者缺少路由参数应该导航回到主页。在组件内部进行程序化导航有多个很好的理由。让我们看一些组件中的代码,其中进行这样的导航:
@Component({})
export class ExampleComponent {
constructor(private router: Router) {}
back() {
this.router.navigateByUrl('/list');
}
}
在这里我们可以看到调用back()方法将执行导航。为此编写测试非常简单。测试应该测试navigateToUrl()方法是否被调用。我们的方法将包括在路由服务中存根化以及在navigateToUrl()方法本身上添加一个间谍。首先,我们定义一个存根,然后指示我们的测试模块使用该存根。我们还确保我们创建了组件的一个实例,以便稍后在其上调用back()方法,就像这样:
describe('Testing routing in a component using a Stub', () => {
let component, fixture;
class RouterStub {
navigateByUrl() {}
}
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ExampleRoutingComponent],
providers: [{
// replace 'Router' with our Stub
provide: Router, useClass: RouterStub
}]
}).compileComponents();
})
beforeEach(() => {
fixture = TestBed.createComponent(Component); component = fixture.debugElement.componentInstance;
})
// ... test to be defined here
}
接下来我们需要做的是定义我们的测试并注入路由实例。一旦我们这样做了,我们就可以在navigateToUrl()方法上设置一个间谍:
import { inject } from '@angular/core/testing';
it('test back() method', inject([Router], router: Router) => {
const spy = spyOn(router, 'navigateByUrl');
// ... more to come here
})
现在在这一点上,我们希望测试测试的是方法是否被调用。编写这样的测试可以被视为防御性的。和测试正确性一样重要的是,编写测试以确保另一个开发人员,或者你自己,不会删除应该工作的行为。因此,让我们添加一些验证逻辑,以确保我们的间谍被调用:
import { inject } from '@angular/core/testing';
it('test back() method', inject([Router], (router: Router)) => {
const spy = spyOn(router, 'navigateByUrl');
// invoking our back method that should call the spy in turn
component.back();
expect(spy.calls.any()).toBe(true);
}))
整个测试现在是用存根替换原始的路由服务。我们在存根上的navigateByUrl()方法上附加了一个间谍,最后我们断言该间谍在调用back()方法时被调用如预期:
describe('Testing routing in a component', () => {
class RouterStub {
navigateByUrl() {}
}
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{
provide: Router, useClass: RouterStub
}]
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(Component); component = fixture.debugElement.componentInstance;
});
it('should call navigateToUrl with argument /list', () => {
spyOn(router, 'navigateByUrl');
/*
invoking our back() method
that should call the spy in turn
*/
component.back();
expect(router.navigateByUrl).toHaveBeenCalledWithArgs('/list');
})
})
通过 URL 测试路由
到目前为止,我们已经通过在导航方法上放置间谍来测试路由,并且在具有路由参数的情况下,我们必须为 Observable 构建一个模拟。不过,还有另一种方法,那就是让路由发生,然后调查我们最终停留在哪里。假设我们有以下情景:我们在列表组件上,想要导航到详细组件。导航发生后,我们想要调查我们所处的状态。让我们首先定义我们的列表组件:
import { Router } from '@angular/router'; import { Component, OnInit } from '@angular/core';
@Component({
selector: 'list-component', template : `` })
export class ListComponent {
constructor(private router: Router) {}
goToDetail() { this.router.navigateByUrl('detail/1'); } }
如您所见,我们有一个goToDetail()方法,如果调用,将会将您导航到一个新的路由。但是,为了使其工作,我们需要在模块文件中正确设置路由,如下所示:
const appRoutes: Routes = [ { path: 'detail/:id', component: DetailComponent } ];
@NgModule({
...
imports: [ BrowserModule, FormsModule, HttpClientModule, RouterModule.forRoot(appRoutes**),** TestModule
],
... })
export class AppModule { }
重要的部分在于appRoutes的定义和在导入数组中调用RouterModule.forRoot()。
现在是定义此测试的时候了。我们需要与一个名为RouterTestingModule的模块进行交互,并且我们需要为该模块提供应该包含的路由。RouterTestingModule是一个非常合格的路由存根版本,因此从原则上讲,与创建自己的存根没有太大区别。不过,可以这样看待,您可以创建自己的存根,但随着您使用越来越多的高级功能,使用高级存根很快就会得到回报。
我们将首先指示我们的RouterTestingModule,当命中detail/:id路由时,它应该加载DetailComponent。这与我们如何从我们的root模块设置路由没有太大区别。好处在于,我们只需要为我们的测试设置我们需要的路由,而不是应用中的每一个路由都需要设置:
beforeEach(() => { TestBed.configureTestingModule({ imports: [ RouterTestingModule.withRoutes([{
path: 'detail/:id',
component: DetailComponent }])
], declarations: [ListComponent, DetailComponent] });
});
完成设置后,我们需要在测试中获取组件的副本,以便调用将我们从列表组件导航出去的方法。您的测试应该如下所示:
it('should navigate to /detail/1 when invoking gotoDetail()', async() => { let fixture = TestBed.createComponent(ListComponent); let router = TestBed.get(Router); let component = fixture.debugElement.componentInstance;
fixture.whenStable().then(() => { expect(router.url).toBe('/detail/1');
});
**component.goToDetail();** })
这里重要的部分是调用使我们导航的方法:
component.goToDetail();
以及我们验证我们的路由器确实已经改变状态的断言:
expect(router.url).toBe('/detail/1');
测试路由参数
您将拥有一些执行路由的组件和一些被路由到的组件。有时,被路由到的组件会有一个参数,通常它们的路由看起来像这样:/jedis/:id。然后,组件的任务是挖出 ID 参数,并在匹配此 ID 的具体绝地武士上进行查找。因此,将调用一个服务,并且响应应该填充我们组件中的适当参数,然后我们可以在模板中显示。这样的组件通常看起来像这样:
import { ActivatedRoute, Router } from '@angular/router'; import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs/Rx'; import { Jedi } from './jedi.model'; import { JediService } from './jedi.service'; @Component({
selector: 'detail-component', templateUrl: 'detail.component.html' })
export class ExampleRoutingParamsComponent{
jedi: Jedi; constructor( private router: Router, private route: ActivatedRoute, private jediService : JediService ) {
route.paramMap.subscribe( p => { const id = p.get('id'); jediService.getJedi( id ).subscribe( data => this.jedi = data ); });
} }
值得强调的是我们如何获取路由中的参数。我们与ActivatedRouter实例交互,我们将其命名为route,以及它的paramMap属性,这是一个可观察对象,如下所示:
route.paramMap.subscribe( p => { const id = p.get('id'); jediService.getJedi(id).subscribe( data => this.jedi = data ) })
那么我们想要测试什么呢?我们想知道,如果某个路由包含一个 ID 参数,那么我们的jedi属性应该通过我们的服务正确填充。我们不想进行实际的 HTTP 调用,因此我们的JediService需要以某种方式进行模拟,并且还有另一件使事情复杂化的事情,即route.paramMap也需要被模拟,而那个东西是一个可观察对象。
这意味着我们需要一种创建可观察对象存根的方法。这可能听起来有点令人生畏,但实际上并不是;多亏了Subject,我们可以很容易地做到这一点。Subject具有一个很好的能力,即我们可以订阅它,但我们也可以向它传递值。有了这个知识,让我们开始创建我们的ActivatedRouteStub:
import { convertToParamMap } from '@angular/router';
class ActivatedRouteStub {
private subject: Subject<any>;
constructor() {
this.subject = new Subject();
}
sendParameters( params : {}) {
this.subject.next(convertToParamMap(params)); // emitting data
}
get paramMap() {
return this.subject.asObservable();
}
}
现在,让我们解释一下这段代码,我们添加了sendValue()方法,以便它可以将我们给它的值传递给主题。我们公开了paramMap属性,作为一个可观察对象,这样我们就可以在主题发出任何值时监听它。但这如何与我们的测试相关呢?嗯,在存储阶段,我们希望在beforeEach()内调用存根的sendValue。这是我们模拟通过路由到达我们的组件并传递参数的一种方式。在测试本身中,我们希望监听路由参数何时被发送给我们,以便我们可以将其传递给我们的jediService。因此,让我们开始勾勒测试。我们将分两步构建测试:
-
第一步是通过传递
ActivatedRouteStub来支持对ActivatedRoute的模拟。 -
第二步是设置
jediService的模拟,确保拦截所有 HTTP 调用,并且当发生 HTTP 调用时我们能够用模拟数据做出响应。
首先,我们设置测试,就像我们迄今为止所做的那样,调用TestBed.configureTestingModule()并传递一个对象。我们提到我们已经为激活的路由构建了一个存根,并且我们需要确保提供这个存根而不是真正的ActivatedRoute。代码如下所示:
describe('A detail component', () => {
let fixture, component, activatedRoute;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{
provide: ActivatedRoute,
useClass: ActivatedRouteStub
}, JediService]
})
})
})
这意味着当我们的组件在构造函数中获取ActivatedRoute依赖注入时,它将注入ActivatedRouteStub,就像这样:
@Component({})
export class ExampleRoutingParamsComponent {
// will inject ActivatedRouteStub
constructor(activatedRoute: ActivatedRoute) {}
}
继续我们的测试,我们需要做三件事:
-
实例化组件
-
将路由参数传递给我们的
ActivatedRouteStub,以便发出路由参数 -
订阅
ActivatedRouteStub,以便我们可以断言参数确实被发出
让我们将这些添加到我们的测试代码中:
beforeEach(() => {
fixture = TestBed.createComponent(ExampleRoutingParamsComponent);
component = fixture.debugElement.componentInstance; activatedRoute = TestBed.get(ActivatedRoute); })
现在我们已经设置好了 fixture、组件和我们的activatedRouteStub。下一步是将实际的路由参数传递给activatedRouteStub,并设置一个subscribe来知道何时接收到新的路由参数。我们在测试本身中执行这个操作,而不是在beforeEach()方法中,就像这样:
it('should execute the ExampleRoutingParamsComponent', () => {
// listen for the router parameter
activatedRoute.paramMap.subscribe(para => { const id = para.get('id');
// assert that the correct routing parameter is being emitted expect(id).toBe(1);
});
// send the route parameter
activatedRoute.sendParameters({ id : 1 }); })
那么这对我们的组件意味着什么?在这个阶段我们测试了多少我们的组件?让我们看看我们的DetailComponent,并突出显示到目前为止我们测试覆盖的代码:
@Component({})
export class ExampleRoutingParamsComponent {
constructor( activatedRoute: ActivatedRoute ) {
activatedRoute.paramMap.subscribe( paramMap => {
const id = paramMap.get('id');
// TODO call service with id parameter
})
}
}
正如你所看到的,在测试中,我们已经覆盖了activatedRoute的模拟,并成功订阅了它。在组件和测试中都缺少的是要考虑到调用一个调用 HTTP 的服务。让我们首先将该代码添加到组件中,就像这样:
@Component({})
export class ExampleRoutingParamsComponent implements OnInit {
jedi: Jedi;
constructor(
private activatedRoute: ActivatedRoute,
private jediService: JediService ) {}
ngOnInit() {
this.activatedRoute.paramMap.subscribe(route => {
const id = route.get('id')
this.jediService.getJedi(id).subscribe(data => this.jedi = data);
});
}
}
在代码中,我们添加了Jedi字段以及对this.jediService.getJedi()的调用。我们订阅了结果,并将操作的结果分配给了Jedi字段。为这部分添加测试支持是我们在前面关于模拟 HTTP 的部分已经涵盖过的。重复这一点是很好的,所以让我们添加必要的代码到单元测试中,就像这样:
it('should call the Http service with link /api/jedis/1', () => {
.. rest of the test remains the same
const jediService = TestBed.get(JediService); const http = TestBed.get(HttpTestingController);
// fake response
const expected = { name: 'Luke', id: 1 }; let actual = {}; http.expectOne('/api/jedis/1').flush(expected);
... rest of the test remains the same })
我们在这里做的是通过从TestBed.get()方法请求JediService的副本。此外,我们要求一个HttpTestingController的实例。我们继续定义我们想要响应的预期数据,并指示HttpTestingController的实例应该期望调用/api/jedis/1,当发生这种情况时,预期的数据应该被返回。所以现在我们有一个测试,涵盖了测试ActivatedRoute参数的场景,以及 HTTP 调用。测试的完整代码如下:
import { Subject } from 'rxjs/Rx'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule,
HttpTestingController } from "@angular/common/http/testing"; import { JediService } from './jedi-service'; import { ExampleRoutingParamsComponent } from './example.routing.params.component'; class ActivatedRouteStub { subject: Subject<any>; constructor() { this.subject = new Subject();
}
sendParameters(params: {}) {
const paramMap = convertToParamMap(params); this.subject.next( paramMap ); }
get paramMap() { return this.subject.asObservable(); }
}
describe('A detail component', () => { let activatedRoute, fixture, component; beforeEach(async() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule ], declarations: [ ExampleRoutingParamsComponent ], providers: [
{ provide: ActivatedRoute, useClass: ActivatedRouteStub },
JediService ] });
})
beforeEach(() => { fixture = TestBed.createComponent(ExampleRoutingParamsComponent); component = fixture.componentInstance; activatedRoute = TestBed.get(ActivatedRoute); });
it('should call the Http service with the route /api/jedis/1 and should display the jedi name corresponding to the id number in the route', async() => { activatedRoute.paramMap.subscribe((para) => { const id = para.get('id'); expect(id).toBe(1); });
activatedRoute.sendParameters({ id : 1 }); const http = TestBed.get(HttpTestingController); // fake response
const expected = { name: 'Luke', id: 1 }; let actual = {}; http.expectOne('/api/jedis/1').flush(expected); fixture.detectChanges(); fixture.whenStable().then(() => { expect(component.jedi.name).toBe('Luke'); });
});
});
那么我们从测试路由参数中学到了什么?由于我们需要创建我们的ActivatedRouteStub,所以有点麻烦,但总的来说,还是相当简单的。
测试指令
我们的单元测试 Angular 元素之旅的最后一站将涵盖指令。指令通常在整体形状上会相当简单,基本上就是没有附加视图的组件。指令通常与组件一起工作的事实给了我们一个很好的想法,该如何进行测试。
指令可以简单地表示为没有外部依赖项。它看起来像这样:
@Directive({
selector: 'some-directive'
})
export class SomeDirective {
someMethod() {}
}
测试很容易,你只需要从SomeDirective类中实例化一个对象。然而,你的指令可能会有依赖项,在这种情况下,我们需要通过将其附加到组件来隐式测试指令。让我们看一个例子。让我们首先定义指令,就像这样:
import { Directive,
ElementRef,
HostListener } from '@angular/core';
@Directive({ selector: '[banana]' }) export class BananaDirective { constructor(private elementRef: ElementRef) { } @HostListener('mouseover') onMouseOver() { this.elementRef.nativeElement.style.color = 'yellow'; }
@HostListener('mouseout') onMouseOut() { this.elementRef.nativeElement.style.color = 'inherit';
}
}
在这里,你看到的是一个简单的指令,如果我们悬停在上面,它会将字体颜色变成黄色。我们需要将它附加到一个组件上。让我们接下来定义一个元素,就像这样:
import { Component } from '@angular/core'; @Component({
selector: 'banana', template: ` <p class="banana" banana>hover me</p> `
})
export class BananaComponent {}
在这里,我们可以看到我们将元素作为属性添加到组件模板中定义的p标签中。
接下来,让我们来看看我们的测试。我们现在知道如何编写测试,特别是如何测试元素,所以下面的测试代码应该不会让你感到意外:
import { By } from '@angular/platform-browser'; import { TestBed } from "@angular/core/testing"; import { BananaComponent } from './banana.component'; import { BananaDirective } from './banana.directive'; describe('A banana directive', () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [BananaDirective, BananaComponent] }).compileComponents(); });
it('should set color property to yellow when mouseover event happens', () => { const fixture = TestBed.createComponent(BananaComponent); const element = fixture.debugElement.query(By.css('.banana')); element.triggerEventHandler('mouseover', null); fixture.detectChanges(); expect(element.nativeElement.style.color).toBe('yellow'); });
})
在beforeEach()方法中,我们与TestBed交谈,配置我们的测试模块,并告诉它关于BananaDirective和BananaComponent的信息,代码如下:
beforeEach(() => { TestBed.configureTestingModule({ declarations: [ BananaDirective, BananaComponent ] }).compileComponents(); });
在测试本身中,我们再次使用TestBed来创建一个组件。然后,我们通过 CSS 类找到我们的元素。我们找到元素以便触发一个事件,即mouseover。触发mouseover事件将触发指令中的代码,使字体颜色变为黄色。触发事件后,我们可以使用这行代码来断言元素的字体颜色:
expect(element.nativeElement.style.color).toBe('yellow');
现在,这就是测试指令的简单方法,即使它有依赖关系。关键是,如果是这种情况,您需要一个元素来放置指令,并且您通过元素隐式测试指令。
前方的道路
这个最后的测试示例总结了我们对 Angular 单元测试的探索,但请记住,我们只是触及了皮毛。一般来说,测试 Web 应用程序,特别是 Angular 应用程序,会出现许多通常需要特定方法的情况。请记住,如果一个特定的测试需要繁琐和复杂的解决方案,那么我们可能面临着模块重新设计的一个好案例。
我们应该从这里走向何方?有几条路径可以增进我们对 Angular 中 Web 应用程序测试的知识,并使我们成为优秀的测试忍者。
在测试堆栈中引入代码覆盖率报告
我们如何知道我们的测试有多远地测试了应用程序?我们能确定我们没有留下任何未经测试的代码吗?如果有,它是否相关?我们如何检测超出当前测试范围的代码片段,以便更好地评估它们是否值得测试?
这些问题可以通过在应用程序测试堆栈中引入代码覆盖率报告来轻松解决。代码覆盖工具旨在跟踪我们单元测试层的范围,并生成一个教育性报告,告诉您测试规范的整体覆盖范围以及仍未覆盖的代码片段。
有几种工具可以在我们的应用程序中实施代码覆盖率分析,目前最流行的是 Blanket(blanketjs.org)和 Istanbul(gotwarlost.github.io/istanbul)。在这两种情况下,安装过程都非常快速和简单。
实施端到端测试
在本章中,我们看到了如何通过评估 DOM 的状态来测试 UI 的某些部分。这给了我们一个很好的想法,从最终用户的角度来看事物会是什么样子,但最终这只是一个经过推敲的猜测。
端到端(E2E)测试是一种测试 Web 应用程序的方法,使用自动化代理程序,可以按照用户的流程从开始到结束进行程序化测试。与单元测试的要求相反,这里并不关心代码实现的细微差别,因为 E2E 测试涉及从用户端点开始到结束测试我们的应用程序。这种方法允许我们以集成的方式测试应用程序。而单元测试侧重于每个部分的可靠性,E2E 测试评估整体拼图的完整性,发现单元测试经常忽视的组件之间的集成问题。
对于 Angular 框架的上一个版本,Angular 团队构建了一个强大的工具,名为 Protractor(www.protractortest.org/),其定义如下:
“端到端测试运行器,模拟用户交互,将帮助您验证 Angular 应用程序的健康状况。”
测试的语法会变得非常熟悉,因为它也使用 Jasmine 来组织测试规范。不幸的是,E2E 超出了本书的范围,但有几个资源可以帮助您扩展对这一主题的了解。在这方面,我们推荐书籍《Angular 测试驱动开发》,Packt Publishing,它提供了关于使用 Protractor 为我们的 Angular 应用程序创建 E2E 测试套件的广泛见解。
摘要
我们已经走到了旅程的尽头,这绝对是一个漫长但令人兴奋的旅程。在本章中,您看到了在我们的 Angular 应用程序中引入单元测试的重要性,单元测试的基本形式,以及为我们的测试设置 Jasmine 的过程。您还看到了如何为我们的组件、指令、管道、路由和服务编写强大的测试。我们还讨论了在掌握 Angular 过程中的新挑战。可以说前方仍有很长的道路要走,而且绝对是一个令人兴奋的道路。
本章的结束也意味着这本书的结束,但体验将超越其界限。Angular 仍然是一个相当年轻的框架,因此,它将为社区带来的所有伟大事物尚未被创造出来。希望您能成为其中的创造者之一。如果是这样,请让作者知道。
感谢您抽出时间阅读这本书。