Angular-开发者的-TyoeScript-2-x-指南-二-

30 阅读40分钟

Angular 开发者的 TyoeScript 2.x 指南(二)

原文:zh.annas-archive.org/md5/53ee2c20bbe7b034b8ae6b7649cd4a4b

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:使用类型化服务分离关注点

本章在上一章的基础上构建,展示了更多技术,说明了如何在应用程序的构建块之间进行通信。在本章中,你将学习以下主题:

  • 服务和依赖注入(DI)概念

  • 组件与服务之间的通信

  • 使用服务编写数据逻辑

为了更好地理解服务,你需要至少了解依赖注入的基本概念。

依赖注入

使用 TypeScript 编写 Angular 要求你的构建块(组件、指令、服务等)是用类编写的。它们只是构建块,这意味着在它们变得功能之前,它们需要相互交织,从而形成一个完整的应用程序。

这个交织过程可能相当令人望而生畏。因此,让我们首先了解这个问题。以以下 TypeScript 类为例:

export class Developer {
  private skills: Array<Skill>;
  private bio: Person;
  constructor() {
    this.bio = new Person('Sarah', 'Doe', 24, 'female');
    this.skills = [
      new Skill('css'), 
      new Skill('TypeScript'), 
      new Skill('Webpack')
    ];
  }
}

人物技能类的实现就像以下这样:

// Person Class
export class Person {
  private fName: string;
  private lName: string;
  private age: number;
  private gender: string;
  constructor(
    fName: string, 
    lName: string, 
    age: number, 
    gender: string, 
  ) {
    this.fName = fName;
    this.lName = lName;
    this.age = age;
    this.gender = gender;
  }
}

// Skill Class
export class Skill {
  private type: string;
  constructor(
    type: string
  ) {
    this.type = type;
  }
}

在开始创建更多需要使用此类的新开发者类型之前,前面的示例是非常功能性和有效的代码。实际上,没有方法可以创建另一种类型的开发者,因为所有实现细节都绑定到一个类上;因此,这个过程不够灵活。在能够用来创建更多类型的开发者之前,我们需要使这个类更加通用。

让我们尝试改进开发者类,使其通过构造函数接收创建类所需的所有值,而不是在类中设置:

export class Developer {
  private skills: Array<Skills>;
  private bio: Person;
  constructor(
    fName: string, 
    lName: string, 
    age: number, 
    gender: string, 
    skills: Array<string>
  ) {
    this.bio = new Person(fName, lName, age, gender);
    this.skills = skills.map(skill => new Skill(skill));
  }
}

在这么少的代码行中就有如此多的改进!我们现在使用构造函数使代码变得更加灵活。通过这次更新,你可以使用开发者类创建所需的所有类型的开发者。

虽然这个解决方案看起来可以解决问题,但系统中仍然存在紧密耦合的问题。当PersonSkill类的构造函数发生变化时会发生什么?其含义是,你将不得不回到Developer类中更新对这个构造函数的调用。以下是在Skill中此类变化的一个示例:

// Skill Class
export class Skill {
  private type: string;
  private yearsOfExperience: number;
  constructor(
    type: string,
    yearsOfExperience: number
  ) {
    this.type = type;
    this.yearsOfExperience = yearsOfExperience
  }
}

我们为yearsOfExperience类添加了另一个字段,它是数字类型,表示开发者练习声称的技能有多长时间。为了在Developer中实际工作,我们必须更新Developer类:

export class Developer {
  public skills: Array<Skill>;
  private bio: Person;
  constructor(
    fName: string, 
    lName: string, 
    age: number, 
    gender: string, 
    skils: Array<any>
  ) {
    this.bio = new Person(fName, lName, age, gender);
    this.slills = skills.map(skill => 
       new Skill(skill.type, skill.yearsOfExperience));
  }
}

每当依赖项发生变化时更新此类,这是我们力求避免的。一种常见的做法是将依赖项的构造函数提升到类的构造函数本身:

export class Developer {
  public skills: <Skill>;
  private person: Person;
  constructor(
    skill: Skill,
    person: Person
  ) {}
}

这样,开发者技能人物的实现细节了解较少。因此,如果它们内部发生变化,开发者不会关心;它只是保持原样。

事实上,TypeScript 提供了一个生产力快捷方式:

export class Developer {
  constructor(
    public skills: <Skill>,
    private person: Person
  ) {}
}

这个快捷方式将隐式声明属性,并通过构造函数将它们作为依赖项分配。

这还不是全部;提升这些依赖关系引入了另一个挑战。我们如何在应用中管理所有依赖关系,而不会失去对事物预期位置的跟踪?这就是依赖注入发挥作用的地方。它不是 Angular 的事情,但这是一个在 Angular 中实现的流行模式。

让我们在 Angular 应用中直接看到依赖注入(DI)的实际应用。

组件中的数据

为了更好地理解服务和依赖注入的重要性,让我们创建一个简单的应用,其中包含一个显示用户评论列表的组件。一旦创建好应用,你可以运行以下命令来生成所需的组件:

ng g component comment-list

更新组件的代码如下片段:

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-comment-list',
  templateUrl: './comment-list.component.html',
  styleUrls: ['./comment-list.component.css']
})
export class CommentListComponent implements OnInit {

  comments: Array<any>
  constructor() { }

  ngOnInit() {
    this.comments = [
      {
        author: 'solomon',
        content: `TypeScript + Angular is amazing`
      },
      {
        author: 'lorna',
        content: `TypeScript is really awesome`
      },
      {
        author: 'codebeast',
        content: `I'm new to TypeScript`
      },
    ];
  }

}

组件有一个 comments 数组,一旦通过 ngOnInit 生命周期初始化组件,就会用硬编码的数据填充。现在我们需要遍历数组并在 DOM 上打印:

<div class="list-group">
  <a href="#" class="list-group-item" *ngFor="let comment of comments">
    <h4 class="list-group-item-heading">{{comment.author}}</h4>
    <p class="list-group-item-text">{{comment.content}}</p>
  </a>
</div>

你需要将组件包含在你的入口(app)组件中,才能使其显示:

<div class="container">
  <h2 class="text-center">TS Comments</h2>
  <div class="col-md-6 col-md-offset-3">
    <app-comment-list></app-comment-list>
  </div>
</div>

你的应用应该看起来像下面这样(记得包括第二章[1388eb32-f9cf-4efd-86fe-dc3f201ed039.xhtml]中提到的 Bootstrap,使用 TypeScript 入门):

图片

这个例子是可行的,但魔鬼藏在细节中。当另一个组件需要一个评论列表或列表的一部分时,我们最终会重新创建评论。这就是组件中存在数据的问题。

数据类服务

为了提高可重用性和可维护性,我们需要将逻辑关注点从组件中抽象出来,让组件仅作为表示层。这就是 TypeScript 在 Angular 中的服务发挥作用的情况之一。

你首先需要使用以下命令创建一个服务:

ng g service comment

这将创建你的服务类 ./src/app/comment.service.ts,并带有脚手架内容。更新内容如下:

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

@Injectable()
export class CommentService {
  private comments: Array<any> = [
    {
      author: 'solomon',
      content: `TypeScript + Angular is amazing`
    },
    {
      author: 'lorna',
      content: `TypeScript is really awesome`
    },
    {
      author: 'codebeast',
      content: `I'm new to TypeScript`
    }
  ];
  constructor() {}

  getComments() {
    return this.comments;
  }
}

现在这个类现在做的是我们的组件本应使用数据做的事情,数据是通过 getComments 方法获取的,该方法简单地返回一个评论数组。CommentService 类也被装饰了;除非类有需要解析的依赖关系,否则这不是必需的。尽管如此,良好的实践要求我们始终使用 Injectable 装饰,以了解一个类是否意味着要成为一个服务。

回到我们的列表组件,我们只需导入类,从构造函数中解析依赖关系以创建服务类的一个实例,然后使用 getComments 返回值填充属性:

import { Component, OnInit } from '@angular/core';
import { CommentService } from '../comment.service';

@Component({
  selector: 'app-comment-list',
  templateUrl: './comment-list.component.html',
  styleUrls: ['./comment-list.component.css']
})
export class CommentListComponent implements OnInit {
  private comments: Array<any>;
  constructor(
    private commentService: CommentService
  ) { }

  ngOnInit() {
    this.comments = this.commentService.getComments();
  }

}

让我们尝试在浏览器中运行这些当前更改,看看是否仍然按预期工作:

图片

见鬼,它爆炸了。出了什么问题?错误信息显示没有为 CommentService 提供提供者!

记住,当我们使用 ng CLI 命令搭建组件时,CLI 不仅创建了一个组件,还将它添加到 ngModule 装饰器的声明数组中:

// ./src/app/app.module.ts
declarations: [
    AppComponent,
    // New scaffolded component here
    CommentListComponent
  ],

模块需要知道哪些组件和服务属于它们作为成员。这就是为什么组件会自动为你添加。对于服务来说,情况并不相同,因为 CLI 不会自动更新模块(在脚手架期间会警告你),当你通过 CLI 工具创建服务类时。我们需要通过providers数组手动添加服务:

import { CommentService } from './comment.service';
//...

@NgModule({
  //...
  providers: [
    CommentService
  ],
})
export class AppModule { }

现在,再次运行应用程序,看看我们的服务现在如何使应用程序运行,且控制台没有更多错误:

图片

如果需要操作数据,必须在服务中而不是在组件中完成。假设你想通过双击列表中的每个项目来删除评论,接收组件上的事件是可以的,但实际的删除应该由服务处理。

首先为列表项添加一个事件监听器:

<a href="#" class="list-group-item" (dblclick)="removeComment(comment)" *ngFor="let comment of comments">
    <h4 class="list-group-item-heading">{{comment.author}}</h4>
    <p class="list-group-item-text">{{comment.content}}</p>
  </a>

dblclick事件是在双击项目时触发的。当发生这种情况时,我们会调用removeComment方法,并传递要从项目中删除的评论。

下面是组件中removeComment的样子:

removeComment(comment) {
    this.comments = this.commentService.removeComment(comment);
}

如您所见,它所做的不仅仅是调用我们的服务上的一个方法,这个方法也被称为removeComment。这是负责从评论数组中删除项的实际责任方法:

// Comment service
removeComment(removableComment) {
    // find the index of the comment
    const index = this.comments.findIndex(
      comment => comment.author === removableComment.author
    );
    // remove the comment from the array
    this.comments.splice(index, 1);
    // return the new array
    return this.comments;
  }

组件与服务之间的交互

这是对服务的一个非常有用的用例。在第六章,“使用 TypeScript 进行组件组合”,我们讨论了组件如何相互交互,并展示了不同的实现方式。其中一种方式被省略了——使用服务作为不同组件的事件中心/通信平台。

假设当列表中的一个项目被点击时,我们使用与评论列表组件相邻的组件来显示选中的评论的详细视图。首先,我们需要创建这个组件:

ng g component comment-detail

然后,你可以更新app.component.html文件以显示添加的组件:

<div class="container">
  <h2 class="text-center">TS Comments</h2>
  <div class="col-md-4 col-md-offset-2">
    <app-comment-list></app-comment-list>
  </div>
  <div class="col-md-4">
    <!-- Comment detail component -->
    <app-comment-detail></app-comment-detail>
  </div>
</div>

现在,我们需要定义我们的组件要做什么,因为它现在还是空的。但在那之前,让我们更新评论服务,使其同时作为列表组件和相邻的详细组件之间的枢纽:

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

@Injectable()
export class CommentService {
  private commentSelectedSource = new Subject<any>();
  public commentSelected$ = this.commentSelectedSource.asObservable();

  private comments: Array<any> = [
   // ...
  ];

  // ...

  showComment(comment) {
    this.commentSelectedSource.next(comment);
  }
}

现在服务使用 Rx subject 创建一个流和一个监听器,选中的评论会通过这个流推送并获取。当评论被点击时,commentSelectedSource对象负责将评论添加到流中。commetSelected$对象是一个可观察对象,我们可以在评论被点击时订阅并对其操作。

现在,直接回到你的组件中,添加一个点击事件以选择评论项:

<div class="list-group">
  <a href="#" class="list-group-item" 
    (dblclick)="removeComment(comment)" 
    *ngFor="let comment of comments"
    (click)="showComment(comment)"
    >
    <h4 class="list-group-item-heading">{{comment.author}}</h4>
    <p class="list-group-item-text">{{comment.content}}</p>
  </a>
</div>

点击事件会在组件上触发一个showComment方法,该方法反过来会调用服务中的showComment方法:

showComment(comment) {
  this.commentService.showComment(comment);
}

我们仍然需要更新评论详情组件,使其订阅我们在类中创建的可观察对象:

import { Component, OnInit } from '@angular/core';
import { CommentService } from '../comment.service';

@Component({
  selector: 'app-comment-detail',
  templateUrl: './comment-detail.component.html',
  styleUrls: ['./comment-detail.component.css']
})
export class CommentDetailComponent implements OnInit {

  comment: any = {
    author: '',
    content: ''
  };
  constructor(
    private commentService: CommentService
  ) { }

  ngOnInit() {
    this.commentService.commentSelected$.subscribe(comment => {
      this.comment = comment;
    })
  }

}

使用ngOnInit生命周期钩子,一旦组件准备就绪,我们就可以创建对可观察对象的订阅。有一个评论属性将被绑定到视图上,并且每次点击评论项时,该属性都会通过订阅进行更新。以下是显示所选评论的组件模板:

<div class="panel panel-default" *ngIf="comment.author">
  <div class="panel-heading">{{comment.author}}</div>
  <div class="panel-body">
    {{comment.content}}
  </div>
</div>

你可以再次启动应用程序并尝试选择一条评论。你应该看到以下行为:

图片

服务作为实用工具

除了管理状态和组件交互外,服务还以处理实用操作而闻名。假设我们想在我们的评论应用中开始收集新的评论。我们对表单了解不多,因此可以使用浏览器的提示。我们期望用户通过提示中的同一个文本框传递用户名和内容,如下所示:

<username>: <comment content>

因此,我们需要一个实用方法来从文本框中提取这些信息到一个具有作者和内容属性的评论对象中。让我们从收集评论列表组件的信息开始:

showPrompt() {
    const commentString = window.prompt('Please enter your username and content: ', 'username: content');
    const parsedComment = this.commentService.parseComment(commentString);
    this.commentService.addComment(parsedComment);
  }

showPrompt()方法用于收集用户输入,并将输入传递到服务的parseComment方法。这是一个实用方法的例子,我们很快就会实现它。我们还将实现addComment方法,该方法使用解析后的评论来更新评论列表。接下来,向视图中添加一个按钮,并添加一个点击事件监听器,该监听器触发showPrompt

<button class="btn btn-primary" 
 (click)="showPrompt()"
>Add Comment</button>

将这两种方法添加到评论服务中:

parseComment(commentString) {
    const commentArr = commentString.split(':');
    const comment = {
      author: commentArr[0].trim(),
      content: commentArr[1].trim()
    }
    return comment;
  }

  addComment(comment) {
    this.comments.unshift(comment);
  }

parseComment方法接受一个字符串,分割该字符串,并获取评论的作者和内容。然后,它返回评论。addComment方法接受一个评论并将其添加到现有评论列表中。

现在,你可以开始添加新的评论,如下面的截图所示:

图片

摘要

本章揭示了数据抽象中的许多有趣概念,同时利用了依赖注入的力量。你学习了组件如何通过服务作为中心进行交互,数据逻辑如何从组件抽象到服务,以及如何在服务中处理可重用的实用代码以保持应用程序的整洁。在下一章中,你将学习 Angular 中表单的实用方法以及 DOM 事件。

第八章:使用 TypeScript 优化表单和事件处理

让我们谈谈表单。从本书的开头,我们就避免在示例中使用表单输入。这是因为我想将整个章节都专门用于表单。我们将涵盖构建收集用户信息的企业应用所需的所有内容。以下是您应该从这个章节中期待的内容:

  • 带类型表单输入和输出

  • 表单控件

  • 验证

  • 表单提交和处理

  • 事件处理

  • 控制状态

为表单创建类型

我们希望尽可能多地使用 TypeScript,因为它简化了我们的开发过程,并使我们的应用行为更加可预测。因此,我们将创建一个简单的数据类来作为表单值的类型。

首先,创建一个新的 Angular 项目以跟随示例。然后,使用以下命令创建一个新的类:

ng g class flight

类生成在 app 文件夹中;用以下数据类替换其内容:

export class Flight {
  constructor(
    public fullName: string,
    public from: string,
    public to: string,
    public type: string,
    public adults: number,
    public departure: Date,
    public children?: number,
    public infants?: number,
    public arrival?: Date,
  ) {}
}

此类代表我们的表单(尚未创建)将拥有的所有值。后面跟着问号(?)的属性是可选的,这意味着当相应的值未提供时,TypeScript 不会抛出错误。

在开始创建表单之前,让我们从一张白纸开始。用以下内容替换 app.component.html 文件:

<div class="container">
  <h3 class="text-center">Book a Flight</h3>
  <div class="col-md-offset-3 col-md-6">
    <!-- TODO: Form here -->
  </div>
</div>

运行应用并保持其运行。您应该在本地主机的 4200 端口看到以下内容(请记住包括 Bootstrap):

图片

表单模块

现在我们有一个想要表单遵循的合约,现在让我们生成表单的组件:

ng  g component flight-form

命令还将组件作为声明添加到我们的 App 模块中:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { FlightFormComponent } from './flight-form/flight-form.component';

@NgModule({
  declarations: [
    AppComponent,
    // Component added after
    // being generated
    FlightFormComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

使 Angular 表单特别且易于使用的是开箱即用的功能,如 NgForm 指令。这些功能在核心浏览器模块中不可用,但在表单模块中可用。因此,我们需要导入它们:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

// Import the form module
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { FlightFormComponent } from './flight-form/flight-form.component';

@NgModule({
  declarations: [
    AppComponent,
    FlightFormComponent
  ],
  imports: [
    BrowserModule,
    // Add the form module 
    // to imports array
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

简单地导入并将 FormModule 添加到 imports 数组中,这就是我们所需做的。

双向绑定

现在是使用浏览器中的表单组件显示一些表单控件的最佳时机。在数据层(模型)和视图之间保持状态同步可能非常具有挑战性,但使用 Angular,这只是一个使用来自 FormModule 的一个指令的问题:

<!-- ./app/flight-form/flight-form.component.html -->
<form>
  <div class="form-group">
    <label for="fullName">Full Name</label>
    <input 
      type="text" 
      class="form-control" 
      [(ngModel)]="flightModel.fullName"
      name="fullName"
    >
  </div>
</form>

Angular 依赖于内部的 name 属性来执行绑定。因此,name 属性是必需的。

注意 [(ngModel)]="flightModel.fullName";它试图将组件类上的属性绑定到表单。此模型将是 Flight 类型,这是我们之前创建的类:

// ./app/flight-form/flight-form.component.ts

import { Component, OnInit } from '@angular/core';
import { Flight } from '../flight';

@Component({
  selector: 'app-flight-form',
  templateUrl: './flight-form.component.html',
  styleUrls: ['./flight-form.component.css']
})
export class FlightFormComponent implements OnInit {
  flightModel: Flight;
  constructor() {
    this.flightModel = new Flight('', '', '', '', 0, '', 0, 0, '');
  }

  ngOnInit() {}
}

flightModel 属性添加到组件中,类型为 Flight 并初始化为一些默认值。

在应用 HTML 中包含组件,以便它可以在浏览器中显示:

<div class="container">
  <h3 class="text-center">Book a Flight</h3>
  <div class="col-md-offset-3 col-md-6">
    <app-flight-form></app-flight-form>
  </div>
</div>

这是您应该在浏览器中看到的内容:

图片

要查看双向绑定的实际效果,使用插值显示flightModel.fullName的值。然后,输入一个值并查看实时更新:

<form>
  <div class="form-group">
    <label for="fullName">Full Name</label>
    <input 
      type="text" 
      class="form-control" 
      [(ngModel)]="flightModel.fullName"
      name="fullName"
    >
    {{flightModel.fullName}}
  </div>
</form>

这就是它的样子:

图片

更多表单字段

让我们动手添加剩余的表单字段。毕竟,我们不能只提供我们的名字就预订航班。

fromto字段将是一个带有可以飞入和飞出的城市列表的选择框。这个城市列表将直接存储在我们的组件类中,然后我们可以在模板中遍历它并将其渲染为选择框:

export class FlightFormComponent implements OnInit {
  flightModel: Flight;
  // Array of cities
  cities:Array<string> = [
    'Lagos',
    'Mumbai',
    'New York',
    'London',
    'Nairobi'
  ];
  constructor() {
    this.flightModel = new Flight('', '', '', '', 0, '', 0, 0, '');
  }
}

数组存储了来自世界各地的几个城市作为字符串。现在让我们使用ngFor指令遍历城市,并在表单中使用选择框显示它们:

<div class="row">
    <div class="col-md-6">
      <label for="from">From</label>
      <select type="text" id="from" class="form-control" [(ngModel)]="flightModel.from" name="from">
        <option *ngFor="let city of cities" value="{{city}}">{{city}}</option>
      </select>
    </div>
    <div class="col-md-6">
      <label for="to">To</label>
      <select type="text" id="to" class="form-control" [(ngModel)]="flightModel.to" name="to">
        <option *ngFor="let city of cities" value="{{city}}">{{city}}</option>
      </select>
    </div>
  </div>

清晰整洁!您可以在浏览器中打开并直接看到它:

图片

当点击下拉选择框时,会显示城市列表,正如预期的那样:

图片

接下来,让我们添加行程类型字段(单选按钮)、出发日期字段(日期控件)和到达日期字段(日期控件):

<div class="row" style="margin-top: 15px">
    <div class="col-md-5">
      <label for="" style="display: block">Trip Type</label>
      <label class="radio-inline">
        <input type="radio" name="type" [(ngModel)]="flightModel.type" value="One Way"> One way
      </label>
      <label class="radio-inline">
        <input type="radio" name="type" [(ngModel)]="flightModel.type" value="Return"> Return
      </label>
    </div>
    <div class="col-md-4">
      <label for="departure">Departure</label>
      <input type="date" id="departure" class="form-control" [(ngModel)]="flightModel.departure" name="departure">
    </div>
    <div class="col-md-3">
      <label for="arrival">Arrival</label>
      <input type="date" id="arrival" class="form-control" [(ngModel)]="flightModel.arrival" name="arrival">
    </div>
  </div>

数据绑定到控件的方式与之前创建的文本和选择字段非常相似。主要区别是控件类型(单选按钮和日期):

图片

最后,添加乘客数量(成人、儿童和婴儿):

<div class="row" style="margin-top: 15px">
    <div class="col-md-4">
      <label for="adults">Adults</label>
      <input type="number" id="adults" class="form-control" [(ngModel)]="flightModel.adults" name="adults">
    </div>
    <div class="col-md-4">
      <label for="children">Children</label>
      <input type="number" id="children" class="form-control" [(ngModel)]="flightModel.children" name="children">
    </div>
    <div class="col-md-4">
      <label for="infants">Infants</label>
      <input type="number" id="infants" class="form-control" [(ngModel)]="flightModel.infants" name="infants">
    </div>
  </div>

乘客部分都是数字类型,因为我们只是预期从每个类别中挑选上船的乘客数量:

图片

验证表单和表单字段

Angular 通过使用其内置指令和状态属性大大简化了表单验证。您可以使用状态属性来检查表单字段是否已被触摸。如果它已被触摸但违反了验证规则,您可以使用ngIf指令来显示相关错误。

让我们看看验证全名字段的例子:

<div class="form-group">
    <label for="fullName">Full Name</label>
    <input 
      type="text" 
      id="fullName" 
      class="form-control" 
      [(ngModel)]="flightModel.fullName" 
      name="fullName"

      #name="ngModel"
      required
      minlength="6">
  </div>

我们只是为表单的全名字段添加了三个额外的显著属性:#namerequiredminlength#name属性与name属性完全不同,前者是一个模板变量,通过ngModel值持有关于此特定字段的信息,而后者是常规表单输入名称属性。

在 Angular 中,验证规则作为属性传递,这就是为什么会有requiredminlength

是的,字段已经进行了验证,但没有向用户反馈出什么出了问题。让我们添加一些错误消息,当表单字段违反时显示:

<div *ngIf="name.invalid && (name.dirty || name.touched)" class="text-danger">
      <div *ngIf="name.errors.required">
        Name is required.
      </div>
      <div *ngIf="name.errors.minlength">
        Name must be at least 6 characters long.
      </div>
    </div>

ngIf指令条件性地显示这些div元素:

  • 如果表单字段已被触摸但没有值,则会显示“名称是必需的”错误

  • 当字段被触摸但内容长度小于6时,也会显示“名称至少为 6 个字符长”。

以下两个截图显示了这些错误输出在浏览器中的样子:

图片

当输入值但值文本计数未达到 6 时,会显示不同的错误:

图片

提交表单

在提交表单之前,我们需要考虑几个因素:

  • 表单是否有效?

  • 在提交之前是否有表单的处理程序?

为了确保表单有效,我们可以禁用提交按钮:

<form #flightForm="ngForm">
  <div class="form-group" style="margin-top: 15px">
    <button class="btn btn-primary btn-block" [disabled]="!flightForm.form.valid">
      Submit
    </button>
  </div>
</form>

首先,我们在表单中添加一个名为flightForm的模板变量,然后使用该变量检查表单是否有效。如果表单无效,我们禁用按钮的点击:

图片

为了处理提交,向表单添加一个ngSubmit事件。当按钮被点击时,将调用此事件:

<form #flightForm="ngForm" (ngSubmit)="handleSubmit()">
    ...
</form>

你现在可以添加一个名为handleSubmit的方法,用于处理表单提交。在这个例子中,简单的控制台日志可能就足够了:

export class FlightFormComponent implements OnInit {
  flightModel: Flight;
  cities:Array<string> = [
    ...
  ];
  constructor() {
    this.flightModel = new Flight('', '', '', '', 0, '', 0, 0, '');
  }

  // Handle for submission
  handleSubmit() {
    console.log(this.flightModel);
  }
}

事件处理

表单并不是我们接收用户输入的唯一方式。简单的 DOM 交互、鼠标点击和键盘交互都可以引发可能导致用户请求的事件。当然,我们必须以某种方式处理他们的请求。这本书中有很多我们无法讨论的事件。我们可以做的是查看基本的键盘和鼠标事件。

鼠标事件

为了演示两种流行的鼠标事件,点击和双击,创建一个新的 Angular 项目,然后添加以下自动生成的app.component.html

<div class="container">
  <div class="row">
    <h3 class="text-center">
      {{counter}}
    </h3>
    <div class="buttons">
      <div class="btn btn-primary">
        Increment
      </div>
      <div class="btn btn-danger">
        Decrement
      </div>
    </div>
  </div>
</div>

使用插值和递增和递减按钮将counter属性绑定到视图。该属性在 app 组件中可用,并初始化为零:

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

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

以下是其基本外观:

图片

点击按钮没有任何作用。让我们给递增按钮添加一个点击事件,每次点击都会将计数属性加 1:

export class AppComponent {
  counter = 0;
  increment() {
    this.counter++
  }
}

我们需要将此事件处理程序绑定到模板中的按钮,以便在按钮被点击时实际增加计数器:

<div class="btn btn-primary" (click)="increment()">
   Increment
</div>

事件通过属性绑定到模板上,但将属性包裹在括号中。属性值成为组件类上的方法,将作为事件处理程序。

我们需要为递减功能提供相同的功能。假设递减是一个你想要确保用户有意实施的操作,你可以附加一个双击事件:

<div class="btn btn-danger" (dblclick)="decrement()">
  Decrement
</div>

如您所见,我们不是使用click,而是使用dblclick事件,然后将递减事件处理程序绑定到它。处理程序只是递增处理程序的逆操作,并检查我们是否达到了零:

decrement() {
  this.counter <= 0 ? (this.counter = 0) : this.counter--;
}

以下展示了新事件的实际应用:

图片

键盘事件

你可以通过监听各种键盘事件来跟踪键盘交互。keypress事件告诉你一个按钮被点击了;如果你有一个监听器附加到它上面,那么监听器就会被触发。你可以以与我们附加鼠标事件相同的方式附加键盘事件:

<div class="container" (keypress)="showKey($event)" tabindex="1">
  ...
  <div class="key-bg" *ngIf="keyPressed">
    <h1>{{key}}</h1>
  </div>
<div>

当按下键时,具有key-bg类的元素会被显示出来;它显示我们按下的确切键,该键存储在key属性中。keyPressed属性是一个布尔值,我们在按下键时将其设置为true

事件触发showKey监听器;让我们来实现它:

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

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  keyPressed = false;
  key = '';
  // ....
  showKey($event) {
    this.keyPressed = true;
    this.key = $event.key.toUpperCase();
    setTimeout(() => {
      this.keyPressed = false;
    }, 500)
  }
}

showKey处理程序执行以下操作:

  • 它将key属性设置为按下的键的值

  • 按下的键被表示为一个小写字符串,所以我们使用toUpperCase方法将其转换为大写

  • keyPressed属性被设置为true,因此它显示按下的键,然后在 500 毫秒后将其设置为false,所以显示的键会被隐藏

当你按下键(并且container div 有焦点时),以下截图显示了会发生什么:

摘要

你现在对通过表单或事件收集用户输入有了大量的了解。我们还涵盖了表单的重要特性,例如文本输入、验证、双向绑定、提交等。我们看到的示例事件涵盖了鼠标和键盘事件以及如何处理它们。所有这些有趣的遭遇都为你构建商业应用程序做好了准备。

第九章:使用 TypeScript 编写模块、指令和管道

模块化对于构建大型软件系统至关重要,Angular 项目也不例外。当我们的应用程序开始增长时,在一个入口模块中管理其不同的成员开始变得非常困难和混乱。当你有很多服务、指令和管道时,这变得更加具有挑战性。说到指令和管道,我们将花一些时间讨论本章中的用例和示例,同时在我们使用模块更好地管理应用程序的过程中进行探索。

指令

DOM 操作并不总是最好在组件中处理。组件应该尽可能瘦;这样,事情保持简单,你的代码可以轻松地移动和重用。那么,我们应该在哪里处理 DOM 操作呢?答案是指令。就像你应该将数据操作任务带到服务中一样,最佳实践建议你将重量级的 DOM 操作带到指令中。

Angular 中有三种类型的指令:

  • 组件

  • 属性指令

  • 结构指令

是的,组件!组件是有资格的指令。它们是有直接访问正在操作的模板的指令。在这本书中,我们已经看到了足够的组件;让我们专注于属性和结构指令。

属性指令

这类指令以其向 DOM 添加行为特性而闻名,但不会删除或添加任何 DOM 内容。例如,改变外观、显示或隐藏元素、操作元素的属性等等。

为了更好地理解属性指令,让我们构建一些应用于组件模板的 UI 指令。这些指令在应用时将改变 DOM 的行为。

使用以下命令在新的项目中创建一个新的指令:

ng generate directive ui-button

这将在应用程序文件夹中创建一个空的指令,内容如下:

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

@Directive({
  selector: '[appUiButton]'
})
export class UiButtonDirective {
  constructor() {}
}

Directive 装饰器首先从 @angular/core 模块导入。装饰器用于任何预期作为指令的类。就像组件上的装饰器一样,指令装饰器接受一个具有选择器属性的对象。当这个选择器应用于 DOM 时,指令的行为就会展现出来。

在这个例子中,我们试图实现的行为是通过单个属性来样式化一个完全未样式的按钮。让我们假设在我们的应用程序组件中有一个以下按钮:

<div class="container">
  <button>Click!!</button>
</div>

这只是一个简单的无聊按钮在屏幕上:

图片

要使用我们刚刚创建的属性指令,将其添加为无值的属性到按钮上:

<button appUiButton>Click!!</button>

接下来,找到一种方法从 directive 类中访问按钮元素。我们需要这种访问来能够从类中直接应用样式到按钮上。多亏了 ElementRef 类,它通过构造函数注入到指令中,给了我们访问原生元素的能力,这是按钮元素可以访问的地方:

import { Directive, ElementRef } from '@angular/core';

@Directive({
  selector: '[appUiButton]'
})
export class UiButtonDirective {
  constructor(el: ElementRef) {

  }
}

它被注入并解析到el属性中。我们可以从属性中访问按钮元素:

import { Directive, ElementRef } from '@angular/core';

@Directive({
  selector: '[appUiButton]'
})
export class UiButtonDirective {
  constructor(el: ElementRef) {
    el.nativeElement.style.backgroundColor = '#ff00a6';
  }
}

nativeElement属性允许你访问应用了属性指令的元素。然后你可以将值当作 DOM API 来处理,这就是为什么我们可以访问stylebackgroundColor属性:

你可以看到粉红色的背景被有效地应用了。让我们通过指令添加更多样式,使按钮更有趣:

import { Directive, ElementRef } from '@angular/core';

@Directive({
  selector: '[appUiButton]'
})
export class UiButtonDirective {
  constructor(el: ElementRef) {
    Object.assign(el.nativeElement.style, {
      backgroundColor: '#ff00a6',
      padding: '7px 15px',
      fontSize: '16px',
      color: '#fff',
      border: 'none',
      borderRadius: '4px'
    })
  }
}

我们不再使用多个点来设置值,而是使用Object.assign方法来减少我们需要编写的代码量。现在,我们在浏览器中有一个更漂亮的按钮,完全使用指令进行样式设置:

在指令中处理事件

指令非常灵活,允许你根据用户触发的事件应用不同的状态。例如,我们可以给按钮添加一个悬停行为,当鼠标光标移到按钮上时,按钮应用不同的颜色(比如黑色):

import { 
  Directive, 
  ElementRef, 
  HostListener } from '@angular/core';

@Directive({
  selector: '[appUiButton]'
})
export class UiButtonDirective {
  constructor(private el: ElementRef) {
    Object.assign(el.nativeElement.style, {
      backgroundColor: '#ff00a6',
      ...
    })
  }

  @HostListener('mouseenter') onMouseEnter() {
    this.el.nativeElement.style.backgroundColor = '#000';
  }

  @HostListener('mouseleave') onMouseLeave() {
    this.el.nativeElement.style.backgroundColor = '#ff00a6';
  }
}

我们向这个文件引入了一些成员:

  • 我们导入了HostListener,这是一个扩展类中方法的装饰器。它将方法转换为一个附加到原生元素的事件监听器。装饰器接受一个事件类型的参数。

  • 我们在onMouseEnteronMouseLeave上定义了两个方法,然后用HostListener装饰这些方法。这些方法在鼠标悬停时改变按钮的背景颜色。

当我们将鼠标悬停在按钮上时,行为如下:

动态属性指令

如果我们,这个指令的作者,是最终的消费者?如果另一个开发者将指令作为 API 重用呢?我们如何使它足够灵活以处理动态值?当你写指令时问自己这些问题,那么是时候让它变得动态了。

在此期间,我们一直在没有值的情况下使用指令。实际上,我们可以使用属性值将输入接收进指令:

<button appUiButton bgColor="red">Click!!</button>

我们添加了一个新的属性,bgColor,它不是一个指令而是一个输入属性。该属性用于将动态值发送到指令,如下所示:

import { 
  Directive, 
  ElementRef, 
  HostListener, 
  Input,
  OnInit } from '@angular/core';

@Directive({
  selector: '[appUiButton]'
})
export class UiButtonDirective implements OnInit {
  @Input() bgColor: string;
  @Input() hoverBgColor: string;
  constructor(private el: ElementRef) {}

  ngOnInit() {
    Object.assign(this.el.nativeElement.style, {
      backgroundColor: this.bgColor || '#ff00a6',
      padding: '7px 15px',
      fontSize: '16px',
      color: '#fff',
      border: 'none',
      borderRadius: '4px'
    })
  }

  @HostListener('mouseenter') onMouseEnter() {
    console.log(this.bgColor);
    this.el.nativeElement.style.backgroundColor = this.hoverBgColor || '#000';
  }

  @HostListener('mouseleave') onMouseLeave() {
    this.el.nativeElement.style.backgroundColor = this.bgColor || '#ff00a6';
  }
}

这里是我们引入的更改:

  • 两个装饰过的Input属性--bgColorbgHoverColor--被引入,作为从模板到指令的动态值流。

  • 这个指令的设置从构造函数移动到了ngOnInit方法。这是因为输入装饰器是由 Angular 的变更检测设置的,这不会在构造函数中发生,因此当我们尝试从构造函数中访问它们时,bgColorbgHoverColor是未定义的。

  • 在设置样式时,我们不是使用硬编码的backgroundColor值,而是使用通过bgColor接收到的值。我们还提供了一个回退值,以防开发者忘记包含该属性。

  • 同样的事情也发生在鼠标进入和鼠标离开事件上。

现在,按钮的视觉效果受到动态值的影响:

图片

结构指令

结构指令在创建方式上与属性指令有很多共同之处,但在预期行为上却非常不同。与属性指令不同,结构指令预期会创建或删除一个 DOM 元素。这与使用 CSS 显示属性来显示或隐藏一个元素不同。在这种情况下,元素仍然在 DOM 树中,但在隐藏时对最终用户不可见。

一个很好的例子是 *ngIf。当使用 *ngIf 结构指令从 DOM 中删除元素时,该指令既从屏幕上消失,也从 DOM 树中删除。

为什么会有这种差异?

你控制 DOM 元素可见性的方式可能会对你的应用程序性能产生重大影响。

以折叠面板为例,用户预期会点击它来显示更多信息。用户可能会在查看内容后决定隐藏折叠面板的内容,然后在稍后时间回来重新打开它以供参考。很明显,折叠面板的内容有在任意时间显示和隐藏的倾向。

当这种情况发生时,最好使用一个属性指令,该指令不会隐藏/删除折叠面板内容,而只是隐藏它。这使得在需要时快速显示和再次隐藏变得非常快。使用结构指令如 *ngIf 会不断创建和销毁 DOM 树的一部分,如果被控制的 DOM 内容很大,这可能会非常昂贵。

另一方面,当你有一些你确信用户只会查看一次或最多两次的内容时,最好使用结构指令如 *ngIf。这样,你的 DOM 就不会被大量未使用的 HTML 内容所充斥。

星号的处理

所有结构指令之前的前缀星号非常重要。当你从 *ngIf*ngFor 指令中移除星号时,这些指令将拒绝工作,这意味着星号是必需的。因此,问题是:为什么星号必须在那里?

在 Angular 中,它们是语法糖,意味着它们不必以这种方式编写。这就是它们的实际样子:

<div template="ngIf true">
  <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Nesciunt non perspiciatis consequatur sapiente provident nemo similique. Minus quo veritatis ratione, quaerat dolores optio facilis dolor nemo, tenetur, obcaecati quibusdam, doloremque.</p>
</div>

这个模板属性反过来会被 Angular 转换为以下内容:

<ng-template [ngIf]="true">
  <div template="ngIf true">
    <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit....</p>
  </div>
</ng-template>

看看 ngIf 现在已经成为一个正常的 Angular 属性,但被注入到模板中。当值为 false 时,模板将从 DOM 树中移除(而不是隐藏)。以这种方式编写这样的指令只是写了很多代码,所以 Angular 添加了语法糖来简化我们编写 ngIf 指令的方式:

<div *ngIf="true">
  <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Nesciunt non perspiciatis consequatur sapiente provident nemo similique.</p>
</div>

创建结构指令

我们已经在之前的示例中看到了如何使用结构指令。我们如何创建它们?我们通过在您的终端运行以下命令来创建它们,就像我们创建属性指令一样:

ng generate directive when

是的,我们正在将指令命名为when。这个指令确实与*ngIf做的一样,所以,希望它能帮助您更好地理解您已经使用的指令的内部机制。

使用以下内容更新指令:

import { 
  Directive, 
  Input, 
  TemplateRef, 
  ViewContainerRef } from '@angular/core';

@Directive({
  selector: '[appWhen]'
})
export class WhenDirective {
  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef) { }
}

我们介绍了一些您还不熟悉的成员。TemplateRef是对我们之前看到的ng-template模板的引用,其中包含我们正在控制的 DOM 内容。ViewContainerRef是对视图本身的引用。

当在视图中使用appWhen指令时,它期望接收一个条件,例如ngIf。为了接收这样的条件,我们需要创建一个装饰的Input设置方法:

export class WhenDirective {
  private hasView = false;

  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef) { }

  @Input() set appWhen(condition: boolean) {
    if (condition && !this.hasView) {
      this.viewContainer.createEmbeddedView(this.templateRef);
      this.hasView = true;
    } else if (!condition && this.hasView) {
      this.viewContainer.clear();
      this.hasView = false;
    }
  }
}

指令中的设置方法检查值是否解析为true,然后显示内容并创建视图(如果尚未创建)。当值解析为false时,情况相反。

让我们通过点击属性指令部分中我们一直在努力工作的按钮来测试指令。当按钮被点击时,它会切换一个属性到truefalse。这个属性绑定到我们创建的指令的值。

使用以下内容更新应用程序组件类:

export class AppComponent {
  toggle = false;
  updateToggle() {
    this.toggle = !this.toggle;
  }
}

updateToggle方法绑定到按钮上,以便在用户点击时翻转toggle的值。以下是应用程序组件 HTML 的样式:

<h3 
  style="text-align:center" 
  *appWhen="toggle"
 >Hi, cute directive</h3>

<button 
  appUiButton 
  bgColor="red" 
  (click)="updateToggle()"
>Click!!</button>

一旦您点击按钮,它就会通过添加或从屏幕中移除文本来显示或隐藏文本:

管道

另一个我们尚未讨论的有趣模板功能是管道。管道允许您在模板内直接格式化模板内容。您不需要在组件中格式化内容,只需在模板中编写一个管道即可完成。以下是一个管道的完美示例:

<div class="container">
  <h2>{{0.5 | percent}}</h2>
</div>

在小数点后添加| percent会将值转换为百分比表示,如下面的截图所示:

这里是另一个使用其中一个案例管道的示例:

<div class="container">
  <h2>{{0.5 | percent}}</h2>
  <h3>{{'this is uppercase' | uppercase}}</h3>
</div>

uppercase管道将文本字符串转换为大写。以下是前面代码示例的输出:

一些管道接受参数,这有助于在将管道应用于某些内容时微调管道的行为。这样的管道示例是货币管道,它接受一个参数来定义内容将使用哪种货币进行格式化:

<h2>{{50.989 | currency:'EUR':true}}</h2>

以下截图显示了一个格式良好的值:

管道接受两个由冒号(:)分隔的参数。第一个参数是我们设置为欧元的货币。第二个参数是一个布尔值,表示显示的货币符号类型。因为值为true,所以显示了欧元符号。以下是当值为false时的输出:

图片

它不是使用符号,而是用货币代码(EUR)在值之前。

创建管道

我们已经看到了我们可以使用管道做什么以及在哪里使用它们。接下来我们需要了解的是如何使用 TypeScript 类创建我们自己的自定义管道。首先,运行以下命令来生成一个空管道:

ng generate pipe reverse

然后,使用以下内容更新生成的类文件:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'reverse'
})
export class ReversePipe implements PipeTransform {

  transform(value: any, args?: any): any {
    return value.split('').reverse().join('');
  }

}

这个例子接受一个字符串并返回字符串的逆序版本。ReversePipe类实现了PipeTransform接口,该接口定义了一个必须以一定签名创建的transform方法,如之前所见。

该类被Pipe装饰器装饰,该装饰器接受一个配置对象作为参数。该对象必须定义一个name属性,该属性作为管道应用于模板时的标识符。在我们的例子中,管道的名称是reverse

你现在可以将你的自定义管道应用于模板:

<h3>{{'watch me flip' | reverse}}</h3> 

当你查看示例时,文本被反转,所以它现在以 p 开头以 w 结尾:

图片

向管道传递参数

我们看到了如何创建管道,但我们也在心里记着管道接受参数。我们如何将这些参数添加到我们的自定义管道中?

由于传递给转换方法的可选args参数,生成的管道可能已经从上一个示例中给出了提示:

transform(value: any, args?: any): any {
    ...
}

假设我们想要定义字符串的逆序是按字母还是按单词进行的,向管道用户提供这种控制的最佳方式是通过参数。以下是一个更新的示例:

export class ReversePipe implements PipeTransform {

  transform(value: any, args?: any): any {
    if(args){
      return value.split(' ').reverse().join(' ');
    } else {
      return value.split('').reverse().join('');
    }
  }

}

当提供的参数是true时,我们按单词而不是按字母逆序字符串。这是通过在存在空白处分割字符串来完成的,而不是空字符串。当它是false时,我们在空字符串处分割,这基于字母逆序字符串。

我们现在可以在传递参数时使用管道:

<h2>{{'watch me flip' | reverse:true}}</h2> 

这是生成的输出:

图片

模块

我们在文章开头提到了模块以及它们如何帮助我们组织项目。考虑到这一点,看看这个应用模块:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { UiButtonDirective } from './ui-button.directive';
import { WhenDirective } from './when.directive';

@NgModule({
  declarations: [
    AppComponent,
    UiButtonDirective,
    WhenDirective
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

这里是一个来自指令的模块:

examples:import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { ReversePipe } from './reverse.pipe';

@NgModule({
  declarations: [
    AppComponent,
    ReversePipe
  ],
  imports: [
    BrowserModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

如果你如此关注细节,你可能已经注意到我们从未在指令中添加UiButtonDirectiveWhenDirective。同样,在管道示例中也没有添加ReversePipe。当运行generate命令时,这些添加会自动应用于所有成员,除了服务。

对于你创建的所有成员,即组件、指令、管道和服务,你需要将它们包含在它们所属的模块中。

模块(通常称为NgModule)是一个被NgModule装饰器装饰的类。这个装饰器接受一个配置对象,它告诉 Angular 关于在应用中创建的成员以及它们属于哪里。

这里是不同的属性:

  • 声明: 组件、指令和管道必须在声明数组中定义,以便它们可以被暴露给应用。如果没有这样做,将在你的控制台中记录错误,告诉你被省略的成员没有被识别。

  • 导入: 应用模块不是唯一存在的模块。你可以有更小、更简单的模块,它们将相关的任务成员分组在一起。在这种情况下,你仍然需要将较小的模块导入到应用模块中。导入数组就是你在那里做的。这些较小的模块通常被称为功能模块。一个功能模块也可以被导入到另一个功能模块中。

  • 提供者: 如果你有一些抽象特定任务的服务,并且需要通过依赖注入将它们注入到应用中,你需要在提供者数组中指定这些服务。

  • 引导: 引导数组仅在入口模块中声明,这通常是应用模块。这个数组定义了哪个组件应该首先启动或哪个组件作为你应用的入口点。值总是AppComponent,因为那是入口点。

摘要

你学到了很多概念,从指令和管道到模块。你学习了不同类型的指令(属性和结构)以及如何创建每一个。我们还讨论了在创建管道时如何传递参数。在下一章中,我们将讨论 Angular 应用程序中的路由以及 TypeScript 如何发挥重要作用。

第十章:客户端路由(SPA)

单页应用(SPA)是一个术语,用来指代从单个服务器路由提供但具有多个客户端视图的应用。单个服务器路由通常是默认的(/*)。一旦单个服务器路由被加载,客户端(JavaScript)就会劫持页面并开始使用浏览器的路由机制来控制路由。

能够从 JavaScript 中控制路由使开发者能够构建更好的用户体验。本章描述了如何在 Angular 中使用 TypeScript 编写的类、指令等来实现这一点。

就像每一章一样,我们将通过实际例子来完成这个任务。

RouterModule

就像表单一样,Angular 在 CLI 脚手架中默认不生成路由。这是因为你可能不需要在正在工作的项目中使用它。为了使路由工作,你需要将其导入需要使用它的模块中:

import { RouterModule }   from '@angular/router';

该模块公开了一个静态的forRoot方法,它接受一个路由数组。这样做为导入RouterModule的模块注册和配置了这些路由。从在app文件夹中创建一个routes.ts文件开始:

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    component: HomeComponent
  },
  {
    path: 'about',
    component: AboutComponent
  },
  {
    path: 'contact',
    component: ContactComponent
  }
];

Routes类的签名是一个数组,它接受一个或多个对象。传入的对象应该有一个路径和一个组件属性。路径属性定义位置,而组件属性定义应该在定义的路径上挂载的 Angular 组件。

你可以在AppModule中使用这些数组来配置RouterModule。我们已经导入了RouterModule,所以让我们导入routes文件并在imports数组中配置路由:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
//Import RuterModule
import { RouterModule } from '@angular/router';

import { AppComponent } from './app.component';

//Imprt routes
import { routes } from './routes';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    // RouterModule used to
    // configure routes
    RouterModule.forRoot(routes)
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

这就是配置 Angular 路由所需的所有步骤。路由的组件尚未创建,所以如果你尝试运行应用,你将在终端看到相同的错误:

让我们使用 CLI 生成这些组件:

ng generate component home
ng generate component about
ng generate component contact

然后,更新路由配置以导入组件:

import { Routes } from '@angular/router';

import { ContactComponent } from './contact/contact.component';
import { AboutComponent } from './about/about.component';
import { HomeComponent } from './home/home.component';

export const routes: Routes = [
  {
    path: '',
    component: HomeComponent
  },
  {
    path: 'about',
    component: AboutComponent
  },
  {
    path: 'contact',
    component: ContactComponent
  }
];

再运行一次应用,看看你是否已经消除了错误:

路由指令

我知道你迫不及待想看到浏览器中的示例,但如果你尝试在端口4200测试应用,你仍然会看到app组件的内容。这是因为我们没有告诉 Angular 它应该在何处挂载路由。

Angular 暴露了两个重要的路由指令:

  • 路由出口:这定义了路由配置应该挂载的位置。这通常是在单页应用的入口组件中。

  • 路由链接:这用于定义 Angular 路由的导航。基本上,它向锚标签添加功能,以便更好地与 Angular 应用中定义的路由一起工作。

让我们替换应用组件模板的内容,以利用路由指令:

<div>
  <nav class="navbar navbar-inverse">
    <div class="container-fluid">
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <ul class="nav navbar-nav">
          <li><a routerLink="/">Home</a></li>
          <li><a routerLink="/about">About</a></li>
          <li><a routerLink="/contact">Contact</a></li>
        </ul>
      </div>
    </div>
  </nav>
  <div class="container">
    <router-outlet></router-outlet>
  </div>
</div>

当我们访问相应的路由时,具有container类的 div 是每个组件将被显示的地方。我们可以通过点击具有routerLink指令的锚标签来导航到每个路由。

打开浏览器并访问本地的4200端口。默认情况下,你应该能看到主页:

尝试点击导航栏中的关于或联系链接。如果你遵循了所有步骤,你应该看到应用程序用关于或联系组件替换了主页组件:

注意地址栏也会更新为我们配置中定义的路径位置:

主-详细信息视图与路由

一个非常常见的 UI 模式是有一个没有太多项目信息的项目列表。当项目被选中、点击或鼠标悬停时,会显示每个项目的详细信息。

每个项目通常被称为主项目,而与项目交互后显示的内容被称为子项目或详细信息。

让我们构建一个简单的博客,在主页上显示文章列表,当点击每篇文章时,会显示帖子页面,你可以阅读所选的文章。

数据源

对于一个基本的例子,我们不需要数据库或服务器。一个包含博客文章的简单 JSON 文件就足够了。在你的app文件夹中创建一个名为db.json的文件,其结构如下:

[
  {
    "imageId": "jorge-vasconez-364878_me6ao9",
    "collector": "John Brian",
    "description": "Yikes invaluably thorough hello more some that neglectfully on badger crud inside mallard thus crud wildebeest pending much because therefore hippopotamus disbanded much."
  },
  {
    "imageId": "wynand-van-poortvliet-364366_gsvyby",
    "collector": "Nnaemeka Ogbonnaya",
    "description": "Inimically kookaburra furrowed impala jeering porcupine flaunting across following raccoon that woolly less gosh weirdly more fiendishly ahead magnificent calmly manta wow racy brought rabbit otter quiet wretched less brusquely wow inflexible abandoned jeepers."
  },
  {
    "imageId": "josef-reckziegel-361544_qwxzuw",
    "collector": "Ola Oluwa",
    "description": "A together cowered the spacious much darn sorely punctiliously hence much less belched goodness however poutingly wow darn fed thought stretched this affectingly more outside waved mad ostrich erect however cuckoo thought."
  },
....
]

结构显示了一个帖子数组。每个帖子都有一个imageID,一个作者作为收藏者,以及一个描述作为帖子内容。

TypeScript 默认情况下,当你尝试将 JSON 文件导入 TypeScript 文件时,不会理解该 JSON 文件。为了解决这个问题,需要使用以下声明定义typings

// ./src/typings.d.ts
declare module "*.json" {
  const value: any;
  export default value;
}

博客服务

记住我们提到过,将我们应用程序的业务逻辑放在组件中是一个坏主意。尽可能避免直接从组件与数据源交互。我们更愿意创建一个服务类来为我们完成同样的工作:

ng generate service blog

更新生成的空服务如下:

import { Injectable } from '@angular/core';
import * as rawData from './db.json';

@Injectable()
export class BlogService {
  data = <any>rawData;
  constructor() { }

  getPosts() {
    return this.data.map(post => {
      return {
        id: post.imageId,
        imageUrl: `https://res.cloudinary.com/christekh/image/upload/c_fit,q_auto,w_300/${post.imageId}`,
        author: post.collector
      }
    })
  }

  byId(id) {
    return this.data
      .filter(post => post.imageId === id)
      .map(post => {
        return {
          id: post.imageId,
          imageUrl: `https://res.cloudinary.com/christekh/image/upload/c_fit,q_auto,w_300/${post.imageId}`,
          author: post.collector,
          content: post.description
        }
      })[0]
  }

}

让我们谈谈服务中发生的事情:

  1. 首先,我们导入我们创建的数据源。

  2. 接下来,我们创建一个getPosts方法,它返回转换后的所有帖子。我们还使用图像 ID 生成一个图像 URL。这是通过将 ID 附加到 Cloudinary([cloudinary.com/](cloudinary.com/))图像服务器 URL 来完成的。这些图像在使用之前已上传到 Cloudinary。

  3. byId方法接受 ID 作为参数,使用过滤方法找到具有该 ID 的帖子,然后转换检索到的帖子。转换后,我们从数组中获取第一个也是唯一的项目。

要公开这个服务,你需要将其添加到app模块中的providers数组:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { BlogService } from './blog.service';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule
  ],
  providers: [
    BlogService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

创建路由

现在我们已经有了数据源和与之交互的服务,是时候开始处理将消费这些数据的路由和组件了。在app文件夹中添加一个routes.ts文件,包含以下配置:

import { Routes } from '@angular/router';

import { HomeComponent } from './home/home.component';
import { PostComponent } from './post/post.component';

export const routes: Routes = [
  {
    path: '',
    component: HomeComponent
  },
  {
    path: 'post/:id',
    component: PostComponent
  }
]

指向post的第二条路由有一个:id占位符。这用于定义一个动态路由,这意味着传入的 ID 值可以用来控制挂载组件的行为。

创建我们之前导入的两个组件:

# Generate home component
ng generate component home

# Generate post component
ng generate component post

更新app模块以导入配置的路由,使用RouterModule

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';

import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { PostComponent } from './post/post.component';
import { BlogService } from './blog.service';
import { routes } from './routes';

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    PostComponent
  ],
  imports: [
    BrowserModule,
    RouterModule.forRoot(routes)
  ],
  providers: [
    BlogService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

要挂载路由,将应用组件模板的全部内容替换为以下标记:

<div class="wrapper">
  <router-outlet></router-outlet>
</div>

在主页组件中列出帖子

我们在主页上挂载的主页组件预期将显示帖子列表。因此,它需要与博客服务交互。更新类如下:

import { Component, OnInit } from '@angular/core';
import { BlogService } from './../blog.service';

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

  public posts;
  constructor(
    private blogService: BlogService
  ) { }

  ngOnInit() {
    this.posts = this.blogService.getPosts();
  }

}

组件依赖于BlogService类,该类在构造函数中解析。然后使用blogService实例获取帖子列表并将其传递给posts属性。这个属性将绑定到视图。

要在浏览器中显示这些帖子,我们需要遍历它们并在组件的模板中显示它们:

<div class="cards">
  <div class="card" *ngFor="let post of posts">
    <div class="card-content">
      <img src="img/{{post.imageUrl}}" alt="{{post.author}}">
      <h4>{{post.author}}</h4>
    </div>
  </div>
</div>

这就是当你运行应用时的样子:

图片

我们需要定义与文章卡片交互的行为。当点击卡片时,我们可以使用路由器链接指令导航到帖子页面。然而,因为我们已经看到了这一点,所以让我们使用第二种选项,即在 TypeScript 方法中定义行为。首先,添加一个事件监听器:

<div class="cards">
  <div class="card" *ngFor="let post of posts" (click)="showPost(post.id)">
    ...
  </div>
</div>

当点击卡片时,我们打算调用showPost方法。此方法接收被点击图像的 ID。以下是方法实现:

import { Router } from '@angular/router';

...
export class HomeComponent implements OnInit {

  public posts;
  constructor(
    private blogService: BlogService,
    private router: Router
  ) { }

  ngOnInit() {
    this.posts = this.blogService.getPosts();
  }

  showPost(id) {
    this.router.navigate(['/post', id]);
  }

}

showPost方法使用路由器的navigate方法移动到新的路由位置。

使用 post 组件阅读文章

post 组件仅显示一个包含所有详细信息的单个帖子。为了显示这个单个帖子,它从 URL 接收参数并将参数传递给博客服务类中的byId方法:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { BlogService } from './../blog.service';

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

  public post;
  constructor(
    private route: ActivatedRoute,
    private blogService: BlogService,
  ) { }

  ngOnInit() {
    this.route.params.subscribe(params => {
      this.post = this.blogService.byId(params.id)
      console.log(this.post)
   });
  }

}

ActivatedRoute类公开一个params属性,它是一个 Observable。你可以订阅这个 Observable 来获取传递给给定路由的参数。我们将post属性设置为byId方法返回的过滤后的值。

现在,你可以在模板中显示帖子:

<div class="detail">
  <img src="img/{{post.imageUrl}}" alt="">
  <h2>{{post.author}}</h2>

  <p>{{post.content}}</p>
</div>

打开应用,并点击每个卡片。它应该带你去它们各自的详情页面:

图片

摘要

在 Angular 中进行路由配置非常重要,它可以是您日常项目中大部分内容的一部分。在这种情况下,这对你来说不会是一个全新的概念。这是因为这一章节已经向您介绍了一些路由基础知识,包括构建导航和客户端路由,构建主子视图关系,通过开发一个简单的博客系统来实现。在下一章中,您将应用所学的知识来构建一个真正使用真实和托管数据的 APP。

第十一章:与真实托管数据一起工作

现代网络应用通常是数据驱动的。大多数情况下,我们需要从各种资源中 CRUD(创建、读取、更新和删除)数据,或者消费 API。Angular 使我们能够轻松地处理外部数据源,用于我们的组件。

Angular 提供了一个简单的 HTTP API,它为我们提供了 HTTP 功能。它是基于现代浏览器暴露的本地 XMLHttpRequest 接口构建的,并且使用它,我们可以执行以下任何 HTTP 操作:

  • 获取:从资源请求数据

  • 发布:向资源提交数据

  • 更新:在资源中修改数据

  • 删除:删除指定的资源

在本章中,我们将学习如何使用 Angular 消费 API 并使我们的应用数据驱动。

可观察对象

可观察对象,类似于承诺,有助于处理应用程序中的异步事件。可观察对象与承诺之间的关键区别是:

  • 可观察对象可以在一段时间内处理多个值,而承诺只被调用一次并返回一个值

  • 可观察对象是可取消的,而承诺不是

要使用可观察对象,Angular 利用JavaScript 的响应式扩展RxJs)的可观察对象库。Angular 在处理 HTTP 请求和响应时广泛使用可观察对象;我们将在本章中了解更多关于它们的内容。

HTTP 模块

要在组件中使用 HTTP,你需要安装HttpModule,它在你的应用程序模块中提供它。首先,导入模块:

import { HttpModule } from '@angular/http';

接下来,你将模块包含在你的应用程序中注册的导入数组中,紧随BrowserModule之后:

// app.module.ts
@NgModule({
imports: [
BrowserModule,
HttpModule,
],
})

构建简单的 todo 演示应用

让我们构建一个简单的todo应用,以便更好地理解如何在 Angular 应用中处理数据。

将使用 Angular-CLI 快速搭建应用程序。应用程序的 API 将使用 Express.js 构建,我们的 Angular 应用将连接到这个 API 以 CRUD todo 数据。

项目设置

使用 CLI 创建新项目:

ng new [project name]

ng new命令创建一个新的 Angular 应用

构建 API

从命令行,使用 npm 安装 express、body-parser 和 cors 作为依赖项:

npm install express body-parser cors

如果你使用 npm 5,你不需要指定-S--save标志来在package.json文件中将它保存为依赖项。

接下来,我们将在 Angular 项目的根目录中创建一个server.js文件,它将包含我们所有的 API 逻辑:

// server.js
const express = require('express');
const path = require('path');
const http = require('http');
const bodyParser = require('body-parser');
const cors = require('cors');
const app = express();
// Get API routes
const route = require('./routes/index');
// Parser for POST data
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// Use CORS
app.use(cors());
// Declare API routes
app.use('/api', route);
/**
* Get port from environment. Default is 3000
*/
const port = process.env.PORT || '3000';
/**
* Create HTTP server.
*/
const server = http.createServer(app);
/**
* Listen on port
*/
app.listen(port, function () {
console.log(`API running on port ${port}`)
} );

此文件使用 ES6 的新版本,所以你应该注意你的代码编辑器不立即识别的情况。

/api路由指向./routes/index.js文件,但我们还没有它。在这个下一步中,我们将创建它。仍然在root目录中,创建一个名为routes的文件夹,并在其中创建一个名为index.js的文件:

// routes/index.js
const express = require('express');
// create a new router object
const router = express.Router();
/* GET api listing. */
router.get('/', (req, res) => {
res.send('api works');
});
module.exports = router;

要启动服务器,输入以下命令:

node server.js

当服务器开始运行时的输出如下:

这里我们可以看到服务器正在运行,并且监听端口 3000。

打开浏览器并访问localhost:3000/api/

图片

如果你能在前面的图像中看到响应,那么 API 就正常工作了。现在我们可以引入更复杂的逻辑,以便我们有实际的数据可以操作。

安装 diskdb

Diskdb 是一个轻量级的基于磁盘的 JSON 数据库,具有类似 MongoDB 的 API,适用于 Node。我们可以使用以下命令安装 diskdb:

npm install diskdb

在目录根目录下创建一个 todos.json 文件。这个文件将作为我们的数据库集合,其中包含我们的待办事项。你可以在 www.npmjs.com/package/diskdb 上了解更多关于 diskdb 的信息。

更新 API 端点

让我们更新 routes/index.js 文件,以包含处理我们的待办事项的新逻辑:

// routes/index.js
const express = require('express');
const router = express.Router();
// require diskdb
const db = require('diskdb');
db.connect(__dirname, ['todos']);
// store Todo
router.post('/todo', function(req, res, next) {
var todo = req.body;
if (!todo.action || !(todo.isDone + '')) {
res.status(400);
res.json({
error: 'bad data'
});
} else {
db.todos.save(todo);
res.json(todo);
}
});
// get Todos
router.get('/todos', function(req, res, next) {
const todos = db.todos.find();
res.json(todos);
});
// update Todo
router.put('/todo/:id', function(req, res, next) {
const todo = req.body;
db.todos.update({_id: req.params.id}, todo);
res.json({ msg: `${req.params.id} updated`});
});
// delete Todo
router.delete('/todo/:id', function(req, res, next) {
db.todos.remove({
_id: req.params.id
});
res.json({ msg: `${req.params.id} deleted` });
});
module.exports = router;

在前面的代码中,我们能够使用 getpostputdelete 端点更新我们的 API。

接下来,我们将使用一些数据初始化我们的数据库。更新 todos.json 文件:

[{
"action":"write more code",
"isDone":false,"
_id":"97a8ee67b6064e06aac803662d98a46c"
},{
"action":"help the old lady",
"isDone":false,"
_id":"3d14ad8d528549fc9819d8b54e4d2836"
},{
"action":"study",
"isDone":true,"
_id":"e77cb6d0efcb4b5aaa6f16f7adf41ed6"
}]

现在,我们可以重新启动我们的服务器并访问 localhost:3000/api/todos 来查看我们的 API 在行动:

图片

数据库中的待办事项列表。

创建 Angular 组件

接下来,我们将创建一个 todo 组件。我们可以使用 Angular-CLI 轻松地做到这一点,使用以下命令:

ng generate component todos

这将生成以下文件:todos.component.tstodos.component.htmltodos.component.ts。待办事项组件也自动导入到 app.module.ts

// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { TodosComponent } from './todos/todos.component';
@NgModule({
declarations: [
AppComponent,
TodosComponent
],
imports: [
BrowserModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

我们应该确保在 src/index.html 的 head 标签内添加 <base href="/">。这是为了告诉路由器如何组合导航 URL。当使用 Angular-CLI 生成 angular 项目时,自动创建了 index.html 文件:

<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Data</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

创建应用程序路由

接下来,我们将创建一个 /todos 路由,并让我们的应用默认重定向到它。

首先,从 @angular/router 导入 RouterModule 并将其添加到 AppModule 的 imports 数组中:

import { RouterModule } from '@angular/router';
...
imports: [
...
RouterModule.forRoot(ROUTES)
],

ngModule 声明上方创建一个 ROUTES 数组,并向其中添加以下路由定义:

const ROUTES = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
component: TodosComponent
}
]

app.component.html 文件中,让我们添加一个路由出口,以便渲染路由:

<div style="text-align:center">
<h1>
Welcome to {{ title }}!
</h1>
<router-outlet></router-outlet>

创建待办事项服务

接下来,我们将创建一个服务来处理调用并将我们的组件连接到 express API。要使用 Angular-CLI 生成服务:

ng generate service todos

服务已创建但尚未注册——为了在我们的应用中注册它,我们需要将其添加到主应用程序模块的 providers 部分。

Angular-CLI 不会自动注册服务。

将 TodosService 添加到 providers 数组:

import {TodosService} from './todos.service';
...
providers: [TodosService],
...
})
export class AppModule { }

现在,在我们的服务中,我们将向 express 服务器发出 HTTP 调用来执行我们的 CRUD 操作。首先,我们将导入 HTTPHeadersrxjs/add/operator/map

import { Injectable } from '@angular/core';
import { Http, Headers} from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
// constructor and methods to execute the crud operations will go in here
}

定义一个构造函数并注入 HTTP 服务:

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
constructor(private http: Http) {}
}
Next, we will define a method that will fetch all todos from the API. Updating todos.service.ts:
// todo.service.ts
...
export class TodosService {
isDone: false;
constructor(private http: Http) {}
// Get all todos
getTodos() {
return this.http
.get('http://localhost:3000/api/todos')
.map(res => res.json());
}
}

在前面的代码中,我们使用了 HttpModule 来向我们的 API 发送一个简单的 get 请求以检索待办事项列表。请求的响应随后以 JSON 格式返回。

接下来,我们将编写一个名为 addTodos() 的方法来存储待办事项。这个方法将用于执行存储待办事项的 POST 请求。

// todo.service.ts
...
addTodos(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/todo', JSON.stringify(todo), { headers })
.map(res => res.json());
}
}

在前面的代码中,我们设置了新的头部并设置了Content-Type以告诉服务器它将接收什么类型的内容('application/json')。

我们使用了http.post()方法来发送 POST 请求。参数JSON.stringify(todo)表示我们希望将新的待办事项作为 JSON 编码的字符串发送。最后,我们可以以 JSON 格式返回 API 的响应。

接下来,我们将定义一个名为deleteTodo()的删除方法。此方法将用于执行删除请求。这使得我们能够从待办事项列表中删除待办事项。再次更新todos.service.ts

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
constructor(private http: Http) {}
getTodos() {
return this.http
.get('http://localhost:3000/api/todos')
.map(res => res.json());
}
addTodos(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/todo', JSON.stringify(todo), { headers })
.map(res => res.json());
}
deleteTodo(id) {
return this.http
.delete(`http://localhost:3000/api/todo/${id}`)
.map(res => res.json());
}
}

在前面的代码中,我们定义了deleteTodo()方法,它将待删除帖子的id作为其唯一参数。此方法向 API 发送删除请求以从数据库中删除指定的待办事项。API 的响应也以 JSON 格式返回。

最后,我们将定义一个名为updateStatus()的方法。此方法将用于执行put请求以更改待办事项项的状态。

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
isDone: false;
constructor(private http: Http) {}
getTodos() {
return this.http
.get('http://localhost:3000/api/todos')
.map(res => res.json());
}
addTodos(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/todo', JSON.stringify(todo), { headers })
.map(res => res.json());
}
deleteTodo(id) {
return this.http
.delete(`http://localhost:3000/api/todo/${id}`)
.map(res => res.json());
}
updateStatus(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.put('http://localhost:3000/api/todo/' + todo._id, JSON.stringify(todo), {
headers: headers
})
.map(res => res.json());
}
}

在前面的代码中,我们创建了一个updateStatus()方法,该方法与addTodos()方法类似。这里的区别在于,updateStatus()方法执行一个put请求。我们还把todo._id连接到被调用的 API 端点。这使得我们能够从待办事项列表中修改单个项目的状态。

记住,我们在服务中使用了 HTTP API,因此,我们应该在app.module.ts中导入HttpModule并将其包含在导入数组中:

import {HttpModule} from '@angular/http';
...
imports: [
HttpModule,
BrowserModule,
RouterModule.forRoot(ROUTES)
],
...

将服务与我们的待办事项组件连接起来

首先,我们必须在待办事项组件中导入待办事项服务:

import {TodosService} from '../todos.service';

然后在组件的构造函数中添加TodosService类:

constructor(private todoService: TodosService) { }

现在,我们将使用待办事项服务来getcreatedeleteupdate待办事项。

这是我们待办事项组件应该看起来像的:

import { Component, OnInit } from '@angular/core';
import { TodosService } from '../todos.service';
@Component({
selector: 'app-todos',
templateUrl: './todos.component.html',
styleUrls: ['./todos.component.css']
})
export class TodosComponent implements OnInit {
//define data types
todos: any = [];
todo: any;
action: any;
name: any;
isDone: boolean;
constructor(private todoService: TodosService) {}
ngOnInit() {
this.todoService.getTodos().subscribe(todos => {
this.todos = todos;
});
}
addTodos(event) {
event.preventDefault();
let newTodo = {
name: this.name,
action: this.action,
isDone: false
};
this.todoService.addTodos(newTodo).subscribe(todo => {
this.todos.push(todo);
this.name = '';
this.action = '';
});
}
deleteTodo(id) {
let todos = this.todos;
this.todoService.deleteTodo(id).subscribe(data => {
const index = this.todos.findIndex(todo => todo._id == id);
todos.splice(index, 1)
});
}
updateStatus(todo) {
var _todo = {
_id: todo._id,
action: todo.action,
isDone: !todo.isDone
};
this.todoService.updateStatus(_todo).subscribe(data => {
const index = this.todos.findIndex(todo => todo._id == _todo._id)
this.todos[index] = _todo;
});
}
choice(todo) {
console.log(todo);
return todo.isDone;
}
}

我们刚刚使服务和组件之间建立了通信。现在component.ts文件可以使用服务和其中的方法。

现在我们已经连接了服务和组件,我们必须在浏览器中显示待办事项操作,这将在todos.component.html中完成。

实现视图

要显示待办事项,我们将使用:

  • Angular 的*ngFor指令,它遍历待办事项数组并为数组中的每个待办事项渲染此模板的一个实例

  • Angular 的插值绑定语法,{{}}

更新todos.component.html

<div class="container">
<form (submit) = "addTodos($event)">
<input type="text"
class="form-control" placeholder="action"
[(ngModel)] ="action" name="action">
<button type="submit"><h4>Submit</h4></button>
</form>
<div *ngFor="let todo of todos">
<div class="container">
<p (click)="updateStatus(todo)" [ngStyle]="{ 'text-decoration': todo.isDone ? 'line-through' : ''}" >Action: {{todo.action}}</p>
{{todo.isDone}}
<button (click) ="deleteTodo(todo._id)" >Delete</button>
</div>
</div>
</div>

为了使我们的应用看起来更好,我们将使用 Bootstrap。Bootstrap是一个强大的前端框架,用于创建网页和用户界面组件,如表单、模态框、手风琴、轮播图和标签页:

<!-- Index.html --&gt;
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<meta charset="utf-8">
<title>Data</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

更新todos.component.html

<form (submit) = "addTodos($event)">
<input type="text" class="form-control" placeholder="action" [(ngModel)] ="action" name="action">
<button class="btn btn-primary" type="submit"><h4>Submit</h4></button>
</form>
<div class="card pos" style="width: 20rem;" *ngFor="let todo of todos">
<div class="card-body">
<h4 class="card-title" (click)="updateStatus(todo)" [ngStyle]="{ 'text-decoration': todo.isDone ? 'line-through' : ''}">{{todo.action}}</h4>
<p class="card-text">{{todo.isDone}}</p>
<button (click) ="deleteTodo(todo._id)" class="btn btn-danger">Delete</button>
</div>
</div>
We'll also update app.component.css file to add some optional extra styling.
// app.component.css
.isDone{
text-decoration: line-through;
}
.pos{
margin-left: 40%;
margin-top: 10px;
}

打开命令行/终端并导航到项目文件夹。运行 node server.js以启动服务器。在project文件夹中打开另一个终端窗口并运行ng serve以提供 Angular 应用。

打开浏览器并访问localhost:4200。以下截图显示了结果应该看起来像:

我们通过通过服务向节点服务器发送 HTTP 请求并然后通过组件将结果渲染到 DOM 中,成功地创建了一个待办事项应用程序。您可以添加待办事项、删除待办事项、获取所有待办事项,当您点击待办事项时,布尔值会改变,并且在该特定待办事项上会出现删除线。当您重新加载浏览器时,您可以看到对待办事项列表所做的更改已保留。

让我们对我们所做的一切进行简要回顾:

  • 首先,我们使用 Angular-CLI 创建了一个 Angular 应用程序

  • 然后,我们创建了一个服务器文件,其中我们引入了我们的依赖项,创建了一个 express 应用程序,设置了我们的 API 路由,声明了服务器监听的端口,添加了用于解析 post 数据的解析器等等

  • 然后,我们定义了我们的数据源,它是一个与 diskdb 通信的 .json 文件待办事项

  • 创建了一个 Angular 组件

  • 使用 getpostputdelete 方法创建了一个与 REST API 通信的服务

让我们看看另一个例子。我们将创建一个简单的应用程序来显示用户列表以及他们的电子邮件和电话号码。用户还将有一个真或假的标志,表示他们是否可用或不可用。

使用 Angular 构建用户目录

我们即将构建的应用程序将有一个 REST API,它将在本例的执行过程中创建。在这个简单的例子中,我们将创建一个 users 应用程序,它将非常简单。该应用程序基本上是一个包含用户列表及其电子邮件地址和电话号码的表格。表格中的每个用户都将有一个 活动 状态,其值为布尔值。我们将能够将特定用户的 活动 状态从假更改为真,反之亦然。该应用程序将使我们能够添加新用户并从表中删除用户。就像上一个例子一样,diskDB 将被用作本例的数据库。我们将有一个 Angular 服务,其中包含负责与 REST 端点通信的方法。这些方法将负责向 REST API 发送 getpostputdelete 请求。服务中的第一个方法将负责向 API 发送 get 请求。这将使我们能够从后端检索所有用户。接下来,我们将有一个另一个方法,它向 API 发送 post 请求。这将使我们能够将新用户添加到现有用户的数组中。

下一个方法将负责向 API 发送 delete 请求,以便启用用户的删除。最后,我们将有一个发送 put 请求到 API 的方法。这将是我们编辑/修改用户状态的方法。为了与 RESTful API 进行这些请求,我们必须使用 HttpModule。本节的目标是巩固你对 HTTP 的理解。作为一个 JavaScript 和,实际上是一个 Angular 开发者,你几乎肯定会与 API 和 Web 服务器进行交互。今天开发者使用的许多数据都是以 API 的形式,为了与这些 API 进行交互,我们需要不断地使用 HTTP 请求。事实上,HTTP 是网络数据通信的基础。

创建一个新的 Angular 应用

如前所述,要启动一个新的 Angular 应用,请运行以下命令:

ng new user

这创建了 Angular 2 用户应用。

安装以下依赖项:

  • Express

  • Body-parser

  • Cors

npm install express body-parser cors --save

创建一个 Node 服务器

在项目目录的根目录下创建一个名为 server.js 的文件。这将是我们 Node 服务器。

server.js 中填充以下代码块:

// Require dependencies
const express = require('express');
const path = require('path');
const http = require('http');
const cors = require('cors');
const bodyParser = require('body-parser');
// Get our API routes
const route = require('./route');
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// Use CORS
app.use(cors());
// Set our api routes
app.use('/api', route);
/**
* Get port from environment.
*/
const port = process.env.PORT || '3000';
/**
* Create HTTP server.
*/
const server = http.createServer(app);
//Listen on provided port
app.listen(port);
console.log('server is listening');

这里发生的事情相当简单:

  • 我们引入并使用了依赖项

  • 我们定义并设置了 API 路由

  • 我们为我们的服务器设置了一个监听端口

API 路由正在从 ./route 中导入,但这个路径尚不存在。让我们快速创建它。

在项目目录的根目录下,创建一个名为 route.js 的文件。这里将创建 API 路由。我们需要有一个数据库的形式,从中我们可以获取、发布、删除和修改数据。

就像在之前的例子中一样,我们将使用 diskdb。路由将基本上与第一个例子中的模式相同。

安装 diskDB

在项目文件夹中运行以下命令以安装 diskdb:

npm install diskdb

在项目目录的根目录下创建一个 users.json 文件,作为我们的数据库集合,其中包含我们的用户详细信息。

users.json 中填充以下内容:

[{"name": "Marcel", "email": "test1@gmail.com", "phone_number":"08012345", "isOnline":false}]

现在,更新 route.js

route.js
const express = require('express');
const router = express.Router();
const db = require('diskdb');
db.connect(__dirname, ['users']);
//save
router.post('/users', function(req, res, next) {
var user = req.body;
if (!user.name && !(user.email + '') && !(user.phone_number + '') && !(user.isActive + '')) {
res.status(400);
res.json({
error: 'error'
});
} else {
console.log('ds');
db.users.save(todo);
res.json(todo);
}
});
//get
router.get('/users', function(req, res, next) {
var foundUsers = db.users.find();
console.log(foundUsers);
res.json(foundUsers);
foundUsers = db.users.find();
console.log(foundUsers);
});
//updateUsers
router.put('/user/:id', function(req, res, next) {
var updUser = req.body;
console.log(updUser, req.params.id)
db.users.update({_id: req.params.id}, updUser);
res.json({ msg: req.params.id + ' updated' });
});
//delete
router.delete('/user/:id', function(req, res, next) {
console.log(req.params);
db.users.remove({
_id: req.params.id
});
res.json({ msg: req.params.id + ' deleted' });
});
module.exports = router;

我们使用 API 路由创建了一个 RESTful API,使用 diskDB 作为数据库。

使用以下命令启动服务器:

node server.js

服务器正在运行,并且正在监听指定的端口。现在,打开浏览器并访问 http://localhost:3000/api/users

这里,我们可以看到我们输入到 users.json 文件中的数据。这表明我们的路由正在工作,并且我们从数据库中获取了数据。

创建一个新的组件

运行以下命令以创建一个新的组件:

ng g component user

这创建了user.component.tsuser.component.htmluser.component.cssuser.component.spec.ts文件。User.component.spec.ts用于测试,因此我们将在本章中不使用它。新创建的组件会自动导入到app.module.ts中。我们必须告诉根组件关于用户组件的信息。我们将通过将user.component.ts中的选择器导入到根模板组件(app.component.html)中来实现这一点:

<div style="text-align:center">
<app-user></app-user>
</div>

创建一个服务

下一步是创建一个与之前创建的 API 交互的服务:

ng generate service user

这创建了一个名为user.service.ts的用户服务。接下来,将UserService类导入到app.module.ts中,并将其包含在提供者数组中:

Import rxjs/add/operator/map in the imports section.
import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
Within the UserService class, define a constructor and pass in the angular 2 HTTP service.
import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class UserService {
constructor(private http: Http) {}
}

在服务类中,编写一个方法,通过 API 获取所有用户及其详细信息,发送一个get请求:

getUser() {
return this.http
.get('http://localhost:3000/api/users')
.map(res => res.json());
}

编写一个发送post请求并创建新待办事项的方法:

addUser(newUser) {
var headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/user', JSON.stringify(newUser), {
headers: headers
})
.map(res => res.json());
}

编写另一个方法来发送一个delete请求。这将使我们能够从用户集合中删除一个用户:

deleteUser(id) {
return this.http
.delete('http://localhost:3000/api/user/' + id)
.map(res => res.json());
}

最后,编写一个发送put请求的方法。此方法将使我们能够修改用户的状态:

updateUser(user) {
var headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.put('http://localhost:3000/api/user/' + user._id, JSON.stringify(user), {
headers: headers
})
.map(res => res.json());
}

更新app.module.ts以导入HttpModuleFormsModule,并将它们包含在导入数组中:

import { HttpModule } from '@angular/http';
import { FormsModule } from '@angular/forms';
.....
imports: [
.....
HttpModule,
FormsModule
]

下一步是教会用户组件使用该服务:

Import UserService in user.component.ts.
import {UserService} from '../user.service';
Next, include the service class in the user component constructor.
constructor(private userService: UserService) { }.
Just below the exported UserComponent class, add the following properties and define their data types:
users: any = [];
user: any;
name: any;
email: any;
phone_number: any;
isOnline: boolean;

现在,我们可以在用户组件中使用用户服务的方法。

更新user.component.ts

ngOnInit方法中,使用用户服务从 API 获取所有用户:

ngOnInit() {
this.userService.getUser().subscribe(users => {
console.log(users);
this.users = users;
});
}

ngOnInit方法下方,编写一个方法,使用用户服务中的post方法添加新用户:

addUser(event) {
event.preventDefault();
var newUser = {
name: this.name,
email: this.email,
phone_number: this.phone_number,
isOnline: false
};
this.userService.addUser(newUser).subscribe(user => {
this.users.push(user);
this.name = '';
this.email = '';
this.phone_number = '';
});
}

让我们使用用户服务中的delete方法来使我们能够删除用户:

deleteUser(id) {
var users = this.users;
this.userService.deleteUser(id).subscribe(data => {
console.log(id);
const index = this.users.findIndex(user => user._id == id);
users.splice(index, 1)
});
}

最后,我们将使用用户服务来对 API 进行put请求:

updateUser(user) {
var _user = {
_id: user._id,
name: user.name,
email: user.email,
phone_number: user.phone_number,
isActive: !user.isActive
};
this.userService.updateUser(_user).subscribe(data => {
const index = this.users.findIndex(user => user._id == _user._id)
this.users[index] = _user;
});
}

我们已经与 API、服务和组件进行了所有通信。我们必须更新user.component.html以在浏览器中展示我们所做的一切。

我们将使用 Bootstrap 进行样式设计。因此,我们必须在index.html中导入 Bootstrap CDN:

<!doctype html>
<html lang="en">
<head>
//bootstrap CDN
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<meta charset="utf-8">
<title>User</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

更新user.component.html

这是用户组件的组件模板:

<form class="form-inline" (submit) = "addUser($event)">
<div class="form-row">
<div class="col">
<input type="text" class="form-control" [(ngModel)] ="name" name="name">
</div>
<div class="col">
<input type="text" class="form-control" [(ngModel)] ="email" name="email">
</div>
<div class="col">
<input type="text" class="form-control" [(ngModel)] ="phone_number" name="phone_number">
</div>
</div> <br>
<button class="btn btn-primary" type="submit" (click) = "addUser($event)"><h4>Add User</h4></button>
</form>
<table class="table table-striped" >
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone_Number</th>
<th>Active</th>
</tr>
</thead>
<tbody *ngFor="let user of users">
<tr>
<td>{{user.name}}</td>
<td>{{user.email}}</td>
<td>{{user.phone_number}}</td>
<td>{{user.isActive}}</td>
<td><input type="submit" class="btn btn-warning" value="Update Status" (click)="updateUser(user)" [ngStyle]="{ 'text-decoration-color:': user.isActive ? 'blue' : ''}"></td>
<td><button (click) ="deleteUser(user._id)" class="btn btn-danger">Delete</button></td>
</tr>
</tbody>
</table>

代码块中有很多内容,让我们深入代码块:

  • 我们有一个表单,它接受三个输入和一个提交按钮,当点击时会触发addUser()方法

  • 有一个删除按钮,当点击时会触发delete方法

  • 还有一个更新状态输入元素,当点击时会触发updateUser()方法

  • 我们创建了一个表格,使用 Angular 的*ngFor指令和 Angular 的插值绑定语法{{}}来显示我们的用户详细信息

将添加一些额外的样式到项目中。转到user.component.css并添加以下内容:

form{
margin-top: 20px;
margin-left: 20%;
size: 50px;
}
table{
margin-top:20px;
height: 50%;
width: 50%;
margin-left: 20%;
}
button{
margin-left: 20px;
}

运行应用

打开两个命令行界面/终端。在它们两个中,导航到项目目录。在一个中运行node server.js以启动服务器。在另一个中运行ng serve以提供 Angular 2 应用程序。

打开浏览器并访问localhost:4200

在这个简单的用户应用程序中,我们可以执行所有 CRUD 操作。我们可以创建新用户,获取用户,删除用户,以及更新用户的州。

默认情况下,新添加的用户活跃状态为 false。可以通过点击更改状态按钮来修改。

摘要

在开发任何应用程序时,使用数据库或 API 的实际数据非常重要。HTTP 以及可观察对象和 Rxjs 使得从 API 中处理所需的数据集成为可能,并且可以执行所有 CRUD 操作。

在下一章中,我们将探讨编写单元测试和调试。