Skip to content

Files

Latest commit

Dec 7, 2016
8f6835f · Dec 7, 2016

History

History
This branch is 48 commits behind master.

ng2-tut

README.md

Table of Contents generated with DocToc

Angular 2.x 从0到1

史上最简单Angular 2.x教程,大叔都学会了

作者:王芃 wpcfan@gmail.com 标签(空格分隔): 编程 前端 javascript 框架 教程

[TOC]


前言 -- 一个大叔码农的Angular2创世纪

作为一个出生于70年代的大叔,我在软件这个领域已经摸爬滚打了16年:从程序员、项目经理、产品经理、项目总监到部门管理等各个角色都体验过。


第一章:认识Angular 2.0

Angular2简介

Angular 2 是Google推出的一个跨平台全终端的框架,和目前比较火的React和Vue.js相比,有如下优点:

  1. 由于Google的目的是推出一个完整解决方案,所以官方默认提供的类库(比如routing,http,依赖性注入(DI)等)非常完整,无需自己选择。React的一大痛点就是选择太多导致在配置寻找组件和类库的过程中消耗太多精力,当然从另一方面看这也是其优势,选择众多且自由。
  2. 官方支持TypeScript(微软出品,是JavaScript的超集,是 JavaScript 的强类型版本)作为首选编程语言,使得开发脚本语言的一些问题可以更早更方便的找到。
  3. RxJS友好使得响应式编程在Augular2中变得极为容易(Google开发的框架依赖这么多的微软的产品,可见微软的转型还是很成功的)
  4. 支持NativeScript甚至ReactNative等进行原生Android/iOS应用开发(React支持React Native)
  5. 支持服务器端渲染(React也支持)

但总体来讲,个人认为Angular2更适合从原生App开发或后端Java/.Net等转型过来开发前端的程序员,因为它的开发模型更接近于传统强类型语言的模式,加上官方内建的组件和类库比较完整,有官方中文站点,学习曲线要低一些。有过Angular 1.x 开发经验的同学要注意了,虽然只有一个版本号的差距,但2.x和1.x是完全不同的,不要奢望1.x的应用会平滑迁移到2.x。

环境配置要求

Angular2需要node.js和npm,我们下面的例子需要node.js 6.x.x和npm 3.x.x,请使用 node -vnpm -v 来检查。由于众所周知的原因,http://npmjs.org 的站点访问经常不是很顺畅,这里给出一个由淘宝团队维护的国内镜像 http://npm.taobao.org/ 。安装好node后,请输入npm config set registry https://registry.npm.taobao.org

官方快速起步文档给出的例子不同,我们下面要使用Angular团队目前正在开发中的一个工具--Angular CLI 。这是一个类似于React CLI和Ember CLI的命令行工具,用于快速构建Angular2的应用。它的优点是进一步屏蔽了很多配置的步骤、自动按官方推荐的模式进行代码组织、自动生成组件/服务等模板以及更方便的发布和测试代码。由于目前这个工具还在beta阶段,安装时请使用 npm install -g angular-cli@latest 命令。

IDE的选择也比较多,免费的Visual Studio CodeAtom,收费的有WebStorm。我们这里推荐采用 Visual Studio Code,可以到 https://code.visualstudio.com/ 下载 Windows/Linux/MacOS 版本。

安装完以上这些工具,开发环境就部署好了,下面我们将开始Angular2的探险之旅。

第一个小应用 Hello Angular

那么现在开启一个terminal(命令行窗口),键入 ng new hello-angular

c1_s1_ng_new_hello-angular.png-51.7kB

如上图所示,这个命令为我们新建了一个名为“hello-angular”的工程,进入该工程目录,键入 code . 可以打开IDE看到如下目录

c1_s1_vscode_project_struct.png-300.1kB

image_1b2aksv68uhs3mf64j1h9st7o9.png-292.3kB

大概了解了文件目录结构后,我们重新回到命令行,在应用根目录键入 ng serve 可以看到应用编译打包后server运行在4200端口。 c1_s1_ng_serve.png-42.5kB 打开浏览器输入 http://localhost:4200 即可看到程序运行成功啦!

c1_s1_project_1st_browser.png-135.7kB

自动生成的太没有成就感了是不是,那么我们动手改一下吧。保持运行服务的命令窗口,然后进入VSCode,打开 src/app/app.component.ts 修改title,比如: title = 'This is a hello-angular app';,保存后返回浏览器看一下吧,结果已经更新了,这种热装载的特性使得开发变得很方便。

c1_s1_project_1st_browser_update.png-146.5kB

第一个组件

那么我们来为我们的app增加一个Component吧,在命令行窗口输入 ng generate component login --inline-template --inline-style 。 顾名思义,参数generate是用来生成文件的,参数component是说明我们要生成一个组件,login呢是我们的组件名称,你可以自己想个其他有意思的名字。后面的两个参数是告诉angular-cli:生成组件时,请把组件的HTML模板和CSS样式和组件放在同一个文件中(其实分开文件更清晰,但第一个例子我们还是采用inline方式了)。是不是感觉这个命令行太长了?幸运的是Angular团队也这么想,所以你可以把上面的命令改写成 ng g c login -it -is ,也就是说可以用generate的首字母g来代替generate,用component的首字母c来代替component,类似的--inline-template的两个词分别取首字母变成-it

image_1b27r02qlo6f11f19qg1q9k1fclm.png-30.3kB

angular-cli为我们在\src\app目录下生成了一个新文件夹login,在login目录下生成了2个文件,其中 login.component.spec.ts 是测试文件,我们这里暂时不提。另一个是 login.component.ts 这个就是我们新建的Component了。Angular提倡的文件命名方式是这样的:组件名称.component.ts ,组件的HTML模板命名为: 组件名称.component.html,组件的样式文件命名为: 组件名称.component.css,大家在编码中尽量遵循Google的官方建议。

我们新生成的Login组件源码如下

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

//@Component是Angular提供的装饰器函数,用来描述Compoent的元数据
//其中selector是指这个组件的在HTML模板中的标签是什么
//template是嵌入(inline)的HTML模板,如果使用单独文件可用templateUrl
//styles是嵌入(inline)的CSS样式,如果使用单独文件可用styleUrls
@Component({
  selector: 'app-login',
  template: `
    <p>
      login Works!
    </p>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

那么这个组件建成后,我们怎么使用呢?注意上面的代码中@Component修饰配置中的 selector: 'app-login',这意味着我们可以在其他组件的template中使用 <app-login></app-login> 来引用我们的这个组件。

现在我们打开 hello-angular\src\app\app.component.html 加入我们的组件引用

<h1>
  {{title}}
</h1>
<app-login></app-login>

保存后返回浏览器,可以看到我们的第一个组件也显示出来了。

image_1b27qsmhp1nlrb8g1uh6cp71qcj9.png-19kB

一些基础概念

这里我们粗略介绍一些Angular的基础概念,这些基础概念在后面的章节中会更详细的讲解。

什么是模块?

简单来说模块就是提供相对独立功能的功能块,每块聚焦于一个特定业务领域。Angular内建的很多库是以模块形式提供的,比如FormsModule封装了表单处理,HttpModule封装了Http的处理等等。每个Angular应用至少有一个模块类 —— 根模块,我们将通过引导根模块来启动应用。按照约定,根模块的类名叫做AppModule,被放在 app.module.ts 文件中。我们这个例子中的根模块位于 hello-angular\src\app\app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

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

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

@NgModule装饰器用来为模块定义元数据。declarations列出了应用中的顶层组件,包括引导性组件AppComponent和我们刚刚创建的LoginComponent。在module里面声明的组件在module范围内都可以直接使用,也就是说在同一module里面的任何Component都可以在其模板文件中直接使用声明的组件,就想我们在AppComponent的模板末尾加上 <app-login></app-login> 一样。

imports引入了3个辅助模块:

  • BrowserModule提供了运行在浏览器中的应用所需要的关键服务(Service)和指令(Directive),这个模块所有需要在浏览器中跑的应用都必须引用;
  • FormsModule提供了表单处理和双向绑定等服务和指令
  • HttpModule提供Http请求和响应的服务

providers列出会在此模块中“注入”的服务(Service),关于依赖性注入会在后面章节中详细解释。 bootstrap指明哪个组件为引导性组件(本案例中的AppComponent)。当Angular引导应用时,它会在DOM中渲染这个引导性组件,并把结果放进index.html的该组件的元素标签中(本案例中的app-root)。

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>HelloAngular</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>Loading...</app-root>
</body>
</html>

引导过程

Angular2通过在main.ts中引导AppModule来启动应用。针对不同的平台,Angular提供了很多引导选项。下面的代码是通过即时(JiT)编译器动态引导,一般在进行开发调试时,默认采用这种方式。

//main.ts
import './polyfills.ts';

// 连同Angular编译器一起发布到浏览器
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { enableProdMode } from '@angular/core';
import { environment } from './environments/environment';
import { AppModule } from './app/';

if (environment.production) {
  enableProdMode();
}
//Angular编译器在浏览器中编译并引导该应用
platformBrowserDynamic().bootstrapModule(AppModule);

另一种方式是使用预编译器(AoT - Ahead-Of-Time)进行静态引导,静态方案可以生成更小、启动更快的应用,建议优先使用它,特别是在移动设备或高延迟网络下。使用static选项,Angular编译器作为构建流程的一部分提前运行,生成一组类工厂。它们的核心就是AppModuleNgFactory。引导预编译的AppModuleNgFactory的语法和动态引导AppModule类的方式很相似。

// 不把编译器发布到浏览器
import { platformBrowser } from '@angular/platform-browser';

// 静态编译器会生成一个AppModule的工厂AppModuleNgFactory
import { AppModuleNgFactory } from './app.module.ngfactory';

// 引导AppModuleNgFactory
platformBrowser().bootstrapModuleFactory(AppModuleNgFactory);

本节代码:https://github.com/wpcfan/awesome-tutorials/tree/chap01/angular2/ng2-tut

下一节我们再继续,记住大叔能学会的你也能。


第二章:用Form表单做一个登录控件

对于login组件的小改造

hello-angular\src\app\login\login.component.ts 中更改其模板为下面的样子

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

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input type="text">
      <button>Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

}

我们增加了一个文本输入框和一个按钮,保存后返回浏览器可以看到结果

c2_s1_input_button_added.png-109.6kB

接下来我们尝试给Login按钮添加一个处理方法 <button (click)="onClick()">Login</button>(click)表示我们要处理这个button的click事件,圆括号是说发生此事件时,调用等号后面的表达式或函数。等号后面的onClick()是我们自己定义在LoginComponent中的函数,这个名称你可以随便定成什么,不一定叫onClick()。下面我们就来定义这个函数,在LoginComponent中写一个叫onClick()的方法,内容很简单就是把“button was clicked”输出到Console。

  onClick() {
    console.log('button was clicked');
  }

返回浏览器,并按F12调出开发者工具。当你点击Login时,会发现Console窗口输出了我们期待的文字。

c2_s1_handle_click_method.png-141kB

那么如果要在onClick中传递一个参数,比如是上面的文本输入框输入的值怎么处理呢?我们可以在文本输入框标签内加一个#usernameRef,这个叫引用(reference)。注意这个引用是的input对象,我们如果想传递input的值,可以用usernameRef.value,然后就可以把onClick()方法改成onClick(usernameRef.value)

<div>
  <input #usernameRef type="text">
  <button (click)="onClick(usernameRef.value)">Login</button>
</div>

在Component内部的onClick方法也要随之改写成一个接受username的方法

  onClick(username) {
    console.log(username);
  }

现在我们再看看结果是什么样子,在文本输入框中键入“hello”,点击Login按钮,观察Console窗口:hello被输出了。 c2_s1_input_button_ref.png-141.1kB 好了,现在我们再加一个密码输入框,然后改写onClick方法可以同时接收2个参数:用户名和密码。代码如下:

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

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }

  onClick(username, password) {
    console.log('username:' + username + "\n\r" + "password:" + password);
  }

}

看看结果吧,在浏览器中第一个输入框输入“wang”,第二个输入框输入“1234567”,观察Console窗口,Bingo!

c2_s1_username_password_ref.png-141.8kB

建立一个服务去完成业务逻辑

如果我们把登录的业务逻辑在onClick方法中完成,当然也可以,但是这样做的耦合性太强了。设想一下,如果我们增加了微信登录、微博登录等,业务逻辑会越来越复杂,显然我们需要把这个业务逻辑分离出去。那么我们接下来创建一个AuthService吧, 首先我们在src\app下建立一个core的子文件夹(src\app\core),然后命令行中输入 ng g s core\auth (s这里是service的缩写,core\auth是说在core的目录下建立auth服务相关文件)。auth.service.tsauth.service.spec.ts这个两个文件应该已经出现在你的目录里了。

下面我们为这个service添加一个方法,你可能注意到这里我们为这个方法指定了返回类型和参数类型。这就是TypeScript带来的好处,有了类型约束,你在别处调用这个方法时,如果给出的参数类型或返回类型不正确,IDE就可以直接告诉你错了。

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

@Injectable()
export class AuthService {

  constructor() { }

  loginWithCredentials(username: string, password: string): boolean {
    if(username === 'wangpeng')
      return true;
    return false;
  }

}

等一下,这个service虽然被创建了,但仍然无法在Component中使用。当然你可以在Component中import这个服务,然后实例化后使用,但是这样做并不好,仍然时一个紧耦合的模式,Angular2提供了一种依赖性注入(Dependency Injection)的方法。

什么是依赖性注入?

如果不使用DI(依赖性注入)的时候,我们自然的想法是这样的,在login.component.ts中import引入AuthService,在构造中初始化service,在onClick中调用service。

import { Component, OnInit } from '@angular/core';
//引入AuthService
import { AuthService } from '../core/auth.service';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  //声明成员变量,其类型为AuthService
  service: AuthService;

  constructor() {
    this.service = new AuthService();
  }

  ngOnInit() {
  }

  onClick(username, password) {
    //调用service的方法
    console.log('auth result is: ' + this.service.loginWithCredentials(username, password));
  }

}

这么做呢也可以跑起来,但存在几个问题:

  • 由于实例化是在组件中进行的,意味着我们如果更改service的构造函数的话,组件也需要更改。
  • 如果我们以后需要开发、测试和生产环境配置不同的AuthService,以这种方式实现会非常不方便。

下面我们看看如果使用DI是什么样子的,首先我们需要在组件的修饰器中配置AuthService,然后在组件的构造函数中使用参数进行依赖注入。

import { Component, OnInit } from '@angular/core';
import { AuthService } from '../core/auth.service';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: [],
  //在providers中配置AuthService
  providers:[AuthService]
})
export class LoginComponent implements OnInit {
  //在构造函数中将AuthService示例注入到成员变量service中
  //而且我们不需要显式声明成员变量service了
  constructor(private service: AuthService) {
  }

  ngOnInit() {
  }

  onClick(username, password) {
    console.log('auth result is: ' + this.service.loginWithCredentials(username, password));
  }

}

看到这里你会发现我们仍然需要import相关的服务,这是import是要将类型引入进来,而provider里面会配置这个类型的实例。当然即使这样还是不太爽,可不可以不引入AuthService呢?答案是可以。

我们看一下app.module.ts,这个根模块文件中我们发现也有个providers,根模块中的这个providers是配置在模块中全局可用的service或参数的。

providers: [
    {provide: 'auth',  useClass: AuthService}
    ]

providers是一个数组,这个数组呢其实是把你想要注入到其他组件中的服务配置在这里。大家注意到我们这里的写法和上面优点区别,没有直接写成

providers:[AuthService]

而是给出了一个对象,里面有两个属性,provide和useClass,provide定义了这个服务的名称,有需要注入这个服务的就引用这个名称就好。useClass指明这个名称对应的服务是一个类,本例中就是AuthService了。这样定义好之后,我们就可以在任意组件中注入这个依赖了。下面我们改动一下login.component.ts,去掉头部的import { AuthService } from '../core/auth.service';和组件修饰器中的providers,更改其构造函数为

onstructor(@Inject('auth') private service) {
  }

我们去掉了service的类型声明,但加了一个修饰符@Inject('auth'),这个修饰符的意思是请到系统配置中找到名称为auth的那个依赖注入到我修饰的变量中。当然这样改完后你会发现Inject这个修饰符系统不识别,我们需要在@angular/core中引用这个修饰符,现在login.component.ts看起来应该是下面这个样子

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

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input #usernameRef type="text">
      <input #passwordRef type="password">
      <button (click)="onClick(usernameRef.value, passwordRef.value)">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  constructor(@Inject('auth') private service) {
  }

  ngOnInit() {
  }

  onClick(username, password) {
    console.log('auth result is: ' + this.service.loginWithCredentials(username, password));
  }

}

双向数据绑定

接下来的问题是我们是否只能通过这种方式进行表现层和逻辑之间的数据交换呢?如果我们希望在组件内对数据进行操作后再反馈到界面怎么处理呢?Angular2提供了一个双向数据绑定的机制。这个机制是这样的,在组件中提供成员数据变量,然后在模板中引用这个数据变量。我们来改造一下login.component.ts,首先在class中声明2个数据变量username和password。

  username = "";
  password = "";

然后去掉onClick方法的参数,并将内部的语句改造成如下样子:

console.log('auth result is: '
      + this.service.loginWithCredentials(this.username, this.password));

去掉参数的原因是双向绑定后,我们通过数据成员变量就可以知道用户名和密码了,不需要在传递参数了。而成员变量的引用方式是this.成员变量。 然后我们来改造模板:

    <div>
      <input type="text"
        [(ngModel)]="username"
        />
      <input type="password"
        [(ngModel)]="password"
        />
      <button (click)="onClick()">Login</button>
    </div>

[(ngModel)]="username"这个看起来很别扭,稍微解释一下,方括号[]的作用是说把等号后面当成表达式来解析而不是当成字符串,如果我们去掉方括号那就等于说是直接给这个ngModel赋值成“username”这个字符串了。方括号的含义是单向绑定,就是说我们在组件中给model赋的值会设置到HTML的input控件中。[()]是双向绑定的意思,就是说HTML对应控件的状态的改变会反射设置到组件的model中。ngModel是FormModule中提供的指令,它负责从Domain Model(这里就是username或password,以后我们可用绑定更复杂的对象)中创建一个FormControl的实例,并将这个实例和表单的控件绑定起来。同样的对于click事件的处理,我们不需要传入参数了,因为其调用的是刚刚我们改造的组件中的onClick方法。现在我们保存文件后打开浏览器看一下,效果和上一节的应该一样的。本节的完整代码如下:

//login.component.ts
import { Component, OnInit, Inject } from '@angular/core';

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input type="text"
        [(ngModel)]="username"
        />
      <input type="password"
        [(ngModel)]="password"
        />
      <button (click)="onClick()">Login</button>
    </div>
  `,
  styles: []
})
export class LoginComponent implements OnInit {

  username = '';
  password = '';

  constructor(@Inject('auth') private service) {
  }

  ngOnInit() {
  }

  onClick() {
    console.log('auth result is: '
      + this.service.loginWithCredentials(this.username, this.password));
  }

}

表单数据的验证

通常情况下,表单的数据是有一定的规则的,我们需要依照其规则对输入的数据做验证以及反馈验证结果。Angular2中对表单验证有非常完善的支持,我们继续上面的例子,在login组件中,我们定义了一个用户名和密码的输入框,现在我们来为它们加上规则。首先我们定义一下规则,用户名和密码都是必须输入的,也就是不能为空。更改login.component.ts中的模板为下面的样子

    <div>
      <input required type="text"
        [(ngModel)]="username"
        #usernameRef="ngModel"
        />
        {{usernameRef.valid}}
      <input required type="password"
        [(ngModel)]="password"
        #passwordRef="ngModel"
        />
        {{passwordRef.valid}}
      <button (click)="onClick()">Login</button>
    </div>

注意到我们只是为username和password两个控件加上了required这个属性,表明这两个控件为必填项。通过#usernameRef="ngModel"我们重新又加入了引用,这次的引用指向了ngModel,这个引用是要在模板中使用的,所以才加入这个引用如果不需要在模板中使用,可以不要这句。{{表达式}}双花括号表示解析括号中的表达式,并把这个值输出到模板中。这里我们为了可以显性的看到控件的验证状态,直接在对应控件后输出了验证的状态。初始状态可以看到2个控件的验证状态都是false,试着填写一些字符在两个输入框中,看看状态变化吧。

c2_s2_form_validation.png-8.5kB

我们是知道了验证的状态是什么,但是如果我们想知道验证失败的原因怎么办呢?我们只需要将{{usernameRef.valid}}替换成{{usernameRef.errors | json}}|是管道操作符,用于将前面的结果通过管道输出成另一种格式,这里就是把errors对象输出成json格式的意思。看一下结果吧,返回的结果如下

c2_s2_form_validation_errors.png-11kB

如果除了不能为空,我们为username再添加一个规则试试看呢,比如字符数不能少于3。

      <input type="text"
        [(ngModel)]="username"
        #usernameRef="ngModel"
        required 
        minlength="3"
        />

c2_s2_form_validation_errors_multiple.png-14.4kB

现在我们试着把{{表达式}}替换成友好的错误提示,我们想在有错误发生时显示错误的提示信息。那么我们来改造一下template。

    <div>
      <input type="text"
        [(ngModel)]="username"
        #usernameRef="ngModel"
        required
        minlength="3"
        />
        {{ usernameRef.errors | json }}
        <div *ngIf="usernameRef.errors?.required">this is required</div>
        <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
      <input required type="password"
        [(ngModel)]="password"
        #passwordRef="ngModel"
        />
        <div *ngIf="passwordRef.errors?.required">this is required</div>
      <button (click)="onClick()">Login</button>
    </div>

ngIf也是一个Angular2的指令,顾名思义,是用于做条件判断的。*ngIf="usernameRef.errors?.required"的意思是当usernameRef.errors.requiredtrue时显示div标签。那么那个?是干嘛的呢?因为errors可能是个null,如果这个时候调用errorsrequired属性肯定会引发异常,那么?就是标明errors可能为空,在其为空时就不用调用后面的属性了。

如果我们把用户名和密码整个看成一个表单的话,我们应该把它们放在一对<form></form>标签中,类似的加入一个表单的引用formRef

    <div>
      <form #formRef="ngForm">
        <input type="text"
          [(ngModel)]="username"
          #usernameRef="ngModel"
          required
          minlength="3"
          />
          <div *ngIf="usernameRef.errors?.required">this is required</div>
          <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
        <input type="password"
          [(ngModel)]="password"
          #passwordRef="ngModel"
          required
          />
          <div *ngIf="passwordRef.errors?.required">this is required</div>
        <button (click)="onClick()">Login</button>
      </form>
    </div>

这时运行后会发现原本好用的代码出错了,这是由于如果在一个大的表单中,ngModel会注册成Form的一个子控件,注册子控件需要一个name,这要求我们显式的指定对应控件的name,因此我们需要为input增加name属性

    <div>
      <form #formRef="ngForm">
        <input type="text"
          name="username"
          [(ngModel)]="username"
          #usernameRef="ngModel"
          required
          minlength="3"
          />
          <div *ngIf="usernameRef.errors?.required">this is required</div>
          <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
        <input type="password"
          name="password"
          [(ngModel)]="password"
          #passwordRef="ngModel"
          required
          />
          <div *ngIf="passwordRef.errors?.required">this is required</div>
        <button (click)="onClick()">Login</button>
        <button type="submit">Submit</button>
      </form>
    </div>

既然我们增加了一个formRef,我们就看看formRef.value有什么吧。 首先为form增加一个表单提交事件的处理 <form #formRef="ngForm" (ngSubmit)="onSubmit(formRef.value)">。 然后在组件中增加一个onSubmit方法

  onSubmit(formValue) {
    console.log(formValue);
  }

你会发现formRef.value中包括了表单所有填写项的值。

c2_s2_form_validation_form_submit.png-27.7kB

有时候在表单项过多时我们需要对表单项进行分组,HTML中提供了fieldset标签用来处理。那么我们看看怎么和Angular2结合吧:

    <div>
      <form #formRef="ngForm" (ngSubmit)="onSubmit(formRef.value)">
        <fieldset ngModelGroup="login">
          <input type="text"
            name="username"
            [(ngModel)]="username"
            #usernameRef="ngModel"
            required
            minlength="3"
            />
            <div *ngIf="usernameRef.errors?.required">this is required</div>
            <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
          <input type="password"
            name="password"
            [(ngModel)]="password"
            #passwordRef="ngModel"
            required
            />
            <div *ngIf="passwordRef.errors?.required">this is required</div>
          <button (click)="onClick()">Login</button>
          <button type="submit">Submit</button>
        </fieldset>
      </form>
    </div>

<fieldset ngModelGroup="login">意味着我们对于fieldset之内的数据都分组到了login对象中。 c2_s2_form_validation_fieldset.png-43.5kB 接下来我们改写onSubmit方法用来替代onClick,因为看起来这两个按钮重复了,我们需要去掉onClick。首先去掉template中的<button (click)="onClick()">Login</button>,然后把<button type="submit">标签后的Submit文本替换成Login,最后改写onSubmit方法。

  onSubmit(formValue) {
    console.log('auth result is: '
      + this.service.loginWithCredentials(formValue.login.username, formValue.login.password));
  }

在浏览器中试验一下吧,所有功能正常工作。

验证结果的样式自定义

如果我们在开发工具中查看网页源码,可以看到

c2_s2_form_validation_form_styling.png-92.5kB

用户名控件的HTML代码是下面的样子:在验证结果为false时input的样式是ng-invalid

<input 
    name="username" 
    class="ng-pristine ng-invalid ng-touched" 
    required="" 
    type="text" 
    minlength="3" 
    ng-reflect-minlength="3" 
    ng-reflect-name="username">

类似的可以实验一下,填入一些字符满足验证要求之后,看input的HTML是下面的样子:在验证结果为true时input的样式是ng-valid

<input 
    name="username" 
    class="ng-touched ng-dirty ng-valid" 
    required="" 
    type="text" 
    ng-reflect-model="ssdsds" 
    minlength="3" 
    ng-reflect-minlength="3" 
    ng-reflect-name="username">

知道这个后,我们可以自定义不同验证状态下的控件样式。在组件的修饰符中把styles数组改写一下:

  styles: [`
    .ng-invalid{
      border: 3px solid red;
    }
    .ng-valid{
      border: 3px solid green;
    }
  `]

保存一下,返回浏览器可以看到,验证不通过时

c2_s2_form_validation_style_fail.png-8.9kB

验证通过时是这样的:

c2_s2_form_validation_style_pass.png-4.6kB

最后说一下,我们看到这样设置完样式后连form和fieldset都一起设置了,这是由于form和fieldset也在样式中应用了.ng-valid.ng-valid,那怎么解决呢?只需要在.ng-valid加上input即可,它表明的是应用于input类型控件并且class引用了ng-invalid的元素。

  styles: [`
    input.ng-invalid{
      border: 3px solid red;
    }
    input.ng-valid{
      border: 3px solid green;
    }
  `]

很多开发人员不太了解CSS,其实CSS还是比较简单的,我建议先从Selector开始看,Selector的概念弄懂后Angular2的开发CSS就会顺畅很多。具体可见W3School中对于CSS Selctor的参考https://css-tricks.com/multiple-class-id-selectors/。

组件样式

刚刚我们其实已经使用了组件样式,这里简单介绍一下什么是组件样式。对于我们写的每个Angular组件来说,除了定义HTML模板之外,我们还要定义用于模板的 CSS 样式、指定任意的选择器、规则和媒体查询。

实现方式之一,是在组件的元数据中设置styles属性。styles属性可以接受一个包含 CSS 代码的字符串数组。通常我们只给它一个字符串就行了,就像我们在LoginComponent中做的那样。

@Component({
  selector: 'app-login',
  template: `
    <div>
      <input type="text"
        [(ngModel)]="username"
        />
      <input type="password"
        [(ngModel)]="password"
        />
      <button (click)="onClick()">Login</button>
    </div>
  `,
  styles: [`
    input.ng-invalid{
      border: 3px solid red;
    }
    input.ng-valid{
      border: 3px solid green;
    }
  `]
})

组件样式在很多方面都不同于传统的全局性样式。我们放在组件样式中的选择器,只会应用在组件自身的模板中。上面这个例子中的input选择器只会对 LoginComponent模板中的<input>标签生效,而对应用中其它地方的<input>元素毫无影响。

这种模块化相对于CSS的传统工作方式有如下优点:

  1. CSS 类名和选择器是控件范围的。属于组件内部的,它不会和应用中其它地方的类名和选择器出现冲突。
  2. 我们组件的样式不会因为别的地方修改了样式而被意外改变。
  3. 我们可以让每个组件的CSS代码和它的TypeScript、HTML代码放在一起,这将促成清爽整洁的项目结构。
  4. 修改或移除组件的CSS代码时,不用搜索整个应用来看它有没有被别处用到。

本节代码: https://github.com/wpcfan/awesome-tutorials/tree/chap02/angular2/ng2-tut

进一步的练习

  • 练习1:如果我们想给username和password输入框设置默认值。比如“请输入用户名”和“请输入密码”,自己动手试一下吧。
  • 练习2:如果我们想在输入框聚焦时把默认文字清除掉,该怎么做?
  • 练习3:如果我们想把默认文字颜色设置成浅灰色该怎么做?

第三章:建立一个待办事项应用

这一章我们会建立一个更复杂的待办事项应用,当然我们的登录功能也还保留,这样的话我们的应用就有了多个相对独立的功能模块。以往的web应用根据不同的功能跳转到不同的功能页面。但目前前端的趋势是开发一个SPA(Single Page Application 单页应用),所以其实我们应该把这种跳转叫视图切换:根据不同的路径显示不同的组件。那我们怎么处理这种视图切换呢?幸运的是,我们无需寻找第三方组件,Angular官方内建了自己的路由模块。

建立routing的步骤

由于我们要以路由形式显示组件,建立路由前,让我们先把src\app\app.component.html中的<app-login></app-login>删掉。

  • 第一步:在src/index.html中指定基准路径,即在<header>中加入<base href="/">,这个是指向你的index.html所在的路径,浏览器也会根据这个路径下载css,图像和js文件,所以请将这个语句放在header的最顶端。
  • 第二步:在src/app/app.module.ts中引入RouterModule:import { RouterModule } from '@angular/router';
  • 第三步:定义和配置路由数组,我们暂时只为login来定义路由,仍然在src/app/app.module.ts中的imports中
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    RouterModule.forRoot([
      {
        path: 'login',
        component: LoginComponent
      }
    ])
  ],

注意到这个形式和其他的比如BrowserModule、FormModule和HTTPModule表现形式好像不太一样,这里解释一下,forRoot其实是一个静态的工厂方法,它返回的仍然是Module,下面的是Angular API文档给出的RouterModule.forRoot的定义。

forRoot(routes: Routes, config?: ExtraOptions) : ModuleWithProviders

为什么叫forRoot呢?因为这个路由定义是应用在应用根部的,你可能猜到了还有一个工厂方法叫forChild,后面我们会详细讲。接下来我们看一下forRoot接收的参数,参数看起来是一个数组,每个数组元素是一个{path: 'xxx', component: XXXComponent}这个样子的对象。这个数组就叫做路由定义(RouteConfig)数组,每个数组元素就叫路由定义,目前我们只有一个路由定义。路由定义这个对象包括若干属性:

  • path:路由器会用它来匹配路由中指定的路径和浏览器地址栏中的当前路径,如 /login 。
  • component:导航到此路由时,路由器需要创建的组件,如 LoginComponent
  • redirectTo:重定向到某个path,使用场景的话,比如在用户输入不存在的路径时重定向到首页。
  • pathMatch:路径的字符匹配策略
  • children:子路由数组

路由插座

运行一下,我们会发现出错了

image_1b0hgdsiu87n1lha1kcahl51ckb9.png-233.2kB

这个错误看上去应该是对于''没有找到匹配的route,这是由于我们只定义了一个'login',我们再试试在浏览器地址栏输入:http://localhost:4200/login。这次仍然出错,但错误信息变成了下面的样子,意思是我们没有找到一个outlet去加载LoginComponent。对的,这就引出了router outlet的概念,如果要显示对应路由的组件,我们需要一个插头(outlet)来装载组件。

error_handler.js:48EXCEPTION: Uncaught (in promise): Error: Cannot find primary outlet to load 'LoginComponent'
Error: Cannot find primary outlet to load 'LoginComponent'
    at getOutlet (http://localhost:4200/main.bundle.js:66161:19)
    at ActivateRoutes.activateRoutes (http://localhost:4200/main.bundle.js:66088:30)
    at http://localhost:4200/main.bundle.js:66052:19
    at Array.forEach (native)
    at ActivateRoutes.activateChildRoutes (http://localhost:4200/main.bundle.js:66051:29)
    at ActivateRoutes.activate (http://localhost:4200/main.bundle.js:66046:14)
    at http://localhost:4200/main.bundle.js:65787:56
    at SafeSubscriber._next (http://localhost:4200/main.bundle.js:9000:21)
    at SafeSubscriber.__tryOrSetError (http://localhost:4200/main.bundle.js:42013:16)
    at SafeSubscriber.next (http://localhost:4200/main.bundle.js:41955:27)

下面我们把<router-outlet></router-outlet>写在src\app\app.component.html的末尾,地址栏输入http://localhost:4200/login重新看看浏览器中的效果吧,我们的应用应该正常显示了。但如果输入http://localhost:4200时仍然是有异常出现的,我们需要添加一个路由定义来处理。输入http://localhost:4200时相对于根路径的path应该是空,即''。而我们这时希望将用户仍然引导到登录页面,这就是redirectTo: 'login'的作用。pathMatch: 'full'的意思是必须完全符合路径的要求,也就是说http://localhost:4200/1是不会匹配到这个规则的,必须严格是http://localhost:4200

    RouterModule.forRoot([
      {
        path: '',
        redirectTo: 'login',
        pathMatch: 'full'
      },
      {
        path: 'login',
        component: LoginComponent
      }
    ])

注意路径配置的顺序是非常重要的,Angular2使用“先匹配优先”的原则,也就是说如果一个路径可以同时匹配几个路径配置的规则的话,以第一个匹配的规则为准。现在打开浏览器试验一下,我们的功能又恢复了正常。

分离路由定义

但是现在还有一点小不爽,就是直接在app.modules.ts中定义路径并不是很好的方式,因为随着路径定义的复杂,这部分最好还是用单独的文件来定义。现在我们新建一个文件src\app\app.routes.ts,将上面在app.modules.ts中定义的路径删除并在app.routes.ts中重新定义。

import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from './login/login.component';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  },
  {
    path: 'login',
    component: LoginComponent
  }
];

export const routing = RouterModule.forRoot(routes);

接下来我们在app.modules.ts中引入routing,import { routing } from './app.routes';,然后在imports数组里添加routing,现在我们的app.modules.ts看起来是下面这个样子。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { AuthService } from './core/auth.service';
import { routing } from './app.routes';

@NgModule({
  declarations: [
    AppComponent,
    LoginComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    routing
  ],
  providers: [
    {provide: 'auth',  useClass: AuthService}
    ],
  bootstrap: [AppComponent]
})
export class AppModule { }

现在我们来规划一下根路径'',对应根路径我们想建立一个todo组件,那么我们使用ng g c todo来生成组件,然后在app.routes.ts中加入路由定义,对于根路径我们不再需要重定向到登录了,我们把它改写成重定向到todo。

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'todo',
    pathMatch: 'full'
  },
  {
    path: 'todo',
    component: TodoComponent
  },
  {
    path: 'login',
    component: LoginComponent
  }
];

在浏览器中键入http://localhost:4200可以看到自动跳转到了todo路径,并且我们的todo组件也显示出来了。

image_1b0k2ba0d1qqraa51mj51hpdpeo9.png-81kB

让待办事项变得有意义

我们希望的Todo页面应该有一个输入待办事项的输入框和一个显示待办事项状态的列表。那么我们先来定义一下todo的结构,todo应该有一个id用来唯一标识,还应该有一个desc用来描述这个todo是干什么的,再有一个completed用来标识是否已经完成。好了,我们来建立这个todo模型吧,在todo文件夹下新建一个文件todo.model.ts

export class Todo {
  id: number;
  desc: string;
  completed: boolean;
}

然后我们应该改造一下todo组件了,引入刚刚建立好的todo对象,并且建立一个todos数组作为所有todo的集合,一个desc是当前添加的新的todo的内容。当然我们还需要一个addTodo方法把新的todo加到todos数组中。这里我们暂且写一个漏洞百出的版本。

import { Component, OnInit } from '@angular/core';
import { Todo } from './todo.model';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css']
})
export class TodoComponent implements OnInit {
  todos: Todo[] = [];
  desc = '';
  constructor() { }

  ngOnInit() {
  }

  addTodo(){
    this.todos.push({id: 1, desc: this.desc, completed: false});
    this.desc = '';
  }
}

然后我们改造一下src\app\todo\todo.component.html

<div>
  <input type="text" [(ngModel)]="desc" (keyup.enter)="addTodo()">
  <ul>
    <li *ngFor="let todo of todos">{{ todo.desc }}</li>
  </ul>
</div>

如上面代码所示,我们建立了一个文本输入框,这个输入框的值应该是新todo的描述(desc),我们想在用户按了回车键后进行添加操作((keyup.enter)="addTodo())。由于todos是个数组,所以我们利用一个循环将数组内容显示出来(<li *ngFor="let todo of todos">{{ todo.desc }}</li>)。好了让我们欣赏一下成果吧

image_1b0kgg9mnppf16pkip81b2hhbrm.png-90.1kB

隔离业务逻辑

如果我们还记得之前提到的业务逻辑应该放在单独的service中,我们还可以做的更好一些。在todo文件夹内建立TodoService:ng g s todo\todo。上面的例子中所有创建的todo都是id为1的,这显然是一个大bug,我们看一下怎么处理。常见的不重复id创建方式有两种,一个是搞一个自增长数列,另一个是采用随机生成一组不可能重复的字符序列,常见的就是UUID了。

我们来引入一个uuid的包:npm i --save angular2-uuid,由于这个包中已经含有了用于typescript的定义文件,这里就执行这一个命令就足够了。这里稍微提一下如何引入第三方JS类库,分几种情况:

  1. 如果类库的npm包中含有类型定义文件(查看node_modules/第三方类库 中是否有 .d.ts 后缀的文件),那么直接使用 npm i --save 要引入包的名称即可。
  2. 如果类库中没有类型定义文件,可先使用 npm i --save 要引入包的名称 正常安装,然后执行 npm install @types/要引入包的名称 --save-dev。这个命令是要在 @types/ 搜索安装类型定义文件。
  3. 当然还是有可能找不到类型定义文件,这时还是可以使用的,但需要手动添加类型定义:首先在 src/typings.d.ts 中写 declare module '要引入包的名称';,然后在组件中可以这样引入 import * as friendName from '要引入包的名称';friendName是个友好别名,起一个你认为符合你风格的名称就行),使用时就可以这样调用方法了 friendName.method();

由于此时Todo对象的id已经是字符型了,请更改其声明为id: string;。 然后修改service成下面的样子:

import { Injectable } from '@angular/core';
import {Todo} from './todo.model';
import { UUID } from 'angular2-uuid';

@Injectable()
export class TodoService {

  todos: Todo[] = [];

  constructor() { }

  addTodo(todoItem:string): Todo[] {
    let todo = {
      id: UUID.UUID(),
      desc: todoItem,
      completed: false
    };
    this.todos.push(todo);
    return this.todos;
  }
}

当然我们还要把组件中的代码改成使用service的

import { Component, OnInit } from '@angular/core';
import { Todo } from './todo.model';
import { TodoService } from './todo.service';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css'],
  providers:[TodoService]
})
export class TodoComponent implements OnInit {
  todos: Todo[] = [];
  desc = '';
  constructor(private service:TodoService) { }

  ngOnInit() {
  }

  addTodo(){
    this.todos = this.service.addTodo(this.desc);
    this.desc = '';
  }
}

为了可以清晰的看到我们的成果,我们为chrome浏览器装一个插件,在chrome的地址栏中输入chrome://extensions,拉到最底部会看到一个“获取更多扩展程序”的链接,点击这个链接然后搜索“Angury”,安装即可。安装好后,按F12调出开发者工具,里面出现一个叫“Angury”的tab。

image_1b0kr7gpn17td7v1p4s1qucuu313.png-273.8kB

我们可以看到id这时候被设置成了一串字符,这个就是UUID了。

建立模拟web服务和异步操作

实际的开发中我们的service是要和服务器api进行交互的,而不是现在这样简单的操作数组。但问题来了,现在没有web服务啊,难道真要自己开发一个吗?答案是可以做个假的,假作真时真亦假。我们在开发过程中经常会遇到这类问题,等待后端同学的进度是很痛苦的。所以Angular内建提供了一个可以快速建立测试用web服务的方法:内存 (in-memory) 服务器。

构建数据模型

一般来说,你需要知道自己对服务器的期望是什么,期待它返回什么样的数据,有了这个数据呢,我们就可以自己快速的建立一个内存服务器了。拿这个例子来看,我们可能需要一个这样的对象

class Todo {
  id: string;
  desc: string;
  completed: boolean;
}

对应的JSON应该是这样的

{
  "data": [
    {
      "id": "f823b191-7799-438d-8d78-fcb1e468fc78",
      "desc": "blablabla",
      "completed": false
    },
    {
      "id": "c316a3bf-b053-71f9-18a3-0073c7ee3b76",
      "desc": "tetssts",
      "completed": false
    },
    {
      "id": "dd65a7c0-e24f-6c66-862e-0999ea504ca0",
      "desc": "getting up",
      "completed": false
    }
  ]
}

首先我们需要安装angular-in-memory-web-api,输入npm install --save angular-in-memory-web-api 然后在Todo文件夹下创建一个文件src\app\todo\todo-data.ts

import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Todo } from './todo.model';

export class InMemoryTodoDbService implements InMemoryDbService {
  createDb() {
    let todos: Todo[] = [
      {id: "f823b191-7799-438d-8d78-fcb1e468fc78", desc: 'Getting up', completed: true},
      {id: "c316a3bf-b053-71f9-18a3-0073c7ee3b76", desc: 'Go to school', completed: false}
    ];
    return {todos};
  }
}

实现内存web服务

可以看到,我们创建了一个实现InMemoryDbService的内存数据库,这个数据库其实也就是把数组传入进去。接下来我们要更改src\app\app.module.ts,加入类引用和对应的模块声明:

import { InMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryTodoDbService } from './todo/todo-data';

然后在imports数组中紧挨着HttpModule加上InMemoryWebApiModule.forRoot(InMemoryTodoDbService),

现在我们在service中试着调用我们的“假web服务”吧

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import { UUID } from 'angular2-uuid';

import 'rxjs/add/operator/toPromise';

import { Todo } from './todo.model';

@Injectable()
export class TodoService {

  //定义你的假WebAPI地址,这个定义成什么都无所谓
  //只要确保是无法访问的地址就好
  private api_url = 'api/todos';
  private headers = new Headers({'Content-Type': 'application/json'});

  constructor(private http: Http) { }

  // POST /todos
  addTodo(desc:string): Promise<Todo> {
    let todo = {
      id: UUID.UUID(),
      desc: desc,
      completed: false
    };
    return this.http
            .post(this.api_url, JSON.stringify(todo), {headers: this.headers})
            .toPromise()
            .then(res => res.json().data as Todo)
            .catch(this.handleError);
  }

  private handleError(error: any): Promise<any> {
    console.error('An error occurred', error); 
    return Promise.reject(error.message || error);
  }
}

上面的代码我们看到定义了一个api_url = 'api/todos',你可能会问这个是怎么来的?其实这个我们改写成api_url = 'blablabla/nahnahnah'也无所谓,因为这个内存web服务的机理是拦截web访问,也就是说随便什么地址都可以,内存web服务会拦截这个地址并解析你的请求是否满足RESTful API的要求。

内存服务器提供的Restful API

简单来说RESTful API中以“名词”来标识资源,比如todos;以don“动词”标识操作,比如:GET请求用于查询,PUT用于更新,DELETE用于删除,POST用于添加。比如如果url是api/todos,那么

  • 查询所有待办事项:以GET方法访问api/todos
  • 查询单个待办事项:以GET方法访问api/todos/id,比如id是1,那么访问api/todos/1
  • 更新某个待办事项:以PUT方法访问api/todos/id
  • 删除某个待办事项:以DELETE方法访问api/todos/id
  • 增加一个待办事项:以POST方法访问api/todos

在service的构造函数中我们注入了Http,而angular的Http封装了大部分我们需要的方法,比如例子中的增加一个todo,我们就调用this.http.post(url, body, options),上面代码中的.post(this.api_url, JSON.stringify(todo), {headers: this.headers})含义是:构造一个POST类型的HTTP请求,其访问的url是this.api_url,request的body是一个JSON(把todo对象转换成JSON),在参数配置中我们配置了request的header。

这个请求发出后返回的是一个Observable(可观察对象),我们把它转换成Promise然后处理res(Http Response)。Promise提供异步的处理,注意到then中的写法,这个和我们传统编程写法不大一样,叫做lamda表达式,相当于是一个匿名函数,(input parameters) => expression=>前面的是函数的参数,后面的是函数体。

还要一点需要强调的是:在用内存Web服务时,一定要注意res.json().data中的data属性必须要有,因为内存web服务坑爹的在返回的json中加了data对象,你真正要得到的json是在这个data里面。

下一步我们来更改Todo组件的addTodo方法以便可以使用我们新的异步http方法

  addTodo(){
    this.service
      .addTodo(this.desc)
      .then(todo => {
        this.todos = [...this.todos, todo];
        this.desc = '';
      });
  }

这里面的前半部分应该还是好理解的:this.service.addTodo(this.desc)是调用service的对应方法而已,但后半部分是什么鬼?...这个貌似省略号的东东是ES7中计划提供的Object Spread操作符,它的功能是将对象或数组“打散,拍平”。这么说可能还是不懂,举例子吧:

let arr = [1,2,3];
let arr2 = [...arr]; 
arr2.push(4); 

// arr2 变成了 [1,2,3,4]
// arr 保存原来的样子

let arr3 = [0, 1, 2];
let arr4 = [3, 4, 5];
arr3.push(...arr4);
// arr3变成了[0, 1, 2, 3, 4, 5]

let arr5 = [0, 1, 2];
let arr6 = [-1, ...arr5, 3];
// arr6 变成了[-1, 0, 1, 2, 3]

所以呢我们上面的this.todos = [...this.todos, todo];相当于为todos增加一个新元素,和push很像,那为什么不用push呢?因为这样构造出来的对象是全新的,而不是引用的,在现代编程中一个明显的趋势是不要在过程中改变输入的参数。第二个原因是这样做会带给我们极大的便利性和编程的一致性。下面通过给我们的例子添加几个功能,我们来一起体会一下。

Angular2内建的Http方法

首先更改src\app\todo\todo.service.ts

//src\app\todo\todo.service.ts
import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import { UUID } from 'angular2-uuid';

import 'rxjs/add/operator/toPromise';

import { Todo } from './todo.model';

@Injectable()
export class TodoService {

  private api_url = 'api/todos';
  private headers = new Headers({'Content-Type': 'application/json'});
  constructor(private http: Http) { }
  // POST /todos
  addTodo(desc:string): Promise<Todo> {
    let todo = {
      id: UUID.UUID(),
      desc: desc,
      completed: false
    };
    return this.http
            .post(this.api_url, JSON.stringify(todo), {headers: this.headers})
            .toPromise()
            .then(res => res.json().data as Todo)
            .catch(this.handleError);
  }
  // PUT /todos/:id
  toggleTodo(todo: Todo): Promise<Todo> {
    const url = `${this.api_url}/${todo.id}`;
    console.log(url);
    let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
    return this.http
            .put(url, JSON.stringify(updatedTodo), {headers: this.headers})
            .toPromise()
            .then(() => updatedTodo)
            .catch(this.handleError);
  }
  // DELETE /todos/:id
  deleteTodoById(id: string): Promise<void> {
    const url = `${this.api_url}/${id}`;
    return this.http
            .delete(url, {headers: this.headers})
            .toPromise()
            .then(() => null)
            .catch(this.handleError);
  }
  // GET /todos
  getTodos(): Promise<Todo[]>{
    return this.http.get(this.api_url)
              .toPromise()
              .then(res => res.json().data as Todo[])
              .catch(this.handleError);
  }
  private handleError(error: any): Promise<any> {
    console.error('An error occurred', error); 
    return Promise.reject(error.message || error);
  }
}

上面的代码中可以看到对应Restful API的各个“动词”,angular 2.x 提供了一系列对应名称的方法,非常简单易用。比如说在deleteTodoById方法中,我们要访问的API是/todos/:id,使用的HTTP方法是DELETE,那么我们就使用this.http.delete(url, {headers: this.headers})

页面展现

更新src\app\todo\todo.component.ts,调用我们新的service中的方法。有趣的是利用Object Spread操作符,我们看到代码风格更一致,逻辑也更容易理解了。

import { Component, OnInit } from '@angular/core';
import { TodoService } from './todo.service';
import { Todo } from './todo.model';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.css'],
  providers: [TodoService]
})
export class TodoComponent implements OnInit {
  todos : Todo[] = [];
  desc: string = '';

  constructor(private service: TodoService) {}
  ngOnInit() {
    this.getTodos();
  }
  addTodo(){
    this.service
      .addTodo(this.desc)
      .then(todo => {
        this.todos = [...this.todos, todo];
        this.desc = '';
      });
  }
  toggleTodo(todo: Todo) {
    const i = this.todos.indexOf(todo);
    this.service
      .toggleTodo(todo)
      .then(t => {
        this.todos = [
          ...this.todos.slice(0,i),
          t,
          ...this.todos.slice(i+1)
          ];
      });
  }
  removeTodo(todo: Todo) {
    const i = this.todos.indexOf(todo);
    this.service
      .deleteTodoById(todo.id)
      .then(()=> {
        this.todos = [
          ...this.todos.slice(0,i),
          ...this.todos.slice(i+1)
        ];
      });
  }
  getTodos(): void {
    this.service
      .getTodos()
      .then(todos => this.todos = [...todos]);
  }
}

模板文件src\app\todo\todo.component.html需要把对应的功能体现在页面上,于是我们增加了toggleTodo(切换完成状态)的checkbox和removeTodo(删除待办事项)的button。

<section class="todoapp">
  <header class="header">
    <h1>Todos</h1>
    <input class="new-todo" placeholder="What needs to be done?" autofocus="" [(ngModel)]="desc" (keyup.enter)="addTodo()">
  </header>
  <section class="main" *ngIf="todos?.length > 0">
    <input class="toggle-all" type="checkbox">
    <ul class="todo-list">
      <li *ngFor="let todo of todos" [class.completed]="todo.completed">
        <div class="view">
          <input class="toggle" type="checkbox" (click)="toggleTodo(todo)" [checked]="todo.completed">
          <label (click)="toggleTodo(todo)">{{todo.desc}}</label>
          <button class="destroy" (click)="removeTodo(todo); $event.stopPropagation()"></button>
        </div>
      </li>
    </ul>
  </section>
  <footer class="footer" *ngIf="todos?.length > 0">
    <span class="todo-count">
      <strong>{{todos?.length}}</strong> {{todos?.length == 1 ? 'item' : 'items'}} left
    </span>
    <ul class="filters">
      <li><a href="">All</a></li>
      <li><a href="">Active</a></li>
      <li><a href="">Completed</a></li>
    </ul>
    <button class="clear-completed">Clear completed</button>
  </footer>
</section

更新组件的css样式:src\app\todo\todo.component.css

.todoapp {
    background: #fff;
    margin: 130px 0 40px 0;
    position: relative;
    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
                0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.todoapp input::-webkit-input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
.todoapp input::-moz-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
.todoapp input::input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
.todoapp h1 {
    position: absolute;
    top: -155px;
    width: 100%;
    font-size: 100px;
    font-weight: 100;
    text-align: center;
    color: rgba(175, 47, 47, 0.15);
    -webkit-text-rendering: optimizeLegibility;
    -moz-text-rendering: optimizeLegibility;
    text-rendering: optimizeLegibility;
}
.new-todo,
.edit {
    position: relative;
    margin: 0;
    width: 100%;
    font-size: 24px;
    font-family: inherit;
    font-weight: inherit;
    line-height: 1.4em;
    border: 0;
    color: inherit;
    padding: 6px;
    border: 1px solid #999;
    box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
    box-sizing: border-box;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}
.new-todo {
    padding: 16px 16px 16px 60px;
    border: none;
    background: rgba(0, 0, 0, 0.003);
    box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
}
.main {
    position: relative;
    z-index: 2;
    border-top: 1px solid #e6e6e6;
}
label[for='toggle-all'] {
    display: none;
}
.toggle-all {
    position: absolute;
    top: -55px;
    left: -12px;
    width: 60px;
    height: 34px;
    text-align: center;
    border: none; /* Mobile Safari */
}
.toggle-all:before {
    content: '❯';
    font-size: 22px;
    color: #e6e6e6;
    padding: 10px 27px 10px 27px;
}
.toggle-all:checked:before {
    color: #737373;
}
.todo-list {
    margin: 0;
    padding: 0;
    list-style: none;
}
.todo-list li {
    position: relative;
    font-size: 24px;
    border-bottom: 1px solid #ededed;
}
.todo-list li:last-child {
    border-bottom: none;
}
.todo-list li.editing {
    border-bottom: none;
    padding: 0;
}
.todo-list li.editing .edit {
    display: block;
    width: 506px;
    padding: 12px 16px;
    margin: 0 0 0 43px;
}
.todo-list li.editing .view {
    display: none;
}
.todo-list li .toggle {
    text-align: center;
    width: 40px;
    /* auto, since non-WebKit browsers doesn't support input styling */
    height: auto;
    position: absolute;
    top: 0;
    bottom: 0;
    margin: auto 0;
    border: none; /* Mobile Safari */
    -webkit-appearance: none;
    appearance: none;
}
.todo-list li .toggle:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#ededed" stroke-width="3"/></svg>');
}
.todo-list li .toggle:checked:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#bddad5" stroke-width="3"/><path fill="#5dc2af" d="M72 25L42 71 27 56l-4 4 20 20 34-52z"/></svg>');
}
.todo-list li label {
    word-break: break-all;
    padding: 15px 60px 15px 15px;
    margin-left: 45px;
    display: block;
    line-height: 1.2;
    transition: color 0.4s;
}
.todo-list li.completed label {
    color: #d9d9d9;
    text-decoration: line-through;
}
.todo-list li .destroy {
    display: none;
    position: absolute;
    top: 0;
    right: 10px;
    bottom: 0;
    width: 40px;
    height: 40px;
    margin: auto 0;
    font-size: 30px;
    color: #cc9a9a;
    margin-bottom: 11px;
    transition: color 0.2s ease-out;
}
.todo-list li .destroy:hover {
    color: #af5b5e;
}
.todo-list li .destroy:after {
    content: '×';
}
.todo-list li:hover .destroy {
    display: block;
}
.todo-list li .edit {
    display: none;
}
.todo-list li.editing:last-child {
    margin-bottom: -1px;
}
.footer {
    color: #777;
    padding: 10px 15px;
    height: 20px;
    text-align: center;
    border-top: 1px solid #e6e6e6;
}
.footer:before {
    content: '';
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    height: 50px;
    overflow: hidden;
    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
                0 8px 0 -3px #f6f6f6,
                0 9px 1px -3px rgba(0, 0, 0, 0.2),
                0 16px 0 -6px #f6f6f6,
                0 17px 2px -6px rgba(0, 0, 0, 0.2);
}
.todo-count {
    float: left;
    text-align: left;
}
.todo-count strong {
    font-weight: 300;
}
.filters {
    margin: 0;
    padding: 0;
    list-style: none;
    position: absolute;
    right: 0;
    left: 0;
}
.filters li {
    display: inline;
}
.filters li a {
    color: inherit;
    margin: 3px;
    padding: 3px 7px;
    text-decoration: none;
    border: 1px solid transparent;
    border-radius: 3px;
}
.filters li a:hover {
    border-color: rgba(175, 47, 47, 0.1);
}
.filters li a.selected {
    border-color: rgba(175, 47, 47, 0.2);
}
.clear-completed,
html .clear-completed:active {
    float: right;
    position: relative;
    line-height: 20px;
    text-decoration: none;
    cursor: pointer;
}
.clear-completed:hover {
    text-decoration: underline;
}
/*
    Hack to remove background from Mobile Safari.
    Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio:0) {
    .toggle-all,
    .todo-list li .toggle {
        background: none;
    }
    .todo-list li .toggle {
        height: 40px;
    }
    .toggle-all {
        -webkit-transform: rotate(90deg);
        transform: rotate(90deg);
        -webkit-appearance: none;
        appearance: none;
    }
}
@media (max-width: 430px) {
    .footer {
        height: 50px;
    }
    .filters {
        bottom: 10px;
    }
}

更新src\styles.css为如下

/* You can add global styles to this file, and also import other style files */
html, body {
    margin: 0;
    padding: 0;
}
button {
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    font-size: 100%;
    vertical-align: baseline;
    font-family: inherit;
    font-weight: inherit;
    color: inherit;
    -webkit-appearance: none;
    appearance: none;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}
body {
    font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
    line-height: 1.4em;
    background: #f5f5f5;
    color: #4d4d4d;
    min-width: 230px;
    max-width: 550px;
    margin: 0 auto;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    font-weight: 300;
}
:focus {
    outline: 0;
}
.hidden {
    display: none;
}
.info {
    margin: 65px auto 0;
    color: #bfbfbf;
    font-size: 10px;
    text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
    text-align: center;
}
.info p {
    line-height: 1;
}
.info a {
    color: inherit;
    text-decoration: none;
    font-weight: 400;
}
.info a:hover {
    text-decoration: underline;
}

现在我们看看成果吧,现在好看多了

image_1b11jlmes1nithths9q1n8ijqg9.png-78.9kB

本节代码:https://github.com/wpcfan/awesome-tutorials/tree/chap03/angular2/ng2-tut


第四章:进化!模块化你的应用

一个复杂组件的分拆

上一节的末尾我偷懒的甩出了大量代码,可能你看起来都有点晕了,这就是典型的一个功能经过一段时间的需求累积后,代码也不可避免的臃肿起来。现在我们看看怎么分拆一下吧。 image_1b11kjibcelb6upnb21su41dilm.png-59.5kB 我们的应用似乎可以分为Header,Main和Footer几部分。首先我们来建立一个新的Component,键入ng g c todo/todo-footer。然后将src\app\todo\todo.component.html中的<footer>...</footer>段落剪切到src\app\todo\todo-footer\todo-footer.component.html中。

  <footer class="footer" *ngIf="todos?.length > 0">
    <span class="todo-count">
      <strong>{{todos?.length}}</strong> {{todos?.length == 1 ? 'item' : 'items'}} left
    </span>
    <ul class="filters">
      <li><a href="">All</a></li>
      <li><a href="">Active</a></li>
      <li><a href="">Completed</a></li>
    </ul>
    <button class="clear-completed">Clear completed</button>
  </footer>

观察上面的代码,我们看到似乎所有的变量都是todos?.length,这提醒我们其实对于Footer来说,我们并不需要传入todos,而只需要给出一个item计数即可。那么我们来把所有的todos?.length改成itemCount

<footer class="footer" *ngIf="itemCount > 0">
  <span class="todo-count">
    <strong>{{itemCount}}</strong> {{itemCount == 1 ? 'item' : 'items'}} left
  </span>
  <ul class="filters">
    <li><a href="">All</a></li>
    <li><a href="">Active</a></li>
    <li><a href="">Completed</a></li>
  </ul>
  <button class="clear-completed">Clear completed</button>
</footer>

这样的话也就是说如果在src\app\todo\todo.component.html中我们可以用<app-todo-footer [itemCount]="todos?.length"></app-todo-footer>去传递todo项目计数给Footer即可。所以在src\app\todo\todo.component.html中刚才我们剪切掉代码的位置加上这句吧。当然,如果要让父组件可以传递值给子组件,我们还需要在子组件中声明一下。@Input()是输入型绑定的修饰符,用于把数据从父组件传到子组件。

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

@Component({
  selector: 'app-todo-footer',
  templateUrl: './todo-footer.component.html',
  styleUrls: ['./todo-footer.component.css']
})
export class TodoFooterComponent implements OnInit {
  //声明itemCount是可以一个可输入值(从引用者处)
  @Input() itemCount: number;
  constructor() { }
  ngOnInit() {
  }
}

运行一下看看效果,应该一切正常!

输入和输出属性

类似的我们建立一个Header组件,键入ng g c todo/todo-header,同样的把下面的代码从src\app\todo\todo.component.html中剪切到src\app\todo\todo-header\todo-header.component.html

<header class="header">
  <h1>Todos</h1>
  <input class="new-todo" placeholder="What needs to be done?" autofocus="" [(ngModel)]="desc" (keyup.enter)="addTodo()">
</header>

这段代码看起来有点麻烦,主要原因是我们好像不但需要给子组件输入什么,而且希望子组件给父组件要输出一些东西,比如输入框的值和按下回车键的消息等。当然你可能猜到了,Angular2里面有@Input()就相应的有@Output()修饰符。 我们希望输入框的占位文字(没有输入的情况下显示的默认文字)是一个输入型的参数,在回车键抬起时可以发射一个事件给父组件,同时我们也希望在输入框输入文字时父组件能够得到这个字符串。也就是说父组件调用子组件时看起来是下面的样子,相当于我们自定义的组件中提供一些事件,父组件调用时可以写自己的事件处理方法,而$event就是子组件发射的事件对象:

<app-todo-header 
    placeholder="What do you want"
    (onTextChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
</app-todo-header>

但是第三个需求也就是“在输入框输入文字时父组件能够得到这个字符串”,这个有点问题,如果每输入一个字符都要回传给父组件的话,系统会过于频繁进行这种通信,有可能会有性能的问题。那么我们希望可以有一个类似滤波器的东东,它可以过滤掉一定时间内的事件。因此我们定义一个输入型参数delay。

<app-todo-header 
    placeholder="What do you want"
    delay="400"
    (textChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
</app-todo-header>

现在的标签引用应该是上面这个样子,但我们只是策划了它看起来是什么样子,还没有做呢。我们一起动手看看怎么做吧。 todo-header.component.html的模板中我们调整了一些变量名和参数以便让大家不混淆子组件自己的模板和父组件中引用子组件的模板片段。

//todo-header.component.html
<header class="header">
  <h1>Todos</h1>
  <input
    class="new-todo"
    [placeholder]="placeholder"
    autofocus=""
    [(ngModel)]="inputValue"
    (keyup.enter)="enterUp()">
</header>

记住子组件的模板是描述子组件自己长成什么样子,应该有哪些行为,这些东西和父组件没有任何关系。比如todo-header.component.html中的placeholder就是HTML标签Input中的一个属性,和父组件没有关联,如果我们不在todo-header.component.ts中声明@Input() placeholder,那么子组件就没有这个属性,在父组件中也无法设置这个属性。父组件中的声明为@Input()的属性才会成为子组件对外可见的属性,我们完全可以把@Input() placeholder声明为@Input() hintText,这样的话在引用header组件时,我们就需要这样写<app-todo-header hintText="What do you want" ...

现在看一下todo-header.component.ts

import { Component, OnInit, Input, Output, EventEmitter, ElementRef } from '@angular/core';
import {Observable} from 'rxjs/Rx';
import 'rxjs/Observable';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';

@Component({
  selector: 'app-todo-header',
  templateUrl: './todo-header.component.html',
  styleUrls: ['./todo-header.component.css']
})
export class TodoHeaderComponent implements OnInit {
  inputValue: string = '';
  @Input() placeholder: string = 'What needs to be done?';
  @Input() delay: number = 300;

  //detect the input value and output this to parent
  @Output() textChanges = new EventEmitter<string>();
  //detect the enter keyup event and output this to parent
  @Output() onEnterUp = new EventEmitter<boolean>();

  constructor(private elementRef: ElementRef) {
    const event$ = Observable.fromEvent(elementRef.nativeElement, 'keyup')
      .map(() => this.inputValue)
      .debounceTime(this.delay)
      .distinctUntilChanged();
    event$.subscribe(input => this.textChanges.emit(input));
  }
  ngOnInit() {
  }
  enterUp(){
    this.onEnterUp.emit(true);
    this.inputValue = '';
  }
}

分析一下代码: placeholder和delay作为2个输入型变量,这样<app-todo-header>标签中就可以设置这两个属性了。 接下来我们看到了由@Output修饰的onTextChanges和onEnterUp,这两个顾名思义是分别处理文本变化和回车键抬起事件的,这两个变量呢都定义成了EventEmitter(事件发射器)。我们会在子组件的逻辑代码中以适当的条件去发射对应事件,而父组件会接收到这些事件。我们这里采用了2中方法来触发发射器

  • enterUp:这个是比较常规的方法,在todo-header.component.html中我们定义了(keyup.enter)="enterUp()",所以在组件的enterUp方法中,我们直接让onEnterUp发射了对应事件。
  • 构造器中使用Rx:这里涉及了很多新知识,首先我们注入了ElementRef,这个是一个Angular中需要谨慎使用的对象,因为它可以让你直接操作DOM,也就是HTML的元素和事件。同时我们使用了Rx(响应式对象),Rx是一个很复杂的话题,这里我们不展开了,但我们主要是利用Observable去观察HTML中的keyup事件,然后在这个事件流中做一个转换把输入框的值发射出来(map),应用一个时间的滤波器(debounceTime),然后应用一个筛选器(distinctUntilChanged)。这里由于这个事件的发射条件是依赖于输入时的当时条件,我们没有办法按前面的以模板事件触发做处理。 最后需要在todo.component.ts中加入对header输出参数发射事件的处理
  onTextChanges(value) {
    this.desc = value;
  }

最后由于组件分拆后,我们希望也分拆一下css,这里就直接给代码了 todo-header.component.css的样式如下:

h1 {
    position: absolute;
    top: -155px;
    width: 100%;
    font-size: 100px;
    font-weight: 100;
    text-align: center;
    color: rgba(175, 47, 47, 0.15);
    -webkit-text-rendering: optimizeLegibility;
    -moz-text-rendering: optimizeLegibility;
    text-rendering: optimizeLegibility;
}
input::-webkit-input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
input::-moz-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
input::input-placeholder {
    font-style: italic;
    font-weight: 300;
    color: #e6e6e6;
}
.new-todo,
.edit {
    position: relative;
    margin: 0;
    width: 100%;
    font-size: 24px;
    font-family: inherit;
    font-weight: inherit;
    line-height: 1.4em;
    border: 0;
    color: inherit;
    padding: 6px;
    border: 1px solid #999;
    box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
    box-sizing: border-box;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}
.new-todo {
    padding: 16px 16px 16px 60px;
    border: none;
    background: rgba(0, 0, 0, 0.003);
    box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
}

todo-footer.component.css的样式如下

.footer {
    color: #777;
    padding: 10px 15px;
    height: 20px;
    text-align: center;
    border-top: 1px solid #e6e6e6;
}
.footer:before {
    content: '';
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    height: 50px;
    overflow: hidden;
    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
                0 8px 0 -3px #f6f6f6,
                0 9px 1px -3px rgba(0, 0, 0, 0.2),
                0 16px 0 -6px #f6f6f6,
                0 17px 2px -6px rgba(0, 0, 0, 0.2);
}
.todo-count {
    float: left;
    text-align: left;
}
.todo-count strong {
    font-weight: 300;
}
.filters {
    margin: 0;
    padding: 0;
    list-style: none;
    position: absolute;
    right: 0;
    left: 0;
}
.filters li {
    display: inline;
}
.filters li a {
    color: inherit;
    margin: 3px;
    padding: 3px 7px;
    text-decoration: none;
    border: 1px solid transparent;
    border-radius: 3px;
}
.filters li a:hover {
    border-color: rgba(175, 47, 47, 0.1);
}
.filters li a.selected {
    border-color: rgba(175, 47, 47, 0.2);
}
.clear-completed,
html .clear-completed:active {
    float: right;
    position: relative;
    line-height: 20px;
    text-decoration: none;
    cursor: pointer;
}
.clear-completed:hover {
    text-decoration: underline;
}
@media (max-width: 430px) {
    .footer {
        height: 50px;
    }
    .filters {
        bottom: 10px;
    }
}

当然上述代码要从todo.component.css中删除,现在的todo.component.css看起来是这个样子

.todoapp {
    background: #fff;
    margin: 130px 0 40px 0;
    position: relative;
    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
                0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.main {
    position: relative;
    z-index: 2;
    border-top: 1px solid #e6e6e6;
}
.todo-list {
    margin: 0;
    padding: 0;
    list-style: none;
}
.todo-list li {
    position: relative;
    font-size: 24px;
    border-bottom: 1px solid #ededed;
}
.todo-list li:last-child {
    border-bottom: none;
}
.todo-list li.editing {
    border-bottom: none;
    padding: 0;
}
.todo-list li.editing .edit {
    display: block;
    width: 506px;
    padding: 12px 16px;
    margin: 0 0 0 43px;
}
.todo-list li.editing .view {
    display: none;
}
.todo-list li .toggle {
    text-align: center;
    width: 40px;
    /* auto, since non-WebKit browsers doesn't support input styling */
    height: auto;
    position: absolute;
    top: 0;
    bottom: 0;
    margin: auto 0;
    border: none; /* Mobile Safari */
    -webkit-appearance: none;
    appearance: none;
}
.todo-list li .toggle:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#ededed" stroke-width="3"/></svg>');
}
.todo-list li .toggle:checked:after {
    content: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="-10 -18 100 135"><circle cx="50" cy="50" r="50" fill="none" stroke="#bddad5" stroke-width="3"/><path fill="#5dc2af" d="M72 25L42 71 27 56l-4 4 20 20 34-52z"/></svg>');
}
.todo-list li label {
    word-break: break-all;
    padding: 15px 60px 15px 15px;
    margin-left: 45px;
    display: block;
    line-height: 1.2;
    transition: color 0.4s;
}
.todo-list li.completed label {
    color: #d9d9d9;
    text-decoration: line-through;
}
.todo-list li .destroy {
    display: none;
    position: absolute;
    top: 0;
    right: 10px;
    bottom: 0;
    width: 40px;
    height: 40px;
    margin: auto 0;
    font-size: 30px;
    color: #cc9a9a;
    margin-bottom: 11px;
    transition: color 0.2s ease-out;
}
.todo-list li .destroy:hover {
    color: #af5b5e;
}
.todo-list li .destroy:after {
    content: '×';
}
.todo-list li:hover .destroy {
    display: block;
}
.todo-list li .edit {
    display: none;
}
.todo-list li.editing:last-child {
    margin-bottom: -1px;
}
label[for='toggle-all'] {
    display: none;
}
.toggle-all {
    position: absolute;
    top: -55px;
    left: -12px;
    width: 60px;
    height: 34px;
    text-align: center;
    border: none; /* Mobile Safari */
}
.toggle-all:before {
    content: '❯';
    font-size: 22px;
    color: #e6e6e6;
    padding: 10px 27px 10px 27px;
}
.toggle-all:checked:before {
    color: #737373;
}
/*
    Hack to remove background from Mobile Safari.
    Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio:0) {
    .toggle-all,
    .todo-list li .toggle {
        background: none;
    }
    .todo-list li .toggle {
        height: 40px;
    }
    .toggle-all {
        -webkit-transform: rotate(90deg);
        transform: rotate(90deg);
        -webkit-appearance: none;
        appearance: none;
    }
}

封装成独立模块

现在我们的todo目录下好多文件了,而且我们观察到这个功能相对很独立。这种情况下我们似乎没有必要将所有的组件都声明在根模块AppModule当中,因为类似像子组件没有被其他地方用到。Angular中提供了一种组织方式,那就是模块。模块和根模块很类似,我们先在todo目录下建一个文件src\app\todo\todo.module.ts

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { HttpModule } from '@angular/http';
import { FormsModule } from '@angular/forms';

import { routing} from './todo.routes'

import { TodoComponent } from './todo.component';
import { TodoFooterComponent } from './todo-footer/todo-footer.component';
import { TodoHeaderComponent } from './todo-header/todo-header.component';
import { TodoService } from './todo.service';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    HttpModule,
    routing
  ],
  declarations: [
    TodoComponent,
    TodoFooterComponent,
    TodoHeaderComponent
  ],
  providers: [
    {provide: 'todoService', useClass: TodoService}
    ]
})
export class TodoModule {}

注意一点,我们没有引入BrowserModule,而是引入了CommonModule。导入 BrowserModule 会让该模块公开的所有组件、指令和管道在 AppModule 下的任何组件模板中直接可用,而不需要额外的繁琐步骤。CommonModule 提供了很多应用程序中常用的指令,包括 NgIf 和 NgFor 等。BrowserModule 导入了 CommonModule 并且 重新导出 了它。 最终的效果是:只要导入 BrowserModule 就自动获得了 CommonModule 中的指令。几乎所有要在浏览器中使用的应用的 根模块 ( AppModule )都应该从 @angular/platform-browser 中导入 BrowserModule 。在其它任何模块中都 不要导入 BrowserModule,应该改成导入 CommonModule 。 它们需要通用的指令。它们不需要重新初始化全应用级的提供商。 由于和根模块很类似,我们就不展开讲了。需要做的事情是把TodoComponent中的TodoService改成用@Inject('todoService')来注入。但是注意一点,我们需要模块自己的路由定义。我们在todo目录下建立一个todo.routes.ts的文件,和根目录下的类似。

import { Routes, RouterModule } from '@angular/router';
import { TodoComponent } from './todo.component';

export const routes: Routes = [
  {
    path: 'todo',
    component: TodoComponent
  }
];
export const routing = RouterModule.forChild(routes);

这里我们只定义了一个路由就是“todo”,另外一点和根路由不一样的是export const routing = RouterModule.forChild(routes);,我们用的是forChild而不是forRoot,因为forRoot只能用于根目录,所有非根模块的其他模块路由都只能用forChild。下面就得更改根路由了,src\app\app.routes.ts看起来是这个样子:

import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from './login/login.component';

export const routes: Routes = [
  {
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  },
  {
    path: 'login',
    component: LoginComponent
  },
  {
    path: 'todo',
    redirectTo: 'todo'
  }
];
export const routing = RouterModule.forRoot(routes);

注意到我们去掉了TodoComponent的依赖,而且更改todo路径定义为redirecTo到todo路径,但没有给出组件,这叫做“无组件路由”,也就是说后面的事情是TodoModule负责的。 此时我们就可以去掉AppModule中引用的Todo相关的组件了。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { TodoModule } from './todo/todo.module';

import { InMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryTodoDbService } from './todo/todo-data';

import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { AuthService } from './core/auth.service';
import { routing } from './app.routes';


@NgModule({
  declarations: [
    AppComponent,
    LoginComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    InMemoryWebApiModule.forRoot(InMemoryTodoDbService),
    routing,
    TodoModule
  ],
  providers: [
    {provide: 'auth',  useClass: AuthService}
    ],
  bootstrap: [AppComponent]
})
export class AppModule { }

而且此时我们注意到其实没有任何一个地方目前还需引用<app-todo></app-todo>了,这就是说我们可以安全地把selector: 'app-todo',从Todo组件中的@Component修饰符中删除了。

更真实的web服务

这里我们不想再使用内存Web服务了,所以我们使用一个更“真”的web服务:json-server。使用npm install -g json-server安装json-server。然后在todo目录下建立todo-data.json。这个json-server的牛逼之处在于可以根据一个或多个json数据建立一个完整的web服务,提供Restful的API形式。比内存Web服务好的地方在于,我们可以通过浏览器或一些工具(比如Postman)检验API的有效性和数据传递。

{
  "todos": [
    {
      "id": "f823b191-7799-438d-8d78-fcb1e468fc78",
      "desc": "blablabla",
      "completed": false
    },
    {
      "id": "dd65a7c0-e24f-6c66-862e-0999ea504ca0",
      "desc": "getting up",
      "completed": false
    },
    {
      "id": "c1092224-4064-b921-77a9-3fc091fbbd87",
      "desc": "you wanna try",
      "completed": false
    },
    {
      "id": "e89d582b-1a90-a0f1-be07-623ddb29d55e",
      "desc": "have to say good",
      "completed": false
    }
  ]
}

src\app\todo\todo.service.ts中更改

// private api_url = 'api/todos';
  private api_url = 'http://localhost:3000/todos';

现在我们的json结构并不在data节点下了,所以请将addTodo和getTodos中then语句中的 res.json().data替换成res.json()。在AppModule中删掉内存web服务相关的语句。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { TodoModule } from './todo/todo.module';

import { AppComponent } from './app.component';
import { LoginComponent } from './login/login.component';
import { AuthService } from './core/auth.service';
import { routing } from './app.routes';


@NgModule({
  declarations: [
    AppComponent,
    LoginComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    routing,
    TodoModule
  ],
  providers: [
    {provide: 'auth',  useClass: AuthService}
    ],
  bootstrap: [AppComponent]
})
export class AppModule { }

另外打开一个命令窗口,进入工程目录,输入json-server ./src/app/todo/todo-data.json

欣赏一下成果吧

image_1b12b5v4onlm16ai1bdn7pu143e9.png-165.7kB

完善Todo应用

在结束本节前,我们得给Todo应用收个尾,还差一些功能没完成:

  • 从架构上来讲,我们似乎还可以进一步构建出TodoList和TodoItem两个组件
  • 全选并反转状态
  • 底部筛选器:All,Active,Completed
  • 清理已完成项目

TodoItem和TodoList组件

在命令行窗口键入ng g c todo/todo-item,angular-cli会十分聪明的帮你在todo目录下建好TodoItem组件,并且在TodoModule中声明。一般来说,如果要生成某个模块下的组件,输入ng g c 模块名称/组件名称。 好的,类似的我们再建立一个TodoList控件,ng g c todo/todo-list。我们希望未来的todo.component.html是下面这个样子的

//todo.component.html
<section class="todoapp">
  <app-todo-header
    placeholder="What do you want"
    (textChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
  </app-todo-header>
  <app-todo-list
    [todos]="todos"
    (onRemoveTodo)="removeTodo($event)"
    (onToggleTodo)="toggleTodo($event)"
    >
  </app-todo-list>
  <app-todo-footer [itemCount]="todos?.length"></app-todo-footer>
</section>

那么TodoItem哪儿去了呢?TodoItem是TodoList的子组件,TodoItem的模板应该是todos循环内的一个todo的模板。TodoList的HTML模板看起来应该是下面的样子:

<section class="main" *ngIf="todos?.length > 0">
  <input class="toggle-all" type="checkbox">
  <ul class="todo-list">
    <li *ngFor="let todo of todos" [class.completed]="todo.completed">
      <app-todo-item
        [isChecked]="todo.completed"
        (onToggleTriggered)="onToggleTriggered(todo)"
        (onRemoveTriggered)="onRemoveTriggered(todo)"
        [todoDesc]="todo.desc">
      </app-todo-item>
    </li>
  </ul>
</section>

那么我们先从最底层的TodoItem看,这个组件怎么剥离出来?首先来看todo-item.component.html

<div class="view">
  <input class="toggle" type="checkbox" (click)="toggle()" [checked]="isChecked">
  <label [class.labelcompleted]="isChecked" (click)="toggle()">{{todoDesc}}</label>
  <button class="destroy" (click)="remove(); $event.stopPropagation()"></button>
</div>

我们需要确定有哪些输入型和输出型参数

  • isChecked:输入型参数,用来确定是否被选中,由父组件(TodoList)设置
  • todoDesc:输入型参数,显示Todo的文本描述,由父组件设置
  • onToggleTriggered:输出型参数,在用户点击checkbox或label时以事件形式通知父组件。在TodoItem中我们是在处理用户点击事件时在toggle方法中发射这个事件。
  • onRemoveTriggered:输出型参数,在用户点击删除按钮时以事件形式通知父组件。在TodoItem中我们是在处理用户点击按钮事件时在remove方法中发射这个事件。
//todo-item.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-todo-item',
  templateUrl: './todo-item.component.html',
  styleUrls: ['./todo-item.component.css']
})
export class TodoItemComponent{
  @Input() isChecked: boolean = false;
  @Input() todoDesc: string = '';
  @Output() onToggleTriggered = new EventEmitter<boolean>();
  @Output() onRemoveTriggered = new EventEmitter<boolean>();

  toggle() {
    this.onToggleTriggered.emit(true);
  }
  remove() {
    this.onRemoveTriggered.emit(true);
  }
}

建立好TodoItem后,我们再来看TodoList,还是从模板看一下

<section class="main" *ngIf="todos?.length > 0">
  <input class="toggle-all" type="checkbox">
  <ul class="todo-list">
    <li *ngFor="let todo of todos" [class.completed]="todo.completed">
      <app-todo-item
        [isChecked]="todo.completed"
        (onToggleTriggered)="onToggleTriggered(todo)"
        (onRemoveTriggered)="onRemoveTriggered(todo)"
        [todoDesc]="todo.desc">
      </app-todo-item>
    </li>
  </ul>
</section>

TodoList需要一个输入型参数todos,由父组件(TodoComponent)指定,TodoList本身不需要知道这个数组是怎么来的,它和TodoItem只是负责显示而已。当然我们由于在TodoList里面还有TodoITem子组件,而且TodoList本身不会处理这个输出型参数,所以我们需要把子组件的输出型参数再传递给TodoComponent进行处理。

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Todo } from '../todo.model';

@Component({
  selector: 'app-todo-list',
  templateUrl: './todo-list.component.html',
  styleUrls: ['./todo-list.component.css']
})
export class TodoListComponent {
  _todos: Todo[] = [];
  @Input()
  set todos(todos:Todo[]){
    this._todos = [...todos];
  }
  get todos() {
    return this._todos;
  }
  @Output() onRemoveTodo = new EventEmitter<Todo>();
  @Output() onToggleTodo = new EventEmitter<Todo>();

  onRemoveTriggered(todo: Todo) {
    this.onRemoveTodo.emit(todo);
  }
  onToggleTriggered(todo: Todo) {
    this.onToggleTodo.emit(todo);
  }
}

上面代码中有一个新东东,就是在todos()方法前我们看到有setget两个访问修饰符。这个是由于我们如果把todos当成一个成员变量给出的话,在设置后如果父组件的todos数组改变了,子组件并不知道这个变化,从而不能更新子组件本身的内容。所以我们把todos做成了方法,而且通过get和set修饰成属性方法,也就是说从模板中引用的话可以写成{{todos}}。通过标记set todos()@Input我们可以监视父组件的数据变化。也就是说如果只定义一个输入型属性的话,那么这个属性是“只写”的,如果要检测父组件给设置的值的变化,我们需要读,所以要提供读和写两个方法。

现在回过头来看一下todo.component.html,我们看到(onRemoveTodo)="removeTodo($event)",这句是为了处理子组件(TodoList)的输出型参数(onRemoveTodo),而$event其实就是这个事件反射器携带的参数(这里是todo:Todo)。我们通过这种机制完成组件间的数据交换。

//todo.component.html
<section class="todoapp">
  <app-todo-header
    placeholder="What do you want"
    (textChanges)="onTextChanges($event)"
    (onEnterUp)="addTodo()" >
  </app-todo-header>
  <app-todo-list
    [todos]="todos"
    (onRemoveTodo)="removeTodo($event)"
    (onToggleTodo)="toggleTodo($event)"
    >
  </app-todo-list>
  <app-todo-footer [itemCount]="todos?.length"></app-todo-footer>
</section>

讲到这里大家可能要问是不是过度设计了,这么少的功能用得着这么设计吗?是的,本案例属于过度设计,但我们的目的是展示出更多的Angular实战方法和特性。

填坑,完成漏掉的功能

现在我们还差几个功能:全部反转状态(ToggleAll),清除全部已完成任务(Clear Completed)和状态筛选器。我们的设计方针是逻辑功能放在TodoComponent中,而其他子组件只负责表现。这样的话,我们先来看看逻辑上应该怎么完成。

用路由参数传递数据

首先看一下过滤器,在Footer中我们有三个过滤器:All,Active和Completed,点击任何一个过滤器,我们只想显示过滤后的数据。

image_1b17mtibdkjn105l1ojl1dgr9il9.png-6.5kB

这个功能其实有几种可以实现的方式,第一种我们可以按照之前讲过的组件间传递数据的方式设置一个@Output的事件发射器来实现。但本节中我们采用另一种方式,通过路由传递参数来实现。Angular2可以给路由添加参数,最简单的一种方式是比如/todo是我们的TodoComponent处理的路径,如果希望携带一个filter参数的话,可以在路由定义中写成

  {
    path: 'todo/:filter',
    component: TodoComponent
  }

这个:filter是一个参数表达式,也就是说例如todo/ACTIVE就意味着参数filter='ACTIVE'。看上去有点像子路由,但这里我们使用一个组件去处理不同路径的,所以todo/后面的数据就被当作路由参数来对待了。这样的话就比较简单了,我们在todo-footer.component.html中把几个过滤器指向的路径写一下,注意这里和需要使用Angular2特有的路由链接指令(routerLink)

  <ul class="filters">
    <li><a routerLink="/todo/ALL">All</a></li>
    <li><a routerLink="/todo/ACTIVE">Active</a></li>
    <li><a routerLink="/todo/COMPLETED">Completed</a></li>
  </ul>

当然我们还需要在todo.routes.ts中增加路由参数到路由数组中

  {
    path: 'todo/:filter',
    component: TodoComponent
  }

根路由定义也需要改写一下,因为原来todo不带参数时,我们直接重定向到todo模块即可,但现在有参数的话应该重定向到默认参数是“ALL”的路径;

  {
    path: 'todo',
    redirectTo: 'todo/ALL'
  }

现在打开todo.component.ts看看怎么接收这个参数:

  1. 引入路由对象 import { Router, ActivatedRoute, Params } from '@angular/router';
  2. 在构造中注入ActivatedRouteRouter
  constructor(
    @Inject('todoService') private service,
    private route: ActivatedRoute,
    private router: Router) {}

然后在ngOnInit()中添加下面的代码,一般的逻辑代码如果需要在ngOnInit()中调用。

  ngOnInit() {
    this.route.params.forEach((params: Params) => {
      let filter = params['filter'];
      this.filterTodos(filter);
    });
  }

this.route.params返回的是一个Observable,里面包含着所以传递的参数,当然我们这个例子很简单只有一个,就是刚才定义的filter。当然我们需要在组件内添加对各种filter处理的方法:调用service中的处理方法后对todos数组进行操作。组件中原有的getTodos方法已经没有用了,删掉吧。

  filterTodos(filter: string): void{
    this.service
      .filterTodos(filter)
      .then(todos => this.todos = [...todos]);
  }

最后我们看看在todo.service.ts中我们如何实现这个方法

  // GET /todos?completed=true/false
  filterTodos(filter: string): Promise<Todo[]> {
    switch(filter){
      case 'ACTIVE': return this.http
                        .get(`${this.api_url}?completed=false`)
                        .toPromise()
                        .then(res => res.json() as Todo[])
                        .catch(this.handleError);
      case 'COMPLETED': return this.http
                          .get(`${this.api_url}?completed=true`)
                          .toPromise()
                          .then(res => res.json() as Todo[])
                          .catch(this.handleError);
      default:
        return this.getTodos();
    }
  }

至此大功告成,我们来看看效果吧。现在输入http://localhost:4200/todo进入后观察浏览器地址栏,看到了吧,路径自动被修改成了http://localhost:4200/todo/ALL,我们的在跟路由中定义的重定向起作用了!

image_1b17o06nv10ob13d6pb1f5613pnm.png-137.8kB

现在,试着点击其中某个todo更改其完成状态,然后点击Active,我们看到不光路径变了,数据也按照我们期待的方式更新了。

image_1b17o6qjlb31grg1o7edjm1q4l13.png-128kB

批量修改和批量删除

ToggleAll和ClearCompleted的功能其实是一个批量修改和批量删除的过程。 在todo-footer.component.html中增加Clear Completed按钮的事件处理

<button class="clear-completed" (click)="onClick()">Clear completed</button>

Clear Completed在Footer中,所以我们需要给Footer组件增加一个输出型参数onClearonClick()事件处理方法

//todo-footer.component.ts
...
  @Output() onClear = new EventEmitter<boolean>();
  onClick(){
    this.onClear.emit(true);
  }
...

类似的,ToggleAll位于TodoList中,所以在todo-list.component.html中为其增加点击事件

<input class="toggle-all" type="checkbox" (click)="onToggleAllTriggered()">

todo-list.component.ts中增加一个输出型参数onToggleAll和onToggleAllTriggered的方法

  @Output() onToggleAll = new EventEmitter<boolean>();
  onToggleAllTriggered() {
    this.onToggleAll.emit(true);
  }

在父组件模板中添加子组件中刚刚声明的新属性,在todo.component.html中为app-todo-list和app-todo-footer添加属性:

  ...
  <app-todo-list
    ...
    (onToggleAll)="toggleAll()"
    >
  </app-todo-list>
  <app-todo-footer
    ...
    (onClear)="clearCompleted()">
  </app-todo-footer>
  ...

最后在父组件(todo.component.ts)中添加对应的处理方法。最直觉的做法是循环数组,执行已有的toggleTodo(todo: Todo)removeTodo(todo: Todo)。我们更改一下todo.component.ts,增加下面两个方法:

  toggleAll(){
    this.todos.forEach(todo => this.toggleTodo(todo));
  }

  clearCompleted(){
    const todos = this.todos.filter(todo=> todo.completed===true);
    todos.forEach(todo => this.removeTodo(todo));
  }

先保存一下,点击一下输入框左边的下箭头图标或者右下角的“Clear Completed”,看看效果

image_1b1c8if181tld15hlj531aasi8a9.png-140kB

大功告成!慢着,等一下,哪里好像不太对。让我们回过头再看看toggleAll方法和clearCompleted方法。目前的实现方式有个明显问题,那就是现在的处理方式又变成同步的了(this.todos.forEach()是个同步方法),如果我们的处理逻辑比较复杂的话,现在的实现方式会导致UI没有响应。但是如果不这么做的话,对于一系列的异步操作我们怎么处理呢?Promise.all(iterable)就是应对这种情况的,它适合把一系列的Promise一起处理,直到所有的Promise都处理完(或者是异常时reject),之后也返回一个Promise,里面是所有的返回值。

let p1 = Promise.resolve(3);
let p2 = 1337;
let p3 = new Promise((resolve, reject) => {
  setTimeout(resolve, 100, "foo");
}); 

Promise.all([p1, p2, p3]).then(values => { 
  console.log(values); // [3, 1337, "foo"] 
});

但是还有个问题,我们目前的toggleTodo(todo: Todo)removeTodo(todo: Todo)并不返回Promise,所以也需要小改造一下:

//todo.component.ts片段
toggleTodo(todo: Todo): Promise<void> {
    const i = this.todos.indexOf(todo);
    return this.service
      .toggleTodo(todo)
      .then(t => {
        this.todos = [
          ...this.todos.slice(0,i),
          t,
          ...this.todos.slice(i+1)
          ];
        return null;
      });
  }
  removeTodo(todo: Todo): Promise<void>  {
    const i = this.todos.indexOf(todo);
    return this.service
      .deleteTodoById(todo.id)
      .then(()=> {
        this.todos = [
          ...this.todos.slice(0,i),
          ...this.todos.slice(i+1)
        ];
        return null;
      });
  }
  toggleAll(){
    Promise.all(this.todos.map(todo => this.toggleTodo(todo)));
  }
  clearCompleted(){
    const completed_todos = this.todos.filter(todo => todo.completed === true);
    const active_todos = this.todos.filter(todo => todo.completed === false);
    Promise.all(completed_todos.map(todo => this.service.deleteTodoById(todo.id)))
      .then(() => this.todos = [...active_todos]);
  }

现在再去试试效果,应该一切功能正常。当然这个版本其实还是有问题的,本质上还是在循环调用toggleTodoremoveTodo,这样做会导致多次进行HTTP连接,所以最佳策略应该是请服务器后端同学增加一个批处理的API给我们。但是服务器端的编程不是本教程的范畴,这里就不展开了,大家只需记住如果在生产环境中切记要减少HTTP请求的次数和缩减发送数据包的大小。说到减小HTTP交互数据的大小的话,我们的todo.service.ts中可以对toggleTodo方法做点改造。原来的put方法是将整个todo数据上传,但其实我们只改动了todo.completed属性。如果你的web api是符合REST标准的话,我们可以用Http的PATCH方法而不是PUT方法,PATCH方法会只上传变化的数据。

  // It was PUT /todos/:id before
  // But we will use PATCH /todos/:id instead
  // Because we don't want to waste the bytes those don't change
  toggleTodo(todo: Todo): Promise<Todo> {
    const url = `${this.api_url}/${todo.id}`;
    let updatedTodo = Object.assign({}, todo, {completed: !todo.completed});
    return this.http
            .patch(url, JSON.stringify({completed: !todo.completed}), {headers: this.headers})
            .toPromise()
            .then(() => updatedTodo)
            .catch(this.handleError);
  }

最后其实Todo的所有子组件其实都没有用到ngInit,所以不必实现NgInit接口,可以去掉ngInit方法和相关的接口引用。

本节代码: https://github.com/wpcfan/awesome-tutorials/tree/chap04/angular2/ng2-tut


第五章:多用户版本的待办事项应用

第四章我们完成的Todo的基本功能看起来还不错,但是有个大问题,就是每个用户看到的都是一样的待办事项,我们希望的是每个用户拥有自己的待办事项列表。我们来分析一下怎么做,如果每个todo对象带一个UserId属性是不是可以解决呢?好像可以,逻辑大概是这样:用户登录后转到/todo,TodoComponent得到当前用户的UserId,然后调用TodoService中的方法,传入当前用户的UserId,TodoService中按UserId去筛选当前用户的Todos。 但可惜我们目前的LoginComponent还是个实验品,很多功能的缺失,我们是先去做Login呢,还是利用现有的Todo对象先试验一下呢?我个人的习惯是先进行试验。

数据驱动开发

按之前我们分析的,给todo加一个userId属性,我们手动给我们目前的数据加上userId属性吧。更改todo\todo-data.json为下面的样子:

{
  "todos": [
    {
      "id": "bf75769b-4810-64e9-d154-418ff2dbf55e",
      "desc": "getting up",
      "completed": false,
      "userId": 1
    },
    {
      "id": "5894a12f-dae1-5ab0-5761-1371ba4f703e",
      "desc": "have breakfast",
      "completed": true,
      "userId": 2
    },
    {
      "id": "0d2596c4-216b-df3d-1608-633899c5a549",
      "desc": "go to school",
      "completed": true,
      "userId": 1
    },
    {
      "id": "0b1f6614-1def-3346-f070-d6d39c02d6b7",
      "desc": "test",
      "completed": false,
      "userId": 2
    },
    {
      "id": "c1e02a43-6364-5515-1652-a772f0fab7b3",
      "desc": "This is a te",
      "completed": false,
      "userId": 1
    }
  ]
}

如果你还没有启动json-server的话让我们启动它: json-server ./src/app/todo/todo-data.json,然后打开浏览器在地址栏输入http://localhost:3000/todos/?userId=2你会看到只有userId=2的json被输出了

[
  {
    "id": "5894a12f-dae1-5ab0-5761-1371ba4f703e",
    "desc": "have breakfast",
    "completed": true,
    "userId": 2
  },
  {
    "id": "0b1f6614-1def-3346-f070-d6d39c02d6b7",
    "desc": "test",
    "completed": false,
    "userId": 2
  }
]

有兴趣的话可以再试试http://localhost:3000/todos/?userId=2&completed=false或其他组合查询。现在todo有了userId字段,但我们还没有User对象,User的json表现形式看起来应该是这样:

    {
      "id": 1,
      "username": "wang",
      "password": "1234"
    }

当然这个表现形式有很多问题,比如密码是明文的,这些问题我们先不管,但大概样子是类似的。那么现在如果要建立User数据库的话,我们应该新建一个user-data.json

{
  "users": [
    {
      "id": 1,
      "username": "wang",
      "password": "1234"
    },
    {
      "id": 2,
      "username": "peng",
      "password": "5678"
    }
  ]
}

但这样做的话感觉单独为其建一个文件有点不值得,我们干脆把user和todo数据都放在一个文件吧,现在删除./src/app/todo/todo-data.json删除,在src\app下面新建一个data.json

//src\app\data.json
{
  "todos": [
    {
      "id": "bf75769b-4810-64e9-d154-418ff2dbf55e",
      "desc": "getting up",
      "completed": false,
      "userId": 1
    },
    {
      "id": "5894a12f-dae1-5ab0-5761-1371ba4f703e",
      "desc": "have breakfast",
      "completed": true,
      "userId": 2
    },
    {
      "id": "0d2596c4-216b-df3d-1608-633899c5a549",
      "desc": "go to school",
      "completed": true,
      "userId": 1
    },
    {
      "id": "0b1f6614-1def-3346-f070-d6d39c02d6b7",
      "desc": "test",
      "completed": false,
      "userId": 2
    },
    {
      "id": "c1e02a43-6364-5515-1652-a772f0fab7b3",
      "desc": "This is a te",
      "completed": false,
      "userId": 1
    }
  ],
  "users": [
    {
      "id": 1,
      "username": "wang",
      "password": "1234"
    },
    {
      "id": 2,
      "username": "peng",
      "password": "5678"
    }
  ]
}

当然有了数据,我们就得有对应的对象,基于同样的理由,我们把所有的entity对象都放在一个文件:删除src\app\todo\todo.model.ts,在src\app下新建一个目录domain,然后在domain下新建一个entities.ts,请别忘了更新所有的引用。

export class Todo {
  id: string;
  desc: string;
  completed: boolean;
  userId: number;
}
export class User {
  id: number;
  username: string;
  password: string;
}

对于TodoService来说,我们可以做的就是按照刚才的逻辑进行改写:删除和切换状态的逻辑不用改,因为是用Todo的ID标识的。其他的要在访问的URL中加入userId的参数。添加用户的时候要把userId传入:

  ...
  addTodo(desc:string): Promise<Todo> {
    let todo = {
      id: UUID.UUID(),
      desc: desc,
      completed: false,
      userId: this.userId
    };
    return this.http
            .post(this.api_url, JSON.stringify(todo), {headers: this.headers})
            .toPromise()
            .then(res => res.json() as Todo)
            .catch(this.handleError);
  }
  getTodos(): Promise<Todo[]>{
    return this.http.get(`${this.api_url}?userId=${this.userId}`)
              .toPromise()
              .then(res => res.json() as Todo[])
              .catch(this.handleError);
  }
  filterTodos(filter: string): Promise<Todo[]> {
    switch(filter){
      case 'ACTIVE': return this.http
                        .get(`${this.api_url}?completed=false&userId=${this.userId}`)
                        .toPromise()
                        .then(res => res.json() as Todo[])
                        .catch(this.handleError);
      case 'COMPLETED': return this.http
                          .get(`${this.api_url}?completed=true&userId=${this.userId}`)
                          .toPromise()
                          .then(res => res.json() as Todo[])
                          .catch(this.handleError);
      default:
        return this.getTodos();
    }
  }
  ...

验证用户账户的流程

我们来梳理一下用户验证的流程

  1. 存储要访问的URL
  2. 根据本地的已登录标识判断是否此用户已经登录,如果已登录就直接放行
  3. 如果未登录导航到登录页面 用户填写用户名和密码进行登录
  4. 系统根据用户名查找用户表中是否存在此用户,如果不存在此用户,返回错误
  5. 如果存在对比填写的密码和存储的密码是否一致,如果不一致,返回错误
  6. 如果一致,存储此用户的已登录标识到本地
  7. 导航到原本要访问的URL即第一步中存储的URL,删掉本地存储的URL

看上去我们需要实现

  • UserService:用于通过用户名查找用户并返回用户
  • AuthService:用于认证用户,其中需要利用UserService的方法
  • AuthGuard:路由拦截器,用于拦截到路由后通过AuthService来知道此用户是否有权限访问该路由,根据结果导航到不同路径。 看到这里,你可能有些疑问,为什么我们不把UserService和AuthService合并呢?这是因为UserService是用于对用户的操作的,不光认证流程需要用到它,我们未来要实现的一系列功能都要用到它,比如注册用户,后台用户管理,以及主页要显示用户名称等。

核心模块

根据这个逻辑流程,我们来组织一下代码。开始之前我们想把认证相关的代码组织在一个新的模块下,我们暂时叫它core吧。在src\app下新建一个core目录,然后在core下面新建一个core.module.ts

import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
  imports: [
    CommonModule
  ]
})
export class CoreModule {
  constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
    if (parentModule) {
      throw new Error(
        'CoreModule is already loaded. Import it in the AppModule only');
    }
  }

注意到这个模块和其他模块不太一样,原因是我们希望**只在应用启动时导入它一次,而不会在其它地方导入它。**在模块的构造函数中我们会要求Angular把CoreModule注入自身,这看起来像一个危险的循环注入。不过,@SkipSelf装饰器意味着*在当前注入器的所有祖先注入器中寻找CoreModule。*如果该构造函数在我们所期望的AppModule中运行,就没有任何祖先注入器能够提供CoreModule的实例,于是注入器会放弃查找。默认情况下,当注入器找不到想找的提供商时,会抛出一个错误。 但@Optional装饰器表示找不到该服务也无所谓。 于是注入器会返回null,parentModule参数也就被赋成了空值,而构造函数没有任何异常。 那么我们在什么时候会需要这样一个模块?比如在这个模块中我们可能会要提供用户服务(UserService),这样的服务系统各个地方都需要,但我们不希望它被创建多次,希望它是一个单例。再比如某些只应用于AppComponent模板的一次性组件,没有必要共享它们,然而如果把它们留在根目录,还是显得太乱了。我们可以通过这种形式隐藏它们的实现细节。然后通过根模块AppModule导入CoreModule来获取其能力。

路由守卫

首先我们来看看Angular内建的路由守卫机制,在实际工作中我们常常会碰到下列需求:

  • 该用户可能无权导航到目标组件。 导航前需要用户先登录(认证)。
  • 在显示目标组件前,我们可能得先获取某些数据。
  • 在离开组件前,我们可能要先保存修改。
  • 我们可能要询问用户:你是否要放弃本次更改,而不用保存它们?

我们可以往路由配置中添加守卫,来处理这些场景。守卫返回true,导航过程会继续;返回false,导航过程会终止,且用户会留在原地(守卫还可以告诉路由器导航到别处,这样也取消当前的导航)。

路由器支持多种守卫:

  • 用CanActivate来处理导航到某路由的情况。
  • 用CanActivateChild处理导航到子路由的情况。
  • 用CanDeactivate来处理从当前路由离开的情况。
  • 用Resolve在路由激活之前获取路由数据。
  • 用CanLoad来处理异步导航到某特性模块的情况。

在分层路由的每个级别上,我们都可以设置多个守卫。路由器会先按照从最深的子路由由下往上检查的顺序来检查CanDeactivate守护条件。然后它会按照从上到下的顺序检查CanActivate守卫。如果任何守卫返回false,其它尚未完成的守卫会被取消,这样整个导航就被取消了。

本例中我们希望用户未登录前不能访问todo,那么需要使用CanActivate

import { AuthGuardService } from '../core/auth-guard.service';
const routes: Routes = [
  {
    path: 'todo/:filter',
    canActivate: [AuthGuardService],
    component: TodoComponent
  }
];

当然光这么写是没有用的,下面我们来建立一个AuthGuardService,命令行中键入ng g s core/auth-guard(angular-cli对于Camel写法的文件名是采用-来分隔每个大写的词)。

import { Injectable, Inject } from '@angular/core';
import {
  CanActivate,
  Router,
  ActivatedRouteSnapshot,
  RouterStateSnapshot }    from '@angular/router';

@Injectable()
export class AuthGuardService implements CanActivate {

  constructor(private router: Router) { }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    //取得用户访问的URL
    let url: string = state.url;
    return this.checkLogin(url);
  }
  checkLogin(url: string): boolean {
    //如果用户已经登录就放行
    if (localStorage.getItem('userId') !== null) { return true; }
    //否则,存储要访问的URl到本地
    localStorage.setItem('redirectUrl', url);
    //然后导航到登陆页面
    this.router.navigate(['/login']);
    //返回false,取消导航
    return false;
  }
}

观察上面代码,我们发现本地存储的userId的存在与否决定了用户是否已登录的状态,这当然是一个漏洞百出的实现,但我们暂且不去管它。现在我们要在登录时把这个状态值写进去。我们新建一个登录鉴权的AuthServiceng g s core/auth

import { Injectable, Inject } from '@angular/core';
import { Http, Headers, Response } from '@angular/http';

import 'rxjs/add/operator/toPromise';
import { Auth } from '../domain/entities';

@Injectable()
export class AuthService {

  constructor(private http: Http, @Inject('user') private userService) { }

  loginWithCredentials(username: string, password: string): Promise<Auth> {
    return this.userService
      .findUser(username)
      .then(user => {
        let auth = new Auth();
        localStorage.removeItem('userId');
        let redirectUrl = (localStorage.getItem('redirectUrl') === null)?
          '/': localStorage.getItem('redirectUrl');
        auth.redirectUrl = redirectUrl;
        if (null === user){
          auth.hasError = true;
          auth.errMsg = 'user not found';
        } else if (password === user.password) {
          auth.user = Object.assign({}, user);
          auth.hasError = false;
          localStorage.setItem('userId',user.id);
        } else {
          auth.hasError = true;
          auth.errMsg = 'password not match';
        }

        return auth;
      })
      .catch(this.handleError);
  }
  private handleError(error: any): Promise<any> {
    console.error('An error occurred', error); // for demo purposes only
    return Promise.reject(error.message || error);
  }
}

注意到我们返回了一个Auth对象,这是因为我们要知道几件事:

  • 用户最初要导航的页面URL
  • 用户对象
  • 如果发生错误的话,是什么错误,我们需要反馈给用户

这个Auth对象同样在src\app\domain\entities.ts中声明

export class Auth {
  user: User;
  hasError: boolean;
  errMsg: string;
  redirectUrl: string;
}

当然我们还得实现UserService:ng g s user

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

import { Http, Headers, Response } from '@angular/http';

import 'rxjs/add/operator/toPromise';
import { User } from '../domain/entities';

@Injectable()
export class UserService {

  private api_url = 'http://localhost:3000/users';

  constructor(private http: Http) { }

  findUser(username: string): Promise<User> {
    const url = `${this.api_url}/?username=${username}`;
    return this.http.get(url)
              .toPromise()
              .then(res => {
                let users = res.json() as User[];
                return (users.length>0)?users[0]:null;
              })
              .catch(this.handleError);
  }
  private handleError(error: any): Promise<any> {
    console.error('An error occurred', error); // for demo purposes only
    return Promise.reject(error.message || error);
  }
}

这段代码比较简单,就不细讲了。下面我们改造一下src\app\login\login.component.html,在原来用户名的验证信息下加入,用于显示用户不存在或者密码不对的情况

        <div *ngIf="usernameRef.errors?.required">this is required</div>
        <div *ngIf="usernameRef.errors?.minlength">should be at least 3 charactors</div>
        <!--add the code below-->
        <div *ngIf="auth?.hasError">{{auth.errMsg}}</div>

接下来我们还得改造src\app\login\login.component.ts

import { Component, OnInit, Inject } from '@angular/core';
import { Router, ActivatedRoute, Params } from '@angular/router';

import { Auth } from '../domain/entities';

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

  username = '';
  password = '';
  auth: Auth;

  constructor(@Inject('auth') private service, private router: Router) { }

  ngOnInit() {
  }

  onSubmit(formValue){
    this.service
      .loginWithCredentials(formValue.login.username, formValue.login.password)
      .then(auth => {
        let redirectUrl = (auth.redirectUrl === null)? '/': auth.redirectUrl;
        if(!auth.hasError){
          this.router.navigate([redirectUrl]);
          localStorage.removeItem('redirectUrl');
        } else {
          this.auth = Object.assign({}, auth);
        }
      });
  }
}

然后我们别忘了在core模块中声明我们的服务src\app\core\core.module.ts

import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AuthService } from './auth.service';
import { UserService } from './user.service';
import { AuthGuardService } from './auth-guard.service';
@NgModule({
  imports: [
    CommonModule
  ],
  providers: [
    { provide: 'auth', useClass: AuthService },
    { provide: 'user', useClass: UserService },
    AuthGuardService
    ]
})
export class CoreModule {
  constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
    if (parentModule) {
      throw new Error(
        'CoreModule is already loaded. Import it in the AppModule only');
    }
  }
}

最后我们得改写一下TodoService,因为我们访问的URL变了,要传递的数据也有些变化

//todo.service.ts代码片段
  // POST /todos
  addTodo(desc:string): Promise<Todo> {
    //“+”是一个简易方法可以把string转成number
    const userId:number = +localStorage.getItem('userId');
    let todo = {
      id: UUID.UUID(),
      desc: desc,
      completed: false,
      userId
    };
    return this.http
            .post(this.api_url, JSON.stringify(todo), {headers: this.headers})
            .toPromise()
            .then(res => res.json() as Todo)
            .catch(this.handleError);
  }
  // GET /todos
  getTodos(): Promise<Todo[]>{
    const userId = +localStorage.getItem('userId');
    const url = `${this.api_url}/?userId=${userId}`;
    return this.http.get(url)
              .toPromise()
              .then(res => res.json() as Todo[])
              .catch(this.handleError);
  }

  // GET /todos?completed=true/false
  filterTodos(filter: string): Promise<Todo[]> {
    const userId:number = +localStorage.getItem('userId');
    const url = `${this.api_url}/?userId=${userId}`;
    switch(filter){
      case 'ACTIVE': return this.http
                        .get(`${url}&completed=false`)
                        .toPromise()
                        .then(res => res.json() as Todo[])
                        .catch(this.handleError);
      case 'COMPLETED': return this.http
                          .get(`${url}&completed=true`)
                          .toPromise()
                          .then(res => res.json() as Todo[])
                          .catch(this.handleError);
      default:
        return this.getTodos();
    }
  }

现在应该已经ok了,我们来看看效果: 用户密码不匹配时,显示password not match

image_1b23h2m601puv1q9664c52c1jem9.png-7.2kB

用户不存在时,显示user not found

image_1b23h3l811dn4g9h16qu1jm11htbm.png-5.6kB

直接在浏览器地址栏输入http://localhost:4200/todo,你会发现被重新导航到了login。输入正确的用户名密码后,我们被导航到了todo,现在每个用户都可以创建属于自己的待办事项了。

image_1b23hdv51l621elh1uucsri32213.png-51.1kB

路由模块化

Angular团队推荐把路由模块化,这样便于使业务逻辑和路由松耦合。虽然目前在我们的应用中感觉用处不大,但按官方推荐的方式还是和大家一起改造一下吧。删掉原有的app.routes.tstodo.routes.ts。添加app-routing.module.ts:

import { NgModule }     from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from './login/login.component';

const routes: Routes = [
  {
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  },
  {
    path: 'login',
    component: LoginComponent
  },
  {
    path: 'todo',
    redirectTo: 'todo/ALL'
  }
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes)
  ],
  exports: [
    RouterModule
  ]
})
export class AppRoutingModule {}

以及src\app\todo\todo-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { TodoComponent } from './todo.component';

import { AuthGuardService } from '../core/auth-guard.service';

const routes: Routes = [
  {
    path: 'todo/:filter',
    canActivate: [AuthGuardService],
    component: TodoComponent
  }
];

@NgModule({
  imports: [ RouterModule.forChild(routes) ],
  exports: [ RouterModule ]
})
export class TodoRoutingModule { }

并分别在AppModule和TodoModule中引入路由模块。

用VSCode进行调试

有读者问如何用vscode进行debug,这章我们来介绍一下。首先需要安装一个vscode插件,点击左侧最下面的图标或者“在查看菜单中选择命令面板,输入install,选择扩展:安装扩展”,然后输入“debugger for chrome”回车,点击安装即可。

image_1b23hjd3rble1nb11u7i19qgjqb1g.png-170.5kB

然后点击最左边的倒数第二个按钮

image_1b23htavu19i412obd751h8kusj1t.png-72.5kB

如果是第一次使用的话,齿轮图标上会有个红点,点击选择debugger for chrome,vscode会帮你创建一个配置文件,这个文件位于\.vscode\launch.json是debugger的配置文件,请改写成下面的样子。注意如果是MacOSX或者Linux,请把userDataDir替换成对应的临时目录,另外把"webpack:///C:*":"C:/*"替换成"webpack:///*": "/*",这句是因为angular-cli是采用webpack打包的,如果没有使用angular-cli不需要添加这句。

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch Chrome against localhost, with sourcemaps",
            "type": "chrome",
            "request": "launch",
            "url": "http://localhost:4200",
            "sourceMaps": true,
            "runtimeArgs": [
                "--disable-session-crashed-bubble",
                "--disable-infobars"
            ],
            "diagnosticLogging": true,
            "webRoot": "${workspaceRoot}/src",
            //windows setup
            "userDataDir": "C:\\temp\\chromeDummyDir",
            "sourceMapPathOverrides": {
                "webpack:///C:*":"C:/*"
                //use "webpack:///*": "/*" on Linux/OSX
            }
        },
        {
            "name": "Attach to Chrome, with sourcemaps",
            "type": "chrome",
            "request": "attach",
            "port": 9222,
            "sourceMaps": true,
            "diagnosticLogging": true,
            "webRoot": "${workspaceRoot}/src",
            "sourceMapPathOverrides": {
                "webpack:///C:*":"C:/*"
            }
        }
    ]
}

现在你可以试着在源码中设置一个断点,点击debug视图中的debug按钮,可以尝试右键点击变量把它放到监视中看看变量值或者逐步调试应用。

image_1b23igfkdhn71ug71cng3in94t2a.png-400.1kB

本章完整代码见: https://github.com/wpcfan/awesome-tutorials/tree/chap05/angular2/ng2-tut


第六章:使用第三方样式库及模块优化

生产环境初体验

用angular-cli建立生产环境是非常简单的,只需输入ng build --prod --aot即可。--prod会使用生产环境的配置文件,--aot会使用AOT替代JIT进行编译。现在实验一下

image_1b2m0102o1d721c438jr18r9f889.png-238.5kB

仔细看一下命令行输出,我们应该可以猜到angular移除了一些没有用到的类库(Google称之为Shaking过程),对js和css等进行了压缩等优化工作。angular在我们的项目根目录下建立了一个dist文件夹,用于生产环境的文件就输出在这个文件夹了。

image_1b2m07bdvqk91aaodsd16pd2kuv.png-116.5kB

我们安装一个http-server,npm i -g http-server,然后在dist目录键入http-server .。打开浏览器进入http://localhost:8080,我们会看到网页打开了。但如果打开console,或者试着登录一下,你会发现存在很多错误。

image_1b2m0l4teqja2f016s61g5o14261c.png-158.4kB

这是由于angular-cli当前的bug产生的,目前需要对路由做hash处理。

...
@NgModule({
  imports: [
    RouterModule.forRoot(routes, { useHash: true })
  ],
  exports: [
    RouterModule
  ]
})
...

只需在app-routing.module.ts中为RouterModule配置{ useHash: true }的属性即可。这样的话angular会在url上加上一个#,比如login的url现在是http://localhost:8080/#/login。这样改动后,功能又好用了。以后我们项目如果需要发布到生产环境的,大家利用angular-cli可以很方便的处理了。然后下面我们回到开发环境,请关掉8080端口的http服务器,并删掉dist。

第三方样式库

之前我们使用的是自己为各个组件写样式,其实angular团队有一套官方的符合Material Design的内建组件库:https://github.com/angular/material2(这个库还属于早期阶段,很多控件不可用,所以大家可以关注,但现阶段不建议在生产环境中使用)。除了官方之外,目前有大量的比较成熟的样式库,比如bootstrap,material-design-lite等。我们这节课以material-design-lite来看一下怎么使用。Material Desing Lite是Google为web开发的一套基于Material Design的样式库。由于是Google开发的,所以你要去访问之前要科学上网。我们当然可以直接使用官方的css样式库,但是有好心人已经帮我们封装成了比较好用的组件模块了,组件模块的好处是可以使模板写起来更简洁,而且易于扩展。现在打开一个terminal输入npm install --save angular2-mdl。然后在你需要使用MDL组件的模块中引入MdlModule。我们首先希望改造一下我们的AppComponent,目前它只有一句简陋的文字输出。

<mdl-layout mdl-layout-fixed-header mdl-layout-header-seamed>
  <mdl-layout-header>
    <mdl-layout-header-row>
      <mdl-layout-title>Awesome Todos</mdl-layout-title>
      <mdl-layout-spacer></mdl-layout-spacer>
      <!-- Navigation. We hide it in small screens. -->
      <nav class="mdl-navigation">
        <a class="mdl-navigation__link">Logout</a>
      </nav>
    </mdl-layout-header-row>
  </mdl-layout-header>
  <mdl-layout-drawer>
    <mdl-layout-title>Title</mdl-layout-title>
    <nav class="mdl-navigation">
      <a class="mdl-navigation__link">Link</a>
    </nav>
  </mdl-layout-drawer>
  <mdl-layout-content class="content">
    <router-outlet></router-outlet>
  </mdl-layout-content>
</mdl-layout>

这段代码里面mdl开头的标签都是我们刚引入的组件库封装的组件,具体的用法可以去 http://mseemann.io/angular2-mdl/ 参考文档资料。<mdl-layout></mdl-layout>是一个布局组件,mdl-layout-fixed-header是一个可以让header固定在页面顶部的属性,mdl-layout-header-seamed是要header没有阴影。mdl-layout-header是一个顶部组件,mdl-layout-header-row是在顶部组件中形成一行的容器。mdl-layout-spacer是一个占位的组件,它会把组件剩余位置占满,防止出现错位。mdl-layout-drawer是一个抽屉组件,和Android的标准应用类似,点击顶部菜单图标会从侧面滑出一个菜单。别忘了在AppModule中引入

...
import { MdlModule } from 'angular2-mdl';
...
@NgModule({
  ...
  imports: [
    ...
    MdlModule,
    ...
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

我们为了使用,还需要对颜色做个定制,这个定制需要使用一种CSS的预编译技术叫SASS,需要建立一个src\styles.scss,然后定义Material Design的颜色,具体颜色名字的定义是在Google调色板类中定义的,可以去这里查看

@import "~angular2-mdl/scss/color-definitions";

$color-primary: $palette-blue-500;
$color-primary-dark: $palette-blue-700;
$color-accent: $palette-amber-A200;
$color-primary-contrast: $color-dark-contrast;
$color-accent-contrast: $color-dark-contrast;

@import '~angular2-mdl/scss/material-design-lite';

由于我们使用的CLI并不知道我们采用了预编译的css,所以需要改一下angular-cli.json,把styles改写成下面的样子

"styles": [
        "styles.scss"
      ],

保存后打开浏览器看一下效果:

image_1b2g0jju71mdsnd3k2v174k7129.png-11.5kB

我们接下来改造一下login的模板

<div>
  <form (ngSubmit)="onSubmit()">
    <mdl-textfield
      type="text"
      label="Username..."
      name="username"
      floating-label
      required
      [(ngModel)]="username"
      #usernameRef="ngModel"
      >
    </mdl-textfield>
    <div *ngIf="auth?.hasError" >
      {{auth?.errMsg}}
    </div>
    <mdl-textfield
      type="password"
      label="Password..."
      name="password"
      floating-label
      required
      [(ngModel)]="password"
      #passwordRef="ngModel">
    </mdl-textfield>
    <button 
      mdl-button mdl-button-type="raised" 
      mdl-colored="primary" 
      mdl-ripple type="submit">
      Login
    </button>
  </form>
</div>

由于采用了符合Material Design的组件,我们就不需要原来的用于验证的div了。

image_1b2g1csop1684jfghpphffui9m.png-17kB

下面看一下Todo,原来我们在css中用了svg来改写复选框的样子,现在我们试试用mdl来做。在todo-list.component.html中把ToggleAll改写成下面的样子

<mdl-icon-toggle class="toggle-all" [mdl-ripple]="true" (click)="onToggleAllTriggered()">expand_more</mdl-icon-toggle>

这个标签是把一个图标做成可复选框的效果,这里用到了Google的icon font,所以需要在index.html中引入

<!doctype html>
<html>
<head>
  ...
  <link rel="stylesheet" href="https://fonts.lug.ustc.edu.cn/icon?family=Material+Icons">
</head>
<body>
  <app-root>Loading...</app-root>
</body>
</html>

我们用了科大的镜像,因为Google的产品,你懂的。 当然TodoItem模板中的checkbox也需要改造成

<mdl-icon-toggle (click)="toggle()" [(ngModel)]="isChecked">check_circle</mdl-icon-toggle>

Todo变成下面的样子,也还不错啊~~

image_1b2g1e0261mkmtp61kjm6f94g513.png-81.7kB

模块优化

现在仔细看一下我们的各个模块定义,发现我们不断地重复引入了CommonModuleFormsModuleMdlModule,这些如果在大部分的组件中都会用到话,我们不妨建立一个SharedModule (src\app\shared\shared.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MdlModule } from 'angular2-mdl';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    MdlModule
  ],
  exports: [
    CommonModule,
    FormsModule,
    MdlModule
  ]
})
export class SharedModule { }

这个模块的作用是把常用的组件和模块打包起来(虽然现在没有组件,只是把常用的模块导入又导出),这样在其他模块中只需引入这个模块即可,比如TodoModule现在看起来是下面的样子:

...
import { SharedModule } from '../shared/shared.module';
...
@NgModule({
  imports: [
    SharedModule,
    ...
  ],
  declarations: [
    TodoComponent,
    ...
  ],
  providers: [
    {provide: 'todoService', useClass: TodoService}
    ],
})
export class TodoModule {}

多个不同组件间的通信

下面我们要实现这样一个功能:在用户未登录时,顶部菜单中只有Login一个链接可见,用户登录后,顶部菜单中有三个链接,一个是Todo,一个是用户个人信息,另一个是Logout。按这个需求将顶部菜单改造成如下:

<!--src\app\app.component.html-->
<mdl-layout mdl-layout-fixed-header mdl-layout-header-seamed>
  <mdl-layout-header>
    <mdl-layout-header-row>
      <mdl-layout-title>{{title}}</mdl-layout-title>
      <mdl-layout-spacer></mdl-layout-spacer>
      <!-- Navigation. We hide it in small screens. -->
      <nav class="mdl-navigation" *ngIf="auth?.user?.username !== null">
        <a class="mdl-navigation__link" routerLink="todo">Todos</a>
      </nav>
      <nav class="mdl-navigation" *ngIf="auth?.user?.username !== null">
        <a class="mdl-navigation__link" routerLink="profile">{{auth.user.username}}</a>
      </nav>
      <nav class="mdl-navigation">
        <a class="mdl-navigation__link" *ngIf="auth?.user?.username === null" (click)="login()">
          Login
        </a>
        <a class="mdl-navigation__link" *ngIf="auth?.user?.username !== null" (click)="logout()">
          Logout
        </a>
      </nav>
    </mdl-layout-header-row>
  </mdl-layout-header>
  <mdl-layout-drawer>
    <mdl-layout-title>{{title}}</mdl-layout-title>
    <nav class="mdl-navigation">
      <a class="mdl-navigation__link">Link</a>
    </nav>
  </mdl-layout-drawer>
  <mdl-layout-content class="content">
    <router-outlet></router-outlet>
  </mdl-layout-content>
</mdl-layout>

这样改造完后的页面结构是顶部菜单只加载一次,底下的内容随着不同路由显示不同内容。但如果我们要在login后顶部菜单也随之改变的话,我们一定要实现某种通信机制。前面我们讲过EventEmiiter,当然我们可以将整个页面当成父控件,顶部菜单是子控件的形式,但这时你发现由于我们是用路由插座(<router-outlet></router-outlet>) l来显示内容的,所以无法采用子控件的形式传递信息。

这种情况就要引入Rx了,rx的学习门槛较高,也不是本教程的重点,但我还是这里尝试着解释一下。Rx是响应式编程的利器,它的学习门槛来自于思维方式的转变,从传统的编程思维转成流式思维:Rx总体来看是一个数据流或信号流,所有的操作符都是为了对这个流进行控制。写Rx时要对系统数据或信号的完整逻辑流程先想清楚,然后就比较好写了。

其实在Angular2中,Rx是无处不在的,还记得我们之前总用到toPromise()这个方法吗?其实这个方法是给不太熟悉Rx的同学用的,Angular本身返回的就是Observable。我们现在把UserService改成Rx版本

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

import { Http, Headers, Response } from '@angular/http';
import { Observable } from 'rxjs/Rx';
import 'rxjs/add/operator/map';

import { User } from '../domain/entities';

@Injectable()
export class UserService {

  private api_url = 'http://localhost:3000/users';

  constructor(private http: Http) { }

  getUser(userId: number): Observable<User> {
    const url = `${this.api_url}/${userId}`;
    return this.http.get(url)
              .map(res => res.json() as User);
  }
  findUser(username: string): Observable<User> {
    const url = `${this.api_url}/?username=${username}`;
    return this.http.get(url)
              .map(res => {
                let users = res.json() as User[];
                return (users.length>0) ? users[0] : null;
              });
  }
}

大家可能注意到了,其实有没有Promise都无所谓,大概的写法也是类似的,只不过返回的是Observable。这里改了之后,相关调用的地方都要改一下,比如LoginComponent:

import { Component, Inject } from '@angular/core';
import { Router, ActivatedRoute, Params } from '@angular/router';
import { Auth } from '../domain/entities';
@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css']
})
export class LoginComponent {

  username = '';
  password = '';
  auth: Auth;
  constructor(@Inject('auth') private service, private router: Router) { }

  onSubmit(){
    this.service
      .loginWithCredentials(this.username, this.password)
      .subscribe(auth => {
        this.auth = Object.assign({}, auth);
        if(!auth.hasError){
          this.router.navigate(['todo']);
        }
      });
  }
}

AuthService也需要改写成下面的样子。这里注意到我们引入了一个新概念:Subject。Subject 既是Observer(观察者)也是Observable(被观察对象)。这里采用Subject的原因是我们在Login时改变了Auth的属性,但由于这个Login方法是Login页面显性调用的,其他需要观察Auth变化的地方调用的是getAuth()方法。这样的话,我们需要在Auth发生变化时推送变化出去,我们在loginWithCredentials方法中以this.subject.next(this.auth);写入其变化,在getAuth()中用return this.subject.asObservable();将Subject转换成Observable。

import { Injectable, Inject } from '@angular/core';
import { Http, Headers, Response } from '@angular/http';

import { ReplaySubject, Observable } from 'rxjs/Rx';
import 'rxjs/add/operator/map';
import { Auth } from '../domain/entities';

@Injectable()
export class AuthService {
  auth: Auth = {hasError: true, redirectUrl: '', errMsg: 'not logged in'};
  subject: ReplaySubject<Auth> = new ReplaySubject<Auth>(1);
  constructor(private http: Http, @Inject('user') private userService) {
  }
  getAuth(): Observable<Auth> {
    return this.subject.asObservable();
  }
  unAuth(): void {
    this.auth = Object.assign(
      {},
      this.auth,
      {user: null, hasError: true, redirectUrl: '', errMsg: 'not logged in'});
    this.subject.next(this.auth);
  }
  loginWithCredentials(username: string, password: string): Observable<Auth> {
    return this.userService
      .findUser(username)
      .map(user => {
        let auth = new Auth();
        if (null === user){
          auth.user = null;
          auth.hasError = true;
          auth.errMsg = 'user not found';
        } else if (password === user.password) {
          auth.user = user;
          auth.hasError = false;
          auth.errMsg = null;
        } else {
          auth.user = null;
          auth.hasError = true;
          auth.errMsg = 'password not match';
        }
        this.auth = Object.assign({}, auth);
        this.subject.next(this.auth);
        return this.auth;
      });
  }
}

但为什么是ReplaySubject呢?我们共有2处需要监听Auth的变化:一处是导航栏,导航栏会依据不同的Auth值来显示/隐藏不同菜单;另一处是todo的路由守卫,它会依据Auth是否有错误来判断是否允许进入该路由url。我们来以时间维度分析一下流程:我们在执行登录时,如果鉴权成功,会导航到某个路由(这里是todo),这时会引发CanActivate的检查,而此时最新的Auth已经发射完毕(因为我们在loginWithCredentials中写入了变化值),CanActivate检查时会发现没有Auth数据。

getAuth() Auth:{}  Auth{user: {id: 1...}} getAuth()-没有Auth数据发射了
|==========|==============|===========================|=====
导航栏    登录前          登录后              todo路由守卫激活

这种情况下我们需要缓存最近的一份Auth数据,无论谁,什么时间订阅,只要没有更新的数据,我们就推送最近的一份给它,这就是ReplaySubject的意义所在。

下面我们改写路由守卫

import { Injectable, Inject } from '@angular/core';
import {
  CanActivate,
  CanLoad,
  Router,
  Route,
  ActivatedRouteSnapshot,
  RouterStateSnapshot }    from '@angular/router';
import { Observable } from 'rxjs/Rx';
import 'rxjs/add/operator/map';

@Injectable()
export class AuthGuardService implements CanActivate, CanLoad {

  constructor(
    private router: Router,
    @Inject('auth') private authService) { }

  canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
    let url: string = state.url;

    return this.authService.getAuth()
      .map(auth => !auth.hasError);
  }
  canLoad(route: Route): Observable<boolean> {
    let url = `/${route.path}`;

    return this.authService.getAuth()
      .map(auth => !auth.hasError);
  }
}

这里你会发现多了一个canLoad方法,canActivate是用于是否可以进入某个url,而canLoad是决定是否加载某个url对应的模块。所以需要再改下路由

import { NgModule }     from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { LoginComponent } from './login/login.component';
import { AuthGuardService } from './core/auth-guard.service';

const routes: Routes = [
  {
    path: '',
    redirectTo: 'login',
    pathMatch: 'full'
  },
  {
    path: 'todo',
    redirectTo: 'todo/ALL',
    canLoad: [AuthGuardService]
  }
];

@NgModule({
  imports: [
    RouterModule.forRoot(routes, { useHash: true })
  ],
  exports: [
    RouterModule
  ]
})
export class AppRoutingModule {}

现在打开浏览器欣赏一下我们的成果。

改造后的首页登录后效果图

本节代码:https://github.com/wpcfan/awesome-tutorials/tree/chap06/angular2/ng2-tut


第七章:给组件带来活力

这节我们的主题是“专注酷炫一百年”;-)其实...没那么夸张了,但我们还是要在这一节了解MDL css框架、Angular2 内建的动画特性、更复杂的组件和概括一下Angular2的组件生命周期。

更炫的登陆页

大家不知道有没有试用过bing(必应)搜索引擎(在Google无法访问的情况下,bing的英文搜索还是不错的选择),这个搜索引擎的主页很有特点:每日都会有一张非常好看的图作为背景。

image_1b36ghm4o179516kdikkbc14qp9.png-2737.5kB

我们想做的一个特效呢是类似地给登陆页增加一个背景,但更酷的一点是,我们的背景每隔3秒会自动替换一张。由于涉及到布局,我们先来熟悉一下CSS的框架设计。

响应式的CSS框架

目前主流的响应式css框架都有网格的概念,在我们现在使用的MDL(Material Design Lite)框架中叫做grid。在MDL中,一个页面在PC浏览器上的展现宽度有12个格子(cell),在平板上有8个格子,在手机上有4个格子。即一个grid的一行在PC上是12个cell,在平板上是8个cell,在手机上是4个cell。如果一行中的cell数目大于限制数目(比如在PC上超过12个),MDL会做折行处理。标识一个grid容器也很简单,在对应标签加上class="mdl-grid"即可。类似的每个cell需要在对应标签内加上class="mdl-cell"。如果要定制化grid的话,我们需要给class添加多个样式类名,比如如果希望grid内是没有间隔的,可以写成class="mdl-grid mdl-grid--no-spacing";如果希望添加更多自己的定义,类似的可以写成mdl-grid my-grid-style,然后在css中定义这个my-grid-style即可。

<div class="mdl-grid">
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
  <div class="mdl-cell mdl-cell--1-col">1</div>
</div>
<div class="mdl-grid">
  <div class="mdl-cell mdl-cell--4-col">4</div>
  <div class="mdl-cell mdl-cell--4-col">4</div>
  <div class="mdl-cell mdl-cell--4-col">4</div>
</div>
<div class="mdl-grid">
  <div class="mdl-cell mdl-cell--6-col">6</div>
  <div class="mdl-cell mdl-cell--4-col">4</div>
  <div class="mdl-cell mdl-cell--2-col">2</div>
</div>
<div class="mdl-grid">
  <div class="mdl-cell mdl-cell--6-col mdl-cell--8-col-tablet">6 (8 tablet)</div>
  <div class="mdl-cell mdl-cell--4-col mdl-cell--6-col-tablet">4 (6 tablet)</div>
  <div class="mdl-cell mdl-cell--2-col mdl-cell--4-col-phone">2 (4 phone)</div>
</div>

image_1b36l1ajl1qqm1t091m89gbe1cr7m.png-49.6kB 你可以尝试把浏览器的窗口缩小,让宽度变窄,调整到一定程度后你会发现,布局改变了,变成了下面的样子,这就是同样的代码在平板上的效果。你会发现原本的第一行折成了两行,因为在平板上8个cell是一行。你可以试试继续把浏览器的宽度变窄,看看在手机上的效果。 image_1b36lq1ikh3vnfkadg8rpnrm13.png-59.4kB

下面我们看看怎么对Login页面做改造,首先在form外套一层div,并应用grid相关的css类,当然为了设置背景图,我们使用了一个angular属性ngStyle,这样让我们可以动态的改变背景图。grid里面我们仅有一个有实际内容的cell,就是form了,这个form在PC和平板上都占3个cell,在手机上占4个cell。但为了使这个form可以放在页面靠右的位置,我们添加了2个占位标签mdl-layout-spacer,标签的作用使将cell剩余的横向空间占满。

<div
  class="mdl-grid mdl-grid--no-spacing login-container"
  [ngStyle]="{'background-image': 'url(' + photo + ')'}">
  <mdl-layout-spacer
    class="mdl-cell mdl-cell--8-col mdl-cell--4-col-tablet mdl-cell--hide-phone">
  </mdl-layout-spacer>
  <form
    class="mdl-cell mdl-cell--3-col mdl-cell--3-col-tablet mdl-cell--4-col-phone login-form"
    (ngSubmit)="onSubmit()"
    >
    <!--...(这里省略掉其他控件的内容)-->
  </form>
  <mdl-layout-spacer></mdl-layout-spacer>
</div>

在我们还没有找到可以动态配置的图片源之前,为了看看页面效果,我们可以先找一张图片放在src\assets目录下面,然后在LoginComponent中将其赋值给photo: photo = '/assets/login_default_bg.jpg';。接下来就看看现在的页面效果吧。 image_1b37me9ik1eba1ruq98s1n041siq9.png-3810.5kB

寻找免费的图片源

当然我们可以找到一些免费的图片,然后存到本地来实现这个功能,但如果有一个海量的图片库,我们可以根据关键字搜索不同的图片不是更酷了吗?幸运的是Bing搜索是有API的,去 https://www.microsoft.com/cognitive-services/en-us/bing-image-search-api 点击Get Started for free后点选Bing Image Search申请获得一个API key即可。

image_1b36ncud0epmjsjsrjqds1tka9.png-1021.8kB

申请完毕后可以在My Account中看到你的key,默认是隐藏的,点击Show链接即可看到了,点击Copy链接可以拷贝key到剪贴板。

image_1b36npfqlhkq0l1fge1o8jon0m.png-109.8kB

Bing Image Search API的Request Url是:https://api.cognitive.microsoft.com/bing/v5.0/images/search,后面可以跟随一系列参数,其中q是必选参数,指明搜索的关键字。

参数 是否必选 类型 功能描述
q string 搜索关键字
count number 返回的图片数量,实际返回值可能小于指定值
offset number 要跳过的结果数量
mkt string 从那个国家搜索,比如美国就是en-US
safeSearch string 应用过滤器过滤掉不良成人内容

知道了这些参数的意义后,我们可以在login目录下新建一个BingImageService

import { Injectable } from '@angular/core';
import { Http, Headers, Response } from '@angular/http';
import { Observable } from 'rxjs/Rx';
import { Image } from '../domain/entities';

@Injectable()
export class BingImageService {

  imageUrl: string;
  headers = new Headers({
    'Content-Type': 'application/json',
    //把你获得API key在这里替换掉下面的enter-your-api-key-here
    'Ocp-Apim-Subscription-Key': 'enter-your-api-key-here'
  });

  constructor(private http: Http) {
    const q = '北极+墙纸';
    const baseUrl: string = `https://api.cognitive.microsoft.com/bing/v5.0/images/search`;
    this.imageUrl = baseUrl + `?q=${q}&count=5&mkt=zh-CN&imageType=Photo&size=Large`;
  }

  getImageUrl(): Observable<Image[]>{
    return this.http.get(this.imageUrl, { headers: this.headers })
            .map(res => res.json().value as Image[])
            .catch(this.handleError);
  }
  private handleError(error: Response) {
    console.error(error);
    return Observable.throw(error.json().error || 'Server error');
  }
}

然后在LoginComponent中即可调用这个服务,在得到返回的图片结果后我们就可以去替换掉默认本地图片的地址了。由于我们是得到一个图片地址的数组,所以我们还需要一个对这个数组中的每张图片做一个4秒的等待。而且我们还做了一个小处理 i = (i + 1) % length;,使得图片可以循环播放。注意到我们让LoginComponent实现了OnDestroy接口,这是由于我们希望在页面销毁时也同时销毁观察者的订阅,而不是让它一直跑在后台。

//代码片段
export class LoginComponent implements OnDestroy {

  username = '';
  password = '';
  auth: Auth;
  slides: Image[] = [];
  photo = '/assets/login_default_bg.jpg';
  subscription: Subscription;

  constructor(
    @Inject('auth') private authService,
    @Inject('bing') private bingService,
    private router: Router) {
    this.bingService.getImageUrl()
      .subscribe((images: Image[]) => {
        this.slides = [...images];
        this.rotateImages(this.slides);
      });
  }
  ...
  ngOnDestroy(){
    this.subscription.unsubscribe();
  }
  rotateImages(arr: Image[]){
    const length = arr.length
    let i = 0;
    setInterval(() => {
      i = (i + 1) % length;
      this.photo = this.slides[i].contentUrl;
    }, 4000);
  }
}

来喝杯咖啡,欣赏一下我们的成果吧!

每隔4秒换一张背景图的登录页面

等待4秒后背景切换了

自带动画技能的Angular2

Angular2的目标是一站式解决方案,当然会自带动画技能。动画会被定义在@Component描述性元数据中。在添加动画之前,先引入一些与动画有关的类库:

import {
  Component,
  Inject,
  trigger,
  state,
  style,
  transition,
  animate,
  OnDestroy
} from '@angular/core';

然后就可以在@Component元数据中去添加动画相关的元数据了,我们这里定义了一个叫loginState的动画触发器(trigger)。这个触发器会在inactiveactive两个状态间转换。scale(1.1)是放缩比例,意味着我们对控件做了1.1倍的放大。这个动画的逻辑就是,当触发器处于active状态时,对应用这个触发器状态的控件做1.1倍放大处理。

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.css'],
  animations: [
    trigger('loginState', [
      state('inactive', style({
        transform: 'scale(1)'
      })),
      state('active',   style({
        transform: 'scale(1.1)'
      })),
      transition('inactive => active', animate('100ms ease-in')),
      transition('active => inactive', animate('100ms ease-out'))
    ])
  ]
})

我们刚刚定义了一个动画,但它还没有被用到任何地方。要想使用它,可以在模板中用[@triggerName]="xxx"的形式来把它附加到一个或多个元素上。

      <button
        mdl-button mdl-button-type="raised"
        mdl-colored="primary"
        mdl-ripple type="submit"
        [@loginState]="loginBtnState"
        (mouseenter)="toggleLoginState(true)"
        (mouseleave)="toggleLoginState(false)">
        Login
      </button>

这里我们对Login这个按钮应用了loginState触发器,并且绑定这个触发器的状态值到一个成员变量loginBtnState。而且我们定义了在鼠标进入按钮区域和离开按钮区域时应该通过一个函数toggleLoginState来改变loginBtnState的值。在LoginComponent中定义这个方法即可,我们要实现的这个功能非常简单,一行代码就搞定了:

  toggleLoginState(state: boolean){
    this.loginBtnState = state ? 'active' : 'inactive';
  }

试着将鼠标放在按钮上和离开按钮区域,看看按钮的变化的效果。

鼠标离开和进入按钮区域时不同的按钮大小

完成遗失已久的注册功能

我们自从完成了基本的多用户待办事项后就没有增加注册功能,现在来填补这个缺憾吧。我们打算在点击登录页的Register按钮时弹出一个注册用户的对话框。

我们要实现的注册对话框效果

如果实现一个对话框,利用我们已经引入的angular2-mdl库,需要几个步骤:

我们需要在src\index.html中增加一个“对话框插座”(<dialog-outlet></dialog-outlet>),就是在<app-root>下面添加即可。

<!doctype html>
<html>
<head>
...
</head>
<body>
  <app-root>Loading...</app-root>
  <dialog-outlet></dialog-outlet>
</body>
</html>

建立dialog页面:angular2-mdl中有很多方便内建对话框和声明式方式,但我们这里介绍一种定制化程度比较高,也略显复杂的方式。打开一个命令行终端,输入 ng g c login/register-dialog

对话框的模板比较简单,由一个用户名输入框、一个密码输入框、一个重复密码输入框、一个加载状态和一个注册按钮组成。其中我们希望按钮在表单验证正确后才可用,而且在处理注册过程中,按钮应该不可用。在处理注册过程中,应该有一个用户提示。

<form [formGroup]="form">
  <h3 class="mdl-dialog__title">Register</h3>
  <div class="mdl-dialog__content">
    <mdl-textfield
      #firstElement
      type="text"
      label="Username"
      formControlName="username"
      floating-label>
    </mdl-textfield>
    <br/>
    <mdl-textfield
      type="password"
      label="Password"
      formControlName="password"
      floating-label>
    </mdl-textfield>
    <br/>
    <mdl-textfield
      type="password"
      label="Repeat Password"
      formControlName="repeatPassword"
      floating-label>
    </mdl-textfield>
  </div>
  <div class="status-bar">
    <p class="mdl-color-text--primary">{{statusMessage}}</p>
    <mdl-spinner [active]="processingRegister"></mdl-spinner>
  </div>
  <div class="mdl-dialog__actions">
    <button
      type="button"
      mdl-button
      (click)="register()"
      [disabled]="!form.valid || processingRegister"
      mdl-button-type="raised"
      mdl-colored="primary" mdl-ripple>
      Register
    </button>
  </div>
</form>

那么对应的组件文件中,我们这次没有使用双向绑定,而是完全采取表单的方式进行。这里介绍几个新面孔:

  • FormBuilder:这个其实是一个工具类,用于快速构造一个表单。
  • FormGroup:顾名思义是一组表单控件,一个表单可以有多个FormGroup,这个常常在比较复杂的表单中使用,用于更好的分类和控制。如果这一组中的任何一个控件验证失败,这个FormGroup的验证状态也是失败的。
  • FormControl:跟踪表单控件的值和验证状态。

Angular2 的FormControl中内置了常用的验证器(Validator),我们在这个例子中除此之外还给出了一个自定义的验证器 passwordMatchValidator,用于判断是否两次密码输入的是相同的。

此外呢我们还用到了一个新修饰符 @HostListener ,这个修饰符是指我们要监听宿主(这里是浏览器)的某些动作和变化。比如本例中,我们想要用户在按Esc键时关闭对话框,但这个动作并不局限在某个控件上,只要用户点击了Esc我们就关闭对话框,这时我们就得监听宿主的 keydown.esc 事件了。

//省略掉Import代码段和修饰符代码段
...
export class RegisterDialogComponent{
  @ViewChild('firstElement') private inputElement: MdlTextFieldComponent;
  public form: FormGroup;
  public processingRegister = false;
  public statusMessage = '';
  private subscription: Subscription;

  constructor(
    private dialog: MdlDialogReference,
    private fb: FormBuilder,
    private router: Router,
    @Inject('auth') private authService) {
      this.form = fb.group({
        'username':  new FormControl('',  Validators.required),
        'passwords': fb.group({
          'password': new FormControl('', Validators.required),
          'repeatPassword': new FormControl('', Validators.required)
        },{validator: this.passwordMatchValidator})
      });
      // just if you want to be informed if the dialog is hidden
      this.dialog.onHide().subscribe( (auth) => {
        console.log('login dialog hidden');
        if (auth) {
          console.log('authenticated user', auth);
        }
      });
      this.dialog.onVisible().subscribe( () => {
        this.inputElement.setFocus();
      });
  }

  passwordMatchValidator(group: FormGroup){
    this.statusMessage = '';
    let password = group.get('password').value;
    let confirm = group.get('repeatPassword').value;

    // Don't kick in until user touches both fields
    if (password.pristine || confirm.pristine) {
      return null;
    }
    if(password===confirm) {
      return null;
    }
    return {'mismatch': true};
  }

  public register() {
    this.processingRegister = true;
    this.statusMessage = 'processing your registration ...';

    this.subscription = this.authService
      .register(
        this.form.get('username').value,
        this.form.get('passwords').get('password').value)
      .subscribe( auth => {
        this.processingRegister = false;
        this.statusMessage = 'you are registered and will be signed in ...';
        setTimeout( () => {
          this.dialog.hide(auth);
          this.router.navigate(['todo']);
        }, 500);
    }, err => {
      this.processingRegister = false;
      this.statusMessage = err.message;
    });
  }

  @HostListener('keydown.esc')
  public onEsc(): void {
    if(this.subscription !== undefined)
      this.subscription.unsubscribe();
    this.dialog.hide();
  }
}

这样做完后,打开浏览器却发现报错了,这是由于我们未引入 ReactiveFormsModule 造成的, FormGroup 是由 ReactiveFormsModule 提供的,因此要在 src\app\login\login.module.ts 中引入这个模块。

未引入ReactiveForms引起的报错

Restful API的实验

现在还需要完成服务器端的API。和以前类似的,我们需要先实验一下json-server的API,确定各参数可行的条件下再进行编码。由于现在我们需要进行GET以外的操作,所以如果有专业工具来辅助会比较方便,这里推荐一个Chrome App:Postman,可以自行科学上网后在Chrome商店搜索安装。安装后点左上角的应用即可看到Postman了

Chrome应用:Postman

点击Postman,输入http://localhost:3000/users可以看到返回的json数据了

PostMan的功能区介绍

我们来试验一下新增一个用户,但这个时候我们已经给User的id定义成数字类型了,实在不想改成UUID了,怎么办呢?幸运的是json-server其实是很聪明的,如果在POST时你不给它传入id字段,它会认为这个id是自增长的。在Postman中将HTTP方法设成POST,在Headers中写上 Content-Typeapplication/json。然后在Body中选择 raw ,并写入

{
	"username": "testUser",
	"password": "testPassword"
}

点击Send后可以看到,新的id自动被写入了,这简直太方便了,也符合一般后端开发的套路。

用Postman测试自增长ID

知道这点后,我们着手写对应方法就很简单了,首先在 UserService 中添加addUser方法。

  addUser(user: User): Observable<User>{
    return this.http.post(this.api_url, JSON.stringify(user), {headers: this.headers})
            .map(res => res.json() as User)
            .catch(this.handleError);
  }

在AuthService中添加一个register方法,正如我们刚刚实验的那样,我们只需构造一个没有id的User对象即可。当然我们要检查一下用户名是否存在,如果不存在的话才可以注册新用户。这里又碰到一个新的Rx方法 switchMap,是用来对原来流中的对象做变换后,发射变换后的流。用一个图示来表示我们下面代码的逻辑是这样的

                                  null               null
                                   /                 /
应用filter前:User=====User=====User=====...=====User======...
应用filter后:==================User=====...=====User======...
(把user===null的滤出来)          |                |
应用switchMap后:              Auth======...======Auth=====... 

  register(username: string, password: string): Observable<Auth> {
    let toAddUser = {
      username: username,
      password: password
    };
    return this.userService
            .findUser(username)
            .filter(user => user === null)
            .switchMap(user => {
              return this.userService.addUser(toAddUser).map(u => {
                this.auth = Object.assign(
                  {},
                  { user: u, hasError: false, errMsg: null, redirectUrl: null}
                );
                this.subject.next(this.auth);
                return this.auth;
              });
            });
  }

打开浏览器,检查所有功能是否完整可用,正常情况下点Register你可以看到下面的界面,试着注册一个新用户,开始管理你的待办事项吧。

完成注册功能的页面

Angular 2的组件生命周期

angular 2 的组件生命周期函数

每个组件都有一个被Angular管理的生命周期:Angular创建、渲染控件;创建、渲染子控件;当数据绑定属性改变时做检查;在把控件移除DOM之前销毁控件等等。

Angular提供生命周期的“钩子”(Hook)以便于开发者可以得到这些关键过程的数据以及在这些过程中做出响应的能力。

指令也有类似的生命周期“钩子”函数,除了一些组件特有的函数外。

下面这段代码展现了如何利用 ngOnInit 这个钩子函数

export class PeekABoo implements OnInit {
  constructor(private logger: LoggerService) { }

  // implement OnInit's `ngOnInit` method
  ngOnInit() { this.logIt(`OnInit`); }

  logIt(msg: string) {
    this.logger.log(`#${nextId++} ${msg}`);
  }
}

钩子函数的接口 (比如上面例子中的 OnInit ) 从纯技术的角度来说不是必须的,这是由于Javascript本身没有接口这个概念,而Typescript最终是转换成Javascript的。

Angular其实是通过检查指令或组件的类中是否定义了相关方法来进行的,比如上面例子中即使不实现 OnInit 接口,只要定义了 ngOnInit() 方法,Angular就会在对应的生命周期调用这个方法。但是还是推荐大家使用接口,因为强类型会给我们带来其他好处。

函数 应用范围 目的和触发时机
ngOnChanges 组件和指令 在ngInit之前触发,当Angular设置数据绑定属性或输入性属性时会得到一个包含当前和之前属性值的对象(SimpleChanges)
ngOnInit 组件和指令 只调用一次,在设置完输入性属性后,通过这个函数初始化组件或指令
ngDoCheck 组件和指令 在ngInit之后,每次检测到变化时触发,可以在此检查一些angular自身无法检查的变化
ngAfterContentInit 组件 在ngDoCheck后触发,只调用一次,把要装载到组件视图的内容初始化后
ngAfterContentChecked 组件 ngAfterContentInit之后每次ngDoCheck都会在之后触发ngAfterContentChecked,对要装载到组件视图的内容进行检查后
ngAfterViewInit 组件 在第一个ngAfterContentInit被调用后触发,只调用一次,在angular初始化视图后响应
ngAfterViewChecked 组件 在ngAfterViewInit后及每个ngAfterContentChecked后触发
ngOnDestroy 组件和指令 在组件或指令被销毁前,清理环境,可以在此处取消Observable的订阅

小结

我们的Angular学习之旅从零开始到现在,完整的搭建了一个小应用。相信大家现在应该对Angular2有一个大概的认识了,而且也可以参与到正式的开发项目中去了。但Angular2作为一个完整框架,有很多细节我们是没有提到的,大家可以到官方文档 https://angular.cn/ 去查找和学习。

本节代码:https://github.com/wpcfan/awesome-tutorials/tree/chap07/angular2/ng2-tut


番外:Rx--隐藏在Angular 2.x中利剑

Rx再体验

常见操作

Angular2中的内建支持