Angular 从 0 到 1 (九):史上最简单的 Angular 教程

3,887 阅读15分钟

第一节:初识Angular-CLI
第二节:登录组件的构建
第三节:建立一个待办事项应用
第四节:进化!模块化你的应用
第五节:多用户版本的待办事项应用
第六节:使用第三方样式库及模块优化用
第七节:给组件带来活力
Rx--隐藏在 Angular 中的利剑
Redux 你的 Angular 应用
第八节:查缺补漏大合集(上)
第九节:查缺补漏大合集(下)

第九章:查缺补漏大合集(下)

Angular2 动画再体验

State和Transition

我写文章的习惯是先试验再理论,所以我们接下来梳理下Angular2提供的动画技能。还是从最简单的例子开始,一个非常简单的模版:

<div class="traffic-light"></div>

同样非常简单的样式(其实就是画一个小黑块):

.traffic-light{  
  width: 100px;  
  height: 100px;  
  background-color: black;
}

现在的效果就是这个样子,如图所示,一点都不酷啊,没关系,我们一点点来,越简单的越容易弄懂概念。

一点也不酷的小黑块

下面我们为组件添加一个animations的元数据描述:

import { 
  Component, 
  trigger,
  state,
  style
} from '@angular/core';

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css'],
  animations: [
    trigger('signal', [
      state('go', style({
        'background-color': 'green' 
      }))
    ])
  ]
})
export class PlaygroundComponent {

  constructor() { }

}

我们注意到animations中接受的是一个数组,这个数组里面我们使用了一个叫trigger的函数,trigger接受的第一个参数是触发器的名字,第二个参数是一个数组。这个数组是由一种叫state的函数和叫transition的函数组成的。

那么什么是state?state表示一种状态,当这种状态激活时,state所附带的样式就会附着在应用trigger的那个控件上。transition又是什么呢?tranistion描述了一系列动画的步骤,在状态迁移时这些动画步骤就会执行。
我们现在的这个版本中暂时只有state而没有transition,让我们先来看看效果,当然在可以看到效果前我们先要把这个trigger应用到某个控件上。那在我们的例子里就是模版中的那个div了。

<div
    [@signal]="'go'"
    class="traffic-light">
</div>

返回浏览器,你会发现那个小黑块变成小绿块了,如图所示

小黑块变成小绿块

这说明什么?我们的state的样式附着在div上了。为什么呢?因为 [@signal]="'go'" 定义了trigger的状态是go。但这一点也不酷是吗?是的,暂时是这样,还是那句话,不要急。
接下来,我们再加一个状态 stop,在stop激活时我们要把小方块的背景色设为红色,那么我们需要把animations改成下面的样子:

animations: [
    trigger('signal', [
      state('go', style({
        'background-color': 'green' 
      })),
      state('stop', style({
          'background-color':'red'
      }))
    ])
  ]

同时我们需要给模板加两个按钮Go和Stop。现在的模版看起来是下面的样子

<div
  [@signal]="signal"
  class="traffic-light">
</div>
<button (click)="onGo()">Go</button>
<button (click)="onStop()">Stop</button>

当然你看得到,我们点击按钮时需要处理对应的点击事件。在这里我们希望点击Go时,方块变绿,点击Stop时方块变红。如果要达成这个目的,我们需要一个叫signal的成员变量,在点击的处理函数中更改相应的状态。

export class PlaygroundComponent {

  signal: string;

  constructor() { }

  onGo(){
    this.signal = 'go';
  }
  onStop(){
    this.signal = 'stop';
  }
}

现在打开浏览器,试验一下,我们会发现点击Go变绿,而点击Stop变红。但是还是没动起来啊,是的,这是因为我们还没加transition呢,我们只需把animations改写一下,你分别点Go和Stop就能看到动画效果了。为了让效果更明显一些,我们为两种状态指定一下高度。

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

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css'],
  animations: [
    trigger('signal', [
      state('void', style({
        'transform':'translateY(-100%)'
      })),
      state('go', style({
        'background-color': 'green', 
        'height':'100px'
      })),
      state('stop', style({
          'background-color':'red',
          'height':'50px'
      })),
      transition('void => *', animate(5000))
    ])
  ]
})
export class PlaygroundComponent {

  signal: string;

  constructor() { }

  onGo(){
    this.signal = 'go';
  }
  onStop(){
    this.signal = 'stop';
  }
}

那么 transition('* => *', animate(500)) 这句什么意思呢?前面那个 '* => *' 是一个状态迁移表达式,* 表示任意状态,所以这个表达式告诉我们,只要有状态的变化就会激发后面的动画效果。后面的就是告诉Angular做500毫秒的动画,这个动画默认是从一个状态过渡到另一个状态。现在大家打开浏览器体验一下,分别点击Go和Stop,会发现我们的小方块从一个正方形变成一个长方形,红色变成绿色的过程。体验完之后再来看这句话:动画其实就是由若干个状态组成,由transition定义状态过渡的步骤。

有了形状和颜色变化的动画

那么下面我们介绍一个void 状态(空状态),为什么会有void状态呢?其实刚刚我们也体验了,只不过没有定义这个void 状态而已。我们在组件中并没有给signal赋初始值,这就意味着一开始trigger的状态就是void。我们往往在实现进场或离场动画时需要这个void状态。void状态就是描述没有状态值时的状态。

animations: [
    trigger('signal', [
      state('void', style({
        'transform':'translateY(-100%)'
      })),
      state('go', style({
        'background-color': 'green', 
        'height':'100px'
      })),
      state('stop', style({
          'background-color':'red',
          'height':'50px'
      })),
      transition('* => *', animate(500))
    ])
  ]

上面代码定义了一个void状态,而且样式上有一个按Y轴做的-100%的位移,其实这就是一开始让小方块从场景外进入场景内,这样就是实现了一种进场动画,大家可以在浏览器中试验一下。

用void状态实现的进场动画

奇妙的animate函数

上面的我们的实验中,你会发现transition中有个animate函数,可能你认为它就是指定一个动画的时间的函数。它的身手可不止那么简单呢,我们来仔细挖掘一下。
首先呢,我们来对上面的代码做一个小改造,把animations数组改成下面的样子:

animations: [
    trigger('signal', [
      state('void', style({
        'transform':'translateY(-100%)'
      })),
      state('go', style({
        'background-color': 'green', 
        'height':'100px'
      })),
      state('stop', style({
          'background-color':'red',
          'height':'50px'
      })),
      transition('* => *', animate('.5s 1s'))
    ])
  ]

我们其实只对animate中的参数做了一点小改动,就是把animate(500) 改成animate('.5s 1s')。那么.5s表示动画过渡时间为0.5秒(其实和上面设置的500毫秒是一样的),1s表示动画延迟1秒后播放。现在我们打开浏览器,看看效果如何吧。

当然还有更狠的大招,这个字符串表达式还可以变成 '.5s 1s ease-out',后面的这个ease-out是一种缓动函数,它是可以让动画效果更真实的一种方式。
现实世界中物体照着一定节奏移动,并不是一开始就移动很快的,也不可能是一直匀速运动的。怎么理解呢?当皮球往下掉时,首先是越掉越快,撞到地上后回弹,最终才又碰触地板。而缓动函数可以使动画的过渡效果按照这样的真实场景抽象出的对应函数来进行绘制。ease-out只是众多的缓动函数的其中一种,我们当然可以指定其他函数。
另外需要说明的一点是诸如ease-out只是真实函数的一个友好名称,我们当然可以直接指定背后的函数:cubic-bezier(0, 0, 0.58, 1) 。我们下个小例子不用这个ease-out,因为效果可能不是特别明显,我们找一个明显的,使用 cubic-bezier(0.175, 0.885, 0.32, 1.275) 。现在我们打开浏览器,你仔细观察一下是否看到了小方块回弹的效果

animations: [
    trigger('signal', [
      state('void', style({
        'transform':'translateY(-100%)'
      })),
      state('go', style({
        'background-color': 'green', 
        'height':'100px'
      })),
      state('stop', style({
          'background-color':'red',
          'height':'50px'
      })),
      transition('* => *', animate('.5s 1s cubic-bezier(0.175, 0.885, 0.32, 1.275)'))
    ])
  ]

加上了缓动函数的进场动画

关于缓动函数的更多资料可以访问 easings.net/zh-cn 在这里可以看到各种函数的曲线和效果,以及cubic-bezier函数的各种参数

easing.net上列出了各种缓动函数的曲线和效果

需要注意的一点是Angular2实现动画的机制其实是基于W3C的Web Animation标准,这个标准暂时无法支持所有的cubic-bezier函数,只有部分函数被支持。这样的话我们如果要实现某些不被支持的函数怎么办呢?那就得有请我们的关键帧出场了。

关键帧

何谓关键帧?首先需要知道什么是帧?百度百科给了定义:
帧——就是动画中最小单位的单幅影像画面,相当于电影胶片上的每一格镜头。在动画软件的时间轴上帧表现为一格或一个标记。
关键帧——相当于二维动画中的原画。指角色或者物体运动或变化中的关键动作所处的那一帧。关键帧与关键帧之间的动画可以由软件来创建,叫做过渡帧或者中间帧。
先来做一个小实验,我们把入场动画改造成关键帧形式。

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

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css'],
  animations: [
    trigger('signal', [
      state('void', style({
        'transform':'translateY(-100%)'
      })),
      state('go', style({
        'background-color': 'green', 
        'height':'100px'
      })),
      state('stop', style({
          'background-color':'red',
          'height':'50px'
      })),
      transition('void => *', animate(5000, keyframes([
        style({'transform': 'scale(0)'}),
        style({'transform': 'scale(0.1)'}),
        style({'transform': 'scale(0.5)'}),
        style({'transform': 'scale(0.9)'}),
        style({'transform': 'scale(0.95)'}),
        style({'transform': 'scale(1)'})
      ]))),
      transition('* => *', animate('.5s 1s cubic-bezier(0.175, 0.885, 0.32, 1.275)'))
    ])
  ]
})
export class PlaygroundComponent {
  // clock = Observable.interval(1000).do(_=>console.log('observable created'));
  signal: string;

  constructor() { }

  onGo(){
    this.signal = 'go';
  }
  onStop(){
    this.signal = 'stop';
  }
}

保存后返回浏览器,你应该可以看到一个正方形由小变大的进场动画。

关键帧实现的入场动画

现在我们来分析一下代码,这个入场动画是5秒的时间,我们给出6个关键帧,也就是0s,1s,2s,3s,4s和5s这几个。对于每个关键帧,我们给出的样式都是放缩,而放缩的比例逐渐加大,而且是先快后慢,也就是说我们可以模拟出缓动函数的效果。

如果我们不光做放缩,而且在style中还指定位置的话,这个动画就会出现边移动边变大的效果了。把入场动画改成下面的样子试试看吧。

transition('void => *', animate(5000, keyframes([
        style({'transform': 'scale(0)', 'padding': '0px'}),
        style({'transform': 'scale(0.1)', 'padding': '50px'}),
        style({'transform': 'scale(0.5)', 'padding': '100px'}),
        style({'transform': 'scale(0.9)', 'padding': '120px'}),
        style({'transform': 'scale(0.95)', 'padding': '135px'}),
        style({'transform': 'scale(1)', 'padding': '140px'})
]))),

加上位移的效果

最后的结果可能还是不酷,但是这样的话利用关键帧我们如果结合好CSS样式,就会做出比较复杂的动画了。

方便的管道--PIPE

我们一直没有提到的一点就是管道,虽然我们的例子中没有用到,但其实这是Angular 2中提供非常方便的一个特性。这个特性可以让我们很快的将数据在界面上以我们想要的格式输出出来。还是拿例子说话,比如我们在页面上显示一个日期,先建立一个简单的模版:

<p> Without Pipe: Today is {{ birthday }} </p>
<p> With Pipe: Today is {{ birthday | date:"MM/dd/yy" }} </p>

再来建立对应的组件文件:

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

@Component({
  selector: 'app-playground',
  templateUrl: './playground.component.html',
  styleUrls: ['./playground.component.css']
})
export class PlaygroundComponent {
  birthday = new Date();
  constructor() { }

}

无管道和有管道的日期输出

上面的例子可能还没太明显,我们 进一步改造一下模板:

<p> Without Pipe: Today is {{ birthday }} </p>
<p> With Pipe: Today is {{ birthday | date:"MM/dd/yy" }} </p>
<p>The time is {{ birthday | date:'shortTime' }}</p>
<p>The time is {{ birthday | date:'medium' }}</p>

同一数据可以显示成不同样子

而且更牛的是多个Pipes可以串起来使用,比如说上图中最下面那个日期我们希望把Dec大写,就可以这样使用:

<p>The time is {{ birthday | date:'medium' | uppercase }}</p>

多个Pipe连用

自定义一个Pipe

那么自己写一个Pipe是怎样的体验呢?创建一个Pipe非常简单,我们来体会一下。首先创建一个 src/app/playground/trim-space.pipe.ts 的文件:

import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
  name: 'trimSpace'
})
export class TrimSpacePipe implements PipeTransform {
  transform(value: any, args: any[]): any {
    return value.replace(/ /g, '');
  }
}

在Module文件中声明这个Pipe:declarations: [PlaygroundComponent, TrimSpacePipe] 以便于其他控件可以使用这个Pipe:

import { NgModule } from '@angular/core';
import { SharedModule } from '../shared/shared.module';
import { PlaygroundRoutingModule } from './playground-routing.module';
import { PlaygroundComponent }   from './playground.component';
import { PlaygroundService } from './playground.service';
import { TrimSpacePipe } from './trim-space.pipe';

@NgModule({
    imports: [
        SharedModule,
        PlaygroundRoutingModule
    ],
    providers:[
        PlaygroundService
    ],
    declarations: [PlaygroundComponent, TrimSpacePipe]
})
export class PlaygroundModule { }

然后在组件的模板文件中使用即可 {{ birthday | date:'medium' | trimSpace}}

<p> Without Pipe: Today is {{ birthday }} </p>
<p> With Pipe: Today is {{ birthday | date:"MM/dd/yy" }} </p>
<p>The time is {{ birthday | date:'shortTime' }}</p>
<p>The time is {{ birthday | date:'medium' | trimSpace}} with trim space pipe applied</p>
<p>The time is {{ birthday | date:'medium' | uppercase }}</p>

打开浏览器看一下效果,我们看到应用了trimSpace管道的日期的空格被移除了,如图所示:

自定义一个移除空格的Pipe

内建的Pipe

Decimal Pipe

DatePipe和UpperCase Pipe我们刚刚已经见识过了,现在我们看一看内建的其他Pipe。首先是用于数字格式化的DecimalPipe。DecimalPipe的参数是以 {minIntegerDigits}.{minFractionDigits}-{maxFractionDigits} 的表达式形式体现的。其中:

  1. minIntegerDigits 是最小的整数位数,默认是1。
  2. minFractionDigits 表示最小的小数位数,默认是0。
  3. maxFractionDigits 表示最大的小数位数,默认是3。
<p>pi (no formatting): {{pi}}</p>
<p>pi (.5-5): {{pi | number:'.5-5'}}</p>
<p>pi (2.10-10): {{pi | number:'2.10-10'}}</p>
<p>pi (.3-3): {{pi | number:'.3-3'}}</p>

如果我们在组件中定义 pi: number = 3.1415927; 的话,上面的数字会被格式化成下图的样子

Decimal Pipe用于数字的格式化

Currency Pipe

顾名思义,这个Pipe是格式化货币的,这个Pipe的表达式形式是这样的: currency[:currencyCode[:symbolDisplay[:digitInfo]]],也就是说在currency管道后用分号分隔不同的属性设置:

<p>A in USD: {{a | currency:'USD':true}}</p>
<p>B in CNY: {{b | currency:'CNY':false:'4.2-2'}}</p>

上面的代码中 USDCNY 表面货币代码,truefalse 表明是否使用该货币的默认符号,后面如果再有一个表达式就是规定货币的位数限制。这个限制的具体规则和上面Decimal Pipe的类似,如下图所示。

Currecy Pipe用于格式化货币

Percent Pipe

这个管道当然就是用来格式化百分数的,百分数的整数位和小数位的规则也和上面提到的Decimal Pipe和Currency Pipe一致。如果在组件中定义 myNum: number = 0.1415927; 下面的代码会输出成下图的样子:

<p>myNum : {{myNum | percent}}</p>
<p>myNum (3.2-2) : {{myNum | percent:'3.2-2'}}</p>

Percent Pipe用来格式化百分数

Json Pipe

这个管道个人感觉更适合在调试中使用,它可以把任何对象格式化成JSON格式输出。如果我们在组件中定义了一个对象:

object: Object = {
  foo: 'bar', 
  baz: 'qux', 
  nested: {
    xyz: 3, 
    numbers: [1, 2, 3, 4, 5]
  }
};

那么下面的模板会输出下图的样子,在调试阶段,这个特性很好帮助你输出可读性很强的对象格式。当然如果你使用了现代化的IDE,这么使用的意义就不是很大了:

<div>
  <p>Without JSON pipe:</p>
  <pre>{{object}}</pre>
  <p>With JSON pipe:</p>
  <pre>{{object | json}}</pre>
</div>

Json Pipe用于以Json形式格式化对象

指令——Directive

另一个我们一直没有提到的重要概念就是指令了,但这个虽然我们没提到,却已经用过了。比如 *ngFor*ngIf 等,这些都叫做结构性指令,而像 *ngModel 等属于属性型指令。
Angular 2中的指令分成三种:结构型(Structural)指令和属性型(Attribute)指令,还有一种是什么呢?就是Component,组件本身就是一个带模板的指令。
结构型指令可以通过添加、删除DOM元素来更改DOM树的布局,比如我们前面使用 *ngFor在todo-list的模板中添加了多个todo-item。而属性型指令可以改变一个DOM元素的外观或行为,比如我们利用 *ngModel 进行双向绑定,改变了该组件的默认行为(我们在组件中改变某个变量值,这种改变会直接反应到组件上,这并不是组件自身定义的行为,而是我们通过 *ngModel 来改变的)。
Angular 2中给出的内建结构型指令如下表所示:

名称 用法 说明
ngIf <div*ngIf="canShow"> 基于canShow表达式的值移除或重新创建部分DOM树。
ngFor <li *ngFor="let todo of todos"> 把li元素及其内容转化成一个模板,并用它来为列表中的每个条目初始化视图。
ngSwitch, ngSwitchCase, ngSwitchDefault <div [ngSwitch]="someCondition"></div> 基于someCondition的当前值,从内嵌模板中选取一个,有条件的切换div的内容。

自定义一个指令也很简单,我们动手做一个。这个指令非常简单就是使任何控件加上这个指令后,其点击动作都会在console中输出 “I am clicked”。由于我们要监视其宿主的click事件,所以我们引入了 HostListener,在onClick方法上用 @HostListen(‘click’) ,表明在检测到宿主发生click事件时调用这个方法。

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

@Directive({
    selector: "[log-on-click]",
})
export class LogOnClickDirective {

    constructor() {}
    @HostListener('click')
    onClick() { console.log('I am clicked!'); }
}

在模板中简单写一句就可以看效果了

<button log-on-click>Click Me</button>

自定义指令使得点击按钮会log一条消息

代码: github.com/wpcfan/awes…

纸书出版了,比网上内容丰富充实了,欢迎大家订购!
京东链接:item.m.jd.com/product/120…

Angular从零到一