Angular 专家级编程(五)
原文:
zh.annas-archive.org/md5/EE5928A26B54D366BD1C7A331E3448D9译者:飞龙
第十三章:处理 Angular 动画
在本章中,我们将学习关于 Angular 动画。动画;这个词听起来很有趣和创造性,所以系好安全带;我们将乐在学习 Angular 动画。Web 应用程序中的动作是关键和重要的设计因素之一,也是良好用户体验的主要驱动因素。特别是过渡,它们非常有趣,因为它们使应用程序的元素从一个状态移动到另一个状态。
本章详细介绍以下主题:
-
介绍 Angular 动画
-
Angular 2 中内置的类来支持动画
-
理解和学习如何使用动画模块,
transition,states,keyframes等 -
页面过渡动画
-
动画切换/折叠手风琴幻灯片
介绍 Angular 动画
Angular 自带了对动画的坚实本地支持,因为运动和过渡是任何应用程序的重要部分。
Angular 具有内置的动画引擎,还支持和扩展了运行在大多数现代浏览器上的 Web 动画 API。
我们必须在项目文件夹中单独安装 Angular 动画。我们将在接下来的部分中创建一些动画示例。
安装 Angular 动画库
正如我们之前讨论的,Angular 动画已经被分离出来作为一个单独的库,需要单独安装。
在这一部分,我们将讨论如何获取最新的 Angular 动画版本并安装它;按照以下步骤进行:
- 获取最新的 Angular 动画库。
您可以使用以下npm命令进行安装:
npm install @angular/animations@latest --save
运行上述命令将保存最新版本的 Angular 动画库,并将其添加为package.json文件中的依赖项。
- 验证最新安装的 Angular 动画库。
确保我们已经安装了 Angular 动画库,打开package.json文件,应该在依赖列表中有@animations/animations的条目。
一旦 Angular 动画库已经被正确导入和安装,package.json文件应该看起来像以下的截图:
- 在
app.module.ts文件中导入 Angular 动画库。
我们需要在app.module.ts文件中导入 Angular 动画库。为了包含该库,我们将使用以下代码片段:
import { BrowserAnimationsModule } from '@angular/platform-
browser/animations';
- 在
ngModule装饰器的导入中包含 Angular 动画库:
@ngModule({
imports: [
BrowserModule,
BrowserAnimationsModule
],
//other imports
})
在前面的代码片段中,我们只是将BrowserAnimationsModule导入到我们的ngModule中,以便在整个应用程序中使用。
太棒了!现在我们的应用程序中有了 Angular 动画库,我们可以继续像往常一样构建我们的组件,添加动画和效果。
在我们开始编写使用动画的组件示例之前,重要的是花一些时间探索 Angular 动画中所有可用的类,以便我们可以利用它们。
Angular 动画 - 特定函数
如前所述,Angular 自带了一个独立的动画库,其中有许多内置类和方法来支持各种动画。
让我们了解本节中提供的各种内置类:
-
trigger -
transition -
state -
style -
animate
我们将详细学习上述每种方法,但在这之前,让我们快速看一下使用这些方法的一般语法。
编写动画的一般语法示例如下:
animations : [
trigger('slideInOut', [
state('in', style({
transform: 'translate3d(0, 0, 0)'
})),
state('out', style({
transform: 'translate3d(100%, 0, 0)'
})),
transition('in => out', animate('400ms ease-in-out')),
transition('out => in', animate('400ms ease-in-out'))
])
]
让我们详细分析前面的代码:
-
我们正在定义一个名为
slideInOut的触发器。 -
我们正在定义两个
states:in和out。 -
对于每个状态,我们都分配了一个样式,即每个相应状态的 CSS
transform属性。 -
我们还添加了
transition来提及state和animation的细节。
看起来很简单,对吧?是的,当然!
现在我们知道了如何编写动画的语法,让我们深入了解 Angular 动画库中提供的每种方法。
触发器
触发器定义了一个将触发动画的名称。触发器名称帮助我们确定基于事件应该触发哪个触发器。
定义触发器的一般语法如下:
trigger('triggerName', [
we define states and transitions here
])
在前面的代码语法中,我们正在定义以下内容:
-
通过传递一个必需的参数来定义触发器,即名称和可选参数,其中可以包括
state和transition。 -
触发器名称;我们定义一个名称来识别触发器。
-
我们还可以在触发器定义中将我们的状态和转换定义为参数。
状态
状态是元素在特定时间点的定义动画属性。
状态是我们应用程序的逻辑状态,例如活动和非活动。我们为状态定义状态名称和相应的样式属性。
定义状态的语法的一般语法如下:
state('in', style({
backgroundColor: '#ffffcc'
}))
在前面的代码语法中,我们正在定义以下内容:
-
我们正在定义一个名为
'in'的state,这是我们应用程序中的一个逻辑状态。 -
在样式中,我们定义了需要应用于元素的状态的
CSS属性。常规的CSS样式属性在这里被定义。
过渡
过渡允许元素在不同状态之间平滑移动。在过渡中,我们定义了各种状态(一个或多个)的动画。
状态是过渡的一部分。
编写transition的一般语法如下:
//Duration Example - seconds or milliseconds
transition('in => out', animate('100'))
// Easing Example: refer http://easings.net
transition('in => out', animate('300ms ease-in'))
// Using Delay in Animation
transition('in => out', animate('10s 50ms'))
在前面的代码语法中,我们正在定义以下内容
-
我们正在定义我们的过渡状态,即从起始状态到结束状态。在我们的语法中,它是从 in 状态到 out 状态。
-
动画选项如下:
-
缓动:动画进行的平滑程度
-
持续时间:动画从开始到结束运行的时间
-
延迟:延迟控制动画触发和过渡开始之间的时间长度。
通过对如何编写 Angular 动画的概念和语法有着深刻的理解,让我们继续使用前面的所有函数来创建示例。
页面过渡动画
在前面的部分中,我们为动画创建了一些状态。在本节中,我们将学习如何使用状态创建过渡。
transition是 Angular 动画库中最重要的方法,因为它负责所有效果和状态变化。
让我们创建一个完整页面过渡的示例。我们将创建组件类learn-animation.component.ts:
import { Component } from '@angular/core';
import { state, style, animate, trigger, transition, keyframes} from '@angular/core';
@Component({
templateUrl: './learn-animation.component.html',
styleUrls: ['./learn-animation.component.css'],
animations : [
trigger('customHover', [
state('inactive', style({
transform: 'scale(1)',
backgroundColor: '#ffffcc'
})),
state('active', style({
transform: 'scale(1.1)',
backgroundColor: '#c5cae8'
})),
transition('inactive => active', animate('100ms ease-in')),
transition('active => inactive', animate('100ms ease-out'))
]),
]
})
export class AppComponent {
title = 'Animation works!';
constructor() {}
state: string = 'inactive';
toggleBackground() {
this.state = (this.state === 'inactive' ? 'active' : 'inactive');
}
}
让我们详细分析前面的代码,以了解 Angular 动画:
-
我们正在定义一个名为
customHover的触发器。 -
我们正在定义两个
states:inactive和active。 -
对于每个状态,我们都分配了一个样式,即 CSS;对于各自的状态,我们分配了
transform和backgroundColor属性。 -
我们还添加了过渡来提及状态和动画细节:
-
transition影响状态从inactive到active的移动。 -
transition影响状态从active到inactive的移动。 -
我们正在定义一个
toggleBackground方法,当调用时,将从inactive状态切换到active状态,反之亦然。
现在我们已经创建了组件类,在我们的learn-animation.component.html模板中调用了toggleBackground方法:
<div>
<div id="content" [@customHover]='state'
(mouseover)="toggleBackground()"
(mouseout)="toggleBackground()">Watch this fade</div>
</div>
让我们详细分析前面的代码:
-
在
learn-animation.component.html中,我们正在定义一个div元素。 -
我们正在将
mouseover和mouseout事件与toggleBackground方法进行绑定。 -
由于我们将触发器定义为
@customHover,我们将使用它进行属性绑定。在我们放置[@customHover]的任何元素上,将应用所定义的动画。 -
由于我们应用了属性绑定,属性
@customHover的值将在active和inactive之间切换。 -
当我们将鼠标悬停在元素上时,将调用
toggleBackground方法,并且我们将看到背景颜色随着transform属性的变化而改变。 -
在鼠标移出事件上,再次调用
toggleBackground方法,并且样式将重置回原始状态。
运行应用程序,我们应该在以下截图中看到输出:
在本节中,我们讨论了如何使用基本的 Angular 动画。在下一节中,我们将探索更多动画示例。
另一个示例 - Angular 动画
在前一节中,我们学习了动画的基础知识;在本节中,我们将使用 Angular 动画创建另一个示例。
在这个例子中,我们将创建一个按钮和一个div元素。当点击按钮时,div元素将滑入页面。很酷,对吧?
让我们开始吧。将以下代码添加到我们在前一节中创建的组件文件learn-animation.component.ts中:
trigger('animationToggle', [
transition('show => hide', [
style({transform: 'translateX(-100%)'}),
animate(350) ]),
transition('hide => show', animate('3000ms'))
])
在前面的代码中,需要注意以下重要事项:
-
我们正在创建一个带有
animationToggle的触发器。 -
我们正在定义两个过渡,即从
show => hide和hide => show。 -
我们正在向
show => hide过渡添加样式属性。 -
我们没有向
hide => show过渡添加样式属性。
定义过渡样式并不是强制性的,但往往我们需要为具有动画效果的元素定义自定义样式。
运行应用程序,您应该在截图后看到以下应用程序和动画:
在我们的应用程序中,当您点击显示按钮时,DIV元素将从右侧滑入页面到左侧。再次点击按钮,它将切换隐藏。
很酷,对吧?是的。Angular 动画使我们能够为元素创建美丽的动画和过渡效果,这将增加用户体验。
我们将构建许多很酷的示例来实现动画。
使用关键帧 - 样式序列
到目前为止,我们已经使用各种方法实现了 Angular 动画的示例。
当我们设计/决定元素的运动和转换时,我们需要遍历各种样式以实现平滑的过渡。
使用keyframes,我们可以在过渡时定义不同样式的迭代。keyframes本质上是为元素定义的一系列样式。
为了更好地理解这一点,让我们看一下以下代码片段:
transition('frameTest1 => frameTest2', [
animate(300, keyframes([
style({opacity: 1, transform: 'rotate(180deg)', offset: 0.3}),
style({opacity: 1, transform: 'rotate(-90deg)', offset: 0.7}),
style({opacity: 0, transform: 'rotate(-180deg)', offset: 1.0})
]))
让我们详细分析前面的代码片段:
-
我们正在定义从
frameTest1 => frameTest2的transition -
我们用
300毫秒定义了animate属性。 -
我们正在定义
keyframes,在其中我们定义了三种不同的样式;元素将逐步经历每个transition帧。
现在,让我们用下面的代码扩展前面部分创建的示例。
更新后的learn-animation.component.ts文件将具有以下代码:
import { Component } from '@angular/core';
import { state, style, animate, trigger, transition, keyframes} from '@angular/animations';
@Component({
selector: 'app-learn-animation',
templateUrl: './learn-animation.component.html',
styleUrls: ['./learn-animation.component.css'],
animations: [
trigger('animationState', [
state('frameTest1', style({ transform: 'translate3d(0, 0, 0)' })),
state('frameTest2', style({ transform:
'translate3d(300px, 0, 0)' })),
transition('frameTest1 => frameTest2',
animate('300ms ease-in-out')),
transition('frameTest2 => frameTest1', [
animate(1000, keyframes([
style({opacity: 1, transform: 'rotate(180deg)', offset: 0.3}),
style({opacity: 1, transform: 'rotate(-90deg)', offset: 0.7}),
style({opacity: 0, transform: 'rotate(-180deg)', offset: 1.0})
]))
])
])
]
})
export class LearnAnimationComponent{
constructor() {}
public left : string = 'frameTest1';
public onClick () : void
{
this.left = this.left === 'frameTest1' ? 'frameTest2' : 'frameTest1';
}
}
让我们详细分析前面的代码:
-
我们从 Angular 动画库中导入所需的模块:
state、style、animate、keyframes和transition。这些模块帮助我们在应用程序中创建动画。 -
我们创建了一个
LearnAnimationComponent组件。 -
我们为组件指定了
animations。 -
我们定义了一个名为
animationState的触发器。 -
对于创建的触发器,我们定义了两个状态--
frameTest1和frameTest2。 -
我们定义了两个转换:
'frameTest2 => frameTest1'和'frameTest2 => frameTest1'。 -
对于定义的每个转换,我们已经实现了
keyframes,也就是与animate方法一起使用的一系列样式,以实现平滑的过渡和时间延迟。 -
在组件类中,我们定义了一个
left变量。 -
我们正在定义一个
onClick方法,切换从frameTest1到frameTest2的值。
到目前为止,一切顺利。我们已经实现了组件。
现在是时候更新我们的learn-animation.component.html并将以下代码片段添加到文件中:
<h4>Keyframe Effects</h4>
<div class="animateElement" [@animationState]="left"
(click)="onClick()">
Click to slide right/ Toggle to move div
</div>
好了,一切准备就绪。现在运行应用程序,您应该看到如屏幕截图所示的输出和下面提到的动画:
当您运行应用程序时,您应该看到以下动画
-
当您点击
DIV元素时--它应该向右滑动 -
再次点击
DIV元素,元素应该向右移动,DIV元素变换--给人一种 DIV 在旋转的感觉。
在本节中,您将学习如何使用keyframes并为元素创建一系列样式,以实现更平滑的过渡。
动画折叠菜单
在本节中,我们将为我们的应用程序创建一个非常重要的部分,即应用程序的侧边栏菜单。
根据我们迄今为止学到的关于 Angular 动画的知识,在本节中我们将创建一个折叠侧边栏的示例。
让我们更新组件模板learn-animation.component.html,并使用以下代码片段更新文件:
<h4>Collapse Menu</h4>
<button (click)="toggleMenu()" class="menuIcon">Toggle Menu</button>
<div class="menu" [@toggleMenu]="menuState">
<ul>
<li>Home</li>
<li>Angular</li>
<li>Material Design</li>
<li>Sridhar Rao</li>
<li>Packt Publications</li>
</ul>
</div>
对前面的代码进行分析如下:
-
我们正在添加一个
<h4>标题,一个Collapse菜单。 -
我们正在定义一个按钮,并将
click事件与toggleMenu方法关联起来。 -
我们正在创建一个带有示例列表项
<li>的无序列表<ul>。
现在,我们将向learn-animation.component.css文件添加一些基本的 CSS 样式:
.animateElement{
background:red;
height:100px;
width:100px;
}
.menu {
background: #FFB300;
color: #fff;
position: fixed;
left: auto;
top: 0;
right: 0;
bottom: 0;
width: 20%;
min-width: 250px;
z-index: 9999;
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
}
ul {
font-size: 18px;
line-height: 3;
font-weight: 400;
padding-top: 50px;
list-style: none;
}
.menuIcon:hover {
cursor: pointer;
}
到目前为止,我们已经创建了我们的应用程序组件模板learn-animation.component.html并为菜单组件learn-animation.component.css设置了样式。
现在,我们将创建菜单组件类。
将以下代码添加到learn-animation.component.ts文件中:
import { Component } from '@angular/core';
import { state, style, animate, trigger, transition, keyframes} from '@angular/core';
@Component({
selector: 'app-learn-animation',
templateUrl: './learn-animation.component.html',
styleUrls: ['./learn-animation.component.css'],
animations: [
trigger('toggleMenu', [
state('opened', style({
transform: 'translate3d(0, 0, 0)'
})),
state('closed', style({
transform: 'translate3d(100%, 0, 0)'
})),
transition('opened => closed', animate('400ms ease-in-out')),
transition('closed => opened', animate('400ms ease-in-out'))
])
])
]
})
export class LearnAnimationComponent{
constructor() {}
menuState : string = 'opened';
toggleMenu()
{
this.menuState = this.menuState === 'closed' ? 'opened' : 'closed';
}
}
让我们详细分析前面的代码:
-
我们正在导入所需的 Angular 动画库模块,例如
state,style,animate,trigger,transition和keyframes。 -
在动画中,我们定义了一个触发器:
toggleMenu。 -
我们正在创建两种状态:
opened和closed。 -
对于每个状态,我们正在定义一些带有
transform的样式属性。 -
我们现在定义了转换
opened => closed和closed => open,并带有一些动画细节延迟。 -
我们已经定义了一个
menuState变量。 -
在组件类中,我们定义了
toggleMenu。 -
在
toggleMenu方法中,我们正在切换menuState变量值为opened或closed,反之亦然。
现在是演示时间。运行应用程序,您应该看到以下输出:
再次点击 Toggle 菜单按钮,我们应该看到菜单向右滑动,如下截图所示:
在本节中,我们使用 Angular 动画创建了应用程序的侧边栏菜单。
总结
在本章中,我们介绍了 Angular 动画。动画对于设计和构建具有平滑过渡和元素效果的美观用户体验至关重要。
我们介绍了如何安装和导入 Angular 动画库,并在库中使用各种模块。
我们讨论了重要的模块,比如state、style、animate、trigger、transition和keyframes。
我们创建并实现了一些使用 Angular 动画的示例。
最后,我们创建了一个带有一些动画效果的网页应用侧边栏菜单。现在,轮到你了!
在下一章中,您将学习如何将 Bootstrap 与 Angular 应用程序集成。Bootstrap 可以说是目前最流行的前端框架,在本章中,您将了解拥有一个 Angular x Bootstrap 应用程序意味着什么。
第十四章:将 Bootstrap 与 Angular 应用程序集成
Bootstrap 可以说是目前最受欢迎的前端框架。你可能会问,Angular 本身不就是一个前端框架吗?是的。那么为什么我需要在同一个应用程序中使用两个前端框架呢?答案是,你不需要。Bootstrap 是由 Twitter 创建和使用的,非常受欢迎。它允许您管理许多事情,比如使用一个名为网格的系统在页面上布置 HTML 组件。我将在接下来的页面中详细解释这个系统,它允许您在不明确使用 CSS 的情况下将网页空间划分为区域。此外,一切都将立即响应。此外,Bootstrap 提供了动态元素,如轮播、进度条、对用户输入的表单反应等。简而言之,Angular 允许您创建应用程序结构并管理数据呈现,而 Bootstrap 处理图形的呈现。
Bootstrap 围绕三个元素展开:
-
bootstrap.css -
bootstrap.js -
glyphicons
在这里,bootstrap.css包含了允许响应式空间划分的框架,而bootstrap.js是一个使您的页面动态化的 JavaScript 框架。
需要注意的是,bootstrap.js依赖于 jQuery 库。
最后,glyphicons是一个包含使用 Bootstrap 时可能需要的所有图标的字体。
在第十章*,* Angular 中的 Material Design中,您将学习如何使用由 Google 官方提供的Material Design包来创建管理动态元素、轮播和其他进度条的应用程序(ng2-material)。Bootstrap(由 Twitter 提供)和 Material Design(由 Google 为 Angular 提供)最终都旨在实现同样的目标:在严格呈现页面给用户时简化您的生活。例如,它们都确保跨浏览器兼容性,防止在项目之间重复编写代码,并在代码库中添加一致性。
在我看来,您应该使用哪一个是个人选择,我可以预见未来几个月将会有关于 C#与 Java 或 PC 与 Mac 之类的激烈争论。一方面,如果您已经精通 Bootstrap 并且在各处都在使用它,那么您也可以在这里使用它。另一方面,如果 Bootstrap 不是您的技能范围,您可以利用这个机会学习并选择您喜欢的。
第三个选项将是完全跳过本章,如果您已经选择了 Material Design(由 Google 为 Angular 提供)的方法。我不介意,我保证。本章涵盖的主题有:
-
安装 Bootstrap
-
了解 Bootstrap 的网格系统
-
使用 Bootstrap 指令
安装 Bootstrap
话不多说,让我们开始并为 Angular 安装 Bootstrap。
在没有像 Angular 这样的前端框架的标准 Web 应用中使用 Bootstrap 时,您需要使用内容传递网络(CDN)来获取组成 Bootstrap 框架的三个部分(bootstrap.css,bootstrap.js和glyphicons)。即使下载了缩小的文件,这些调用仍然需要时间(例如,三个 HTTP 请求,下载,校验和等)才能完成。对于您的客户来说,使用 Angular,我们可以采用相同的方法,并简单地在src/index.html中添加对某些 CDN 的引用,但这将是一个相当大的错误。
首先,如果用户没有缓存资源的副本,那么我们将遭受与标准 Web 应用相同的副作用,因为我们的客户将不得不等待 CDN 提供 Bootstrap 框架,特别是考虑到我们的应用经过 Angular CLI 部署流程进行了缩小并以单个文件提供。其次,我们将无法轻松地在我们的 Angular 组件中控制 Bootstrap 组件。
将 Bootstrap 与我们的 Angular 应用程序集成的更好方法是使用ng-bootstrap包。该包允许我们在我们的组件中使用 Angular 指令来管理 Bootstrap。在撰写本文时,这是最全面、维护良好且与 Angular 集成良好的包,允许我们在 Angular 中使用 Bootstrap。
为了探索 Bootstrap,我们将在第七章,使用可观察对象进行异步编程和第九章,Angular 中的高级表单中使用的 Marvel Cinematic Universe 的 JSON API 基础上构建。
您可以在github.com/MathieuNls/mastering-angular2/tree/master/chap9找到《第九章》,Angular 中的高级表单的代码。
要将此代码克隆到名为angular-bootstrap的新存储库中,请使用以下命令:
$ **git** clone --depth one https://github.com/MathieuNls/mastering-angular
angular-bootstrap
$ **cd** angular-bootstrap
$ **git** filter-branch --prune-empty --subdirectory-filter chap9 HEAD
这些命令将 GitHub 存储库的最新版本拉到名为angular-bootstrap的文件夹中。然后,我们进入angular-bootstrap文件夹,并清除不在第九章 Angular 中的高级表单目录中的所有内容。
现在让我们安装ng-bootstrap包:
npm install --save @ng-bootstrap/ng-bootstrap
现在,在src/app/app.module.ts中,导入import {NgbModule} from @ng-bootstrap/ng-bootstrap包,并将NgbModule.forRoot()添加到AppModule类的导入列表中。如果您重用了第九章 Angular 中的高级表单中的代码,它应该是这样的:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap
import { AppComponent } from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule,
ReactiveFormsModule,
NgbModule.forRoot()
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
这个包允许我们摆脱 jQuery 和bootstrap.js的依赖,但不幸的是,它不包括bootstrap.css。它包含了我们即将使用的网格系统和组件所需的样式。
前往getbootstrap.com/,并在src/index.html中导入以下显示的链接:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Chap15</title>
<base href="/">
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-
alpha.4/css/bootstrap.min.css" integrity="sha384-
2hfp1SzUoho7/TsGGGDaFdsuuDL0LX2hnUp6VkX3CUQ2K4K+xjboZdsXyp4oUHZj"
crossorigin="anonymous">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root>Loading...</app-root>
</body>
</html>
通过这些小改变,我们已经可以看到 Bootstrap 正在接管我们的样式。在下面的图片中,左边是我们在第九章 Angular 中的高级表单结束时表单的样子。
然而,右边是我们现在表单的样子。正如您所看到的,这里和那里有一些小的不同。例如,h1标记,错误字段和输入的样式不同:
Bootstrap 之前和之后。
如果我们使用 Google Chrome 的检查功能,我们可以清楚地看到我们的h1标记的应用样式来自 maxcdn.bootstrapcdn.com,如下面的屏幕截图所示:
Chrome 检查样式。
就是这样:我们完成了 Bootstrap 的初始化。让我们学习如何使用 Angular 指令来使用 Bootstrap。
理解网格系统
在本章中,我们更关心学习如何使用不同的 Angular Bootstrap 指令,而不是学习 Sass 混合和其他演示技巧。换句话说,网格系统的高级功能超出了本章的范围。然而,在本节中,我将快速介绍网格系统是什么,以及如何使用它的概述。
如果你以前使用过 Bootstrap,尤其是使用过网格系统,你可以跳过这一部分,直接进入下一部分,在那里我们学习如何使用手风琴指令。
因此,网格系统将我们的演示分成了十二列。列的大小可以是额外小、小、中、大和额外大。列的大小可以通过 CSS 类前缀(分别是col-xs、col-sm、col-md、col-lg和col-xl)手动设置,并对应不同的屏幕宽度(小于 540 像素、540 像素、720 像素、960 像素和 1140 像素)。
为了了解如何利用网格系统来分隔我们的演示,让我们在src/app/app.component.html中的<h1>{{title}}</h1>标记后面添加以下内容:
<div class="container">
<div class="row">
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
<div class="col-md-1">col-md-1</div>
</div>
<div class="row">
<div class="col-md-8">col-md-8</div>
<div class="col-md-4">col-md-4</div>
</div>
<div class="row">
<div class="col-md-4">col-md-4</div>
<div class="col-md-4">col-md-4</div>
<div class="col-md-4">col-md-4</div>
</div>
<div class="row">
<div class="col-md-6">col-md-6</div>
<div class="col-md-6">col-md-6</div>
</div>
</div>
正如你所看到的,这里有几个 CSS 类在起作用。首先,让我们看看容器。这是必需的,它定义了 Bootstrap 网格系统将应用的空间。然后,我们有包含col-的行。每行占据屏幕的整个宽度,并被分成列。列的实际宽度取决于你在列类声明的末尾使用的数字(4、8、6 等)。知道行被分成 12 列,我们使用了col-md类前缀,我们可以推断出一行的最大尺寸是 720 像素。因此,每列宽 60 像素。在第一行中,我们在我们的声明中使用了-1后缀;因此,我们有 60 像素宽的列(即屏幕宽度除以 12)。然而,在第二行,我们使用了-8和-4后缀。
这意味着我们将有一列的宽度是a-1列的 8 倍(480 像素),另一列的宽度是a-1列的 4 倍(240 像素)。在第三行,我们使用了三个四列,最后,在第四行,我们有两个六列。
要查看发生了什么,请在app/app.component.css中添加以下内容:
.row > [class^="col-"]{
padding-top: .75rem;
padding-bottom: .75rem;
background-color: rgba(86, 61, 124, 0.15);
border: 1px solid rgba(86, 61, 124, 0.2);
}
这段 CSS 将为任何col类添加背景和边框,无论它们可能具有的前缀或后缀是什么:
网格系统的运行。
正如你在上图中所看到的,空间被很好地按计划划分。现在,这并不是网格系统的真正优势。主要优势在于,如果屏幕宽度变小于 720 像素,列会自动堆叠在彼此上面。
例如,在 iPhone 6 上,其屏幕宽度为 375px,所有列将堆叠在一起,如下截图所示:
iPhone 6 上的网格系统。
这是官方文档中的另一个例子,可以在v4-alpha.getbootstrap.com/layout/grid/找到:
<!-- Stack the columns on mobile by making one full-width and the other half-width -->
<div class="row">
<div class="col-xs-12 col-md-8">.col-xs-12 .col-md-8</div>
<div class="col-xs-6 col-md-4">.col-xs-6 .col-md-4</div>
</div>
<!-- Columns start at 50% wide on mobile and bump up to 33.3% wide on desktop -->
<div class="row">
<div class="col-xs-6 col-md-4">.col-xs-6 .col-md-4</div>
<div class="col-xs-6 col-md-4">.col-xs-6 .col-md-4</div>
<div class="col-xs-6 col-md-4">.col-xs-6 .col-md-4</div>
</div>
<!-- Columns are always 50% wide, on mobile and desktop -->
<div class="row">
<div class="col-xs-6">.col-xs-6</div>
<div class="col-xs-6">.col-xs-6</div>
</div>
我不会详细介绍网格系统,但知道你可以在 Packt Library 找到很多关于这个主题的精彩书籍。只需查找以下内容:
-
精通 Bootstrap 4
-
Bootstrap 4 蓝图
使用 Bootstrap 指令
在本节中,我们将学习如何使用一些最常用的 Bootstrap 指令来构建您的应用程序。
手风琴
我们将首先概述手风琴指令。手风琴允许您创建一个可以通过单击其各自的标题独立显示的不同内容面板。
我们将使用我们在第九章中制作的表单,Angular 中的高级表单,允许用户在漫威电影宇宙中添加电影,以实验手风琴。这里的目标是为表单设置一个面板,为电影的枚举设置另一个面板。
让我们从研究创建 Bootstrap 手风琴所需的最小 HTML 开始,如下所示:
<ngb-accordion>
<ngb-panel>
<template ngbPanelTitle>
<span>Mastering angular X Bootstrap</span>
</template>
<template ngbPanelContent>
Some deep insights
</template>
</ngb-panel>
<ngb-panel>
<template ngbPanelTitle>
<span>Some Title</span>
</template>
<template ngbPanelContent>
Some text
</template>
</ngb-panel>
</ngb-accordion>
前面的 HTML 模板将产生以下结果:
一个简单的手风琴。
分析前面的代码片段,我们可以看到以下特点:
-
ngb-accordion:这是主要的手风琴指令。它定义了一个包含ngb-panel的手风琴。 -
ngb-panel:这代表手风琴的一个面板。可以通过单击面板标题来切换其可见性。ngb-panel包含一个可以用于标题或内容的模板。 -
<template ngbPanelContent>:这包含给定面板的标题或内容。 -
<template ngbPanelTitle>:这包含标题。
到目前为止,一切都相当简单。现在,它变得强大的地方是当您从您的 TypeScript 组件中管理它时。首先,ngb-accordion指令有三个不同的@Input属性,我们利用了它们。第一个是activeIds,它是string[]类型,包含您希望打开的面板的 ID。面板 ID 是从ngb-panel-0自动生成的。面板 ID 的格式为ngb-panel-x。第二个@Input是一个布尔值:closeOthers。这允许您指定是否一次只能打开一个面板。最后,使用string类型来指定手风琴的类型。在 Bootstrap 中,有四种类型被识别:success、info、warning和danger。
除了这三个@Inputs之外,ngb-accordion指令还提供了一个名为panelChange的@Output。这个@Output会在每次面板的可见性即将被切换时触发。
让我们通过将app/app.component.html转换为以下内容来尝试这些@Input和@Output属性:
<div class="container">
<!-- First Row -->
<div class="row">
<h1 class="col-md-12">
{{title}}
</h1>
</div>
<!-- Second Row -->
<div class="row">
<!-- Start of the accordion -->
<ngb-accordion class="col-md-12"
<!-- Bind to a variable called activeIds -->
[activeIds]="activeIds"
<!-- Simply use the string 'success' -->
type="success"
<!-- Simply use true -->
closeOthers="true"
<!-- Bind to the output -->
(panelChange)=pannelChanged($event)
>
<!-- Firt pannel -->
<ngb-panel>
<template ngbPanelTitle>
<span>Add a Movie</span>
</template>
<!-- Form content is here -->
<template ngbPanelContent>
<form [formGroup]="movieForm">
<!-- Form content omitted for clarity -->
</form>
</template>
</ngb-panel>
<!-- Second pannel -->
<ngb-panel>
<template ngbPanelTitle>
<span>Movies</span>
</template>
<!-- Movie enumeration is here -->
<template ngbPanelContent>
<ul>
<li *ngFor="let movie of movies">{{movie}}</li>
</ul>
</template>
</ngb-panel>
</ngb-accordion>
</div>
</div>
在这里,我们使用了[activeIds]="activeIds"、type="success"、closeOthers="true"和(panelChange)=pannelChanged($event)来绑定到我们组件中的一个名为activeIds的变量,将表单类型设置为success,并将closeOthers设置为 true。然后,我们将一个名为pannelChanged的方法绑定到panelChange输出。在app.component.ts中,我们需要添加activeIds变量和pannelChanged方法如下:
private activeIds = ["ngb-panel-1"];
private pannelChanged(event:{panelId:string, nextState:boolean}){
console.log(event.nextState, event.panelId);
}
在这里,private activeIds = ["ngb-panel-1"];允许我们定义panel-1(第二个)应该默认打开,并且pannelChanged方法应该接收一个由panelId:string和nextState:boolean组成的事件负载。我们记录了这两个负载属性。
应用程序现在看起来像下面截图中显示的那样:
一个由 TypeScript 管理的手风琴。
当您切换面板时,控制台会记录以下内容:
**true** "ngb-panel-0"
**false** "ngb-panel-0"
警报
本章中我们将探讨的下一个指令是ng-alert。在 Bootstrap 词汇中,警报是以有色div形式显示给用户的重要信息。有四种类型的警报:success、info、warning和danger。
要创建一个 Bootstrap 警报,最小可行的 HTML 模板如下:
<ngb-alert>
Something important
</ngb-alert>
这段代码的结果如下截图所示:
一个基本的警报。
与手风琴类似,警报指令提供了一些@Input和@Output。我们可以使用@Input作为dismissible:boolean,它管理警报的可解除性,以及type:string,它接受success、info、warning和danger。
为了使我们的表单更具 Bootstrap 风格,我们可以用警报替换我们的错误消息。目前,在表单中,错误消息看起来像这样:
<p class='error' *ngIf=!movieForm.controls.movie_id.valid>This field is required</p>
现在的目标是有以下内容:
<ngb-alert
[dismissible]="false"
*ngIf=!movieForm.controls.movie_id.valid
type="danger"
>
This field is required
</ngb-alert>
在上述片段中的每个字段,上述代码将产生以下结果:
危险警报作为表单错误。
日期选择器
本章中的下一个指令是日期选择器。无论您使用什么技术,日期总是有些棘手,因为每个供应商都提出了许多格式。此外,日期国际化使事情变得更加困难。
幸运的是,Bootstrap 带有一个足够简单的日期选择器,允许用户在弹出的日历中选择日期。其代码如下所示:
<div class="input-group">
<input class="form-control" placeholder="yyyy-mm-dd"
ngbDatepicker #dp="ngbDatepicker">
<div class="input-group-addon" (click)="dp.toggle()" >
<img src="https://ng-bootstrap.github.io/img/calendar-icon.svg"
style="width: 1.2rem; height:
1rem; cursor: pointer;"/>
</div>
</div>
这里发生了很多事情。首先,我们有一个formControl输入,其占位符设置为yyyy-mm-dd。您定义的占位符很重要,因为它将作为用户选择的数据的强制格式化程序。对于格式化程序的语法,您可以使用日期的每个经典符号(例如,d、D、j、l、N、S、w、z 等)。换句话说,我们输入的日期将自动匹配此模式。然后,我们有ngbDatepicker #d="ngbDatepicker"。ngbDatepicker定义了我们的输入是一个ngbDatepicker,#dp="ngbDatepicker"允许我们创建对我们的输入的本地引用。这个名为dp的本地引用在以下div的(click)事件上使用:(click)="dp.toggle()"。这个div包含了日历的图像。点击它,一个动态的日历将弹出,我们将能够选择一个日期。
这个 HTML 将给我们以下内容:
日期选择器。
然后,一旦触发了click事件,将显示如下内容:
日期选择器被点击。
为了改善我们对漫威电影宇宙的管理,我们可以将release_date字段更改为日期选择器。目前,release_date字段看起来像这样:
<label>release_date</label>
<ngb-alert [dismissible]="false" type="danger"
*ngIf=!movieForm.controls.release_date.valid>This field is required</ngb-alert>
<input type="text" formControlName="release_date" [(ngModel)]="movie.release_date"><br/>
如果字段无效,我们会有输入和 Bootstrap 警报。Bootstrap 警报默认是活动的(即当字段为空时)。让我们将我们的输入转换为以下内容:
<label>release_date</label>
<ngb-alert [dismissible]="false" type="danger"
*ngIf=!movieForm.controls.release_date.valid>This
field is required</ngb-alert>
<div class="input-group">
<input
formControlName="release_date"
placeholder="yyyy-mm-dd"
ngbDatepicker #dp="ngbDatepicker"
[(ngModel)]="movie.release_date">
<div class="input-group-addon" (click)="dp.toggle()" >
<img src="https://ng-bootstrap.github.io/img/calendar-icon.svg"
style="width: 1.2rem;
height: 1rem; cursor: pointer;"/>
</div>
</div>
这里的不同之处在于我们将输入链接到了我们的formControl。实际上,在第九章 Angular 中的高级表单中,我们定义了表单如下:
this.movieForm = this.formBuilder.group({
movie_id: ['',
Validators.compose(
[
Validators.required,
Validators.minLength(1),
Validators.maxLength(4),
Validators.pattern('[0-9]+'),
MovieIDValidator.idNotTaken
]
)
],
title: ['', Validators.required],
phase: ['', Validators.required],
category_name: ['', Validators.required],
release_year: ['', Validators.required],
running_time: ['', Validators.required],
rating_name: ['', Validators.required],
disc_format_name: ['', Validators.required],
number_discs: ['', Validators.required],
viewing_format_name: ['', Validators.required],
aspect_ratio_name: ['', Validators.required],
status: ['', Validators.required],
release_date: ['', Validators.required],
budget: ['', Valida tors.required],
gross: ['', Validators.required],
time_stamp: ['', Validators.required]
});
所以,我们有一个必填的release_date字段。HTML 输入定义了与release_date字段的双向数据绑定,带有[(ngModel)]="movie.release_date",此外,我们还需要在输入框内添加formControlName="release_date"属性。实施后,屏幕上将显示以下内容:
MCU 的日期选择器。
工具提示
接下来,我们有 tooltip 指令,它允许我们在给定一组元素的左侧、右侧、顶部或底部显示信息性文本。
tooltip 指令是最简单的之一。实际上,你只需要为你希望增强的元素添加两个属性:placement 和ngbTooltip。placement 的值可以是 top、bottom、left 或 right,而ngbTooltip的值是你希望显示的文本。
让我们修改movie_id字段的标签:
<ngb-alert [dismissible]="false" type="danger"
*ngIf=!movieForm.valid>danger</ngb-alert>
<label >movie_id</label>
<ngb-alert [dismissible]="false" type="danger"
*ngIf=!movieForm.controls.movie_id.valid>This field
is required</ngb-alert>
<input type="text" formControlName="movie_id"
[(ngModel)]="movie.movie_id" name="movie_id" >
<br/> to
<ngb-alert [dismissible]="false" type="danger"
*ngIf=!movieForm.valid>danger</ngb-alert>
<label placement="top" ngbTooltip="Title of
your movie"> movie_id</label>
<ngb-alert [dismissible]="false" type="danger"
*ngIf=!movieForm.controls.movie_id.valid>This
field is required</ngb-alert>
<input type="text" formControlName="movie_id"
[(ngModel)]="movie.movie_id" name="movie_id" ><br/>
在这里,我们保持了警报和输入不变。但是,我们在标签中添加了 placement 和ngbTooltip属性。结果,当我们悬停在movie_id标签上时,电影标题将显示在顶部。如下截图所示:
movie_id 上的工具提示。
进度条
还有一些其他的 Bootstrap 组件可以用来增强我们的表单;然而,太多的组件很快就会成为可用性过度的情况。例如,将进度条集成到我们的表单中将会很棘手。然而,我们可以为我们想要测试的每个新的 Bootstrap 指令添加一个手风琴面板。
让我们为进度条添加一个面板:
<ngb-panel>
<template ngbPanelTitle>
<span>Progress Bar</span>
</template>
<template ngbPanelContent>
<ngb-progressbar type="success" [value]="25"></ngb-progressbar>
</template>
</ngb-panel>
progressbar指令是另一个简单的指令。它有两个@Input属性:type 和 value。和往常一样,type 可以是success、danger、warning或info。value 属性可以绑定到一个 TypeScript 变量,而不是像我做的那样硬编码为 25。
这是结果:
movie_id 上的进度条。
评分
评分指令也是非常出名的。它允许用户对某物进行评分,或者显示给定的评分。
正如预期的那样,这个指令很容易理解。它有一个评分输入,您可以硬编码(例如,"rate"=25),绑定([rate]="someVariable"),或者应用双向数据绑定([(rate)]="someVariable")。除了评分输入,您还可以使用[readonly]="read-only"来使您的评分条不可修改。
默认情况下,评分条由 10 颗星组成。评分值可以从 0 到 10,包括小数。
以下是一个新面板内默认评分条的示例:
<ngb-panel>
<template ngbPanelTitle>
<span>Rating bar</span>
</template>
<template ngbPanelContent>
<ngb-rating rate="5"></ngb-rating>
</template>
</ngb-panel>
这将产生以下结果:
评分条。
摘要
在本章中,我们看到了一些最受欢迎的 Bootstrap 组件。我们学会了如何使用 ng2-Bootstrap 包提供的原生 Angular 指令来使用它们。然而,我们并没有探索每一个 Bootstrap 组件。您可以查看托管在ng-bootstrap.github.io/的官方文档。
在下一章中,您将学习如何使用单元测试来测试您的 Angular 应用程序。
第十五章:使用 Jasmine 和 Protractor 框架测试 Angular 应用程序
测试是现代应用程序开发过程中最重要的方面之一。我们甚至有专门的软件开发方法论,主要是基于测试优先的方法。
除了 Angular 提供的测试工具之外,还有一些推荐的框架,如 Jasmine、Karma 和 Protractor,使用这些框架可以轻松创建、维护和编写测试脚本。使用 Jasmine 和 Protractor 编写的测试脚本可以节省时间和精力,并且最重要的是在开发过程中更早地发现缺陷。
在本章中,您将学习如何使用 Jasmine 和 Protractor 测试 Angular 应用程序。在本章中,我们将讨论以下内容:
-
了解测试中的重要概念
-
了解 Angular CLI 用于单元测试特定环境
-
介绍 Jasmine 框架
-
使用 Jasmine 编写测试脚本
-
编写测试脚本来测试 Angular 组件
-
测试 Angular 组件:一个高级示例
-
使用 Jasmine 测试脚本测试 Angular 服务
-
学习 Protractor
-
使用 Protractor 编写 E2E 测试脚本
测试中的概念
在我们开始测试我们的 Angular 应用程序之前,重要的是我们快速复习并了解一些在测试中常用的术语:
-
单元测试:一个单元测试可以被视为应用程序中最小的可测试部分。
-
测试用例:这是一组测试输入、执行条件和期望结果,以实现一个目标。在 Jasmine 框架中,这些被称为规范。
-
TestBed:TestBed 是一种通过传递所有必需的数据和对象来以隔离的方式测试特定模块的方法。
-
测试套件:这是一组旨在用于端到端测试模块的测试用例集合。
-
系统测试:对完整和集成的系统进行的测试,以评估系统功能。
-
端到端测试:这是一种测试方法,用于确定应用程序的行为是否符合要求。我们传递数据、必需对象和依赖项,并在模拟实时用例和场景的情况下从头到尾执行。
既然我们知道了前面的术语,让我们学习如何测试 Angular 应用程序。
了解并设置 Angular CLI 进行测试
到目前为止,我们已经使用 Angular CLI 来设置我们的项目,创建新组件、服务等。我们现在将讨论如何使用命令行工具来设置和执行测试套件,以测试我们的 Angular 应用程序。
首先,快速回顾如何使用 Angular CLI 快速创建项目:
npm install -g angular-cli
使用上述代码片段,我们安装了 Angular 命令行工具。现在,让我们创建一个名为test-app的新目录并进入项目目录:
ng new test-app
cd test-app
现在是时候快速创建一个名为test-app的新组件了:
ng g component ./test-app
现在,我们将看到以下输出:
我们应该看到新目录和相应的文件在目录中创建。命令行工具已经创建了与组件相关的四个文件,包括test-app.component.spec.ts测试脚本占位符文件。
现在,让我们启动我们的应用程序:
ng serve
此时,我们的应用程序已经启动。现在是时候开始测试我们的 Angular 应用程序了。
Jasmine 框架介绍
Jasmine 是一个用于测试 JavaScript 代码的行为驱动开发框架。这是官方网站如何解释 Jasmine 的方式:
Jasmine 是一个用于测试 JavaScript 代码的行为驱动开发框架。它不依赖于任何其他 JavaScript 框架。它不需要 DOM。它有一个清晰明了的语法,让您可以轻松编写测试。
Jasmine 测试套件的一般语法如下所示:
describe("Sample Test Suite", function() {
it("This is a spec that defines test", function() {
expect statement // asserts the logic etc
});
});
让我们分析上述代码片段,以了解测试套件语法。已经按照以下步骤进行了操作:
-
每个 Jasmine 测试套件都将有一个
describe语句,我们可以给出一个名称。 -
在测试套件内,我们使用
it语句创建较小的测试用例;每个测试用例将有两个参数,一个名称和一个函数,其中包含需要测试的应用程序逻辑。 -
我们使用
expect语句来验证数据,以确保我们的应用程序和数据按预期工作。
在下一节中,您将详细了解 Jasmine 框架和可用的方法和函数,我们可以在测试脚本中使用。
Jasmine 框架 - 我们可以使用的全局方法
Jasmine 框架支持并为我们提供了许多预定义的方法来使用和编写我们的测试套件。 Jasmine 对测试环境、对元素进行间谍操作等提供了广泛的支持。请参阅官方网站以获取有关可用方法的完整帮助和文档。
为了编写测试脚本,我们需要对 Jasmine 框架中最常用和频繁使用的一些方法有基本的理解和知识。
Jasmine 中常用的方法
以下是编写测试套件可用的最常用的 Jasmine 全局方法列表:
| 全局方法 | 描述 |
|---|---|
| describe | describe 函数是实现测试套件的代码块 |
| it | 通过调用全局 Jasmine 函数it来定义规范,如所述,它接受一个字符串和一个函数 |
| beforeEach | 此方法在调用它的描述中的每个规范之前调用一次 |
| afterEach | 此方法在每个规范后调用一次 |
| beforeAll | 此方法在描述中的所有规范之前调用一次 |
| afterAll | 此方法仅在所有规范调用后调用一次 |
| xdescribe | 这会暂时禁用您不想执行的测试 |
| pending | 未运行的待定规范将被添加到待定结果列表中 |
| xit | 任何使用 xit 声明的规范都会被标记为待定 |
| spyOn | 间谍可以替换任何函数并跟踪对它的调用和所有参数;这在描述或 it 语句内部使用 |
| spyOnProperty | 对间谍的每次调用都会被跟踪并暴露在 calls 属性上 |
有关更多详细信息和完整文档,请参阅 GitHub 上的 Jasmine 框架文档。
Angular CLI 和 Jasmine 框架-第一个测试
安装 Angular CLI 时,Jasmine 框架会自动与工具一起提供。
在前面的部分中,我们看到了在 Jasmine 中编写测试的一般语法。现在,让我们使用 Jasmine 框架编写一个快速的测试脚本:
describe('JavaScript addition operator', function () { it('adds two numbers together', function () { expect(1 + 2).toEqual(3); }); });
以下是关于前面的测试脚本的重要事项:
-
我们编写一个
describe语句来描述测试脚本。 -
然后我们使用
it语句和相应的方法定义一个测试脚本。 -
在
expect语句中,我们断言两个数字,并使用toEqual测试两个数字的相加是否等于3。
使用 Jasmine 测试 Angular 组件
现在是时候使用 Jasmine 框架创建我们的测试套件了。在第一部分“理解和设置用于测试的 Angular CLI”中,我们使用ng命令创建了TestAppComponent组件和test-app.component.ts文件。我们将在本节中继续使用相同的内容。
要开始,请添加以下代码文件的所有内容:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TestAppComponent } from './test-app.component';
describe('Testing App Component', () => {
it('Test learning component', () => {
let component = new TestAppComponent();
expect(component).toBeTruthy();
});
});
让我们逐步分析前面的测试套件步骤。在代码块中遵循的步骤如下:
-
在第一步中,我们从
@angular/core/testing导入了所有所需的测试模块。 -
我们导入了新创建的组件
TestAppComponent。 -
我们通过编写一个带有名称的
describe语句Testing App Component来创建了一个测试套件。 -
我们使用
it和相应的方法() =>编写了一个测试脚本。 -
我们创建了一个
TestAppComponent类的component对象。 -
然后我们断言返回的值是否为 true。如果将该值强制转换为
boolean后得到 true,则该值为toBeTruthy。
所有编写的测试套件都将以.spec.ts扩展名结尾,例如test-app.component.spec.ts。
我们目前做得很好!太棒了,现在我们将运行我们的测试套件并查看其输出。
我们仍在使用 Angular CLI 工具;让我们在项目目录中使用ng命令运行测试,并在终端中运行以下命令:
ng test
命令行工具将构建整个应用程序,打开一个新的 Chrome 窗口,使用 Karma 测试运行器运行测试,并运行 Jasmine 测试套件。
Karma 测试运行器会生成一个在浏览器中执行所有测试并监视karma.conf.js中指定的所有配置的 Web 服务器。我们可以使用测试运行器来运行各种框架,包括 Jasmine 和 Mocha。Web 服务器会收集所有捕获浏览器的结果并显示给开发人员。
我们应该看到如下截图所示的输出:
如果你看到了前面的截图,恭喜你。你已成功执行了测试套件,并注意测试脚本已通过。
恭喜!现在让我们深入研究并为测试组件和服务创建更复杂的测试脚本。
使用 Jasmine 测试 Angular 组件
在我们之前的示例中,我们已经看到了编写测试脚本和测试 Angular 组件的基本示例。
在本节中,我们将探讨编写测试 Angular 组件的最佳实践。我们将使用在前一节中创建的相同组件--TestAppComponent--并通过添加变量和方法来扩展测试套件。
在test-app.component.ts文件中,让我们创建一些变量并将它们映射到视图中:
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-test-app',
templateUrl: './test-app.component.html',
styleUrls: ['./test-app.component.css']
})
export class TestAppComponent implements OnInit {
public authorName = 'Sridhar';
}
让我们分析在我们的test-app.component.ts文件中编写的前面的代码:
-
我们创建了一个组件--
TestAppComponent。 -
我们在
templateUrl和styleUrls中映射了相应的 HTML 和 CSS 文件。 -
我们声明了一个名为
authorName的公共变量,并赋予了值'Sridhar'。
现在,让我们转到test-app.component.spec.ts。我们将编写我们的测试套件,并定义一个测试用例来验证authorName是否与传递的字符串匹配:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TestAppComponent } from './test-app.component';
describe('TestAppComponent', () => {
it('Testing App component', () => {
let component = new TestAppComponent();
expect(component.authorName).toMatch('Sridhar');
});
});
让我们分析在test-app.component.spec.ts文件中前面的代码片段。已遵循以下步骤来编写代码块:
-
我们导入了所有必需的模块
async、componentFixture和TestBed来运行测试。 -
我们通过编写
describe语句并分配Testing App Component名称来创建了一个测试套件。 -
我们创建了一个测试用例,并创建了
TestAppComponent类的新实例。 -
在
expect语句中,我们断言authorName变量是否与字符串匹配。结果将返回 true 或 false。
很好!到目前为止,一切顺利。现在,继续阅读。
是时候将其提升到下一个级别了。我们将向component类添加新方法,并在specs文件中对它们进行测试。
在test-app.component.ts文件中,让我们添加一个变量和一个方法:
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-test-app',
templateUrl: './test-app.component.html',
styleUrls: ['./test-app.component.css']
})
export class TestAppComponent {
public authorName = 'Sridhar';
public publisherName = 'Packt'
public hiPackt() {
return 'Hello '+ this.publisherName;
}
}
让我们创建test-app.component.spec.ts文件,并测试在component类中定义的变量和方法。
在test-app.component.spec.ts文件中,添加以下代码行:
it('Testing Component Method', () => {
let component = new TestAppComponent();
expect(component.hiPackt()).toBe("Hello Packt");
});
让我们详细分析前面的代码片段。已遵守以下步骤:
-
我们创建了一个测试用例,并创建了
TestAppComponent类的component实例。 -
在
expect语句中,我们断言并验证传递的字符串是否与hiPackt方法的返回值匹配。
在运行前面的测试脚本之前,让我们也快速看一下另一个测试用例:
describe('TestAppComponent', () => { beforeEach(function() {
this.app = new TestAppComponent();
}); it('Component should have matching publisher name', function() {
expect(this.app.publisherName).toBe('Packt');
}); });
让我们分析前面的代码片段:
-
我们实现了
beforeEachJasmine 方法。我们在每个测试脚本之前创建一个AppComponent的实例。 -
我们编写了一个测试脚本,并使用了组件的实例,也就是
this.app,我们获取了publisherName变量的值,并断言publisherName变量的值是否与toBe('Packt')匹配。
现在,测试应该自动构建,否则调用ng test来运行测试。
我们应该看到以下截图:
太棒了!您学会了编写测试脚本来测试我们的 Angular 组件,包括变量和方法。
您学会了使用 Jasmine 框架的一些内置方法,比如beforeEach、expect、toBeTruthy和toBe。
在下一节中,我们将继续学习高级技术,并编写更多的测试脚本,以更详细地测试 Angular 组件。
测试 Angular 组件-高级
在本节中,我们将更深入地探讨并学习测试 Angular 组件的一些更重要和高级的方面。
如果你注意到,在前面部分的示例中可以注意到以下内容:
-
我们在每个测试用例中单独创建了对象的实例。
-
我们必须为每个测试用例单独注入所有的提供者。
相反,如果我们可以在每个测试脚本之前定义组件的实例,那将是很好的。我们可以通过使用TestBed来实现这一点--这是 Angular 提供的用于测试的最重要的实用程序之一。
TestBed
TestBed是 Angular 提供的最重要的测试实用程序。它创建了一个 Angular 测试模块--一个@NgModule类,我们可以用于测试目的。
由于它创建了一个@NgModule,我们可以定义提供者、导入和导出--类似于我们常规的@NgModule配置。
我们可以在async或sync模式下配置TestBed。
-
为了异步配置
TestBed,我们将使用configureTestingModule来定义对象的元数据。 -
为了同步配置
TestBed,我们将根据前面部分的讨论定义组件的对象实例。
现在,让我们看一下以下代码片段:
beforeEach(() => { fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
de = fixture.debugElement.query(By.css('h1'));
});
在前面的代码片段中需要注意的重要事项:
-
我们定义了
beforeEach,这意味着这段代码将在每个测试用例运行之前运行。 -
我们使用
TestBed创建了一个组件实例。 -
使用
TestBed同步方式,我们定义了一个fixture变量,它创建了组件AppComponent。 -
使用
componentInstance,我们创建了一个comp变量,它是AppComponent的一个测试实例。 -
使用
debugElement函数,我们可以在视图中定义和定位特定的元素。 -
使用
debugElement,我们可以通过 CSS 元素选择器来定位单个元素。
现在,使用前面的beforeEach方法,该方法具有组件实例,我们将创建用于测试 Angular 组件的测试脚本。
示例 - 使用变化检测编写测试脚本
在本节中,我们将继续编写一些带有变化的测试脚本单元测试。我们还将实现变化检测和元素跟踪。
让我们开始创建一个简单的app.component.ts组件:
import { Component } from '@angular/core';
@Component({
selector: 'test-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Packt Testing works';
}
让我们分析上述代码片段:
-
我们创建了一个
AppComponent组件类。 -
我们声明了一个具有值的
title变量。 -
我们将组件的模板和样式文件映射到它们各自的
templateUrl和styleUrls。
在app.component.html中,添加以下代码:
<h1> {{ title }} </h1>
在上述代码中,我们正在添加一个<h1>标签并映射title变量。
现在,是时候创建我们的测试脚本,其中包含多个断言。但在编写测试脚本之前,让我们了解用例:
-
我们将编写脚本来检查是否创建了
ChangeDetectTestComponent。 -
我们将编写断言来检查
title是否等于Packt Testing works。 -
最后,我们将检查变化检测并验证
h1标记是否应呈现并包含值Packt Testing works。 -
我们还将利用
querySelector来定位特定的元素并匹配值。
现在,让我们来看看前面用例的测试脚本:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectTestComponent } from './change-detect-test.component';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
describe('ChangeDetectTestComponent', () => {
let comp:ChangeDetectTestComponent;
let fixture: ComponentFixture<ChangeDetectTestComponent>;
let de:DebugElement;
let el:HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ ChangeDetectTestComponent ]
});
fixture = TestBed.createComponent(ChangeDetectTestComponent);
comp = fixture.componentInstance;
de = fixture.debugElement.query(By.css('h1'));
el = de.nativeElement;
});
it('should have as title 'Packt Testing works!'', async(() => {
const fixture = TestBed.createComponent(ChangeDetectTestComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('Packt Testing works');
}));
it('should render title in a h1 tag', async(() => {
const fixture = TestBed.createComponent(ChangeDetectTestComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Packt
Testing works');
}));
});
让我们详细分析上述代码片段:
-
我们从
angular/core/testing中导入所需的模块,即TestBed,ComponentFixture和async。 -
我们定义
beforeEach并初始化变量fixture,comp和de。 -
在第一个测试脚本中,我们为组件编写了一个简单的期望语句,即
tobeTruthy。 -
在第二个测试脚本中,我们通过
TestBed.createComponent创建了组件的实例。 -
使用
debugElement,我们创建了已创建组件的实例,即app。 -
使用
app组件的实例,我们能够获取组件的title并断言toEqual。 -
在最后一个测试脚本中,我们使用
async方法。我们利用debugElement的nativeElement方法并定位一个元素--在我们的情况下是<h1>,并检查标题是否包含Packt Testing Works。 -
第二个和第三个测试脚本之间的区别在于我们使用了
async方法,并等待变化被检测--detectChanges--在第三个测试脚本中。
运行测试,我们应该看到如下截图所示的输出:
在本节中,您学会了如何使用beforeEach为所有测试脚本创建一个组件实例,以及如何使用nativeElement来定位任何元素。
我们使用detectChanges方法来识别元素中发生的变化。
在接下来的部分,我们将继续学习有关 Jasmine 框架测试 Angular 服务的更多知识。
测试 Angular 服务
在本节中,我们将学习有关测试 Angular 服务的知识。
在大多数 Angular 应用程序中,编写服务是一个重要且核心的方面,因为它执行与后端服务的交互;创建和共享组件之间的数据,并且在长期内易于维护。因此,确保我们彻底测试我们的 Angular 服务同样重要。
让我们学习如何编写测试脚本来测试我们的服务。为了测试一个服务,让我们首先使用ng命令创建一个服务。
在您的终端中运行以下命令:
ng g service ./test-app/test-app
上述命令将在test-app文件夹中生成test-app.service.ts和test-app.service.spec.ts文件。
服务是可注入的,这意味着我们必须将它们导入到它们各自的组件中,将它们添加到提供者列表中,并在组件构造函数中创建服务的实例。
我们修改test-app.service.ts并向其中添加以下代码:
import { Injectable } from '@angular/core';
@Injectable()
export class TestAppService {
getAuthorCount() {
let Authors =[
{name :"Sridhar"},
{name: "Robin"},
{name: "John"},
{name: "Aditi"}
];
return Object.keys(Authors).length;
};
}
从上述代码片段中注意以下重要事项:
-
我们从 Angular 核心中导入了
injectable。 -
我们定义了
@injectable元数据,并为我们的服务创建了一个类--TestAppService。 -
我们定义了
getAuthorCount方法来返回作者的数量。
我们需要将服务类导入并注入到组件中。为了测试上述服务,我们将在test-app.service.specs.ts文件中编写我们的测试脚本。
我们编写测试服务的方式与编写测试组件的方式类似。
现在,让我们通过在test-app.service.spec.ts文件中添加以下代码来创建测试套件以测试一个服务:
import { TestBed, inject } from '@angular/core/testing';
import { TestAppService } from './test-app.service';
describe('TestAppService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [TestAppService]
});
});
it('Service should return 4 values', inject([TestAppService],
(service: TestAppService) => {
let countAuthor = service.getAuthorCount;
expect(countAuthor).toBe(4);
}));
});
上述代码的分析如下:
-
我们将所需的模块
TestBed和inject导入到spec文件中。 -
我们将
TestAppService服务导入spec文件。 -
使用依赖注入(DI),我们创建了
TestAppService的service实例。 -
我们创建一个测试用例;我们需要注入服务,调用
getAuthorCount方法,并断言该值是否等于4。
当我们运行测试时,以下截图显示了输出:
在本节中,您学习了使用 Jasmine 测试脚本对 Angular 组件和服务进行单元测试。
我们必须在每个测试用例中使用 DI 来注入服务。
测试 Angular 服务-模拟后端服务
在前面的部分,您学习了如何编写测试脚本来测试我们的 Angular 服务。在本节中,我们将编写一个测试脚本,并学习如何在实时项目中模拟后端服务。
以下是我们将为其编写测试脚本的用例:
-
编写一个测试脚本来测试服务中的方法。
-
编写一个测试脚本来检查方法的返回值是否包含特定值。
-
编写一个测试脚本来模拟后端连接使用
mockBackend,并检查目标 URL 是否正确。 -
编写一个测试脚本来为请求 URL 设置
mockResponse。 -
最后,调用
service中编写的方法并映射响应,这应该等于mockResponse。
让我们创建我们的服务test.service.ts文件,并将以下代码添加到其中:
import { Injectable } from '@angular/core'; import { Http } from '@angular/http'; import { Observable } from 'rxjs'; import 'rxjs/add/operator/map'; @Injectable() export class TestService {
constructor (private http: Http) {}
getpublications() {
return ['Packt', 'Packt PDF', 'Packt Video'];
}
getproducts() {
return this.http.get('someurl1').map((response) => response);
}
search(term: string): Observable<any> {
return this.http.get(
'someurl'
).map((response) => response.json());
}
}
在前面的代码片段中需要注意的重要事项如下:
-
我们将所需的模块导入
spec文件,即从Angular/core导入injectable。 -
我们将所需的模块导入
spec文件,即从Angular/http导入Http。 -
我们将所需的模块导入
spec文件,即从Angular/rxjs导入Observable。 -
我们正在为
TestService创建组件类。 -
我们正在使用
@injectable装饰器,这将允许服务被注入到任何组件或服务中。 -
在构造函数中,我们注入
HTTP服务并创建一个 HTTP 实例。 -
我们正在创建三个方法:
getPublications,getProducts和search。 -
在
getProducts中,我们正在进行 HTTP 调用,当然,我们使用它来模拟服务器 URL。 -
我们正在将 HTTP 请求的响应映射到
response变量。
现在我们的服务准备就绪,我们可以开始编写我们的测试规范文件来测试变量和方法。
在spec文件中编写测试脚本之前,让我们创建一个beforeEach方法,其中将包含所有的初始化,并在每个测试脚本之前注册提供者:
beforeEach(() => { TestBed.configureTestingModule({
imports: [ HttpModule ], providers: [ { provide: XHRBackend,
useClass: XHRBackend
}, TestService ]
}); });
就像我们为测试 Angular 组件定义了beforeEach方法一样,我们也为服务定义了beforeEach方法。在提供者数组配置中,我们正在注册XHRBackend类。
由于服务依赖于其他模块并需要提供者,我们需要使用configureTestingModule来定义和注册所需的服务。
让我们详细分析前面的代码片段:
-
我们正在定义一个
beforeEach方法,它将在每个测试脚本之前执行。 -
使用
TestBed,我们正在使用configuringTestingModule配置测试模块。 -
由于
configureTestingModule中传递的参数类似于传递给@NgModule装饰器的元数据,我们可以指定提供者和导入项。 -
在
imports中,我们导入HttpModule。 -
我们在提供者列表中配置所需的依赖项--
XHRBackend和TestService。 -
我们正在注册一个提供者,使用一个注入令牌
XHRBackend并将提供者设置为XHRBackend,这样当我们请求提供者时,DI 系统会返回一个XHRBackend实例。
现在我们可以创建spec文件test.service.spec.ts,并将以下代码添加到文件中:
import {TestService} from './test.service'; import { TestBed, inject } from '@angular/core/testing'; import { MockBackend, MockConnection} from '@angular/http/testing'; import { HttpModule,XHRBackend, ResponseOptions,Response, RequestMethod } from '@angular/http'; const mockResponse = { 'isbn': "123456",
'book': { "id": 10,
"title": "Packt Angular"
} }; const mockResponseText = 'Hello Packt'; describe('service: TestService', () => { beforeEach(() => { TestBed.configureTestingModule({
imports: [ HttpModule ], providers: [ { provide: XHRBackend,
useClass: XHRBackend }, TestService]
}); }); it('Service should return 4 publication values',
inject([TestService, XHRBackend], (service: TestService,
XHRBackend: XHRBackend) => { let names = service.getpublications();
expect(names).toContain('Packt');
expect(names).toContain('Packt PDF');
expect(names).toContain('Packt Video');
expect(names.length).toEqual(3);
})); it('Mocking Services with Json', inject([TestService, XHRBackend],
(service: TestService, XHRBackend: XHRBackend) => { const expectedUrl = 'someurl';
XHRBackend.connections.subscribe(
(connection: MockConnection) => { expect(connection.request.method).toBe(RequestMethod.Get);
expect(connection.request.url).toBe(expectedUrl);
connection.mockRespond(new Response(
new ResponseOptions({ body: mockResponse }) )); }); service.getbooks().subscribe(res => { expect(res).toEqual(mockResponse);
}); })); });
这是一个很长的代码片段,让我们分解进行分析:
-
我们将
TestService服务文件导入到spec文件中。 -
我们从
@angular/core/testing中导入所需的模块TestBed和inject。 -
我们从
@angular/http/testing中导入模块MockBackend和MockConnection。 -
我们从
@angular/http中导入模块HttpModule、XHRBackend、ResponseOptions、Response和RequestMethod。 -
我们定义了一个
mockResponse变量,其中包含一个临时的json对象。 -
我们还定义了一个
mockResponseText变量并为其赋值。 -
我们将使用之前定义的
beforeEach方法,通过它我们将注册所有的提供者和依赖项。 -
在第一个测试脚本中,我们将
TestService实例注册为service,将XHRBackend实例注册为XHRBackend。 -
我们调用
service.getpublications()方法,它将返回数组。 -
在结果名称中,我们断言值应包含作为测试数据传递的字符串。
-
在第二个测试脚本中,我们使用
mockBackend创建连接,并使用subscribe传递请求的method和url。 -
使用
mockRespond连接,我们将响应值设置为mockResponse。 -
我们还调用
getbooks方法,映射响应,并断言toEqual值为mockResponse。
运行测试,我们应该看到以下截图中显示的输出:
如果你看到了前面的截图,那太棒了。
到目前为止,在本节中,你已经学习并探索了 Jasmine 框架及其用于测试 Angular 组件和服务的内置方法。
我们讨论了测试 Angular 组件:测试变量和方法。我们还讨论了如何编写beforeEach方法,在每个测试脚本之前执行,并如何创建组件的实例并访问其属性。我们还介绍了如何使用 Jasmine 框架测试 Angular 服务以及测试 Angular 服务及其属性:变量和方法。
对于测试 Angular 服务,你学会了如何创建一个beforeEach方法,在每个测试脚本之前执行,并且在每个测试脚本之前创建提供者和依赖项。
你学会了通过模拟服务来测试后端服务。当你独立开发 Angular 服务和组件时,这非常有用。
在下一节中,你将学习如何使用 Protractor 框架进行端到端测试。
Protractor 框架简介
在前面的部分中,你学习了使用 Jasmine 进行单元测试。在本节中,你将学习如何使用 Protractor 框架进行 Angular 应用程序的端到端测试。
这就是官方网站如何解释 Protractor 的。
Protractor 是一个用于 Angular 和 AngularJS 应用程序的端到端测试框架。Protractor 在真实浏览器中运行测试,与用户交互。
Protractor 框架打包在 Angular CLI 工具中,我们可以在主项目目录中找到创建的e2e文件夹:
你将学习为你的 Angular 应用程序编写端到端测试,并将它们保存在e2e文件夹下。
记住,最好的做法是为每个功能或页面创建单独的 E2E 脚本。
Protractor - 快速概述
Protractor 是 Selenium WebDriver 的封装,提供了许多内置的类和方法,我们可以用来编写端到端测试。
Protractor API 主要公开了各种类和方法,主要围绕Browser、Element、Locators和ExpectedConditions。
Protractor 支持 Chrome、Firefox、Safari 和 IE 的最新两个主要版本,这意味着我们可以编写测试脚本并在任何/所有可用的主流浏览器上运行它们。
为了编写端到端测试,我们需要定位页面中的元素,读取它们的属性,更新属性,并调用附加到元素的方法,或者发送和验证数据。
我们将讨论 Protractor 框架中提供的各种类和方法,通过这些方法,我们可以编写端到端测试来自动化应用程序功能。
让我们了解一下可用的方法和类,我们可以使用 Protractor 框架。
Protractor 和 DOM
在本节中,您将学习如何使用 Protractor 与页面中的 DOM 元素进行交互。
Protractor API 支持并公开了用于定位页面中元素的类和方法。我们需要明确说明我们是需要定位特定元素,还是期望返回一组元素。
element函数用于在网页上查找 HTML 元素。它返回一个ElementFinder对象,可用于与元素交互或获取有关其属性和附加方法的信息。
我们需要动态地在页面中查找、编辑、删除和添加元素及其属性。但是,要实现这些用例,我们需要首先定义并找到目标元素。
我们可以使用以下方法定义目标元素:
element:此方法将返回单个/特定元素:
element( by.css ( 'firstName' ) );
element.all:此方法返回一个元素集合:
element.all(by.css('.parent'))
使用上述方法,我们可以定位页面中的任何元素。在下一节中,您将学习可以与element或element.all方法一起使用的可用方法。
一些可用于选择元素的方法
在前面的部分中,我们看到了一系列最常用的方法,用于选择或定位页面中的元素或多个元素。
要使用前面讨论的方法,您需要明确说明您是需要定位特定元素,还是期望返回一组元素。
在本节中,让我们了解一下在测试脚本中定位/选择元素的可用方法和方式。我们可以一次定位一个或多个元素。
我们可以使用几乎所有的属性、属性和自定义指令来定位特定的元素。
让我们看一下在测试脚本中定位元素的一些方法:
by.css:我们可以传递 CSS 选择器来选择一个或多个元素:
element( by.css('.firstName' ) );
CSS选择器是定位和选择元素最常用的方法。
by.model:我们使用这个来选择或定位使用绑定到元素的ng-model名称的元素:
element( by.model ( 'firstName' ) );
请注意,官方文档仍建议使用 CSS 选择器而不是模型。
by.repeater:我们使用这个方法来选择使用ng-repeat指令显示的元素:
element( by.repeater('user in users').row(0).column('name') );
by.id:我们使用这个方法来使用它的 ID 选择一个元素:
element( by.id( 'firstName' ) );
by.binding:使用这个来选择与单向或双向 Angular 绑定相关的元素:
element( by.binding( 'firstName' ) );
by.xpath:使用这个来通过xpath遍历选择元素:
element(by.css('h1')).element(by.xpath('following-
sibling::div'));
first()、last()或特定元素:我们使用这些方法来获取特定位置或索引处的元素:
element.all(by.css('.items li')).first();
我们了解了一些方法,可以使用它们的属性和信息来定位元素。有关可用方法的完整列表,请参阅 GitHub 上 Protractor 的官方文档。
在下一节中,您将了解可以使用的各种内置方法,以编写测试脚本来自动化应用程序逻辑。
探索 Protractor API
在本节中,您将了解 Protractor API 中各种内置类和方法,我们可以用来编写我们的测试脚本。
Protractor API 具有许多预定义的内置属性和方法,用于支持Browser、Element、Locators和ExpectedConditions。
它提供了许多内置方法,从点击事件到设置输入表单的数据,从获取文本到获取 URL 详细信息等等,以模拟应用程序页面中的操作和事件。
让我们快速看一下一些可用的内置方法来模拟用户交互:
click:使用这个方法,我们可以安排一个命令来点击这个元素。该方法用于模拟页面中的任何点击事件:
element.all( by.id('sendMail') ).click();
getTagName:这会获取元素的标签/节点名称:
element(by.css('.firstName')).getTagName()
sendKeys:使用这个方法,我们可以安排一个命令在 DOM 元素上输入一个序列:
element(by.css('#firstName')).sendKeys("sridhar");
isDisplayed:使用此方法,我们可以安排一个命令来测试此元素当前是否显示在页面中:
element(by.css('#firstPara')).isDisplayed();
Wait:使用此方法,我们可以执行一个命令来等待条件保持或承诺被解决:
browser.wait(function() {
return true;
}).then(function () {
// do some operation
});
getWebElement:使用此方法,我们可以找到由此ElementFinder表示的网页元素:
element(by.id('firstName')).getWebElement();
getCurrentUrl:使用此方法,我们可以检索当前应用程序页面的 URL。此方法与browser模块一起使用:
var curUrl = browser.getCurrentUrl();
有关属性和方法的完整列表,请参考 GitHub 上 Protractor 的官方文档。
在本节中,您了解了一些可用于编写测试脚本和在页面中自动化应用程序工作流程的方法。
我们将通过示例学习在以下部分中使用一些内置方法。在下一节中,我们将开始使用 Protractor 编写测试脚本。
Protractor - 初步
在本节中,让我们开始使用 Protractor 编写测试脚本。我们将利用本章前面看到的方法和元素定位来编写我们的测试脚本。
Protractor 框架测试套件的一般语法如下:
describe("Sample Test Suite", function() {
it("This is a spec that defines test", function() {
// expect statement to assert the logic etc
});
});
分析上述代码片段,您会意识到它与我们为 Jasmine 测试脚本创建的非常相似。太棒了!
为 Jasmine 和 Protractor 编写的测试套件看起来很相似。主要区别在于我们通过element和browser模块来定位页面中的任何特定 DOM 元素。
现在,在app.e2e-specs.ts文件中,我们编写我们的第一个端到端测试脚本;将以下代码片段添加到文件中:
import {element, by, browser} from 'protractor';
describe('dashboard App', () => {
it('should display message saying app works', () => {
browser.get('/');
let title = element(by.tagName('h1')).getText();
expect(title).toEqual('Testing E2E');
});
});
让我们详细分析上述代码片段。已遵循以下步骤:
-
我们正在从
protractor库中导入所需的模块element,by和browser到我们的测试脚本中。 -
使用
describe语句,我们为我们的端到端测试规范分配一个名称,并为其编写specDefinitions。 -
我们使用
it语句定义一个测试脚本,并在函数中使用browser导航到主页并检查<H1>标签和值是否等于Testing E2E。
我们已经定义了我们的e2e测试脚本;现在让我们使用ng命令运行测试,如下所示:
ng e2e
上述命令将运行,调用浏览器,执行e2e测试脚本,然后关闭浏览器。
您应该在终端中看到以下结果:
如果您看到所有测试脚本都通过了,那么我们所有的 E2E 测试都通过了。恭喜!
该命令需要在项目目录的父目录中运行。
使用 Protractor 编写 E2E 测试
在前面的部分中,您学会了如何使用 Protractor 编写您的第一个测试脚本。在本节中,我们将扩展我们的示例,并为其添加更多内容。
让我们来看看我们在示例中将自动化的用例:
-
我们将检查我们的主页是否具有标题
Testing E2E。 -
我们将检查页面上是否显示了具有
firstParaID 的元素。 -
我们将断言具有
firstParaID 的元素的class属性是否等于'custom-style'。 -
最后,我们读取页面的当前 URL,并检查它是否等于我们在断言中传递的值。
现在让我们为此编写我们的 E2E 规范。在app.e2e.spec.ts文件中,添加以下代码行:
import { browser, by, element } from 'protractor';
describe('Form automation Example', function() {
it('Check paragraphs inner text', function() {
browser.get('/first-test');
var s = element(by.css('#firstPara')).getText();
expect(s).toEqual('Testing E2E');
});
it('Should check for getAttribute - class', function() {
browser.get('/first-test');
var frstPa = element(by.id('firstPara'));
expect(frstPa.getAttribute('class')).toEqual('custom-style');
});
it('Should check element for isDisplayed method', function() {
browser.get('/first-test');
var ele = element(by.css('#firstPara')).isDisplayed();
expect(ele).toBeTruthy();
});
it('Check the applications current URL', function() {
var curUrl = browser.getCurrentUrl();
expect(curUrl).toBe('http://localhost:49152/first-test');
});
});
前面代码的分解和分析如下:
-
我们从
protractor导入了所需的模块element、by和browser。 -
我们编写了一个
describe语句,创建了一个名为“表单自动化示例”的测试套件。 -
对于第一个测试脚本,我们告诉
protractor使用browser通过get方法导航到/first-testURL。 -
我们获得了具有
id为firstPara的元素及其文本,并检查其值是否等于Testing E2E。 -
在第二个测试脚本中,我们使用
get方法导航到 URL/first-test,并获得具有id为firstPara的相同元素。 -
现在使用
getAttribute方法,我们获取元素的class属性,并检查其值是否与'custom-style'匹配。 -
在第三个测试脚本中,我们告诉
protractor使用browser通过get方法导航到/first-testURL。 -
使用
isDisplayed方法,我们检查元素是否在页面上显示。 -
在第四个测试脚本中,我们告诉
protractor使用browser方法getCurrentUrl来获取页面的currentUrl。 -
我们检查
currentUrl是否与测试脚本中传递的值匹配。
为了运行端到端测试,我们将使用ng命令。在项目目录中,运行以下命令:
ng e2e
以下截图显示了一旦所有测试通过后我们将看到的输出:
创建和运行测试是多么简单和容易,对吧?
这是一个很好的开始,我们将继续学习使用高级技术编写更多的测试脚本。
继续前进,编写自动化测试脚本来插入你的逻辑和应用程序。
使用 Protractor 编写 E2E 测试-高级
到目前为止,在之前的章节中,我们已经涵盖了使用 Protractor 框架安装、使用和编写测试脚本。我们已经学习并实现了 Protractor API 公开的内置方法和类。
在本节中,我们将介绍编写高级测试脚本,这些脚本将在页面中进行交互,并对元素进行彻底测试。
让我们看一下我们将涵盖的用例:
-
我们将测试我们的数组数值。
-
我们将使用
class属性来定位我们的元素。 -
我们将检查页面的标题。
-
我们将模拟附加在按钮上的
click事件,然后验证另一个元素的文本更改。
让我们开始编写我们的测试脚本。
我们需要首先创建我们的test-app.component.html文件。创建文件,并将以下代码添加到文件中:
<h3 class="packtHeading">Using protractor - E2E Tests</h3>
<input id="sendEmailCopy" type="checkbox"> Send email copy
<!-- paragraph to load the result -->
<p class="afterClick">{{afterClick}}</p>
<!-- button to click -->
<button (click)="sendMail()">Send mail!</button>
上述代码片段的分析如下:
-
我们定义了一个
h3标题标签,并分配了一个class属性,值为packtHeading。 -
我们创建了一个 ID 为
sendEmailCopy的input类型checkbox元素。 -
我们定义了一个带有
class属性为afterClick的段落p标签,并绑定了{{ }}中的值。 -
我们定义了一个
button并附加了一个click事件来调用sendMail方法。 -
sendMail方法的目的是改变paragraph标签内的文本。
现在我们已经定义了模板文件,是时候创建我们的组件文件了。
创建test-app.component.ts文件,并将以下代码片段添加到其中:
import { Component } from '@angular/core';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-test-app',
templateUrl: './test-app.component.html',
styleUrls: ['./test-app.component.css']
})
export class TestAppComponent {
constructor() {}
public myModel = "Testing E2e";
public authorName = 'Sridhar';
public publisherName = 'Packt';
public afterClick = 'Element is not clicked';
public hiPackt() {
return 'Hello ' + this.publisherName;
}
public sendMail() {
this.afterClick = 'Element is clicked';
}
}
让我们详细分析上述代码片段:
-
我们从
@angular/core导入了Component和Oninit模块。 -
我们还从
@angular/forms导入了FormsModule。 -
我们创建了
Component并将 HTML 和 CSS 文件分别关联到templateUrl和stylesUrl。 -
我们定义了
myModel、authorName、publisherName和afterClick变量。 -
我们为定义的变量赋值。
-
我们定义了一个
hiPackt方法,它将显示Hello Packt。 -
我们定义了一个
sendMail方法,当调用时将更新afterClick变量的值。
到目前为止,一切顺利。跟着我继续;我们很快就要编写出漂亮的测试脚本了。
现在,我们已经定义了模板文件并实现了组件文件;我们非常了解组件的功能。现在是时候开始测试部分了。
让我们创建测试规范app.e2e.spec.ts文件,并将以下代码片段添加到其中:
import {element, by, browser} from 'protractor';
describe('dashboard App', () => {
beforeEach(function () {
browser.get('/test-app');
});
it('should display message saying app works', () => {
const title = element(by.tagName('h1')).getText();
expect(title).toEqual('Learning Angular - Packt Way');
});
it('should display message saying app works', () => {
element(by.tagName('button')).click();
const title = element(by.css('.afterClick')).getText();
expect(title).toEqual('Element is not clicked');
});
it('Should check is radio button is selected or deselected',
function() {
var mailCopy = element(by.id('sendEmailCopy'));
expect(mailCopy.isSelected()).toBe(false);
mailCopy.click();
expect(mailCopy.isSelected()).toBe(true);
});
it('Check the applications current URL', function() {
var curUrl = browser.getCurrentUrl();
expect(curUrl).toBe('http://localhost:49152/test-app');
});
});
让我们详细看看我们的测试规范中发生了什么:
-
我们定义了一个
beforeEach方法,它将在测试脚本之前执行,并打开浏览器 URL。 -
现在,我们编写一个测试脚本来测试
h1标签的title值,使用断言toEqual。 -
在第二个测试脚本中,我们使用
tagName获取button元素,并调用click方法。 -
由于方法是
clicked,段落的值已经更新。 -
我们将使用
by.css检索段落元素,并获取其中的段落文本value。 -
我们断言新更新的
value是否等于Element is clicked。 -
在第三个测试脚本中,我们使用
isSelected方法检查input元素类型checkbox是否被选中。 -
使用
click方法,我们现在切换checkbox并再次检查值。这个测试脚本是为了向您展示如何操作表单元素。 -
最后,在最后一个测试脚本中,我们使用
getCurrentUrl获取当前页面的 URL,并检查它是否匹配/test-app。
就这样,全部完成了。现在,我们已经有了模板文件,创建了组件,也有了测试规范文件。
现在是展示时间。让我们运行应用程序,我们应该看到以下截图中显示的输出:
在本节中,您学会了使用 Protractor 框架编写测试脚本。我们探索了框架中所有内置的可用方法,供我们在编写脚本时使用。
我们注意到编写的测试脚本与 Jasmine 测试脚本类似。我们还看到了如何使用各种方法(如by.css、by.binding和by.id)来定位特定元素或元素集合。
我们讨论了使用 Protractor 框架进行事件处理和绑定。
总结
测试是应用程序开发中最关键和重要的方面之一。在本章中,您学习了如何使用 Angular CLI、Jasmine 和 Protractor 框架。使用 Jasmine 和 Protractor 进行自动化测试可以帮助您节省时间和精力。
您学习了为 Angular 组件和服务编写单元测试脚本,以及如何为工作流自动化测试编写 E2E 测试用例。您详细了解了 Jasmine 框架和 Protractor 框架中内置到函数中的方法和变量。
我们深入研究了针对特定元素的定位,以及一起检索元素集合以读取、更新和编辑属性和数值。继续使用这些出色的测试框架来自动化您的应用程序。
在下一章中,您将学习 Angular 中的设计模式。Typescript 是一种面向对象的编程语言,因此我们可以利用几十年关于面向对象架构的知识。您还将探索一些最有用的面向对象设计模式,并学习如何在 Angular 中应用它们。
第十六章:Angular 中的设计模式
TypeScript 是一种面向对象的编程语言,因此我们可以利用几十年的面向对象架构知识。在这一章中,我们将探讨一些最有用的面向对象设计模式,并学习如何在 Angular 中应用它们。
Angular 本身是一个面向对象的框架,它强制你以某种方式进行大部分开发。例如,你需要有组件、服务、管道等。强制你使用这些构建块有助于构建良好的架构。这很像 Zend 框架为 PHP 或 Ruby on Rails 为 Ruby 所做的事情。框架的存在是为了让你的生活更轻松,加快开发时间。
虽然 Angular 的设计方式远远超出平均水平,但我们总是可以做得更好。我并不是说我在这一章中提出的设计是最终的,你可以用它来解决从面包店的一页纸到火星一号任务的仪表板的任何问题--这样的设计并不存在--但它确实会提高你的工具箱。
在这一章中,我们将学习使用以下模式:
-
模型-视图-控制器(MVC)
-
单例
-
依赖注入
-
原型
-
可重用池
-
工厂
-
备忘录
模型-视图-控制器(MVC)
哦,MVC,好老的 MVC。多年来你为我们服务得很好。现在,人们希望你退休,最好不要有麻烦。此外,即使我也能看到,更年轻的单向用户界面架构可以比你更聪明,让你看起来像是过去的遗物。
在本节中,我们将首先描述 MVC 是什么,不管用什么编程语言来实现它,然后我们将看到将 MVC 应用于前端编程的缺点。最后,我将介绍一种在 Angular 中实现有意义的 MVC 的方法,这种方法考虑了实现的便利性、维护性和性能。
MVC 的大局
MVC 设计模式的整体原则非常简单。事实上,如下图所示,它由三个部分组成:模型、视图和控制器。更具体地说,MVC 的意图是定义对象之间的一对多依赖关系,以便当一个对象改变状态时,所有依赖它的对象都会被通知并自动更新:
MVC 概述
让我们逐块分析前面的图像:
-
模型根据控制器发送的命令存储应用程序所需的数据。
-
控制器接收用户的操作(即点击按钮)并相应地指导模型更新。它还可以设置在任何给定时刻使用哪个视图。
-
视图在模型更改时生成和更新。
就是这样。
让我们看看纯 TypeScript 中一个简单的 MVC 实现会是什么样子。
首先,让我们像我们在第十章中所做的那样定义一个Movie类,在 Angular 中的 Material Design。在这个版本的Movie类中,我们只有两个属性,title和release_year,它们是使用 TypeScript 构造函数定义的:
class Movie{
constructor(private title:string, private release_year:number){}
public getTitle():string{
return this.title;
}
public getReleaseYear():number{
return this.release_year;
}
}
然后,我们定义一个Model类,导入包含Movie类的movie.ts文件,使用引用关键字。这个Model类将负责更新视图,它有一个电影数组和两个方法。第一个方法addMovie(title:string, year:number)是公共的,它在 movies 属性的末尾添加一个新的电影。它还调用类的第二个方法:appendView(movie:Movie),这个方法是私有的。这个第二个方法根据 MVC 定义操纵视图。视图操纵相当简单;我们在视图的movie元素中添加一个新的li标签。新创建的li标签的内容是电影标题和发行年份的连接:
class Model{
private movies:Movie[] = [];
constructor(){
}
public addMovie(title:string, year:number){
let movie:Movie = new Movie(title, year);
this.movies.push(movie);
this.appendView(movie);
}
private appendView(movie:Movie){
var node = document.createElement("LI");
var textnode = document.createTextNode(movie.getTitle() +
"-" + movie.getReleaseYear());
node.appendChild(textnode);
document.getElementById("movies").appendChild(node);
}
}
我们现在可以为我们的纯 TypeScript MVC 定义一个控制器。控制器有一个私有的model:Model属性,在构造函数中初始化。此外,定义了一个click方法。这个方法接受一个字符串和一个数字作为参数,分别用于标题和发行年份。正如你所看到的,click方法将标题和发行年份转发给模型的addMovie方法。然后,控制器的工作就完成了。它不会操纵视图。你还会注意到controller.ts文件的最后一行:let controller = new Controller();。这行允许我们创建一个Controller类的实例,视图可以绑定到它:
class Controller{
private model:Model;
constructor(){
this.model = new Model();
}
click(title:string, year:number){
console.log(title, year);
this.model.addMovie(title, year);
}
}
let controller = new Controller();
我们 MVC 实现的最后一部分是视图。我们有一个简单的 HTML 表单,提交时会调用以下内容:controller.click(this.title.value, this.year.value); return false;. 控制器在controller.ts文件中已经定义为let controller = new Controller();。然后,对于参数,我们发送this.title.value和this.year.value,其中 this 指的是<form>。
标题和年份分别指的是电影的标题和发行年份字段。我们还必须添加return false以防止页面重新加载。实际上,HTML 表单的默认行为是在提交时导航到操作 URL:
<html>
<head>
<script src="mvc.js"></script>
</head>
<body>
<h1>Movies</h1>
<div id="movies">
</div>
<form action="#" onsubmit="controller.click(this.title.value,
this.year.value); return false;">
Title: <input name="title" type="text" id="title">
Year: <input name="year" type="text" id="year">
<input type="submit">
</form>
</body>
</html>
在页眉中,我们添加了通过以下命令生成的mvc.js脚本:tsc--out mvc.jscontroller.ts model.ts movie.ts。生成的 JavaScript 如下所示:
var Movie = (function () {
function Movie(title, release_year) {
this.title = title;
this.release_year = release_year;
}
Movie.prototype.getTitle = function () {
return this.title;
};
Movie.prototype.getReleaseYear = function () {
return this.release_year;
};
return Movie;
}());
/// <reference path="./movie.ts"/>
var Model = (function () {
function Model() {
this.movies = [];
}
Model.prototype.addMovie = function (title, year) {
var movie = new Movie(title, year);
this.movies.push(movie);
this.appendView(movie);
};
Model.prototype.appendView = function (movie) {
var node = document.createElement("LI");
var textnode = document.createTextNode(movie.getTitle() +
"-" + movie.getReleaseYear());
node.appendChild(textnode);
document.getElementById("movies").appendChild(node);
};
return Model;
}());
/// <reference path="./model.ts"/>
var Controller = (function () {
function Controller() {
this.model = new Model();
}
Controller.prototype.add = function (title, year) {
console.log(title, year);
this.model.addMovie(title, year);
};
return Controller;
}());
var controller = new Controller();
在执行方面,在加载时,HTML 页面将如下截图所示:
加载时的 MVC。
然后,如果您使用表单并添加电影,它将自动影响视图,并显示新的电影,如下图所示:
在使用表单后的 MVC。
前端的 MVC 限制
那么,为什么在前端编程中使用 MVC 模式不那么常见,尤其是在像 Angular 这样的框架支持下?首先,如果您正在为提供服务的应用程序使用 Angular,您很可能会有一个与之交换一些信息的后端。然后,如果您的后端也使用 MVC 设计模式,您将得到以下层次结构:
前端和后端的 MVC。
在这个层次结构中,我们在另一个 MVC 实现的顶部有一个 MVC 实现。这两种实现通过一个 API 服务进行通信,该服务向后端控制器发送请求并解析生成的视图。具体示例是,如果用户必须登录您的应用程序,他们将在由用户模型和登录控制器提供支持的前端上看到登录视图。一旦输入了所有信息(电子邮件、密码),用户就会点击登录按钮。
这个点击触发了模型更新,然后模型使用 API 服务触发 API 调用。API 服务向您的 API 的user/signin端点发出请求。在后端,请求被用户控制器接收并转发到用户模型。后端用户模型将查询您的数据库,以查看是否有提供的用户和密码匹配的用户。最后,如果登录成功,将输出一个包含用户信息的视图。回到前端,API 服务将解析生成的视图并将相关信息返回给前端用户模型。依次,前端用户模型将更新前端视图。
对于一些开发人员来说,这么多层以及架构在前端和后端上的重复似乎不太对,尽管它通过明确定义的关注点分离带来了可维护性。
双重 MVC 并不是唯一的问题。另一个问题是,前端模型不会是纯模型,因为它们必须考虑 UI 本身的变量,比如可见标签、表单有效性等等。因此,你的前端模型往往会变成一团丑陋的代码,其中 UI 变量与用户的实际表示相互交织。
现在,像往常一样,你可以避免这些陷阱,利用 MVC 模式的优势。让我们在下一节中看看如何做到这一点。
Angular 是 MVC
在本节中,我提出了一个在 Angular 中证明有效的 MVC 架构。我在toolwatch.io(Web、Android 和 iOS)过去的八个月中使用了这个架构。显然,我们在 Web 版本或移动应用上提出的功能是相同的,工作方式也相同;不同的是视图和导航方案。
下图展示了整体架构:
Angular 的 MVC。
从上到下,我们有后端、可重用的前端部分和专门的前端(即移动端或 Web 端)。正如你所看到的,在后端,没有任何变化。我们保留了传统的 MVC。请注意,前端部分也可以与非 MVC 后端一起工作。
我们的模型将使用该服务通过假想的 JSON API 从远程数据库获取、放置和删除一个简单的 TypeScript 对象。
我们的用户 TypeScript 对象如下所示:
export class User {
public constructor(private _email:string, private _password:string){}
get email():string{
return this._password;
}
get password():string{
return this._email;
}
set email (email:string){
this._password = email;
}
set password (password:string){
this._email = password;
}
}
这里没有太多花哨的东西。只是一个简单的 TypeScript 对象,包含两个属性:email:_string和password:_string。这两个属性在构造函数中使用 TypeScript 内联声明样式进行初始化。我们还利用了 TypeScript 的 getter/setter 来访问password:string和_email:string属性。你可能已经注意到,TypeScript 的 getter/setter 看起来像 C#属性。嗯,微软是 TypeScript 的主要工业调查者之一,所以这是有道理的。
我确实喜欢写作的简洁性,特别是在构造函数中与内联属性声明结合在一起时。然而,我不喜欢的是必须使用下划线变量名。问题在于,再次,这个 TypeScript 将被转译成 JavaScript,在 JavaScript 中,变量和函数比如说 Java 或 C#更加抽象。
实际上,在我们当前的示例中,我们可以调用user类的 getter 如下:
user:User = new User('mathieu.nayrolles@gmail.com', 'password');
console.log(user.email); // will print mathieu.nayrolles@gmail.com
正如你所看到的,TypeScript 并不关心它调用的目标的类型。它可以是一个名为 email 的变量,也可以是一个名为email()的函数。无论哪种方式,它都可以工作,产生不同的结果,但它可以工作。这种奇怪行为背后的基本原理是,在面向对象的程序中,在 JavaScript 中,这是可以接受的:
var email = function(){
return "mathieu.nayrolles@gmail.com";
}
console.log(email);
因此,我们需要用不同的名称区分函数的实际变量。因此有了下划线。
现在我们有一个完全可靠的用户对象来操作,让我们回到我们的 MVC 实现。现在我们可以有一个UserModel来操作用户普通旧 TypeScript 对象(POTO)和图形界面所需的变量:
export class UserModel{
private user:User;
private _loading:boolean = false;
public constructor(private api:APIService){}
public signin(email:string, password:string){
this._loading = true;
this.api.getUser(new User(email, password)).then(
user => {
this.user = user;
this._loading = false;
}
);
}
public signup(email:string, password:string){
this._loading = true;
this.api.postUser(new User(email, password)).then(
user => {
this.user = user;
this._loading = false;
}
);
}
get loading():boolean{
return this._loading;
}
}
我们的模型,名为UserModel,接收了一个APIService的注入。APIService的实现留给读者作为练习。然而,它将非常类似于我们在第九章中看到的Angular 2 中的高级表单。除了APIService,UserModel拥有user:User和loading:bool属性。user:User代表了实际的用户及其密码和电子邮件。然而,loading:bool将用于确定视图中是否应该显示加载旋转器。正如你所看到的,UserModel定义了signin和signup方法。在这些方法中,我们调用了假设的APIService的getUser和postUser方法,它们都接受一个 User 作为参数,并返回一个包含所述用户通过 JSON API 同步的 Promise。收到 Promise 后,我们关闭loading:bool旋转器。
然后,让我们来看看控制器,它也将是 Angular 环境中的一个组件,因为 Angular 组件控制显示的视图等等。
@Component({
templateUrl: 'user.html'
})
export class UserComponent{
private model:UserModel;
public constructor(api:APIService){
this.model = new UserModel(api);
}
public signinClick(email:string, password:string){
this.model.signin(email, password);
}
public signupClick(email:string, password:string){
this.model.signup(email, password);
}
}
正如你所看到的,控制器(组件)很简单。我们只有一个对模型的引用,并且我们接收一个注入的APIService以传递给模型。然后,我们有signinClick和signupClick方法,从视图接收用户输入并将其传递给model。最后一部分,视图,看起来像这样:
<h1>Signin</h1>
<form action="#" onsubmit="signinClick(this.email.value, this.password.value); return false;">
email: <input name="email" type="text" id="email">
password: <input name="password" type="password" id="password">
<input [hidden]="model.loading" type="submit">
<i [hidden]="!model.loading" class="fa fa-spinner"
aria-hidden="true">loading</i>
</form>
<h1>Signup</h1>
<form action="#" onsubmit="signupClick(this.email.value,
this.password.value); return false;">
email: <input name="email" type="text" id="email">
password: <input name="password" type="password" id="password">
<input [hidden]="model.loading" type="submit">
<i [hidden]="!model.loading" class="fa fa-spinner"
aria-hidden="true">loading</i>
</form>
在这里,我们有两个表单,一个用于登录,一个用于注册。这两个表单除了它们使用的onsubmit方法不同之外,都是相似的。登录表单使用我们控制器的signinClick方法,注册表单使用signupClick方法。除了这两个表单,我们还在每个表单上有一个 Font Awesome 旋转器,只有在用户模型正在加载时才可见。我们通过使用[hidden] Angular 指令来实现这一点:[hidden]="!model.loading"。同样,当模型正在加载时,提交按钮也是隐藏的。
所以,这就是一个应用于 Angular 的功能性 MVC。
正如我在本节开头所说的,对我来说,MVC 模式在 Angular 中的实际用处来自于它的可扩展性。事实上,利用 TypeScript 的面向对象的特性(以及随之而来的内容)允许我们为不同的 Angular 应用程序专门定制控制器和模型。例如,如果你有一个 Angular 网站和一个 Angular 移动应用程序,就像我在toolwatch.io中所做的那样,那么你可以在两边重用业务逻辑。当我们本可以只有一个时,如果随着时间的推移,我们需要编写和维护两个登录、两个注册和两个所有内容,那将是一件遗憾的事情!
例如,在toolwatch.io,Web 应用程序使用标准的 Angular,我们使用 Ionic2 和 Angular 构建移动应用程序。显然,我们在移动应用程序(Android 和 iOS)和网站之间共享了许多前端逻辑。最终,它们倾向于实现相同的目的和功能。唯一的区别是用于使用这些功能的媒介。
在下图中,我粗略地表示了一种利用 MVC 模式实现重用和可扩展性的完整方式:
可重用的 Angular MVC。
后端保持不变。我们在那里有相同的 MVC 模式。作为提醒,后端上的 MVC 模式完全取决于您,例如,您可以利用前端 MVC 模式与功能性的 Go 后端。在此处公开的 MVC 的先前版本不同的是引入了可重用的前端部分。在这部分中,我们仍然有一个负责消费我们的 JSON API 的 API 服务。然后,我们有一个实现IModel接口的模型:
export interface IModel{
protected get(POTO):POTO;
protected put(POTO):POTO;
protected post(POTO):POTO;
protected delete(POTO):boolean;
protected patch(POTO):POTO;
}
该接口定义了必须在随后的模型中实现的put、post、delete和patch方法。这些方法接受和返回的POTO类型是您程序中任何领域模型的母类。领域模型代表您的业务逻辑中可同步的实体,例如我们之前使用的用户。领域模型和 MVC 的模型部分不应混淆。它们根本不是同一回事。在这种架构中,用户将扩展POTO。
这次的模型(MVC 模式)也包含一个POTO来实现IModel接口。此外,它包含您需要更新视图的变量和方法。模型本身的实现如我在本节前面所示,相当简单。然而,我们可以通过利用 TypeScript 的通用方面并设想以下内容来提高一些东西:
export class AbstractModel<T extends POTO> implements IModel{
protected T domainModel;
public constructor(protected api:APIService){}
protected get(POTO):T{
//this.api.get ...
};
protected put(T):T{
//this.api.put...
};
protected post(T):T{
//this.api.post...
};
protected delete(T):boolean{
//this.api.delete...
};
protected patch(T):T{
//this.api.patch...
};
}
export class UserModel extends AbstractModel<User>{
public constructor(api:APIService){
super(api);
}
public signin(email:string, password:string){
this._loading = true;
this.get(new User(email, password)).then(
user => {
this.user = user;
this._loading = false;
}
);
}
public signup(email:string, password:string){
this._loading = true;
this.post(new User(email, password)).then(
user => {
this.user = user;
this._loading = false;
}
);
}
//Only the code specialized for the UI!
}
在这里,我们有一个通用的AbstractModel,它受到POTO的约束。这意味着AbstractModel通用类的实际实例(在诸如 C++之类的语言中称为模板)受到类专门化的POTO的约束。换句话说,只有诸如User之类的领域模型才能被使用。到目前为止,关注点的分离以及可重用性都非常出色。可重用部分的最后一部分是控制器。在我们的注册/登录示例中,它看起来会非常像这样:
export class UserController{
public UserComponent(protected model:UserModel){
}
public signin(email:string, password:string){
this.model.signin(email, password);
}
public signup(email:string, password:string){
this.model.signup(email, password);
}
}
现在,为什么我们在这里需要一个额外的构建块,不能像我们在 Angular MVC 的简化版本中那样使用简单的 Angular 组件呢?嗯,问题在于,取决于您在 Angular 核心之上使用的内容(Ionic、Meteor 等),组件并不一定是主要的构建块。例如,在 Ionic2 世界中,您使用的是页面,这是经典组件的自定义版本。
因此,例如,移动部分会是这样的:
export class LoginPage extends UserController{
public LoginPage(api:APIService){
super(new UserModel(api));
}
//Only what's different on mobile!
}
如果需要的话,你也可以扩展UserModel并添加一些专业化,就像在 Angular 的可重用 MVC 图中所示的那样。在浏览器端,添加这段代码:
@Component({
templateUrl: 'login.html'
})
export class LoginComponent extends UserController{
public UserComponent(api:APIService){
super(new UserModel(api));
}
//Only what's different on the browser !
}
再一次,你也可以扩展UserModel并添加一些专业化。唯一剩下的要涵盖的部分是视图。令我绝望的是,没有办法扩展或添加样式文件。因此,除非 HTML 文件在移动应用和浏览器应用之间是相同的,否则我们注定要在客户端之间有 HTML 文件的重复。根据经验,这种情况并不经常发生。
整个可重用的前端可以作为 Git 子模块、独立库或NgModule进行发布。我个人喜欢使用 Git 子模块的方法,因为它允许我在进行对共享前端进行修改时,享受客户端自动刷新的同时拥有两个独立的存储库。
请注意,如果你有几个前端同时访问同一个后端,而不是几种类型的前端,这个 MVC 也是有效的。例如,在电子商务设置中,你可能希望拥有不同品牌的网站来销售在同一个后端中管理的不同产品,就像 Magento 的视图所能实现的那样。
单例模式和依赖注入
前端应用程序中使用的另一个方便的模式是单例模式。单例模式确保程序中只存在一个给定对象的实例。此外,它提供了对对象的全局访问点。
实际上看起来是这样的:
export class MySingleton{
private static instance:MySingleton = null;
//This constructor is private in order to prevent new creation
//of MySingleton objects private constructor(){
}
public static getInstance():MySingleton{
if(MySingleton.instance == null){
MySingleton.instance = new MySingleton();
} return MySingleton.instance; }
}
let singleton:MySingleton = MySingleton.getInstance();
我们有一个类,它有一个private static instance:MySingleton属性。然后,我们有一个private构造函数,使以下操作失败:
let singleton:MySingleton = new MySingleton();
请注意,它失败是因为你的 TypeScript 转译器抱怨可见性。然而,如果你将MySingleton类转译为 JavaScript 并在另一个 TypeScript 项目中导入它,你将能够使用新的操作符,因为转译后的 JavaScript 根本没有可见性。
这种单例模式的相当简单的实现的问题是并发。确实,如果两个进程同时调用getInstance():MySingleton,那么程序中将会有两个MySingleton的实例。为了确保这种情况不会发生,我们可以使用一种称为早期实例化的技术:
export class MySingleton{
private static instance:MySingleton = new MySingleton();
private constructor(){
}
}
singleton:MySingleton = MySingleton.getInstance();
虽然你可以在 TypeScript 中实现你的单例,但你也可以利用 Angular 创建单例的方式:服务!确实,在 Angular 中,服务只被实例化一次,并注入到需要它们的任何组件中。这里有一个通过NgModule进行服务和注入的例子,我们在本书中之前已经看过:
// ./service/api.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class APIService {
private increment:number = 0;
public constructor(){
this.increment++;
}
public toString:string{
return "Current instance: " + this.increment;
}
}
// ./app.component.ts
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
public constructor(api:APIService){
console.log(api);
}
}
// ./other.component.ts
@Component({
selector: 'other-root',
templateUrl: './other.component.html',
styleUrls: ['./other.component.css'],
})
export class OtherComponent {
public constructor(api:APIService){
console.log(api);
}
}
//app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { APIService } from './services/api.service'
import { AppComponent } from './app.component';
import { OtherComponent } from './other.component';
@NgModule({
declarations: [
AppComponent,
OtherComponent
],
imports: [
BrowserModule,
FormsModule,
HttpModule,
ReactiveFormsModule,
NgbModule.forRoot()
],
providers: [APIService],
bootstrap: [AppComponent]
})
export class AppModule { }
在上述代码中,我们有:
-
APIService显示了@Injectable()注解,使其可以被注入。另外,APIService有一个increment:number属性,每次创建新实例时都会增加。increment:number是静态的,它将告诉我们程序中有多少个实例。最后,APIService有一个toString:string方法,返回当前实例编号。 -
AppComponent是一个经典组件,它接收了APIService的注入。 -
OtherComponent是另一个经典组件,它接收了APIService的注入。 -
/app.module.ts包含我们的NgModule。在NgModule中,这里显示的大部分声明已经在本书中讨论过。新颖之处来自于providers: [APIService]部分。在这里,我们声明了APIService本身的提供者。由于APIService并没有做什么太疯狂的事情,它可以通过引用类来提供。更复杂的服务,例如需要注入的服务,需要定制的提供者。
现在,如果我们导航到这两个组件,结果是:
Current instance: 1
Current instance: 1
这证明只创建了一个实例,并且相同的实例已被注入到两个组件中。因此,我们有一个单例。然而,这个单例虽然方便,但并不安全。你为什么这样问。嗯,APIService也可以在组件级别提供,就像这样:
// ./app.component.ts
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
providers: [APIService],
})
export class AppComponent {
public constructor AppComponent(APIService api){
console.log(api);
}
}
// ./other.component.ts
@Component({
selector: 'other-root',
templateUrl: './other.component.html',
styleUrls: ['./other.component.css'],
providers: [APIService],
})
export class OtherComponent {
public constructor OtherComponent(APIService api){
console.log(api);
}
}
在这种情况下,将创建两个单独的实例,导致以下输出:
Current instance: 1
Current instance: 2
因此,使用 Angular 服务,你无法强制实施单例模式,与其普通的 TypeScript 对应相反。另外,普通的 TypeScript 会比 Angular 服务快上一个数量级,因为我们完全跳过了注入过程。确切的数字严重依赖于你的机器的 CPU/RAM。
在单例的情况下,唯一剩下的问题是何时使用它。单例强制程序中给定类的唯一实例。因此,它非常适合与后端或任何硬件访问进行通信。例如,在与后端通信的情况下,可能希望只有一个APIService处理 API 密钥、API 限制和跨站请求伪造令牌,而无需确保我们在所有组件、模型等中传递相同的服务实例。在硬件访问的情况下,可能希望确保只有一个连接打开到用户的网络摄像头或麦克风,这样在使用完毕后可以正确释放它们。
原型和可重用池
面向对象的开发人员寻找减少创建对象成本的方法,特别是当这些对象因为需要进行数据库拉取或复杂的数学运算而昂贵时。减少特定对象创建成本的另一个原因是当你创建大量对象时。如今,后端开发人员倾向于忽视优化的这一方面,因为按需的 CPU/内存已经变得便宜且易于调整。在你的后端上每月多花几美元就可以获得额外的核心或 256MB 的 RAM。
这对于桌面应用程序开发人员来说曾经是一个大问题。在客户端桌面上,没有办法按需添加 CPU/RAM,但是相当规律的四核处理器和消费级 PC 上荒谬的大量 RAM 使这个问题变得不那么棘手。
如今,似乎只有游戏和密集型分析解决方案开发人员似乎关心这个问题。那么,毕竟为什么你应该关心对象的创建时间呢?嗯,你正在构建的东西很可能会被旧设备访问(我仍然在厨房或沙发上使用 iPad 1 进行休闲浏览)。虽然桌面应用程序开发人员可以发布最低和推荐配置,并通过拒绝安装来强制执行它们,但作为 Web 开发人员,我们没有这种奢侈。现在,如果你的网站表现不佳,用户不会质疑他们的设备,而是质疑你的技能。最终,即使在一台性能强大的机器上,他们也不会使用你的产品。
让我们看看如何使用Prototype设计模式。Prototype设计模式允许对象创建定制对象,而无需知道它们的类或任何创建它们的详细信息。其目的是通过复制这个原型来创建新对象,而不是实际实例化一个新对象。首先,我们需要一个Prototype接口,如下所示:
export interface Prototype{
clone(): Prototype;
}
Prototype接口只定义了一个clone方法,该方法返回一个符合Prototype的对象。您已经猜到了,创建对象的优化方式是在需要时进行克隆!所以假设您有一个Movie对象,由于某种原因,需要花费时间来构建:
export class Movie implements Prototype {
private title:string;
private year:number;
//...
public constructor();
public constructor(title?: string, year?: number);
public constructor(title?: string, year?: number) {
{
if(title == undefined || year == undefined){
//do the expensive creation
}else{
this.title = title;
this.year = year;
}
}
clone() : Movie {
return new Movie(this.title, this.year);
}
}
let expensiveMovie:Movie = new Movie();
cheapMovie = expensiveMovie.clone();
正如您所看到的,TypeScript 中的覆盖函数与大多数语言不同。在这里,构造函数的两个签名叠在一起,并共享相同的实现。
此外,这就是Prototype模式的全部内容。
通常与原型模式一起使用的另一个模式是对象池模式。在使用昂贵的创建对象时,克隆它们确实会有所不同。更大的不同之处在于根本不做任何事情:不创建,不克隆。为了实现这一点,我们可以使用池模式。在这种模式下,我们有一组对象池,可以被任何客户端或组件共享,例如在 Angular 应用程序的情况下。池的实现很简单:
export class MoviePool{
private static movies:[{movie:Movie, used:boolean}];
private static nbMaxMovie = 10;
private static instance:MoviePool;
private constructor(){}
public static getMovie(){
//first hard create
if(MoviePool.movies.length == 0){
MoviePool.movies.push({movie:new Movie(), used:true});
return MoviePool.movies[0].movie;
}else{
for(var reusableMovie of MoviePool.movies){
if(!reusableMovie.used){
reusableMovie.used = true;
return reusableMovie.movie;
}
}
}
//subsequent clone create
if(MoviePool.movie.length < MoviePool.nbMaxMovie){
MoviePool.movies.push({movie:MoviePool.movies[MoviePool.movies.
length - 1].movie.clone(), used:true});
return MoviePool.movies[MoviePool.movies.length - 1].movie;
}
throw new Error('Out of movies');
}
public static releaseMovie(movie:Movie){
for(var reusableMovie of MoviePool.movies){
if(reusableMovie.movie === movie){
reusableMovie.used = false;
}
return;
}
}
}
首先,池也是一个单例。事实上,如果任何人都可以随意创建池,那么将不会有太多意义。因此,我们有static instance:MoviePool和私有构造函数,以确保只能创建一个池。然后,我们有以下属性:
private static movies:[{movie:Movie, used:boolean}];
movies属性存储了一系列电影和一个布尔值,用于确定当前是否有人在使用任何给定的电影。由于电影对象在理论上很费力创建或在内存中维护,因此有必要对我们的池中可以拥有多少这样的对象进行硬性限制。这个限制由私有的static nbMaxMovie = 10属性管理。要获取电影,组件必须调用getMovie():Movie方法。这个方法在第一部电影上进行了硬性创建,然后利用原型模式来创建任何后续的电影。
每当从池中检出一部电影时,getMovie方法会将使用的布尔值更改为 true。请注意,在池已满且我们没有任何空闲电影可供赠送的情况下,将抛出错误。
最后,组件需要一种方法来将它们的电影归还到池中,以便其他组件可以使用它们。这是通过releaseMovie方法实现的。该方法接收一个已检出的电影,并遍历池中的电影,将相应的布尔值设置为 false。因此,电影可以供其他组件使用。
工厂模式
假设我们有一个User类,其中有两个私有变量:lastName:string和firstName:string。此外,这个简单的类提供了一个打印“Hi I am”,this.firstName,this.lastName 的方法 hello:
class User{
constructor(private lastName:string, private firstName:string){
}
hello(){
console.log("Hi I am", this.firstName, this.lastName);
}
}
现在,考虑到我们通过 JSON API 接收用户。很可能会是这样的:
[{"lastName":"Nayrolles","firstName":"Mathieu"}...].
通过以下代码片段,我们可以创建一个User:
let userFromJSONAPI: User = JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]')[0];
到目前为止,TypeScript 编译器没有抱怨,并且执行顺利。这是因为解析方法返回any(即 Java 对象的 TypeScript 等价物)。当然,我们可以将any转换为User。然而,userFromJSONAPI.hello()将产生:
json.ts:19
userFromJSONAPI.hello();
^
TypeError: userFromUJSONAPI.hello is not a function
at Object.<anonymous> (json.ts:19:18)
at Module._compile (module.js:541:32)
at Object.loader (/usr/lib/node_modules/ts-node/src/ts-node.ts:225:14)
at Module.load (module.js:458:32)
at tryModuleLoad (module.js:417:12)
at Function.Module._load (module.js:409:3)
at Function.Module.runMain (module.js:575:10)
at Object.<anonymous> (/usr/lib/node_modules/ts-node/
src/bin/ts-node.ts:110:12)
at Module._compile (module.js:541:32)
at Object.Module._extensions..js (module.js:550:10)
为什么?嗯,=语句的左侧被定义为User,但当我们将其转译为 JavaScript 时,它将被擦除。
使用类型安全的 TypeScript 方法来做这件事将是:
let validUser = JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]')
.map((json: any):User => {
return new User(json.lastName, json.firstName);
})[0];
有趣的是,typeof函数也无法帮助你。在这两种情况下,它都会显示Object而不是User,因为 JavaScript 中并不存在User的概念。
虽然直接的类型安全方法可以工作,但它并不是非常可扩展或可重用的。实际上,无论何时接收到一个 JSON 用户,都必须在每个地方重复使用 map callback方法。最方便的方法是使用Factory模式来做到这一点。Factory用于创建对象,而不会将实例化逻辑暴露给客户端。
如果我们要有一个工厂来创建一个用户;它会是这样的:
export class POTOFactory{
/**
* Builds an User from json response
* @param {any} jsonUser
* @return {User}
*/
static buildUser(jsonUser: any): User {
return new User(
jsonUser.firstName,
jsonUser.lastName
);
}
}
在这里,我们有一个名为buildUser的静态方法,它接收一个 JSON 对象,并从 JSON 对象中获取所有必需的值,以调用一个假设的User构造函数。该方法是静态的,就像工厂的所有方法一样。实际上,我们不需要在工厂中保存任何状态或实例绑定的变量;我们只需要封装用户的创建过程。请注意,您的工厂可能会与您的 POTO 的其余部分共享。
备忘录模式
在 Angular 的上下文中,备忘录模式是一个非常有用的模式。在由 Angular 驱动的应用程序中,我们过度使用双向数据绑定,例如User或Movie等领域模型。
让我们考虑两个组件:一个名为Dashboard,另一个名为EditMovie。在 Dashboard 组件上,您有一个电影列表显示在我们类似 IMDB 的应用程序的上下文中。这样的仪表板视图可能如下所示:
<div *ngFor="let movie of model.movies">
<p>{{movie.title}}</p>
<p>{{movie.year}}</p>
</div>
这个简单的视图拥有一个ngFor指令,它遍历模型中包含的电影列表。然后,对于每部电影,它显示两个包含标题和发行年份的 p 元素。
现在,EditMovie组件访问model.movies数组中的一部电影,并允许用户对其进行编辑:
<form>
<input id="title" name="title" type="text" [(ngModel)]="movie.title" />
<input id="year" name="year" type="text" [(ngModel)]="movie.year" />
</form>
<a href="/back">Cancel</a>
由于这里使用了双向数据绑定,对电影标题和年份的修改将直接影响仪表板。正如您所注意到的,我们这里有一个取消按钮。虽然用户可能期望修改是实时同步的,但他/她也期望取消按钮/链接取消对电影所做的修改。
这就是备忘录模式发挥作用的地方。这种模式允许您对对象执行撤消操作。它可以以许多种方式实现,但最简单的方式是使用克隆。使用克隆,我们可以在给定时刻存储对象的一个版本,并在需要时取回它。让我们根据Prototype模式来增强我们的Movie对象:
export class Movie implements Prototype {
private title:string;
private year:number;
//...
public constructor();
public constructor(title?: string, year?: number);
public constructor(title?: string, year?: number) {
if(title == undefined || year == undefined){
//do the expensive creation
}else{
this.title = title;
this.year = year;
}
}
clone() : Movie {
return new Movie(this.title, this.year);
}
restore(movie:Movie){
this.title = movie.title;
this.year = movie.year;
}
}
在这个新版本中,我们添加了restore(movie:Movie)方法,它接受一个Movie作为参数,并将本地属性设置为接收到的电影的值。
然后,在实践中,我们的EditMovie组件的构造函数可能如下所示:
import { Component } from '@angular/core';
import { Movie } from './movie';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'app works!';
private memento: Movie;
constructor(){
this.memento = new Movie("Title", 2015);
let movieTmp = this.memento.clone();
this.memento.setTitle("Another Title");
//Prints Another title
console.log(this.memento.getTitle());
this.memento.restore(movieTmp);
//Prints Title
console.log(this.memento.getTitle());
}
}
有趣的是,您不限于一次性保存状态;您可以拥有尽可能多的状态。
摘要
在本章中,我们学习了如何使用一些经典的面向对象模式,这些模式适用于可重用和易于维护/扩展的现实世界应用程序。MVC 被调整为 Angular,并扩展以在不同应用程序之间实现高度可重用的业务逻辑。然后,我们看到如何使用单例模式以及依赖注入和原型模式与池相结合来控制对象的创建,以限制系统中昂贵对象的数量。最后,我们学习了如何使用工厂模式来避免在 JSON 到 TypeScript 自动(和部分)对象转换中的陷阱,并看到如何使用备忘录模式执行撤消操作。
如果你想学习更多关于模式来提高你的性能、操作成本和可维护性,你可以查看即将推出的 Packt Publishing 出版的《Angular 设计模式与最佳实践》一书。这本书深入探讨了模式及其实施,以找到最适合你的应用程序。