文档结构  
翻译进度:已翻译     翻译赏金:10 元 (?)    ¥ 我要打赏

本文是 SitePoint Angular 2+ 教程 的第 3 部分,本教程教大家使用 Angular CLI 创建 CRUD App。

  1. 第 0 部分 — 终极 Angular CLI 参考指南
  2. 第 1 部分 — 创建第 1 版的 Todo 应用并运行
  3. t第 2 部分— 创建独立组件来显示任务(Todo)列表以及单个任务
  4. 第 3 部分— 更新 Todo 服务使其与 REST API 通信
  5. Part 4— 使用 Angular 路由来解析数据
  6. Part 5— 添加身份验证来保护私有内容

作者的其它文章

第 1 段(可获 1.3 积分)

我们在第1部分中学习了如何创建 Todo 应用、运行它以及将其发布在 Github Pages 上。这很好,但不爽的是整个应用都被塞在一个组件中。

我们在第2部分体验了模块化组件架构并学习了如何把一个组件拆分成树形结构的多个小组件,这样更容易理解、重用和维护。

在这一部分中,我们会更新这个应用程序,以便与 REST API 后端通信。

不必先去看第1部分和第2部分,第3部分是可以独立阅读的。你可以从我们的库取出第2部分产生的代码,以此作为起点。下面对此进行详细说明。

 

第 2 段(可获 1.64 积分)

快速回顾

第2部分结束的时候我们的应用结构如下所示:

Application Architecture

这时候 TodoDataService 把所有数据保存在内存中。在这第三部分中,我们会更新应用与后端的 REST API 通信,通过这种方式来代替保存数据的方案。

我们会:

  • 创建一个模拟的后端 REST API
  • 把 API URL 保存为环境变量
  • 创建 ApiService 来与 REST API 通信
  • 更新 TodoDataService 来使用新的 ApiService
  • 更新 AppComponent 处理异步 API 调用
  • 创建 ApiMockService 来避免在单元测试中使用真实的 HTTP
第 3 段(可获 1.11 积分)

Application Architecture

文章的最后你将理解:

  • 如何使用环境变量来存储应用设置
  • 如何使用 Angular HTTP 客户端来执行 HTTP 请求
  • 如何处理 Angular HTTP 客户端返回的 Observables
  • 如何对模拟 HTTP 调用来避免在单元测试中发起真正的 HTTP 请求

现在开始吧!

启动和运行

确保你安装你最新版本的 Angular CLI ,如果没有的话可以使用如下方式安装:

npm install -g @angular/cli@latest

安装前需要删除老版本的 Angular CLI,删除方法:

第 4 段(可获 1.34 积分)
npm uninstall -g @angular/cli angular-cli
npm cache clean
npm install -g @angular/cli@latest

之后,您将需要从第二部分中获得代码的副本。。 这可以在https://github.com/sitepoint-editors/angular-todo-app获得。 该系列中的每篇文章都在存储库中具有相应的标签,因此您可以在应用程序的不同状态之间来回切换。

我们在第二部分中结束的代码,以及在本文中我们开始的代码被标记为第2部分。 我们结束这篇文章的代码被标记为第3部分

您可以将特定提交ID的别名视为标签。 您可以使用git checkout在它们之间切换。 你可以在这里阅读更多内容

第 5 段(可获 1.34 积分)

因此,为了启动和运行(最新版本的Angular  CLI安装),我们会这样做:

git clone git@github.com:sitepoint-editors/angular-todo-app.git
cd angular-todo-app
git checkout part-2
npm install
ng serve

然后访问 http://localhost:4200/. 如果一切顺利,你应该可以看到工作的Todo应用程序。

建立一个REST API后端

让我们用 json-server快速设置一个模拟后端。

在应用程序的根目录,运行:

npm install json-server --save

接下来,在我们应用程序的根目录中,创建一个名为db.json的文件,其中包含以下内容:

第 6 段(可获 0.91 积分)
{
  "todos": [
    {
      "id": 1,
      "title": "Read SitePoint article",
      "complete": false
    },
    {
      "id": 2,
      "title": "Clean inbox",
      "complete": false
    },
    {
      "id": 3,
      "title": "Make restaurant reservation",
      "complete": false
    }
  ]
} 

最后,添加一个script到package.json开始我们的后端:

"scripts": {
  ...
  "json-server": "json-server --watch db.json"
}

我们可以通过使用如下代码启动REST API:

npm run json-server

这将显示

  \{^_^}/ hi!

  Loading db.json
  Done

  Resources
  http://localhost:3000/todos

  Home
  http://localhost:3000
第 7 段(可获 0.25 积分)

就是这样! 现在我们有了一个监听3000端口的REST API。

为了验证后端是否按预期运行,可以将浏览器导航到http://localhost:3000。

支持以下端点:

  • GET /todos: 获取所有存在的todo
  • GET /todos/:id: 获取一个存在的todo
  • POST /todos: 创建一个新的todo
  • PUT /todos/:id: 更新一个存在的todo
  • DELETE /todos/:id: 删除一个存在的todo

如果你将浏览器导航到 http://localhost:3000/todos, 你将会从db.json看到一个包含所有todo的JSON响应。

要了解有关json-server的更多信息,请务必查看使用json-server模拟REST API

第 8 段(可获 1.1 积分)

存储API URL

现在我们的后端已经到位,我们必须将其URL存储在我们的Angular应用程序中。

理想情况下,我们应该能够:

  1. 将URL存储在一个地方,以便当我们需要更改其值时只需要更改一次
  2. 使我们的应用程序在开发过程中连接到开发API,在生产过程中连接到生产API

幸运的是,Angular CLI支持环境配置。 默认情况下,有两个环境:开发环境和生产环境,都有相应的环境文件:src/environments/environment.ts和 ‘src/environments/environment.prod.ts。

第 9 段(可获 1.11 积分)

让我们将API URL添加到这两个文件中:

// src/environments/environment.ts
// used when we run `ng serve` or `ng build`
export const environment = {
  production: false,

  // URL of development API
  apiUrl: 'http://localhost:3000'
};
// src/environments/environment.prod.ts
// used when we run `ng serve --environment prod` or `ng build --environment prod`
export const environment = {
  production: true,

  // URL of production API
  apiUrl: 'http://localhost:3000'
};

稍后,通过以下代码将使我们在Angular应用程序中从我们的环境中获取API URL:

第 10 段(可获 0.35 积分)
import { environment } from 'environments/environment';

// we can now access environment.apiUrl
const API_URL = environment.apiUrl;

当我们运行ng serve 或ng build时,Angular CLI使用开发环境中指定的值(src/environments/environment.ts)。

但是,当我们运行ng serve --environment prod或ng build --environment prod时,Angular CLI使用src/environments/environment.prod.ts中指定的值。

为开发和生产使用不同的API URL而无需更改代码,这正是我们需要做的。

本系列中的应用程序不托管在生产环境中,因此我们在开发和生产环境中指定相同的API URL。 这使我们能够在本地运行 ng serve --environment prod或 ng build --environment prod,以查看一切是否符合预期。

第 11 段(可获 1.08 积分)

您可以在.angle-cli.json中找到dev和prod之间的映射及其对应的环境文件:

"environments": {
  "dev": "environments/environment.ts",
  "prod": "environments/environment.prod.ts"
} 

您还可以通过添加键来创建其他环境(如staging):

"environments": {
  "dev": "environments/environment.ts",
  "staging": "environments/environment.staging.ts",
  "prod": "environments/environment.prod.ts"
}

并创建相应的环境文件。

要了解有关Angular CLI环境的更多信息,请务必查看“Angular CLI终极参考指南”

第 12 段(可获 0.63 积分)

现在我们的API URL存储在我们的环境中,我们可以创建一个Angular服务来与REST API进行通信。

创建与REST API通信的服务

让我们使用Angular CLI创建一个ApiService来与我们的REST API进行通信:

ng generate service Api --module app.module.ts

其给出以下输出:

installing service
  create src/app/api.service.spec.ts
  create src/app/api.service.ts
  update src/app/app.module.ts

--module app.module.ts选项指示Angular CLI不仅创建服务,而且还将其注册为在app.module.ts中定义的Angular模块中的provider。

第 13 段(可获 0.95 积分)

打开src/app/api.service.ts:

import { Injectable } from '@angular/core';

@Injectable()
export class ApiService {

  constructor() { }

} 

并注入我们的环境和Angular的内置HTTP服务:

import { Injectable } from '@angular/core';
import { environment } from 'environments/environment';
import { Http } from '@angular/http';

const API_URL = environment.apiUrl;

@Injectable()
export class ApiService {

  constructor(
    private http: Http
  ) {
  }

}

在实现我们需要的方法之前,让我们先来看一下Angular的HTTP服务。

第 14 段(可获 0.38 积分)

如果您不熟悉语法,为什么不去购买我们的高级课程引入TypeScript呢。

Angular HTTP服务

Angular HTTP服务通过@angular/http作为注入类提供。

构建在XHR/JSONP之上,并为我们提供了一个HTTP客户端,我们可以使用它从我们的Angular应用程序内部进行HTTP请求。

以下方法可用于执行HTTP请求:  

  • delete(url, options): 执行DELETE请求
  • get(url, options): 执行GET请求
  • head(url, options): 执行HEAD请求
  • options(url, options): 执行OPTIONS请求
  • patch(url, body, options): 执行PATCH请求
  • post(url, body, options): 执行POST请求
  • put(url, body, options): 执行PUT请求
第 15 段(可获 1.19 积分)

这些方法都返回一个RxJS Observable。

与AngularJS 1.x HTTP服务方法(返回promises)相反,Angular HTTP服务方法返回了Observables。

如果您还不熟悉RxJS Observables,请不要担心。 我们只需要使我们的应用程序启动并运行的基础知识。 当您的应用程序需要它们时,您可以逐步了解更多可用的操作符,并且ReactiveX网站提供了绝妙的文档。

如果您想了解有关Observables的更多信息,SitePoin上的文章使用RxJS介绍函数响应式编程也值得一看。

第 16 段(可获 1.21 积分)

实现apiservice方法

如果我们回想一下我们的REST API后端暴露的端点:

  • GET /todos: 获取所有存在的todo
  • GET /todos/:id: 获取存在的todo
  • POST /todos: 创建一个新的todo
  • PUT /todos/:id: 更新存在的todo
  • DELETE /todos/:id: 删除存在的todo

我们已经可以粗略地勾勒出我们需要的方法以及与他们相应的Angular HTTP方法:

import { Injectable } from '@angular/core';
import { environment } from 'environments/environment';

import { Http, Response } from '@angular/http';
import { Todo } from './todo';
import { Observable } from 'rxjs/Observable';

const API_URL = environment.apiUrl;

@Injectable()
export class ApiService {

  constructor(
    private http: Http
  ) {
  }

  // API: GET /todos
  public getAllTodos() {
    // will use this.http.get()
  }

  // API: POST /todos
  public createTodo(todo: Todo) {
    // will use this.http.post()
  }

  // API: GET /todos/:id
  public getTodoById(todoId: number) {
    // will use this.http.get()
  }

  // API: PUT /todos/:id
  public updateTodo(todo: Todo) {
    // will use this.http.put()
  }

  // DELETE /todos/:id
  public deleteTodoById(todoId: number) {
    // will use this.http.delete()
  }
}
第 17 段(可获 0.68 积分)

我们来仔细看看每一种方法。

getAllTodos()

getAllTodos()方法允许我们从API获取所有todos:

public getAllTodos(): Observable<Todo[]> {
  return this.http
    .get(API_URL + '/todos')
    .map(response => {
      const todos = response.json();
      return todos.map((todo) => new Todo(todo));
    })
    .catch(this.handleError);
}

首先,我们从API发起一个获取所有todo的GET请求:

this.http
  .get(API_URL + '/todos')

这将返回一个Observable。

然后我们在Observable上调用map()方法将API的响应转换成Todo对象的数组:

第 18 段(可获 0.78 积分)
.map(response => {
  const todos = response.json();
  return todos.map((todo) => new Todo(todo));
})

传入的HTTP响应是一个字符串,所以我们首先调用response.json()来将JSON字符串解析为对应的JavaScript值。

然后,我们循环遍历API响应的todo,并返回一个Todo实例数组。 请注意,第二次使用的map()是Array.prototype.map(),而不是RxJS运算符。

最后,我们附加一个错误处理程序来将潜在错误记录到控制台:

.catch(this.handleError);

我们在一个单独的方法中定义错误处理程序,因此我们可以在其他方法中重用它:

第 19 段(可获 1.01 积分)
private handleError (error: Response | any) {
  console.error('ApiService::handleError', error);
  return Observable.throw(error);
}

在我们可以运行这个代码之前,我们必须从RxJS库导入必要的依赖项:

import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';

请注意,RxJS库是巨大的。 建议只导入需要的部分,而不是使用import * as Rx from 'rxjs/Rx'导入整个RxJS库。 这将大大减少您生成的代码包的大小。

第 20 段(可获 0.69 积分)

在我们的应用程序中,我们导入Observable类:

import { Observable } from 'rxjs/Observable';

并导入我们的代码需要的三个operator:

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';

导入operator确保我们的Observable实例具有附加的相应方法。

如果我们的代码中没有import 'rxjs/add/operator/map',那么以下操作将不起作用:

this.http
  .get(API_URL + '/todos')
  .map(response => {
    const todos = response.json();
    return todos.map((todo) => new Todo(todo));
  })
第 21 段(可获 0.55 积分)

因为this.http.get返回的Observable没有map()方法。

我们只需要导入一次operator,以便在应用程序中全局启用相应的Observable方法。 然而,多次导入也没有问题,不会增加生成的包的大小。

getTodoById()

getTodoById()方法允许我们获得一个todo:

public getTodoById(todoId: number): Observable<Todo> {
  return this.http
    .get(API_URL + '/todos/' + todoId)
    .map(response => {
      return new Todo(response.json());
    })
    .catch(this.handleError);
}
第 22 段(可获 0.7 积分)

在我们的应用程序中不需要这个方法,在这里列出来只是为了让你知道它应该如何实现而已。

createTodo()

createTodo() 方法可以使我们创建一个新的todo:

public createTodo(todo: Todo): Observable<Todo> {
  return this.http
    .post(API_URL + '/todos', todo)
    .map(response => {
      return new Todo(response.json());
    })
    .catch(this.handleError);
}

首先我们向API发送一个POST请求,然后将数据作为第二个参数传递进去:

this.http.post(API_URL + '/todos', todo)

接着我们将响应结果转换成一个 Todo 对象:

第 23 段(可获 0.75 积分)
map(response => {
  return new Todo(response.json());
})

updateTodo()

updateTodo() 方法可以更新单个的todo对象:

public updateTodo(todo: Todo): Observable<Todo> {
  return this.http
    .put(API_URL + '/todos/' + todo.id, todo)
    .map(response => {
      return new Todo(response.json());
    })
    .catch(this.handleError);
}

首先我们向API发送一个PUT请求,并把数据作为第二个参数传进去:

put(API_URL + '/todos/' + todo.id, todo)

然后将响应转换为一个Todo对象:

map(response => {
  return new Todo(response.json());
})
第 24 段(可获 0.45 积分)

deleteTodoById()

deleteTodoById()方法可以用来删除单个的todo对象:

public deleteTodoById(todoId: number): Observable<null> {
  return this.http
    .delete(API_URL + '/todos/' + todoId)
    .map(response => null)
    .catch(this.handleError);
}

首先我们向API发出一个DELETE请求:

delete(API_URL + '/todos/' + todoId)

然后将响应结果转换为null:

map(response => null)

我们并不需要转换响应结果,因此可以不用管这一行代码。这里只是展示下当你发出DELETE请求时你的API返回数据的时候你该如何处理响应结果而已。

第 25 段(可获 0.86 积分)

以下是我们的ApiService的完整代码:

import { Injectable } from '@angular/core';
import { environment } from 'environments/environment';

import { Http, Response } from '@angular/http';
import { Todo } from './todo';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';

const API_URL = environment.apiUrl;

@Injectable()
export class ApiService {

  constructor(
    private http: Http
  ) {
  }

  public getAllTodos(): Observable<Todo[]> {
    return this.http
      .get(API_URL + '/todos')
      .map(response => {
        const todos = response.json();
        return todos.map((todo) => new Todo(todo));
      })
      .catch(this.handleError);
  }

  public createTodo(todo: Todo): Observable<Todo> {
    return this.http
      .post(API_URL + '/todos', todo)
      .map(response => {
        return new Todo(response.json());
      })
      .catch(this.handleError);
  }

  public getTodoById(todoId: number): Observable<Todo> {
    return this.http
      .get(API_URL + '/todos/' + todoId)
      .map(response => {
        return new Todo(response.json());
      })
      .catch(this.handleError);
  }

  public updateTodo(todo: Todo): Observable<Todo> {
    return this.http
      .put(API_URL + '/todos/' + todo.id, todo)
      .map(response => {
        return new Todo(response.json());
      })
      .catch(this.handleError);
  }

  public deleteTodoById(todoId: number): Observable<null> {
    return this.http
      .delete(API_URL + '/todos/' + todoId)
      .map(response => null)
      .catch(this.handleError);
  }

  private handleError (error: Response | any) {
    console.error('ApiService::handleError', error);
    return Observable.throw(error);
  }
}
第 26 段(可获 0.09 积分)

现在我们有了ApiService,我们可以使用它来让TodoDataService与REST API后端进行通信。

更新TodoDataService

目前,我们的TodoDataService将所有数据存储在内存中:

import {Injectable} from '@angular/core';
import {Todo} from './todo';

@Injectable()
export class TodoDataService {

  // Placeholder for last id so we can simulate
  // automatic incrementing of id's
  lastId: number = 0;

  // Placeholder for todo's
  todos: Todo[] = [];

  constructor() {
  }

  // Simulate POST /todos
  addTodo(todo: Todo): TodoDataService {
    if (!todo.id) {
      todo.id = ++this.lastId;
    }
    this.todos.push(todo);
    return this;
  }

  // Simulate DELETE /todos/:id
  deleteTodoById(id: number): TodoDataService {
    this.todos = this.todos
      .filter(todo => todo.id !== id);
    return this;
  }

  // Simulate PUT /todos/:id
  updateTodoById(id: number, values: Object = {}): Todo {
    let todo = this.getTodoById(id);
    if (!todo) {
      return null;
    }
    Object.assign(todo, values);
    return todo;
  }

  // Simulate GET /todos
  getAllTodos(): Todo[] {
    return this.todos;
  }

  // Simulate GET /todos/:id
  getTodoById(id: number): Todo {
    return this.todos
      .filter(todo => todo.id === id)
      .pop();
  }

  // Toggle todo complete
  toggleTodoComplete(todo: Todo) {
    let updatedTodo = this.updateTodoById(todo.id, {
      complete: !todo.complete
    });
    return updatedTodo;
  }

}
第 27 段(可获 0.36 积分)

为了让TodoDataService与我们的REST API通信,我们必须注入新的ApiService:

import { Injectable } from '@angular/core';
import { Todo } from './todo';
import { ApiService } from './api.service';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class TodoDataService {

  constructor(
    private api: ApiService
  ) {
  }
}

并更新其方法将所有工作委托给ApiService中的相应方法:

import { Injectable } from '@angular/core';
import { Todo } from './todo';
import { ApiService } from './api.service';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class TodoDataService {

  constructor(
    private api: ApiService
  ) {
  }

  // Simulate POST /todos
  addTodo(todo: Todo): Observable<Todo> {
    return this.api.createTodo(todo);
  }

  // Simulate DELETE /todos/:id
  deleteTodoById(todoId: number): Observable<Todo> {
    return this.api.deleteTodoById(todoId);
  }

  // Simulate PUT /todos/:id
  updateTodo(todo: Todo): Observable<Todo> {
    return this.api.updateTodo(todo);
  }

  // Simulate GET /todos
  getAllTodos(): Observable<Todo[]> {
    return this.api.getAllTodos();
  }

  // Simulate GET /todos/:id
  getTodoById(todoId: number): Observable<Todo> {
    return this.api.getTodoById(todoId);
  }

  // Toggle complete
  toggleTodoComplete(todo: Todo) {
    todo.complete = !todo.complete;
    return this.api.updateTodo(todo);
  }

}
第 28 段(可获 0.34 积分)

新的方法实现看起来相当简洁,因为数据逻辑现在由REST API所处理。

不过,他们有一个重要的区别。旧的方法使用同步代码会立刻返回结果,而新的方法使用了异步代码,只会返回一个Observable对象。

这意味着我们还需要更新调用TodoDataService的方法来正确处理Observables。

更新AppComponent

目前,AppComponent期望TodoDataService能够直接返回JavaScript 对象和数组:

import {Component} from '@angular/core';
import {TodoDataService} from './todo-data.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: [TodoDataService]
})
export class AppComponent {

  constructor(
    private todoDataService: TodoDataService
  ) {
  }

  onAddTodo(todo) {
    this.todoDataService.addTodo(todo);
  }

  onToggleTodoComplete(todo) {
    this.todoDataService.toggleTodoComplete(todo);
  }

  onRemoveTodo(todo) {
    this.todoDataService.deleteTodoById(todo.id);
  }

  get todos() {
    return this.todoDataService.getAllTodos();
  }

}
第 29 段(可获 0.96 积分)

然而新的ApiService方法却返回的是Observables对象。

跟Promises类似,Observables本质上也是异步的,因此我们需要更新相应的代码来处理Observable响应:

如果我们在 get todos()里调用TodoDataService.getAllTodos() 方法:

// AppComponent

get todos() {
  return this.todoDataService.getAllTodos();
}

TodoDataService.getAllTodos()方法则会调用相应的ApiService.getAllTodos()方法:

// TodoDataService

getAllTodos(): Observable<Todo[]> {
  return this.api.getAllTodos();
}

这样反过来就会通知Angular HTTP服务来执行一个HTTP的GET请求:

第 30 段(可获 0.68 积分)
// ApiService

public getAllTodos(): Observable<Todo[]> {
  return this.http
    .get(API_URL + '/todos')
    .map(response => {
      const todos = response.json();
      return todos.map((todo) => new Todo(todo));
    })
    .catch(this.handleError);
}

不过,我们还要记住一件重要的事!

如果我们不请求由如下语句所返回的Observable对象:

this.todoDataService.getAllTodos()

就不会发出真实的HTTP请求。

我们可以使用subscribe()方法来请求一个Observable对象,该方法可以接收3个参数:

  • onNext: 当Observable对象产生一个新值时,该方法会被调用。
  • onError: 当Observable对象抛出一个异常时,该方法会被调用。
  • onCompleted: 当Observable对象正常终止时,该方法会被调用。
第 31 段(可获 0.91 积分)

让我们重写当前代码:

// AppComponent

get todos() {
  return this.todoDataService.getAllTodos();
}

在AppComponent初始化时异步加载todo:

import { Component, OnInit } from '@angular/core';
import { TodoDataService } from './todo-data.service';
import { Todo } from './todo';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: [TodoDataService]
})
export class AppComponent implements OnInit {

  todos: Todo[] = [];

  constructor(
    private todoDataService: TodoDataService
  ) {
  }

  public ngOnInit() {
    this.todoDataService
      .getAllTodos()
      .subscribe(
        (todos) => {
          this.todos = todos;
        }
      );
  }
}
第 32 段(可获 0.2 积分)

首先,我们定义一个公共属性todos并将其初始值设置为一个空数组。

然后,我们使用ngOnInit() 方法订阅this.todoDataService.getAllTodos() ,当一个值进来时,我们将它分配给this.todos,覆盖其空数组的初始值。

现在我们来更新onAddTodo(todo)方法来处理一个Observable响应:

// previously:
// onAddTodo(todo) {
//  this.todoDataService.addTodo(todo);
// }

onAddTodo(todo) {
  this.todoDataService
    .addTodo(todo)
    .subscribe(
      (newTodo) => {
        this.todos = this.todos.concat(newTodo);
      }
    );
}
第 33 段(可获 0.66 积分)

再次,我们使用subscribe()方法订阅this.todoDataService.addTodo(todo)返回的Observable,当响应到来时,我们将新创建的todo添加到当前的todo列表中。

我们对其他方法重复相同的练习,直到我们的AppComponent看起来像这样:

import { Component, OnInit } from '@angular/core';
import { TodoDataService } from './todo-data.service';
import { Todo } from './todo';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  providers: [TodoDataService]
})
export class AppComponent implements OnInit {

  todos: Todo[] = [];

  constructor(
    private todoDataService: TodoDataService
  ) {
  }

  public ngOnInit() {
    this.todoDataService
      .getAllTodos()
      .subscribe(
        (todos) => {
          this.todos = todos;
        }
      );
  }

  onAddTodo(todo) {
    this.todoDataService
      .addTodo(todo)
      .subscribe(
        (newTodo) => {
          this.todos = this.todos.concat(newTodo);
        }
      );
  }

  onToggleTodoComplete(todo) {
    this.todoDataService
      .toggleTodoComplete(todo)
      .subscribe(
        (updatedTodo) => {
          todo = updatedTodo;
        }
      );
  }

  onRemoveTodo(todo) {
    this.todoDataService
      .deleteTodoById(todo.id)
      .subscribe(
        (_) => {
          this.todos = this.todos.filter((t) => t.id !== todo.id);
        }
      );
  }
}
第 34 段(可获 0.56 积分)

现在所有方法都能够处理由TodoDataService里的方法所返回的Observables对象了。

要注意的是,没有必要手动取消订阅你订阅的由Angular的HTTP服务所返回的Observable对象。Angular会清理它们来避免内存泄漏。

现在我们看看是否一切都如预期那样工作。

尝试一下

打开一个终端窗口。

在我们应用目录的根目录下,启动我们的REST API后端应用:

npm run json-server

再打开另外一个终端窗口。

同样的,在我们应用目录的根目录下,输入serve命令:

第 35 段(可获 1.18 积分)
ng serve

现在,将您的浏览器导航到http://localhost:4200.

如果一切顺利,你应该会看到:

如果您看到错误,您可以将代码与GitHub上的工作版本进行比较。

真棒! 我们的应用程序正在与REST API通信!

小提示:如果你想要在同一终端中运行 npm run json-server和ng serve,您可以并发运行这两个命令,而不用打开多个终端窗口或选项卡。

我们来运行我们的单元测试,以验证一切都按预期工作。

运行我们的测试

打开第三个终端窗口。

再一次,从您的应用程序的根目录,运行单元测试:

第 36 段(可获 1.25 积分)
ng test

看来11个单元测试失败了:

让我们看看为什么测试会失败,以及我们如何修复它们。

修复我们的单元测试

首先,让我们打开src/todo-data.service.spec.ts:

/* tslint:disable:no-unused-variable */

import {TestBed, async, inject} from '@angular/core/testing';
import {Todo} from './todo';
import {TodoDataService} from './todo-data.service';

describe('TodoDataService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [TodoDataService]
    });
  });

  it('should ...', inject([TodoDataService], (service: TodoDataService) => {
    expect(service).toBeTruthy();
  }));

  describe('#getAllTodos()', () => {

    it('should return an empty array by default', inject([TodoDataService], (service: TodoDataService) => {
      expect(service.getAllTodos()).toEqual([]);
    }));

    it('should return all todos', inject([TodoDataService], (service: TodoDataService) => {
      let todo1 = new Todo({title: 'Hello 1', complete: false});
      let todo2 = new Todo({title: 'Hello 2', complete: true});
      service.addTodo(todo1);
      service.addTodo(todo2);
      expect(service.getAllTodos()).toEqual([todo1, todo2]);
    }));

  });

  describe('#save(todo)', () => {

    it('should automatically assign an incrementing id', inject([TodoDataService], (service: TodoDataService) => {
      let todo1 = new Todo({title: 'Hello 1', complete: false});
      let todo2 = new Todo({title: 'Hello 2', complete: true});
      service.addTodo(todo1);
      service.addTodo(todo2);
      expect(service.getTodoById(1)).toEqual(todo1);
      expect(service.getTodoById(2)).toEqual(todo2);
    }));

  });

  describe('#deleteTodoById(id)', () => {

    it('should remove todo with the corresponding id', inject([TodoDataService], (service: TodoDataService) => {
      let todo1 = new Todo({title: 'Hello 1', complete: false});
      let todo2 = new Todo({title: 'Hello 2', complete: true});
      service.addTodo(todo1);
      service.addTodo(todo2);
      expect(service.getAllTodos()).toEqual([todo1, todo2]);
      service.deleteTodoById(1);
      expect(service.getAllTodos()).toEqual([todo2]);
      service.deleteTodoById(2);
      expect(service.getAllTodos()).toEqual([]);
    }));

    it('should not removing anything if todo with corresponding id is not found', inject([TodoDataService], (service: TodoDataService) => {
      let todo1 = new Todo({title: 'Hello 1', complete: false});
      let todo2 = new Todo({title: 'Hello 2', complete: true});
      service.addTodo(todo1);
      service.addTodo(todo2);
      expect(service.getAllTodos()).toEqual([todo1, todo2]);
      service.deleteTodoById(3);
      expect(service.getAllTodos()).toEqual([todo1, todo2]);
    }));

  });

  describe('#updateTodoById(id, values)', () => {

    it('should return todo with the corresponding id and updated data', inject([TodoDataService], (service: TodoDataService) => {
      let todo = new Todo({title: 'Hello 1', complete: false});
      service.addTodo(todo);
      let updatedTodo = service.updateTodoById(1, {
        title: 'new title'
      });
      expect(updatedTodo.title).toEqual('new title');
    }));

    it('should return null if todo is not found', inject([TodoDataService], (service: TodoDataService) => {
      let todo = new Todo({title: 'Hello 1', complete: false});
      service.addTodo(todo);
      let updatedTodo = service.updateTodoById(2, {
        title: 'new title'
      });
      expect(updatedTodo).toEqual(null);
    }));

  });

  describe('#toggleTodoComplete(todo)', () => {

    it('should return the updated todo with inverse complete status', inject([TodoDataService], (service: TodoDataService) => {
      let todo = new Todo({title: 'Hello 1', complete: false});
      service.addTodo(todo);
      let updatedTodo = service.toggleTodoComplete(todo);
      expect(updatedTodo.complete).toEqual(true);
      service.toggleTodoComplete(todo);
      expect(updatedTodo.complete).toEqual(false);
    }));

  });

});
第 37 段(可获 0.39 积分)

大多数失败的单元测试与检查数据处理有关。 这些测试不再需要,因为数据处理现在由我们的REST API而不是TodoDataService执行,所以我们删除过时的测试:

/* tslint:disable:no-unused-variable */

import {TestBed, inject} from '@angular/core/testing';
import {TodoDataService} from './todo-data.service';

describe('TodoDataService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        TodoDataService,
      ]
    });
  });

  it('should ...', inject([TodoDataService], (service: TodoDataService) => {
    expect(service).toBeTruthy();
  }));

});
第 38 段(可获 0.48 积分)

如果我们现在运行单元测试,我们会得到一个错误:

TodoDataService should ...
Error: No provider for ApiService!

抛出错误是因为TestBed.configureTestingModule()创建了一个用于测试的临时模块,并且临时模块的注入程序没有感知到任何ApiService。

为了使注入器感知到ApiService,我们必须通过在传递给TestBed.configureTestingModule()的配置对象中列出ApiService作为provider来注册临时模块:

/* tslint:disable:no-unused-variable */

import {TestBed, inject} from '@angular/core/testing';
import {TodoDataService} from './todo-data.service';
import { ApiService } from './api.service';

describe('TodoDataService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        TodoDataService,
        ApiService
      ]
    });
  });

  it('should ...', inject([TodoDataService], (service: TodoDataService) => {
    expect(service).toBeTruthy();
  }));

});
第 39 段(可获 0.79 积分)

但是,如果我们这样做,我们的单元测试将使用我们真正的ApiService,它连接到我们的REST API。

我们不希望我们的测试运行器在运行单元测试时连接到一个真正的API,所以让我们创建一个ApiMockService来模拟单元测试中的真正的ApiService。

创建ApiMockService

让我们使用Angular CLI来生成一个新的ApiMockService:

ng g service ApiMock --spec false

其中显示:

installing service
  create src/app/api-mock.service.ts
  WARNING Service is generated but not provided, it must be provided to be used


接下来,我们实现了与ApiService相同的方法,但是我们让这些方法返回模拟数据,而不是发出HTTP请求:

第 40 段(可获 1.03 积分)

注意每个方法如何返回新的模拟数据。 这似乎有点重复,但确实是一个很好的做法。 如果一个单元测试会更改模拟数据,该更改将永远不会影响另一个单元测试中的数据。

现在我们有一个ApiMockService服务,我们可以在ApiMockService的单元测试中替换ApiService。

我们再次打开src/todo-data.service.spec.ts。

在providers数组中,每当请求ApiService时,我们告诉注入器提供ApiMockService:

/* tslint:disable:no-unused-variable */

import {TestBed, inject} from '@angular/core/testing';
import {TodoDataService} from './todo-data.service';
import { ApiService } from './api.service';
import { ApiMockService } from './api-mock.service';

describe('TodoDataService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        TodoDataService,
        {
          provide: ApiService,
          useClass: ApiMockService
        }
      ]
    });
  });

  it('should ...', inject([TodoDataService], (service: TodoDataService) => {
    expect(service).toBeTruthy();
  }));

});
第 41 段(可获 0.9 积分)

如果我们现在重新运行单元测试,错误消失了。 太棒了!

我们还有两个失败的测试:

ApiService should ...
Error: No provider for Http!

AppComponent should create the app
Failed: No provider for ApiService!

这些错误与我们刚刚修复的错误类似。

要修复第一个错误,让我们打开src/api.service.spec.ts:

import { TestBed, inject } from '@angular/core/testing';

import { ApiService } from './api.service';

describe('ApiService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [ApiService]
    });
  });

  it('should ...', inject([ApiService], (service: ApiService) => {
    expect(service).toBeTruthy();
  }));
});
第 42 段(可获 0.48 积分)

测试失败,并显示一条消息 No provider for Http!,表示我们需要为Http添加一个provider。

再次,我们不希望Http服务发送真正的HTTP请求,所以我们使用Angular的MockBackend实例化一个模拟Http服务:

import { TestBed, inject } from '@angular/core/testing';

import { ApiService } from './api.service';
import { BaseRequestOptions, Http, XHRBackend } from '@angular/http';
import { MockBackend } from '@angular/http/testing';

describe('ApiService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        {
          provide: Http,
          useFactory: (backend, options) => {
            return new Http(backend, options);
          },
          deps: [MockBackend, BaseRequestOptions]
        },
        MockBackend,
        BaseRequestOptions,
        ApiService
      ]
    });
  });

  it('should ...', inject([ApiService], (service: ApiService) => {
    expect(service).toBeTruthy();
  }));
});
第 43 段(可获 0.48 积分)

如果配置测试模块看起来有点让人不知所措,请别担心。

您可以在官方文档测试Angular应用程序中了解更多有关设置单元测试的信息。

要修复最后的错误:

AppComponent should create the app
Failed: No provider for ApiService!

让我们打开src/app.component.spec.ts:

import { TestBed, async } from '@angular/core/testing';

import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TodoDataService } from './todo-data.service';

describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        FormsModule
      ],
      declarations: [
        AppComponent
      ],
      providers: [
        TodoDataService
      ],
      schemas: [
        NO_ERRORS_SCHEMA
      ]
    }).compileComponents();
  }));

  it('should create the app', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
});
第 44 段(可获 0.48 积分)

并为注入器提供我们模拟的ApiService:

import { TestBed, async } from '@angular/core/testing';

import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { TodoDataService } from './todo-data.service';
import { ApiService } from './api.service';
import { ApiMockService } from './api-mock.service';

describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        FormsModule
      ],
      declarations: [
        AppComponent
      ],
      providers: [
        TodoDataService,
        {
          provide: ApiService,
          useClass: ApiMockService
        }
      ],
      schemas: [
        NO_ERRORS_SCHEMA
      ]
    }).compileComponents();
  }));

  it('should create the app', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
});
第 45 段(可获 0.09 积分)

哇!我们所有的测试都通过了:

我们已经成功了将我们的Angular应用连上了REST API后端程序。

为了将我们的应用部署到生产环境,现在我们可以这么做:

ng build --aot --environment prod

然后将生成的dist文件夹上传到我们的托管服务器上。是不是很爽?

我们来总结一下我们学到了些什么。

总结

第一篇文章里,我们学会了如何 :

  • 使用Angular CLI初始化我们的Todo应用
  • 创建一个Todo类来呈现一个个的待办事项
  • 创建一个TodoDataService服务用来创建、更新和删除待办事项
  • 使用AppComponent组件来呈现用户界面
  • 将我们的应用部署到GitHub页面
第 46 段(可获 1.23 积分)

在 第二篇文章 中,我们重构了 AppComponent 来将其大多数工作委派给:

  • TodoListComponent 用来显示待办事宜列表
  • TodoListItemComponent 用来显示单个待办事宜
  • TodoListHeaderComponent 创建新的待办事宜
  • TodoListFooterComponent 显示剩下多少待办事宜

而第三篇文章中:

  • 创建一个模拟 REST API 后端
  • 将 API URL 存为环境变量
  • 创建 ApiService 来与 REST API 进行通信
  • 更新 TodoDataService 以便使用新的 ApiService
  • 更新 AppComponent 来处理异步的 API 调用
  • 创建 ApiMockService 来避免在运行单元测试的时候避免发起 HTTP 请求
第 47 段(可获 1.16 积分)

在这个过程中,我们学会了:

  • 如何使用环境变量来存储应用设置
  • 如何使用Angular的HTTP客户端来执行HTTP请求
  • 如何处理由Angular的HTTP客户端返回的Observables对象
  • 在执行单元测试时,如何模拟HTTP请求来避免发送真实的HTTP请求

从这篇文章的所有代码都可以在https://github.com/sitepoint-editors/angular-todo-app/tree/part-3上找到。

在第四部分中,我们将会引入router和refactor的AppComponent,使用router来从后端获取待办事项列表。

在第五部分,我们将会实现一个身份验证来阻止他人对我们应用进行未经授权的访问。

所以,敬请期待更多精彩!

第 48 段(可获 1.35 积分)

文章评论