Angular2 路由

Angular组件路由使一个视图至另一个视图的导航任务组件。

一、概要

浏览器是一个最熟悉的导航模型应用了,输入一个URL地址后浏览器导航相应URL页面,点击网页上的链接导航至另一个新的页面,浏览器的“后退”或“前近”可以导航至我们浏览器的历史页面。

Angular组件路由(也叫路由)就是借用这个模型,可以理解为通过浏览器的网址来生成客户端视图,并通过一些可选参数指定当前显示哪些视图。当然也可以把路由绑定至一个视图组件里,当点击链接时,导航至适合的视图。并且记录在浏览器历史列表,以便浏览器的前进或后退正常工作。

二、基础知识

先从组件路由基本概念开始,慢慢深入。

1、<base href>

大部分带有路由应用程序都会在 index.html<head> 加上 <base> 元素,用来设置基准URL(也就是应用程序的根路径)。

<base href="/">

2、导入 Router

Angular组件路由是个可选组件,所以并不包括在 core 里,需要我们单独引用 @angular/router 模块。

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

3、配置

当浏览器URL改变时,路由会查找相应的 RouteDefinition 来确定组件的显示。

一般路由配置都在宿主组件内,(在Angular1.x经常会看到一些项目所有路由都在一个js文件当中)而且非常简单,通过 @Routers 装饰注册路由。

@Component({ ... })
@Routes([
  {path: '/crisis-center', component: CrisisListComponent},
  {path: '/heroes',        component: HeroListComponent},
  {path: '/hero/:id',      component: HeroDetailComponent}
])
export class AppComponent  implements OnInit {
  constructor(private router: Router) {}

  ngOnInit() {
    this.router.navigate(['/crisis-center']);
  }
}

有好几种 RouteDefinition 路由定义方式,大部分都是用URL地址来映射组件。

其中第三条路由 :id 是一个路由参数,比如URL地址 /hero/42 中 “42” 值就是 id 参数值。

4、Router Outlet

现在我们已经知道如何配置路由,当浏览器请求URL地址 /heroes 时,路由会查找 RouteDefinition 匹配到 HeroListComponent,那么匹配到的组件放在哪呢?我们就需要在当前组件模板里加入 RouterOutlet

<router-outlet></router-outlet>

我把它叫成点位符更贴切一点。

5、路由链接

知道如何配置和显示,那接下来就是如何导航,正常我们是通过浏览器的地址栏输入网站,可大部分是用户去触发某个链接进行导航的。

可以在一个锚标签添加一个 RouterLink 指令,模板表达式绑定的是一个字符串数组类型的网址。第一眼一定会很奇怪为什么是数组?这是因为还可能需要URL可选参数,虽然还是很奇怪。

template: `
  <h1>Component Router</h1>
  <nav>
    <a [routerLink]="['/crisis-center']">Crisis Center</a>
    <a [routerLink]="['/heroes']">Heroes</a>
  </nav>
  <router-outlet></router-outlet>
`,

6、总结一下

先通过 @RouterConfig 配置组件 AppComponent 的路由,再利用 RouterOutlet 来指定命中路由在哪显示,最后使用 RouterLinks 来处理用户点击链接的导航。

现在 AppComoponent 已经变成了一个路由组件。

路由 意思
Router 类定义了当前活动URL地址环境,可以用它在组件类里进行导航管理。
@RouteConfig 路由装饰器,接收一个 RouteDefinitions 路由定义对象数组。
RouteDefinition 路由定义对象。
RouterOutlet 匹配到组件的显示位置。
RouterLink 用于模板的链接导航。
Link Parameters Array 链接参数数组,可以用在模板的 RouterLink 或 Router.navigate 方法。
Routing Component 配置路由的组件。

三、一个示例

官网用了蛮长的来说明这个示例是怎么回事,大概是这样的:有两个列表分别 “Crisis Center”(记A) 和 “Heroes”(记B),点击列表的项时会进入编辑状态。这在之前二者都是一样的,不同在于编辑页面。

对于A有保存和取消按钮,不管点哪个按钮最后都会回到A列表,而且如果你改动 Name 后点击浏览器后退时会有一个对话框告诉你是不是要放弃编辑;反之B编辑时就很简单只有一个返回按钮,因为你每一次变动都会自动保存。

一个很长的动画,有耐心的可以先看一看效果。

router-anim

那么好,接下来会按里程碑式一步步说说是怎么实现的。

四、里程碑一:开始Router入门

很简单,通过路由来控制两个视图显示。

router-1-anim

1、设置 <base href>

组件路由使用浏览器 history.pushState 来导航,这样的URL地址看起来就像跟直接从服务端URL一样,例如:localhost:3000/crisis-center

现代浏览器都支持HTML5 pushState,为了让 pushState 更好工作,还需要在 index.html 添加一个 <base href="/"> 元素,这样同样可以确保所有请求的资源前缀。

HTML5模式导航是组件路由默认风格,我们也可以用老式的哈希(#)风格,后面会谈到。

2、导入组件路由模块

前面就说过路由模块是独立模块,需要从 angular/router 模块中获取。

import { Routes, Router, ROUTER_DIRECTIVES } from '@angular/router';

3、引导程序中注册路由服务

需要在 main.ts 文件里添加启动时注册路由服务。

import { bootstrap }        from '@angular/platform-browser-dynamic';
import { ROUTER_PROVIDERS } from '@angular/router';

import { AppComponent }     from './app.component';
bootstrap(AppComponent, [
  ROUTER_PROVIDERS
]);

从路由模块中导入 ROUTER_PROVIDERS,它实现一个默认配置的项,并注入到 provider 中。

4、AppComponent组件

和往常一样 AppComponent 组件是我们根组件,模板里包括两个导航链接和一个Router Outlet,通过点击导航链接显示不同的组件。

shell-and-outlet

对应的模板:

template: `
  <h1>Component Router</h1>
  <nav>
    <a [routerLink]="['/crisis-center']">Crisis Center</a>
    <a [routerLink]="['/heroes']">Heroes</a>
  </nav>
  <router-outlet></router-outlet>
`,

RouterOutlet

组件模板中 <router-outlet> 标签就是点位符,告诉命中路由的组件放在哪。

RouterLink属性绑定

RouterLink 属性指令,模板表达式的右边是一个数组对象来表示路由器导航所需要的数据:

  • 下标0是固定的URL部分。
  • 下标1以后放置的是可选参数数据。
<a [routerLink]="['/heroes']">Heroes</a>

除此之外,我们还可以在组件类里面使用 Route 导航管理器进行跳转。

this.router.navigate(['/heroes']);

总结

对于组件而言是一个带有层级结构的,那么当一个组件被放置时就已经被确立其URL地址结构了;所以组件自身理应包括路由的定义。如何更好在代码里面编写路由注册代码,应该在构建项目时就要加以考虑。

路由这一课题Angular2.x一直在变动,但整体的使用上并没有太多不同。

Angular2 结构指令

一个单页应用程序(SPA)最大特征就是对DOM树的操作,是根据程序状态对整段DOM的显示和消失。

一、什么是结构指令

Angular有三种指令类型:

  1. 组件
  2. 属性指令
  3. 结构指令

组件实际是一个带有模板的指令,这是最常见的三种指令,我们的应用程序也是基于指令来构建的。

属性指令改变的是元素的外观或行为,例如内置 NgStyle 指令,可以根据绑定组件的属性值同时改变多个元素的样式。

而结构指令是对DOM的添加或移除,这会改变DOM的布局。在模板语法章节就介绍过 ngIfngSwitchngFor

<div *ngIf="hero">{{hero}}</div>
<div *ngFor="let hero of heroes">{{hero}}</div>
<div [ngSwitch]="status">
  <template [ngSwitchWhen]="'in-mission'">In Mission</template>
  <template [ngSwitchWhen]="'ready'">Ready</template>
  <template ngSwitchDefault>Unknown</template>
</div>

二、NgIf 案例研究

ngIf 指令通过一个布尔类型变量来改变DOM的显示或消失。

<p *ngIf="condition">
  condition is true and ngIf is true.
</p>
<p *ngIf="!condition">
  condition is false and ngIf is false.
</p>

ngIf 指令并不是隐藏元素,使用浏览器开发工具我们可以看到,当条件为 true 时显示第一段DOM,反之显示第二段。

为什么要移除而不是隐藏?

我们可以通过设置CSS display 样式为 none 来隐藏元素,元素保留在DOM中只是看不见而已。相反,就用 ngIf

当我们隐藏一个元素,组件的行为还是继续着,像事件监听、变化检测、子组件的事件监听等依然还会在执手,哪怕我们压根看不见,很明显隐藏这一点来说最大的可能是性能问题;当然他也有好处,那就是当元素想要再一次被显示时,会非常快。

ngIf 就不同,设置为 false 时,会释放组件及其子组件的所有资源,并从DOM里移除元素;当然坏处就是,当你很快又需要该元素时,就要重新构建了。

虽然两种方法都有利弊,如果我们确认不使用的元素在短时间内不会再用那么 ngIf 最合理,反之。

接下来,我们用一个示例来说明二者的区别,通过设置css样式和 ngIf 对一个组件 heavy-loader (假设组件非常复杂)的显示或隐藏。

<div><!-- Visibility -->
  <button (click)="isVisible = !isVisible">show | hide</button>
  <heavy-loader [style.display]="isVisible ? 'inline' : 'none'" [logs]="logs"></heavy-loader>
</div>
<div><!-- NgIf -->
  <button (click)="condition = !condition">if | !if</button>
  <heavy-loader *ngIf="condition" [logs]="logs"></heavy-loader>
</div>
<h4>heavy-loader log:</h4>
<div *ngFor="let message of logs">{{message}}</div>

同时还记录组件的创建和销毁日志,这是利用内置 ngOnInitngOnDestroy 生命周期钩子来实现,效果如下:

heavy-loader-toggle

一开始在DOM都显示这两个组件,首先我们反复切换可见性,利用CSS样式由于每一次的可见性都是同一个实例,所以日志没有任何改变。

相反,当我们反复切换第二 ngIf 的可见性时,每一次都会出现创建新实例和销毁日志。

三、<template> 标签

结构指令像 ngIf,使用HTML template标签。对于非Angular应用而言 <template> 标签 display 样式值为 none,内容是不可见的文档片断;而Angular应用中,会移除所有 <template> 标签,并存储起来。

比如以下代码片断,中间的 Hip! 是用一个 <template> 包裹着。

<p>
  Hip!
</p>
<template>
  <p>
    Hip!
  </p>
</template>
<p>
  Hooray!
</p>

左边是非Angular应用,右边是Angular应用,二者的DOM区别。

template-in-out-of-a2

显然,默认情况下Angular会把 <template> 标签替换成一个空内容的 <script> 标签(虽然我本地的测试里并不会有这个空的script标签),不过呢,他可以做更多的事,比如 ngSwitch 指令在 <template> 标签中的应用:

<div [ngSwitch]="status">
  <template [ngSwitchWhen]="'in-mission'">In Mission</template>
  <template [ngSwitchWhen]="'ready'">Ready</template>
  <template ngSwitchDefault>Unknown</template>
</div>

当其中一个 ngSwitch 条件为 true 时,Angular会把标签里模板内容插入DOM当中。

四、关于指令中的 * 号

看看下面代码有什么特别的?

<div *ngIf="hero">{{hero}}</div>
<div *ngFor="let hero of heroes">{{hero}}</div>

上面指令名称有前缀星号(*),星号是一个语法糖,简化了 ngIfngFor 二者的读写,在模板引擎里最后还是会转换成 <template> 模式。

以下是 ngIf 语法糖与 <template> 的写法:

<!-- Examples (A) and (B) are the same -->
<!-- (A) *ngIf paragraph -->
<p *ngIf="condition">
  Our heroes are true!
</p>

<!-- (B) [ngIf] with template -->
<template [ngIf]="condition">
  <p>
    Our heroes are true!
  </p>
</template>

只要不傻肯定用第一种写法。值得注意的是从A扩展到B,先将段落及其内容移到 <template> 标签里面,属性绑定部分作为 <template> 标签的指令。

接下来看看 *ngFor 是如何转换:

<!-- Examples (A) and (B) are the same -->

<!-- (A) *ngFor div -->
<div *ngFor="let hero of heroes">{{ hero }}</div>

<!-- (B) ngFor with template -->
<template ngFor let-hero [ngForOf]="heroes">
  <div>{{ hero }}</div>
</template>

基本上和 ngIf 如出一辙;额外的细微是多了 ngForOf 属性绑定和 hero 本地模板变量。

五、创建结构指令

让我们创建一个 Unless 指令,类似 ngIf,只不过条件是反着的,当为 false 时才会显示内容。创建指令类似于创建组件:

  • 导入 Directive 装饰器。
  • 添加指令的CSS属性选择器标识符。
  • 指定一个公共的 Input 属性属性用于绑定(通常是指令的名字)。
  • 指令类实现。

先从以下代码开始:

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

@Directive({ selector: '[myUnless]' })
export class UnlessDirective {
}

指令的选择器用的 [myUnless],了解CSS选择器的就很清楚,用中括号来表示某个HTML元素属性。同时命名也尽可能避免 ng 开头,这一点说过很多次,无非就是害怕冲突。

现在还需要分别定义一个 TemplateRef 用来访问模板内容,以及 ViewContainerRef 用来访问视图容器。

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

重点来了,给指令定义一个输入属性 myUnless (名称和 selector 一样),并且接收一个布尔类型,用于条件判断添加或移除模板内容。

@Input() set myUnless(condition: boolean) {
  if (!condition) {
    this.viewContainer.createEmbeddedView(this.templateRef);
  } else {
    this.viewContainer.clear();
  }
}

整体的逻辑非常简单,指令所有代码如下:

import { Directive, Input } from '@angular/core';
import { TemplateRef, ViewContainerRef } from '@angular/core';
@Directive({ selector: '[myUnless]' })
export class UnlessDirective {
  constructor(
    private templateRef: TemplateRef<any>,
    private viewContainer: ViewContainerRef
    ) { }
  @Input() set myUnless(condition: boolean) {
    if (!condition) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}

现在来试试怎么运用这个指令,同 *ngIf 一样,只不过改成 *myUnless

<p *myUnless="condition">
  condition is false and myUnless is true.
</p>

<p *myUnless="!condition">
  condition is true and myUnless is false.
</p>

Angular2 管道(pipes)

每一个应用程序都是一些非常简单的任务开始:获取数据、转换数据,并把它们显示给用户。

获取数据可以是像创建一个变量那样简单,也可以像从Websocket获取数据流那么复杂。

一旦数据达到,我们可以调用 toString 直接在屏幕上显示原始的数据。但这样可能用户体验很差,几乎所有人都需要一个简单的生日日期(1988-4-15)而非原始字符串格式(Fri Apr 15 1988 00:00:00 GMT-0700 (Pacific Daylight Time))。

我们发现,应用程序中重复着相同的转换非常多,事实上,我们想将这些转换直接在HTML模板里应用。这一过程叫做管道(pipe)。

一、使用管道

管道是将数据输入并将其转换后输出,比如把生日日期转换成更友好的日期:

import { Component } from '@angular/core'

@Component({
  selector: 'hero-birthday',
  template: `<p>The hero's birthday is {{ birthday | date }}</p>`
})
export class HeroBirthday {
  birthday = new Date(1988,3,15); // April 15, 1988
}

重点看组件模板:

<p>The hero's birthday is {{ birthday | date }}</p>

插值表达式绑定组件 birthday 属性,然后通过管道操作符(|),而操作符的右边是一个日期管道(Date pipe)函数。这便是管道的调用方式。

二、内置管道

Angular自带一堆管道,比如:DatePipeUpperCasePipeLowerCasePipeCurrencyPipePercentPipe,他们都可以在任何模板中使用;另外还可以通过API Reference 查看所有内置的管道。

三、管道参数化

一个管理可以接受任意数量的可选参数来微调输出,参数之间用冒号(:)隔开,比如: currency:'EUR'slice:1:5

现在给生日模板增加一个日期格式参数。

<p>The hero's birthday is {{ birthday | date:"yyyy-MM-dd" }} </p>

参数值可以是任何有效的模板表达式,如:字符串文本、组件属性。换句话说,我们可通过一个绑定的方式来控制我们的格式,比如通过组件类属性值来控制我们的生日日期格式。

template: `
  <p>The hero's birthday is {{ birthday | date:format }}</p>
  <button (click)="toggleFormat()">Toggle Format</button>
`

按钮点击事件是调用 toggleFormat 方法,方法是用来切换短日期和长日期。

export class HeroBirthday2 {
  birthday = new Date(1988,3,15); // April 15, 1988
  toggle = true; // start with true == shortDate

  get format()   { return this.toggle ? 'shortDate' : 'fullDate'}
  toggleFormat() { this.toggle = !this.toggle; }
}

结果如下:

date-format-toggle-anim

四、链式管道

我们可以通过若干有用的管道依次转移输出,这便叫链式管道。下面示例,我们先通过 DatePipe 管道和 UpperCasePipe 管道的组合,将生日日期先转换成短日期,然后再把结果大写化。最终显示的是:APR 15, 1988。

The chained hero's birthday is
{{ birthday | date | uppercase}}

如果我们需要给日期指定一个日期格式,可以利用括号来改变管道的执行顺序,比如:

The chained hero's birthday is
{{ ( birthday | date:'fullDate' ) | uppercase}}

五、自定义管道

ExponentialStrengthPipe 是一个计算次幂的自定义管道:

import { Pipe, PipeTransform } from '@angular/core';
/*
 * Raise the value exponentially
 * Takes an exponent argument that defaults to 1.
 * Usage:
 *   value | exponentialStrength:exponent
 * Example:
 *   {{ 2 |  exponentialStrength:10}}
 *   formats to: 1024
*/
@Pipe({name: 'exponentialStrength'})
export class ExponentialStrengthPipe implements PipeTransform {
  transform(value: number, exponent: string): number {
    let exp = parseFloat(exponent);
    return Math.pow(value, isNaN(exp) ? 1 : exp);
  }
}

这个管道有几个关键点:

  • 管道是一个使用管道元数据装饰的类。
  • 类必须实现 PipeTransform 接口 transform 方法,方法接收一个输入参数,和若干可选参数,最后返回转换后的值。
  • 管道的 transform 方法有一个可选参数 exponent
  • 我们通过 @Pipe 装饰器来告诉Angular这是一个管道类,所以还需要从Angular库中加载它。
  • @Pipe 装饰器有一个 name 属性,用来指定管道名称。名称必须是有效的JavaScript标识符,这里叫 exponentialStrength

现在演示如何在组件中调用自定义管道。

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

import { ExponentialStrengthPipe } from './exponential-strength.pipe';

@Component({
  selector: 'power-booster',
  template: `
    <h2>Power Booster</h2>
    <p>
      Super power boost: {{2 | exponentialStrength: 10}}
    </p>
  `,
  pipes: [ExponentialStrengthPipe]
})
export class PowerBooster { }

两个注意点:

  1. 自定义管道和内置管道使用方式完全相同。
  2. 必须在 @Component 装饰器的 pipes 数组里列出我们的管道。

六、Power Boost计算器

目前自定义的管道计算都是很死,现在我把他升级成一个利用 ngModel 双向绑定管道的输入和可选参数,这样我们只就可以在文本框输入数值。

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

import { ExponentialStrengthPipe } from './exponential-strength.pipe';

@Component({
  selector: 'power-boost-calculator',
  template: `
    <h2>Power Boost Calculator</h2>
    <div>Normal power: <input [(ngModel)]="power"></div>
    <div>Boost factor: <input [(ngModel)]="factor"></div>
    <p>
      Super Hero Power: {{power | exponentialStrength: factor}}
    </p>
  `,
  pipes: [ExponentialStrengthPipe]
})
export class PowerBoostCalculator {
  power = 5;
  factor = 1;
}

power-boost-calculator-anim

七、管道和变化检测

Angular每次JavaScript事件(如:按键、移动鼠标、定时器触发、服务器响应)后会执行数据绑定值变化检测,这可能会带来性能问题,Angular会尽可能在适当的时候提高性能。

如果我们使用管道,Angular会选择使用更简单、更快速的变化检测算法。现在来看看有无管道时Angular是如何优化性能的。

1、无管道

示例是一个显示 heroes 数组列表,并在每一变动时都会重新更新显示,这是一种比较积极变化检测策略。

组件模板内容:

New hero:
  <input type="text" #box
          (keyup.enter)="addHero(box.value); box.value=''"
          placeholder="hero name">
  <button (click)="reset()">Reset</button>
  <div *ngFor="let hero of heroes">
    {{hero.name}}
  </div>

组件类提供 heroes 属性,将新 hero 推入数组中,以及可以重置数据。

export class FlyingHeroesComponent {
  heroes:any[] = [];
  canFly = true;
  constructor() { this.reset(); }

  addHero(name:string) {
    name = name.trim();
    if (!name) { return; }
    let hero = {name, canFly: this.canFly};
    this.heroes.push(hero)
  }

  reset() { this.heroes = HEROES.slice(); }
}

任何对 heroes 数组进行操作都会重新被刷新显示。

2、Flying Heroes 管道

让我们来添加一个 FlyingHeroesPipe 管道用于过滤出那只能飞的 hero。先在组件模板里使用自定义的管道。

<div *ngFor="let hero of (heroes | flyingHeroes)">
  {{hero.name}}
</div>

FlyingHeroesPipe 自定义管道如下:

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

import { Flyer } from './heroes';

@Pipe({ name: 'flyingHeroes' })
export class FlyingHeroesPipe implements PipeTransform {
  transform(allHeroes:Flyer[]) {
    return allHeroes.filter(hero => hero.canFly);
  }
}

当我们运行示例会发现很奇怪,添加的 hero 并不会显示。虽然没有得到我们想要的效果,但是Angular一切都是正常的,只不过使用了一个忽略变更列表的变化检测算法。

看看是如何新增一个 hero 的:

this.heroes.push(hero)

我们是将新 hero 对象推入到数组里面,数组的对象引并没有发生改变,它还是同一个数组,所以从Angular角度来说,相同数组、没有变化、不需要更新。

我们可以解决这个问题,就是使用 concat 将老数据和新数据连接成一些新的副本,并重新赋值给 heroes,这样数组的对象引用地址就变成新的,Angular也能检测到新的变化。

这里通过新增一个 Mutate Array 选择框来表示我们是采用 push (理解为:变改数组) 或 concat(理解为:替换数组) 来插入新 hero的。

flying-heroes-anim

通过替换数组这种方式比较变异的写法,而且很难控制,我们对数组的操作都要重新产生一个新副本,这肯定会让人崩溃。

因此,Angular提供另一种管道,用来解决检测变化时对对象引用依然有效的办法。

八、Pure和Impure管道

管道有两种类型:pureimpure,其中pure是默认类型,我们可以在管道元数据里指定 pure:false 表明管道是 impure 类型。

@Pipe({
  name: 'flyingHeroes',
  pure: false
})

现在我们来看看 pure 和 impure 两者的区别。

1、Pure管道

Angular只会检测到输入值变更才会执行pure管道。pure变更指的是原始输入值的变化(StringNumberBooleanSymbol)或对象引用的变化(DateArrayFunctionObject`)。

Angular会忽略对象内部的变化,一个对象引用检查要比一个深差异检查要快得多,这样Angular才能快速判断它是否可以跳过管道或屏幕更新。正因为有对象引用的受限,所以我们需要另一种管道的变化检测策略 impure 管道。

2、Impure管道

Angular每次组件变化检测周期时都会执行 impure 管道,每个按钮或鼠标移动时都会执行管道。所以实施一个 impure 管道要很注意了,因为管道执行不足够快,有可能会让用户体验变得很差。

3、impure 管道示例

现在我们把 FlyingHeroesPipe 用 impure 管道实现一个 FlyingHeroesImpurePipe 管道。

@Pipe({
  name: 'flyingHeroes',
  pure: false
})
export class FlyingHeroesImpurePipe extends FlyingHeroesPipe {}

唯一不同的是,只是在pipe元数据增加一个 pure:false在线示例

4、impure异步管道

Angular里有一个非常有趣的 AsyncPipe 管道,授收的是 PromiseObservable 输入值,并自己订阅它,最终返回产生的值。

它也有状态,管道维护 Observable 的订阅并保持从 Observable 传递值。

下面示例,演示 async 管道与一个 Observable 消息字符串的使用方法。

import { Component } from '@angular/core';
import { Observable } from 'rxjs/Rx';
// Initial view: "Message: "
// After 500ms: Message: You are my Hero!"
@Component({
  selector: 'hero-message',
  template: `
    <h2>Async Hero Message and AsyncPipe</h2>
    <p>Message: {{ message$ | async }}</p>
    <button (click)="resend()">Resend</button>`,
})
export class HeroAsyncMessageComponent {
  message$:Observable<string>;
  constructor() { this.resend(); }
  resend() {
    this.message$ = Observable.interval(500)
      .map(i => this.messages[i])
      .take(this.messages.length);
  }
  private messages = [
    'You are my hero!',
    'You are the best hero!',
    'Will you be my hero?'
  ];
}

异步管道保存样本是放在组件代码里,组件里面并没有订阅异步数据源。

5、impure 缓存管道

让我们再写一个 impure 管道,通过 HTTP 请求远程服务端数据。

如果请求的网址已经改变,管道会重新发起服务器请求;如果请求的是已经缓存的网址,则直接返回缓存的结果。

import { Pipe, PipeTransform } from '@angular/core';
import { Http }                from '@angular/http';
@Pipe({
  name: 'fetch',
  pure: false
})
export class FetchJsonPipe  implements PipeTransform{
  private fetched:any = null;
  private prevUrl = '';
  constructor(private _http: Http) { }
  transform(url: string): any {
    if (url !== this.prevUrl) {
      this.prevUrl = url;
      this.fetched = null;
      this._http.get(url)
        .map( result => result.json() )
        .subscribe( result => this.fetched = result );
    }
    return this.fetched;
  }
}

然后我们为了证明只调用一次请求,在模板里使用了两次管道。

template: `
  <h2>Heroes from JSON File</h2>
  <div *ngFor="let hero of ('heroes.json' | fetch) ">
    {{hero.name}}
  </div>
  <p>Heroes as JSON:
  {{'heroes.json' | fetch | json}}
  </p>
`,

尽管我们多次调用管道,但是通过浏览器开发者工具,确实只有一个请求。

hero-list

6、JsonPipe

Angular内建的 JsonPipe,将输入对象转换成JSON字符串输出,用来打印一次测试信息倒是很方便哈。

九、在类里使用管道

Angular2.x的管道其实就是1.x的过滤器,而管道有时候也需要在类里面调用,比如:DatePipe 是Angular内置管道,存放在 angular/common 库里面。

前面我们示例中所有的管道实际都是一个类,因此,如果我们想在某个组件类调用管道,可以先加载相应模块,然后按类实例调用 transform 方法即可。

import { DatePipe } from '@angular/common';

export class HeroBirthday {
  year = new DatePipe().transform(new Date(1988,3,15), 'y');
}

Angular2 NPM包

Angular应用和Angular自身依赖者很多第三方包,这些软件包的维护和安装都通过NPM来管理。Angualr建议利用快速入门的package.json做为基础。

{
  "dependencies": {
    "@angular/common": "2.0.0-rc.0",
    "@angular/compiler": "2.0.0-rc.0",
    "@angular/core": "2.0.0-rc.0",
    "@angular/http": "2.0.0-rc.0",
    "@angular/platform-browser": "2.0.0-rc.0",
    "@angular/platform-browser-dynamic": "2.0.0-rc.0",
    "@angular/router-deprecated": "2.0.0-rc.0",
    "@angular/upgrade": "2.0.0-rc.0",
    "systemjs": "0.19.27",
    "es6-shim": "^0.35.0",
    "reflect-metadata": "^0.1.3",
    "rxjs": "5.0.0-beta.6",
    "zone.js": "^0.6.12",
    "angular2-in-memory-web-api": "0.0.5",
    "bootstrap": "^3.3.6"
  },
  "devDependencies": {
    "concurrently": "^2.0.0",
    "lite-server": "^2.2.0",
    "typescript": "^1.8.10",
    "typings": "^0.8.1"
  }
}

npm有非常丰富的第三方包可以选择,我们可以增加和减少来满足应该需求。接下来解释这些包是什么意思、干什么用的。

一、dependencies和devDependencies节点

package.json 将依赖分成dependencies和devDependencies。前者是运行应用程序所需要包;后者是开发应用所需要包,可以在部署生产环境时除掉。

npm install my-application --production

二、依赖

package.jsondependencies 节点的依赖包分为三种类型:

  • 功能 功能包提供我们的应用框架和实用功能。
  • Polyfills Polyfills 插件消除JavaScript在各浏览器上的差异。
  • 其它 其它第三方库,比如:bootstrap

1、功能包

@angular/core – 应用程序所需要的关键运行库,包括所有元数据装饰器、ComponentDirective、依赖注入以及组件生命周期钩子。

@angular/common – Angular提供常用Service、管道和指令(表单)。

@angular/compiler – Angular模板编译器。

@angular/platform-browser – 有关DOM与浏览器相关的一切,尤其是帮助DOM渲染。包还包括引导应用启动方法,以及预编译模板方法。

@angular/platform-browser-dynamic – 动态编译?

**@angular/http ** – Angular Http客户端。

@angular/router – 组件路由。

@angular/upgrade – 用于升级1.x至2的实用工具集。

system.js – 兼容ES2015模块规范的动态模块加载器,也可以选择其他库代替,例如Webpack。SystemJS恰好是我们所有系列中的示例样本。

2、Polyfills 插件

Angular应用环境需要一些polyfills,这些polyfills列表都放在 dependencies 节点当中。

es6-shim – ES6引擎,用于解决低浏览器版本的一些ES6特性,不过一般只是在开发时会用它来做API转换,实现部署时这些东西应该都是移除的。

reflect-metadata – 一个依赖在Angular和TypeScript中间的元数据编译器。

rxjs – 一种利用 Observables 流的异步编程接口。

zone.js – 用于解决异步任务之间的持久性传递,2.x的事件绑定诸如 (click) 而无须像 1.x 中还要定义一个 ngClick 指令,这里面 zone 功不可没。

3、其它辅助库

bootstrap – 不用多话,最常用的HTML和CSS框架。

angular2-in-memory-web-api – 之前示例有出现过,用于模拟一个远程WEB API服务接口,用在测试上蛮方便的。

三、devDependencies

package.jsondevDependencies 节点列出的包是帮助我们开发应用,不需要被部署到生产环境中,

concurrently 一个同时运行多个 npm 命令的实用工具(支持os/x,Windows,Linus)

lite-server 一个轻型的静态文件服务器。

typescript TypeScript编译器。

typings TypeScript定义文件的管理器。

当然还有一些像测试和构建等工具也是放在这里。

四、总结

其实Angular2利用了在大量第三方库,好好的认知这些库对学习Angualr2非常有帮助。对于devDependencies节点,如何去构建一套便于团队开发的项目样本,就需要对各种测试、构建工具非常熟悉。

官网文章还有一小节是介绍 peerDependencies 节点,它是为了解决一些库的兼容性依赖问题,在我们目前接触的示例中从未体现过,但是在Angular源代码中有该节点。可在我写文章时,目前最新的源代码已经没有这个节点,我猜想可能是因为 npm3.x 的关系,因为npm3解决一个路径深度的问题。

Angualr2 生命周期钩子

Angular会管理一个组件的生命周期,包括组件的创建、渲染、子组件创建与渲染、当数据绑定属性变化时的校验、DOM移除之前毁销。

Angular提供组件生命周期钩子便于我们在某些关键点发生时添加操作。

一、组件生命周期钩子

指令和组件实例有个生命周期用于创建、更新和销毁,开发者可通过 angular2/core 库实现一个或多个这些生命周期钩子接口,每个接口都有一个单独的钩子方法,都是以接口名称加上 ng 前缀,比如:OnInit 接口有个钩子方法名为 ngOnInit,我们可以组件类实现它,比如:

import { OnInit } from 'angular2/core';

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

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

  protected _logIt(msg:string){
    this._logger.log(`#${nextId++} ${msg}`);
  }
}

不是所有指令或组件都能实现所有钩子方法,有的只能用在组件上。

Angular只能调用已经在指令/组件定义过钩子方法。

可选接口

从技术角度而言,对于JavaScript和TypeScript开发者来说接口是可选的,JavaScript语言并没有接口概念,Angular在运行过程中也没TypeScript接口说法,因为当TypeScript编译以后接口就消失了。

进一步来讲,其实对于Angular而言用不用钩子接口并不关心的,只要组件类存在钩子的命令规范,比如组件类有 onOnInit(),Angular自然就会调用。

尽管如此,大家还是按照接口的写法吧。

那么,刚才说过钩子接口只对指令或组件有效,而部分只对组件有效。

1、指令和组件都有效的钩子

钩子 目的
ngOnInit 发生于构造函数之后,用于初始化指令/组件,主要用于数据绑定的输入属性处理。
ngOnChanges 在数据绑定属性检查后被调用,在view和content children检查后其中之一发生改变之前。换个简单的说法,数据绑定属性的值发生变化前。
ngDoCheck 发生在每次变化检测时,主要用于在进行自定义的检查前扩展变化的检查或强制忽略检查。
ngOnDestroy 在Angular销毁组件/指令之前的清理工作,取消订阅监控对象和事件处理函数,以避免内存泄漏,仅会被调用一次。

2、组件有效的钩子

钩子 目的
ngAfterContentInit 在Angular将外部内容放到视图内之后。
ngAfterContentChecked 在Angular检测放到视图内的外部内容的绑定后。
ngAfterViewInit 在Angular创建了组件视图之后。
ngAfterViewChecked 在Angular检测了组件视图的绑定之后。

二、生命周期顺序

在Angular通过构造函数创建组件/指令后,就会按以下顺序调用这些生命周期钩子方法:

钩子 时间点
ngOnChanges 在 `ngOnInit` 之前,当数据绑定输入属性的值发生变化时。
ngOnInit 在第一次 `ngOnChanges` 之后。
ngDoCheck 每次Angular变化检测时。
ngAfterContentInit 在外部内容放到组件内之后。
ngAfterContentChecked 在放到组件内的外部内容每次检查之后。
ngAfterViewInit 在初始化组件视图和子视图之后。
ngAfterViewChecked 在组件视图和子视图检查之后。
ngOnDestroy 在Angular销毁组件/指令之前。

三、其他生命周期钩子

除上面列出的钩子外,Angular子系统也可能有自个生命周期钩子。例如,路由器,也有自己的钩子,使我们能够在路由导航的特定时刻做一些操作。

ngOnInitrouterOnActivate 都是属于钩子,为了避免发生冲突,二者前缀规范不同。当然第三方库也可能是会定义一个钩子,总之呀多注意点命令方面的问题。

四、生命周期示例简要说明

下面会通过一系列的示例,演示生命周期钩子的使用方法。所有示例都以 AppComponent 为父组件,每个子组件都会特定对某个生命周期的钩子方法进行演示。

所有示例的在线DEMO,最好是和在线DEMO一起阅读,因为后面并不会完整的去展示代码。以下是每个子组件的简要说明:

组件 说明
Peek-a-boo 演示每个生命周期钩子,每个钩子被调用时都会在屏幕上打印日志。
Spy 指令生命周期钩子,利用 `SpyDirective` 指令的 `ngOnInit` 和 `ngOnDestroy` 钩子,演示一个指令是如何被创建或销毁的。
OnChanges 看看Angular调用 `ngOnChanges` 钩子在 `changes` 对象每一次变化时的调用情况。
DoCheck 实现一个自定义变化检测 `ngDoCheck` 钩子方法,看看Angular如果调用钩子及它更改会日志的变化。
AfterView 演示Angular如何显示一个视图,包括 `ngAfterViewInit` 和 `ngAfterViewChecked` 钩子。
AfterContent 展示如何将项目外部内容放进组件内,且如何把项目内容和组件子视图区分开来,演示的钩子包括 `ngAfterContentInit` 和 `ngAfterContentChecked`。
Counter 演示一个自定义钩子示例。

五、Peek-a-boo

利用 PeekABooComponent 演示所有钩子。在实际项目中,我们很少需要实现所有钩子接口,这个示例为的是看看Angular调用钩子的执行顺序。这里有个快照,点击创建按钮再一次点击销毁的钩子调用过程。

peek-a-boo

根据日志消息的显示,钩子调用的顺序:OnChangesOnInitDoCheck(3次)、AfterContentInitAfterContentChecked(3次)、AfterViewInitAfterViewChecked(3次)、OnDestroy

如果我们点击 Update Hero 按钮,会看到另一个 OnChanges 和两对 DoCheckAfterContentCheckedAfterViewChecked

六、Spying OnInit and OnDestroy

现在来揭开这两个钩子到底是如何初始化和销毁。

创建一个 spy 指令,并实现 ngOnInitngOnDestroy 钩子,并注入 LoggerService,当钩子被调用时能在屏幕上打印日志。

// Spy on any element to which it is applied.
// Usage: <div mySpy>...</div>
@Directive({selector: '[mySpy]'})
export class Spy implements OnInit, OnDestroy {

  constructor(private _logger: LoggerService) { }

  ngOnInit()    { this._logIt(`onInit`); }

  ngOnDestroy() { this._logIt(`onDestroy`); }

  private _logIt(msg: string) {
    this._logger.log(`Spy #${nextId++} ${msg}`);
  }
}

我们可以把指令用到任何本地模板或组件元素当中,这样当这些元素初始化时或销毁时指令也会跟着初始化或销毁。

`<div *ngFor="let hero of heroes"  mySpy  class="heroes">
   {{hero}}
 </div>`

这里 spy 指令(mySpy)依附在 <div> 元素,意味者元素的初始化或销毁也会发生在指令身上。当添加一个新 hero 时,会多出一个初始化指令的动作,如果点击 reset,所有的指令也会跟着元素一同被销毁。

spy-directive

ngOnInitngOnDestroy 在实际项目应用中发挥很重要的作用,接着看看为什么我们需要他们?

1、ngOnInit

使用 ngOnInit 有两个主要理由:

  • 在构造函数之后执行复杂初始化。
  • 在设置组件之后设置输入属性。

ngOnInit 经常用来获取数据,比如之前我们介绍的 HTTP 客户端的文章。

为什么我们不把获取数据放在构造函数里呢?之前HTTP文章也谈过,试想如果在构造函数里调用一个远程服务时有异常,那实例到底应该是成功还是失败呢?一个类的构造函数应该是简单且安全的。

当一个组件必须很快的启用并开始工作时,就应该让Angular去调用 ngOnInit 方法来启用这些复杂的业务逻辑。

记住一个指令数据绑定输入属性是必须等构造函数完成以后才会开始设置,如果需要基于这些属性来初始化指令,也必须在 ngOnInit 里运行它。

2、ngOnDestroy

ngOnDestroy 主要用来清除一些逻辑,以及那些无法被GC自动回收释放资源,比如:退订observable、DOM事件、停止interval计时器等。

七、OnChanges

下面是一个监测 OnChanges 钩子的示例,每当输入属性发生变化时,Angular会调用 ngOnChanges 方法。

OnChangesComponent 组件

ngOnChanges(changes: {[propertyName: string]: SimpleChange}) {
  for (let propName in changes) {
    let prop = changes[propName];
    let cur  = JSON.stringify(prop.currentValue);
    let prev = JSON.stringify(prop.previousValue);
    this.changeLog.push(`${propName}: currentValue = ${cur}, previousValue = ${prev}`);
  }
}

ngOnChanges 钩子接收一个类型为 SimpleChange 的映射对象,包括新值和旧值。

组件里还指定两个输入属性:

@Input() hero: Hero;
@Input() power: string;

父组件绑定示例:

<on-changes [hero]="hero" [power]="power"></on-changes>

最终效果

on-changes-anim

我们看到 power 的每一次输入变化都会打印出日志,而为什么 hero 没有什么任何检测日志呢?

这是因为Angular只会对那些值会发生变化的输入属性调用 ngOnChanages,而 hero 属性是一个引用 hero 对象,Angular无法知道 hero 的 name 属性是不是已经发生变化了。所以Angular的 onChanage 钩子对于引用对象类型是无效的。

八、DoCheck

我们可以使用 DoCheck 钩子来改变变化检测结果。DoCheck 示例是在 OnChanages 示例代码基础上扩展并实现 DoCheck:

ngDoCheck() {

  if (this.hero.name !== this.oldHeroName) {
    this.changeDetected = true;
    this.changeLog.push(`DoCheck: Hero name changed to "${this.hero.name}" from "${this.oldHeroName}"`);
    this.oldHeroName = this.hero.name;
  }

  if (this.power !== this.oldPower) {
    this.changeDetected = true;
    this.changeLog.push(`DoCheck: Power changed to "${this.power}" from "${this.oldPower}"`);
    this.oldPower = this.power;
  }

  if (this.changeDetected) {
      this.noChangeCount = 0;
  } else {
      // log that hook was called when there was no relevant change.
      let count = this.noChangeCount += 1;
      let noChangeMsg = `DoCheck called ${count}x when no change to hero or power`;
      if (count === 1) {
        // add new "no change" message
        this.changeLog.push(noChangeMsg);
      } else {
        // update last "no change" message
        this.changeLog[this.changeLog.length - 1] = noChangeMsg;
      }
  }

  this.changeDetected = false;
}

我们手动检测并比较新值与旧值,当发生变化时打印日志。

do-check-anim

现在我们可以检测 hero.name 的变化了,但一定要小心。ngDoCheck 钩子调用频率非常高,不管你有没有值发生变化,每个变化都会进行一次检测。

就拿上面的示例来说,哪怕你什么值也没有改变,离开焦点时也会进行一次检测,所以 ngDoCheck 最好不要做一些很复杂的事情,这样会影响用户体验。

九、AfterView

AfterView示例探讨 AfterViewInitAfterViewChecked,Angular会在创建组件的子视图(视图可以理解为指令或组件)后调用它们。

子视图是一个文本框里显示 hero 的 name。

@Component({
  selector: 'my-child',
  template: '<input [(ngModel)]="hero">'
})
export class ChildViewComponent {
  hero = 'Magneta';
}

AfterViewComponent 组件的模板内显示子视图:

template: `
  <div>-- child view begins --</div>
    <my-child></my-child>
  <div>-- child view ends --</div>`

为了能让钩子能够检测值变化,还需要使用 @ViewChild 查询类型为 ChildViewComponent 的组件,并绑定到 viewChild 属性。

export class AfterViewComponent implements  AfterViewChecked, AfterViewInit {
  private _prevHero = '';

  // Query for a VIEW child of type `ChildViewComponent`
  @ViewChild(ChildViewComponent) viewChild: ChildViewComponent;

  ngAfterViewInit() {
    // viewChild is set after the view has been initialized
    this._logIt('AfterViewInit');
    this._doSomething();
  }

  ngAfterViewChecked() {
    // viewChild is updated after the view has been checked
    if (this._prevHero === this.viewChild.hero) {
      this._logIt('AfterViewChecked (no change)');
    } else {
      this._prevHero = this.viewChild.hero;
      this._logIt('AfterViewChecked');
      this._doSomething();
    }
  }
  // ...
}

遵守单向数据流规则

_doSomething 方法用来检测当 name 超过10个字符时显示 “That’s a long name”。

// This surrogate for real business logic sets the `comment`
private _doSomething() {
  let c = this.viewChild.hero.length > 10 ? "That's a long name" : '';
  if (c !== this.comment) {
    // Wait a tick because the component's view has already been checked
    setTimeout(() => this.comment = c, 0);
  }
}

为什么 _doSomething 方法需要使用 setTimeout 来延迟更新 comment 呢?

我们必须坚持Angular单向数据流规则,换句话说,如果我们立即更新组件数据绑定 comment 属性,而此时正在检测变化Angular会抛出异常,这一点很容易发生因为当 ngAfterViewChecked 触发前会先进行 DoCheck 这之前我们也讨论过,所以这里才用 setTimeout 推迟浏览器JavaScript循环周期。

after-view-anim

十、AfterContent

AfterContent示例探讨 AfterContentInitAfterContentChecked,Angular会在项目外部内容被放进组件内后调用它们。

1、内容投影

内容投影是将外部组件的HTML内容插入到组件模板的指定位置;跟Angular1.x的transclusion类似。

本示例同 AfterView 的示例一样,只不过这一次,我们父组件模板多套一层 AfterContentComponent 组件:

`<after-content>
   <my-child></my-child>
 </after-content>`

注意, <after-content> 标签包裹着 <my-child> 标签,除非我们打算把内容也插入到组件里,否则永远不要把其他元素放到这中间。

现在看看组件模板:

template: `
  <div>-- projected content begins --</div>
    <ng-content></ng-content>
  <div>-- projected content ends --</div>`

<ngContent> 标签是外部内容的占位符,告诉Angular要在哪插入内容。

2、AfterContent 钩子

AfterContent钩子类似于AfterView钩子,关键的区别在于如何查找子组件。

  • AfterView钩子涉及 ViewChildren,子组件通过组件模板插入。
  • AfterContent钩子涉及 ContentChildren,子组件通过映射内容插入。

接下来 AfterContent 钩子只能通过属性装饰 @ContentChild 查询组件。

export class AfterContentComponent implements AfterContentChecked, AfterContentInit {
  private prevHero = '';
  comment = '';

  // Query for a CONTENT child of type `ChildComponent`
  @ContentChild(ChildComponent) contentChild: ChildComponent;

  ngAfterContentInit() {
    // viewChild is set after the view has been initialized
    this.logIt('AfterContentInit');
    this.doSomething();
  }

  ngAfterContentChecked() {
    // viewChild is updated after the view has been checked
    if (this.prevHero === this.contentChild.hero) {
      this.logIt('AfterContentChecked (no change)');
    } else {
      this.prevHero = this.contentChild.hero;
      this.logIt('AfterContentChecked');
      this.doSomething();
    }
  }
  // ...
}

[开发指南系列] Angular2 HTTP客户端

HTTP主要是浏览器与服务端的通信协议,流行浏览器支持XHRJSONP两种基于HTTP API,少数浏览器还需要 Fetch

Angular2 HTTP客户端封装了 XHRJSONP API。

一、示例

我们使用Angular Http 客户端访问XHR。

http-toh

依然以前面一直用的 Hero 的示例,只不过这回是从服务端获取、保存数据。一个父组件 TohComponent 和子组件 HeroListComponent,这和我们之前看到样本示例都一样,只不过改装成从服务端交互。

app/toh/toh.component.ts

import { Component }         from 'angular2/core';
import { HTTP_PROVIDERS }    from 'angular2/http';
import { HeroListComponent } from './hero-list.component';
import { HeroService }       from './hero.service';
@Component({
  selector: 'my-toh',
  template: `
  <h1>Tour of Heroes</h1>
  <hero-list></hero-list>
  `,
  directives: [HeroListComponent],
  providers:  [
    HTTP_PROVIDERS,
    HeroService,
  ]
})
export class TohComponent { }

像往常一样,我们要先导入所需要的模块。这一次要使用Angular HTTP库的 HTTP_PROVIDERS(如果你依然是用官网示例做为项目种子,要先引用 http.dev.js);同时还导入 HeroService 这也是我们之前一直在用的用于获取 hero 数据的类。同时还需要注入DI中。

另外 HTTP_PROVIDERS 提供一些基本的HTTP请求操作,比如:HttpXHRBackendRequestOptionsResponseOptions 等,所以最佳的方式,可以在Angular2启动时就注册注入:

import { HTTP_PROVIDERS } from 'angular2/http';
bootstrap(TohComponent, [HTTP_PROVIDERS]);

接者 HeroListComponent 组件模板内容:

app/toh/hero-list.component.html

<h3>Heroes:</h3>
<ul>
  <li *ngFor="let hero of heroes">
    {{ hero.name }}
  </li>
</ul>
New Hero:
<input #newHero />
<button (click)="addHero(newHero.value); newHero.value=''">
  Add Hero
</button>
<div class="error" *ngIf="errorMessage">{{errorMessage}}</div>

首先是一个利用 NgFor 循环指令显示列表。列表的下方是一文本框和Add Hero按钮,可以输入新名称并将它添加到数据库中;使用本地模板变量 newHero 访问值和按钮 (click) 事件绑定,当用户点击按钮时,调用 addHero 组件方法并清空文本框的内容。最下方是一个用于提示错误消息。

app/toh/hero-list.component.ts

export class HeroListComponent implements OnInit {
  constructor (private _heroService: HeroService) {}
  errorMessage: string;
  heroes:Hero[];
  ngOnInit() { this.getHeroes(); }
  getHeroes() {
    this._heroService.getHeroes()
                     .subscribe(
                       heroes => this.heroes = heroes,
                       error =>  this.errorMessage = <any>error);
  }
  addHero (name: string) {
    if (!name) {return;}
    this._heroService.addHero(name)
                     .subscribe(
                       hero  => this.heroes.push(hero),
                       error =>  this.errorMessage = <any>error);
  }
}

在构造函数里注入 HeroService,它在父组件 TohComponent 已经被注册过,所以子组件是可以直接被注入。

注意这里并不与服务端有任何直接联系,这一切都委派 heroService 类来处理。

1、黄金法则

始终将数据访问委派给Service类

2、最佳实践

虽然在组件运行时要请求 heroes 列表,但是我们并没有在构造函数里调用Service的 get 方法。而是利用 ngOnInit 生命周期钩子,当实例化组件时Angular自动调用 ngOnInit 方法。

当其构建函数很简单或需要一些调用远程服务时,最好是使用生命周期钩子,这样更有利于测试和调试。为什么这么说呢?试想如果在构造函数里调用一个远程服务时有异常,那实例到底应该是成功还是失败呢?

组件类的 getaddHero 方法返回的 hero 数据是一个 Observable 类型。我们可以订阅它的请求和失败观察者。

现在组件基本上已经弄好了,现在重点要关注 HeroService 客户端与后端数据源的交互。

2、获取数据

系列文章里有很多 HeroService 示例,并且还模拟从服务端请求数据的过程,比如:

import {Injectable} from 'angular2/core';

import {Hero} from './hero';
import {HEROES} from './mock-heroes';

@Injectable()
export class HeroService {
  getHeroes() {
    return Promise.resolve(HEROES);
  }
}

当然,我们现在是要真正从服务器获取 heroes 数据,所以有个全新的 HeroService 类:

import {Injectable}     from 'angular2/core';
import {Http, Response} from 'angular2/http';
import {Hero}           from './hero';
import {Observable}     from 'rxjs/Observable';
@Injectable()
export class HeroService {
  constructor (private http: Http) {}
  private _heroesUrl = 'app/heroes';  // URL to web api
  getHeroes (): Observable<Hero[]> {
    return this.http.get(this._heroesUrl)
                    .map(this.extractData)
                    .catch(this.handleError);
  }
  private extractData(res: Response) {
    if (res.status < 200 || res.status >= 300) {
      throw new Error('Bad response status: ' + res.status);
    }
    let body = res.json();
    return body.data || { };
  }
  private handleError (error: any) {
    // In a real world app, we might send the error to remote logging infrastructure
    let errMsg = error.message || 'Server error';
    console.error(errMsg); // log to console instead
    return Observable.throw(errMsg);
  }
}

我们通过加载Angular Http 客户端服务并把它注入到 HeroService 构造函数中。

Http 并不是 angular2/core 的一部分,这是一个可选服务在 angular2/http 库里。此外,该库并不是核心Angular脚本文件里,所以还需要在 index.html 的引入。

<script src="node_modules/angular2/bundles/http.dev.js"></script>

仔细看看我们怎么调用 http.get

getHeroes (): Observable<Hero[]> {
  return this.http.get(this._heroesUrl)
                  .map(this.extractData)
                  .catch(this.handleError);
}

get 方法传递资源URL,它会访问服务端并返回 heroes。

返回的结果可能会很惊讶,因为我们会比较期待返回一个 promise,这样我们可以使用 then() 来获取数据,然后我们调用了 map() 方法,而非 promise。

事实上,http.get 方法返回的是一个HTTP响应 Observable 对象,由RxJS库提供,map() 也是RxJS的一个操作符。

3、RxJS库

RxJS是一个第三方库,由Angular赞助,实现异步观察模式

官网示例默认就有安装 RxJS,并且也在 index.html 里引入脚本,因为Observable模式被广泛在应用在Angular程序。其实早在 Angualr 作者在针对 1.x 被访谈时就已经说过Observable才是未来。

HTTP客户端就需要RxJS,我们必须采用额外的步骤开启RxJS Observables。

4、启用RxJS操作符

RxJS库非常大,当我们构建一个用于移动设备的应用程序时,应该只包括我们实际需要的功能。因此,Angular在 rxjs/Observable 模块中提供一简化版 Observable,但是缺乏几乎所有操作,包括 getHeroes 中的 map 方法。

我们需要哪些RxJS操作符,按需加载即可。但是现在我们是学习HTTP,不计较这些,所以全部加载吧。

// Add all operators to Observable
import 'rxjs/Rx';

5、处理响应对象

记得,我们的 getHeroes 方法中的 map() 方法,会将提取数据映射到 this.extractData 方法。

private extractData(res: Response) {
  if (res.status < 200 || res.status >= 300) {
    throw new Error('Bad response status: ' + res.status);
  }
  let body = res.json();
  return body.data || { };
}

response 对象并不是返回我们可以直接使用的数据,要想变成应用程序所需要的数据需要:

  • 检查不良响应
  • 解析响应数据

错误状态码

示例中的状态码200-300范围从应用角度来说是错误,但对于 http 角度来说并非错误,所以先判断状态码并抛出一个错误。而对于 404 - Not Found 像其他一样会有响应,我们发送一请求出去,然后返回一个响应,这对于 http 来说是错误的,所以会立即得到一个 observable 错误。

因为状态码200-300范围从应用角度来说是错误,所以我们拦截并抛出,移动 observable 链到错误路径。而 catch 操作来处理我们抛出的错误。

解析JSON

响应数据返回的是一个JSON字符串格式,我们必须调用 response.json() 转换成JavaScript对象。这并非Angular自行设计的,这是因为Angular HTTP Client使用的是ES2015规范中的 Fetch 方法返回响应对象,它定义了一个 json() 方法来解析响应内容成JavaScript对象。

JSON劫持

我们不应该让 json() 直接返回一个数组 hero,而应该是返回一个带有 data 属性的对象,比如:{data: [ hero, hero ] }

这是因对于老式浏览器可能会有JSON被劫持安全漏洞,这是另外一个话题了,大概就是老式浏览器可以重写 Array.prototype.constructor 从而达到拦截JSON的内容,安全问题不再过多叙述。

不要直接返回response对象

getHeroes() 能够直接返回 Observable<Response>,但这不是好主意,Server类的目的就是为了向用户隐藏与服务器交互细节,组件只需调用 HeroService 获取想要 hreoes,谁管你什么是Response。

记得处理错误

细心的读者可能已经发现我们使用 catch 操作符并结合 handleError 方法,但并未讨论实际的工作方式,每当我们处理 I/O 时,必须要做到处理错误的准备。

HeroService 捕获错误并返回一个对用户更友好、能理解的消息,handleError 方法是 catch 操作符委托的处理方法。

getHeroes (): Observable<Hero[]> {
  return this.http.get(this._heroesUrl)
                  .map(this.extractData)
                  .catch(this.handleError);
}
private handleError (error: any) {
  // In a real world app, we might send the error to remote logging infrastructure
  let errMsg = error.message || 'Server error';
  console.error(errMsg); // log to console instead
  return Observable.throw(errMsg);
}

二、HeroListComponent的订阅

回到 HeroListComponent,要调用 heroService.getHeroessubscribe 提供的第二个函数用来处理错误消息,并把错误消息赋值给 errorMessage 变量。

getHeroes() {
  this._heroService.getHeroes()
                   .subscribe(
                     heroes => this.heroes = heroes,
                     error =>  this.errorMessage = <any>error);
}

三、发送数据到服务端

到目前为止,我们使用Angular内置的 Http 服务从远程检索数据,现在该到保存数据到后端了。

我们将在 HeroListComponent 组件创建一个简单的 addHero 方法,只需要一个name(字符串)参数,结果返回一个新 hero 的 observable 类型。

addHero (name: string) : Observable<Hero>

要实现这一点,我们需要了解服务器应用程序接口的一些细节来创建 hero。我们数据服务遵循REST风格,一个端点可以通过请求POST或GET来创建或检索。使用POST请求时,只需要提供一个无 id 属性的 Hero 实体,并把数据放在body里,例如:

{ "name": "Windstorm" }

服务器将生成新 id 并返回包括生成id的整个实体对象,并用一个 data 属性包裹返回响应。

app/toh/hero.service.ts

import {Headers, RequestOptions} from 'angular2/http';

addHero(name: string): Observable<Hero> {

  let body = JSON.stringify({ name });
  let headers = new Headers({ 'Content-Type': 'application/json' });
  let options = new RequestOptions({ headers: headers });

  return this.http.post(this._heroesUrl, body, options)
    .map(this.extractData)
    .catch(this.handleError);
}

post 方法第二个参数需要一个 JSON 字符串,所以这里用 JSON.stringify 将对象转化成字符串。

1、Headers

POST请求需要指明一个 Content-Type 请求体,Headers是一个RequestOptions类型,并作为 post 方法的第三个参数。

app/toh/hero.service.ts

let headers = new Headers({ 'Content-Type': 'application/json' });
let options = new RequestOptions({ headers: headers });

return this.http.post(this._heroesUrl, body, options)

2、JSON结果

如同 getHeroes,我们使用 json() 从响应中提取数据,也是没有把 hero 包裹至 data 属性。

回到 HeroListComponent,我们看到了 _heroService.addHero 方法返回一个可被订阅的观察对象,当数据返回时,把新对象推到数组当中。

app/toh/hero-list.component.ts

addHero (name: string) {
  if (!name) {return;}
  this._heroService.addHero(name)
                   .subscribe(
                     hero  => this.heroes.push(hero),
                     error =>  this.errorMessage = <any>error);
}

3、~~回到Promise~~

虽然Angular http 客户端API返回的是 Observable<Response>,如果愿意,可以把它变成一个 Promise。

使用Promise重写 HeroService

getHeroes (): Promise<Hero[]> {
  return this.http.get(this._heroesUrl)
                  .toPromise()
                  .then(this.extractData)
                  .catch(this.handleError);
}
addHero (name: string): Promise<Hero> {
  let body = JSON.stringify({ name });
  let headers = new Headers({ 'Content-Type': 'application/json' });
  let options = new RequestOptions({ headers: headers });
  return this.http.post(this._heroesUrl, body, options)
             .toPromise()
             .then(this.extractData)
             .catch(this.handleError);
}
private extractData(res: Response) {
  if (res.status < 200 || res.status >= 300) {
    throw new Error('Bad response status: ' + res.status);
  }
  let body = res.json();
  return body.data || { };
}
private handleError (error: any) {
  // In a real world app, we might send the error to remote logging infrastructure
  let errMsg = error.message || 'Server error';
  console.error(errMsg); // log to console instead
  return Promise.reject(errMsg);
}

把 Observable 转换成 Promise 最简单就是调用 toPromise(success, fail),把observable的 map 回调移到第一个成功参数,catch 回调移到第二个错误参数就行了。或者也可以用 then.catch 模式。

errorHandler 里返回一个失败的promise而非Observable。

然后调整组件使用 Promise

getHeroes() {
  this._heroService.getHeroes()
                   .then(
                     heroes => this.heroes = heroes,
                     error =>  this.errorMessage = <any>error);
}
addHero (name: string) {
  if (!name) {return;}
  this._heroService.addHero(name)
                   .then(
                     hero  => this.heroes.push(hero),
                     error =>  this.errorMessage = <any>error);
}

最明显的区别是,把 subscribe 变成 then

从代码角度虽然只是变换了一个单词,但是还是返回的结果有很大的区别。

基于promise的 then 返回的另一个promise,我们可以继续接着链式调用多个 thencatch,每一次都会返回新prromise。

subscribe 方法返回的是一个 Subscription,这对于observable来讲已经是终结,我们不能够再调用 mapsubscribe

四、JSONP

我们只是学会了如何使用内置的 Http 服务创建一个 XMLHttpRequests,这是服务器通信中最常用的方法,但并非适用所有情况。

出于安全考虑,网页浏览器有一种叫同源策略机制,即无法访问其它域的资源。而流行浏览器允许 XHR 利用CORS协议请求跨域资源共享,这除了浏览器支持外还需要服务端允许。但一些老式浏览器并不支持或服务端因为某些原因无法设置CORS,那么我们需要用 JSONP 来解决只读跨域问题。

1、维基百科搜索

维基百科提供了一个 JSONP 搜索API,让我们弄个简单的维基百科搜索。

wiki-1

Angular的 Jsonp 服务其实是扩展了 Http 服务,并限制只允许 GET 请求,所有其它HTTP方法都会直接抛错,因为JSONP只允许只读请求。

一如既往,我们把所有交互细节都由Service来完成:

app/wiki/wikipedia.service.ts

import {Injectable} from 'angular2/core';
import {Jsonp, URLSearchParams} from 'angular2/http';
@Injectable()
export class WikipediaService {
  constructor(private jsonp: Jsonp) {}
  search (term: string) {
    let wikiUrl = 'http://en.wikipedia.org/w/api.php';
    var params = new URLSearchParams();
    params.set('search', term); // the user's search value
    params.set('action', 'opensearch');
    params.set('format', 'json');
    params.set('callback', 'JSONP_CALLBACK');
    // TODO: Add error handling
    return this.jsonp
               .get(wikiUrl, { search: params })
               .map(request => <string[]> request.json()[1]);
  }
}

构造函数注入 jsonp 服务,并且在调用 WikipediaService 所在的组件里注册 JSONP_PROVIDERS 依赖。

2、搜索参数

维基开放搜索API提供四个查询参数(键值对),分别:searchactionformatcallback;其中 search 为于查询关键词,另外三个都是固定值。

如果我们想查询有关 “Angular” 文章,我们可构造这样一个查询字符串,并调用 jsonp

let queryString =
  `?search=${term}&action=opensearch&format=json&callback=JSONP_CALLBACK`

return this.jsonp
           .get(wikiUrl + queryString)
           .map(request => <string[]> request.json()[1]);

如果参数很多时,也可以使用Angular URLSearchParams 辅助类。

var params = new URLSearchParams();
params.set('search', term); // the user's search value
params.set('action', 'opensearch');
params.set('format', 'json');
params.set('callback', 'JSONP_CALLBACK');

这次调用 jsonp,它具有两个参数,wikiUrl 和 一个可选对象做为查询参数。

app/wiki/wikipedia.service.ts

// TODO: Add error handling
return this.jsonp
           .get(wikiUrl, { search: params })
           .map(request => <string[]> request.json()[1]);

3、WikiComponent组件

现在,已经有一个可以查询维基百科服务接口,我们转向使用用户输入关键词和显示搜索结果的组件。

import {Component}        from 'angular2/core';
import {JSONP_PROVIDERS}  from 'angular2/http';
import {Observable}       from 'rxjs/Observable';
import {WikipediaService} from './wikipedia.service';
@Component({
  selector: 'my-wiki',
  template: `
    <h1>Wikipedia Demo</h1>
    <p><i>Fetches after each keystroke</i></p>
    <input #term (keyup)="search(term.value)"/>
    <ul>
      <li *ngFor="let item of items | async">{{item}}</li>
    </ul>
  `,
  providers:[JSONP_PROVIDERS, WikipediaService]
})
export class WikiComponent {
  constructor (private _wikipediaService: WikipediaService) {}
  items: Observable<string[]>;
  search (term: string) {
    this.items = this._wikipediaService.search(term);
  }
}

组件元数据 providers 数组指定了 JSONP_PROVIDERS 用于支持 Jsonp 服务,之前就说过 WikipediaService 里并没有任何注册依赖。

组件提供一个 <input> 搜索框,收集搜索关键词,同时绑定 keyup 事件,每于输入都会调用 search(term) 方法。search(term) 方法委托 WikipediaService 返回一个Observable字符串列表,而不是像我们之前在 HeroListComponent 组件里看到一样返回一个可被订阅的Observable,并在模板里通过 ngFor 异步管道处理订阅。

五、优化

维基百科搜索对服务器的请求太频繁了,这并不是我们最想要。

1、等待用户停止录入

我们绑定的是 keyup,意味者每一次录入都会发送请求,应用程序应该在用户停止录入时才发送请求:

wiki-2

2、搜索词变化时才发送请求

假设用户在搜索框输入 “Angular”,并暂停一段时间后,应用程序发送请求。然后用户删去三个字母 “lar” 又立即重新录入回去,这时搜索词依然是 “Angular”,应用程序不应该发送请求。

3、处理过期响应

用户录入 angular、停顿、清除搜索框、重新录入 http,应用程序发起 angular 和 http 两次搜索请求。但是我们无法知道哪一次会优先得到响应,有可能是 http 请求优先到达,这时后到达的 angular 覆盖了 http 列表。

当有多个请求时,响应也应该是按顺序响应,像上面如果 angular 是后到达则应该抛弃响应处理。

六、更多Observables玩法

我们可以通过几个漂亮的 observable 操作符来解决这些问题和优化我们的应用程序用户体验,重新更新 WikipediaService

import {Component}        from 'angular2/core';
import {JSONP_PROVIDERS}  from 'angular2/http';
import {Observable}       from 'rxjs/Observable';
import {Subject}          from 'rxjs/Subject';
import {WikipediaService} from './wikipedia.service';
@Component({
  selector: 'my-wiki-smart',
  template: `
    <h1>Smarter Wikipedia Demo</h1>
    <p><i>Fetches when typing stops</i></p>
    <input #term (keyup)="search(term.value)"/>
    <ul>
      <li *ngFor="let item of items | async">{{item}}</li>
    </ul>
  `,
  providers:[JSONP_PROVIDERS, WikipediaService]
})
export class WikiSmartComponent {
  constructor (private _wikipediaService: WikipediaService) { }
  private _searchTermStream = new Subject<string>();
  search(term:string) { this._searchTermStream.next(term); }
  items:Observable<string[]> = this._searchTermStream
    .debounceTime(300)
    .distinctUntilChanged()
    .switchMap((term:string) => this._wikipediaService.search(term));
}

这里只是更新组件类,元数据和模板都没有任何改动。

1、创建关键词流

搜索框绑定 keyup 事件,所以每次按键都会调用组件 search 方法。

我们使用 Subject 把这些事件变成一个 observable 关键词流,所以需要先加载 RxJS 模块:

import {Subject}          from 'rxjs/Subject';

每个关键词都是一个字符串,所以定义一个名为 _searchTermStream 类型为 Subject<string> 私有变量,每次调用 search 方法都会把搜索框值通过 next 方法添加到流当中。

private _searchTermStream = new Subject<string>();

search(term:string) { this._searchTermStream.next(term); }

2、监听关键词

之前我们直接通过Service类请求数据,然后在模块中显示。现在我们改成监听流,并在访问Service请求数据前做一些操作。

items:Observable<string[]> = this._searchTermStream
  .debounceTime(300)
  .distinctUntilChanged()
  .switchMap((term:string) => this._wikipediaService.search(term));
  • debounceTime 等待用户输入至少300毫秒时才发送请求。
  • distinctUntilChanged 只有变更搜索值才会发送请求。
  • switchMap 把Observable产生的结果转换成多个Observable,然后把它扁平化成一个Observable,然依次按顺序把结果给订阅者,虽然会按顺序请求,但是如果请求响应的最新结果早于旧请求结果时,会自动舍弃旧结果的所有操作。

七、总结

Http Client 是一个非常核心库,但是光只是一个HTTP请求库并没有什么特殊的;重点是配合 RxJS,让我们在编写异步操作时使用同步的代码风格,我们也可以叫:响应式编程。

© 2017 卡片机色彩 沪ICP备13032872号-3

Theme by cipchk

to top