Angular6-示例第三版-三-

49 阅读1小时+

Angular6 示例第三版(三)

原文:zh.annas-archive.org/md5/ea9e0be757a76027d750b88ab0b9bedf

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:支持服务器数据持久性

现在是时候与服务器通信了!创建锻炼、添加练习并将其保存,然后意识到所有努力都白费,因为数据没有在任何地方持久化,这毫无乐趣。我们需要解决这个问题。

应用程序很少是自包含的。任何消费者应用程序,无论其大小如何,都有与边界之外的元素交互的部分。对于基于 Web 的应用程序,交互主要是与服务器。应用程序通过服务器进行身份验证、授权、存储/检索数据、验证数据以及执行其他此类操作。

本章探讨了 Angular 提供的客户端-服务器交互的构造。在这个过程中,我们在个人教练中添加了一个持久层,用于从后端服务器加载数据和保存数据。

本章涵盖的主题包括以下内容:

  • 提供后端以持久化锻炼数据:我们设置了一个 MongoLab 账户,并使用其数据 API 访问和存储锻炼数据。

  • 理解 Angular HttpClientHttpClient允许我们通过 HTTP 与服务器交互。你将学习如何使用HttpClient进行所有类型的GETPOSTPUTDELETE请求。

  • 实现锻炼数据的加载和保存:我们使用HTTPClient从 MongoLab 数据库中加载数据和存储锻炼数据。

  • 我们可以使用 HttpClient 的 XMLHttpRequest 的两种方式:要么是可观察对象,要么是承诺。

  • 使用 RxJS 和可观察对象:用于订阅和查询数据流。

  • 使用承诺:在本章中,我们将了解如何将承诺作为 HTTP 调用和响应的一部分来使用。

  • 处理跨域访问:由于我们正在与不同域的 MongoLab 服务器交互,你将了解浏览器对跨域访问的限制。你还将了解 JSONP 和 CORS 如何帮助我们轻松实现跨域访问,以及 Angular 对 JSONP 的支持。

让我们开始吧。

Angular 和服务器交互

任何客户端-服务器交互通常都归结为向服务器发送 HTTP 请求并从服务器接收响应。对于重型 JavaScript 应用程序,我们依赖于 AJAX 请求/响应机制与服务器通信。为了支持基于 AJAX 的通信,Angular 提供了 Angular HttpClient模块。在我们深入研究HttpClient模块之前,我们需要设置我们的服务器平台,该平台存储数据并允许我们管理它。

设置持久存储

对于数据持久性,我们使用一个名为 MongoDB 的文档数据库(www.mongodb.com/),它托管在 MongoLab(www.mlab.com/)上,作为我们的数据存储。我们之所以选择 MongoLab,是因为它提供了一个直接与数据库交互的接口。这节省了我们设置支持 MongoDB 交互的服务器中间件的精力。

直接将数据存储/数据库暴露给客户端从来不是一个好主意。但在这种情况下,由于我们的主要目标是学习 Angular 和客户端-服务器交互,我们采取了这个自由,并直接访问 MongoLab 上托管的 MongoDB 实例。还有一种新的应用类型,它们是基于无后端解决方案构建的。在这种配置中,前端开发者构建应用时不需要了解确切的后端。服务器交互仅限于向后端发出 API 调用。如果您想了解更多关于这些无后端解决方案的信息,请查看nobackend.org/

我们的首要任务是配置 MongoLab 上的账户并创建一个数据库:

  1. 前往mlab.com,按照网站上的说明注册一个 mLab 账户

  2. 一旦账户配置完成,登录并点击主页上的“创建新数据库”按钮来创建一个新的 Mongo 数据库

  3. 在数据库创建屏幕上,您需要做一些选择以配置数据库。请参阅以下截图以选择免费数据库层和其他选项:

图片

  1. 创建数据库并记下您创建的数据库名称

  2. 数据库配置完成后,打开数据库,并从“集合”标签页添加两个集合:

    • exercises:这个存储所有个人训练师的锻炼内容

    • workouts:这个存储所有个人训练师的锻炼内容

在 MongoDB 世界中,集合等同于数据库表。

MongoDB 属于一种称为文档数据库的数据库类型。这里的核心概念是文档、属性及其链接。与传统数据库不同,模式不是刚性的。我们不会在本书中介绍文档数据库是什么以及如何为基于文档的存储执行数据建模。个人训练师有有限的存储需求,我们使用前面提到的两个文档集合来管理它。我们甚至可能没有真正使用文档数据库。

集合添加后,从“用户”标签页将您自己添加到数据库中。

下一步是确定 MongoLab 账户的 API 密钥。配置的 API 密钥必须附加到对 MongoLab 发出的每个请求中。要获取 API 密钥,请执行以下步骤:

  1. 点击右上角的用户名(而不是账户名称)以打开用户资料。

  2. 在标题为“API 密钥”的部分,显示了当前的 API 密钥;复制它。同时,点击 API 密钥下方的按钮以启用数据 API 访问。默认情况下,这是禁用的。

数据存储模式已完整。我们现在需要为这些集合进行初始化。

数据库初始化

个人训练师应用已经预定义了一个锻炼计划和 12 个锻炼的列表。我们需要用这些数据初始化集合。

打开 seed.js 文件,位于 trainer/db 文件夹中,这是从配套代码库中 5.1 检查点的文件。它包含种子 JSON 脚本以及如何将数据种子到 MongoLab 数据库实例的详细说明。

一旦初始化,数据库将在 workouts 集合中有一个锻炼项目,在 exercises 集合中有 12 个练习项目。请在 MongoLab 网站上验证这一点;集合应显示以下内容:

现在一切都已经设置好了,让我们开始讨论 HttpClient 模块,并为 Personal Trainer 应用程序实现锻炼/练习的持久化。

HTTPClient 模块的基础知识

HTTPClient 模块的核心是 HttpClient。它使用 XMLHttpRequest 作为默认的后端(JSONP 也可用,我们将在本章后面看到)。它支持 GETPOSTPUTDELETE 等请求。在本章中,我们将使用 HttpClient 来执行所有这些类型的请求。正如我们将看到的,HttpClient 使得以最小的设置和复杂性进行这些调用变得容易。对于之前使用过 Angular 或构建与后端数据存储通信的 JavaScript 应用程序的人来说,这些术语都不会感到惊讶。

然而,Angular 处理 HTTP 请求的方式发生了重大变化。现在调用请求会返回一个 HTTP 响应的 Observable。它是通过使用 RxJS 库来实现的,RxJS 是一个众所周知的异步 Observable 模式的开源实现。

您可以在 GitHub 上找到 RxJS 项目,网址为 github.com/Reactive-Extensions/RxJS。网站表明,该项目正在由微软与一群开源开发者合作积极开发。我们在此不会详细介绍异步 Observable 模式,并鼓励您访问该网站以了解更多关于该模式以及 RxJS 如何实现它的信息。Angular 使用的 RxJS 版本是 beta 5。

简而言之,使用 Observables 允许开发者将应用程序中流动的数据视为信息流,应用程序可以随时从中抽取和使用。这些流随时间变化,这使得应用程序能够对这些变化做出反应。Observables 的这种特性为 函数式响应式编程FRP)提供了基础,这从根本上改变了构建 Web 应用程序的模式,从命令式转变为响应式。

RxJS 库提供了允许您订阅和查询这些数据流的操作符。此外,您可以轻松混合和组合它们,正如我们将在本章中看到的。Observables 的另一个优点是取消或取消订阅它们很容易,这使得可以无缝地在线处理错误。

虽然在 Angular 中仍然可以使用 Promise,但默认的方法是使用 Observables。我们也将在本章中介绍 Promise。

个人训练师和服务器集成

如前一章所述,客户端-服务器交互完全是关于异步的。当我们修改我们的个人训练师应用程序以从服务器加载数据时,这种模式变得显而易见。

在前一章中,初始的锻炼和练习集合被硬编码在WorkoutService实现中。让我们看看如何首先从服务器加载数据。

加载锻炼和训练数据

在本章早期,我们使用数据表单,即seed.js文件,对数据库进行了初始化。我们现在需要在我们的视图中呈现这些数据。MongoLab 数据 API 将帮助我们在这里。

MongoLab 数据 API 使用 API 密钥来验证访问请求。对 MongoLab 端点发出的每个请求都需要有一个查询字符串参数,apikey=<key>,其中key是我们在本章早期提供的 API 密钥。请记住,密钥始终提供给用户并与他们的账户相关联。避免与他人共享您的 API 密钥。

该 API 遵循可预测的模式来查询和更新数据。对于任何 MongoDB 集合,典型的端点访问模式如下(以下给出的是基本 URL:api.mongolab.com/api/1/databases):

  • /<dbname>/collections/<name>?apiKey=<key>:以下请求如下:

    • GET:此操作获取给定集合名称中的所有对象。

    • POST:此操作向集合名称添加一个新的对象。MongoLab 有一个_id属性,该属性唯一标识文档(对象)。如果未在提交的数据中提供,则自动生成。

  • /<dbname>/collections/<name>/<id>?apiKey=<key>:以下请求如下:

    • GET:此操作从集合中获取具有特定 ID(在_id属性上执行匹配)的特定文档/集合项。

    • PUT:此操作更新集合名称中的特定项(id)。

    • DELETE:此操作从集合名称中删除具有特定 ID 的项目。

关于数据 API 接口的更多详细信息,请访问 MongoLab 数据 API 文档,网址为docs.mlab.com/data-api

现在我们已经准备好开始实现锻炼/训练列表页面。

我们在本章开始时使用的代码是 GitHub 上本书的checkpoint 4.6(文件夹:trainer)。它在 GitHub 上可用(github.com/chandermani/angular6byexample)。检查点作为 GitHub 上的分支实现。如果您不使用 Git,请从以下 GitHub 位置下载checkpoint 4.6的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint4.6。在第一次设置快照时,请参考trainer文件夹中的README.md文件。

从服务器加载锻炼和训练列表

要从 MongoLab 数据库中拉取锻炼和训练列表,我们必须重写我们的WorkoutService服务方法:getExercisesgetWorkouts。但在我们能够这样做之前,我们必须设置我们的服务以使用 Angular 的 HTTPClient 模块。

将 HTTPClient 模块和 RxJS 添加到我们的项目中

Angular HTTPClient 模块包含在你已经安装的 Angular 包中。为了使用它,我们需要将其导入到app.module.ts中,如下所示(确保导入遵循BrowserModule):

import { HttpClientModule } from '@angular/common/http';
. . . 
@NgModule({ 
  imports: [ 
    BrowserModule,
    HttpClientModule, 
. . . 
})

我们还需要一个外部第三方库:JavaScript 的响应式扩展(RxJS)。RxJS 实现了可观察模式,并且与 HTTPClient 模块一起由 Angular 使用。它包含在我们项目中的 Angular 包中。

更新 workout-service 以使用 HTTPClient 模块和 RxJS

trainer/src/app/core打开workout.service.ts。为了在WorkoutService中使用 HTTPClient 和 RxJS,我们需要将该文件中添加以下导入:

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { catchError } from 'rxjs/operators';

我们正在导入HTTPClient模块以及来自 RxJS 的Observable和一个额外的 RxJS 操作符:catchError。我们将看到这个操作符是如何在本节中使用的。

在类定义中,添加以下属性,包括一个锻炼属性以及设置我们 Mongo 数据库中集合的 URL 和数据库的键的属性,以及另一个属性:params,它设置 API 密钥作为 API 访问的查询字符串:

workout: WorkoutPlan; 
collectionsUrl = "https://api.mongolab.com/api/1/ databases/<dbname>/collections"; 
apiKey = <key> 
params = '?apiKey=' + this._apiKey; 

<dbname><key>令牌替换为我们在本章前面配置的数据库的名称和 API 密钥。

接下来,使用以下代码行将 HTTPClient 模块注入到WorkoutService构造函数中:

constructor(public http: HttpClient) {
}

然后将getExercises()方法更改为以下内容:

getExercises() {
    return this.http.get<ExercisePlan>(this.collectionsUrl + '/exercises' + this.params)
        .pipe(catchError(WorkoutService.handleError));
}

如果你习惯于使用承诺(promises)进行异步数据操作,这里看到的内容将看起来不同。这里发生的情况不是将一个调用then()的承诺链在一起,而是http.get方法返回一个来自 RxJS 库的可观察对象。注意,我们还在将响应设置为<ExercisePlan>类型,以便明确告诉我们的上游调用者从我们的 HTTP GET 调用返回的是哪种类型的可观察对象。

使用HTTPClient模块的get方法时,返回一个可观察对象(Observable)是默认响应。然而,可观察对象可以被转换为一个承诺(promise)。而且,正如我们将在本章后面看到的那样,返回 JSONP 的选项也存在。

在我们继续之前,还有一件事情需要提及。注意,我们正在使用一个管道方法来添加一个catchError操作符。这个操作符接受一个方法handleError来处理失败的响应。handleError方法接受失败的响应作为参数。我们将错误记录到控制台,并使用Observable.throw将错误返回给消费者:

static handleError (error: Response) { 
    console.error(error); 
    return Observable.throw(error || 'Server error');
}

明确一点,这并不是生产代码,但它将给我们机会展示如何在上游编写代码来处理数据访问过程中生成的错误。

需要理解的是,在这个阶段,如果没有对 Observable 进行订阅,那么 Observable 中不会有数据流动。如果你没有仔细添加订阅到你的 Observables,这可能会导致添加和更新等操作出现意外情况。

getWorkouts() 方法修改为使用 HTTPClient 模块

获取锻炼数据的代码更改几乎与获取练习数据的代码相同:

getWorkouts() {
    return this.http.get<WorkoutPlan[]>(this.collectionsUrl + '/workouts' + this.params)
        .pipe(catchError(WorkoutService.handleError));
}

再次明确,我们指定了 Observable 的类型——在这个例子中是 <WorkoutPlan[]>——它将由我们的 HTTP GET 调用返回,并使用 pipe 添加一个 catchError 操作符。

现在,getExercisesgetWorkouts 方法已经更新,我们需要确保它们与上游调用者兼容。

更新锻炼/练习列表页面

练习和锻炼列表页面(以及 LeftNavExercises)在 model.ts 中调用 getExercisesgetWorkouts 方法。为了使这些调用与现在使用 HTTPClient 模块进行的远程调用兼容,我们需要修改这些调用以订阅由 HTTPClient 模块返回的 Observable。因此,将 exercises.component.ts 中的 ngOnInit 方法代码更新如下:

  ngOnInit() {
    this.workoutService.getExercises()
    .subscribe(
        exercises => this.exerciseList = exercises,
        (err: any) => console.error
    );

我们的方法现在订阅了由 getExercises 方法返回的 Observable;当响应到达时,它将结果分配给 exerciseList。如果有错误,它将错误分配给一个 console.error 调用,在控制台中显示错误。所有这些现在都是使用 HTTPClient 模块和 RxJS 异步处理的。

接下来,对 workouts.component.tsleft-nav-exercises.component.ts 中的 ngOnInit 方法进行类似的修改。

刷新锻炼/练习列表页面,锻炼和练习数据将从数据库服务器加载。

如果你在 GitHub 仓库中难以检索/显示数据,请查看第 5.1 个检查点的完整实现。注意,在这个检查点中,我们已禁用导航链接到锻炼和练习屏幕,因为我们还需要向它们添加 Observable 实现。我们将在下一节中完成这项工作。此外,记得在运行 Checkpoint 5.1 中的代码之前替换数据库名称和 API 密钥。如果你不使用 Git,可以从以下 GitHub 位置下载 Checkpoint 5.1 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.1。在设置快照时,请参考 trainer 文件夹中的 README.md 文件。

这看起来不错,列表加载也正常。嗯,几乎是这样!在锻炼列表页面上有一个小故障。如果我们仔细查看任何列表项(实际上只有一个项),就可以轻松地发现它:

锻炼时长计算不再起作用了!可能的原因是什么?我们需要回顾一下这些计算是如何实现的。WorkoutPlan服务(在model.ts中)定义了一个totalWorkoutDuration方法来完成这个计算。

差异在于绑定到视图的锻炼数组。在前一章中,我们使用WorkoutPlan服务创建了一个包含模型对象的数组。但现在,由于我们从服务器检索数据,我们绑定了一个简单的 JavaScript 对象数组到视图,这显然没有计算逻辑。

我们可以通过将服务器响应映射到我们的模型类对象并将它们返回给任何上游调用者来解决这个问题。

将服务器数据映射到应用程序模型

如果模型和服务器存储定义匹配,则将服务器数据映射到我们的模型以及反之亦然可能是不必要的。如果我们查看Exercise模型类和我们在 MongoLab 中为锻炼添加的种子数据,我们会看到它们是匹配的,因此映射变得不必要。

将服务器响应映射到模型数据变得至关重要,如果:

  • 我们模型定义了任何方法

  • 存储的模型与其代码表示不同

  • 使用相同的模型类来表示来自不同来源的数据(这可能在混合应用中发生,我们从不同的来源获取数据)

WorkoutPlan服务是一个模型表示与其存储之间阻抗不匹配的典型例子。查看以下截图以了解这些差异:

模型和服务器数据之间的两个主要差异如下:

  • 模型定义了totalWorkoutDuration方法。

  • exercises数组表示也各不相同。模型的exercises数组包含完整的Exercise对象,而服务器数据只存储锻炼标识符或名称。

这显然意味着加载和保存锻炼需要模型映射。

我们将通过添加另一个操作符来转换 Observable 响应对象来实现这一点。到目前为止,我们只返回了一个普通的 JavaScript 对象作为响应。好事是,我们用来添加错误处理的 pipe 方法还允许我们添加额外的操作符,我们可以使用这些操作符将 JavaScript 对象转换为我们模型中的WorkoutPlan类型。

让我们在workout-service.ts文件中重写getWorkouts方法如下:

    getWorkouts(): Observable<WorkoutPlan[]> {
        return this.http.get<WorkoutPlan[]>(this.collectionsUrl + '/workouts' + this.params)
            .pipe(
                map((workouts: Array<any>) => {
                  const result: Array<WorkoutPlan> = [];
                  if (workouts) {
                      workouts.forEach((workout) => {
                          result.push(
                              new WorkoutPlan(
                                  workout.name,
                                  workout.title,
                                  workout.restBetweenExercise,
                                  workout.exercises,
                                  workout.description
                              ));
                      });
                  }
                  return result;
                }),
                catchError(this.handleError<WorkoutPlan[]>('getWorkouts', []))
            );
    }

我们添加了一个map操作符,将这个 Observable 转换成一个由WorkoutPlan对象组成的 Observable。然后,每个WorkoutPlan对象(目前我们只有一个)都将拥有我们需要的totalWorkoutDuration方法。

查看代码,你可以看到我们操作的是 HTTPClient 响应的 JSON 结果,这就是为什么我们使用<any>类型。然后我们创建一个WorkoutPlans的类型化数组,并使用箭头函数forEach遍历第一个数组,将每个 JavaScript 对象分配给一个WorkoutPlan对象。

我们将这些映射的结果返回给订阅它们的调用者,在这种情况下是workouts.component.ts。我们还更新了catchError操作符,使用了一个新的handleError方法,你可以在Checkpoint 5.2中找到它。调用者不需要对其用于订阅我们的锻炼 Observable 的代码进行任何更改。相反,模型映射可以在应用程序的一个位置进行,然后在整个应用程序中使用。

如果你重新运行应用程序,你会看到现在总秒数显示正确:

图片

GitHub 仓库中的 Checkpoint 5.2 包含了我们到目前为止所涵盖的完整实现。GitHub 分支是checkpoint5.2(文件夹:trainer)。

从服务器加载锻炼和锻炼数据

正如我们在之前的WorkoutService中修复了getWorkouts实现一样,我们可以为与锻炼和锻炼相关的其他 get 操作实现。从checkpoint 5.2中的trainer/src/app/core文件夹中的workout.service.ts复制getExercisegetWorkout方法的实现。

getWorkoutgetExercise方法使用锻炼/锻炼的名称来检索结果。每个 MongoLab 集合项都有一个_id属性,该属性唯一标识了项/实体。在我们的ExerciseWorkoutPlan对象的情况下,我们使用锻炼的名称进行唯一标识。因此,每个对象的name_id属性总是匹配的。

在这一点上,我们需要在workout.service.ts中添加一个额外的导入:

import { forkJoin } from 'rxjs/observable/forkJoin';

这个导入引入了forkJoin操作符,我们将在稍后讨论。

请特别注意getWorkout方法的实现,因为由于模型和数据存储格式不匹配,这里会发生相当数量的数据转换。这就是getWorkout方法现在的样子:

    getWorkout(workoutName: string): Observable<WorkoutPlan> {
      return forkJoin (
          this.http.get(this.collectionsUrl + '/exercises' + this.params),
          this.http.get(this.collectionsUrl + '/workouts/' + workoutName + this.params))
          .pipe(
               map(
                  (data: any) => {
                      const allExercises = data[0];
                      const workout = new WorkoutPlan(
                          data[1].name,
                          data[1].title,
                          data[1].restBetweenExercise,
                          data[1].exercises,
                          data[1].description
                      );
                      workout.exercises.forEach(
                          (exercisePlan: any) => exercisePlan.exercise = allExercises.find(
                              (x: any) => x.name === exercisePlan.name
                          )
                      );
                      return workout;
                  }
              ),
              catchError(this.handleError<WorkoutPlan>(`getWorkout id=${workoutName}`))
        );
      }

getWorkout方法内部发生了很多事情,我们需要理解。

getWorkout方法使用 Observable 及其forkJoin操作符来返回两个 Observable 对象:一个用于检索Workout,另一个用于检索所有Exercises的列表。forkJoin操作符有趣的地方在于,它不仅允许我们返回多个 Observable 流,而且在进一步处理结果之前,它还会等待两个 Observable 流都检索到它们的数据。换句话说,它使我们能够从多个并发 HTTP 请求中流式传输响应,然后对组合结果进行操作。

一旦我们有了 Workout 详细信息和完整的锻炼列表,我们就将结果通过 pipe 传输到 map 操作符(我们之前在 Workouts 列表代码中看到过),我们使用它来将锻炼的 exercises 数组转换为正确的 Exercise 类对象。我们通过在 allExercises Observable 中搜索从服务器返回的 workout.exercises 数组中的锻炼名称来实现这一点,然后将匹配的锻炼分配给锻炼服务数组。最终结果是,我们有一个完整的 WorkoutPlan 对象,其 exercises 数组已正确设置。

这些对 WorkoutService 的更改也要求上游调用者进行修复。我们已经修复了 LeftNavExercisesExercises 组件中的锻炼列表以及 Workouts 组件中的锻炼。现在让我们按照类似的方式修复 WorkoutExercise 组件。锻炼服务中的 getWorkoutgetExercise 方法不是直接由这些组件调用,而是由构建服务调用。因此,我们将不得不与 WorkoutExercise 组件以及两个解析器——WorkoutResolverExerciseResolver——一起修复,我们将这些解析器添加到这些组件的路由中。

修复构建服务

现在我们已经设置了 WorkoutService 来从我们的远程数据存储中检索锻炼,我们必须修改 WorkoutBuilderService 以能够将那个锻炼作为一个 Observable 检索。提取 Workout 详细信息的方法是 startBuilding。为了做到这一点,我们将当前的 startBuilding 方法拆分为两个方法,一个用于新的锻炼,另一个用于我们从服务器检索到的现有锻炼。以下是新锻炼的代码:

    startBuildingNew() {
      const exerciseArray: ExercisePlan[] = [];
      this.buildingWorkout = new WorkoutPlan('', '', 30, exerciseArray);
      this.newWorkout = true;
      return this.buildingWorkout;
    }

对于现有锻炼,我们添加以下代码:

    startBuildingExisting(name: string) {
      this.newWorkout = false;
      return this.workoutService.getWorkout(name);
    }

我们将让您在 ExerciseBuilderService 中进行相同的修复。

更新解析器

当我们开始使用 Observable 类型与我们的数据访问一起使用时,我们将不得不对我们为通往锻炼和锻炼屏幕的路由创建的解析器进行一些调整。我们从 workout-resolver.ts 中的 WorkoutResolver 开始,该文件位于 workout 文件夹中。

首先添加以下来自 RxJs 的导入:

import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import { map, catchError } from 'rxjs/operators';

接下来更新 resolve 方法如下:

  resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<WorkoutPlan> {
    const workoutName = route.paramMap.get('id');

    if (!workoutName) {
        return this.workoutBuilderService.startBuildingNew();
    } else {
        return this.workoutBuilderService.startBuildingExisting(workoutName)
        .pipe(
          map(workout => {
            if (workout) {
              this.workoutBuilderService.buildingWorkout = workout;
              return workout;
            } else {
              this.router.navigate(['/builder/workouts']);
              return null;
            }
          }),
          catchError(error => {
            console.log('An error occurred!');
            this.router.navigate(['/builder/workouts']);
            return of(null);
          })
        );
    }

如您所见,我们已经将新锻炼的行为(在 URL 参数中没有传递锻炼名称的情况)和现有锻炼的行为分开。在前一种情况下,我们调用 workoutBuilderService.startBuildingExisting,这将返回一个新的 WorkoutPlan。在后一种情况下,我们调用 workoutBuilderService.startBuildingExisting 并将结果通过管道传输,然后映射以返回 workout,除非找不到,在这种情况下,我们将用户路由回 Workouts 屏幕。

修复 WorkoutExercise 组件

一旦我们修复了WorkoutBuilderServiceWorkoutResolver,实际上在WorkoutComponent中就不再需要进一步的修复。处理 Observables 的所有工作已经在更下游完成,我们现在需要做的只是订阅路由数据并检索锻炼项目,就像我们之前所做的那样:

  ngOnInit() {
      this.sub = this.route.data
          .subscribe(
            (data: { workout: WorkoutPlan }) => {
              this.workout = data.workout;
            }
          );
  }

为了测试实现,取消注释以下在workouts.component.ts中的onSelect方法内高亮的代码:

  onSelect(workout: WorkoutPlan) {
      this.router.navigate( ['./builder/workout', workout.name] );
  }

然后点击列表中显示在/builder/workouts/的任何现有锻炼项目,例如7 分钟锻炼。锻炼数据应该能够成功加载。

ExerciseBuilderServiceExerciseResolver也需要修复。Checkpoint 5.2包含了这些修复。你可以复制这些文件或自己进行修复,并比较实现。别忘了取消注释exercises.component.ts中的onSelect方法中的代码。

GitHub 仓库中的Checkpoint 5.2包含了到目前为止我们所涵盖的完整实现。如果你不使用 Git,可以从以下 GitHub 位置下载 Checkpoint 5.2 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.2。在首次设置快照时,请参考trainer文件夹中的README.md文件。

现在是时候修复、创建和更新锻炼项目的场景了。

对锻炼/项目执行 CRUD 操作

当涉及到创建、读取、更新和删除(CRUD)操作时,所有保存、更新和删除方法都需要转换为 Observable 模式。

在本章的早期部分,我们详细介绍了在 MongoLab 集合中 CRUD 操作的端点访问模式。回到加载锻炼和锻炼数据部分,重新审视访问模式。我们现在需要这些信息,因为我们计划创建/更新锻炼项目。

在开始实现之前,了解 MongoLab 如何识别集合项以及我们的 ID 生成策略非常重要。在 MongoDB 中,每个集合项都通过_id属性在集合中唯一标识。在创建新项时,我们提供 ID 或服务器自动生成一个。一旦_id被设置,就不能更改。对于我们的模型,我们将使用锻炼/项目的name属性作为唯一 ID,并将名称复制到_id字段(因此,没有自动生成_id)。还要记住,我们的模型类不包含这个_id字段;它必须在第一次保存记录之前创建。

让我们先修复锻炼创建场景。

创建新的锻炼项目

采用自下而上的方法,首先需要修复的是WorkoutService。按照以下代码更新addWorkout方法:

    addWorkout(workout: WorkoutPlan) {
      const workoutExercises: any = [];
      workout.exercises.forEach(
          (exercisePlan: any) => {
              workoutExercises.push({name: exercisePlan.exercise.name, duration: exercisePlan.duration});
          }
      );

      const body = {
          '_id': workout.name,
          'exercises': workoutExercises,
          'name': workout.name,
          'title': workout.title,
          'description': workout.description,
          'restBetweenExercise': workout.restBetweenExercise
      };

      return this.http.post(this.collectionsUrl + '/workouts' + this.params, body)
        .pipe(
          catchError(this.handleError<WorkoutPlan>())
        );
    }

getWorkout 中,我们必须将服务器模型中的数据映射到我们的客户端模型中;这里需要做的是反向操作。首先,我们为锻炼创建一个新的数组 workoutExercises,然后向该数组添加一个更紧凑的版本,以便在服务器上存储。我们只想在服务器上的锻炼数组中存储锻炼名称和持续时间(此数组为 any 类型,因为在其紧凑格式中它不符合 ExercisePlan 类型)。

接下来,我们将这些更改映射到一个 JSON 对象中,以此设置我们帖子的主体。请注意,在构建此对象的过程中,我们将 _id 属性设置为锻炼的名称,以便在锻炼集合的数据库中唯一标识它。

使用锻炼/练习的 名称 作为记录标识符(或 id)在 MongoDB 中的简单方法对于任何中等规模的应用程序都会失效。请记住,我们正在创建一个可以由许多用户同时访问的基于 Web 的应用程序。由于总有可能有两个用户为锻炼/练习想出相同的名称,我们需要一个强大的机制来确保名称不会重复。MongoLab REST API 的另一个问题是,如果有重复的 POST 请求具有相同的 id 字段,第一个将创建一个新的文档,第二个将更新它,而不是第二个失败。这意味着在客户端对 id 字段的任何重复检查都无法防止数据丢失。在这种情况下,分配自动生成 id 值是更好的选择。在标准情况下,当我们创建实体时,唯一 ID 的生成是在服务器上完成的(通常由数据库完成)。当实体创建时,响应将包含生成的 ID。在这种情况下,在将数据返回给调用代码之前,我们需要更新模型对象。

最后,我们调用 HTTPClient 模块的 post 方法,传递要连接的 URL、额外的查询字符串参数(apiKey)以及我们正在发送的数据。

最后一个返回语句应该看起来很熟悉,因为我们使用 Observables 将锻炼对象作为 Observable 解析的一部分返回。您需要确保在 Observable 链中添加 .subscribe 以使其工作。我们将通过向 WorkoutComponentsave 方法添加订阅来实现这一点。我们将在稍后这样做。

更新锻炼

为什么不尝试实现更新操作呢?updateWorkout 方法可以以相同的方式修复,唯一的区别是需要 HTTPClient 模块的 put 方法:

    updateWorkout(workout: WorkoutPlan) {
      const workoutExercises: any = [];
      workout.exercises.forEach(
          (exercisePlan: any) => {
              workoutExercises.push({name: exercisePlan.exercise.name, duration: exercisePlan.duration});
          }
      );

      const body = {
          '_id': workout.name,
          'exercises': workoutExercises,
          'name': workout.name,
          'title': workout.title,
          'description': workout.description,
          'restBetweenExercise': workout.restBetweenExercise
      };

      return this.http.put(this.collectionsUrl + '/workouts/' + workout.name + this.params, body)
        .pipe(
          catchError(this.handleError<WorkoutPlan>())
        );
    }

前面的请求 URL 现在包含一个额外的片段(workout.name),表示需要更新的集合项的标识符。

MongoLab 的PUT API 请求在集合中找不到文档时,会创建作为请求体传递的文档。在执行PUT请求时,请确保原始记录存在。我们可以通过首先对该文档执行GET请求并确认在更新之前我们得到了一个文档来实现这一点。我们将把这个留给你来实现。

删除锻炼

需要修复的最后一个操作是删除锻炼。这里有一个简单的实现,我们调用HTTPClient模块的delete方法来删除由特定 URL 引用的锻炼:

    deleteWorkout(workoutName: string) {
        return this.http.delete(this.collectionsUrl + '/workouts/' + workoutName + this.params)
          .pipe(
            catchError(this.handleError<WorkoutPlan>())
          );
    }

修复上游代码

现在是时候修复WorkoutBuilderServiceWorkout组件了。WorkoutBuilderServicesave方法现在看起来如下:

    save() {
      const workout = this.newWorkout ?
          this.workoutService.addWorkout(this.buildingWorkout) :
          this.workoutService.updateWorkout(this.buildingWorkout);
      this.newWorkout = false;
      return workout;
   }

大部分看起来和之前一样,因为它们确实是相同的!我们不需要更新这段代码,因为我们有效地在WorkoutService组件中隔离了与外部服务器的交互。

最后,这里展示了Workout组件的保存代码:

  save(formWorkout: any) {
    this.submitted = true;
    if (!formWorkout.valid) { return; }
    this.workoutBuilderService.save().subscribe(
      success => this.router.navigate(['/builder/workouts']),
      err => console.error(err)
    );
  }

我们已经做出了一些更改,现在我们订阅了保存。如您从我们之前的讨论中回忆起来,subscribe使 Observable 变得活跃,这样我们就可以完成保存。

就这样!我们现在可以创建新的锻炼并更新现有的锻炼(我们将删除锻炼的完成留给你)。这并不太难!

让我们试试看。打开新的Workout Builder页面,创建一个锻炼并保存它。也尝试编辑一个现有的锻炼。这两种情况都应该无缝工作。

如果你在运行本地副本时遇到问题,请查看checkpoint 5.3的最新实现。如果你不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.3 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.3。在首次设置快照时,请参考trainer文件夹中的README.md文件。

当我们进行POSTPUT请求保存数据时,网络端会发生一些有趣的事情。打开浏览器的网络日志控制台(F12)查看正在进行的请求。日志看起来可能如下所示:

网络日志

在实际执行POSTPUT之前,会向同一端点发出一个OPTIONS请求。我们在这里观察到的行为被称为预战斗请求。这是因为我们正在向api.mongolab.com发起跨域请求。

使用承诺进行 HTTP 请求

本章的大部分内容都集中在 Angular HTTPClient 如何使用观察者(Observables)作为 XMLHttpRequests 的默认值。这代表了一个重大的变化,与过去的工作方式相比。许多开发者熟悉使用承诺进行异步 HTTP 请求。在这种情况下,Angular 继续支持承诺,但不是作为默认选择。开发者必须选择在 XMLHttpRequest 中使用承诺才能使用它们。

例如,如果我们想在 WorkoutService 中的 getExercises 方法中使用承诺,我们必须将命令重构如下:

    getExercises(): Promise<Exercise[]> {
        return this.http.get<Exercise[]>(this.collectionsUrl + '/exercises' + this.params)
        .toPromise()
        .then(res => res)
        .catch(err => {
            return Promise.reject(this.handleError('getExercises', []));
        });
    }

为了将此方法转换为使用承诺(promises),我们只需在方法链中添加 .toPromise(),为承诺添加一个成功参数 then,以及一个使用指向现有 handleError 方法的 Promise.rejectcatch

对于上游组件,我们只需将处理返回值从观察者(Observable)更改为承诺。因此,在这种情况下使用承诺,我们需要在 Exercises.component.tsLeftNavExercises.component.ts 中的代码中首先添加一个新的错误消息属性(我们将如何显示屏幕上的错误消息留给你):

errorMessage: any;

然后将调用 WorkoutServicengOnInit 方法更改为以下内容:

  ngOnInit() {
    this.workoutService.getExercises()
 .then(exerciseList => this.exerciseList = exerciseList,
 error => this.errorMessage = <any>error
    );
  }  

当然,我们在这个简单示例中用承诺替换观察者(Observables)的便捷性并不意味着它们本质上相同。一个 then 承诺返回另一个承诺,这意味着你可以创建连续链式的承诺。而在观察者(Observable)的情况下,订阅基本上是终点,并且在该点之后不能映射或订阅。

如果你熟悉承诺(promises),在这个阶段可能会倾向于继续使用它们而不尝试观察者(Observables)。毕竟,我们在本章中使用观察者(Observables)所做的许多事情也可以用承诺来完成。例如,我们使用 ObservableforkJoin 操作符通过 getWorkouts 实现的两个观察者流映射也可以使用承诺的 q,all 函数来完成。

然而,如果你采取那种方法,你就是在贬低自己。观察者(Observables)为使用所谓的函数式响应式编程(functional reactive programming)进行网页开发开辟了令人兴奋的新途径。它们涉及一种基本思维方式的转变,将应用程序的数据视为一个持续的信息流,应用程序对其做出反应和响应。这种转变允许以不同的架构构建应用程序,使它们更快、更健壮。观察者(Observables)是 Angular 的核心,例如事件发射器和 NgModel 的新版本。

虽然承诺是工具箱中的一个有用工具,但我们鼓励你在使用 Angular 进行开发时调查观察者(Observables)。它们是 Angular 向前看哲学的一部分,并将有助于确保你的应用程序和技能集在未来具有前瞻性。

查看 checkpoint 5.3 文件以获取包含我们之前涵盖的与承诺相关的代码的最新实现。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.3 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.3。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。请注意,在下一节中,我们将恢复使用 Observables 来处理此代码。此代码可在 checkpoint 5.4 文件中找到。

async pipe

正如我们在本章中涵盖的许多数据操作中看到的那样,有一个相当常见的模式被反复重复。当一个 Observable 从 HTTP 请求返回时,我们将响应转换为 JSON 并订阅它。然后订阅将 Observable 输出绑定到 UI 元素。如果我们能够消除这种重复的编码并替换为一种更简单的方式来完成我们想要做的事情,那岂不是很好?

毫不奇怪,Angular 为我们提供了正确的方式来做到这一点。它被称为 async pipe,它可以像任何其他管道一样用于绑定到屏幕上的元素。然而,async pipe 是比其他管道更强大的机制。它接受一个 Observable 或承诺作为输入并自动订阅它。它还处理 Observable 订阅的拆卸,而无需任何额外的代码行。

让我们看看我们应用中的一个例子。让我们回到上一节中与承诺相关的 LeftNavExercises 组件。请注意,我们已经将此组件和 Exercises 组件从承诺转换回使用 Observables。

查看 checkpoint 5.4 文件以获取最新的实现,该实现包括将此代码转换为使用 Observables。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 5.4 的快照(ZIP 文件):github.com/chandermani/angular6byexample/tree/checkpoint5.4。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

然后在 LeftNavExercises 中进行以下更改。首先,从 RxJs 导入 Observable:

import { Observable } from 'rxjs/Observable';

然后将 exerciseList 从练习数组更改为相同类型的 Observable:

public exerciseList:Observable<Exercise[]>;

接下来修改调用 WorkoutService 获取练习的代码,以消除订阅:

this.exerciseList = this.workoutService.getExercises();

最后,打开 left-nav-exercises.component.html 并将 async 管道添加到 *ngFor 循环中,如下所示:

<div *ngFor="let exercise of exerciseList|async|orderBy:'title'">

刷新页面后,你仍然会看到显示的练习列表。但这次,我们使用了 async 管道来消除设置对 Observable 订阅的需要。非常酷!这是 Angular 添加的一个很好的便利功能,因为我们已经在这个章节中花费时间理解 Observables 与订阅的工作方式,所以我们现在对 async 管道在幕后为我们处理什么有了清晰的认识。

我们将把这个相同的更改在 Exercises 组件中的实现留给你。

理解 HTTP 请求的跨域行为以及 Angular 提供用于进行跨域请求的结构非常重要。

跨域访问和 Angular

跨域请求是对不同域中资源的请求。当从 JavaScript 发起时,这些请求会受到浏览器的一些限制;这些限制被称为 同源策略 限制。这种限制阻止浏览器向与脚本原始源不同的域发送 AJAX 请求。源匹配是严格基于协议、主机和端口的组合进行的。

对于我们自己的应用,对 https://api.mongolab.com 的调用是跨域调用,因为我们的源代码托管在不同的域中(最可能是类似 http://localhost/.... 的东西)。

有一些解决方案和一些标准有助于放宽/控制跨域访问。我们将探讨这两种技术,因为它们是最常用的。它们如下:

  • 填充 JSONJSONP

  • 跨源资源共享CORS

一种绕过相同源策略的常见方法是使用 JSONP 技术。

使用 JSONP 进行跨域请求

远程调用的 JSONP 机制依赖于浏览器可以从任何域执行 JavaScript 文件的事实,无论其来源如何,只要脚本是通过 <script> 标签包含的。

在 JSONP 中,不是直接向服务器发送请求,而是生成一个动态的 <script> 标签,其 src 属性设置为需要调用的服务器端点。当这个 <script> 标签附加到浏览器的 DOM 上时,会导致向目标服务器发送请求。

服务器随后需要以特定格式发送响应,将响应内容包裹在函数调用代码中(这额外的填充围绕响应数据使得这项技术被称为 JSONP)。

Angular JSONP 服务隐藏了这种复杂性,并提供了一个简单的 API 来进行 JSONP 请求。StackBlitz 链接 stackblitz.com/edit/angular-nxeuxo 突出了如何进行 JSONP 请求。它使用 IEX Free Stock API([iextrading.com/developer/](iextrading.com/developer/)…

Angular JSONP 服务仅支持 HTTP GET 请求。使用任何其他 HTTP 请求,例如 POSTPUT,将生成错误。

如果你查看 StackBlitz 项目,你会看到我们在这本书中一直遵循的组件创建的熟悉模式。我们不会再次介绍这个模式,但会突出一些与使用 Angular JSONP 服务相关的细节。

首先,除了导入 FormsModuleHttpClientModule,你还需要将 HttpClientJsonpModule 导入到 app.module.ts 中,如下所示:

. . . 
import { HttpClientModule, HttpClientJsonpModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
. . . 
@NgModule({
. . . 
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule,
 HttpClientJsonpModule
  ],
. . . 
}) 

接下来,我们需要将以下导入添加到 get-quote.component.ts

import { Component }from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { map } from 'rxjs/operators';

我们正在导入 HttpClient,它包含我们将要使用的 JSONP 方法,以及 RxJS 的 Observablemap 操作符。这些导入对你来说应该很熟悉,因为我们已经在本章中构建过。

当你使用 Angular JSONP 时,重要的是要理解默认情况下,它使用 RxJS 返回 Observables。这意味着我们将必须遵循订阅这些 Observables 的模式,并使用 RxJS 操作符来操作结果。我们还可以使用异步管道来简化这些操作。

然后将 HttpClient 注入到构造函数中:

constructor(public http: HttpClient) {}

接下来,我们添加几个将在我们的 JSONP 调用中使用的变量:

   symbol: string;
   quote: Observable<string>;
   url: string = 'https://api.iextrading.com/1.0/stock/';

symbol 变量将保存用户提供的搜索字符串。quote 变量将用于在我们的模板中显示从 JSONP 调用返回的值。而 url 变量是我们将要调用服务的基准 URL。

现在我们已经为我们的 getQuote 方法准备好了所有东西。让我们来看看它:

   getQuote (){ 
      let searchUrl = `${this.url}${this.symbol}/quote`;
      this.quote = this.http.jsonp(searchUrl, 'callback')
          .pipe(
          map( (res: string) => res)
        ); 
    }; 

我们首先通过连接 urlsymbol 并添加 /quote 来构造我们的 searchUrl。最后一部分 quote 是我们需要传递给报价服务以返回股票报价的内容。

然后,我们使用 HTTPClient 的 jsonp 方法执行对报价服务的远程调用。我们将 searchUrl 作为该方法的第一个参数传递,并将字符串 'callback' 作为我们的第二个参数。后一个参数由 Angular 用于在 searchUrl 中添加一个额外的查询字符串参数 callback。内部,Angular JSONP 服务生成一个动态的 script 标签和一个回调函数,并执行远程请求。

打开 StackBlitz 并输入符号,如 GOOGMSFTFB,以查看股票报价服务的作用。浏览器网络日志中的请求看起来如下:

https://api.iextrading.com/1.0/stock/MSFT/quote?callback=ng_jsonp_callback_0

在这里,ng_jsonp_callback_0 是动态生成的函数。响应看起来如下:

typeof ng_jsonp_callback_0 === 'function' && ng_jsonp_callback_0({"quote"::{"symbol":"MSFT"..});

响应被包裹在回调函数中。Angular 解析并评估这个响应,这导致调用 __ng_jsonp__.__req1 回调函数。然后,这个函数内部将数据路由到我们的函数回调。

我们希望这解释了 JSONP 的工作原理以及 JSONP 请求的底层机制。然而,JSONP 有其局限性:

  • 首先,我们只能进行 GET 请求(这是显而易见的,因为这些请求是由于脚本标签而发起的)

  • 其次,服务器还需要实现解决方案中涉及将响应包装在函数回调中的部分

  • 第三,始终存在安全风险,因为 JSONP 依赖于动态脚本生成和注入

  • 第四,错误处理也不可靠,因为很难确定脚本加载失败的原因

最终,我们必须认识到 JSONP 更像是一种权宜之计,而不是一种解决方案。随着我们迈向 Web 2.0,其中混搭变得司空见惯,越来越多的服务提供商决定通过 Web 公开他们的 API,一种更好的解决方案/标准已经出现:CORS。

跨域资源共享

跨域资源共享CORS)为 Web 服务器提供了一种支持跨站访问控制的方法,允许浏览器从脚本中发起跨域请求。根据这一标准,消费者应用程序(如私人教练)被允许进行某些类型的请求,称为简单请求,而无需任何特殊设置要求。这些简单请求限于GETPOST(具有特定的 MIME 类型)和HEAD。所有其他类型的请求都称为复杂请求

对于复杂请求,CORS 强制要求请求之前必须有一个 HTTP OPTIONS请求(也称为预检请求),该请求查询服务器以确定允许跨域请求的 HTTP 方法。只有在成功探测后,才会发出实际请求。

你可以从 MDN 文档中了解更多关于 CORS 的信息,该文档可在developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS找到。

CORS 最好的部分是客户端不需要像 JSONP 那样进行调整。完整的握手机制对调用代码是透明的,我们的 Angular HTTPClient调用工作得非常顺利。

CORS 需要在服务器上配置,MongoLab 服务器已经配置为允许跨域请求。因此,我们之前向 MongoLab 发送的添加和更新ExerciseWorkout文档的POSTPUT请求都导致了预检OPTIONS请求。

处理找不到的锻炼项目

你可能还记得,在第四章“私人教练”中,我们创建了WorkoutResolver,不仅用于在导航到WorkoutComponent之前检索锻炼项目,而且还防止导航到该组件,如果路由参数中存在不存在的锻炼项目。现在我们希望通过在锻炼屏幕上显示错误消息来增强这一功能,指出找不到锻炼项目。

为了实现这一点,我们需要修改WorkoutResolver,使其在找不到锻炼项目时重定向到锻炼屏幕。首先,将以下子路由添加到WorkoutBuilderRoutingModule中(确保它位于现有的锻炼路由之前):

children: [ 
  {path: '', pathMatch: 'full', redirectTo: 'workouts'}, 
 {path: 'workouts/workout-not-found', component: WorkoutsComponent'}, 
  {path: 'workouts', component: 'WorkoutsComponent'}, 
   *** other child routes *** 
  }, 
]

接下来,修改 WorkoutResolver 中的 resolve 方法,在找不到锻炼的情况下重定向到该路由。

resolve(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<WorkoutPlan> {
    const workoutName = route.paramMap.get('id');

    if (!workoutName) {
        return this.workoutBuilderService.startBuildingNew();
    } else {
        this.isExistingWorkout = true;
        return this.workoutBuilderService.startBuildingExisting(workoutName)
        .pipe(
          map(workout => {
            if (workout) {
              this.workoutBuilderService.buildingWorkout = workout;
              return workout;
            } else {
              this.router.navigate(['/builder/workouts/workout-not-found']);
              return null;
            }
          }),
          catchError(error => {
            console.log('An error occurred!');
            this.router.navigate(['/builder/workouts']);
            return of(null);
          })
        );
    }

然后在 Workouts 组件的变量中添加一个设置为 falsenotFound 布尔值:

  workoutList: Array<WorkoutPlan> = [];
  public notFound = false;

在该组件的 ngOnInit 方法中,添加以下代码以检查 workout-not-found 路径并将 notFound 值设置为 true

ngOnInit() {
  if(this.route.snapshot.url[1] && this.route.snapshot.url[1].path === 
  'workout-not-found') this.notFound = true; 
  this.subscription = this.workoutService.getWorkouts() 
  .subscribe( 
    workoutList => this.workoutList = workoutList, 
    (err:any) => console.error(err) 
  ); 
}

最后,在 Workouts.component.html 模板中,在显示 notFound 设置为 true 的锻炼列表上方添加以下 div 标签:

<div *ngIf="notFound" class="not-found-msgbox">Could not load the specific workout!</div>

如果当用户返回到 Workouts 页面时,在路径中找到 workout-not-found,那么屏幕上会显示以下信息:

我们已经修复了 Workout Builder 页面的路由失败,但练习构建器页面仍然待定。我们再次将修复工作留给你自己。

另一个主要(且待定)的实现是修复 7 分钟健身,因为它目前只针对一个锻炼方案。

修复 7 分钟健身应用

目前,7 分钟健身(或锻炼跑步者)应用只能播放一个特定的锻炼。它需要修复以支持使用 个人教练 构建的任何锻炼计划的执行。显然,需要整合这两个解决方案。我们已经完成了这项整合的基础工作。我们有了共享模型服务和 WorkoutService 来加载数据,这足以让我们开始。

修复 7 分钟健身 并将其转换为通用的 锻炼跑步者 大致涉及以下步骤:

  • 移除在 7 分钟健身 中使用的硬编码的锻炼和练习。

  • 修复起始页面以显示所有可用的锻炼,并允许用户选择要运行的锻炼。

  • 修复锻炼路由配置以将选定的锻炼名称作为路由参数传递到锻炼页面。

  • 使用 WorkoutService 加载选定的锻炼数据并开始锻炼。

  • 当然,我们需要重命名应用中的“7 分钟健身”部分;现在的名字已经不准确了。我认为整个应用可以命名为“个人教练”。我们还可以从视图中移除所有关于“7 分钟健身”的引用。

这是一个值得你亲自尝试的优秀练习!这就是为什么我们不会带你通过解决方案。相反,你可以继续实现解决方案。将你的实现与 checkpoint 5.4 中可用的解决方案进行比较。

是时候结束这一章节并总结你的学习了。

摘要

我们现在有一个可以进行很多操作的应用。它可以运行锻炼、加载锻炼、保存和更新它们,并跟踪历史记录。如果我们回顾一下,我们用最少的代码实现了这一点。我们打赌,如果我们尝试在标准的 jQuery 或其他框架中做这件事,与 Angular 相比,它将需要更多的努力。

我们在章节开始时在 MongoLab 服务器上提供了一个 MongoDB 数据库。由于 MongoLab 提供了 RESTful API 来访问数据库,我们没有设置自己的服务器基础设施,从而节省了一些时间。

我们首先接触到的 Angular 结构是HTTPClient,这是连接到任何 HTTP 后端的主要服务。

你还学习了HTTPClient模块如何使用 Observables。在本章中,我们第一次创建了自己的 Observable,并解释了如何为这些 Observable 创建订阅。

我们修复了我们的个人训练师应用程序,使其使用HTTPClient模块来加载和保存锻炼数据(请注意,锻炼数据持久化留给你来完成)。在这个过程中,你也了解了关于跨域资源访问的问题。你学习了 JSONP,这是一种绕过浏览器同源限制的解决方案,以及如何使用 Angular 发起 JSONP 请求。我们还提到了 CORS,当涉及到跨域通信时,它已成为一种标准。

我们现在已经涵盖了 Angular 的大部分构建块,除了最大的一个:Angular 指令。我们在各个地方都使用了指令,但还没有创建一个。下一章将专门介绍 Angular 指令。我们将创建一些小的指令,例如远程验证器、AJAX 按钮以及为锻炼构建器应用程序的验证提示指令。

第六章:深入了解 Angular 指令

指令无处不在。它们是 Angular 的基本构建块。每个应用扩展都导致我们创建了新的组件指令。这些组件指令进一步消耗了属性指令(如NgClassNgStyle)和结构指令(如NgIfNgFor)来扩展它们的行为。

虽然我们已经构建了许多组件指令和一个单独的属性指令,但仍有一些指令构建的概念值得探索。这对于属性和结构指令尤其如此,我们尚未详细涵盖。

本章将涵盖以下主题:

  • 构建指令:我们构建多个指令,并学习指令在哪里有用,它们与组件有何不同,以及指令如何相互通信以及/或与宿主组件通信。我们探讨了所有指令类型,包括组件指令属性指令结构指令

  • 异步验证:Angular 使得验证需要服务器交互且本质上是异步的规则变得容易。我们将在本章构建我们的第一个异步验证器。

  • 使用渲染器进行视图操作:渲染器允许以平台无关的方式操作视图。我们将利用渲染器为忙碌指示器指令,并了解其 API。

  • 宿主绑定:宿主绑定允许指令与其宿主元素进行通信。本章将介绍如何利用此类绑定来处理指令。

  • 指令注入:Angular DI 框架允许根据指令在 HTML 层次结构中的声明位置进行指令注入。我们将涵盖与这种注入相关的多个场景。

  • 处理视图子元素和内容子元素:组件具有将外部视图模板包含到其自身视图中的能力。如何处理注入的内容是我们将在此处涵盖的内容。

  • 理解 NgIf 平台指令:我们将深入了解NgIf平台指令,并尝试理解结构指令(如NgIf)的工作原理。

  • Angular 组件的视图封装:我们将学习 Angular 如何使用来自Web 组件的概念来支持视图和样式的封装。

让我们通过重申指令的基本分类来开始本章。

指令分类

Angular 指令将 HTML 视图与应用程序状态集成。指令帮助我们随着应用程序状态的变化来操作视图,并以最少或没有与实际 DOM 的交互来响应视图更新。

根据它们对视图的影响,这些指令进一步分为三种类型。

组件

组件指令组件是具有封装视图的指令。在 Angular 中,当我们构建 UI 小部件时,我们实际上是在构建组件。我们已经构建了很多,例如WorkoutRunnerComponentWorkoutAudioComponentVideoPlayerComponent以及更多!

在这里要认识到的一个重要观点是,视图绑定到组件实现,并且它只能与在支持组件上定义的属性和事件一起工作。

属性指令

属性指令,另一方面,扩展现有的组件或 HTML 元素。将它们视为对这些组件/元素的行为扩展。

由于指令是预定义元素的扩展行为,因此每个构建指令的练习都涉及操作应用这些指令的组件/元素的状态。第三章,更多 Angular 2 – 深入 SPA、路由和数据流中构建的MyAudioDirective也是如此。该指令包装了 HTML5 的audio元素(HTMLAudioElement),以便于使用。ngStylengClass等平台指令也以类似的方式工作。

结构指令

结构指令,例如属性指令,不定义自己的视图。相反,它们在其使用过程中作为其一部分工作的视图模板(HTML 片段)。通常,结构指令的目的是显示/隐藏或克隆提供给它的模板视图。NgForNgIfNgSwitch等平台指令是这个类别的典型例子。

我希望这个关于指令的快速复习足以让我们开始。我们将通过扩展锻炼构建验证来开始我们的追求,并构建一个异步验证指令。

我们将从第五章,支持服务器数据持久性中我们离开的地方开始。Git 分支checkpoint5.4可以作为本章的基础。代码也已在 GitHub 上提供(github.com/chandermani/angular6byexample)供每个人下载。检查点作为 GitHub 上的分支实现。如果您不使用 Git,可以从 GitHub 位置bit.ly/ng6be-checkpoint-5-4下载checkpoint5.4(ZIP 文件)。在首次设置快照时,请参阅trainer文件夹中的README.md文件。此外,请记住将services/workout-service.ts中的 API 密钥更新为您自己的 API 密钥。

构建远程验证指令

我们在第五章,支持服务器数据持久性中结束了,其中锻炼运行器能够管理 MongoDB 存储中的锻炼。由于每个锻炼都应该有一个独特的名称,我们需要强制实施唯一性约束。因此,在创建/编辑锻炼时,每次用户更改锻炼名称,我们都可以查询 MongoDB 以验证该名称是否已存在。

就像任何远程调用一样,这个检查也是异步的,因此需要一个远程验证器。我们将使用 Angular 的异步验证支持来构建这个远程验证器。

异步验证器与标准自定义验证器类似,除了验证检查的返回值是一个promise,而不是键值对象映射或 null。这个 promise 最终会解析为设置验证状态(如果有错误),或者 null(在验证成功的情况下)。

我们将创建一个执行锻炼名称检查的验证指令。对于这样的指令,有两种可能的实现方法:

  • 我们可以创建一个专门用于唯一名称验证的指令。

  • 我们可以创建一个通用的指令,可以执行任何远程验证。

验证指令

当我们在构建验证指令时,我们本可以构建一个标准的自定义验证器类。创建指令的优势在于,它允许我们将指令集成到模板驱动的表单方法中,其中指令可以嵌入到视图 HTML 中。或者,如果表单是使用模型(响应式方法)生成的,我们可以在创建Control对象时直接使用验证器类。

起初,检查数据源(mLab数据库)中重复名称的要求似乎过于具体,无法由通用验证器处理。但通过一些合理的假设和设计选择,我们仍然可以实施一个可以处理所有类型远程验证的验证器,包括锻炼名称验证。

计划创建一个外部化实际验证逻辑的验证器。该指令将验证函数作为输入。这意味着实际验证逻辑不是验证器的一部分,而是需要验证输入数据的组件的一部分。指令的职责仅仅是调用函数,并根据函数的返回值返回适当的错误键。

让我们将这个理论付诸实践,并构建我们的远程验证指令,命名为RemoteValidatorDirective

下一个部分的配套代码库是 Git 分支checkpoint6.1。您可以与我们同步工作,或者检查上述文件夹中可用的实现。或者如果您不使用 Git,可以从 GitHub 位置bit.ly/ng2be-checkpoint6-1下载checkpoint6.1(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

使用异步验证器验证锻炼名称

与自定义验证器类似,异步验证器也继承自相同的Validator类;但这次,异步验证器返回的是一个Promise,而不是对象映射。

让我们看看验证器的定义。从 GitHub (bit.ly/ng6be-6-1-remote-validator-directive-ts) 文件夹中复制验证器的定义,并将其添加到 shared 模块文件夹中。验证器的定义如下:

import { Directive, Input } from '@angular/core';
import { NG_ASYNC_VALIDATORS, FormControl } from '@angular/forms';

@Directive({
  selector: '[abeRemoteValidator][ngModel]',
  providers: [{ provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective, multi: true }]
})
export class RemoteValidatorDirective {

  @Input() abeRemoteValidator: string;
  @Input() validateFunction: (value: string) => Promise<boolean>;

  validate(control: FormControl): { [key: string]: any } {
    const value: string = control.value;
    return this.validateFunction(value).then((result: boolean) => {
      if (result) {
        return null;
      }
      else {
        const error: any = {};
        error[this.abeRemoteValidator] = true;
        return error;
      }
    });
  }
} 

请务必从共享模块中导出此指令,以便我们可以在锻炼构建模块中使用它。

由于我们将验证器注册为指令而不是使用 FormControl 实例(通常在构建使用 响应式方法 的表单时使用),我们需要额外的提供者配置设置(在先前的 @Directive 元数据中添加),使用此语法:

 providers:[{ provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective,  multi: true }] 

这条语句将验证器注册到现有的异步验证器中。

在前面的代码中使用的奇怪指令选择器 selector: `[abeRemoteValidator][ngModel]` 将在下一节中介绍,我们将构建一个忙碌指示器指令。

在我们深入研究验证器实现之前,让我们将其添加到锻炼名称输入中。这将帮助我们关联验证器的行为与其使用。

使用验证器声明更新锻炼名称输入 (workout.component.html):

<input type="text" name="workoutName" ... 
 abeRemoteValidator="workoutname"[validateFunction]="validateWorkoutName"> 

前缀指令选择器

总是在你的指令前加上一个标识符(如你刚才看到的 abe),以区分它们与框架指令和其他第三方指令。

注意:如果 ngModelOptionsupdateOn 设置为 submit,则将其更改为 blur

指令实现接受两个输入:通过指令属性 abeRemoveValidator 传递的 验证键,用于设置 错误键,以及 验证函数 (validateFunction),用于验证控件值。这两个输入都带有 @Input 装饰器。

输入参数 @Input("validateFunction") validateFunction: (value: string) => Promise<boolean>; 绑定到一个函数,而不是一个标准组件属性。由于底层语言 TypeScript(以及 JavaScript)的特性,我们可以将函数视为属性。

当异步验证触发(在 input 的变化上)时,Angular 会调用该函数,传入底层的 control。作为第一步,我们拉取当前的输入值,然后使用这个输入调用 validateFunction 函数。validateFunction 返回一个承诺,该承诺最终应该解析为 truefalse

  • 如果承诺解析为 true,则验证成功,承诺回调函数返回 null

  • 如果它是 false,则验证失败,并返回一个错误键值映射。这里的 是我们在使用验证器时设置的字符串字面量(a2beRemoteValidator="workoutname")。

当输入中声明了多个验证器时,这个 非常有用,它允许我们识别出失败的验证。

接下来,向锻炼组件添加一个针对此失败的验证消息。在现有的 锻炼名称 验证 label 之后添加此标签声明:

<label *ngIf="name.control.hasError('workoutname')" class="alert alert-danger validation-message">A workout with this name already exists.</label> 

然后将这两个标签包裹在一个 div 中,就像我们对 锻炼标题 错误标签所做的那样。

hasError 函数检查 'workoutname' 验证键是否存在。

此实现中缺失的最后一块是我们在应用指令时分配的实际验证函数([validateFunction]="**validateWorkoutName**"),但我们从未实现它。

validateWorkoutName 函数添加到 workout.component.ts

validateWorkoutName = (name: string): Promise<boolean> => {
    if (this.workoutName === name) { return Promise.resolve(true); }
    return this.workoutService.getWorkout(name).toPromise()
      .then((workout: WorkoutPlan) => {
        return !workout;
      }, error => {
        return true;
      });
  }  

在我们探索前面的函数之前,我们需要对 WorkoutComponent 类做一些更多的修复。validateWorkoutName 函数依赖于 WorkoutService 来获取具有特定名称的锻炼。让我们在构造函数中注入该服务,并在导入部分添加必要的导入:

import { WorkoutService }  from "../../core/workout.service"; 
... 
constructor(... , private workoutService: WorkoutService) { 

然后声明变量 workoutNamequeryParamsSub

private workoutName: string;
queryParamsSub: Subscription

将此语句添加到 ngOnInit

this.queryParamsSub = this.route.params.subscribe(params => this.workoutName = params['id']); 

前面的语句通过监视(订阅)route.params 服务来设置当前锻炼名称。如果使用原始锻炼名称,则使用 workoutName 来跳过现有锻炼的名称验证。

之前创建的订阅需要清除以避免内存泄漏,因此将此行添加到 ngDestroy 函数中:

this.queryParamsSub.unsubscribe();

validateWorkoutName 函数定义为 实例函数(使用 箭头操作符)而不是定义为标准函数(在 原型 上声明函数)的原因是 'this' 作用域问题。

查看 RemoteValidatorDirective 内部的验证函数调用(使用 @Input("validateFunction") validateFunction; 声明):

return this.validationFunction(value).then((result: boolean) => { ... }); 

当调用(名为 validateFunction)函数时,this 引用绑定到 RemoteValidatorDirective 而不是 WorkoutComponent。由于 execute 在前面的设置中引用了 validateWorkoutName 函数,因此 validateWorkoutName 内部的任何对 this 的访问都是问题性的。

这导致 validateWorkoutName 函数内部的 if (this.workoutName === name) 语句失败,因为 RemoteValiatorDirective 没有名为 workoutName 的实例成员。通过将 validateWorkoutName 定义为一个实例函数,TypeScript 编译器在函数定义时会在 this 的值周围创建一个闭包。

使用新的声明,validateWorkoutName 函数内部的 this 无论函数如何调用,始终指向 WorkoutComponent

我们还可以查看 WorkoutComponent 的编译后的 JavaScript 来了解闭包是如何与 validateWorkoutName 一起工作的。我们感兴趣的生成代码的部分如下:

function WorkoutComponent(...) { 
 var _this = this; 
  ... 
  this.validateWorkoutName = function (name) { 
 if (_this.workoutName === name) 
      return Promise.resolve(true); 

如果我们查看验证函数的实现,我们会看到它涉及到查询mLab以获取特定的锻炼名称。当没有找到具有相同名称的锻炼时,validateWorkoutName函数返回true,当找到具有相同名称的锻炼时(实际上返回的是一个promise)返回false

WorkoutService上的getWorkout函数返回一个observable,但我们通过在可观察对象上调用toPromise函数将其转换为promise

现在可以测试验证指令了。创建一个新的锻炼并输入一个现有的锻炼名称,例如7minworkout。看看验证错误消息最终是如何显示出来的:

太棒了!看起来很棒,但仍然有些不足。用户没有得到我们正在验证锻炼名称的通知。我们可以改善这种体验。

构建忙碌指示器指令

当远程验证锻炼名称时,我们希望用户意识到后台的活动。在远程验证发生时,输入框周围的视觉线索应该起到这个作用。

仔细思考;有一个带有异步验证器(执行远程验证)的输入框,我们希望在验证过程中用视觉线索装饰输入框。这似乎是一个常见的模式来解决?确实如此,所以让我们创建另一个指令!

但在我们开始实现之前,我们必须理解我们并不孤单。忙碌指示器指令需要另一个指令NgModel的帮助。我们已经在第四章构建个人教练中使用了NgModel指令在input元素上。NgModel帮助我们跟踪输入元素的状态。以下示例取自第四章构建个人教练,并突出了NgModel如何帮助我们验证输入:

<input type="text" name="workoutName" #name="ngModel"  class="form-control" id="workout-name" ... [(ngModel)]="workout.name" required> 
... 
<label *ngIf="name.control.hasError('required') && (name.touched || submitted)" class="alert alert-danger">Name is required</label>  

即使在上一节中完成的唯一锻炼名称验证也采用了相同的技巧,即使用NgModel来检查验证状态。

让我们从定义指令的大纲开始。在src/app/shared文件夹中使用 CLI 生成器创建一个busy-indicator.directive.ts文件:

ng generate directive busy-indicator

此外,通过在共享模块文件shared.module.ts中的exports数组中添加指令来导出它。

接下来,更新指令的构造函数以进行NgModel注入,并从@angular/forms导入NgModel引用:

constructor(private model: NgModel) { }

这指示 Angular 向声明的元素注入NgModel实例。记住,NgModel指令已经在inputworkoutname)上存在:

<input... name="workoutName" #name="ngModel" [(ngModel)]="workout.name" ...>

这就足够将我们的新指令集成到锻炼视图中了,所以让我们快速完成它。

workout-builder打开workout.component.html,并将忙碌指示器指令添加到锻炼名称input

<input type="text" name="workoutName" ... abeBusyIndicator> 

创建一个新的锻炼或打开一个现有的锻炼,以查看BusyIndicatorDirective是否已加载,以及NgModel注入是否正常工作。这可以通过在BusyIndicatorDirective构造函数中设置断点来轻松验证。

当 Angular 在输入 HTML 中遇到 ngModel 时,它将相同的 NgModel 实例注入到 BusyIndicatorDirective 中。

你可能会想知道,如果我们将此指令应用于没有 ngModel 属性的输入元素,或者实际上应用于任何 HTML 元素/组件,例如以下这样:

<div abeBusyIndicator></div> 
<input type="text" abeBusyIndicator> 

注入会起作用吗?

当然不是!我们可以在创建锻炼视图时尝试它。打开 workout.component.html 并在锻炼名称 input 上方添加以下 input。刷新应用:

<input type="text" name="workoutName1" a2beBusyIndicator> 

Angular 抛出异常,如下:

 EXCEPTION: No provider for NgModel! (BusyIndicatorDirective -> NgModel)

如何避免这种情况?嗯,Angular 的依赖注入(DI)可以在这里帮助我们,因为它允许我们声明一个可选依赖。

在进一步操作之前,请移除之前添加的 input 控制器。

使用 @Optional 装饰器注入可选依赖项

Angular 有一个 @Optional 装饰器,当应用于构造函数参数时,指示 Angular 注入器如果找不到依赖项则注入 null

因此,忙碌指示器构造函数可以写成如下:

constructor(@Optional() private model: NgModel) { } 

问题解决了吗?实际上并没有;如前所述,我们需要 NgModel 指令才能使 BusyIndicatorDirective 工作。所以,虽然我们学到了一些新东西,但在当前场景中并不十分有用。

在进一步操作之前,请记住将 workoutname input 恢复到原始状态,并应用 abeBusyIndicator

只有在元素上已经存在 NgModel 指令时,才应该应用 BusyIndicatorDirective

selector 指令这次将拯救我们的日子。将 BusyIndicatorDirective 的选择器更新如下:

selector: `[abeBusyIndicator][ngModel]` 

这个选择器仅在元素上存在 a2beBusyIndicatorngModel 属性的组合时创建 BusyIndicatorDirective。问题解决了!

现在是时候添加实际实现了。

实现一 - 使用渲染器

为了使 BusyIndicatorDirective 能够工作,它需要知道异步验证在 input 上何时触发以及何时结束。这个信息只有通过 NgModel 指令才能获得。NgModel 有一个属性 control,它是 Control 类的一个实例。正是这个 Control 类跟踪输入的当前状态,包括以下内容:

  • 目前分配的验证器(同步和异步)

  • 当前值

  • 输入元素的状态,例如 pristinedirtytouched

  • 输入验证状态,可能是 validinvalidpending 中的任何一个,具体取决于是否正在执行异步验证

  • 跟踪值变化或验证状态变化的操作

Control 看起来是一个有用的类,而我们感兴趣的是它的 pending 状态!

让我们为 BusyIndicatorDirective 类添加第一个实现。用以下代码更新类:

private subscriptions: Array<any> = []; 
ngAfterViewInit(): void {
    this.subscriptions.push(
      this.model.control.statusChanges.subscribe((status: any) => {
        if (this.model.control.pending) {
          this.renderer.setElementStyle(this.element.nativeElement, 'border-width', '3px');
          this.renderer.setElementStyle(this.element.nativeElement, 'border-color', 'gray');
        }
        else {
          this.renderer.setElementStyle(this.element.nativeElement, 'border-width', null);
          this.renderer.setElementStyle(this.element.nativeElement, 'border-color', null);
        }
      }));
  }  

需要在构造函数中添加两个新的依赖项,因为我们将在 ngAfterViewInit 函数中使用它们。将 BusyIndicatorDirective 的构造函数更新如下:

constructor(private model: NgModel,  
 private element: ElementRef, private renderer: Renderer) { }

还需要在'@angular/core'中添加对ElementRefRenderer的导入。

ElementRef是对底层 HTML 元素(在这种情况下是input)的包装对象。在第三章更深入的 Angular 2 - 单页应用、路由和数据流中构建的MyAudioDirective指令使用了ElementRef来获取底层的Audio元素。

Renderer注入值得注意。调用setElementStyle是一个明显的迹象表明Renderer负责管理 DOM。但在我们深入探讨Renderer的角色之前,让我们先尝试理解前面的代码在做什么。

在前面的代码中,模型(NgModel实例)上的control属性定义了一个事件(一个Observable),statusChanges,我们可以订阅它以了解控制验证状态何时改变。可用的验证状态有validinvalidpending

订阅检查控制状态是否为pending,并相应地使用Renderer API 函数setElementStyle装饰底层元素。我们设置了输入的border-widthborder-color

前面的实现被添加到ngAfterViewInit指令生命周期钩子中,该钩子在视图初始化后调用。

让我们试试。打开创建锻炼页面或现有的7 分钟锻炼。当我们离开锻炼名称输入时,input样式会改变,并在远程验证锻炼名称完成后恢复。不错!

图片

在继续前进之前,还要在BusyIndicatorDirective中添加取消订阅的代码以避免内存泄漏。将此函数(生命周期钩子)添加到BusyIndicatorDirective

ngOnDestroy() { 
    this.subscriptions.forEach((s) => s.unsubscribe()); 
} 

总是取消订阅观察者

总是要记得在代码中取消任何Observable/EventEmitter订阅,以避免内存泄漏。

实现看起来不错。Renderer正在做它的工作。但还有一些未解决的问题。

为什么不直接获取底层 DOM 对象并使用标准的 DOM API 来操作输入样式呢?为什么我们需要renderer

Angular 渲染器,翻译层

Angular 2 的主要设计目标之一是使其能够在各种环境中、框架和设备上运行。Angular 通过将核心框架实现分为应用层渲染层来实现这一点。应用层拥有我们与之交互的 API,而渲染层提供了一个抽象层,应用层可以使用它而无需担心实际视图是如何和在哪里被渲染的。

通过分离渲染层,Angular 理论上可以在各种设置中运行。这包括但不限于:

  • 浏览器

  • 浏览器主线程和 Web Worker 线程,出于明显的性能原因

  • 服务器端渲染

  • 原生应用框架;目前正在努力将 Angular 与NativeScriptReactNative集成

  • 测试,允许我们在浏览器外测试应用 UI

Angular 在我们浏览器内部使用的Renderer实现是DOMRenderer。它负责将我们的 API 调用转换为浏览器 DOM 更新。实际上,我们可以在BusyIndicatorDirective的构造函数中添加一个断点,查看renderer的值来验证渲染器类型。

正是因为这个原因,我们避免在BusyIndicatorDirective内部直接操作 DOM 元素。你永远不知道代码会运行到哪里。我们本可以轻松做到这一点:

this.element.nativeElement.style.borderWidth="3px"; 

相反,我们使用了Renderer以平台无关的方式完成同样的操作。

看一下Renderer API 函数,setElementStyle

this.renderer.setElementStyle( 
             this.element.nativeElement, "border-width", "3px"); 

设置样式需要元素、要更新的样式属性以及要设置的值。element引用的是注入到BusyIndicatorDirective中的input元素。

重置样式

通过调用setElementStyle设置的样式可以通过在第三个参数中传递null值来重置。查看前面代码中的else条件。

Renderer API 有其他一些方法可以用来设置属性、设置属性、监听事件,甚至创建新视图。每次你构建一个新的指令时,记得评估Renderer API 以进行 DOM 操作。

关于Renderer及其应用的更详细解释可以作为 Angular 设计文档的一部分在此处找到:bit.ly/ng2-render

我们还没有完成!借助 Angular 的强大功能,我们可以改进实现。Angular 允许我们在指令实现中进行宿主绑定,帮助我们避免大量的样板代码。

指令中的宿主绑定

在 Angular 领域,指令附加到的组件/元素被称为宿主元素:一个承载我们的指令/组件的容器。对于BusyIndicatorDirectiveinput元素是宿主

虽然我们可以使用Renderer来操作宿主(我们也确实这样做了),但 Angular 的数据绑定基础设施可以进一步减少代码。它提供了一种声明式的方式来管理指令-宿主交互。使用宿主绑定概念,我们可以操作元素的性质属性事件

让我们了解每个宿主绑定能力,最后我们将修复我们的BusyIndicatorDirective实现。

使用@HostBinding 进行属性绑定

使用宿主属性绑定指令属性绑定到宿主元素属性。任何对指令属性的更改都会在变更检测阶段与链接的宿主属性同步。

我们只需要在想要同步的指令属性上使用@HostBinding装饰器。例如,考虑以下绑定:

@HostBinding("readOnly") get busy() {return this.isbusy}; 

当应用于input时,它将inputreadOnly属性设置为true,当isbusy指令属性为true时。

注意,readonly也是input上的一个属性。这里我们指的是输入属性 readOnly

属性绑定

属性绑定将指令属性绑定到宿主组件属性。例如,考虑以下绑定方式的指令:

@HostBinding("attr.disabled") get canEdit(): string  
  { return !this.isAdmin ? "disabled" : null }; 

如果应用于输入,当isAdmin标志为false时,它将在input上添加disabled属性,否则清除它。在这里,我们也遵循了 HTML 模板中使用的相同的属性绑定符号。属性名以字符串字面量attr为前缀。

我们也可以用样式绑定做类似的事情。考虑以下行:

@HostBinding('class.valid')  
   get valid { return this.control.valid; } 

这行代码设置了一个类绑定,下一行创建了一个样式绑定:

@HostBinding("style.borderWidth")  
   get focus(): string { return this.focus?"3px": "1px"}; 

事件绑定

最后,事件绑定用于订阅宿主组件/元素引发的事件。考虑以下示例:

@Directive({ selector: 'button, div, span, input' }) 
class ClickTracker { 
  @HostListener('click', ['$event.target']) 
  onClick(element: any) { 
    console.log("button", element, "was clicked"); 
  } 
} 

这将在宿主事件click上设置一个监听器。Angular 将为视图中的每个buttondivspaninput实例化前面的指令,并使用onClick函数设置宿主绑定。$event变量包含引发事件的的事件数据,而target指的是被点击的元素/组件。

事件绑定也适用于组件。考虑以下示例:

@Directive({ selector: 'workout-runner' }) 
class WorkoutTracker { 
  @HostListener('workoutStarted', ['$event']) 
  onWorkoutStarted(workout: any) { 
    console.log("Workout has started!"); 
  } 
} 

使用这个指令,我们跟踪在WorkoutRunner组件上定义的workoutStarted事件。当锻炼开始时,会调用onWorkoutStarted函数,并带有开始锻炼的详细信息。

现在我们已经了解了这些绑定的工作原理,我们可以改进我们的BusyIndicatorDirective实现。

实现二 - 带有宿主绑定的 BusyIndicatorDirective

你可能已经猜到了!我们将使用宿主属性绑定而不是Renderer来设置样式。想要试试吗?请继续!清除现有的实现,并尝试为borderWidthborderColor样式属性设置宿主绑定,而不查看以下实现。

这就是宿主绑定实现后指令的外观:

import {Directive, HostBinding} from '@angular/core'; 
import {NgModel} from '@angular/forms'; 

@Directive({ selector: `[abeBusyIndicator][ngModel]`}) 
export class BusyIndicatorDirective {
  private get validating(): boolean {
    return this.model.control != null && this.model.control.pending;
  }
  @HostBinding('style.borderWidth') get controlBorderWidth():
        string { return this.validating ? '3px' : null; }
  @HostBinding('style.borderColor') get controlBorderColor():
        string { return this.validating ? 'gray' : null; }

  constructor(private model: NgModel) { }
}

我们将pending状态检查移动到了一个名为validating的指令属性中,然后使用了controlBorderWidthcontrolBorderColor属性进行样式绑定。这绝对比我们之前的方法更简洁!去测试一下吧。

如果我们告诉你这可以不使用自定义指令就能完成,请不要感到惊讶!我们就是这样做的,只需在锻炼名称input上使用样式绑定即可:

<input type="text" name="workoutName" ... 
[style.borderColor]="name.control.pending ? 'gray' : null" [style.borderWidth]="name.control.pending ? '3px' : null">

我们得到了相同的效果!

不,我们的努力并没有白费。我们确实学到了渲染器宿主绑定的概念。在构建提供复杂行为扩展而不是仅设置元素样式的指令时,这些概念将非常有用。

如果你在运行代码时遇到问题,请查看 Git 分支checkpoint6.1以获取我们迄今为止所做的工作的版本。或者如果你不使用 Git,可以从bit.ly/ng6be-checkpoint-6-1下载checkpoint6.1的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

我们接下来要讨论的主题是,指令注入

指令注入

回到几页前的BusyIndicatorDirective实现,它使用了渲染器,特别是构造函数:

constructor(private model: NgModel ...) { } 

Angular 自动定位为指令元素创建的NgModel指令,并将其注入到BusyIndicatorDirective中。这是因为这两个指令都是在同一个宿主元素上声明的。

好消息是我们可以影响这种行为。在父 HTML 树或子树上创建的指令也可以注入。接下来的几节将讨论如何在组件树中注入指令,这是一个非常实用的功能,允许具有共同血统(在视图中)的指令进行跨指令通信。

我们将使用 StackBlitz (stackblitz.com/edit/angular-pzljm3) 来演示这些概念。StackBlitz 是一个在线 IDE,可以运行 Angular 应用程序!

首先,查看文件app.component.ts。它有三个指令:RelationAcquaintanceConsumer,这个视图层次结构定义如下:

<div relation="grand-parent" acquaintance="jack"> 
    <div relation="parent"> 
 <div relation="me" consumer> 
        <div relation="child-1"> 
          <div relation="grandchild-1"></div> 
        </div> 
        <div relation="child-2"></div> 
      </div> 
    </div> 
</div> 

在接下来的几节中,我们将描述我们可以将不同的relationAcquaintance指令注入到consumer指令中的各种方法。检查浏览器控制台,查看在ngAfterViewInit生命周期钩子中记录的注入依赖项。

注入同一元素上定义的指令

默认情况下,构造函数注入支持注入同一元素上定义的指令。构造函数只需要声明我们想要注入的指令类型变量:

variable:DirectiveType 

BusyIndicatorDirective中我们进行的NgModel注入属于这一类别。如果指令在当前元素上找不到,Angular DI 将抛出错误,除非我们将依赖项标记为@Optional

可选依赖项

@Optional装饰器不仅限于指令注入。它存在于标记任何类型的依赖项为可选。

从 plunk 示例中,第一次注入(在Consumer指令实现中)将带有me属性的Relation指令(relation="me")注入到消费者指令中:

constructor(private me:Relation ... 

从父元素注入指令依赖项

在构造函数参数前加上@Host装饰器指示 Angular 在当前元素其父元素或其父元素上搜索依赖项,直到达到组件边界(一个在其视图层次结构中某处有指令的组件)。检查第二个consumer注入:

constructor(..., @Host() private myAcquaintance:Acquaintance  

此语句注入了在层次结构上方两级声明的Acquaintance指令实例。

就像之前描述的@Option装饰器一样,@Host()的使用不仅限于指令。Angular 服务注入也遵循相同的模式。如果一个服务被标记为@Host,搜索将停止在宿主组件上。它不会继续向上到组件树。

@Skipself装饰器可以用来跳过当前元素以进行指令搜索。

从 StackBlitz 示例中,这次注入将具有relation属性值parentrelation="parent")的Relation指令注入到consumer中:

@SkipSelf() private myParent:Relation 

注入子指令(或指令集)

如果需要将嵌套 HTML 中定义的指令(或指令集)注入到父指令/组件中,有四个装饰器可以帮助我们:

  • @ViewChild/@ViewChildren

  • @ContentChild/@ContentChildren

如这些命名约定所暗示的,有装饰器可以注入单个子指令或多个子指令:

要理解@ViewChild/@ViewChildren@ContentChild/@ContentChildren的重要性,我们需要看看视图和内容子代是什么,这是一个我们将很快讨论的话题。但就现在而言,理解视图子代是组件自身视图的一部分,而内容子代是注入到组件视图的外部 HTML 就足够了。

看看在 StackBlitz 示例中,ContentChildren装饰器是如何用来将子Relation指令注入到Consumer中的:

@ContentChildren(Relation) private children:QueryList<Relation>; 

令人惊讶的是,变量children的数据类型不是一个数组,而是一个自定义类-QueryListQueryList类不是一个典型的数组,而是一个 Angular 在添加或删除依赖项时保持更新的集合。这可能会在使用结构指令如NgIfNgFor创建/销毁 DOM 树时发生。我们将在接下来的章节中更多地讨论QueryList

你可能已经注意到前面的注入不是像前两个示例那样的构造函数注入。这是有原因的。注入的指令将在底层组件/元素的内容初始化之前不可用。正因为如此,我们在ngAfterViewInit生命周期钩子内部有console.log语句。我们应在生命周期钩子执行后仅访问内容子代。

前面的示例代码将所有三个子relation对象注入到consumer指令中。

注入后代指令(或指令集)

标准的@ContentChildren装饰器(或者实际上@ViewChildren也是如此)仅注入指令/组件的直接子代,而不是其后代。要包括所有后代,我们需要向Query提供一个参数:

@ContentChildren(Relation, {descendants: true}) private 
allDescendents:QueryList<Relation>; 

传递descendants: true参数将指示 Angular 搜索所有后代。

如果你查看控制台日志,前面的语句注入了所有四个后代。

Angular DI(依赖注入)虽然看起来使用简单,但包含了大量的功能。它管理我们的服务、组件和指令,并在正确的时间、正确的位置为我们提供所需的内容。组件和其他指令中的指令注入提供了一种机制,使指令能够相互通信。这种注入允许一个指令访问另一个指令的公共 API(公共函数/属性)。

现在是时候探索一些新内容了。我们将构建一个 Ajax 按钮组件,允许我们将外部视图注入到组件中,这个过程也被称为内容****转译

构建 Ajax 按钮组件

当我们保存/更新一个练习或锻炼时,总是存在重复提交(或重复POST请求)的可能性。当前的实现没有提供有关保存/更新操作何时开始和何时完成的任何反馈。由于缺乏视觉提示,应用程序的用户可能会有意或无意地多次点击保存按钮。

让我们尝试通过创建一个专门的按钮——一个Ajax 按钮来解决这个问题,当点击时提供一些视觉提示,并阻止重复的 Ajax 提交。

按钮组件将按照以下方式工作。它接受一个函数作为输入。这个输入函数(输入参数)应该返回一个与远程请求相关的 promise。点击按钮时,按钮内部会调用远程函数(使用输入函数),跟踪底层的 promise,等待其完成,并在这一过程中显示一些忙碌的提示。此外,按钮在远程调用完成之前保持禁用状态,以避免重复提交。

下一个部分的配套代码库是 Git 分支checkpoint6.2。您可以与我们一同工作,或者检查分支中可用的实现。如果您不使用 Git,可以从 GitHub 位置bit.ly/ng6be-checkpoint-6-2下载checkpoint6.2(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。

让我们创建组件轮廓以使事情更清晰。使用以下命令在应用程序的共享模块(src/app/shared)下创建一个ajax-button组件,然后从SharedModule导出组件:

ng generate component ajax-button -is

更新组件定义,并从@angular/core导入它们:

export class AjaxButtonComponent implements OnInit { 
  busy: boolean = null; 
  @Input() execute: any; 
  @Input() parameter: any; 
} 

并将以下 HTML 模板添加到ajax-button.component.html

<button [attr.disabled]="busy" class="btn btn-primary"> 
    <span [hidden]="!busy">
        <div class="ion-md-cloud-upload spin"></div>
    </span>
    <span>Save</span> 
</button> 

组件(AjaxButtonComponent)接受两个属性绑定,executeparameterexecute属性指向在 Ajax 按钮点击时调用的函数。parameter是可以传递给此函数的数据。

看看视图中 busy 标志的使用。当 busy 标志被设置时,我们禁用按钮并显示旋转器。让我们添加使一切工作的实现。将以下代码添加到 AjaxButtonComponent 类中:

@HostListener('click', ['$event'])
onClick(event: any) {
    const result: any = this.execute(this.parameter);
    if (result instanceof Promise) {
      this.busy = true;
      result.then(
        () => { this.busy = null; },
        (error: any) => { this.busy = null; });
    }
}

我们将 主机事件绑定 设置到 AjaxButtonComponent 的点击事件上。每当点击 AjaxButtonComponent 组件时,都会调用 onClick 函数。

需要将 HostListener 导入添加到 '@angular/core' 模块中。

onClick 实现调用输入函数,以单个参数作为 parameter。调用结果存储在 result 变量中。

if 条件检查 result 是否是 Promise 对象。如果是,则将 busy 指示器设置为 true。然后按钮等待使用 then 函数解决的承诺。无论承诺是以 成功 还是 错误 解决,busy 标志都会设置为 null

busy 标志设置为 null 而不是 false 的原因是这个属性绑定 [attr.disabled]="busy"。除非 busynull,否则 disabled 属性不会被移除。记住,在 HTML 中,disabled="false" 不会启用按钮。在按钮再次可点击之前,需要移除该属性。

如果我们对这一行感到困惑:

    const result: any = this.execute(this.parameter); 

然后你需要看看组件是如何使用的。打开 workout.component.html 并将 Save 按钮的 HTML 替换为以下内容:

<abe-ajax-button [execute]="save" [parameter]="f"></abe-ajax-button> 

Workout.save 函数绑定到 execute,而 parameter 接收 FormControl 对象 f

我们需要将 Workout 类中的 save 函数更改为返回一个承诺,以便 AjaxButtonComponent 可以工作。将 save 函数实现更改为以下内容:

save = (formWorkout: any): Promise<Object | WorkoutPlan> => {
    this.submitted = true;
    if (!formWorkout.valid) { return; }
    const savePromise = this.workoutBuilderService.save().toPromise();

    savePromise.then(
      result => this.router.navigate(['/builder/workouts']),
      err => console.error(err)
    );
    return savePromise;
  } 

save 函数现在返回一个 promise,我们通过在 workoutBuilderService.save() 调用返回的 observable 上调用 toPromise 函数来构建它。

注意我们如何将 save 函数定义为 实例函数(使用箭头操作符)来创建对 this 的闭包。这是我们之前在构建 远程验证指令 时所做的一件事。

是时候测试我们的实现了!刷新应用程序并打开创建/编辑锻炼视图。点击保存按钮,看看 Ajax 按钮如何工作:

图片

在保存后返回锻炼列表页面时,前面的动画可能不会持续很久。我们可以暂时禁用导航来查看新的更改。

我们开始本节的目标是突出外部元素/组件如何被包含到组件中。现在就让我们来做吧!

将外部组件/元素包含到组件中

从一开始,我们就需要理解 transclusion 是什么意思。而理解这个概念最好的方法就是看看一个例子。

我们迄今为止构建的任何组件都没有从外部借用内容。不确定这是什么意思?

考虑 workout.component.html 中的先前 AjaxButtonComponent 示例:

<ajax-button [execute]="save" [parameter]="f"></ajax-button> 

如果我们将ajax-button的使用方式改为以下内容?

<ajax-button [execute]="save" [parameter]="f">Save Me!</ajax-button> 

Save Me!文本会显示在按钮上吗?不会尝试它!

AjaxButtonComponent组件已经有了模板,并且它拒绝了我们之前提供的内容。如果我们能以某种方式让内容(前例中的Save Me!)加载到AjaxButtonComponent中会怎样?将外部视图片段注入到组件视图中的这一行为就是我们所说的转译,框架提供了必要的结构来启用转译。

是时候介绍两个新概念了,内容子元素视图子元素

内容子元素和视图子元素

简单来说,组件内部定义的 HTML 结构(使用templatetemplateUrl)是组件的视图子元素。然而,作为组件使用的一部分提供的 HTML 视图(例如<ajax-button>**Save Me!**</ajax-button>),定义了组件的内容子元素

默认情况下,Angular 不允许像之前看到的那样将内容子元素嵌入。Save Me!文本从未被发出。我们需要明确告诉 Angular 在组件视图模板中何处发出内容子元素。为了理解这个概念,让我们修复AjaxButtonComponent视图。打开ajax-button.component.ts并更新视图模板定义如下:

<button [attr.disabled]="busy" class="btn btn-primary"> 
    <span [hidden]="!busy"> 
        <ng-content select="[data-animator]"></ng-content> 
   </span> 
 <ng-content select="[data-content]"></ng-content> 
</button>

前一个视图中的两个ng-content元素定义了内容注入位置,内容子元素可以注入/转译。selector属性定义了在注入到主宿主时应该使用的CSS 选择器

一旦我们在workout.component.html中修复了AjaxButtonComponent的使用方式,将其改为以下内容,它就会开始变得更有意义:

<ajax-button [execute]="save" [parameter]="f">
    <div class="ion-md-cloud-upload spin" data-animator></div>
 <span data-content>Save</span>
</ajax-button> 

带有data-animator属性的span被注入到带有select=[data-animator]属性的ng-content中,另一个带有data-content属性的span被注入到第二个ng-content声明中。

再次刷新应用程序并尝试保存一个锻炼。虽然最终结果相同,但生成的视图是多个视图片段的组合:一部分是组件定义(视图子元素),另一部分是组件使用(内容子元素)。

下面的图示突出了渲染的AjaxButtonComponent之间的这一差异:

图片

ng-content可以声明而不带selector属性。在这种情况下,组件标签内部定义的全部内容将被注入。

内容注入到现有组件视图中是一个非常强大的概念。它允许组件开发者提供扩展点,组件消费者可以轻松消费并自定义组件的行为,而且是在受控的方式下。

我们为AjaxButtonComponent定义的内容注入允许消费者更改忙碌指示器动画和按钮内容,同时保持按钮的行为不变。

Angular 的优势不仅于此。它具有将内容子项视图子项注入到组件代码/实现的能力。这允许组件与其内容/视图子项交互并控制它们的行为。

使用@ViewChild 和@ViewChildren 注入视图子项

在第三章,“更深入的了解 Angular 2 - 单页应用、路由和数据流”,我们使用了类似的方法,视图子注入。为了回忆我们做了什么,让我们看看WorkoutAudioComponent实现的相关部分。

视图定义看起来如下:

<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
<audio #nextUpExercise="MyAudio" [src]="'/assets/audio/' + nextupSound"></audio>
// Some other audio elements 

注入看起来如下:

@ViewChild('ticks') private _ticks: MyAudioDirective; 
@ViewChild('nextUp') private _nextUp: MyAudioDirective; 
@ViewChild('nextUpExercise') private _nextUpExercise: MyAudioDirective; 

audio标签关联的指令(MyAudioDirective)是通过@ViewChild装饰器注入到WorkoutAudio实现的。传递给@ViewChild的参数是用于在视图定义中定位元素的模板变量名称(例如tick)。然后WorkoutAudio组件使用这些音频指令来控制7 分钟锻炼的音频播放。

虽然前面的实现注入了MyAudioDirective,甚至子组件也可以被注入。例如,如果我们构建一个MyAudioComponent,它可能如下所示:

@Component({ 
  selector: 'my-audio', 
  template: '<audio ...></audio>', 
}) 
export class MyAudioComponent { 
  ... 
} 

我们可以使用它而不是audio标签:

<my-audio #ticks loop  
  src="img/tick10s.mp3"></my-audio> 

注入仍然会起作用。

如果在组件视图中定义了多个同类型的指令/组件会发生什么?使用@ViewChildren装饰器。它允许你查询一种类型的注入。使用@ViewChildren的语法如下:

@ViewChildren(directiveType) children: QueryList<directiveType>; 

这将注入类型为directiveType的所有视图子项。对于前面提到的WorkoutAudio组件示例,我们可以使用以下语句来获取所有MyAudioDirective

@ViewChildren(MyAudioDirectives) private all: QueryList<MyAudioDirectives>; 

ViewChildren装饰器也可以接受一个由逗号分隔的选择器列表(模板变量名称)而不是类型。例如,为了在WorkoutAudio组件中选择多个MyAudioDirective实例,我们可以使用以下方法:

 @ViewChildren('ticks, nextUp, nextUpExercise, halfway, aboutToComplete') private all: QueryList<MyAudioDirective>; 

QueryList类是 Angular 提供的一个特殊类。我们在本章前面的注入子指令部分介绍了QueryList。让我们进一步探索QueryList

使用 QueryList 跟踪注入的依赖项

对于需要注入多个组件/指令(使用@ViewChildren@ContentChildren)的组件,注入的是QueryList对象。

QueryList类是一个只读的组件/指令集合。Angular 根据用户界面的当前状态保持此集合的同步。

例如,考虑WorkoutAudio指令视图。它有五个MyAudioDirective实例。因此,对于以下集合,我们将有五个元素:

@ViewChildren(MyAudioDirective) private all: QueryList<MyAudioDirective>; 

虽然前面的例子没有突出同步部分,但 Angular 可以跟踪组件/指令被添加或从视图中移除。这发生在我们使用内容生成指令,如ngFor时。

以这个假设的模板为例:

<div *ngFor="let audioData of allAudios"> 
  <audio [src]="audioData.url"></audio> 
</div> 

这里注入的MyAudioDirective指令的数量等于allAudios数组的大小。在程序执行期间,如果向allAudios数组添加或从中删除元素,框架也会保持指令集合的同步。

虽然QueryList类不是一个数组,但它可以通过for (var item in queryListObject)语法进行迭代(因为它实现了ES6 可迭代接口)。它还有一些其他有用的属性,如lengthfirstlast,这些属性可能会很有用。有关更多详细信息,请查看框架文档(bit.ly/ng2-querylist-class)。

从前面的讨论中,我们可以得出结论,QueryList为组件开发者节省了大量手动跟踪所需的样板代码。

视图子元素访问时机

当组件/指令初始化时,视图子元素注入不可用。Angular 确保视图子元素注入在ngAfterViewInit生命周期事件之前对组件可用。确保您只在(或之后)ngAfterViewInit事件触发时访问注入的组件/指令。

现在我们来看内容子元素注入,它与之前几乎相同,只是有一些细微的差别。

使用@ContentChild@ContentChildren注入内容子元素

Angular 允许我们使用一组并行的属性注入内容子元素@ContentChild用于注入特定的内容子元素,@ContentChildren用于注入特定类型的内容子元素。

如果我们回顾AjaxButtonComponent的使用,其内容子元素 span 可以通过以下方式注入到AjaxButtonComponent实现中:

@ContentChild('spinner') spinner:ElementRef; 
@ContentChild('text') text:ElementRef; 

workout.component.html中添加模板变量到相应的 span 标签:

<div class="ion-md-cloud-upload spin" data-animator #spinner></div>
<span data-content #text>Save</span>

虽然前面的注入是ElementRef,但它也可以是一个组件。如果我们为旋转器定义了一个组件,例如:

<ajax-button> 
    <busy-spinner></busy-spinner> 
    ... 
</ajax-button> 

我们也可以使用以下方式注入它:

@ContentChild(BusySpinner) spinner: BusySpinner; 

对于指令也是如此。在AjaxButtonComponent上声明的任何指令都可以注入到AjaxButtonComponent实现中。对于前面的情况,由于转义元素是标准 HTML 元素,我们注入了ElementRef,这是 Angular 为任何 HTML 元素创建的包装器。

就像视图子元素一样,Angular 确保内容子元素引用绑定到在ngAfterContentInit生命周期事件之前注入的变量。

在讨论注入依赖项时,让我们谈谈关于将服务注入到组件中的一些变体。

使用 viewProvider 进行依赖注入

我们已经熟悉 Angular 中 DI 注册的机制,其中通过将依赖项添加到任何模块声明中,在全局级别注册依赖项。

或者我们可以在组件级别使用 @Component 装饰器上的 providers 属性来完成:

providers:[WorkoutHistoryTracker, LocalStorage] 

为了避免混淆,我们现在正在讨论注入除了指令/组件对象之外的依赖项。指令/组件在模块的 declarations 数组中注册,在可以使用装饰器提示(如 @Query@ViewChild@ViewChildren 以及其他几个)注入之前。

在组件级别注册的依赖项对其 视图子组件内容子组件 及其后代都是可用的。

在我们继续之前,我们希望每个人都对 视图内容子组件 之间的区别有清晰的认识。如果有疑问,请再次参考 内容子组件和视图子组件 部分。

让我们从第四章,构建个人教练 中举一个例子。WorkoutBuilderService 服务在 WorkoutBuilderModule(锻炼构建模块)中在应用级别进行了注册:

providers: [ExerciseBuilderService, ...  
 WorkoutBuilderService]);

这允许我们在整个应用中注入 WorkoutBuilderService 以构建锻炼并在锻炼运行时使用。相反,我们可以在 WorkoutBuilderComponent 级别注册该服务,因为它是一切锻炼/锻炼创建组件的父组件,如下所示:

@Component({ 
    template: `...` 
 providers:[ WorkoutBuilderService ] 
}) 
export class WorkoutBuilderComponent { 

此更改将不允许在 WorkoutRunner 或任何与锻炼执行相关的组件中注入 WorkoutBuilderService

如果 WorkoutBuilderService 服务同时在应用级别和组件级别注册(如前例所示),注入将如何发生?根据我们的经验,我们知道 Angular 将将 WorkoutBuilderService 服务的不同实例注入到 WorkoutBuilderComponent(及其后代),而应用的其他部分(锻炼运行器)将获得全局依赖项。记住 分层注入器

Angular 并不止步于此。它还通过 viewProviders 属性提供进一步的范围依赖。viewProviders 属性在 @Component 装饰器上可用,允许注册只能在视图子组件中注入的依赖项。

让我们再次考虑 AjaxButtonComponent 示例,以及一个简单的指令实现 MyDirective,以进一步阐述我们的讨论:

@Directive({ 
  selector: '[myDirective]', 
}) 
export class MyDirective { 
  constructor(service:MyService) { } 
  ... 
} 

MyDirective 类依赖于一个服务,MyService

要将此指令应用于 AjaxButtonComponent 模板中的 *button 元素*,我们还需要注册 MyService 依赖项(假设 MyService 没有在全局范围内注册):

@Component({ 
  selector: 'ajax-button', 
  template:` <button [attr.disabled]="busy" ... 
 myDirective> 
                ... 
             <button>` 
 providers:[MyService], 
... 

由于 MyServiceAjaxButtonComponent 注册,因此也可以将其添加到其内容子组件中。因此,在 spinner HTML 上的 myDirective 应用也将生效(workout.component.html 中的代码):

<div class="ion-md-cloud-upload spin" data-animator myDirective></div>

但将 providers 属性更改为 viewProviders

viewProviders:[MyService]

将导致AjaxButtonComponent的内容子组件(前述代码中的div)的MyService注入失败,并在控制台出现 DI 错误。

使用viewProviders注册的依赖项对其内容子组件是不可见的。

这种对视图和内容子组件的依赖作用域可能一开始看起来并不实用,但它确实有其好处。想象一下,我们正在构建一个可重用的组件,我们希望将其打包并交付给开发者使用。如果该组件有一个预先打包的服务依赖项,我们需要格外小心。如果这样的组件允许内容注入(内容子组件),当在组件上使用基于提供者的注册时,依赖的服务会被广泛暴露。任何内容子组件都可以获取服务依赖项并使用它,从而导致不希望的结果。通过使用viewProvider注册依赖项,只有组件实现及其子视图可以访问依赖项,提供了必要的封装层。

再次,我们被 DI 框架提供的灵活性和定制化水平所折服。虽然对于初学者来说可能有些令人畏惧,但一旦我们开始用 Angular 构建越来越多的组件/指令,我们总会发现这些概念使我们的实现变得更加简单。

让我们把注意力转移到指令的第三类:结构指令

理解结构指令

虽然我们经常会使用结构指令,如NgIfNgFor,但很少需要创建结构指令。仔细思考一下。如果我们需要一个新视图,我们创建一个组件。如果我们需要扩展现有的元素/组件,我们使用一个指令。而结构指令最常见的使用是克隆视图的一部分(也称为模板视图),然后根据某些条件:

  • 要么注入/销毁这些模板(NgIfNgSwitch

  • 或者复制这些模板(NgFor

使用结构指令实现的任何行为都会无意中落入这两个类别之一。

基于这个事实,我们不如看看NgIf实现的源代码,而不是构建我们自己的结构指令。

以下是从NgIf指令中摘录的,对我们感兴趣的部分。我们故意忽略了ngIfElse部分:

@Directive({selector: '[ngIf]'})
export class NgIf {
 constructor(private _viewContainer: ViewContainerRef, templateRef: TemplateRef<NgIfContext>) {
    this._thenTemplateRef = templateRef;
 }

 @Input()
  set ngIf(condition: any) {
    this._context.$implicit = this._context.ngIf = condition;
    this._updateView();
 }
 private _updateView() {
    if (this._context.$implicit) {
      if (!this._thenViewRef) {
        this._viewContainer.clear();
        this._elseViewRef = null;
        if (this._thenTemplateRef) {
          this._thenViewRef =
              this._viewContainer.createEmbeddedView(this._thenTemplateRef, this._context);
        }
      }
    }
    ...
}

没有魔法,只是一个简单的结构指令,它检查一个布尔条件(this._context.$implicit)来创建/销毁视图!

上面的第一个 if 条件检查,如果条件 this._context.$implicittrue。下一个条件确保视图尚未渲染,通过检查变量 _thenViewRef。我们只想在 this._context.$implicitfalse 转换为 true 时切换视图。如果两个 if 条件都为真,则清除现有视图(this._viewContainer.clear()),并清除对 else 视图的引用。最内层的 if 条件确保 if 的模板引用可用。最后,代码调用 _viewContainer.createEmbeddedView 来渲染(或重新渲染)视图。

理解这个指令的工作方式并不困难。需要详细说明的是两个新的注入,ViewContainerRef (_viewContainer)TemplateRef (_templateRef)

TemplateRef

TemplateRef 类(_templateRef)存储结构化指令所引用的模板的引用。记得第二章中关于结构化指令的讨论,构建我们的第一个应用 - 7 分钟锻炼?所有结构化指令都接受一个它们工作的模板 HTML。当我们使用像 NgIf 这样的指令时:

<h3 *ngIf="currentExercise.exercise.name=='rest'"> 
  ... 
</h3> 

Angular 内部将这个声明转换为以下形式:

<ng-template [ngIf]="currentExercise.exercise.name=='rest'"> 
  <h3> ... </h3> 
</ng-template> 

这是结构化指令工作的模板,_templateRef 指向这个模板。

另一个注入是 ViewContainerRef

ViewContainerRef

ViewContainerRef 类指向模板渲染的容器。这个类提供了一些方便的方法来管理视图。NgIf 实现使用的两个函数,createEmbeddedViewclear,用于添加和移除模板 HTML。

createEmbeddedView 函数接受模板引用(再次注入到指令中)并渲染视图。

clear 函数销毁已注入的元素/组件,并清除视图容器。由于模板(TemplateRef)内部引用的每个组件及其子组件都被销毁,所有相关的绑定也停止存在。

结构化指令有一个非常特定的应用领域。尽管如此,我们可以使用 TemplateRefViewContainerRef 类做很多巧妙的事情。

我们可以实现一个结构化指令,根据用户角色显示/隐藏视图模板。

考虑以下假设的结构化指令示例,forRoles

<button *forRoles="admin">Admin Save</button> 

如果用户不属于 admin 角色,forRoles 指令将不会渲染按钮。核心逻辑可能如下所示:

if(this.loggedInUser.roles.indexOf(this.forRole) >=0){ 
      this.viewContainer.createEmbeddedView(this.templateRef); 
} 
else { 
      this.viewContainer.clear(); 
}  

指令实现需要某种服务,该服务返回已登录用户的详细信息。我们将把这个指令的实现留给读者。

forRoles 指令所做的事情也可以使用 NgIf 来完成:

<button *ngIf="loggedInUser.roles.indexOf('admin')>=0">Admin Save</button> 

forRoles 指令只是通过清晰的意图增加了模板的可读性。

结构指令的一个有趣的应用可能涉及创建一个仅仅复制传递给它的模板的指令。这相当容易构建;我们只需要调用createEmbeddedView两次:

ngOnInit() {       
 this.viewContainer.createEmbeddedView(this._templateRef);        
 this.viewContainer.createEmbeddedView(this._templateRef); 
}  

另一个有趣的练习!

ViewContainerRef类还有一些其他功能,允许我们注入组件、获取嵌入视图的数量、重新排序视图等等。查看框架文档中的ViewContainerRefbit.ly/view-container-ref)以获取更多详细信息。

这就完成了我们对结构指令的讨论,现在是时候开始新的内容了!

我们迄今为止构建的组件从共同的bootstrap 样式表和一些在app.css中定义的自定义样式那里获取它们的样式(CSS)。Angular 在这个领域提供了更多。一个真正可重用的组件在行为和用户界面方面都应该是完全自包含的。

组件样式和视图封装

Web 应用开发的一个长期问题是 DOM 元素行为和样式的封装不足。我们无法通过任何机制将应用程序的一部分 HTML 与另一部分隔离开来。

实际上,我们手中的权力过大。有了像 jQuery 这样的库和强大的CSS 选择器,我们可以获取任何 DOM 元素并改变其行为。在它可以访问的内容方面,我们的代码和任何外部库代码之间没有区别。每一块代码都可以操作渲染 DOM 的任何部分。因此,封装层被破坏了。一个编写不良的库可能会引起一些难以调试的糟糕问题。

CSS 样式也是如此。任何 UI 库实现都可以覆盖全局样式,如果库实现想要这样做的话。

这些是任何库开发者在构建可重用库时面临的真正挑战。一些新兴的 Web 标准试图通过提出诸如Web 组件等概念来解决此问题。

Web 组件,简单来说,是封装了它们的状态样式用户界面行为的可重用用户界面小部件。功能通过定义良好的 API 公开,用户界面部分也被封装。

Web 组件 概念由四个标准启用:

  • HTML 模板

  • 阴影 DOM

  • 自定义元素

  • HTML 导入

对于这次讨论,我们感兴趣的技术标准是阴影 DOM

阴影 DOM 概述

阴影 DOM就像一个在组件内部(一个 HTML 元素,不要与 Angular 组件混淆)托管并隐藏在主 DOM 树之外的并行 DOM 树。除了组件本身之外,应用程序的任何部分都无法访问这个阴影 DOM。

阴影 DOM 的实现允许视图、样式和行为封装。理解阴影 DOM 的最好方式是看看 HTML5 的videoaudio标签。

你是否曾经想过这个audio声明:

<audio src="img/nextup.mp3" controls></audio> 

产生以下结果?

图片

是浏览器生成底层的 Shadow DOM 来渲染音频播放器。令人惊讶的是,我们甚至可以查看生成的 DOM!以下是我们的操作方法:

  • 将前面的 HTML 创建一个虚拟 HTML 页面,并在 Chrome 中打开它。

  • 然后打开开发者工具窗口(F12)。点击左上角的设置图标。

  • 在常规设置中,点击以下截图中突出显示的复选框,以启用 Shadow DOM 的检查:

图片

刷新页面,如果我们现在检查生成的audio HTML,Shadow DOM 就会出现:

图片

shadow-root下,有一个其他页面部分和脚本都无法访问的全新世界。

在 Shadow DOM 领域,shadow-root(前述代码中的#shadow-root)是生成 DOM 的根节点,位于shadow host(在这种情况下是audio标签)内部。当浏览器渲染这个元素/组件时,渲染的是来自shadow root的内容,而不是shadow host

从这次讨论中,我们可以得出结论,Shadow DOM 是由浏览器创建的并行 DOM,它封装了 HTML 元素的标记样式行为(DOM 操作)。

这是对 Shadow DOM 的温和介绍。要了解更多关于 Shadow DOM 如何工作的信息,我们推荐 Rob Dodson 的这篇系列文章:bit.ly/shadow-dom-intro

但这一切与 Angular 有什么关系呢?实际上,Angular 组件也支持某种视图封装!这使我们也能为 Angular 组件隔离样式。

Shadow DOM 和 Angular 组件

要理解 Angular 如何使用 Shadow DOM 的概念,我们首先需要了解如何为 Angular 组件进行样式设计。

当涉及到为本书中构建的应用程序进行样式设计时,我们采取了保守的方法。无论是Workout Builder还是Workout Runner(7 分钟健身)应用程序,我们构建的所有组件都从bootstrap CSSapp.css中定义的定制样式获取样式。没有组件定义了自己的样式。

虽然这遵循了网络应用程序开发的常规做法,但有时我们确实需要偏离。当我们构建自包含、打包和可重用组件时,这一点尤其正确。

Angular 允许我们通过在@Component装饰器上使用style(用于内联样式)和styleUrl(外部样式表)属性来定义特定于组件的样式。让我们玩一下style属性,看看 Angular 会做什么。

我们将使用AjaxButtonComponent实现作为下一个练习的游乐场。但在做之前,让我们看看现在的AjaxButtonComponent HTML。AjaxButtonComponent的 HTML 树如下所示:

图片

让我们使用styles属性覆盖一些样式:

@Component({ 
  ... 
  styles:[` 
    button { 
      background: green; 
    }`] 
}) 

上述CSS 选择器将所有 HTML 按钮的background属性设置为green。保存上述样式并刷新工作构建页面。按钮样式已更新。这里没有惊喜吗?不,事实并非如此,还有一些惊喜!看看生成的 HTML:

已向多个 HTML 元素添加了一些新属性。最近定义的样式又落在哪里呢?就在head标签的顶部:

head部分定义的样式具有额外的范围,带有_ngcontent-c1属性(在您的案例中属性名可能不同)。这种范围定义允许我们独立地样式化AjaxButtonComponent,并且它不能覆盖任何全局样式。

即使使用styleUrls属性,Angular 也会做同样的事情。假设我们已经在外部 CSS 文件中嵌入相同的 CSS,并使用以下方式:styleUrls:['static/css/ajax-button.css'],Angular 仍然会将样式内联到head部分,通过获取 CSS,解析它,然后注入。

按照定义,应该影响应用程序中所有按钮外观的样式,却没有产生任何效果。Angular 对这些样式进行了范围定义。

这种范围定义确保组件样式不会与已定义的样式混淆,但反之则不然。全局样式仍然会影响组件,除非在组件本身中覆盖。

这种范围定义的样式是 Angular 尝试模拟 Shadow DOM 范式的结果。组件上定义的样式永远不会泄漏到全局样式。这一切的奇妙之处都不需要任何努力!

如果您正在构建定义自己样式的组件并希望有一定程度的隔离,请使用组件的style/styleUrl属性,而不是使用传统的所有样式共享一个 CSS 文件的方法。

我们可以通过使用名为@Component的装饰器属性encapsulation进一步控制这种行为。该属性的 API 文档提到:

encapsulation: ViewEncapsulation 指定模板和样式应该如何封装。如果视图有样式,默认值为ViewEncapsulation.Emulated,否则为ViewEncapsulation.None

如我们所见,一旦我们在组件上设置样式,封装效果就是Emulated。否则,它是None

如果我们明确地将encapsulation设置为ViewEncapsulation.None,则移除范围属性,并将样式嵌入到head部分作为正常样式。

然后还有一个第三种选项,ViewEncapsulation.Native,其中 Angular 实际上为组件视图创建了 Shadow DOM。将AjaxButtonComponent实现上的encapsulation属性设置为ViewEncapsulation.Native,现在看看渲染的 DOM:

AjaxButtonComponent现在有了阴影 DOM!这也意味着按钮的完整样式已经丢失(从 bootstrap CSS 派生的样式),按钮现在需要定义自己的样式。

Angular 不遗余力地确保我们开发的组件可以独立工作并且可重用。每个组件都有自己的模板和行为。除此之外,我们还可以封装组件样式,使我们能够创建健壮的、独立的组件。

这就带我们结束了本章,现在是时候总结本章所学的内容了。

摘要

随着本章的结束,我们现在对指令的工作原理以及如何有效地使用它们有了更好的理解。

我们本章开始时构建了一个RemoteValidatorDirective,并了解了 Angular 对异步验证的支持。

接下来是BusyIndicatorDirective,同样是一个极好的学习场所。我们探索了渲染器服务,它允许以平台无关的方式操作组件视图。我们还学习了宿主绑定,它让我们能够绑定到宿主元素的事件属性属性

Angular 允许跨视图层次声明的指令注入到层次结构中。我们专门用几个部分来理解这种行为。

我们创建的第三个指令(组件)是AjaxButtonComponent。它帮助我们理解了组件中内容子元素视图子元素之间的关键区别。

我们还简要提到了结构指令,其中我们探讨了NgIf平台指令。

最后,我们探讨了 Angular 在视图封装方面的能力。我们研究了 Shadow DOM 的基础,并学习了框架如何采用 Shadow DOM 范式来提供视图加样式封装。

下一章全部关于测试 Angular 应用,这是完整框架提供中的关键部分。Angular 框架是考虑到可测试性而构建的。框架构造和工具支持使得在 Angular 中进行自动化测试变得容易。更多内容将在下一章中介绍……

第七章:测试个人教练

除非您是一位编写代码完美的超级英雄,否则您需要测试您所构建的内容。此外,除非您有大量空闲时间不断测试您的应用程序,否则您需要一些测试自动化。

当我们说 Angular 是以可测试性为设计理念时,我们确实是认真的。它有一个强大的 依赖注入DI)框架,一些良好的模拟构造,以及使在 Angular 应用中进行测试变得富有成效的出色工具。

本章全部关于测试,致力于测试本书过程中我们所构建的内容。我们从组件到管道、服务以及我们的应用指令,测试了所有内容。

本章涵盖的主题包括:

  • 理解大局:我们将尝试理解测试如何融入 Angular 应用开发的整体背景。我们还将讨论 Angular 支持的测试类型,包括单元测试和 端到端E2E)测试。

  • 工具和框架概述:我们将介绍帮助使用 Angular 进行单元测试和端到端测试的工具和框架。这些包括 KarmaProtractor

  • 编写单元测试:您将学习如何在浏览器中使用 JasmineKarma 进行 Angular 的单元测试。我们将对上一章构建的内容进行单元测试。本节还将教会我们如何对各种 Angular 构造进行单元测试,包括管道、组件、服务和指令。

  • 创建端到端测试:自动化的端到端测试通过模拟实际用户的行为并通过浏览器自动化来实现。您将学习如何使用 Protractor 结合 WebDriver 进行端到端测试。

让测试开始吧!

当您开始阅读本章时,我们建议您下载 checkpoint 7.1 的代码。它可以在 GitHub 上供所有人下载(github.com/chandermani/angular6byexample)。检查点作为 GitHub 上的分支实现。如果您不使用 Git,可以从此 GitHub 位置下载 checkpoint7.1 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint7.1.zip。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

此检查点包含在前面章节创建组件、服务、管道和指令时由 Angular CLI 生成的测试。我们对这些测试进行了细微的修改,以确保它们都能通过。大部分这些测试都是基本的“Hello World”测试,用于确认组件或其他 Angular 构造的创建。我们将在本章中不涉及这些测试,但鼓励您进行回顾。

自动化的需求

随着时间的推移,为网络构建的应用的大小和复杂性都在增长。我们现在构建网络应用的可选方案繁多,令人眼花缭乱。再加上产品/应用的发布周期已经从几个月缩短到几天,甚至每天有多个版本发布!这给软件测试带来了很大的负担。有太多东西需要测试。多个浏览器、多个客户端和屏幕尺寸(桌面和移动)、多个分辨率等等。

在这样一个多样化的环境中要有效,自动化是关键。“自动化一切可以自动化的内容”应该是我们的座右铭。

Angular 中的测试

Angular 团队意识到了可测试性的重要性,因此创建了一个框架,使得基于该框架构建的应用可以轻松进行测试(自动化)。使用 DI 构造来注入依赖的设计选择有助于这一点。随着章节的推进,我们将为我们的应用构建多个测试,这一点将会变得清晰。然而,在那之前,让我们了解在构建该平台上的应用时,我们针对哪些类型的测试。

测试类型

对于典型的 Angular 应用,我们主要进行两种形式的测试:

  • 单元测试:单元测试完全是针对组件进行隔离测试,以验证其行为的正确性。被测试组件的大多数依赖项需要用模拟实现来替换,以确保单元测试不会因为依赖组件的失败而失败。

  • 端到端测试:这种测试类型完全模拟真实用户的操作,并验证应用的行为。与单元测试不同,组件不是单独测试的。测试是在真实浏览器中针对运行中的系统进行的,断言是基于用户界面状态和显示的内容进行的。

单元测试是防止错误的第一个防线,我们应该能够在单元测试期间用代码解决大多数问题。但除非进行了端到端测试,否则我们无法确认软件是否正确运行。只有当系统中的所有组件以期望的方式交互时,我们才能确认软件是正常工作的;因此,端到端测试成为了一种必需。

你可以将这两种测试类型看作是一个金字塔,端到端测试位于顶部,单元测试位于底部。金字塔表明,你编写的单元测试数量应该远多于端到端测试的数量。原因是,通过单元测试,你将应用分解成小的可测试单元,而通过集成测试,你跨越了从 UI 到后端的多个组件。此外,设置端到端测试通常比单元测试更复杂。

谁编写单元测试和端到端测试,以及何时编写,都是需要回答的重要问题。

测试 – 谁来做,何时做?

传统上,端到端测试(E2E testing)是由质量保证QA)团队执行的,而开发人员则负责在提交代码前进行单元测试。开发人员也会进行一定程度的端到端测试,但总体来说,端到端测试过程是手动的。

随着形势的变化,现代测试工具,尤其是在网络前端,已经允许开发人员自己编写自动化的端到端测试,并针对任何部署设置(如开发/测试/生产)执行它们。例如,Selenium 与 WebDriver 一起使用,可以轻松实现浏览器自动化,从而使得编写和执行针对真实网络浏览器的端到端测试变得容易。

在开发完成并准备部署时是编写端到端场景测试的好时机。

当涉及到单元测试时,关于何时编写测试存在不同的观点。测试驱动开发者在功能实现之前编写测试。其他人则在实现完成后编写测试以确认行为。有些人则在开发组件的同时编写测试。选择一种适合你的风格,同时记住,你编写测试的时间越早,效果越好。

我们不会给出任何建议,也不会就哪种方法更好而争论。任何数量的单元测试都比没有好。我们个人的偏好是采用中间方法。在使用测试驱动开发(TDD)时,我们有时觉得测试创建的努力因为规格/需求的变化而白费。一开始编写的测试容易因为需求变化而需要不断修正。在最后编写单元测试的问题在于,我们的目标是创建符合当前实现的测试。编写的测试是为了测试实现,而不是测试规格。在中间某个地方添加测试对我们来说效果最好。

现在我们来了解一下可用于 Angular 测试的工具和技术环境。

Angular 测试生态系统

看一下以下图表,了解支持 Angular 测试的工具和框架:

图片

支持 Angular 测试的工具和框架

如我们所见,我们使用单元测试库,如JasmineMocha来编写测试。

目前,Angular 测试库默认与Jasmine一起工作。然而,Angular 团队已经表明,他们已经使框架更加通用,这样你就可以使用其他测试库,如 Mocha。Angular 文档尚未更新以包含如何做到这一点。有关使用 Mocha 与 Angular CLI 测试命令的讨论,请参阅github.com/angular/angular-cli/issues/4071

这些测试根据我们是否编写单元测试或集成测试,由 Karma 或 Protractor 执行。这些测试运行器反过来在浏览器(如 Chrome、Firefox、IE)或无头浏览器(如 PhantomJS)中运行我们的测试。重要的是要强调,不仅端到端测试,单元测试也是在真实浏览器中执行的。

本章中的所有测试都是使用 Jasmine 编写的(包括单元测试和集成测试)。Karma 将作为单元测试的测试运行器,而 Protractor 将用于端到端测试。

开始使用单元测试

单元测试的最终目的是在隔离状态下测试特定的代码/组件,以确保组件按照规范工作。这减少了组件与其他软件部分集成时出现失败/错误的机会。在我们开始编写测试之前,有一些指导原则可以帮助我们编写良好且可维护的测试:

  • 一个单元应该测试一个行为。出于明显的原因,每个单元测试测试一个行为是有意义的。失败的单元测试应清楚地突出问题区域。如果一起测试多个行为,失败的测试需要更多的调查来确定违反了哪个行为。

  • 单元测试中的依赖项应使用测试替身(如模拟、模拟或 st)来模拟。正如其名所示,单元测试应该测试单元,而不是其依赖项。

  • 单元测试不应该永久改变被测试组件的状态。如果发生了这种情况,其他测试可能会受到影响。

  • 单元测试的执行顺序应该是无关紧要的。一个单元测试不应该依赖于另一个单元测试在它之前执行。这是脆弱单元测试的迹象。这也可能意味着依赖项没有被模拟。

  • 单元测试应该快速。如果它们不够快,开发者就不会运行它们。这是一个在单元测试中模拟所有依赖项(如数据库访问、远程 Web 服务调用等)的好理由。

  • 单元测试应尝试覆盖所有代码路径。代码覆盖率是一个可以帮助我们评估单元测试有效性的指标。如果在测试期间覆盖了所有正面和负面场景,覆盖率确实会更高。在此提醒一点:高代码覆盖率并不意味着代码没有错误,但低覆盖率明显表明单元测试中未覆盖的区域。

  • 单元测试应测试正面和负面场景。只是不要只关注正面测试用例;所有软件都可能失败,因此单元测试失败场景与成功场景一样重要。

这些指导原则不是框架特定的,但为我们编写良好测试提供了足够的弹药。让我们通过设置单元测试所需的组件来开始单元测试的过程。

为单元测试设置 Karma 和 Jasmine

当我们使用 Angular CLI 创建项目时,CLI 会配置使用 Karma 和 Jasmine 对我们的代码进行单元测试的设置。它是通过向我们的项目中添加几个 Karma 和 Jasmine 模块来实现的。它还在应用程序的根目录 trainer/ 中添加了一个名为 karma.config.js 的 Karma 配置文件,并在 trainer/src 目录中添加了一个名为 tests.ts 的文件。CLI 在运行时使用这些文件来创建执行我们的测试的配置。这意味着我们可以通过简单地使用以下命令来运行我们的测试:

ng test

CLI 还会监视我们的测试以检测更改,并自动重新运行它们。

我们在这里不会详细讲解配置文件。默认设置对我们的目的来说已经足够了。有关各种 Karma 配置选项的更多信息,请参阅 Karma 文档(karma-runner.github.io/1.0/config/configuration-file.html)。

我们测试文件的组织和命名

要对应用程序进行单元测试,我们应该为项目中计划测试的每个 TypeScript 文件有一个测试文件(例如 workout-runner.spec.ts)。这正是 Angular CLI 为我们做的事情。当我们使用 CLI 创建组件、服务、管道或指令时,CLI 将生成相应的测试并将其放置在相同的文件目录中。

使用被测试文件名称加上 .spec 来命名测试文件是使用 Jasmine 进行测试的开发者所采用的一种约定。它也用于便于我们在之前概述的配置步骤中映射文件到测试。

此测试文件包含对应组件的单元测试规范,如下面的截图所示(在运行单元测试时在 Karma 调试器中捕获):

图片

单元测试 Angular 应用程序

在本书的整个过程中,我们构建了涵盖 Angular 中每个构造的组件。我们构建了组件、管道、一些服务,最后还有一些指令。所有这些都可以在单元测试中进行测试。

本章剩余的代码可以在 checkpoint 7.2 中找到。它可以在 GitHub 上供每个人下载(github.com/chandermani/angular6byexample)。检查点作为 GitHub 上的分支实现。如果您不使用 Git,可以从以下 GitHub 位置下载 checkpoint7.2 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint7.1.zip。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。

为了熟悉使用 Jasmine 进行单元测试,让我们首先测试最小且最简单的组件:管道。

单元测试管道

管道是最容易测试的,因为它们对其他构造的依赖最小或为零。我们为Workout Runner7 分钟锻炼应用程序)创建的SecondsToTimePipe没有依赖关系,可以轻松地进行单元测试。

查看 Jasmine 框架文档,了解如何使用 Jasmine 编写单元测试。CLI 正在使用 Jasmine 2.6 进行我们的单元测试(jasmine.github.io/2.6/introduction.html)。Jasmine 拥有一些最好的文档,并且整个框架非常直观易用。我们强烈建议您访问 Jasmine 网站,在继续之前熟悉这个框架。

trainer/src/app/shared文件夹中打开seconds-to-time.pipe.spec.ts文件,并按照以下方式更新那里的单元测试:

import { SecondsToTimePipe } from './seconds-to-time.pipe';
describe('SecondsToTimePipe', () => {
  const pipe = new SecondsToTimePipe();
  it('should convert integer to time format', () => {
      expect(pipe.transform(5)).toEqual('00:00:05');
      expect(pipe.transform(65)).toEqual('00:01:05');
      expect(pipe.transform(3610)).toEqual('01:00:10');
  });
});

让我们看看在我们的测试文件中我们正在做什么。

毫不奇怪,我们导入了SecondsToTimePipe,这是我们将要测试的。这就像我们在 TypeScript 类中其他地方使用的导入一样。请注意,我们使用了一个相对路径来指向该文件的位置 './seconds-to-time.pipe'。在 Angular 中,这意味着在测试本身所在的目录中查找要测试的组件。如您所回忆的,这是我们设置文件结构的方式:将我们的测试放在与被测试文件相同的目录中。

在下一行,我们开始使用 Jasmine 语法。首先,我们用describe函数包裹测试以标识测试。这个函数的第一个参数是对测试的用户友好描述;在这种情况下,它是SecondsToTimePipe。对于第二个参数,我们传递一个 lambda(胖箭头)函数,它将包含我们的测试。在设置一个本地变量来保存管道后,我们调用 Jasmine 的beforeEach函数,并使用它来注入我们的管道实例。

由于beforeEach函数在describe函数中的每个测试之前运行,我们可以用它来运行每个测试中都会运行的公共代码。在这种情况下,它不是严格必要的,因为我们的describe函数中只有一个测试。但养成使用它的习惯是一个好主意,正如我们将看到的那样。

接下来,我们调用 Jasmine 的it函数,并传递一个标题,以及三个对 Jasmine 的expect函数(Jasmine 对断言的称呼)的调用。这些都是不言自明的。

在我们的测试中不需要显式导入这些 Jasmine 函数。

运行我们的测试文件

现在是时候使用以下命令运行我们的测试了:

ng test

Angular CLI 将把我们的 TypeScript 文件转换为 JavaScript,并监视这些文件的变化。

我们应该在终端窗口中看到这个输出(对于您来说,测试的总数可能不同):

最后一行显示我们的测试成功通过(以及我们所有的其他测试)。

您还可以在 Karma 运行我们的测试时它启动的浏览器窗口中查看测试结果:

图片

你会注意到这里,Karma 显示了用于我们管道测试的 describe 语句(SecondsToTimePipe),并且在其下嵌套了 it 语句(应将整数转换为时间格式),以展示我们创建的测试的预期结果。以显示的格式读取结果使得理解测试结果变得非常容易。

为了确保它报告正确的通过/失败结果,让我们在测试中做一个更改,导致其中一个期望失败。将第一个期望中的时间从五秒更改为六秒,如下所示:

expect(pipe.transform(5, [])).toEqual('00:00:06'); 

我们得到以下错误消息:

图片

这个错误消息的优点是它将 describeit 描述合并成一个完整的句子,提供了对错误的清晰总结。这显示了 Jasmine 如何允许我们编写可读的测试,以便新接触我们代码的人可以快速理解其中可能出现的任何问题。下一行显示了哪个期望未满足,期望的是什么,以及实际结果是什么,这些结果没有满足这个期望。

在此消息下方,我们还得到一个堆栈跟踪和一个显示我们测试总体结果的最后一条线:

图片

在浏览器中,我们看到以下内容:

图片

你会注意到,当我们更改测试时,我们不必重新运行 Karma。相反,它监视我们文件和相关测试的任何更改,并在我们做出更改时立即报告成功或失败。

非常酷!让我们撤销我们做的最后一个更改,将测试恢复到通过状态。

单元测试组件

测试 Angular 组件比测试简单的管道或服务更复杂。这是因为 Angular 组件与视图相关联,并且通常比服务、过滤器或指令有更多的依赖项。

Angular 测试实用工具

由于它们的复杂性,Angular 引入了使我们可以更容易地测试组件的实用工具。这些测试实用工具包括 TestBed 类(我们之前用来初始化测试的)和 @angular/core/testing 中的几个辅助函数。

TestBed 有一个 createComponent 方法,它返回一个包含多个成员和方法的 ComponentFixture,包括:

  • debugElement:用于调试组件

  • componentInstance:用于访问组件属性和方法

  • nativeElement:用于访问视图的标记和其它 DOM 元素

  • detectChanges:用于触发组件的变更检测周期

ComnponentFixture 还包含用于覆盖组件视图、指令、绑定和提供者的方法。从现在开始,我们将在剩余的测试中使用 TestBed

TestBed 有一个名为 configureTestingModule 的方法,我们可以使用它来设置我们的测试作为一个单独的模块。这意味着我们可以绕过初始引导过程,并在我们的测试文件中编译要测试的组件。我们还可以使用 TestBed 来指定额外的依赖项并识别我们需要的提供者。

根据 Angular 文档(angular.io/guide/testi… beforeEach 中调用 TestBed 方法对于确保每个单独测试前的全新开始非常重要。

在我们的测试中管理依赖项

Angular 中的组件将视图与所有其他内容集成。因此,与任何服务、过滤器或指令相比,组件通常有更多的依赖项。

尽管我们的单元测试专注于组件内部的代码,但我们仍然需要在测试中考虑这些依赖项,否则测试将失败(我们跳过了管道测试的依赖项设置,因为它没有外部依赖)。

处理这些依赖项有两种方法:将它们注入到我们的组件中或为它们创建一个模拟或伪造,我们可以在测试中使用它。如果一个依赖项足够简单,我们只需将其实例注入到测试类中即可。然而,如果一个依赖项非常复杂,特别是如果它有自己的依赖项并且/或者进行远程服务器调用,那么我们应该模拟它。Angular 测试库为我们提供了进行此操作的工具。

我们在本节计划测试的组件是 WorkoutRunner 组件。位于 trainer/src/components/workout-runner/ 中,这是运行特定锻炼的组件。

单元测试 WorkoutRunnerComponent

在这个背景下,让我们开始对 WorkoutRunnerComponent 进行单元测试。

首先,打开 workout-runner-component.spec.ts 并更新导入如下:

import { inject, fakeAsync, async, tick, TestBed, discardPeriodicTasks } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Router } from '@angular/router';
import { of } from 'rxjs/observable/of';

import { WorkoutPlan, ExercisePlan, Exercise } from '../core/model';
import { WorkoutRunnerComponent } from './workout-runner.component';
import { SecondsToTimePipe } from '../shared/seconds-to-time.pipe';
import { WorkoutService } from '../core/workout.service';
import { WorkoutHistoryTrackerService } from '../core/workout-history-tracker.service';

这些导入标识了我们在测试中将使用的测试工具(以及来自 RxJSRouterof 等东西),以及我们的组件所需的类型和依赖项。我们稍后会讨论这些依赖项。其中一个与其他导入不同的导入是导入 @angular/core 中的 NO_ERRORS_SCHEMA。我们将使用这个导入来忽略我们不会测试的组件中的元素。同样,我们稍后会进一步讨论这一点。

关于导入还有一点需要注意,即 @angular/core/testing 是核心模块的一部分,而不是单独的测试模块。这是 Angular 测试导入的常见模式。例如,当我们到达 HTTP 测试时,你会看到我们是从 @angular/http/testing 导入的。

设置组件依赖项

接下来,我们需要确定我们的组件依赖项,并确定我们是否需要注入或模拟它们。如果我们查看 WorkoutRunner 组件的代码,我们会看到有三个依赖项被注入到我们的组件中:

  • WorkoutHistoryTracker:这是一个附加了一些行为的组件。因此,我们肯定想要模拟它。

  • Router:我们也必须模拟这个,以便将WorkoutRunner与应用程序的其余部分隔离开来,并防止我们的测试尝试从WorkoutRunner视图中导航离开。

  • WorkoutService:这是一个我们将用它来发起 HTTP 调用以检索我们的锻炼的服务。我们也将模拟这个服务,因为我们不希望在测试中向外部系统发起调用。

模拟依赖 - 锻炼历史跟踪器

Angular 允许我们使用简单的类以直接的方式模拟我们的依赖。让我们从模拟WorkoutHistoryTracker开始。为此,在导入之后添加以下类:

class MockWorkoutHistoryTracker { 
    startTracking() {} 
    endTracking() {} 
    exerciseComplete() {} 
} 

我们不需要模拟整个WorkoutHistoryTracker类,而只需要模拟WorkoutRunner将要调用的方法。在这种情况下,这些方法包括startTracking()endTracking()exerciseComplete()。我们已经将这些方法设置为空,因为我们不需要从它们那里返回任何内容来测试WorkoutRunner。现在我们可以将这个虚拟实现注入到WorkoutRunner中,无论它在何处寻找WorkoutHistoryTracker

模拟依赖 - 锻炼服务

在第五章“支持服务器数据持久性”中,我们扩展了锻炼服务以进行远程调用以检索填充锻炼的数据。为了对锻炼运行器进行单元测试,我们希望用返回一些静态数据的模拟实现来替换这个调用,这样我们就可以使用这些数据来运行测试。因此,我们将添加第三个模拟类,如下所示:

class MockWorkoutService {

    sampleWorkout = new WorkoutPlan(
         'testworkout',
         'Test Workout',
          40,
          [
              new ExercisePlan(new Exercise( 'exercise1', 'Exercise 1', 'Exercise 1 description', 
                                               '/image1/path', 'audio1/path'), 50),
              new ExercisePlan(new Exercise( 'exercise1', 'Exercise 2', 'Exercise 2 description', 
                                               '/image2/path', 'audio2/path'), 30),
              new ExercisePlan(new Exercise( 'exercise1', 'Exercise 3', 'Exercise 3 description', 
                                               '/image3/path', 'audio3/path'), 20)
          ],
          'This is a test workout'
    );

    getWorkout(name: string) {
        return of(this.sampleWorkout);
    }
    totalWorkoutDuration() {
        return 180;
    }
} 

注意,getWorkout方法返回了一个Observable,正如使用of操作符所示。否则,这个类是自解释的。

模拟依赖 - 路由

就像WorkoutHistoryTrackerWorkoutService一样,我们也将使用模拟来处理我们对 Angular 路由的依赖。但在这里,我们将采取一种稍微不同的方法。我们将把一个 Jasmine spy 分配给我们的模拟上的navigate方法:

export class MockRouter {
    navigate = jasmine.createSpy('navigate');
}

这对于我们的目的来说已经足够了,因为我们只想确保路由器的navigate方法是以适当的路由(finished)作为参数被调用的。Jasmine 的 spy 将允许我们做到这一点,就像我们稍后将要看到的那样。

使用 TestBed 配置我们的测试

现在我们已经处理好了导入和依赖,让我们开始进行测试本身。我们首先添加一个 Jasmine describe函数来包装我们的测试,然后使用let设置两个局部变量:一个用于fixture,另一个用于runner

describe('Workout Runner', () =>{ 
    let fixture:any; 
    let runner:any; 

接下来,我们将添加一个beforeEach函数来设置我们的测试配置:

beforeEach( async(() =>{ 
    TestBed 
        .configureTestingModule({ 
            declarations: [ WorkoutRunnerComponent, SecondsToTimePipe ], 
            providers: [ 
                {provide: Router, useClass: MockRouter}, 
                {provide: WorkoutHistoryTracker ,useClass: 
                MockWorkoutHistoryTracker}, 
                {provide: WorkoutService ,useClass: MockWorkoutService} 
            ], 
            schemas: [ NO_ERRORS_SCHEMA ] 
        }) 
        .compileComponents() 
        .then(() => { 
            fixture = TestBed.createComponent(WorkoutRunnerComponent); 
            runner = fixture.componentInstance; 
        }); 
}));  

beforeEach方法在每个测试之前执行,这意味着我们只需要在我们的测试文件中设置一次。在beforeEach内部,我们添加一个async调用。这是必需的,因为我们正在调用异步的compileComponents方法。

Angular 文档表明,async函数安排测试者的代码在一个特殊的async测试区域内运行,这个区域隐藏了异步执行的机制,就像它传递给it测试时一样。有关更多信息,请参阅https://angular.io/docs/ts/latest/guide/testing.html#!#async-in-before-each。我们将在稍后详细讨论这一点。

让我们按照它们执行的顺序逐一查看每个方法调用。第一个方法是configureTestingModule,它允许我们在测试模块的基本配置上构建,并添加诸如导入、声明(我们将在测试中使用的组件、指令和管道)和提供者等。在我们的测试中,我们首先添加了关于锻炼运行者、我们正在测试的组件以及SecondsToTimePipe的声明:

declarations: [ WorkoutRunnerComponent, SecondsToTimePipe ], 

然后我们为我们的RouterWorkoutHistoryTrackerWorkoutService添加了三个提供者:

providers: [ 
    {provide: Router, useClass: MockRouter}, 
    {provide: WorkoutHistoryTracker ,useClass: MockWorkoutHistoryTracker}, 
    {provide: WorkoutService ,useClass: MockWorkoutService} 
], 

对于这些提供者中的每一个,我们将useClass属性设置为我们的模拟而不是实际组件。现在,在我们的测试中,当WorkoutRunner需要这些组件中的任何一个时,将使用模拟。

下一个配置可能看起来有点神秘:

schemas: [ NO_ERRORS_SCHEMA ] 

这个设置允许我们绕过与我们在组件模板中使用的两个组件(ExerciseDescriptionComponentVideoPlayerComponent)相关的自定义元素可能产生的错误。在这个阶段,我们不想在WorkoutRunnerComponent的测试中测试这些组件。相反,我们应该单独测试它们。然而,需要注意的是,当你使用这个设置时,它将抑制与测试组件模板中的元素和属性相关的所有模式错误;因此,它可能会隐藏你希望看到的其他错误。

当你使用NO_ERRORS_SCHEMA设置测试时,你正在创建一个所谓的浅测试,它不会深入到你正在测试的组件。浅测试允许你减少你正在测试的组件模板中的复杂性,并减少对模拟依赖的需求。

我们测试配置的最后一步是编译和实例化我们的组件:

.compileComponents() 
.then(() => { 
    fixture = TestBed.createComponent(WorkoutRunnerComponent); 
    runner = fixture.componentInstance; 
}); 

如前所述,我们在beforeEach方法中使用了一个async函数,因为当我们调用compileComponents方法时这是必需的。这个方法调用是异步的,我们需要在这里使用它,因为我们的组件有一个外部模板,该模板在templateUrl中指定。此方法编译该外部模板,然后将其内联,以便它可以由createComponent方法(它是同步的)使用来创建我们的组件固定装置。这个组件固定装置反过来包含一个componentInstance-WorkoutRunner。然后我们将fixturecomponentInstance都分配给局部变量。

如前所述,我们正在使用的async函数创建了一个特殊的async测试区域,我们的测试将在其中运行。你会注意到这个函数是从正常的async编程中简化的,并允许我们做一些事情,比如使用.then运算符而不返回一个承诺。

你也可以在单个测试方法内编译和实例化测试组件。但beforeEach方法允许我们为所有测试执行一次操作。

现在我们已经配置了测试,让我们继续对WorkoutRunner进行单元测试。

开始单元测试

从加载数据到过渡到练习、暂停锻炼和运行练习视频,WorkoutRunner有许多方面我们可以进行测试。workout.spec.ts文件(位于trainer/src/components/workout-runner文件夹下)包含了一系列单元测试,覆盖了上述场景。我们将选择其中一些测试并逐一进行。

首先,让我们添加一个测试用例来验证一旦组件加载,锻炼就开始运行:

it('should start the workout', () => { 
    expect(runner.workoutTimeRemaining).toEqual(runner.workoutPlan.totalWorkoutDuration()); 
    expect(runner.workoutPaused).toBeFalsy(); 
});  

此测试断言锻炼的总时长正确,锻炼处于运行状态(即,没有暂停)。

因此,让我们执行测试。它失败了(检查 Karma 控制台)。奇怪!所有依赖项都已正确设置,但第二个expect函数在it块中仍然失败,因为它未定义。

我们需要调试这个测试。

在 Karma 中调试单元测试

在 Karma 中调试单元测试很容易,因为测试是在浏览器中运行的。我们像调试标准 JavaScript 代码一样调试测试。由于我们的 Karma 配置已将我们的 TypeScript 文件映射到我们的 JavaScript 文件,我们可以直接在 TypeScript 中进行调试。

当 Karma 启动时,它会打开一个特定的浏览器窗口来运行测试。要调试 Karma 中的任何测试,我们只需点击浏览器窗口顶部的调试按钮。

Karma 打开了一个窗口,当我们点击调试时又打开了一个窗口;我们也可以使用原始窗口进行测试,但原始窗口连接到 Karma 并执行实时刷新。此外,原始窗口中的脚本文件带有时间戳,每次更新测试时都会改变,因此我们需要再次设置断点来测试。

一旦我们点击调试,就会打开一个新的标签页/窗口,其中加载了所有测试和其他应用程序脚本以供测试。这些是在karma.conf.js文件配置设置期间定义的脚本。

为了调试前面的失败,我们需要在两个位置添加断点。一个应该添加在测试本身内部,另一个应该添加在WorkoutComponent内部,在那里它加载锻炼并将数据分配给适当的局部变量。

执行以下步骤在 Google Chrome 中添加断点:

  1. 通过点击 Karma 启动时加载的窗口上的调试按钮,打开 Karma 调试窗口/标签页。

  2. 按下 F12 键打开开发者控制台。

  3. 前往“源”标签页,你的应用程序的 TypeScript 文件将位于source文件夹中。

  4. 我们现在可以通过单击行号来在所需位置设置断点。这是调试任何脚本的常规机制。在以下突出显示的位置添加断点:

图片

  1. 我们刷新调试页面(我们在点击调试按钮时打开的页面)。workout-runner.ts中的断点从未被触发,导致测试失败。

我们忽略的是,我们试图访问的代码位于workout-runnerstart方法中,而start方法并没有在构造函数中被调用。相反,它在通过在ngOnInit中调用getWorkout方法加载锻炼数据之后,在ngDoCheck中被调用。在你的测试中添加对ngOnInitngDoCheck的调用,如下所示:

        it('should start the workout', () => { 
 runner.ngOnInit(); runner.ngDoCheck(); 
            expect(runner.workoutTimeRemaining).toEqual(
                   runner.workoutPlan.totalWorkoutDuration()); 
            expect(runner.workoutPaused).toBeFalsy(); 
        }); 
  1. 保存更改后,Karma 将再次运行测试。这次它将通过。

随着测试数量的增加,单元测试可能需要我们专注于特定的测试或特定的测试套件。Karma 允许我们通过在现有的it块前添加f来定位一个或多个测试;也就是说,it变成了fit。如果 Karma 发现带有fit的测试,它只会执行这些测试。同样,可以通过在现有的describe块前添加f来定位特定的测试套件:fdescribe。此外,如果你在it块前添加x,使其变为xit,那么该块将被跳过。

让我们继续对组件进行单元测试!

单元测试 WorkoutRunner 继续...

我们还能测试哪些有趣的事情呢?我们可以测试是否开始了第一个练习。我们在刚刚添加的测试之后,将这个测试添加到workout.spec.ts中:

it('should start the first exercise', () => { 
    spyOn(runner, 'startExercise').and.callThrough(); 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    expect(runner.currentExerciseIndex).toEqual(0); 
    expect(runner.startExercise).toHaveBeenCalledWith(
    runner.workoutPlan.exercises[runner.currentExerciseIndex]); 
    expect(runner.currentExercise).toEqual(
    runner.workoutPlan.exercises[0]); 
}); 

这个测试中的第二个expect函数很有趣。它使用了 Jasmine 的一个特性:间谍。间谍可以用来验证方法调用和依赖关系。

使用 Jasmine 间谍来验证方法调用

间谍是一个拦截它所监视的函数每个调用的对象。一旦调用被拦截,它可以选择返回固定数据或将调用传递给实际被调用的函数。它还记录了调用调用详情,这些详情可以在之后的expect中使用,就像我们在前面的测试中所做的那样。

间谍非常强大,可以在单元测试期间以多种方式使用。查看有关间谍的文档jasmine.github.io/2.0/introduction.html#section-Spies,了解更多信息。

第二个expect函数验证了当锻炼开始时调用了startExercise方法(toHaveBeenCalledWith)。它还断言了传递给函数的参数的正确性。第二个expect语句使用间谍断言行为,但我们需要首先设置间谍以使这个断言生效。

在这种情况下,我们使用间谍来模拟对startExercise方法的调用。我们可以使用间谍来确定方法是否被调用以及调用时使用了什么参数,使用 Jasmine 的toHaveBeenCalledWith函数。

查看 Jasmine 文档中的toHaveBeenCalledtoHaveBeenCalledWith函数,了解更多关于这些断言函数的信息。

在这里,方法是以当前Exercise作为参数被调用的。由于之前的expect确认这是第一个练习,这个expect确认启动第一个练习的调用已经执行。

在这里有几个需要注意的事项。首先,你必须小心地将spyOn的设置放在调用ngOnInit之前。否则,当调用startExercise方法时,间谍将不会进行监视,并且方法调用不会被捕获。

第二,由于间谍是一个模拟,我们通常无法在startExercise方法内部进行验证。这是因为该方法本身正在被模拟。这意味着我们实际上无法验证currentExercise属性是否已经设置,因为这是在模拟的方法内部完成的。然而,Jasmine 允许我们使用and.callThrough将间谍链式调用,这意味着除了跟踪方法的调用外,它还会委托到实际实现。这样我们就可以测试currentExercise是否在startExercise方法内部也正确设置了。

使用 Jasmine 间谍验证依赖项

虽然我们只是使用间谍来验证我们类内部的方法调用,但 Jasmine 间谍在模拟外部依赖的调用时也非常有用。但为什么要测试对我们外部依赖的调用呢?毕竟,我们试图将测试限制在组件本身上!

答案是我们模拟一个依赖项以确保依赖项不会对测试中的组件产生不利影响。从单元测试的角度来看,我们仍然需要确保这些依赖项在正确的时间以正确的输入被测试组件调用。在 Jasmine 的世界里,间谍帮助我们断言依赖项是否被正确调用。

如果我们查看WorkoutRunner的实现,每当锻炼开始时,我们都会发出一个包含锻炼详情的消息。外部依赖项WorkoutHistoryTracker订阅了这个消息/事件。所以让我们创建一个间谍并确认当锻炼开始时WorkoutHistoryTracker也开始了。

在前一个it块之后添加这个it块:

it("should start history tracking", inject([WorkoutHistoryTracker], (tracker: WorkoutHistoryTracker) => { 
     spyOn(tracker, 'startTracking'); 
     runner.ngOnInit(); 
     runner.ngDoCheck(); 
     expect(tracker.startTracking).toHaveBeenCalled(); 
 })); 

it块内部,我们添加了对tracker的监视,它是WorkoutHistoryTracker的一个本地实例。然后我们使用这个间谍来验证那个依赖项的startTracking方法已经被调用。简单且表达清晰!

你可能记得我们在这里使用的是MockHistoryWorkoutTracker;它包含一个模拟,一个空的startTracking方法,它不返回任何内容。这是可以的,因为我们不是在测试WorkoutHistoryTracker本身,而是在测试WorkoutRunner对其的调用方法。这个测试展示了能够将模拟与间谍结合使用来完全测试WorkoutRunner的内部工作方式,独立于其依赖项是多么有用。

测试事件发射器

检查WorkoutRunner的代码,我们发现它设置了几个事件发射器,其中一个是用于workoutStarted的如下所示:

@Output() workoutStarted: EventEmitter<WorkoutPlan> = new EventEmitter<WorkoutPlan>(); 

Angular 文档将事件发射器描述为一个输出属性,它触发我们可以通过事件绑定来订阅的事件。在第二章构建我们的第一个应用 - 7 分钟锻炼中,我们详细描述了在 Workout Runner 中使用事件发射器的方式。因此,我们对它们的作用有很好的理解。但我们如何对事件发射器进行单元测试,并确定它们是否以我们期望的方式触发事件呢?

实际上做起来相当简单。如果我们记得事件发射器是一个我们可以订阅的 Observable Subject,我们就会意识到我们可以在单元测试中简单地订阅它。让我们回顾一下验证锻炼开始的那个测试,并向其中添加高亮代码:

it('should start the workout', () => { 
 runner.workoutStarted.subscribe((w: any) => { expect(w).toEqual(runner.workoutPlan); }); 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    expect(runner.workoutTimeRemaining).toEqual(
    runner.workoutPlan.totalWorkoutDuration()); 
    expect(runner.workoutPaused).toBeFalsy(); 
}); 

我们注入了WorkoutService,并添加了对WorkoutStarted事件发射器的订阅和一个期望检查,以查看当事件被触发时属性是否正在发射WorkoutPlan。订阅被放置在ngOnInit之前,因为这个方法会导致workoutStarted事件被触发,我们需要在它发生之前设置我们的订阅。

测试间隔和超时实现

我们面临的一个有趣的挑战是验证锻炼随着时间的流逝而进展。Workout组件使用setInterval来随着时间推进。我们如何在不实际等待的情况下模拟时间呢?

答案是 Angular 测试库的fakeAsync函数,它允许我们以同步的方式运行本应异步执行的代码。它是通过将待执行的函数包裹在fakeAsync区域中实现的。然后它支持在该区域内使用同步计时器,并允许我们使用tick()函数模拟异步时间的流逝。

更多关于fakeAsync的信息,请参阅 Angular 文档中的angular.io/guide/testing#async-test-with-fakeasync.

让我们看看如何使用fakeAsync函数来测试我们代码中的超时和间隔实现。将以下测试添加到workout-runner.spec.ts中:

    it('should increase current exercise duration with time', fakeAsync(() => {
        runner.ngOnInit();
        runner.ngDoCheck();
        expect(runner.exerciseRunningDuration).toBe(0);
        tick(1000);
        expect(runner.exerciseRunningDuration).toBe(1);
        tick(1000);
        expect(runner.exerciseRunningDuration).toBe(2);
        tick(8000);
        expect(runner.exerciseRunningDuration).toBe(10);
        discardPeriodicTasks();
    })); 

除了注入WorkoutRunner之外,我们首先使用fakeAsync包装测试。然后我们调用WorkoutRunnerngOnInit方法。这将在WorkoutRunner内部启动练习的计时器。然后在测试中,我们使用设置在不同时间段的tick()函数来测试练习计时器的操作,并确保它以我们期望的持续时间继续运行。使用tick()允许我们快速前进通过代码,避免异步运行代码时需要等待几秒钟才能完成练习。

最后,我们调用discardPeriodicTasks()。这是 Angular 测试实用工具之一,它可以与fakeAsync一起使用来清除任务队列中可能存在的任何挂起的计时器。

更多关于这些和其他 Angular 测试实用工具的信息可以在angular.io/guide/testing#testing-utility-apis找到。

让我们尝试另一个类似的测试。我们想要确保WorkoutRunner能够正确地从一项练习过渡到下一项练习。请将以下测试添加到workout-runner.ts中:

it("should transition to next exercise on one exercise complete", fakeAsync(() => { 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    let exerciseDuration = runner.workoutPlan.exercises[0].duration; 
    TestHelper.advanceWorkout(exerciseDuration); 
    expect(runner.currentExercise.exercise.name).toBe('rest'); 
    expect(runner.currentExercise.duration).toBe(
    runner.workoutPlan.restBetweenExercise); 
    discardPeriodicTasks();
})); 

我们再次使用fakeAsync包装测试并调用runner.ngOnInit来启动计时器。然后我们获取第一项练习的持续时间,并在随后的TestHelper方法中使用tick()函数将计时器推进超过该练习持续时间的一秒。

class TestHelper {
    static advanceWorkout(duration: number) {
        for (let i = 0; i <= duration; i++) {tick(1000);
    }
}

接下来,我们测试期望我们现在处于rest练习中,因此已经从第一项练习过渡过来。

测试锻炼暂停和恢复

当我们暂停锻炼时,它应该停止,时间计数器不应中断。为了检查这一点,请添加以下时间测试:

it("should not update workoutTimeRemaining for paused workout on 
    interval lapse", fakeAsync(() => { 
    runner.ngOnInit(); 
    runner.ngDoCheck(); 
    expect(runner.workoutPaused).toBeFalsy(); 
    tick(1000); 
    expect(runner.workoutTimeRemaining).toBe(
    runner.workoutPlan.totalWorkoutDuration() - 1); 
    runner.pause(); 
    expect(runner.workoutPaused).toBe(true); 
    tick(1000); 
    expect(runner.workoutTimeRemaining).toBe(
    runner.workoutPlan.totalWorkoutDuration() - 1); 
    discardPeriodicTasks();
})); 

测试从验证锻炼状态未暂停开始,将时间推进一秒,暂停它,然后验证在暂停后workoutTimeRemaining的时间没有变化。

单元测试服务

单元测试服务与单元测试组件没有太大区别。一旦我们掌握了如何设置组件及其依赖项(主要使用模拟),将这种学习应用到测试服务上就变成了一件例行公事。通常情况下,挑战在于设置服务的依赖项,以便能够有效地进行测试。

对于进行远程请求(使用httpjsonp)的服务来说,情况略有不同。在我们可以单独测试此类服务之前,需要进行一些设置。

我们将针对WorkoutService编写一些单元测试。由于此服务会向远程请求加载锻炼数据,我们将探讨如何使用模拟 HTTP 后端测试此类服务。Angular 为我们提供了HttpTestingController来执行此操作。

使用 HttpTestingController 模拟 HTTP 请求/响应

当测试进行远程请求的服务(或者实际上,任何其他 Angular 构造)时,我们显然不希望实际向后端发送请求来检查行为。这甚至都不符合单元测试的标准。后端交互只需要被模拟。Angular 正好提供了这样的功能。使用 HttpTestingController,我们拦截 HTTP 请求,模拟来自服务器的实际响应,并断言端点调用。

打开 workout-service.spec.ts 并在文件顶部添加以下导入语句:

import { TestBed, inject, async, fakeAsync } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpClient, HttpResponse } from '@angular/common/http';

import { WorkoutService } from './workout.service';
import { WorkoutPlan, Exercise } from './model';

除了从 core/testing 模块导入之外,我们还从 http/testing 模块导入了 HttpClientTestingModuleHttpTestingController。我们还导入了我们将要测试的 WorkoutServiceWorkoutPlan

一旦我们设置了导入,我们将开始使用 Jasmine 的 describe 语句创建测试,该语句封装了我们的测试,并设置了一些局部变量:

describe('Workout Service', () => { 
  const collectionUrl = '...[mongo connnection url]...';
  const apiKey = '...[mongo key]...';
  const params = '?apiKey=' + apiKey;
  let httpClient: HttpClient;
  let httpTestingController: HttpTestingController;
  let workoutService: WorkoutService;

除了为 HttpClientHttpTestingControllerWorkoutService 创建局部变量之外,你还会注意到我们为我们的 MongoDB 连接设置了局部变量。为了明确,我们不是设置这些变量以向 MongoDB 发送远程调用,而是为了测试连接属性是否被正确设置。

下一步是设置测试的提供者和依赖注入。为了处理提供者,将以下内容添加到测试文件中:

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [ WorkoutService ],
    });
    httpClient = TestBed.get(HttpClient);
    httpTestingController = TestBed.get(HttpTestingController);
    workoutService = TestBed.get(WorkoutService);
  });

首先,我们调用 TestBed.configureTestingModule 来导入 HttpClientTestingModule 并添加 WorkoutService。根据 Angular 文档(angular.io/api/common/http/testing/HttpClientTestingModule),HttpClientTestingModuleHttpClient 设置为使用 HttpClientTestingBackend 作为 HttpBackend。这里的好处是,这种设置完全隐藏在我们的测试设置中,所以我们不需要编写代码来连接它。

接下来,我们使用 TestBed.get 方法填充我们的局部变量—httpClienthttpTestingControllerworkoutService。我们还将添加以下 afterEach 方法以确保在每个测试完成后没有更多挂起的请求:

 afterEach(() => {
   httpTestingController.verify();
 });

在设置好这一切之后,我们现在可以创建针对 WorkoutService 的测试,以避免我们进行远程调用。我们将从一个简单的测试开始,确保 workoutService 被加载:

it('should be created', inject([WorkoutService], (service: WorkoutService) => {
   expect(service).toBeTruthy();
 }));

虽然这个测试可能看起来微不足道,但将其放在这里很重要,因为它作为一个检查,确保我们已经正确设置了配置。

接下来,我们将添加以下测试以确保我们能够在实例化 WorkoutService 时注入 HttpClient

it('can instantiate service with "new"', inject([HttpClient], (http: HttpClient) => {
    expect(http).not.toBeNull('http should be provided');
    const service = new WorkoutService(http);
    expect(service instanceof WorkoutService).toBe(true, 'new service should be ok');
}));

现在,我们将转向测试 workout-service 中的几个方法。首先,我们将确保当调用 getWorkouts 方法时,它返回所有锻炼项目。为此,添加以下测试:

  it('should should return all workout plans', () => {
    let expectedWorkouts: WorkoutPlan[];
    let actualWorkouts: WorkoutPlan[];

    expectedWorkouts = [
      { name: 'Workout1', title: 'workout1' },
      { name: 'Workout2', title: 'workout2' },
      { name: 'Workout3', title: 'workout3' },
      { name: 'Workout4', title: 'workout4' }
    ] as WorkoutPlan[];

     workoutService.getWorkouts().subscribe(
      workouts => actualWorkouts = workouts,
      fail
    );
    const req = httpTestingController.expectOne(workoutService.collectionsUrl + '/workouts' + params );
    expect(req.request.method).toEqual('GET');
    req.flush(expectedWorkouts);
    expect(actualWorkouts === expectedWorkouts);
  });

我们将首先声明两个WorkoutPlans数组——expectedWorkoutsactualWorkouts。然后我们将expectedWorkouts填充为四个WorkoutPlans。因为我们正在测试WorkoutPlans的检索而不是其内容,所以我们创建了这些最小的工作计划。

由于Http模块返回RxJS Observables,我们接下来使用订阅这些 Observables 的模式。你应该已经习惯了从第五章支持服务器数据持久性中我们关于 Observables 的介绍中看到这个模式。注意,我们使用fail作为第二个参数,如果订阅 Observable 存在问题,这将导致测试失败。

接下来,我们在HttpTestingController上调用一个名为expectOne的方法,并传递我们的请求 URL。根据 Angular 文档(angular.io/api/common/http/testing/HttpTestingController#expectone),此方法执行两个操作:它期望一个请求已被发出,该请求与方法调用中提供的 URL 匹配,并返回一个模拟请求。在下一行中,我们确保模拟请求是一个 HTTP GET。最后,我们使用expectedWorkouts刷新请求,并确认actualWorkouts等于expectedWorkouts

我们将遵循相同的模式来构建额外的测试,以确认我们能够做到以下事情:

  • 返回一个具有特定名称的workout计划

  • getWorkout方法中正确映射exercises

你可以在checkpoint 7.2的代码中查看这些测试。但要注意的一点是,在这两个测试中,我们都在测试两个 HTTP 调用。例如,以下是这两个测试中的第二个测试的代码:

const req1 = httpTestingController.expectOne(workoutService.collectionsUrl + '/exercises' + params);
expect(req1.request.method).toEqual('GET');
req1.flush(allExercises);

const req2 = httpTestingController.expectOne(workoutService.collectionsUrl + '/workouts/Workout1' + params);
expect(req2.request.method).toEqual('GET');
req2.flush(expectedWorkout);

这可能一开始看起来有些令人困惑,直到我们意识到,实际上,使用getWorkout方法,我们实际上进行了两个Http调用:一个用于检索workout,另一个用于检索所有exercises。如您从第五章支持服务器数据持久性中回忆的那样,我们这样做是为了创建每个包含在workout中的exercise的更完整描述。

有了这些,我们就完成了对服务的测试。

接下来,我们需要学习如何测试指令。下一节将专门介绍指令测试的挑战以及如何克服它们。

单元测试指令

到目前为止,我们测试过的所有其他 Angular 构建项都不涉及任何 UI 交互。但正如我们所知,指令是另一回事。指令全部关于增强组件的视图和扩展 HTML 元素的行为。在测试指令时,我们不能忽视 UI 连接,因此指令测试可能并不严格符合单元测试的定义。

指令测试的好处是它的设置过程不像服务或组件那么复杂。在单元测试指令时应该遵循以下模式:

  1. 取一个包含指令标记的 HTML 片段

  2. 编译并将其链接到一个模拟组件

  3. 验证生成的 HTML 是否具有所需的属性

  4. 验证指令创建的更改是否改变了状态

TestBed 类

如前所述,Angular 提供了 TestBed 类来简化此类 UI 测试。我们可以使用它来深入查看组件视图中的标记,并检查由事件触发的 DOM 变化。有了这个工具,让我们开始对指令进行测试。在本节中,我们将测试 remoteValidator

这将是回顾我们在上一章中构建的指令的好时机。同时,保留我们将要在以下部分创建的测试的代码。

测试远程验证器

让我们从单元测试 remoteValidatorDirective 开始。为了刷新我们的记忆,remoteValidatorDirective 通过调用返回一个 promise 的组件方法来验证输入与远程规则。如果 promise 成功解析,则验证通过;否则,验证失败。[validateFunction] 属性提供了 DOM 和组件中检查重复的方法之间的链接。

与我们的其他测试文件类似,我们在共享文件夹中有一个 remote-validator.directive.spec.ts 文件。请参考 checkpoint 7.2 中的文件以获取导入,我们在此处不会涉及。

在导入语句下方,添加以下组件定义:

@Component({
    template: `
      <form>
      <input type="text" name="workoutName"
      id="workout-name" [(ngModel)]="workoutName"
      abeRemoteValidator="workoutname" [validateFunction]="validateWorkoutName">
      </form>
    `
}) 
export class TestComponent { 
    workoutName: string; 

    constructor() { 
        this.workoutName = '7MinWorkout'; 
    } 
    validateWorkoutName = (name: string): Promise<boolean> => { 
        return Promise.resolve(false); 
    } 
} 

这个组件看起来很像我们在其他测试中设置的组件,用于模拟依赖项。然而,在这里,它起着略微不同的作用;它充当我们将要测试的指令的主容器。使用这个最小组件,我们可以避免加载此指令的实际宿主组件,即 Workout 组件。

这里需要注意的一点是,我们为 validateWorkoutName 方法设置了一个方法,它将由我们的指令调用。它本质上是一个返回已解析的 Promisefalse 的存根。记住,我们并不关心这个方法如何处理其验证,而是要验证指令调用了它,并返回了正确的结果,即 truefalse

接下来,我们通过添加以下代码来设置测试套件的 describe 语句,该代码将 RemoteValidatorDirective 注入到我们的测试中:

describe('RemoteValidator', () => { 
    let fixture: any; 
    let comp: any; 
    let debug: any; 
    let input: any; 

    beforeEach(async(() => { 
        TestBed.configureTestingModule({ 
            imports: [ FormsModule ], 
            declarations: [ TestComponent, RemoteValidatorDirective ] 
        }); 
        fixture = TestBed.createComponent(TestComponent); 
        comp = fixture.componentInstance; 
        debug = fixture.debugElement; 
        input = debug.query(By.css('[name=workoutName]')); 
    }));  

如您所见,我们正在为 fixture、其 componentInstancedebugElement 设置局部变量。我们还在 debugElement 上使用 by.css(我们将在端到端测试中了解更多)以及查询方法来从我们的组件中提取 workoutName 输入。我们将使用这些来深入查看指令中渲染的 HTML。

现在,我们已经准备好编写我们的单个测试。首先,我们将编写一个测试来确认我们已经能够加载 RemoteValidatorDirective。因此,添加以下代码:

it("should load the directive without error", fakeAsync(() => {
    expect(input.attributes.a2beRemoteValidator).toBe('workoutname',  'remote validator directive should be loaded.')
}));

这个测试有趣的地方在于,使用debugElement,我们已经能够深入挖掘我们宿主组件中输入标签的属性,并找到我们的验证器,确认它确实已经被加载。同时注意fakeAsync的使用,我们在单元测试中讨论过。使用它使得我们能够以同步的方式编写测试,并避免在尝试管理宿主组件的异步渲染时可能出现的复杂性。接下来,我们将编写两个测试来确认我们的验证器是否正常工作。第一个测试将确保如果远程验证失败(即找到与我们所使用的相同名称的锻炼),将创建一个错误。为此测试添加以下代码:

    it('should create error if remote validation fails', fakeAsync(() => {
        spyOn(comp, 'validateWorkoutName').and.callThrough();
        fixture.detectChanges();
        input.nativeElement.value = '6MinWorkout';
        tick();

        const form: NgForm = debug.children[0].injector.get(NgForm);
        const control = form.control.get('workoutName');

        expect(comp.validateWorkoutName).toHaveBeenCalled();
        expect(control.hasError('workoutname')).toBe(true);
        expect(control.valid).toBe(false);
        expect(form.valid).toEqual(false);
        expect(form.control.valid).toEqual(false);
        expect(form.control.hasError('workoutname', ['workoutName'])).toEqual(true);
    }));

再次,我们使用fakeAsync来消除我们可能面临的与我们的remoteValidatorDirective渲染和执行相关的异步行为带来的挑战。接下来,我们添加一个间谍来跟踪validateWorkoutName方法的调用。我们还设置间谍调用我们的方法,因为在这种情况下,我们期望它返回false。间谍被用来验证我们的方法确实被调用。接下来,我们设置fixture.detectChanges,这会触发一个变更检测周期。然后我们设置输入的值并调用 tick,这将,我们希望,触发我们期望从远程验证器那里得到的响应。然后我们使用从调试元素的子元素数组中可用的注入器获取包含我们的输入标签的表单。从那里,我们提取我们的输入框的表单控件。然后我们运行几个期望,确认错误已经添加到我们的控件和表单中,并且它们现在都处于无效状态。下一个测试是这个测试的镜像相反,并检查一个积极的:

    it('should not create error if remote validation succeeds', fakeAsync(() => {
        spyOn(comp, 'validateWorkoutName').and.returnValue(Promise.resolve(true));
        fixture.detectChanges();
        input.nativeElement.value = '6MinWorkout';
        tick();

        const form: NgForm = debug.children[0].injector.get(NgForm);
        const control = form.control.get('workoutName');

        expect(comp.validateWorkoutName).toHaveBeenCalled();
        expect(control.hasError('workoutname')).toBe(false);
        expect(control.valid).toBe(true);
        expect(form.control.valid).toEqual(true);
        expect(form.valid).toEqual(true);
        expect(form.control.hasError('workoutname', ['workoutName'])).toEqual(false);
    }));

除了改变期望之外,我们从上一个测试中做出的唯一改变是设置我们的间谍返回true的值。对remoteValidatorDirective进行单元测试展示了TestBed工具在测试我们的 UI 及其相关元素和行为时的强大功能。

开始进行端到端测试

如果底层框架支持,自动化的**端到端(E2E)**测试是一项无价的资产。随着应用程序规模的扩大,自动化的端到端测试可以节省大量的手动工作。

没有自动化,确保应用程序功能正常只是一场永无止境的战斗。然而,记住在一个端到端(E2E)的设置中,并非所有事情都可以自动化;自动化可能需要大量的努力。经过尽职调查,我们可以减少大量的手动工作,但并非所有。

基于用户界面状态对基于 Web 的应用程序进行端到端测试的过程是在真实浏览器中运行应用程序,并根据用户界面状态断言应用程序的行为。这就是实际用户进行测试的方式。

浏览器自动化是这里的关键,现代浏览器在支持自动化方面已经变得更加智能和强大。Selenium 浏览器自动化工具是当前最受欢迎的选项。Selenium 拥有 WebDriver API(www.w3.org/TR/webdriver/),它允许我们通过现代浏览器原生支持的自动化 API 来控制浏览器。

提出 Selenium WebDriver 的原因在于,Angular 端到端测试框架/运行器Protractor也使用了WebDriverJS,这是 WebDriver 在 Node 上的 JavaScript 绑定。这些语言绑定(如前面的 JavaScript 绑定)允许我们使用我们选择的语言的自动化 API。

在我们开始为我们的应用程序编写一些集成测试之前,让我们先讨论一下 Protractor。

介绍 Protractor

Protractor是 Angular 端到端测试的默认测试运行器。Protractor 使用 Selenium WebDriver 来控制浏览器并模拟用户操作。

一个典型的 Protractor 设置包含以下组件:

  • 测试运行器(Protractor)

  • Selenium 服务器

  • 浏览器

我们使用 Jasmine 编写测试,并使用 Protractor(它是 WebDriverJS 的包装器)公开的一些对象来控制浏览器。

当这些测试运行时,Protractor 会向 Selenium 服务器发送命令。这种交互主要发生在 HTTP 上。

Selenium 服务器反过来,使用WebDriver Wire Protocol与浏览器通信,并且内部浏览器使用浏览器驱动程序(例如 Chrome 中的ChromeDriver)来解释操作命令。

理解这种通信的技术细节并不那么重要,但我们应该了解端到端测试的设置。查看 Protractor 文档中的文章angular.github.io/protractor/#/infrastructure,了解更多关于这个流程的信息。

在使用 Protractor 时,还有一个重要的事情需要意识到,那就是与浏览器或浏览器控制流的整体交互本质上是异步的,基于 Promise 的。任何 HTML 元素操作,无论是sendKeysgetTextclicksubmit还是其他任何操作,都不会在调用时执行;相反,该操作会被排队到控制流队列中。正因为如此,每个操作语句的返回值都是一个 Promise,当操作完成时得到解决。

为了处理 Jasmine 测试中的这种异步性,Protractor 对 Jasmine 进行了修补,因此像这样的断言是有效的:

expect(element(by.id("start")).getText()).toBe("Select Workout"); 

尽管getText函数返回一个 Promise 而不是元素内容,它们仍然可以正常工作。

在我们对 Protractor 的工作原理有了基本的了解之后,让我们设置 Protractor 进行端到端测试。

设置 Protractor 进行端到端测试

Angular CLI 已经为我们设置了项目,以便我们可以使用 Protractor。该设置的配置可以在 trainer 文件夹中的 protractor.config.js 文件中找到。在大多数情况下,你应该能够使用这些配置而不做任何更改来运行你的端到端测试。然而,我们在该配置文件中做了一项更改。我们将 defaultTimeoutInterval 在该文件中扩展到 60000 毫秒,以便给运行锻炼的测试更多的时间来完成:

const { SpecReporter } = require('jasmine-spec-reporter');

exports.config = {
    . . .
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 60000,
    print: function() {}
  },
    . . .
};

Protractor 网站上的配置文件文档(github.com/angular/protractor/blob/master/lib/config.ts)包含有关其他支持的配置的详细信息。

这就足够开始使用 Protractor 进行测试了。为了运行我们的测试,我们只需在 trainer 文件夹中执行以下命令:

ng e2e

现在,让我们开始编写一些端到端测试。

为应用编写端到端测试

让我们从简单的方式开始,测试我们的应用启动页面(#/start)。这个页面包含一些静态内容,一个带有搜索功能的锻炼列表部分,以及通过点击任何锻炼文件来开始锻炼的能力。

我们所有的端到端测试都将添加到 trainer 文件夹下的 e2e 文件夹中。

打开位于 trainer 文件夹下的 e2e 文件夹中的 app.e2e-spec.ts 文件,其中包含以下代码:

import { AppPage } from './app.po';

describe('Personal Trainer App', () => {
  let page: AppPage;

  beforeEach(() => {
    page = new AppPage();
  });

  it('should display welcome message', () => {
    page.navigateTo();
    expect(page.getParagraphText()).toEqual('Ready for a Workout?');
  });
})

让我们一步步走过这个简单的测试。

最有趣的部分是页面顶部的导入——import { AppPage } from './app.po';。这指的是同一目录下包含所谓的页面对象的文件。这个页面对象包含以下内容:

import { browser, by, element } from 'protractor';

export class AppPage {
  navigateTo() {
    return browser.get('/');
  }

  getParagraphText() {
    return element(by.css('abe-root h1')).getText();
  }
}

页面对象的使用允许我们简化测试中的代码,使其更易于阅读。因此,我们不是直接在我们的测试中调用 browser.get ('/'),而是从我们的页面对象中调用 navigateTo() 方法。

在我们的页面对象中提到的浏览器对象是 Protractor 提供的全球对象,它用于控制浏览器级别的操作。在底层,它只是 WebDriver 的包装器。browser.get("") 方法在测试开始之前每次都会导航到浏览器以启动应用页面。

对于 getParagraphText() 方法也是如此——它允许我们在测试中调用该方法,并在屏幕上查找一些文本,而无需确定该文本将在页面上出现的确切位置。随着我们进入更复杂的端到端测试,我们将更详细地讨论页面对象。在我们的页面对象中,getParagraphText() 也使用了两个新的全局变量,elementby,这些变量由 Protractor 提供:

  • element:这个函数返回一个 ElementFinder 对象。ElementFinder 的主要任务是交互所选元素。我们将使用 element 函数在我们的测试中广泛选择 ElementFinder

请参考http://www.protractortest.org/#/locators#actions文档了解有关元素操作 API 支持的更多信息。例如,getText()函数实际上是在WebElement上定义的,但总是通过ElementFinder来访问。正如文档所建议的,在大多数情况下可以将ElementFinder视为WebElement。更多详细信息,您可以参考http://www.protractortest.org/#/locators#behind-the-scenes-elementfinders-versus-webelements

  • by:此对象用于定位元素。它具有创建locators的函数。在先前的测试中,创建了一个定位器来搜索具有等于abe-root h1的 CSS 标签的元素。如果您熟悉 CSS 选择器,您将知道这标识了我们正在寻找的h1标签,它位于自定义元素abe-root内部。有几种定位器可以用来搜索特定元素,包括按类、按 ID 和按 CSS。有关支持的定位器的详细信息,请参阅 Protractor 文档中的定位器部分angular.github.io/protractor/#/locators

只是为了重申我们之前讨论的内容,页面对象中的getText()并不返回实际的文本,而是一个 Promise;我们仍然可以对文本值进行断言。

回到实际的测试,它使用页面对象中的方法来验证某些内容(“Ready for a Workout?”)是否出现在页面上。

这个简单的测试突出了 Protractor 的另一个显著特点。它自动检测 Angular 应用何时加载以及数据何时可用于测试。在标准 E2E 测试场景中,不需要使用timeouts等丑陋的技巧来延迟测试。

记住,这是一个SPA;页面不会进行全页刷新,因此确定页面何时加载以及为 AJAX 调用渲染的数据何时可用并不简单。Protractor 使这一切成为可能。

在尝试评估页面是否可用于测试时,Protractor 可能会超时。如果您在使用 Protractor 时遇到超时错误,这篇来自 Protractor 文档的文章可能会对调试此类问题非常有帮助(www.protractortest.org/#/timeouts)。

设置 E2E 测试的后端数据

不论我们使用哪个 E2E 框架进行测试,设置 E2E 测试的后端数据都是一个挑战。最终目标是针对某些数据断言应用程序的行为,除非数据是固定的,否则我们无法验证涉及获取或设置数据的行为。

设置 E2E 测试数据的一个方法是为 E2E 测试创建一个专门的数据存储库,并包含一些种子数据。一旦 E2E 测试完成,数据存储库可以重置到原始状态,以便未来的测试。对于Personal Trainer,我们可以在 MongoLab 中创建一个专门用于 E2E 测试的新数据库。

这可能看起来需要很多努力,但这是必要的。谁说端到端测试容易!实际上,即使我们进行手动测试,这个挑战也存在。对于一个真正的应用程序,我们总是必须为每个环境设置数据存储/数据库,无论是devtest还是production

在这种情况下,我们将继续使用现有的后端,但添加另一个我们将用于测试的锻炼。将此锻炼命名为1minworkout,并给它一个标题1 Minute Workout。添加两个练习到锻炼中:跳绳和墙坐。将每个练习的持续时间设置为 15 秒,休息时间为 1 秒。

我们故意将新的锻炼保持简短,这样我们就可以在 Protractor 提供的正常超时时间内完成此锻炼的端到端测试。

更多端到端测试

让我们回到测试首页上的锻炼搜索功能。随着1 Minute Workout的添加,我们现在有两个锻炼,我们可以对这些进行断言。

如果你已经在后端添加了其他锻炼,只需相应地调整此测试中的数字。

workout-runner.spec.ts中现有测试之后添加此测试:

it('should search workout with specific name.', () => {
    const filteredWorkouts = element.all(by.css('.workout.tile'));
    expect(filteredWorkouts.count()).toEqual(5);

    const searchInput = element(by.css('.form-control'));
    searchInput.sendKeys('1 Minute Workout');

    expect(filteredWorkouts.count()).toEqual(1);
    expect(filteredWorkouts.first().element(by.css('.title')).getText()).toBe('1 Minute Workout');
});

测试使用ElementFinderLocator API在页面上查找元素。检查测试的第二行。我们正在使用element.all函数和by.css定位器对屏幕上使用.workout.tileCSS 类的所有元素进行多元素匹配。这给我们一个锻炼列表,针对这个列表,下一行断言元素个数为 3。

测试随后使用element函数和by.css定位器获取搜索输入,以对使用.form-contolCSS 类的元素进行单元素匹配。然后我们使用sendKeys函数来模拟搜索输入中的数据输入。

最后两个期望操作检查列表中的元素数量,搜索后应为 1。它们还检查是否根据div标签使用titleCSS 类正确过滤了正确的锻炼,该标签是包含我们的锻炼的元素的子元素。这个最后的期望语句突出了我们可以如何链式过滤元素并获取 HTML 中的子元素。

我们应该添加与首页相关的一个附加测试。它测试从首页导航到锻炼运行器屏幕。为此测试添加以下代码:

it('should navigate to workout runner.', () => {
    const filteredWorkouts = element.all(by.css('.workout.tile'));
    filteredWorkouts.first().click();
    expect(browser.getCurrentUrl()).toContain('/workout/1minworkout');
}) 

此测试使用click函数来模拟点击锻炼瓷砖,然后我们使用browser.getCurrentUrl函数来确认导航是否正确。

再次运行测试(protractor tests/protractor.conf.js),再次观察浏览器自动化的魔力,因为测试一个接一个地运行。

我们能否自动化Workout Runner的端到端测试?嗯,我们可以试试。

测试 Workout Runner

测试 Workout Runner 的主要挑战之一是所有内容都是时间依赖的。在单元测试中,我们至少能够模拟间隔,但现在不行了。测试锻炼转换和工作完成确实很困难。

然而,在我们解决这个问题或尝试找到一个可接受的解决方案之前,让我们暂时偏离一下,来了解一下管理端到端测试的一个重要技术:页面对象!

使用页面对象来管理端到端测试

我们之前提到了页面对象的概念。页面对象的简单概念是将页面元素的表示封装到一个对象中,这样我们就不需要在端到端测试代码中充斥着ElementFinderlocators。如果任何页面元素移动,我们只需要修复页面对象。

这里是我们如何表示我们的 Workout Runner 页面:

import { browser, by, element } from 'protractor';

export class WorkoutRunnerPage {
  pauseResume: any;
  playButton: any;
  pauseButton: any;
  exerciseTitle: any;
  exerciseDescription: any;
  exerciseTimeRemaining; any;

  constructor() {
      this.pauseResume = element(by.id('pause-overlay'));
      this.playButton = element.all(by.css('.ion-md-play'));
      this.pauseButton = element.all(by.css('.ion-md-pause'));
      this.exerciseTitle = element(by.id('exercise-pane')).element(by.tagName('h1')).getText();
      this.exerciseDescription = element.all(by.className('card-text')).first().getText();
      this.exerciseTimeRemaining = element(by.id('exercise-pane')).all(by.tagName('h4')).first().getText();
  }
}

这个页面对象现在封装了我们想要测试的许多元素。通过在一个地方组织元素选择代码,我们提高了端到端测试的可读性和可维护性。

现在将 Workout Runner 页面对象添加到测试文件顶部。我们将在针对锻炼运行者的测试中使用它。添加以下新的 describe 块,包含我们的第一个锻炼运行者测试:

describe('Workout Runner page', () => {
    beforeEach(() => {
        browser.get('/workout/1minworkout');
    });

    it('should load workout data', () => {
        browser.waitForAngularEnabled(false);
        const page = new WorkoutRunnerPage();
        page.pauseResume.click();
        expect(page.exerciseTitle).toBe('Jumping Jacks');
        expect(page.exerciseDescription)
          .toBe('A jumping jack or star jump, also called side-straddle hop is a physical jumping exercise.');
    });

测试验证了锻炼已加载并且显示了正确的数据。我们充分利用了我们之前定义的页面对象。运行测试并验证它是否通过。

让我们回到基于intervaltimeout测试代码的挑战。让我们添加一个测试来确认当按下暂停按钮时屏幕上的点击事件:

it('should pause workout when paused button clicked', () => {
    const page = new WorkoutRunnerPage();
    let timeRemaining;
    browser.waitForAngularEnabled(false);

    page.pauseResume.click();
    expect(page.playButton.count()).toBe(1);
    expect(page.pauseButton.count()).toBe(0);

    page.exerciseTimeRemaining.then((time) => {
        timeRemaining = time;
        browser.sleep(3000);
    });
    page.exerciseTimeRemaining.then((time) => {
        expect(page.exerciseTimeRemaining).toBe(timeRemaining);
    });
});

这里有趣的是,我们在承诺中使用browser.sleep函数来验证在按钮点击前后剩余的练习时间是否相同。我们再次使用我们的WorkoutRunner页面对象来使测试更加可读和易懂。

现在是时候总结本章内容并总结我们的学习了。

摘要

我们不需要重复说明单元测试和端到端测试对任何应用程序的重要性。Angular 框架的设计方式使得测试 Angular 应用程序变得容易。在本章中,我们介绍了如何使用针对 Angular 的库和框架编写单元测试和端到端测试。

对于单元测试,我们使用 Jasmine 编写测试,并使用 Karma 执行它们。我们测试了来自Personal Trainer的管道、组件、服务和指令。在这个过程中,我们了解了测试这些类型的挑战和所使用的技巧。

对于端到端测试,我们选择的是 Protractor 框架。我们仍然使用 Jasmine 编写测试,但这次测试运行者是 Protractor。我们学习了如何使用 Selenium WebDriver 自动化端到端测试,就像我们为StartWorkout Runner页面进行了一些场景测试一样。

如果你已经到达这个阶段,你正越来越接近成为一名熟练的 Angular 开发者。下一章通过更多使用 Angular 构建的实用场景和实现来加强这一点。我们将在本书的最后一章涉及一些重要概念;这些包括多语言支持、身份验证和授权、通信模式、性能优化以及一些其他内容。你当然不希望错过它们!