管道

每个应用开始的时候差不多都是一些简单任务:获取数据、转换它们,然后把它们显示给用户。 获取数据可能简单到创建一个局部变量就行,也可能复杂到从WebSocket中获取数据流。

一旦取到数据,我们可以把它们原始值的toString结果直接推入视图中。 但这种做法很少能具备良好的用户体验。 比如,几乎每个人都更喜欢简单的日期格式,例如1988-04-15,而不是服务端传过来的原始字符串格式 —— Fri Apr 15 1988 00:00:00 GMT-0700 (Pacific Daylight Time)

显然,有些值最好显示成用户友好的格式。我们很快就会发现,在很多不同的应用中,都在重复做出某些相同的变换。 我们几乎会把它们看做某种CSS样式,事实上,我们也确实更喜欢在HTML模板中应用它们 —— 就像CSS样式一样。

通过引入Angular管道,我们可以把这种简单的“显示-值”转换器声明在HTML中。

试试在线例子 / 可下载的例子

使用管道

管道把数据作为输入,然后转换它,给出期望的输出。 我们将把组件的birthday属性转换成对人类更友好的日期格式,来说明这一点:

src/app/hero-birthday1.component.ts

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

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

重点看下组件的模板。

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

在这个插值表达式中,我们让组件的birthday值通过管道操作符( | )流动到 右侧的Date管道函数中。所有管道都会用这种方式工作。

DateCurrency管道需要ECMAScript国际化(I18n)API,但Safari和其它老式浏览器不支持它,该问题可以用垫片(Polyfill)解决。

<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=Intl.~locale.en"></script>

内置的管道

Angular内置了一些管道,比如DatePipeUpperCasePipeLowerCasePipeCurrencyPipePercentPipe。 它们全都可以直接用在任何模板中。

要学习更多内置管道的知识,参见API参考手册,并用“pipe”为关键词对结果进行过滤。

Angular没有FilterPipeOrderByPipe管道,原因在后面的附录中有解释。

对管道进行参数化

管道可能接受任何数量的可选参数来对它的输出进行微调。 我们可以在管道名后面添加一个冒号( : )再跟一个参数值,来为管道添加参数(比如currency:'EUR')。 如果我们的管道可以接受多个参数,那么就用冒号来分隔这些参数值(比如slice:1:5)。

我们将通过修改生日模板来给这个日期管道提供一个格式化参数。 当格式化完该英雄的4月15日生日之后,它应该被渲染成04/15/88

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

参数值可以是任何有效的模板表达式(参见模板语法中的模板表达式部分),比如字符串字面量或组件的属性。 换句话说,借助属性绑定,我们也可以像用绑定来控制生日的值一样,控制生日的显示格式。

我们来写第二个组件,它把管道的格式参数绑定到该组件的format属性。这里是新组件的模板:

src/app/hero-birthday2.component.ts (template)

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

我们还能在模板中添加一个按钮,并把它的点击事件绑定到组件的toggleFormat()方法。 此方法会在短日期格式('shortDate')和长日期格式('fullDate')之间切换组件的format属性。

src/app/hero-birthday2.component.ts (class)

export class HeroBirthday2Component {
  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; }
}

当我们点击按钮的时候,显示的日志会在“04/15/1988”和“Friday, April 15, 1988”之间切换。

Date Format Toggle

要了解更多DatePipes的格式选项,请参阅API文档

链式管道

我们可以把管道链在一起,以组合出一些潜在的有用功能。 下面这个例子中,我们把birthday链到DatePipe管道,然后又链到UpperCasePipe,这样我们就可以把生日显示成大写形式了。 比如下面的代码就会把生日显示成APR 15, 1988

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

下面这个显示FRIDAY, APRIL 15, 1988的例子用同样的方式链接了这两个管道,而且同时还给date管道传进去一个参数。

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

自定义管道

我们还可以写自己的自定义管道。 下面就是一个名叫ExponentialStrengthPipe的管道,它可以放大英雄的能力:

src/app/exponential-strength.pipe.ts

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方法是管道的基本要素。 PipeTransform接口中定义了它,并用它指导各种工具和编译器。 理论上说,它是可选的。Angular不会管它,而是直接查找并执行transform方法。

现在,我们需要一个组件来演示这个管道。

src/app/power-booster.component.ts

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

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

要注意的有两点:

别忘了`declarations`数组

我们必须手动注册自定义管道。如果忘了,Angular就会报告一个错误。 在前一个例子中我们没有把DatePipe列进去,这是因为Angular所有的内置管道都已经预注册过了。

如果我们试一下这个在线例子 / 可下载的例子,就可以通过修改值和模板中的可选部分来体会其行为。

能力倍增计算器

仅仅升级模板来测试这个自定义管道其实没多大意思。 我们干脆把这个例子升级为“能力倍增计算器”,它可以把该管道和使用ngModel的双向数据绑定组合起来。

src/app/power-boost-calculator.component.ts

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'power-boost-calculator',
  4. template: `
  5. <h2>Power Boost Calculator</h2>
  6. <div>Normal power: <input [(ngModel)]="power"></div>
  7. <div>Boost factor: <input [(ngModel)]="factor"></div>
  8. <p>
  9. Super Hero Power: {{power | exponentialStrength: factor}}
  10. </p>
  11. `
  12. })
  13. export class PowerBoostCalculatorComponent {
  14. power = 5;
  15. factor = 1;
  16. }
Power Boost Calculator

管道与变更检测

Angular通过变更检测过程来查找绑定值的更改,并在每一次JavaScript事件之后运行:每次按键、鼠标移动、定时器以及服务器的响应。 这可能会让变更检测显得很昂贵,但是Angular会尽可能降低变更检测的成本。

当我们使用管道时,Angular选用了一种简单、快速的变更检测算法。

无管道

我们下一个例子中的组件使用默认的、激进(昂贵)的变更检测策略来检测和更新heroes数组中的每个英雄。下面是它的模板:

src/app/flying-heroes.component.html (v1)

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>

和模板相伴的组件类可以提供英雄数组,能把新的英雄添加到数组中,还能重置英雄数组。

src/app/flying-heroes.component.ts (v1)

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(); }
}

我们可以添加新的英雄,加完之后,Angular就会更新显示。 reset按钮会把heroes替换成一个由原来的英雄组成的新数组,重置完之后,Angular就会更新显示。 如果我们提供了删除或修改英雄的能力,Angular也会检测到那些更改,并更新显示。

“会飞的英雄”管道

我们来往*ngFor重复器中添加一个FlyingHeroesPipe管道,这个管道能过滤出所有会飞的英雄。

src/app/flying-heroes.component.html (flyers)

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

下面是FlyingHeroesPipe的实现,它遵循了我们以前见过的那些写自定义管道的模式。

src/app/flying-heroes.pipe.ts

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);
  }
}

当运行在线例子 / 可下载的例子时,我们看到一种奇怪的行为。添加的每个英雄都是会飞行的英雄,但是没有一个被显示出来。

虽然我们没有得到期望的行为,但Angular也没有出错。 这里只是用了另一种变更检测算法 —— 它会忽略对列表及其子项所做的任何更改。

来看看我们是如何添加新英雄的:

this.heroes.push(hero);

当我们往heroes数组中添加一个新的英雄时,这个数组的引用并没有改变。它还是那个数组。而引用却是Angular所关心的一切。 从Angular的角度来看,这是同一个数组,没有变化,也就不需要更新显示

我们可以修复它。让我们创建一个新数组,把这个英雄追加进去,并把它赋给heroes。 这次,Angular检测到数组的引用变化了。它执行了这个管道,并使用这个新数组更新显示,这次它就包括新的飞行英雄了。

如果我们修改了这个数组,没有管道被执行,也没有显示被更新。 如果我们替换了这个数组,管道就会被执行,显示也更新了。 这个飞行英雄的例子用检查框和其它显示内容扩展了原有代码,来帮我们体验这些效果。

Flying Heroes

直接替换这个数组是通知Angular更新显示的一种高效方式。 我们该什么时候替换这个数组呢?当数据变化的时候。 在这个玩具级例子中,这是一个简单的规则,因为这里修改数据的唯一途径就是添加新英雄。

更多情况下,我们不知道什么时候数据变化了,尤其是在那些有很多种途径改动数据的程序中 —— 可能在程序中很远的地方。 组件就是一个通常无法知道那些改动的例子。此外,它会导致削足适履 —— 扭曲我们的组件设计来适应管道。 我们要尽可能保持组件类独立于HTML。组件不应该关心管道的存在。

为了过滤会飞的英雄,我们要使用非纯(impure)管道

纯(pure)管道与非纯(impure)管道

有两类管道:的与非纯的。 默认情况下,管道都是纯的。我们以前见到的每个管道都是纯的。 通过把它的pure标志设置为false,我们可以制作一个非纯管道。我们可以像这样让FlyingHeroesPipe变成非纯的:

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

在继续往下走之前,我们先理解一下非纯之间的区别,从管道开始。

纯管道

Angular只有在它检测到输入值发生了纯变更时才会执行纯管道纯变更是指对原始类型值(StringNumberBooleanSymbol)的更改, 或者对对象引用(DateArrayFunctionObject)的更改。

Angular会忽略(复合)对象内部的更改。 如果我们更改了输入日期(Date)中的月份、往一个输入数组(Array)中添加新值或者更新了一个输入对象(Object)的属性,Angular都不会调用纯管道。

这可能看起来是一种限制,但它保证了速度。 对象引用的检查是非常快的(比递归的深检查要快得多),所以Angular可以快速的决定是否应该跳过管道执行和视图更新。

因此,如果我们要和变更检测策略打交道,就会更喜欢用纯管道。 如果不能,我们就可以转回到非纯管道。

或者我们也可以完全不用管道。 有时候,使用组件的属性能比用管道更好的达到目的,这一点我们等后面会再提起。

非纯管道

Angular会在每个组件的变更检测周期中执行非纯管道。 非纯管道可能会被调用很多次,和每个按键或每次鼠标移动一样频繁。

要在脑子里绷着这根弦,我们必须小心翼翼的实现非纯管道。 一个昂贵、迟钝的管道将摧毁用户体验。

非纯版本的FlyingHeroesPipe

我们把FlyingHeroesPipe换成了FlyingHeroesImpurePipe。 下面是完整的实现:

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

我们把它从FlyingHeroesPipe中继承下来,以证明无需改动内部代码。 唯一的区别是管道元数据中的pure标志。

这是一个很好地非纯管道候选者,因为它的transform函数又小又快。

return allHeroes.filter(hero => hero.canFly);

我们可以从FlyingHeroesComponent派生出一个FlyingHeroesImpureComponent

src/app/flying-heroes-impure.component.html (excerpt)

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

唯一的重大改动就是管道。 我们可以在在线例子 / 可下载的例子中确认,当我们输入新的英雄甚至修改

数组时,这个

的显示也跟着更新了。

非纯AsyncPipe

Angular的AsyncPipe是一个有趣的非纯管道的例子。 AsyncPipe接受一个PromiseObservable作为输入,并且自动订阅这个输入,最终返回它们给出的值。

AsyncPipe管道是有状态的。 该管道维护着一个所输入的Observable的订阅,并且持续从那个Observable中发出新到的值。

在下面例子中,我们使用该async管道把一个消息字符串(message$)的Observable绑定到视图中。

src/app/hero-async-message.component.ts

  1. import { Component } from '@angular/core';
  2. import { Observable } from 'rxjs/Observable';
  3. import 'rxjs/add/observable/interval';
  4. import 'rxjs/add/operator/map';
  5. import 'rxjs/add/operator/take';
  6. @Component({
  7. selector: 'hero-message',
  8. template: `
  9. <h2>Async Hero Message and AsyncPipe</h2>
  10. <p>Message: {{ message$ | async }}</p>
  11. <button (click)="resend()">Resend</button>`,
  12. })
  13. export class HeroAsyncMessageComponent {
  14. message$: Observable<string>;
  15. private messages = [
  16. 'You are my hero!',
  17. 'You are the best hero!',
  18. 'Will you be my hero?'
  19. ];
  20. constructor() { this.resend(); }
  21. resend() {
  22. this.message$ = Observable.interval(500)
  23. .map(i => this.messages[i])
  24. .take(this.messages.length);
  25. }
  26. }

这个Async管道节省了组件的样板代码。 组件不用订阅这个异步数据源,而且不用在被销毁时取消订阅(如果订阅了而忘了反订阅容易导致隐晦的内存泄露)。

一个非纯而且带缓存的管道

我们来写更多的非纯管道:一个向服务器发起HTTP请求的管道。

时刻记住,非纯管道可能每隔几微秒就会被调用一次。 如果我们不小心点,这个管道就会发起一大堆请求“攻击”服务器。

我们确实得小心点。 这个管道只有当所请求的URL发生变化时才会向服务器发起请求。它会缓存服务器的响应。 代码如下,它使用Angular http客户端来接收数据

src/app/fetch-json.pipe.ts

  1. import { Pipe, PipeTransform } from '@angular/core';
  2. import { Http } from '@angular/http';
  3. import 'rxjs/add/operator/map';
  4. @Pipe({
  5. name: 'fetch',
  6. pure: false
  7. })
  8. export class FetchJsonPipe implements PipeTransform {
  9. private cachedData: any = null;
  10. private cachedUrl = '';
  11. constructor(private http: Http) { }
  12. transform(url: string): any {
  13. if (url !== this.cachedUrl) {
  14. this.cachedData = null;
  15. this.cachedUrl = url;
  16. this.http.get(url)
  17. .map( result => result.json() )
  18. .subscribe( result => this.cachedData = result );
  19. }
  20. return this.cachedData;
  21. }
  22. }

接下来我们用一个测试台组件演示一下它,该组件的模板中定义了两个使用到此管道的绑定,他们都从heroes.json文件中取得英雄数据。

src/app/hero-list.component.ts

  1. import { Component } from '@angular/core';
  2. @Component({
  3. selector: 'hero-list',
  4. template: `
  5. <h2>Heroes from JSON File</h2>
  6. <div *ngFor="let hero of ('heroes.json' | fetch) ">
  7. {{hero.name}}
  8. </div>
  9. <p>Heroes as JSON:
  10. {{'heroes.json' | fetch | json}}
  11. </p>`
  12. })
  13. export class HeroListComponent { }

组件渲染起来是这样的:

Hero List

这个管道上的断点请求数据的过程显示:

JsonPipe

第二个绑定除了用到FetchPipe之外还链接了更多管道。 我们把获取数据的结果同时显示在第一个绑定和第二个绑定中。第二个绑定中,我们通过链接到一个内置管道JsonPipe把它转成了JSON格式。

借助json管道进行调试

JsonPipe为你诊断数据绑定的某些神秘错误或为做进一步绑定而探查数据时,提供了一个简单途径。

纯管道与纯函数

纯管道使用纯函数。 纯函数是指在处理输入并返回结果时,不会产生任何副作用的函数。 给定相同的输入,它们总是返回相同的输出。

我们在本章前面见过的管道都是用纯函数实现的。 内置的DatePipe就是一个用纯函数实现的纯管道。 ExponentialStrengthPipe是如此, FlyingHeroesComponent也是如此。 不久前我们刚看过的FlyingHeroesImpurePipe,是一个用纯函数实现的非纯管道

但是一个纯管道必须总是用纯函数实现。忽略这个警告将导致失败并带来一大堆这样的控制台错误:表达式在被检查后被变更。

下一步

管道能很好的封装和共享的通用“值-显示”转换逻辑。我们可以像样式一样使用它们,把它们扔到模板表达式中,以提升视图的表现力和可用性。

要浏览Angular的所有内置管道,请到API参考手册。 学着写写自定义管道,并贡献给开发社区。

附录:没有FilterPipe或者OrderByPipe

Angular没有随身发布过滤或列表排序的管道。 熟悉AngularJS的开发人员应该知道filterorderBy过滤器,但在Angular中它们没有等价物。

这并不是疏忽。Angular不想提供这些管道,因为 (a) 它们性能堪忧,以及 (b) 它们会阻止比较激进的代码最小化(minification)。 无论是filter还是orderBy都需要它的参数引用对象型属性。 我们前面学过,这样的管道必然是非纯管道,并且Angular会在几乎每一次变更检测周期中调用非纯管道。

过滤、 特别是排序是昂贵的操作。 当Angular每秒调用很多次这类管道函数时,即使是中等规模的列表都可能严重降低用户体验。 在AngularJS程序中,filterorderBy经常被误用,结果连累到Angular自身,人们抱怨说它太慢。 从某种意义上,这也不冤:谁叫AngularJS把filterorderBy作为首发队员呢?是它自己准备了这个性能陷阱。

虽然不是很明显,但代码最小化方面也存在风险。想象一个用于英雄列表的排序管道。我们可能根据英雄原始属性中的nameplanet进行排序,就像这样:

<!-- NOT REAL CODE! -->
<div *ngFor="let hero of heroes | orderBy:'name,planet'"></div>

我们使用文本字符串来标记出排序字段,期望管道通过索引形式(如hero['name'])引用属性的值。 不幸的是,激进的代码最小化策略会改变Hero类的属性名,所以Hero.nameHero.planet可能会被变成Hero.aHero.b。 显然,hero['name']是无法正常工作的。

我们中的一些人可能不想做那么激进的最小化。但那不过是我们的选择而已。 Angular作为一个产品不应该拒绝那些想做激进的最小化的人。 所以,Angular开发组决定随Angular一起发布的每样东西,都应该能被安全的最小化。

Angular开发组和一些有经验的Angular开发者强烈建议你:把你的过滤和排序逻辑挪进组件本身。 组件可以对外暴露一个filteredHeroessortedHeroes属性,这样它就获得控制权,以决定要用什么频度去执行其它辅助逻辑。 你原本准备实现为管道,并在整个应用中共享的那些功能,都能被改写为一个过滤/排序的服务,并注入到组件中。

如果你不需要顾虑这些性能和最小化问题,也可以创建自己的管道来实现这些功能(参考FlyingHeroesPipe中的写法)或到社区中去找找。