在本书中,我们已经学习了如何使用Angular的内置指令以及如何创建组件。本章将深入探讨用于开发组件的高级Angular特性。

我们将在本章中学习以下内容:

●组件样式封装

●修改宿主DOM元素

●使用内容投影修改模板

●访问邻近的指令

●使用生命周期钩子

●变更检测

14.1 样式

Angular提供了一套用来指定“组件级”样式的机制。尽管CSS的意思是层叠样式表(cascading style sheet),但有时候我们并不想要“层叠”效果。我们可能只想为某个特定的组件提供样式,而不要影响到页面的其他部分。

Angular为组件提供了两个属性来定义CSS类。

为了定义组件样式,我们使用视图属性styles来定义内联样式或者借助styleUrls属性来使用外部CSS文件,还可以在组件的装饰器中直接定义这些属性。

我们来创建一个使用内联样式的组件。

code/advanced_components/app/ts/styling/styling.ts

@Component({

 selector:'inline-style',

 styles:[`

 .highlight {

  border:2px solid red;

  background-color:yellow;

  text-align:center;

  margin-bottom:20px;

 }

 `],

 template:`

 <h4 class="ui horizontal divider header">

  Inline style example

 </h4>

 <div class="highlight">

  This uses component <code>styles</code>

  property

 </div>

 `

})

class InlineStyle {

}

在这个示例中,我们在styles数组参数中声明了CSS类.highlight,它定义了我们要用的样式。

然后在模板中使用<div class="highlight">引用这个类。

最后的结果与我们预期的一样:一个红色边框、黄色背景的div(如图14-1所示)。

图14-1 使用styles属性的组件示例

另一种声明CSS类的方法是使用styleUrls属性。它可以让我们从外部文件中定义CSS并在组件中直接引用它们。

在用这种方式创建另一个组件之前,创建一个名为external.css的文件,它包含下面这些类。

code/advanced_components/app/ts/styling/external.css

.highlight {

 border:2px dotted red;

 text-align:center;

 margin-bottom:20px;

}

然后就可以在组件代码中引用它。

code/advanced_components/app/ts/styling/styling.ts

@Component({

 selector:'external-style',

 styleUrls:[externalCSSUrl],

 template:`

 <h4 class="ui horizontal divider header">

  External style example

 </h4>

 <div class="highlight">

  This uses component <code>styleUrls</code>

  property

 </div>

 `

})

class ExternalStyle {

}

加载页面时,就可以看见有虚线边框的div(如图14-2所示)。

图14-2 使用styleUrls属性的组件示例

14.1.1 视图(样式)封装

这个例子中有意思的地方是,这两个组件都定义了名为highlight的类;尽管其属性是不同的,但它们并没有相互干扰。

这是因为Angular默认将组件样式封装在组件的上下文中。如果检查页面并展开<head>标签,可以注意到Angular把我们定义的样式注入到了一个<style>标签之中,如图14-3所示。

图14-3 注入后的样式

你还会注意到,到这个CSS类使用了_ngcontent-hve-2属性来限定其作用域:

.highlight[\_ngcontent-hve-2] {

 border:2px solid red;

 background-color:yellow;

 text-align:center;

 margin-bottom:20px; }

}

如果查看<div>的渲染结果,会发现它也添加了一个_ng-content-hve-2属性,如图14-4所示。

图14-4 注入后的样式:<div>的渲染结果

引用外部样式文件时的效果也是一样的,如图14-5所示。

图14-5 外部样式

<div>的渲染结果如图14-6所示。

图14-6 外部样式:<div>的渲染结果

Angular允许我们使用encapsulation属性来更改这种行为。

这个属性可以取下列值之一,它们都定义在ViewEncapsulation枚举中。

●Emulated(仿真):这是默认选项,它会采用我们刚刚解释过的技术来封装样式。

●Native(原生):使用这个选项,Angular会采用Shadow DOM技术(下面会详细介绍)。

●None(无):使用这个选项,Angular不会封装任何样式,允许样式渗透给页面的其他元素。

14.1.2 Shadow DOM封装

你可能会问:Shadow DOM有什么用呢?通过使用Shadow DOM,组件会生成一棵独一无二的DOM树,而这棵DOM树对于页面中的其他元素是不可见的。这样,在这个元素中定义的样式对页面的其余部分来说就像不存在一样。

 要深入了解Shadow DOM,请查阅Eric Bidelman撰写的指南http://www.html5rocks.com/en/tutorials/webcomponents/shadowdom/。

我们来创建另一个使用Native封装(Shadow DOM)的组件,理解它是如何工作的。

code/advanced_components/app/ts/styling/styling.ts

@Component({

 selector:`native-encapsulation`,

 styles:[`

 .highlight {

  text-align:center;

  border:2px solid black;

  border-radius:3px;

  margin-botton:20px;

 }`],

 template:`

 <h4 class="ui horizontal divider header">

  Native encapsulation example

 </h4>

 <div class="highlight">

  This component uses <code>ViewEncapsulation.Native</code>

 </div>

 `,

 encapsulation:ViewEncapsulation.Native

})

class NativeEncapsulation {

}

在这个例子中,如果查看源代码,会看到如图14-7所示的结果。

图14-7 Native封装

#shadow-root元素里面的一切都被封装起来了,并且和页面的其他部分是完全隔离的。

14.1.3 不使用封装

最后,如果我们创建一个组件并指定ViewEncapsulation.None,那就不会进行任何的样式封装。

code/advanced_components/app/ts/styling/styling.ts

@Component({

 selector:`no-encapsulation`,

 styles:[`

 .highlight {

  border:2px dashed red;

  text-align:center;

  margin-bottom:20px;

 }

 `],

 template:`

 <h4 class="ui horizontal divider header">

  No encapsulation example

 </h4>

 <div class="highlight">

  This component uses <code>ViewEncapsulation.None</code>

 </div>

 `,

 encapsulation:ViewEncapsulation.None

})

class NoEncapsulation {

}

检查元素时,会看到如图14-8所示的结果。

图14-8 不进行封装

可以看到HTML中没有注入任何东西。在页头中可以找到注入的<style>标签,它跟我们在styles参数中定义的完全一样:

.highlight {

 border:2px dashed red;

 text-align:center;

 margin-bottom:20px;

}

使用ViewEncapsulation.None的缺点是,因为没有进行任何封装,所以它的样式会影响到其他组件。在图13-8中可以看到,使用ViewEncapsulation.Native的组件已经受到了这个新组件的样式的影响。但有时候这可能恰恰是你想要的。

你可以注释掉StyleSampleApp模板中的<no-encapsulation></no-encapsulation>这行代码来看一看区别。

14.2 创建popup指令:引用并修改宿主元素

宿主元素是指令或组件被绑定到的元素。有时组件可能需要往它的宿主元素上附加一些标记或行为。

在这个示例中,我们会创建一个popup指令。它会往宿主元素上附加行为,在宿主元素被点击时显示一条信息。

组件与指令:两者的区别是什么?

组件和指令有着密不可分的关系,但它们略有不同。

你或许曾听说过“组件就是有视图的指令”。其实这并不完全正确。组件自带的功能使它很容易添加视图,但指令同样也可以有视图。事实上,组件是用指令来实现的

一个很好的例子就是ngIf,它根据条件来渲染视图。

但我们可以使用指令没有模板的情况下给元素附加行为。

你可以这样认为:组件就是指令,但组件必须有视图。指令可以有视图,也可以没有。

如果你选择在指令中渲染视图(模板)的话,可以对该模板的呈现方式进行更多的控制。在本章的后面我们会讨论如何对模板进行控制。

14.2.1 popup指令的结构

现在来编写我们的首个指令。我们希望在点击一个带有popup属性的DOM元素时,该指令能显示出一个提示消息。这个消息是通过该元素的message属性来指定的。

我们希望它看起来如下所示:

<element popup message="Some message"></element>

为了让这个组件正常工作,我们还要做一些事:

●接收来自宿主元素的message属性;

●当宿主元素被点击时得到通知。

我们这就开始编写它。

code/advanced_components/app/ts/host/steps/host_01.ts

@Directive({

 selector:'[popup]'

})

class Popup {

 constructor(){

  console.log('Directive bound');

 }

}

我们使用Directive注解并将selector参数设置为[popup]。这可以让该指令绑定到任何定义了popup属性的元素。

现在来创建一个应用,它包含一个有popup属性的元素。

code/advanced_components/app/ts/host/steps/host_01.ts

@Component({

 selector:'host-sample-app',

 template:`

 <div class="ui message" popup>

  <div class="header">

   Learning Directives

  </div>

  <p>

   This should use our Popup diretive

  </p>

 </div>

 `

})

export class HostSampleApp1 {

}

运行这个应用时,我们期望Directive bound消息会被打印到控制台中,这表示我们已经成功绑定了模板中的第一个<div>(如图14-9所示)。

图14-9 绑定到宿主元素

14.2.2 使用ElementRef

如果我们想对指令所绑定的宿主元素进行更多控制,可以使用内置的ElementRef类。

这个类保存着指定Angular元素的相关信息,使用它的nativeElement属性可以获取原生的DOM元素。

为了看到指令所绑定的元素,我们可以在构造函数中接收ElementRef并把它打印到控制台中。

code/advanced_components/app/ts/host/steps/host_02.ts

@Directive({

 selector:'[popup]'

})

class Popup {

 constructor(_elementRef:ElementRef){

  console.log(_elementRef);

 }

}

我们还可以往页面中添加另一个元素,它也使用这个指令。这样就可以看见控制台中打印了两个不同的ElementRef。

code/advanced_components/app/ts/host/steps/host_02.ts

@Component({

 selector:'host-sample-app',

 template:`

 <div class="ui message" popup>

  <div class="header">

   Learning Directives

  </div>

  <p>

   This should use our Popup diretive

  </p>

 </div>

 <i class="alarm icon" popup></i>

 `

})

export class HostSampleApp2 {

}

现在,当运行应用时,可以看到两个不同的ElementRef:一个是div.ui.message,另一个是i.alarm.icon。这表示该指令已经成功绑定了两个不同的宿主元素,如图14-10所示。

图14-10 两个ElementRef

14.2.3 绑定到host属性

我们的下一个目标是在宿主元素被点击时做一些事。

我们之前学过,在Angular中给元素绑定事件的方法是使用(event)语法。

为了给宿主元素绑定事件,我们必须做一些类似的事情,不同之处是这次使用指令的host属性。host属性允许指令改变其宿主元素的属性和行为

我们还希望宿主元素使用它的message属性来定义点击时要弹出的消息。

首先,在指令中添加inputs属性。我们导入Input,并使用@Input注解来修饰这个输入属性。

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

……

class Popup {

 @Input()message:String;

 ……

}

这段代码表示我们有一个名为message的属性,并且期望接收一个与之同名的输入。

接着,我们通过往@Component注解上添加host属性来把它绑定到宿主元素上。

code/advanced_components/app/ts/host/steps/host_03.ts

@Directive({

 selector:'[popup]',

 host:{

  '(click)':'displayMessage()'

 }

})

然后,当宿主元素被点击时就会调用指令的displayMessage方法,它会显示宿主元素定义的消息。

现在代码如下所示。

code/advanced_components/app/ts/host/steps/host_03.ts

class Popup {

 @Input()message:String;

 constructor(_elementRef:ElementRef){

  console.log(_elementRef);

 }

 displayMessage():void {

  alert(this.message);

 }

}

最后,我们需要修改应用的模板,为每个元素添加要显示的消息。

code/advanced_components/app/ts/host/steps/host_03.ts

@Component({

 selector:'host-sample-app',

 template:`

 <div class="ui message" popup

    message="Clicked the message">

  <div class="header">

   Learning Directives

  </div>

  <p>

   This should use our Popup diretive

  </p>

 </div>

 <i class="alarm icon" popup

   message="Clicked the alarm icon"></i>

 `

})

export class HostSampleApp3 {

}

注意,这里使用了两次popup指令并传入了不同的message属性。这意味着当我们运行本应用时,点击信息内容或者图标将会看到不同的弹出信息,分别如图14-11和图14-12所示。

图14-11 弹出信息1

图14-12 弹出信息2

14.2.4 添加按钮并使用exportAs

假设现在又来了新需求:通过点击按钮来手动触发弹出信息。那么该如何在宿主元素之外触发弹出信息呢?

为了实现这个目标,我们要让指令在模板中的任何地方都能被访问到。正如我们在之前章节中讨论过的,可以使用模板变量来引用组件。我们也可以用同样的方式来引用指令。

为了可以在模板中引用指令,就要使用exportAt属性。这将允许宿主元素(或宿主元素的子元素)使用#var="exportName"语法定义一个模板变量来引用指令。

让我们把exportAs属性添加到指令中。

code/advanced_components/app/ts/host/steps/host_04.ts

@Directive({

 selector:'[popup]',

 exportAs:'popup',

 host:{

  '(click)':'displayMessage()'

 }

})

class Popup {

 @Input()message:String;

 constructor(_elementRef:ElementRef){

  console.log(_elementRef);

 }

 displayMessage():void {

  alert(this.message);

 }

}

现在我们需要修改这两个元素来导出模板变量。

code/advanced_components/app/ts/host/steps/host_04.ts

 template:`

 <div class="ui message" popup #popup1="popup"

    message="Clicked the message">

  <div class="header">

   Learning Directives

  </div>

  <p>

   This should use our Popup diretive

  </p>

 </div>

 <i class="alarm icon" popup #p2="popup"

   message="Clicked the alarm icon"></i>

可以看到,我们用模板变量#p1代表div.message,用#p2代表icon。

现在再添加两个按钮,分别触发它们的弹出信息。

code/advanced_components/app/ts/host/steps/host_04.ts

 <div style="margin-top:20px;">

  <button(click)="popup1.displayMessage()" class="ui button">

   Display popup for message element

  </button>

  <button(click)="p2.displayMessage()" class="ui button">

   Display popup for alarm icon

  </button>

 </div>

现在刷新页面并分别点击每个按钮,每条消息都会如预期那样出现。

14.3 使用内容投影创建消息面板

有时,我们在创建组件的时候想要把组件内部的标记作为一个参数传给组件。这种技术就叫作内容投影(content projection)。它能让我们指定一些会扩散到更大模板之中的标记。

 在AngularJS中,这种技术被称为透传(transclusion)。

我们来创建一个指令,它将渲染一个比较好看的消息,如图14-13所示。

图14-13 popup指令渲染的消息

我们的最终目标是写如下标记。

<div message header="My Message">

 This is the content of the message

</div>

它将被渲染成更复杂的HTML。

<div class="ui message">

 <div class="header">

  My Message

 </div>

 <p>

  This is the content of the message

 </p>

</div>

这里面临两个挑战:我们要给宿主元素添加两个CSS类(ui和message),还要把div中的内容添加到标记中的一个指定位置。

14.3.1 改变host属性的CSS类

和之前添加事件一样,为了给宿主元素添加属性,要使用host属性;但是在这里我们定义了属性的名称和值,而不是使用(event)的写法。在这个例子中是这样的。

host:{ 'class':'ui message' }

它会修改宿主元素,把这些类添加到class属性中。

14.3.2 使用ng-content

下一个挑战是将宿主元素节点原来的子节点包含进视图中的指定部分。要做到这一点,我们使用ng-content指令。

因为这个指令需要模板,所以在这里改用组件,并编写如下代码。

code/advanced_components/app/ts/content-projection/content-projection.ts

@Component({

 selector:'[message]',

 host:{

  'class':'ui message'

 },

 template:`

  <div class="header">

   {{ header }}

  </div>

  <p>

   <ng-content></ng-content>

  </p>

 `

})

export class Message {

 @Input()header:string;

 ngOnInit():void {

  console.log('header', this.header);

 }

}

下面是一些要点:

●用@inputs注解表明我们希望接收宿主元素上设置的header属性;

●用组件的host属性把宿主元素的class属性设置为ui message;

●使用<ng-content></ng-content>将宿主元素的子节点投影到模板中的指定位置。

当我们在浏览器中打开应用并检查有message属性的div时,会看到它正如我们所预期的那样工作,如图14-14所示。

图14-14 投影进来的内容

14.4 查询相邻的指令:编写标签页

如果你能创建一个完全封装了自身行为的组件,那当然很棒。

然而,随着组件功能的不断扩展,将组件切割成一些更小的组件再将它们组合在一起就变得有意义了。

一个拥有多个标签页的标签面板是组件协同工作的好例子。标签面板或者标签集合是由多个标签页组合而成的。在这个场景中,我们有一个父组件(标签集合)和多个子组件(标签页)。单独看标签面板或标签页没有意义,但把所有逻辑都放在同一个组件中又太笨重了。因此,我们将在这个示例中讲解如何让这些单独的组件协同工作。

下面来编写这些组件,最终目标是这样用。

<tabset>

 <tab title="Tab 1">Tab 1</tab>

 <tab title="Tab 2">Tab 2</tab>

 ……

</tabset>

我们将使用Semantic UI的Tab组件来渲染标签页。

14.4.1 Tab组件

先来编写Tab组件。

code/advanced_components/app/ts/tabs/tabs.ts

@Component({

 selector:'tab',

 template:`

 <div class="ui bottom attached tab segment"

    [class.active]="active">

  <ng-content></ng-content>

 </div>

 `

})

class Tab {

 @Input()title:string;

 active:boolean = false;

 name:string;

}

这里没有什么新概念。我们声明了一个组件,它的选择器是tab并且接收一个输入属性title。

然后渲染一个<div>标签,并使用前一节中学过的内容投影概念把<tab>指令的行内内容嵌入这个div。

接下来声明三个组件属性:title、active和name。需要注意的是,我们把title属性添加到了@Input('title')注解中。这个注解告诉Angular自动把输入属性title组件属性title进行绑定。

14.4.2 Tabset组件

现在让我们转向Tabset组件,用它来包裹住标签页。

code/advanced_components/app/ts/tabs/tabs.ts

@Component({

 selector:'tabset',

 template:`

 <div class="ui top attached tabular menu">

  <a *ngFor="let tab of tabs"

    class="item"

    [class.active]="tab.active"

    (click)="setActive(tab)">

    {{ tab.title }}

  </a>

 </div>

 <ng-content></ng-content>

 `

})

class Tabset implements AfterContentInit {

 @ContentChildren(Tab)tabs:QueryList<Tab>;

 constructor(){

 }

 ngAfterContentInit(){

  this.tabs.toArray()[0].active = true;

 }

 setActive(tab:Tab){

  this.tabs.toArray().forEach((t)=> t.active = false);

  tab.active = true;

 }

}

我们来分别讲解它的实现,这样可以更好地学习它引入的新概念。

Tabset的@Component注解

@Component部分没有什么新概念。我们使用<tabset>作为选择器。

模板本身使用ngFor来遍历tabs属性。如果一个tab的active标记是true,那么它就会在用来渲染tab的<a>元素上添加CSS类active。

我们还指定了,在初始化div之后、在ng-content所在位置渲染所有标签。

Tabset类

现在让我们把注意力转向Tabset类。这里的第一个新概念就是Tabset类实现了AfterContentInit。这个生命周期钩子告诉Angular,一旦子组件的内容初始化,就调用类的方法(ngAfterContentInit)。

Tabset的ContentChildren和QueryList

接下来,我们声明tabs属性,用它来保存在tabset中声明的每个Tab组件。注意,这里声明的不是一个Tab的数组,而是使用QueryList类并传入泛型Tab。这是为什么呢?

QueryList是Angular提供的类。当我们同时使用QueryList和ContentChildren时,Angular就会将匹配查询的组件填充到QueryList,然后在应用状态变更时保持这些填充项的更新

然而,QueryList需要ContentChildren来进行填充。我们这就来看一看。

在tab实例对象上,我们添加了@ContentChildren(Tab)注解。这个注解告诉Angular要在tabs参数中注入所有Tab类型的直接子指令。然后再将其赋值给组件的tabs属性。有了tabs属性,我们就可以获得并使用所有的子Tab组件了

初始化Tabset

当这个组件初始化之后,我们希望它的第一个标签页是激活的。为了做到这点,要使用ngAfterContentInit函数(AfterContentInit钩子对应的实现方法)。注意这里使用this.tabs.toArray()将Angular的QueryList强制转换为原生的TypeScript数组。

Tabset的setActive方法

最后,我们定义了setActive方法。当点击模板中的标签页时就会调用这个方法,例如(click)="setActive(tab)"。这个函数会遍历所有标签页,将它们的active属性设置为false。然后把我们点击的标签页设置为激活页。

14.4.3 使用Tabset

下一个任务是开发一个应用组件,它将使用我们创建好的这两个组件。我们可以这样做。

code/advanced_components/app/ts/tabs/tabs.ts

@Component({

 selector:'tabs-sample-app',

 template:`

 <tabset>

  <tab title="First tab">

   Lorem ipsum dolor sit amet, consectetur adipisicing elit.

   Quibusdam magni quia ut harum facilis, ullam deleniti porro

   dignissimos quasi at molestiae sapiente natus, neque voluptatum

   ad consequuntur cupiditate nemo sunt.

  </tab>

  <tab *ngFor="let tab of tabs" [title]="tab.title">

   {{ tab.content }}

  </tab>

 </tabset>

 `

})

export class TabsSampleApp {

 tabs:any;

 constructor(){

  this.tabs = [

   { title:'About', content:'This is the About tab' },

   { title:'Blog', content:'This is our blog' },

   { title:'Contact us', content:'Contact us here' },

  ];

 }

}

我们使用tabs-sample-app作为组件的选择器并且使用了组件Tabset和Tab。

我们在模板中创建了一个tabset组件并添加了一个静态的tab组件(第一页),然后往组件控制器类中的tabs属性中又添加了几个tab组件,阐明了动态渲染tab组件的方法。完成后的应用如图14-15所示。

图14-15 使用Tabset的应用

14.5 生命周期钩子

Angular提供了一些生命周期钩子。在指令生命周期的每个阶段之前或之后,它们允许你添加并执行一些代码。

Angular提供的生命周期钩子如下:

●OnInit

●OnDestroy

●DoCheck

●OnChanges

●AfterContentInit

●AfterContentChecked

●AfterViewInit

●AfterViewChecked

这些钩子的使用方法遵循相似的模式。

为了得到这些事件的通知,你需要:

(1)声明你的指令类实现接口;

(2)声明钩子对应的ng方法(例如,ngOnInit)。

每个方法名都以ng开头,再加上钩子的名字。比如,OnInit要声明ngOnInit方法,AfterContentInit要声明ngAfterContentInit方法,以此类推。

当Angular知道组件实现了这些函数后,就会在适当的时机调用它们。

下面分别看看每个钩子的用法以及使用场景。

 实际上,让这个类实现(implement)该接口并不是必需的,也可以只创建此钩子要求的方法。不过实现该接口是一项最佳实践,它能在强类型和编辑器等方面给你带来好处。

14.5.1 OnInit和OnDestroy

在指令的属性初始化完成之后、子指令的属性开始初始化之前,Angular会调用OnInit钩子。

同样,在指令的实例销毁之前,Angular调用OnDestroy钩子。它最典型的应用场景是,当指令销毁、要做一些清理工作时。

为了说明这些,我们来编写一个同时实现了OnInit和OnDestroy的组件。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_01.ts

@Component({

 selector:'on-init',

 template:`

 <div class="ui label">

  <i class="cubes icon"></i> Init/Destroy

 </div>

 `

})

class OnInitCmp implements OnInit, OnDestroy {

 ngOnInit():void {

  console.log('On init');

 }

 ngOnDestroy():void {

  console.log('On destroy');

 }

}

在这个组件中,当钩子被调用时,我们只是往控制台中打印字符串On init和On destroy。

要测试这些钩子,我们就要在应用组件中使用这些组件,并用ngIf来根据布尔值决定是否显示我们的组件。然后添加一个按钮让我们切换这个布尔型标志:当标记变为假时,组件会从页面中移除,OnDestroy就会被调用;当标记变为真时,OnInit钩子会被调用。

应用组件看起来如下所示。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_01.ts

@Component({

 selector:'lifecycle-sample-app',

 template:`

 <h4 class="ui horizontal divider header">

  OnInit and OnDestroy

 </h4>

 <button class="ui primary button"(click)="toggle()">

  Toggle

 </button>

 <on-init *ngIf="display"></on-init>

 `

})

export class LifecycleSampleApp1 {

 display:boolean;

 constructor(){

  this.display = true;

 }

 toggle():void {

  this.display =!this.display;

 }

}

首次运行该应用时,可以看到OnInit钩子在组件首次初始化后被调用了,如图14-16所示。

图14-16 组件的初始状态

在第一次点击Toggle按钮时,组件被销毁,OnDestroy钩子也如预期一般被调用了,如图14-17所示。

图14-17 OnDestroy钩子:首次点击Toggle按钮

如果再次点击Toggle按钮,结果将如图14-18所示。

图14-18 OnDestroy钩子:再次点击Toggle按钮

14.5.2 OnChanges

OnChanges钩子在一个或多个组件属性更改后调用。ngOnChanges方法会接收一个参数来告诉你哪些属性发生了改变。

为了更好地理解这一点,我们来编写一个评论组件。该组件有两个输入属性:name和comment。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_02.ts

@Component({

 selector:'on-change',

 template:`

 <div class="ui comments">

  <div class="comment">

   <a class="avatar">

    <img src="app/images/avatars/matt.jpg">

   </a>

   <div class="content">

    <a class="author">{{name}}</a>

    <div class="text">

     {{comment}}

    </div>

   </div>

  </div>

 </div>

 `

})

class OnChangeCmp implements OnChanges {

 @Input('name')name:string;

 @Input('comment')comment:string;

 ngOnChanges(changes:{[propName:string]:SimpleChange}):void {

  console.log('Changes', changes);

 }

}

最重要的一点是,这个组件实现了OnChanges接口,并声明了该接口的ngOnChanges方法。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_02.ts

 ngOnChanges(changes:{[propName:string]:SimpleChange}):void {

  console.log('Changes', changes);

 }

当name属性或comment属性的值发生变化时,这个方法就会被触发。这时,我们就会收到一个对象,它把发生变化的字段映射到SimpleChange对象中。

每个SimpleChange实例都有两个字段:currentValue和previousValue。如果组件的name和comment属性都发生了变化,那么该方法的changes值就应该是这样的。

{

 name:{

  currentValue:'new name value',

  previousValue:'old name value'

 },

 comment:{

  currentValue:'new comment value',

  previousValue:'old comment value'

 }

}

现在对应用组件进行修改,让它使用我们的组件并添加一个小型表单。这样就可以试试与组件的name属性和comment属性的交互了。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_02.ts

@Component({

 selector:'lifecycle-sample-app',

 template:`

 <h4 class="ui horizontal divider header">

  OnInit and OnDestroy

 </h4>

 <button class="ui primary button"(click)="toggle()">

  Toggle

 </button>

 <on-init *ngIf="display"></on-init>

 <h4 class="ui horizontal divider header">

  OnChange

 </h4>

 <div class="ui form">

  <div class="field">

   <label>Name</label>

   <input type="text" #namefld value="{{name}}"

      (keyup)="setValues(namefld, commentfld)">

  </div>

  <div class="field">

   <label>Comment</label>

   <textarea(keyup)="setValues(namefld, commentfld)"

        rows="2" #commentfld>{{comment}}</textarea>

  </div>

 </div>

 <on-change [name]="name" [comment]="comment"></on-change>

 `

})

export class LifecycleSampleApp2 {

 display:boolean;

 name:string;

 comment:string;

 constructor(){

  this.display = true;

  this.name = 'Felipe Coury';

  this.comment = 'I am learning so much!';

 }

 setValues(namefld, commentfld):void {

  this.name = namefld.value;

  this.comment = commentfld.value;

 }

 toggle():void {

  this.display =!this.display;

 }

}

重点是我们往模板中添加了一个新的表单,这个表单有name和comment两个字段。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_02.ts

 <div class="ui form">

  <div class="field">

   <label>Name</label>

   <input type="text" #namefld value="{{name}}"

      (keyup)="setValues(namefld, commentfld)">

  </div>

  <div class="field">

   <label>Comment</label>

   <textarea(keyup)="setValues(namefld, commentfld)"

        rows="2" #commentfld>{{comment}}</textarea>

  </div>

 </div>

无论在name字段还是comment字段的keyup事件触发时,我们都通过模板变量调用setValues方法。模板变量namefld和commentfld分别代表这里的input和textarea。

这个方法只是取出这些字段的值并更新对应的name和comment属性。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_02.ts

 setValues(namefld, commentfld):void {

  this.name = namefld.value;

  this.comment = commentfld.value;

 }

现在,当我们第一次打开应用时,就会看到OnChanges钩子被调用了,如图14-19所示。

图14-19 OnChanges钩子:首次打开应用时

这个事件在刚刚设置了初始值时发生在LifecycleSampleApp组件的构造函数中。

如果在Name输入框中进行输入,就会看到钩子函数不断被重复调用。如图14-20所示,当我们在Name输入框中粘贴Nate Murray时,控制台正如我们预期的那样反映出了值的变化。

图14-20 OnChanges钩子:输入后

14.5.3 DoCheck

Angular默认的通知系统就是通过OnChanges实现的,每当Angular的变更检测机制检测到指令的属性变化时就会触发它。

然而,有时候这种变更通知机制可能开销过大,尤其是在对性能要求较高的场景下。

有时候,我们只想在特定的条件下进行一些操作,比如在移除或添加一个项目时,或是在某个特定的属性发生变化时。

如果遇到上述场景之一,就可以使用DoCheck钩子。

 有一点非常重要,如果我们同时实现了OnChanges和DoCheck,那么OnChanges会被DoCheck覆盖,也就是说OnChanges会被忽略。

变更检测

为了找出有哪些变化,Angular提供了differ(差分器)类。differ会对指令的某个属性进行计算,以确定它是否发生了改变

有两种内置的differ类型:迭代differ键值对differ

迭代differ

当我们使用列表类的数据结构并且只想知道在列表中添加或删除了哪些条目时,应该使用迭代differ。

键值对differ

当我们使用字典类数据结构时,应该使用键值对differ;它在键一级工作。这个differ会识别出键的添加、删除或某个键对应值的改变。

使用do-check-item渲染单条评论

为了阐明这些概念,我们来构建一个渲染一系列评论的组件,如图14-21所示。

图14-21 DoCheck钩子示例

首先,我们编写一个渲染单条评论的组件。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

@Component({

 selector:'do-check-item',

 outputs:['onRemove'],

 template:`

 <div class="ui feed">

  <div class="event">

   <div class="label" *ngIf="comment.author">

    <img src="/app/images/avatars/{{comment.author.toLowerCase()}}.jpg">

   </div>

   <div class="content">

    <div class="summary">

     <a class="user">

      {{comment.author}}

     </a> posted a comment

     <div class="date">

      1 Hour Ago

     </div>

    </div>

    <div class="extra text">

     {{comment.comment}}

    </div>

    <div class="meta">

     <a class="trash"(click)="remove()">

      <i class="trash icon"></i> Remove

     </a>

     <a class="trash"(click)="clear()">

      <i class="eraser icon"></i> Clear

     </a>

     <a class="like"(click)="like()">

      <i class="like icon"></i> {{comment.likes}} Likes

     </a>

    </div>

   </div>

  </div>

 </div>

 `

})

我们声明了组件的元数据。组件会接收输入属性comment并渲染它,还会在点击删除按钮时发出一个事件。

继续看组件类的实现。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

class DoCheckItem implements DoCheck {

 @Input('comment')comment:any;

 onRemove:EventEmitter<any>;

 differ:any;

在这个类声明中,我们实现了DoCheck接口,并且声明了输入属性comment、输出事件onRemove和一个differ属性。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 constructor(differs:KeyValueDiffers){

  this.differ = differs.find([]).create(null);

  this.onRemove = new EventEmitter();

 }

在这个构造函数中,我们用differs变量接收了一个KeyValueDiffers的实例,然后通过differs.find([]).create(null)语法创建了一个键值对differ的实例。我们还初始化了事件发射器onRemove。

接下来,我们来实现接口要求的ngDoCheck方法。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 ngDoCheck():void {

  var changes = this.differ.diff(this.comment);

  if(changes){

   changes.forEachAddedItem(r => this.logChange('added', r));

   changes.forEachRemovedItem(r => this.logChange('removed', r));

   changes.forEachChangedItem(r => this.logChange('changed', r));

  }

 }

这里用键值对differ检测了变更,只要调用diff方法并提供想要检查的属性就可以了。在这个例子中,我们想知道comment属性是否发生了变化。

当没有检测到任何变化时,返回值就是null。如果有变化,我们可以调用differ上的三个不同的迭代方法:

●forEachAddedItem,用于枚举所有新增的键;

●forEachRemovedItem,用于枚举所有删除的键;

●forEachChangedItem,用于枚举所有变化的键。

每个方法都会调用我们提供的接收record参数的回调函数。对于键值对differ,这个record参数是KVChangeRecord类的实例(如图14-22所示)。

图14-22 KVChangeRecord实例的一个例子

用来了解变化的最重要的几个字段是key、previousValue和currentValue。

接下来,我们写一个方法,把发生的这些变化以通俗易懂的句子输出到控制台中。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 logChange(action, r){

  if(action === 'changed'){

   console.log(r.key, action, 'from', r.previousValue, 'to', r.currentValue);

  }

  if(action === 'added'){

   console.log(action, r.key, 'with', r.currentValue);

  }

  if(action === 'removed'){

   console.log(action, r.key, '(was ' + r.previousValue + ')');

  }

 }

最后,我们来写几个方法,帮助我们改变组件中的值以便触发DoCheck钩子。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 remove():void {

  this.onRemove.emit(this.comment);

 }

 clear():void {

  delete this.comment.comment;

 }

 like():void {

  this.comment.likes += 1;

 }

remove()方法会发出事件,表示用户请求删除这条评论。clear()方法会把评论文字从评论对象中删除。like()方法会增加这条评论的“赞”数。

使用do-check渲染评论列表

写好了表示单条评论的组件之后,我们再来写第二个组件,它负责渲染评论列表。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

@Component({

 selector:'do-check',

 template:`

 <do-check-item [comment]="comment"

  *ngFor="let comment of comments"(onRemove)="removeComment($event)">

 </do-check-item>

 <button class="ui primary button"(click)="addComment()">

  Add

 </button>

 `

})

组件的元数据十分简单:使用上面创建的组件,然后用ngFor来遍历组件列表并渲染它们。我们还加了一个按钮让用户添加新评论。

接下来实现评论列表类DoCheckCmp。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

class DoCheckCmp implements DoCheck {

 comments:any[];

 iterable:boolean;

 authors:string[];

 texts:string[];

 differ:any;

我们声明了要用的变量:comments、iterable、authors和texts。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 constructor(differs:IterableDiffers){

  this.differ = differs.find([]).create(null);

  this.comments = [];

  this.authors = ['Elliot', 'Helen', 'Jenny', 'Joe', 'Justen', 'Matt'];

  this.texts = [

   "Ours is a life of constant reruns.We're always circling back to where we\

'd we started, then starting all over again.Even if we don't run extra laps tha\

t day, we surely will come back for more of the same another day soon.",

   'Really cool!',

   'Thanks!'

  ];

  this.addComment();

 }

对于这个组件,我们使用了迭代differ。可以看到这里用来创建differ的类是IterableDiffers,但创建differ的方式还是和以前一样。

在构造函数中,我们还初始化了作者列表和评论文字列表,会在添加新评论的时候用到它们。

最后,我们调用addComment()方法。这样,评论列表在应用刚刚初始化时不会是空白的。

接下来的三个方法是用来添加一条新评论的。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 getRandomInt(max:number):number {

  return Math.floor(Math.random()*(max + 1));

 }

 getRandomItem(array:string[]):string {

  let pos:number = this.getRandomInt(array.length - 1);

  return array[pos];

 }

 addComment():void {

  this.comments.push({

   author:this.getRandomItem(this.authors),

   comment:this.getRandomItem(this.texts),

   likes:this.getRandomInt(20)

  });

 }

 removeComment(comment){

  let pos = this.comments.indexOf(comment);

  this.comments.splice(pos, 1);

 }

我们声明了两个方法,它们分别返回一个随机数和一个数组中的随机项。

最后,addComment()方法会使用随机作者、随机文本和随机点赞数来添加一条新评论。

接下来是removeComment()方法,它用来从列表中删除一条评论。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 removeComment(comment){

  let pos = this.comments.indexOf(comment);

  this.comments.splice(pos, 1);

 }

最后声明变更检测方法ngDoCheck()。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_03.ts

 ngDoCheck():void {

  var changes = this.differ.diff(this.comments);

  if(changes){

   changes.forEachAddedItem(r => console.log('Added', r.item));

   changes.forEachRemovedItem(r => console.log('Removed', r.item));

  }

 }

尽管在行为上与键值对differ一样,但是迭代differ只提供了添加和删除条目的方法。

当运行应用时,我们得到了一个只有一条评论的列表(如图14-23所示)。

图14-23 初始状态

我们还看到一些信息被打印到了控制台中,就像下面这样。

added author with Matt

……

added likes with 14

我们来看看,点击Add按钮来添加一条新评论时会发生什么(如图14-24所示)。

图14-24 添加的评论

可以看到迭代differ识别出了添加到列表中的新评论对象{author:"Hellen", comment:"Thanks!", likes:17}。

评论对象中单独的属性变化也打印出来了,也就是键值对differ检测到的。

added author with Helen

added comment with Thanks!

added likes with 17

现在点击这条新评论的Likes图标(如图14-25所示)。

图14-25 点赞数变化

现在只有like属性的变化会被检测到。

如果点击Clear图标,它会从评论对象中删除comment键(如图14-26所示)。

图14-26 清空评论内容

打印出的日志证实这个键确实被删除了。

最后,我们通过点击Remove图标删除最后一条评论(如图14-27所示)。

图14-27 删除评论

如预期一样,我们得到了一条对象被删除的日志。

14.5.4 AfterContentInit、AfterViewInit、AfterContentChecked和AfterViewChecked

AfterContentInit钩子的调用发生在OnInit之后。一旦组件或指令的内容初始化完成,就会立即调用它。

AfterContentChecked也类似,不过它是在指令检查结束后调用的。这里的“检查”是指变更检测系统进行的检查。

另外两个钩子AfterViewInit和AfterViewChecked会紧跟着上述内容钩子,在视图完全初始化之后触发。但是这两个钩子只适用于组件,不能用于指令。

同时,AfterXXXInit之类的钩子在整个指令生命周期里都只会被调用一次,而AfterXXXChecked之类的钩子在每次变更检测周期后都会被调用。

为了更好地理解这些,我们来编写另一个组件,它会对每个生命周期钩子都打印日志到控制台。它还有一个counter属性,可以通过点击按钮来增加计数。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_04.ts

@Component({

 selector:'afters',

 template:`

 <div class="ui label">

  <i class="list icon"></i> Counter:{{ counter }}

 </div>

 <button class="ui primary button"(click)="inc()">

  Increment

 </button>

 `

})

class AftersCmp implements OnInit, OnDestroy, DoCheck,

              OnChanges, AfterContentInit,

              AfterContentChecked, AfterViewInit,

              AfterViewChecked {

 counter:number;

 constructor(){

  console.log('AfterCmd ———————— [constructor]');

  this.counter = 1;

 }

 inc(){

  console.log('AfterCmd ———————— [counter]');

  this.counter += 1;

 }

 ngOnInit(){

  console.log('AfterCmd - OnInit');

 }

 ngOnDestroy(){

  console.log('AfterCmp - OnDestroy');

 }

 ngDoCheck(){

  console.log('AfterCmp - DoCheck');

 }

 ngOnChanges(){

  console.log('AfterCmp - OnChanges');

 }

 ngAfterContentInit(){

  console.log('AfterCmp - AfterContentInit');

 }

 ngAfterContentChecked(){

  console.log('AfterCmp - AfterContentChecked');

 }

 ngAfterViewInit(){

  console.log('AfterCmp - AfterViewInit');

 }

 ngAfterViewChecked(){

  console.log('AfterCmp - AfterViewChecked');

 }

}

现在把它和Toggle按钮添加到应用组件中,就像之前在OnDestroy钩子中的用法一样。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_04.ts

 <afters *ngIf="displayAfters"></afters>

 <button class="ui primary button"(click)="toggleAfters()">

  Toggle

 </button>

应用组件的最终实现看起来应该是这样的。

code/advanced_components/app/ts/lifecycle-hooks/lifecycle_04.ts

@Component({

 selector:'lifecycle-sample-app',

 template:`

 <h4 class="ui horizontal divider header">

  OnInit and OnDestroy

 </h4>

 <button class="ui primary button"(click)="toggle()">

  Toggle

 </button>

 <on-init *ngIf="display"></on-init>

 <h4 class="ui horizontal divider header">

  OnChange

 </h4>

 <div class="ui form">

  <div class="field">

   <label>Name</label>

   <input type="text" #namefld value="{{name}}"

      (keyup)="setValues(namefld, commentfld)">

  </div>

  <div class="field">

   <label>Comment</label>

   <textarea(keyup)="setValues(namefld, commentfld)"

        rows="2" #commentfld>{{comment}}</textarea>

  </div>

 </div>

 <on-change [name]="name" [comment]="comment"></on-change>

 <h4 class="ui horizontal divider header">

  DoCheck

 </h4>

 <do-check></do-check>

 <h4 class="ui horizontal divider header">

  AfterContentInit, AfterViewInit, AfterContentChecked and AfterViewChecked

 </h4>

 <afters *ngIf="displayAfters"></afters>

 <button class="ui primary button"(click)="toggleAfters()">

  Toggle

 </button>

 `

})

export class LifecycleSampleApp4 {

 display:boolean;

 displayAfters:boolean;

 name:string;

 comment:string;

 constructor(){

  // OnInit and OnDestroy

  this.display = true;

  // OnChange

  this.name = 'Felipe Coury';

  this.comment = 'I am learning so much!';

  // AfterXXX

  this.displayAfters = true;

 }

 setValues(namefld, commentfld){

  this.name = namefld.value;

  this.comment = commentfld.value;

 }

 toggle():void {

  this.display =!this.display;

 }

 toggleAfters():void {

  this.displayAfters =!this.displayAfters;

 }

}

当应用启动后,我们可以看到每个钩子都打印了日志(如图14-28所示)。

图14-28 应用启动

现在我们清空控制台并点击Increment按钮(如图14-29所示)。

图14-29 计数增加

可以看到,这次只触发了DoCheck、AfterContentChecked和AfterViewChecked这三个钩子。

如果点击Toggle按钮,将如图14-30所示。

图14-30 首次切换

接着再点击一次Toggle按钮,将如图14-31所示。

图14-31 再次切换

所有钩子都被触发了。

14.6 高级模板

template元素是种特殊的元素,用来创建可以动态操控的视图。

为了使template元素用起来更简单,Angular提供了一些语法糖来创建template元素,因此通常不需要手动创建。

举例来说,如果我们写:

<do-check-item

 *ngFor="let comment of comments"

 [comment]="comment"

 (onRemove)="removeComment($event)">

</do-check-item>

它就会转换成:

<do-check-item

 template="ngFor let comment of comments; #i=index"

 [comment]="comment"

 (onRemove)="removeComment($event)">

</do-check-item>

接着转换成:

<template

 ngFor

 [ngForOf]="comments"

 let-comment="$implicit"

 let-index="i">

 <do-check-item

  [comment]="comment"

  (onRemove)="removeComment($event)">

 </do-check-item>

</template>

理解其背后的概念很重要,这样我们才能构建自己的指令。

14.6.1 重写ngIf:ngBookIf

我们来创建一个指令,它和ngIf所做的事情完全一样。我们称之为ngBookIf。

ngBookIf的@Directive

我们先为这个类声明@Directive注解:

@Directive({

 selector:'[ngBookIf]',

})

正如前面所说,我们要使用[ngBookIf]作为选择器。这是因为当使用*ngBookIf="condition"时,它会被转换成:

<template ngBookIf [ngBookIf]="condition">

由于ngBookIf同时是一个属性,我们还需要指出想把ngBookIf作为输入属性进行接收。

这个指令要做的是:当条件为真时,添加指令模板的内容;否则删除。

当条件为真时,我们就会使用视图容器(view container)。视图容器是用来给指令附加一个或多个视图的。

视图容器可以用来:

●创建一个新视图,嵌入我们的指令模板;

●清空视图容器内容。

在使用它之前,需要注入ViewContainerRef和TemplateRef。它们会注入指令的视图容器和模板。

代码如下所示。

code/advanced_components/app/ts/templates/if.ts

class NgBookIf {

 constructor(private viewContainer:ViewContainerRef,

       private template:TemplateRef<any>){}

有了视图容器和模板的引用,我们就可以写TypeScript的属性设置器(setter)了,并且用Input()注解表明它是输入属性。

code/advanced_components/app/ts/templates/if.ts

 @Input()set ngBookIf(condition){

  if(condition){

   this.viewContainer.createEmbeddedView(this.template);

  }

  else {

   this.viewContainer.clear();

  }

 }

每当设置类的ngBookIf属性时,这个方法都会被调用。也就是说,只要ngBookIf="condition"中的condition发生变化,就会调用这个方法。

现在,如果条件为真,就使用视图容器的createEmbeddedView方法来添加指令的模板;否则使用clear方法来删除视图容器中的所有内容。

使用ngBookIf

要想使用这个指令,可以编写下面的组件。

code/advanced_components/app/ts/templates/if.ts

@Component({

 selector:'template-sample-app',

 template:`

 <button class="ui primary button"(click)="toggle()">

  Toggle

 </button>

 <div *ngBookIf="display">

  The message is displayed

 </div>

 `

})

export class IfTemplateSampleApp {

 display:boolean;

 constructor(){

  this.display = true;

 }

 toggle(){

  this.display =!this.display;

 }

}

运行应用时,可以看到指令如预期的一样工作:当我们点击Toggle按钮时,会在页面中切换显示消息This message is displayed。

14.6.2 重写ngFor:ngBookRepeat

现在再来编写一个简易版的ngFor指令,用来为指定的集合反复渲染模板。

ngBookRepeat模板解构

我们将通过*ngBookRepeat="let var of collection"语法来使用该指令。

就像在前一个指令中所做的那样,我们需要声明选择器[ngBookRepeat]。不过,这里的输入参数并不是只有ngBookRepeat。

如果回头看一下Angular是如何转换*something="let var in collection"标记的,就会发现该元素展开后的最终形态等价于:

<template something [somethingOf]="collection" let-var="$implicit">

 <!—— …… ——>

</template>

如前所见,传入的输入属性不是something,而是somethingOf。它的值就是我们的指令要接收并迭代的集合。

对于生成的模板,我们将使用局部视图变量#var,它会从局部变量$implicit接收值。当Angular对语法糖进行展开时,会将一个局部变量放到模板中。这个局部变量的名称就是$implicit。

ngBookRepeat的@Directive

该开始编写这个指令了。首先来写指令的注解。

code/advanced_components/app/ts/templates/for.ts

@Directive({

 selector:'[ngBookRepeat]'

})

ngBookRepeat类

然后编写组件类。

code/advanced_components/app/ts/templates/for.ts

class NgBookRepeat implements DoCheck {

 private items:any;

 private differ:IterableDiffer;

 private views:Map<any, ViewRef> = new Map<any, ViewRef>();

 constructor(private viewContainer:ViewContainerRef,

       private template:TemplateRef<any>,

       private changeDetector:ChangeDetectorRef,

       private differs:IterableDiffers){}

我们为类声明了一些属性:

●items保存我们要迭代的集合;

●differ是一个IterableDiffer对象(已经在14.5节学过),用于变更检测;

●views是一个Map,它将把集合中给出的条目和包含它的视图链接起来。

构造函数会接收viewContainer、template和一个IterableDiffers实例(全部参数都在本章的前面讨论过)。

接下来要做的就是注入变更检测器。我们会在下一节中深入讲解变更检测器,现在可以先把它理解为Angular创建的类,用来在指令属性发生变化时触发检测动作。

下一步是编写设置ngBookRepeatOf属性时要触发的代码。

code/advanced_components/app/ts/templates/for.ts

 @Input()set ngBookRepeatOf(items){

  this.items = items;

  if(this.items &&!this.differ){

   this.differ = this.differs.find(items).create(this.changeDetector);

  }

 }

当设置该属性时,我们将此集合保存在指令的item属性中。如果集合是有效的并且还没有differ的话,就创建一个differ。

要做到这一点,我们创建一个IterableDiffer类的实例。它可以复用指令的变更检测器(已经在构造函数中注入过了)。

接下来就要编写对集合的变化作出响应的代码了。为此,我们要实现下面的ngDoCheck方法来实现DoCheck生命周期钩子。

code/advanced_components/app/ts/templates/for.ts

 ngDoCheck():void {

  if(this.differ){

   let changes = this.differ.diff(this.items);

   if(changes){

    changes.forEachAddedItem((change)=> {

     let view = this.viewContainer.createEmbeddedView(this.template,

      {'$implicit':change.item});

     this.views.set(change.item, view);

    });

    changes.forEachRemovedItem((change)=> {

     let view = this.views.get(change.item);

     let idx = this.viewContainer.indexOf(view);

     this.viewContainer.remove(idx);

     this.views.delete(change.item);

    });

   }

  }

 }

我们来分解一下这段代码。在这个方法中,我们做的第一件事就是确保differ已经实例化了。如果没有,那我们就不做任何事。

接下来,询问differ哪些东西发生了变化。如果有变化,就用changes.forEachAddedItem方法来遍历所有新增项。对于每个添加进来的元素,该回调方法将接收一个CollectionChangeRecord对象。

对于每个元素,都使用视图容器的createEmbeddedView方法来创建一个新的嵌入视图:

let view = this.viewContainer.createEmbeddedView(this.template, {'$implicit':change.item});

createEmbeddedView方法的第二个参数是视图的上下文。在这个例子中,我们把局部变量$implicit设置为change.item。这样就可以访问视图里在*ngBookRepeat="let var of collection"中声明的var变量了。也就是说,let var中的var就是$implicit变量。使用$implicit是因为当我们写这个组件时还不知道用户会给它起什么名字。

最后,我们要把集合中的条目和视图关联起来。背后的原因是,如果从集合中删除了一个条目,也需要删除相应的视图。这就是接下来我们要做的。

对于从集合中删除的每一个条目,我们都要根据集合条目到视图的映射找到视图,并查询该视图在视图容器中的索引。这是因为视图容器的remove方法需要一个索引。最后,还要从集合条目到视图的映射中删除这个视图。

试用这个指令

要测试这个指令,可以编写如下组件。

code/advanced_components/app/ts/templates/for.ts

@Component({

 selector:'template-sample-app',

 template:`

 <ul>

  <li *ngBookRepeat="let p of people">

   {{ p.name }} is {{ p.age }}

   <a href(click)="remove(p)">Remove</a>

  </li>

 </ul>

 <div class="ui form">

  <div class="fields">

   <div class="field">

    <label>Name</label>

    <input type="text" #name placeholder="Name">

   </div>

   <div class="field">

    <label>Age</label>

    <input type="text" #age placeholder="Age">

   </div>

  </div>

 </div>

 <div class="ui submit button"

   (click)="add(name, age)">

  Add

 </div>

 `

})

export class ForTemplateSampleApp {

 people:any[];

 constructor(){

  this.people = [

   {name:'Joe', age:10},

   {name:'Patrick', age:21},

   {name:'Melissa', age:12},

   {name:'Kate', age:19}

  ];

 }

 remove(p){

  let idx:number = this.people.indexOf(p);

  this.people.splice(idx, 1);

  return false;

 }

 add(name, age){

  this.people.push({name:name.value, age:age.value});

  name.value = '';

  age.value = '';

 }

}

我们使用指令来遍历人员列表。

code/advanced_components/app/ts/templates/for.ts

 <ul>

  <li *ngBookRepeat="let p of people">

   {{ p.name }} is {{ p.age }}

   <a href(click)="remove(p)">Remove</a>

  </li>

 </ul>

当点击Remove按钮时,我们将该条目从集合中删除并触发变更检测。

我们还提供了一个表单,可以用它向集合中添加条目。

code/advanced_components/app/ts/templates/for.ts

 <div class="ui form">

  <div class="fields">

   <div class="field">

    <label>Name</label>

    <input type="text" #name placeholder="Name">

   </div>

   <div class="field">

    <label>Age</label>

    <input type="text" #age placeholder="Age">

   </div>

  </div>

 </div>

 <div class="ui submit button"

   (click)="add(name, age)">

  Add

 </div>

14.7 变更检测

在用户与我们的应用交互时,数据(state)会发生改变,我们的应用需要据此作出响应。

任何现代JavaScript框架都需要解决的一大问题就是:怎样才能知道发生了变化并据此重新渲染组件?

为了让视图可以响应组件状态的变化,Angular使用了变更检测

什么可以触发组件状态的改变?最明显的就是用户交互。比如,如果我们有这样一个组件:

@Component({

 selector:'my-component',

 template:`

 Name:{{name}}

 <button(click)="changeName()">Change!</button>

 `

})

class MyComponent {

 name:string;

 constructor(){

  this.name = 'Felipe';

 }

 changeName(){

  this.name = 'Nate';

 }

}

可以看到,当用户点击Change!按钮时,组件的name属性会发生改变。

另一个变化的来源可能是HTTP请求:

@Component({

 selector:'my-component',

 template:`

 Name:{{name}}

 `

})

class MyComponent {

 name:string;

 constructor(private http:Http){

  this.http.get('/names/1')

   .map(res => res.json())

   .subscribe(data => this.name = data.name);

 }

}

最后,我们还可以用计时器来触发变化:

@Component({

 selector:'my-component',

 template:`

 Name:{{name}}

 `

})

class MyComponent {

 name:string;

 constructor(){

  setTimeout(()=> this.name = 'Felipe', 2000);

 }

}

但是Angular要如何察觉到这些变化呢?

首先要知道的是,每个组件都有自己的变更检测器。

就像我们之前看到的,一个典型的应用有很多组件,组件之间会进行交互,从而创建一个如图14-32所示的依赖关系树。

图14-32 组件树

对于树中的每个组件,都会创建一个变更检测器。因此,我们的变更检测器同样是一棵树(如图14-33所示)。

图14-33 变更检测器树

当一个组件发生变更时,无论它在树的什么位置,都会触发树中的所有变更检测器。这是因为Angular会从顶部节点开始,一直扫描到树的叶子节点(如图14-34所示)。

图14-34 默认的变更检测方式

在上面的图中,深灰色的组件发生了变化。但是,正如我们所见,它触发了整棵组件树中的检查。被检查的组件用浅灰色表示(注意,引起变化的组件本身也被检查了)。

直觉上,你可能会认为这种方式的开销非常大;然而实际上,由于经过大量的优化(这使得Angular代码可以被JavaScript引擎进一步优化),它的速度快得惊人。

14.7.1 自定义变更检测

有时,默认的变更检测机制可能有些大材小用。比如,你可能使用了不可改变对象或者应用的数据架构是依赖可观察对象的。在这些场景下,Angular提供了可以自定义变更检测系统的机制,可以使检测变得非常快。

修改变更检测器行为的第一种方式是告诉组件:只有当它的输入属性值发生改变时才需要去检查。

简单来说,输入属性值就是组件从外部接收的属性。比如,在这段代码中:

class Person {

 constructor(public name:string, public age:string){}

}

@Component({

 selector:'mycomp',

 template:`

  <div>

   <span class="name">{person.name}</span>

   is {person.age} years old.

  </div>

 `

})

class MyComp {

 @Input()person:Person;

}

我们有一个输入属性person。现在,如果只想在输入属性发生变化时才让组件改变,只要修改变更检测策略,把changeDetection设置成ChangeDetectionStrategy.OnPush就可以了。

 顺便一提,changeDetection的默认值是ChangeDetectionStrategy.Default。

我们写两个组件来做个小实验。第一个组件使用默认的变更检测行为,而另外一个组件使用OnPush策略。

code/advanced_components/app/ts/change-detection/onpush.ts

import {

 Component,

 Input,

 ChangeDetectionStrategy,

} from '@angular/core';

class Profile {

 constructor(private first:string, private last:string){}

 lastChanged(){

  return new Date();

 }

}

我们先导入一些东西,然后声明Person类。Person类会作为这两个组件的输入属性。注意,我们还在Profile类中创建了一个lastChange()方法。这个方法非常有用,可以决定何时触发变更检测。当把一个给定的组件标记为需要检查时,这个方法就会被调用,然后呈现在模板中。因此,该方法可以准确地表明组件的最后检查时间。

接下来,我们声明了DefaultCmp组件,它将使用默认变更检测策略。

code/advanced_components/app/ts/change-detection/onpush.ts

@Component({

 selector:'default',

 template:`

 <h4 class="ui horizontal divider header">

  Default Strategy

 </h4>

 <form class="ui form">

  <div class="field">

   <label>First Name</label>

   <input

    type="text"

    [(ngModel)]="profile.first"

    name="first"

    placeholder="First Name">

  </div>

  <div class="field">

    <label>Last Name</label>

    <input

     type="text"

     [(ngModel)]="profile.last"

     name="last"

     placeholder="Last Name">

  </div>

 </form>

 <div>

  {{profile.lastChanged()| date:'medium'}}

 </div>

 `

})

export class DefaultCmp {

 @Input()profile:Profile;

}

第二个组件使用OnPush策略。

code/advanced_components/app/ts/change-detection/onpush.ts

@Component({

 selector:'on-push',

 changeDetection:ChangeDetectionStrategy.OnPush,

 template:`

 <h4 class="ui horizontal divider header">

  OnPush Strategy

 </h4>

  <form class="ui form">

   <div class="field">

    <label>First Name</label>

    <input

     type="text"

     [(ngModel)]="profile.first"

     name="first"

     placeholder="First Name">

   </div>

   <div class="field">

    <label>Last Name</label>

    <input

     type="text"

     [(ngModel)]="profile.last"

     name="last"

     placeholder="Last Name">

   </div>

  </form>

  <div>

   {{profile.lastChanged()| date:'medium'}}

  </div>

 `

})

export class OnPushCmp {

 @Input()profile:Profile;

}

正如我们所见,两个组件使用相同的模板。唯一不同的就是注解中的变更检测策略。

最后,我们添加一个组件来并排渲染两个组件。

code/advanced_components/app/ts/change-detection/onpush.ts

@Component({

 selector:'change-detection-sample-app',

 template:`

 <div class="ui page grid">

  <div class="two column row">

   <div class="column area">

    <default [profile]="profile1"></default>

   </div>

   <div class="column area">

    <on-push [profile]="profile2"></on-push>

   </div>

  </div>

 </div>

 `

})

export class OnPushChangeDetectionSampleApp {

 profile1:Profile = new Profile('Felipe', 'Coury');

 profile2:Profile = new Profile('Nate', 'Murray');

}

运行这个应用时,我们会看到两个组件如图14-35这样被渲染出来。

图14-35 默认策略与OnPush策略

当我们更改左边的组件(使用默认策略)时,可以注意到右边组件的时间戳并没有发生改变,如图14-36所示。

图14-36 默认的组件变化时,OnPush的组件不会检查

要理解为何如此,我们来检查下这个新的组件树(如图14-37所示)。

图14-37 新组件树

Angular对于变化的检查是自上而下的,所以首先查询的是ChangeDetectionSampleApp,然后是DefaultCmp,最后是OnPushCmp。当它推测出OnPushCmp发生变化时,就会自上而下地更新组件树中的所有组件,这会导致重新渲染DefaultCmp。

当我们改变右边组件的值时,如图14-38所示。

图14-38 OnPush的组件变化时,默认的组件也会检查

变更检测引擎生效后,只检查了DefaultCmp组件而没有检查OnPushCmp。这是因为当我们为组件设置了OnPush策略时,只有它自己的输入发生变化时才执行检测。改变组件树中的其他组件时并不会触发这个组件变更检测器。

14.7.2 Zones

在底层,Angular使用了一个名叫Zones的类库,它可以自动检测变化并触发变更检测机制。在一些最常见的情景下,Zones会自动告诉Angular发生了某些变化:

●当DOM事件发生时(比如click、change等);

●当HTTP请求完成时;

●当定时器被触发时(setTimeout或setInterval)。

然而,还有一些场景是Zones无法自动识别出变化的。在这些场景下,OnPush策略就会变得非常有用。

下面是一些Zones无法掌控的例子:

●使用异步方式运行第三方类库;

●不可变的数据;

●可观察对象。

在这些情况下,非常适合通过OnPush以及一点小技巧去手动提示Angular有东西发生了变化。

14.7.3 可观察对象和OnPush

我们来编写一个组件,它接收一个可观察对象作为参数。每当从这个可观察对象中接收到值时,我们就会增加组件的计数器属性。

如果使用常规的变更检测策略,那么只要我们增加计数,Angular就会触发变更检测。然而,这个组件将使用OnPush策略,只有当计数是5的倍数或者可观察对象完成时,我们才让变更检测器生效,而不是每次增加计数时都触发变更检测器。

要做到这一点,我们来写个组件。

code/advanced_components/app/ts/change-detection/observables.ts

import {

 Component,

 Input,

 ChangeDetectorRef,

 ChangeDetectionStrategy

} from '@angular/core';

import { Observable } from 'rxjs/Rx';

@Component({

 selector:'observable',

 changeDetection:ChangeDetectionStrategy.OnPush,

 template:`

 <div>

  <div>Total items:{{counter}}</div>

 </div>

 `

})

export class ObservableCmp {

 @Input()items:Observable<number>;

 counter = 0;

 constructor(private changeDetector:ChangeDetectorRef){

 }

 ngOnInit(){

  this.items.subscribe((v)=> {

   console.log('got value', v);

   this.counter++;

   if(this.counter % 5 == 0){

    this.changeDetector.markForCheck();

   }

  },

  null,

  ()=> {

   this.changeDetector.markForCheck();

  });

 }

}

我们将代码分解来看,以确保理解正确。首先,我们声明该组件接收items作为输入属性并使用OnPush作为变更检测策略。

code/advanced_components/app/ts/change-detection/observables.ts

@Component({

 selector:'observable',

 changeDetection:ChangeDetectionStrategy.OnPush,

 template:`

 <div>

  <div>Total items:{{counter}}</div>

 </div>

 `

})

接下来,我们把输入属性存储在组件类的items属性中,然后设置另一个属性counter为0。

code/advanced_components/app/ts/change-detection/observables.ts

export class ObservableCmp {

 @Input()items:Observable<number>;

 counter = 0;

然后,我们使用构造函数来取得组件的变更检测器。

code/advanced_components/app/ts/change-detection/observables.ts

 constructor(private changeDetector:ChangeDetectorRef){

 }

然后,当组件初始化时,在ngOnInit钩子中如下所示。

code/advanced_components/app/ts/change-detection/observables.ts

 ngOnInit(){

  this.items.subscribe((v)=> {

   console.log('got value', v);

   this.counter++;

   if(this.counter % 5 == 0){

    this.changeDetector.markForCheck();

   }

  },

  null,

  ()=> {

   this.changeDetector.markForCheck();

  });

 }

我们订阅了可观察对象。subscribe方法接收三个回调函数:onNext、onError和onCompleted。

onNext回调函数会打印出我们得到的值,然后增加计数。最后,如果当前计数器的值是5的倍数,我们就调用变更检测器的markForCheck方法。只要我们想告诉Angular已经发生了变化,就可以使用这个方法,从而使变更检测器生效。

对于onError回调函数,我们传入了null。这表示我们不想处理这个场景。

最后,对于onComplete回调函数,我们同样触发了变更检测器,所以最终的计数器可以被显示出来。

现在来看应用组件的代码。它会创建订阅者。

code/advanced_components/app/ts/change-detection/observables.ts

@Component({

 selector:'change-detection-sample-app',

 template:`

 <observable [items]="itemObservable"></observable>

 `

})

export class ObservableChangeDetectionSampleApp {

 itemObservable:Observable<number>;

 constructor(){

  this.itemObservable = Observable.timer(100, 100).take(101);

 }

}

下面这行代码很重要:

this.itemObservable = Observable.timer(100, 100).take(101);

这一行创建了一个可观察对象,我们会通过items输入属性将这个可观察对象传递进组件。timer方法有两个参数:第一个是等待的毫秒数,第二个是间隔的毫秒数。因此,这个可观察对象会创建一系列的值。

因为我们不需要一直创建下去,所以使用了take函数,只获取前101个值。

当我们运行这段代码时,会发现每取到5个值才会更新一次计数器,并且生成了一个最终值101(如图14-39所示)。

图14-39 手动触发变更检测

14.8 总结

Angular为我们提供了许多可以用来编写高级组件的工具。使用本章的这些技术,你几乎能写出任何想要的组件功能。

然而,在高级组件中还有一个重要的概念,那就是依赖注入。

使用依赖注入,我们可以让组件和系统中的很多其他部分挂接起来。第8章详细讨论了什么是依赖注入,如何在应用中使用它,以及注入服务的常用模式。

  1. http://semantic-ui.com/modules/tab.html#/examples
  2. https://angular.io/docs/ts/latest/guide/lifecycle-hooks.html