Angular-学习手册第二版-二-

44 阅读24分钟

Angular 学习手册第二版(二)

原文:zh.annas-archive.org/md5/6C06861E49CB1AD699C8CFF7BAC7E048

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:通过管道和指令增强我们的组件

在之前的章节中,我们构建了几个组件,借助输入和输出属性在屏幕上呈现数据。我们将利用本章的知识,通过使用指令和管道,将我们的组件提升到一个新的水平。简而言之,管道为我们提供了在模板中绑定的信息进行解析和转换的机会,而指令允许我们进行更有野心的功能,我们可以访问宿主元素的属性,并绑定我们自己的自定义事件监听器和数据绑定。

在本章中,我们将:

  • 全面了解 Angular 的内置指令

  • 探讨如何使用管道来优化我们的数据输出

  • 看看如何设计和构建我们自己的自定义管道和指令

  • 利用内置对象来操作我们的模板

  • 将所有前述主题和更多内容付诸实践,以构建一个完全交互式的待办事项表

Angular 中的指令

Angular 将指令定义为没有视图的组件。事实上,组件是具有关联模板视图的指令。之所以使用这种区别,是因为指令是 Angular 核心的一个重要部分,每个指令(普通指令和组件指令)都需要另一个存在。指令基本上可以影响 HTML 元素或自定义元素的行为和显示其内容。

核心指令

让我们仔细研究一下框架的核心指令,然后您将在本章后面学习如何构建自己的指令。

NgIf

正如官方文档所述,ngIf指令根据表达式删除或重新创建 DOM 树的一部分。如果分配给ngIf指令的表达式求值为false,则该元素将从 DOM 中移除。否则,元素的克隆将重新插入 DOM 中。我们可以通过利用这个指令来增强我们的倒计时器,就像这样:

<timer> [seconds]="timeout"></timer>
<p *ngIf="timeout === 0">Time up!</p>

当我们的计时器达到 0 时,将在屏幕上呈现显示“时间到!”文本的段落。您可能已经注意到了在指令前面加上的星号。这是因为 Angular 将标有ngIf指令的 HTML 控件(以及其所有 HTML 子树,如果有的话)嵌入到<ng-template>标记中,稍后将用于在屏幕上呈现内容。涵盖 Angular 如何处理模板显然超出了本书的范围,但让我们指出,这是 Angular 提供的一种语法糖,作为其他更冗长的基于模板标记的语法的快捷方式。

也许您想知道使用*ngIf="conditional"在屏幕上呈现一些 HTML 片段与使用[hidden]="conditional"有什么区别。前者将克隆并注入模板化的 HTML 片段到标记中,在条件评估为false时从 DOM 中删除它,而后者不会从 DOM 中注入或删除任何标记。它只是设置带有该 DOM 属性的已存在的 HTML 片段的可见性。

NgFor

ngFor指令允许我们遍历集合(或任何其他可迭代对象),并将其每个项目绑定到我们选择的模板,我们可以在其中定义方便的占位符来插入项目数据。每个实例化的模板都作用域限定在外部上下文中,我们可以访问其他绑定。假设我们有一个名为Staff的组件:它具有一个名为 employees 的字段,表示一个Employee对象数组。我们可以这样列出这些员工和职位:

<ul>
 <li *ngFor="let employee of employees">
 Employee {{ employee.name }}, {{ employee.position }}
 </li>
</ul>

正如我们在提供的示例中看到的,我们将从每次循环中获取的可迭代对象中的每个项目转换为本地引用,以便我们可以轻松地在我们的模板中绑定这个项目。需要强调的是,表达式以关键字let开头。

该指令观察底层可迭代对象的更改,并将根据项目在集合中添加、删除或重新排序而添加、删除或排序呈现的模板。

高级循环

除了只循环列表中的所有项目之外,还可以跟踪其他可用属性。每个属性都可以通过在声明项目后添加另一个语句来使用:

<div *ngFor="let items of items; let property = property">{{ item }}</div>

First/last,这是一个布尔值,用于跟踪我们是否在循环中的第一个或最后一个项目上,如果我们想要以不同的方式呈现该项目。可以通过以下方式访问它:

<div *ngFor="let item of items; let first = first">
 <span [ngClass]="{ 'first-css-class': first, 'item-css-class' : !first }">
 {{ item }}
 </span>
</div>

Index,是一个数字,告诉我们我们在哪个索引上;它从 0 开始。

Even/odd是一个布尔值,指示我们是否在偶数或奇数索引上。

TrackBy,要解释trackBy做什么,让我们首先谈谈它试图解决的问题。问题是,*ngFor指向的数据可能会发生变化,元素可能会被添加或删除,甚至整个列表可能会被替换。对于添加/删除元素的天真方法是对所有这些元素在 DOM 树上进行创建/删除。如果使用相同的天真方法来显示新列表而不是我们用来显示这个旧列表,那将是非常昂贵和缓慢的。Angular 通过将 DOM 元素保存在内存中来处理这个问题,因为创建是昂贵的。在内部,Angular 使用称为对象标识的东西来跟踪列表中的每个项目。然而,trackBy允许您从对象标识更改为项目上的特定属性。默认的对象标识在大多数情况下都很好,但是如果您开始遇到性能问题,请考虑更改*ngFor应查看的项目的属性,如下所示:

@Component({
 template : `
 <*ngFor="let item of items; trackBy: trackFunction">{{ item }}</div>
 `
})
export class SomeComponent {
 trackFunction(index, item) {
 return item ? item.id : undefined;
 }
}

Else

Else 是 Angular 4.0 的一个新构造,并且是一个简写,可以帮助您处理条件语句。想象一下,如果您有以下内容:

<div *ngIf="hero">
 {{ hero.name }}
</div>
<div *ngIf="!hero">
 No hero set
</div>

我们在这里的用例非常清楚;如果我们设置了一个人,那么显示它的名字,否则显示默认文本。我们可以使用else以另一种方式编写这个:

<div *ngIf="person; else noperson">{{person.name}}</div>
<div #noperson>No person set</div>

这里发生的是我们如何定义我们的条件:

person; else noperson

我们说如果person已设置,那么继续,如果没有显示模板nopersonnoperson也可以应用于普通的 HTML 元素以及ng-template

应用样式

在您的标记中应用样式有三种方法:

  • 插值

  • NgStyle

  • NgClass

插值

这个版本是关于使用花括号并让它们解析应该应用什么类/类。您可以编写一个看起来像这样的表达式:

<div class="item {{ item.selected ? 'selected' : ''}}"

这意味着如果您的项目具有选定的属性,则应用 CSS 类 selected,否则应用空字符串,即没有类。虽然在许多情况下这可能足够,但它也有缺点,特别是如果需要应用多个样式,因为有多个需要检查的条件。

插值表达式在性能方面被认为是昂贵的,通常是不鼓励使用的。

NgStyle

正如你可能已经猜到的那样,这个指令允许我们通过评估自定义对象或表达式来绑定 CSS 样式。我们可以绑定一个对象,其键和值映射 CSS 属性,或者只定义特定属性并将数据绑定到它们:

<p [ngStyle]="{ 'color': myColor, 'font-weight': myFontWeight }">
 I am red and bold
</p>

如果我们的组件定义了myColormyFontWeight属性,分别具有redbold的值,那么文本的颜色和粗细将相应地改变。该指令将始终反映组件内所做的更改,我们还可以传递一个对象,而不是按属性基础绑定数据:

<p [ngStyle]="myCssConfig">I am red and bold</p>

NgClass

ngStyle类似,ngClass允许我们以一种方便的声明性语法在 DOM 元素中定义和切换类名。然而,这种语法有其自己的复杂性。让我们看看这个例子中可用的三种情况:

<p [ngClass]="{{myClassNames}}">Hello Angular!</p>

例如,我们可以使用字符串类型,这样如果myClassNames包含一个由空格分隔的一个或多个类的字符串,所有这些类都将绑定到段落上。

我们也可以使用数组,这样每个元素都会被添加。

最后但同样重要的是,我们可以使用一个对象,其中每个键对应于由布尔值引用的 CSS 类名。标记为true的每个键名将成为一个活动类。否则,它将被移除。这通常是处理类名的首选方式。

ngClass还有一种替代语法,格式如下:

[ngClass]="{ 'class' : boolean-condition, 'class2' : boolean-condition-two }"

简而言之,这是一个逗号分隔的版本,在条件为true时将应用一个类。如果有多个条件为true,则可以应用多个类。如果在更现实的场景中使用,它会看起来像这样:

<span [ngClass] ="{
 'light' : jedi.side === 'Light',
 'dark' : jedi.side === 'Dark'
}">
{{ jedi.name }}
</span>

生成的标记可能如下,如果jedi.side的值为light,则将 CSS 类 light 添加到 span 元素中:

<span class="light">Luke</span>

NgSwitch、ngSwitchCase 和 ngSwitchDefault

ngSwitch指令用于根据显示每个模板所需的条件在特定集合内切换模板。实现遵循几个步骤,因此在本节中解释了三个不同的指令。

ngSwitch将评估给定的表达式,然后切换和显示那些带有ngSwitchCase属性指令的子元素,其值与父ngSwitch元素中定义的表达式抛出的值匹配。需要特别提到带有ngSwitchDefault指令属性的子元素。该属性限定了当其ngSwitchCase兄弟元素定义的任何其他值都不匹配父条件表达式时将显示的模板。

我们将在一个例子中看到所有这些:

<div [ngSwitch]="weatherForecaseDay">
 <ng-template ngSwitchCase="today">{{weatherToday}}</ng-template>
 <ng-template ngSwitchCase="tomorrow">{{weatherTomorrow}}</ng-template>
 <ng-template ngSwitchDefault>
 Pick a day to see the weather forecast
 <ng-template>
</div>

[ngSwitch]参数评估weatherForecastDay上下文变量,每个嵌套的ngSwitchCase指令将针对其进行测试。我们可以使用表达式,但我们希望将ngSwitchCase包装在括号中,以便 Angular 可以正确地将其内容评估为上下文变量,而不是将其视为文本字符串。

NgPluralNgPluralCase的覆盖范围超出了本书的范围,但基本上提供了一种方便的方法来呈现或删除与开关表达式匹配的模板 DOM 块,无论是严格的数字还是字符串,类似于ngSwitchngSwitchWhen指令的方式。

使用管道操作模板绑定

因此,我们看到了如何使用指令根据我们的组件类管理的数据来呈现内容,但是还有另一个强大的功能,我们将在日常实践中充分利用 Angular。我们正在谈论管道。

管道允许我们在视图级别过滤和引导我们表达式的结果,以转换或更好地显示我们绑定的数据。它们的语法非常简单,基本上由管道符号分隔的要转换的表达式后面跟着管道名称(因此得名):

@Component({
 selector: 'greeting',
 template: 'Hello {{ name | uppercase }}'
})
export class GreetingComponent{ name: string; }

在前面的例子中,我们在屏幕上显示了一个大写的问候语。由于我们不知道名字是大写还是小写,所以我们通过在视图级别转换名称的值来确保一致的输出。管道是可链式的,Angular 已经内置了各种管道类型。正如我们将在本章中进一步看到的,我们还可以构建自己的管道,以在内置管道不足以满足需求的情况下对数据输出进行精细调整。

大写/小写管道

大写/小写管道的名称就是它的含义。就像之前提供的示例一样,这个管道可以将字符串输出设置为大写或小写。在视图中的任何位置插入以下代码,然后自行检查输出:

<p>{{ 'hello world' | uppercase}}</p>  // outputs HELLO WORLD
<p>{{ 'wEIrD hElLo' | lowercase}}</p>  // outputs weird hello

小数、百分比和货币管道

数值数据可以有各种各样的类型,当涉及到更好的格式化和本地化输出时,这个管道特别方便。这些管道使用国际化 API,因此只在 Chrome 和 Opera 浏览器中可靠。

小数管道

小数管道将帮助我们使用浏览器中的活动区域设置定义数字的分组和大小。其格式如下:

number_expression | number[:digitInfo[:locale]]

在这里,number_expression是一个数字,digitInfo的格式如下:

{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}

每个绑定对应以下内容:

  • minIntegerDigits:要使用的整数位数的最小数字。默认为 1。

  • minFractionDigits:分数后的最小数字位数。默认为 0。

  • maxFractionDigits:分数后的最大数字位数。默认为 3。

请记住,每个数字和其他细节的可接受范围将取决于您的本地国际化实现。让我们尝试通过创建以下组件来解释这是如何工作的:

import { Component, OnInit } from  '@angular/core'; @Component({ selector:  'pipe-demo', template: ` <div>{{ no  |  number }}</div>   <!-- 3.141 --> <div>{{ no  |  number:'2.1-5' }}</div> <! -- 03.14114 --> <div>{{ no  |  number:'7.1-5' }}</div> <!-- 0,000,003.14114 -->
 <div>{{ no  |  number:'7.1-5':'sv' }}</div> <!-- 0 000 003,14114 -->
 ` }) export  class  PipeDemoComponent { no:  number  =  3.1411434344; constructor() { } }

这里有一个四种不同表达式的示例,展示了我们如何操作数字、分数以及区域设置。在第一种情况下,我们除了使用number管道之外没有给出任何指令。在第二个示例中,我们指定了要显示的小数位数和数字,通过输入number: '2.1-5'。这意味着我们在分数标记的左侧显示两个数字,右侧显示 5 个数字。因为左侧只有 3 个数字,我们需要用零来填充。右侧我们只显示 5 位小数。在第三个示例中,我们指示它显示 7 个数字在分数标记的左侧,右侧显示 5 个数字。这意味着我们需要在左侧填充 6 个零。这也意味着千位分隔符被添加了。我们的第四个示例演示了区域设置功能。我们看到显示的结果是千位分隔符的空格字符,小数点的逗号。

不过有一件事要记住;要使区域设置起作用,我们需要在根模块中安装正确的区域设置。原因是 Angular 只有从一开始就设置了 en-US 区域设置。不过添加更多区域设置非常容易。我们需要将以下代码添加到app.module.ts中:

import { BrowserModule } from  '@angular/platform-browser'; import { NgModule } from  '@angular/core'; import { AppComponent } from  './app.component'; import { PipeDemoComponent } from  "./pipe.demo.component"; 
import { registerLocaleData } from  '@angular/common'; import localeSV from '@angular/common/locales/sv'; 
registerLocaleData(localeSV**);** 
@NgModule({
  declarations: [ AppComponent, PipeDemoComponent ],
 imports: [ BrowserModule
 ],
 providers: [], bootstrap: [AppComponent] })
export  class  AppModule { }

百分比管道

百分比管道将数字格式化为本地百分比。除此之外,它继承自数字管道,以便我们可以进一步格式化输出,以提供更好的整数和小数大小和分组。它的语法如下:

number_expression | percent[:digitInfo[:locale]]

货币管道

这个管道将数字格式化为本地货币,支持选择货币代码,如美元的 USD 或欧元的 EUR,并设置我们希望货币信息显示的方式。它的语法如下:

number_expression | currency[:currencyCode[:display[:digitInfo[:locale]]]]

在前面的语句中,currencyCode显然是 ISO 4217 货币代码,而display是一个字符串

可以是code,假设值为symbolsymbol-narrow。值symbol-narrow指示是否使用货币符号(例如,$)。值symbol指示在输出中使用货币代码(例如 USD)。与小数和百分比管道类似,我们可以通过digitInfo值格式化输出,还可以根据区域设置格式化。

在下面的示例中,我们演示了所有三种形式:

import { Component, OnInit } from  '@angular/core'; 
@Component({ selector:  'currency-demo', template: ` <p>{{ 11256.569  |  currency:"SEK":'symbol-narrow':'4.1-2' }}</p> <!--kr11,256.57 --> <p>{{ 11256.569  |  currency:"SEK":'symbol':'4.1-3' }}</p> <!--SEK11,256.569 --> <p>{{ 11256.569  |  currency:"SEK":'code' }}</p> <!--SEK11,256.57 --> `
})
export  class  CurrencyDemoComponent { constructor() { } }  

切片管道

这个管道的目的相当于Array.prototype.slice()String.prototype.slice()在减去集合列表、数组或字符串的子集(切片)时所起的作用。它的语法非常简单,遵循与前述slice()方法相同的约定:

expression | slice: start[:end]

基本上,我们配置一个起始索引,我们将从中开始切片项目数组或字符串的可选结束索引,当省略时,它将回退到输入的最后索引。

开始和结束参数都可以取正值和负值,就像 JavaScript 的slice()方法一样。请参考 JavaScript API 文档,了解所有可用场景的详细情况。

最后但并非最不重要的是,请注意,在操作集合时,返回的列表始终是副本,即使所有元素都被返回。

日期管道

你一定已经猜到了,日期管道根据请求的格式将日期值格式化为字符串。格式化输出的时区将是最终用户机器的本地系统时区。它的语法非常简单:

date_expression | date[:format[:timezone[:locale]]]

表达式输入必须是一个日期对象或一个数字(自 UTC 纪元以来的毫秒数)。格式参数是高度可定制的,并接受基于日期时间符号的各种变化。为了我们的方便,一些别名已经被提供为最常见的日期格式的快捷方式:

  • '中等':这相当于'yMMMdjms'(例如,对于 en-US,Sep 3, 2010, 12:05:08 PM)

  • '短':这相当于'yMdjm'(例如,9/3/2010, 12:05 PM

对于 en-US)

  • 'fullDate':这相当于'yMMMMEEEEd'(例如,对于 en-US,Friday, September 3, 2010)

  • '长日期':这相当于'yMMMMd'(例如,September 3, 2010)

  • '中等日期':这相当于'yMMMd'(例如,对于 en-US,Sep 3, 2010)

  • '短日期':这相当于'yMd'(例如,对于 en-US,9/3/2010)

  • '中等时间':这相当于'jms'(例如,对于 en-US,12:05:08 PM)

  • '短时间':这相当于'jm'(例如,对于 en-US,12:05 PM)

  • json 管道

JSON 管道

JSON 可能是定义中最直接的管道;它基本上以对象作为输入,并以 JSON 格式输出它:

import { Component } from  '@angular/core'; 
@Component({
  selector:  'json-demo', template: ` {{ person | json **}}** 
 **<!--{ "name": "chris", "age": 38, "address": { "street": "Oxford Street", "city": "London" }** } --> `
})
export  class  JsonDemoComponent { person  = { name:  'chris', age:  38, address: { street:  'Oxford Street', city:  'London' }
 }

 constructor() { } }  

使用 Json 管道的输出如下:{ "name": "chris", "age": 38, "address": { "street": "Oxford Street", "city": "London" } }。这表明管道已将单引号转换为双引号,从而生成有效的 JSON。那么,我们为什么需要这个?一个原因是调试;这是一个很好的方式来查看复杂对象包含什么,并将其漂亮地打印到屏幕上。正如您从前面的字段'person'中看到的,它包含一些简单的属性,但也包含复杂的'address'属性。对象越深,json 管道就越好。

i18n 管道

作为 Angular 对提供强大国际化工具集的坚定承诺的一部分,已经提供了一组针对常见 i18n 用例的管道。本书将只涵盖两个主要的管道,但很可能在将来会发布更多的管道。请在完成本章后参考官方文档以获取更多信息。

i18nPlural 管道

i18nPlural管道有一个简单的用法,我们只需评估一个数字值与一个对象映射不同的字符串值,根据评估的结果返回不同的字符串。这样,我们可以根据数字值是零、一、二、大于N等不同的情况在我们的模板上呈现不同的字符串。语法如下:

expression | i18nPlural:mapping[:locale]

让我们看看这在你的组件类上的一个数字字段jedis上是什么样子的:

<h1> {{ jedis | i18nPlural:jediWarningMapping }} </h1>

然后,我们可以将这个映射作为我们组件控制器类的一个字段:

export class i18DemoComponent {
 jedis: number = 11;
 jediWarningMapping: any = {
 '=0': 'No jedis',
 '=1' : 'One jedi present',
 'other' : '# jedis in sight'
 }
}

我们甚至通过在字符串映射中引入'#'占位符来绑定表达式中评估的数字值。当找不到匹配的值时,管道将回退到使用键'other'设置的映射。

i18nSelect 管道

i18nSelect管道类似于i18nPlural管道,但它评估的是一个字符串值。这个管道非常适合本地化文本插值或根据状态变化提供不同的标签,例如。例如,我们可以回顾一下我们的计时器,并以不同的语言提供 UI:

<button (click)="togglePause()">
 {{ languageCode | i18nSelect:localizedLabelsMap }}
</button>

在我们的控制器类中,我们可以填充localizedLabelsMap,如下所示:

export class TimerComponent {
 languageCode: string ='fr';
 localizedLabelsMap: any = {
 'en' : 'Start timer',
 'es' : 'Comenzar temporizador',
 'fr' : 'Demarrer une sequence',
 'other' : 'Start timer' 
 }
}

重要的是要注意,我们可以在除了本地化组件之外的用例中使用这个方便的管道,而是根据映射键和类似的东西提供字符串绑定。与i18nPlural管道一样,当找不到匹配的值时,管道将回退到使用'other'键设置的映射。

异步管道

有时,我们管理可观察数据或仅由组件类异步处理的数据,并且我们需要确保我们的视图及时反映信息的变化,一旦可观察字段发生变化或异步加载在视图渲染后完成。异步管道订阅一个可观察对象或承诺,并返回它发出的最新值。当发出新值时,异步管道标记组件以检查更改。我们将在第七章中返回这个概念,使用 Angular 进行异步数据服务

将所有内容放在任务列表中

现在你已经学会了所有的元素,可以让你构建完整的组件,是时候把所有这些新知识付诸实践了。在接下来的页面中,我们将构建一个简单的任务列表管理器。在其中,我们将看到一个包含我们需要构建的待办事项的任务表。

我们还将直接从可用任务的积压队列中排队任务。这将有助于显示完成所有排队任务所需的时间,并查看我们工作议程中定义了多少任务。

设置我们的主 HTML 容器

在构建实际组件之前,我们需要先设置好我们的工作环境,为此,我们将重用在上一个组件中使用的相同的 HTML 样板文件。请将您迄今为止所做的工作放在一边,并保留我们在以前的示例中使用的package.jsontsconfig.jsontypings.jsonindex.html文件。如果需要的话,随时重新安装所需的模块,并替换我们index.html模板中的 body 标签的内容:

<nav class="navbar navbar-default navbar-static-top">
 <div class="container">
 <div class="navbar-header">
 <strong class="navbar-brand">My Tasks</strong>
 </div>
 </div>
</nav>
<tasks></tasks>

简而言之,我们刚刚更新了位于我们新的<tasks>自定义元素上方的标题布局的标题,该元素替换了以前的<timer>。您可能希望更新app.module.ts文件,并确保将任务作为一个可以在我们模块之外可见的组件,输入到exports关键数组中:

@NgModule({
  declarations : [ TasksComponent ],
 imports : [ ],
 providers : [],
  exports : [ TasksComponent ]
})
export class TaskModule{}

让我们在这里强调一下,到目前为止,应用程序有两个模块:我们的根模块称为AppModule和我们的TaskModule。我们的根模块应该像这样导入我们的TaskModule

@NgModule({
 imports : [
 BrowserModule,
    TaskModule
 ]
})
export class AppModule {}

使用 Angular 指令构建我们的任务列表表格

创建一个空的 tasks.ts 文件。您可能希望使用这个新创建的文件从头开始构建我们的新组件,并在其中嵌入我们将在本章后面看到的所有伴随管道、指令和组件的定义。

现实生活中的项目从未以这种方式实现,因为我们的代码必须符合“一个类,一个文件”的原则,利用 ECMAScript 模块将事物粘合在一起。第六章,使用 Angular 组件构建应用程序,将向您介绍构建 Angular 应用程序的一套常见最佳实践,包括组织目录树和不同元素(组件、指令、管道、服务等)的可持续方式。相反,本章将利用tasks.ts将所有代码包含在一个中心位置,然后提供我们现在将涵盖的所有主题的鸟瞰视图,而无需在文件之间切换。请记住,这实际上是一种反模式,但出于教学目的,我们将在本章中最后一次采用这种方法。文件中声明元素的顺序很重要。如果出现异常,请参考 GitHub 中的代码存储库。

在继续我们的组件之前,我们需要导入所需的依赖项,规范我们将用于填充表格的数据模型,然后搭建一些数据,这些数据将由一个方便的服务类提供。

让我们首先在我们的tasks.ts文件中添加以下代码块,导入我们在本章中将需要的所有标记。特别注意我们从 Angular 库中导入的标记。我们已经介绍了组件和输入,但其余的内容将在本章后面进行解释:

import { 
 Component,
 Input,
 Pipe,
 PipeTransform,
 Directive,
 OnInit,
 HostListener
 } from '@angular/core';

已经导入了依赖标记,让我们在导入的代码块旁边定义我们任务的数据模型:

/// Model interface
interface Task {
 name: string;
 deadline: Date;
 queued: boolean;
 hoursLeft: number;
}

Task模型接口的架构非常容易理解。每个任务都有一个名称,一个截止日期,一个字段用于通知需要运送多少单位,以及一个名为queued的布尔字段,用于定义该任务是否已被标记为在下一个会话中完成。

您可能会惊讶我们使用接口而不是类来定义模型实体,但当实体模型不需要实现方法或在构造函数或 setter/getter 函数中进行数据转换时,这是完全可以的。当后者不需要时,接口就足够了,因为它以简单且更轻量的方式提供了我们需要的静态类型。

现在,我们需要一些数据和一个服务包装类,以集合Task对象的形式提供这样的数据。在这里定义的TaskService类将起到作用,因此请在Task接口之后立即将其附加到您的代码中:

/// Local Data Service
class TaskService {
 public taskStore: Array<Task> = [];
 constructor() {
 const tasks = [
 {
 name : 'Code and HTML table',
 deadline : 'Jun 23 2015',
 hoursLeft : 1
 }, 
 {
 name : 'Sketch a wireframe for the new homepage',
 deadline : 'Jun 24 2016',
 hoursLeft : 2
 }, 
 {
 name : 'Style table with bootstrap styles',
 deadline : 'Jun 25 2016',
 hoursLeft : 1
 }
 ];

 this.taskStore = tasks.map( task => {
 return {
 name : task.name,
 deadline : new Date(task.deadline),
 queued : false,
 hoursLeft : task.hoursLeft 
 };
 })
 }
}

这个数据存储相当简单明了:它公开了一个taskStore属性,返回一个符合Task接口的对象数组(因此受益于静态类型),其中包含有关名称、截止日期和时间估计的信息。

现在我们有了一个数据存储和一个模型类,我们可以开始构建一个 Angular 组件,该组件将使用这个数据源来呈现我们模板视图中的任务。在您之前编写的代码之后插入以下组件实现:

/// Component classes
// - Main Parent Component
@Component({
 selector : 'tasks',
 styleUrls : ['tasks.css'],
 templateUrl : 'tasks.html'
})
export class TaskComponent {
 today: Date;
 tasks: Task[];
 constructor() {
 const TasksService: TaskService = new TasksService();
 this.tasks = tasksService.taskStore;
 this.today = new Date();
 }
}

正如您所见,我们通过引导函数定义并实例化了一个名为TasksComponent的新组件,选择器为<tasks>(我们在填充主index.html文件时已经包含了它,记得吗?)。这个类公开了两个属性:今天的日期和一个任务集合,它将在组件视图中的表中呈现,我们很快就会看到。为此,在其构造函数中实例化了我们之前创建的数据源,并将其映射到作为Task对象类型的模型数组,由任务字段表示。我们还使用 JavaScript 内置的Date对象的实例初始化了 today 属性,其中包含当前日期。

正如您所见,组件选择器与其控制器类命名不匹配。我们将在本章末深入探讨命名约定,作为第六章《使用 Angular 组件构建应用程序》的准备工作。

现在让我们创建样式表文件,其实现将非常简单明了。在我们的组件文件所在的位置创建一个名为tasks.css的新文件。然后,您可以使用以下样式规则填充它:

h3, p {
 text-align : center;
}

table {
 margin: auto;
 max-width: 760px;
}

这个新创建的样式表非常简单,以至于它可能看起来有点多余作为一个独立的文件。然而,在我们的示例中,这是展示组件元数据的styleUrls属性功能的好机会。

关于我们的 HTML 模板,情况大不相同。这一次,我们也不会在组件中硬编码我们的 HTML 模板,而是将其指向外部 HTML 文件,以更好地管理我们的呈现代码。请在与我们的主要组件控制器类相同的位置创建一个 HTML 文件,并将其保存为tasks.html。创建完成后,使用以下 HTML 片段填充它:

<div class="container text-center">
 <img src="assets/img/task.png" alt="Task" />
 <div class="container">
 <h4>Tasks backlog</h4>
 <table class="table">
 <thead>
 <tr>
 <th> Task ID</th>
 <th>Task name</th>
 <th>Deliver by</th>
 <th></th>
 <th>Actions</th>
 </tr>
 </thead>
 <tbody>
 <tr *ngFor="let task of tasks; let i = index">
 <th scope="row">{{i}}</th>
 <td>{{ task.name | slice:0:35 }}</td>
 <span [hidden]="task.name.length < 35">...</span>
 </td>
 <td>
 {{ task.deadline | date:'fullDate' }}
 <span *ngIf="task.deadline < today" 
 class="label label-danger">
 Due
 </span>
 </td>
 <td class="text-center">
 {{ task.hoursLeft }}
 </td>
 <td>[Future options...]</td>
 </tbody>
 </table>
</div> 

基本上,我们正在创建一个基于 Bootstrap 框架的具有整洁样式的表格。然后,我们使用始终方便的ngFor指令渲染所有任务,提取并显示我们在本章早些时候概述ngFor指令时解释的集合中每个项目的索引。

请看我们如何通过管道格式化任务名称和截止日期的输出,以及如何方便地显示(或不显示)省略号来指示文本是否超过了我们为名称分配的最大字符数,方法是将 HTML 隐藏属性转换为绑定到 Angular 表达式的属性。所有这些呈现逻辑都标有红色标签,指示给定任务是否在截止日期之前到期。

您可能已经注意到,这些操作按钮在我们当前的实现中不存在。我们将在下一节中修复这个问题,在我们的组件中玩转状态。回到第一章,在 Angular 中创建我们的第一个组件,我们提到了点击事件处理程序来停止和恢复倒计时,然后在第四章,在我们的组件中实现属性和事件中更深入地讨论了这个主题,我们涵盖了输出属性。让我们继续研究,看看我们如何将 DOM 事件处理程序与我们组件的公共方法连接起来,为我们的组件添加丰富的交互层。

在我们的任务列表中切换任务

将以下方法添加到您的TasksComponent控制器类中。它的功能非常基本;我们只是简单地切换给定Task对象实例的 queued 属性的值:

toggleTask(task: Task): void {
 task.queued = !task.queued;
}

现在,我们只需要将其与我们的视图按钮连接起来。更新我们的视图,包括在ngFor循环中创建的按钮中的点击属性(用大括号括起来,以便它充当输出属性)。现在,我们的Task对象将具有不同的状态,让我们通过一起实现ngSwitch结构来反映这一点:

<table class="table">
 <thead>
 <tr>
 <th>Task ID</th>
 <th>Task name</th>
 <th>Deliver by</th>
 <th>Units to ship</th>
 <th>Actions</th>
 </tr>
 </thead>
 <tbody>
 <tr *ngFor="let task of tasks; let i = index">
 <th scope="row">{{i}}
 <span *ngIf="task.queued" class="label label-info">Queued</span>
 </th>
 <td>{{task.name | slice:0:35}}
 <span [hidden]="task.name.length < 35">...</span>
 </td>
 <td>{{ task.deadline | date:'fullDate'}}
 <span *ngIf="task.deadline < today" class="label label-danger">Due</span>
 </td>
 <td class="text-center">{{task.hoursLeft}}</td>
 <td>
 <button type="button" 
 class="btn btn-default btn-xs"
 (click)="toggleTask(task)"
 [ngSwitch]="task.queued">
 <ng-template ngSwitchCase="false">
 <i class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 <ng-template ngSwitchCase="true">
 <i class="glyphicon glyphicon-minus-sign"></i>
 Remove
 <ng-template>
 <ng-template ngSwitchDefault>
 <i class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 </button>
 </td>
 </tbody>
</table>

我们全新的按钮可以在我们的组件类中执行“toggleTask()”方法,将Task对象作为参数传递给ngFor迭代对应的对象。另一方面,先前的ngSwitch实现允许我们根据Task对象在任何给定时间的状态来显示不同的按钮标签和图标。

我们正在用从 Glyphicons 字体系列中获取的字体图标装饰新创建的按钮。这些图标是我们之前安装的 Bootstrap CSS 捆绑包的一部分,与 Angular 无关。请随意跳过使用它或用另一个图标字体系列替换它。

现在执行代码并自行检查结果。整洁,不是吗?但是,也许我们可以通过向任务列表添加更多功能来从 Angular 中获得更多的效果。

在我们的模板中显示状态变化

现在我们可以从表中选择要完成的任务,很好地显示出我们需要运送多少个单位的一些视觉提示将是很好的。逻辑如下:

  • 用户审查表上的任务,并通过点击每个任务来选择要完成的任务

  • 每次点击一行时,底层的Task对象状态都会发生变化,并且其布尔排队属性会被切换

  • 状态变化立即通过在相关任务项上显示queued标签来反映在表面上

  • 用户得到了需要运送的单位数量的提示信息和交付所有这些单位的时间估计

  • 我们看到在表格上方显示了一排图标,显示了所有要完成的任务中所有单位的总和

这个功能将不得不对我们处理的Task对象集的状态变化做出反应。好消息是,由于 Angular 自己的变化检测系统,使组件完全意识到状态变化变得非常容易。

因此,我们的第一个任务将是调整我们的TasksComponent类,以包括一种计算和显示排队任务数量的方法。我们将使用这些信息来在我们的组件中渲染或不渲染一块标记,其中我们将通知我们排队了多少任务,以及完成所有任务需要多少累计时间。

我们类的新queuedTasks字段将提供这样的信息,我们将希望在我们的类中插入一个名为updateQueuedTasks()的新方法,该方法将在实例化组件或排队任务时更新其数值。除此之外,我们将创建一个键/值映射,以便稍后根据排队任务的数量使用I18nPlural管道来呈现更具表现力的标题头:

class TasksComponent {
 today: Date;
 tasks: Task[];
 queuedTasks: number;
 queuedHeaderMapping: any = {
 '=0': 'No tasks',
 '=1': 'One task',
 'other' : '# tasks'
 };

 constructor() {
 const TasksService: TasksService = new TasksService();
 this.tasks = tasksService.tasksStore;
 this.today = new Date();
 this.updateQueuedTasks();
 }

 toggleTask(task: Task) {
 task.queued = !task.queued;
 this.updateQueuedTasks();
 }

 private updateQueuedTasks() {
 this.queuedTasks = this.tasks
 .filter( task:Task => task.queued )
 .reduce((hoursLeft: number, queuedTask: Task) => {
 return hoursLeft + queuedTask.hoursLeft;
 }, 0)
 }
}

updateQueuedTasks()方法利用 JavaScript 的原生Array.filter()Array.reduce()方法从原始任务集合属性中构建一个排队任务列表。应用于结果数组的reduce方法给出了要运送的单位总数。现在有了一个有状态的计算排队单位数量的方法,是时候相应地更新我们的模板了。转到tasks.html并在<h4>Tasks backlog</h4>元素之前注入以下 HTML 代码块。代码如下:

<div>
 <h3>
 {{queuedTasks | i18nPlural:queueHeaderMapping}}
 for today
 <span class="small" *ngIf="queuedTasks > 0">
 (Estimated time: {{ queuedTasks > 0 }})
 </span>
 </h3>
</div>
<h4>Tasks backlog</h4>
<!-- rest of the template remains the same -->

前面的代码块始终呈现一个信息性的标题,即使没有任务排队。我们还将该值绑定在模板中,并使用它通过表达式绑定来估算通过每个会话所需的分钟数。

我们正在在模板中硬编码每个任务的持续时间。理想情况下,这样的常量值应该从应用程序变量或集中设置中绑定。别担心,我们将在接下来的章节中看到如何改进这个实现。

保存更改并重新加载页面,然后尝试在表格上切换一些任务项目,看看信息如何实时变化。令人兴奋,不是吗?

嵌入子组件

现在,让我们开始构建一个微小的图标组件,它将嵌套在TasksComponent组件内部。这个新组件将显示我们大图标的一个较小版本,我们将用它来在模板上显示排队等待完成的任务数量,就像我们在本章前面描述的那样。让我们为组件树铺平道路,我们将在第六章中详细分析,使用 Angular 组件构建应用程序。现在,只需在之前构建的TasksComponent类之前包含以下组件类。

我们的组件将公开一个名为 task 的公共属性,我们可以在其中注入一个Task对象。组件将使用这个Task对象绑定,根据该任务的hoursLeft属性所需的会话次数,在模板中复制渲染的图像,这都是通过ngFor指令实现的。

在我们的tasks.ts文件中,在TasksComponent之前注入以下代码块:

@Component({
 selector : 'task-icons',
 template : `
 <img *ngFor="let icon of icons"
 src="/assets/img/task.png"
 width="50">`
})
export class TaskIconsComponent implements OnInit {
 @Input() task: Task;
 icons: Object[] = [];
 ngOnInit() {
 this.icons.length = this.task.hoursLeft;
 this.icons.fill({ name : this.task.name });
 }
}

在我们继续迭代我们的组件之前,重要的是要确保我们将组件注册到一个模块中,这样其他构造体就可以知道它的存在,这样它们就可以在它们的模板中使用该组件。我们通过将它添加到其模块对象的declarations属性中来注册它:

@NgModule({
 imports : [ /* add needed imports here */ ]
 declarations : [ 
 TasksComponent,
   TaskIconsComponent  
 ]
})
export class TaskModule {}

现在TaskModule知道了我们的组件,我们可以继续改进它。

我们的新TaskIconsComponent具有一个非常简单的实现,具有一个非常直观的选择器,与其驼峰命名的类名匹配,以及一个模板,在模板中,我们根据控制器类的 icons 数组属性中填充的对象的数量,多次复制给定的<img>标签,这是通过 JavaScript API 中的Array对象的 fill 方法填充的(fill 方法用静态值填充数组的所有元素作为参数传递),在ngOnInit()中。等等,这是什么?我们不应该在构造函数中实现填充图标数组成员的循环吗?

这种方法是我们将在下一章概述的生命周期钩子之一,可能是最重要的一个。我们之所以在这里填充图标数组字段,而不是在构造方法中,是因为我们需要在继续运行 for 循环之前,每个数据绑定属性都得到适当的初始化。否则,太早访问输入值任务将会返回一个未定义的值。

OnInit接口要求在实现此接口的控制器类中集成一个ngOnInit()方法,并且一旦所有已定义绑定的输入属性都已检查,它将被执行。我们将在第六章中对组件生命周期钩子进行概述,使用 Angular 组件构建应用程序

我们的新组件仍然需要找到其父组件。因此,让我们在TasksComponent的装饰器设置的 directives 属性中插入对组件类的引用:

@Component({
 selector : 'tasks',
 styleUrls : ['tasks.css'],
 templateUrl : 'tasks.html'
})

我们的下一步将是在TasksComponent模板中注入<task-icons>元素。回到tasks.html,并更新条件块内的代码,以便在hoursLeft大于零时显示。代码如下:

<div>
 <h3>
 {{ hoursLeft | i18nPlural:queueHeaderMapping }}
 for today
 <span class="small" *ngIf="hoursLeft > 0">
 (Estimated time : {{ hoursLeft * 25 }})
 </span>
 </h3> 
 <p>
 <span *ngFor="let queuedTask of tasks">
      <task-icons
 [task]="queuedTask"
 (mouseover)="tooltip.innerText = queuedTask.name"
 (mouseout)="tooltip.innerText = 'Mouseover for details'">
 </task-icons>
 </span>
 </p>
 <p #tooltip *ngIf="hoursLeft > 0">Mouseover for details</p>
</div>
<h4>Tasks backlog</h4>
<!-- rest of the template remains the same -->

然而,仍然有一些改进的空间。不幸的是,图标大小在TaskIconsComponent模板中是硬编码的,这使得在其他需要不同大小的上下文中重用该组件变得更加困难。显然,我们可以重构TaskIconsComponent类,以公开一个size输入属性,然后将接收到的值直接绑定到组件模板中,以便根据需要调整图像的大小。

@Component({
 selector : 'task-icon',
 template : `
 <img *ngfor="let icon of icons" 
 src="/assets/img/task.png" 
 width="{{size}}">`
})
export class TaskIconsComponent implements OnInit {
 @Input() task: Task;
 icons : Object[] = [];
  @Input() size: number;
 ngOnInit() {
 // initialise component here
 }
}

然后,我们只需要更新tasks.html的实现,以声明我们需要的大小值:

<span *ngFor="let queuedTask of tasks">
 <task-icons 
 [task]="queuedTask" 
    size="50" 
 (mouseover)="tooltip.innerText = queuedTask.name">
 </task-icons>
</span>

请注意,size属性没有用括号括起来,因为我们绑定了一个硬编码的值。如果我们想要绑定一个组件变量,那么该属性应该被正确声明为[size]="{{mySizeVariable}}"

我们插入了一个新的 DOM 元素,只有在剩余小时数时才会显示出来。我们通过在 H3 DOM 元素中绑定hoursLeft属性,显示了一个实际的标题告诉我们剩余多少小时,再加上一个总估计时间,这些都包含在{{ hoursLeft * 25 }}表达式中。

ngFor指令允许我们遍历 tasks 数组。在每次迭代中,我们渲染一个新的<task-icons>元素。

我们在循环模板中将每次迭代的Task模型对象,由queuedTask引用表示,绑定到了<task-icons>的 task 输入属性中。

我们利用了<task-icons>元素来包含额外的鼠标事件处理程序,这些处理程序指向以下段落,该段落已标记为#tooltip本地引用。因此,每当用户将鼠标悬停在任务图标上时,图标行下方的文本将显示相应的任务名称。

我们额外努力,将由<task-icons>渲染的图标大小作为组件 API 的可配置属性。我们现在有了实时更新的图标,当我们切换表格上的信息时。然而,新的问题已经出现。首先,我们正在显示与每个任务剩余时间匹配的图标组件,而没有过滤掉那些未排队的图标。另一方面,为了实现所有任务所需的总估计时间,显示的是总分钟数,随着我们添加更多任务,这个信息将毫无意义。

也许,现在是时候修改一下了。自定义管道来拯救真是太好了!

构建我们自己的自定义管道

我们已经看到了管道是什么,以及它们在整个 Angular 生态系统中的目的是什么,但现在我们将更深入地了解如何构建我们自己的一组管道,以提供对数据绑定的自定义转换。

自定义管道的解剖

定义管道非常容易。我们基本上需要做以下事情:

  • 导入PipePipeTransform

  • 实现PipeTransform接口

  • Pipe组件添加到模块中

实现Pipe的完整代码看起来像这样:

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

@Pipe({
 name : 'myPipeName'
})
export class MyPipe implements PipeTransform {
 transform( value: any, ...args: any[]): any {
 // We apply transformations to the input value here
 return something;
 }
}
@Component({
 selector : 'my-selector',
 template : '<p>{{ myVariable | myPipeName: "bar"}}</p>'
})
export class MyComponent {
 myVariable: string = 'Foo';
}

让我们逐步分解即将到来的小节中的代码。

导入

我们导入了以下结构:

import { Pipe, PipeTransform, Component }

定义我们的管道

Pipe是一个装饰器,它接受一个对象文字;我们至少需要给它一个名称属性:

@Pipe({ name : 'myPipeName' })

这意味着一旦使用,我们将像这样引用它的名称属性:

{{ value | myPipeName }}

PipeTransform是我们需要实现的接口。我们可以通过将其添加到我们的类中轻松实现:

@Pipe({ name : 'myPipeName' })
export class MyPipeClass {
 transform( value: any, args: any[]) {
 // apply transformation here
 return 'add banana ' + value; 
 }
}

在这里,我们可以看到我们有一个 transform 方法,但第一个参数是值本身,其余是args,一个包含您提供的任意数量参数的数组。我们已经展示了如何使用这个Pipe,但是如果提供参数,它看起来有点不同,就像这样:

{{ value | myPipeName:arg1:arg2 }}

值得注意的是,对于我们提供的每个参数,它最终都会出现在args数组中,并且我们用冒号分隔它。

注册它

要使一个构造可用,比如一个管道,你需要告诉模块它的存在。就像组件一样,我们需要像这样添加到 declarations 属性中:

@NgModule({
 declarations : [ MyPipe ]
})
export ModuleClass {}

纯属性

我们可以向我们的@Pipe装饰器添加一个属性,pure,如下所示:

@Pipe({ name : 'myPipe', pure : false })
export class MyPipe implements PipeTransform {
 transform(value: any, ...args: any[]) {}
}

“为什么我们要这样做?”你问。嗯,有些情况下可能是必要的。如果你有一个像这样处理原始数据的管道:

{{ "decorate me" |  myPipe }}

我们没有问题。但是,如果它看起来像这样:

{{ object | myPipe }}

我们可能会遇到问题。考虑组件中的以下代码:

export class Component {
 object = { name : 'chris', age : 37 }

 constructor() {
 setTimeout(() => this.object.age = 38 , 3000)
 }
}

假设我们有以下Pipe实现来配合它:

@Pipe({ name : 'pipe' })
export class MyPipe implements PipeTransform {
 transform(value:any, ...args: any[]) {
 return `Person: ${value.name} ${value.age}` 
 }
}

这起初会是输出:

Chris 37

然而,你期望输出在 3 秒后改变为Chris 38,但它没有。管道只关注引用是否已更改。在这种情况下,它没有,因为对象仍然是相同的,但对象上的属性已更改。告诉它对更改做出反应的方法是指定pure属性,就像我们在开始时所做的那样。因此,我们更新我们的Pipe实现如下:

@Pipe({ name : 'pipe', pure: false })
export class MyPipe implements PipeTransform {
 transform(value: any, ...args:any[]) {
 return `Person: ${value.name} ${value.age}`
 }
}

现在,我们突然看到了变化发生。不过,需要注意的是,这实际上意味着transform方法在每次变更检测周期被触发时都会被调用。因此,这对性能可能会造成损害。如果设置pure属性,你可以尝试缓存该值,但也可以尝试使用 reducer 和不可变数据以更好地解决这个问题:

// instead of altering the data like so
this.jedi.side = 'Dark'

// instead do
this.jedi = Object.assign({}, this.jedi, { side : 'Dark' });

前面的代码将更改引用,我们的 Pipe 不会影响性能。总的来说,了解 pure 属性的作用是很好的,但要小心。

更好地格式化时间输出的自定义管道

当排列要完成的任务时,观察总分钟数的增加并不直观,因此我们需要一种方法将这个值分解为小时和分钟。我们的管道将被命名为formattedTime,并由formattedTimePipe类实现,其唯一的 transform 方法接收一个表示总分钟数的数字,并返回一个可读的时间格式的字符串(证明管道不需要返回与载荷中接收到的相同类型)。:

@Pipe({
 name : 'formattedTime'
})
export class FormattedTimePipe implements PipeTransform {
 transform(totalMinutes : number) {
 let minutes : number = totalMinutes % 60;
 let hours : numbers = Math.floor(totalMinutes / 60);
 return `${hours}h:{minutes}m`;
 }
}

我们不应该错过强调管道的命名约定,与我们在组件中看到的一样,管道类的名称加上Pipe后缀,再加上一个与该名称匹配但不带后缀的选择器。为什么管道控制器的类名和选择器之间存在这种不匹配?这是常见的做法,为了防止与第三方管道和指令定义的其他选择器发生冲突,我们通常会给我们自定义管道和指令的选择器字符串添加一个自定义前缀。

@Component({
 selector : 'tasks',
 styleUrls : [ 'tasks.css' ],
 templateUrl : 'tasks.html'
})
export class TasksComponent {}

最后,我们只需要调整tasks.html模板文件中的 HTML,以确保我们的 EDT 表达式格式正确:

<span class="small">
 (Estimated time: {{ queued * 25 | formattedTime }})
</span>

现在,重新加载页面并切换一些任务。预计时间将以小时和分钟正确呈现。

最后,我们不要忘记将我们的Pipe构造添加到其模块tasks.module.ts中:

@NgModule({
 declarations: [TasksComponent, FormattedTimePipe]
})
export class TasksModule {}

使用自定义过滤器过滤数据

正如我们已经注意到的,我们目前为每个任务在从任务服务提供的集合中显示一个图标组件,而没有过滤出哪些任务标记为排队,哪些不是。管道提供了一种方便的方式来映射、转换和消化数据绑定,因此我们可以利用其功能来过滤我们ngFor循环中的任务绑定,只返回那些标记为排队的任务。

逻辑将非常简单:由于任务绑定是一个Task对象数组,我们只需要利用Array.filter()方法来获取那些queued属性设置为trueTask对象。我们可能会额外配置我们的管道以接受一个布尔参数,指示我们是否要过滤出排队或未排队的任务。这些要求的实现如下,您可以再次看到选择器和类名的惯例:

@Pipe({
 name : 'queuedOnly'
})
export class QueuedOnlyPipe implements PipeTransform {
 transform(tasks: Task[]), ...args:any[]): Task[] {
 return tasks.filter( task:Task => task.queued === args[0])
 }
}

实现非常简单,所以我们不会在这里详细介绍。然而,在这个阶段有一件值得强调的事情:这是一个不纯的管道。请记住,任务绑定是一个有状态对象的集合,随着用户在表格上切换任务,其长度和内容将发生变化。因此,我们需要指示管道利用 Angular 的变更检测系统,以便其输出在每个周期都被后者检查,无论其输入是否发生变化。然后,将管道装饰器的pure属性配置为false就可以解决问题。

现在,我们只需要更新使用此管道的组件的 pipes 属性:

@Component({
 selector : 'tasks',
 styleUrls : ['tasks.css'],
 templateUrl : 'tasks.html'
})
export class TasksComponent {
 // Class implementation remains the same
}

然后,在tasks.html中更新ngFor块,以正确过滤出未排队的任务:

<span *ngFor="queuedTask of tasks | queuedOnly:true">
 <task-icons
 [task]="queuedTask"
 (mouseover)="tooltip.innerText = queuedTask.name"
 (mouseout)="tooltip.innerText = 'Mouseover for details'">
 </task-icons>
</span>

请检查我们如何将管道配置为queuedOnly: true。将布尔参数值替换为false将使我们有机会列出与我们未选择的队列相关的任务。

保存所有工作并重新加载页面,然后切换一些任务。您将看到我们的整体 UI 如何根据最新更改做出相应的反应,我们只列出与排队任务的剩余小时数相关的图标。

构建我们自己的自定义指令

自定义指令涵盖了广泛的可能性和用例,我们需要一整本书来展示它们提供的所有复杂性和可能性。

简而言之,指令允许您将高级行为附加到 DOM 中的元素上。如果指令附有模板,则它将成为一个组件。换句话说,组件是具有视图的 Angular 指令,但我们可以构建没有附加视图的指令,这些指令将应用于已经存在的 DOM 元素,使其 HTML 内容和标准行为立即对指令可用。这也适用于 Angular 组件,其中指令将在必要时访问其模板和自定义属性和事件。

自定义指令的解剖

声明和实现自定义指令非常容易。我们只需要导入Directive类,以为其附属的控制器类提供装饰器功能:

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

然后,我们定义一个由@Directive装饰器注释的控制器类,在其中我们将定义指令选择器、输入和输出属性(如果需要)、应用于宿主元素的可选事件,以及可注入的提供者令牌,如果我们的指令构造函数在实例化时需要特定类型由 Angular 注入器实例化自己(我们将在第六章中详细介绍这一点,使用 Angular 组件构建应用程序):

让我们先创建一个非常简单的指令来热身:

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

@Directive({
 selector : '[highlight]'
})
export class HighLightDirective {
 constructor( private elementRef: ElementRef, private renderer : Renderer2 ) {
 var nativeElement = elementRef.nativeElement;
 this.renderer.setProperty( nativeElement,'backgroundColor', 'yellow');
 }
}

要使用它就像输入一样简单:

<h1 highlight></h1>

我们在这里使用了两个 actor,ElementRefRenderer2,来操作底层元素。我们可以直接使用elementRef.nativeElement,但这是不鼓励的,因为这可能会破坏服务器端渲染或与服务工作者交互时。相反,我们使用Renderer2的实例进行所有操作。

注意我们不输入方括号,而只输入选择器名称。

我们在这里快速回顾了一下,注入了ElementRef并访问了nativeElement属性,这是实际元素。我们还像在组件和管道上一样,在类上放置了一个@Directive装饰器。创建指令时要有的主要思维方式是考虑可重用的功能,不一定与某个特定功能相关。之前选择的主题是高亮,但我们也可以相对容易地构建其他功能,比如工具提示、可折叠或无限滚动功能。

属性和装饰器,比如选择器、@Input()@Output()(与输入和输出相同),可能会让您回想起我们概述组件装饰器规范时的时间。尽管我们尚未详细提到所有可能性,但选择器可以声明为以下之一:

  • element-name: 通过元素名称选择

  • .class: 通过类名选择

  • [attribute]: 通过属性名称选择

  • [attribute=value]: 通过属性名称和值选择

  • not(sub_selector): 仅在元素不匹配时选择

sub_selector

  • selector1, selector2: 如果selector1selector2匹配,则选择

除此之外,我们还会找到主机参数,该参数指定了与主机元素(即我们指令执行的元素)相关的事件、动作、属性和属性,我们希望从指令内部访问。因此,我们可以利用这个参数来绑定与容器组件或任何其他目标元素(如窗口、文档或主体)的交互处理程序。这样,当编写指令事件绑定时,我们可以引用两个非常方便的本地变量:

  • $event: 这是触发事件的当前事件对象。

  • $target: 这是事件的来源。这将是一个 DOM 元素或一个 Angular 指令。

除了事件,我们还可以更新属于主机组件的特定 DOM 属性。我们只需要将任何特定属性用大括号括起来,并在我们指令的主机定义中将其作为键值对与指令处理的表达式链接起来。

可选的主机参数还可以指定应传播到主机元素的静态属性,如果尚未存在。这是一种方便的方式,可以使用计算值注入 HTML 属性。

Angular 团队还提供了一些方便的装饰器,这样我们就可以更加直观地在代码中声明我们的主机绑定和监听器,就像这样:

@HostBinding('[class.valid]')
isValid: boolean; // The host element will feature class="valid"
// is the value of 'isValid' is true.
@HostListener('click', ['$event'])
onClick(e) {
 // This function will be executed when the host 
  // component triggers a 'click' event.
}

在接下来的章节中,我们将更详细地介绍指令和组件的配置接口,特别关注它的生命周期管理以及我们如何轻松地将依赖项注入到我们的指令中。现在,让我们只是构建一个简单但强大的指令,它将对我们的 UI 的显示和维护产生巨大的影响。

监听事件

到目前为止,我们已经能够创建我们的第一个指令,但这并不是很有趣。然而,添加监听事件的能力会使它变得更有趣,所以让我们来做吧。我们需要使用一个叫做HostListener的辅助工具来监听事件,所以我们首先要导入它:

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

我们需要做的下一件事是将它用作装饰器并装饰一个方法;是的,一个方法,而不是一个类。它看起来像下面这样:

@Directive({
 selector : '[highlight]'
})
export class HighlightDirective {
 @HostListener('click')
 clicked() {
 alert('clicked') 
 }
}

使用这个指令点击一个元素将会导致一个警告窗口弹出。添加事件非常简单,所以让我们尝试添加mouseovermouseleave事件:

@Directive({
 selector : '[highlight]'
})
export class HighlightDirective {
 private nativeElement;

 constructor(elementRef: ElementRef, renderer: Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @HostListener('mousenter')
 onMouseEnter() {
 this.background('red');
 }

 onMouseLeave('mouseleave') {
 this.background('yellow');
 }

 private background(bg:string) {
 this.renderer.setAttribute(nativeElement,'backgroundColor', bg);
 }
}

这给了我们一个指令,当鼠标悬停在组件上时,背景会变成红色,当鼠标离开时会恢复为黄色

添加输入数据

我们的指令对于使用什么颜色是相当静态的,所以让我们确保它们可以从外部设置。要添加第一个输入,我们需要使用我们的老朋友@Input装饰器,但是不像我们习惯的那样不给它任何参数作为输入,我们需要提供指令本身的名称,如下所示:

<div highlight="orange"></div>

@Directive({ selector : '[highlight]' })
export class HighlightDirective 
 private nativeElement;

 constructor(elementRef: ElementRef, renderer: Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @Input('highlight') color:string;

 @HostListener('mousenter')
 onMouseEnter(){
 this.background(this.color);
 }

 onMouseLeave() {
 this.background('yellow'); 
 }

 private background(bg: string) {
 this.renderer( nativeElement, 'background', bg );
 }
}

在这一点上,我们已经处理了第一个输入;我们用以下方法做到了这一点:

@Input('highlight') color: string;

但是,我们如何向我们的指令添加更多的输入?我们将在下一小节中介绍这个问题。

添加多个输入属性

所以你想要添加另一个输入,这也相对容易。我们只需要在我们的 HTML 元素中添加一个属性,如下所示:

<div [highlight]="orange" defaultColor="yellow">

在代码中我们输入:

@Directive({})
export class HighlightDirective {
 @Input() defaultColor
 constructor() {
 this.background(this.defaultColor);
 }
 // the rest omitted for brevity
}

然而,我们注意到在我们进行第一次mousenter + mouseleave之前,我们没有颜色,原因是构造函数在我们的defaultColor属性被设置之前运行。为了解决这个问题,我们需要稍微不同地设置输入。我们需要像这样使用一个属性:

private defaultColor: string;

@Input()
set defaultColor(value) { 
 this.defaultColor = value;
 this.background(value); 
}

get defaultColor(){ return this.defaultColor; }

总结一下关于使用输入的部分,很明显我们可以使用@Input装饰器来处理一个或多个输入。然而,第一个输入应该是指令的选择器名称,第二个输入是你给它的属性的名称。

第二个例子 - 错误验证

让我们利用对指令的这些新知识,构建一个指示字段错误的指令。我们认为错误是指我们着色元素并显示错误文本:

import { Directive, ElementRef, Input } from '@angular/core';
@Directive({
 selector: '[error]'
})
export class ErrorDirective {
 error:boolean;
 private nativeElement;
 @Input errorText: string;
 @Input()
 set error(value: string) {
 let val = value === 'true' ? true : false;
 if(val){ this.setError(); }
 else { this.reset(); }
 }

 constructor(
 private elementRef: ElementRef, 
 private renderer: Renderer2
 ) {
 this.nativeElement = elementRef.nativeElement;
 }

 private reset() { 
 this.renderer.setProperty(nativeElement, 'innerHTML', '');
 this.renderer.setProperty(nativeElement, 'background', '') 
 }

 private setError(){
 this.renderer.setProperty(nativeElement, 'innerHTML', this.errorText);
 this.renderer.setProperty(nativeElement, 'background', 'red');
 }
}

而要使用它,我们只需输入:

<div error="{{hasError}}" errorText="display this error">

构建一个任务提示自定义指令

到目前为止,我们已经构建了一个高亮指令以及一个错误显示指令。我们已经学会了如何处理事件以及多个输入。

关于提示信息的简短说明。当我们悬停在一个元素上时,会出现提示信息。通常你要做的是在元素上设置 title 属性,就像这样:

<div title="a tooltip"></div>

通常有几种方法可以在这样的组件上构建提示信息。一种方法是绑定到title属性,就像这样:

<task-icons [title]="task.name"></task-icons>

然而,如果你有更多的逻辑想法,将所有内容都添加到标记中可能不太好,所以在这一点上,我们可以创建一个指令来隐藏提示信息,就像这样:

@Directive({ selector : '[task]' })
export class TooltipDirective {
 private nativeElement;
 @Input() task:Task;
 @Input() defaultTooltip: string;

 constructor(private elementRef: ElementRef, private renderer : Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @HostListener('mouseover')
 onMouseOver() {
 let tooltip = this.task ? this.task.name : this.defaultTooltip;
 this.renderer.setProperty( this.nativeElement, 'title', tooltip );
 }
}

使用它将是:

<div [task]="task">

然而,我们还可以采取另一种方法。如果我们想在悬停在一个元素上时改变另一个元素的 innerText 呢?这是很容易做到的,我们只需要将我们的指令传递给另一个元素,并更新它的 innerText 属性,就像这样:

<div [task]="task" [elem]="otherElement" defaultTooltip="default text" >
<div #otherElement>

当然,这意味着我们需要稍微更新我们的指令到这样:

@Directive({ selector : '[task]' })
export class TooltipDirective {
 private nativeElement;
 @Input() task:Task;
 @Input() defaultTooltip: string;

 constructor(private elementRef: ElementRef, private renderer : Renderer2) {
 this.nativeElement = elementRef.nativeElement;
 }

 @HostListener('mouseover')
 onMouseOver() {
 let tooltip = this.task ? this.task.name : this.defaultTooltip;
    this.renderer.setProperty( this.nativeElement, 'innerText', tooltip );
 }
}

关于自定义指令和管道的命名约定

谈到可重用性,通常的约定是在选择器前面添加一个自定义前缀。这可以防止与其他库定义的选择器发生冲突,这些库可能在我们的项目中使用。同样的规则也适用于管道,正如我们在介绍我们的第一个自定义管道时已经强调的那样。

最终,这取决于你和你采用的命名约定,但建立一个可以防止这种情况发生的命名约定通常是一个好主意。自定义前缀绝对是更容易的方法。

总结

现在我们已经达到这一点,可以说你几乎知道构建 Angular 组件所需的一切,这些组件确实是所有 Angular 2 应用程序的核心和引擎。在接下来的章节中,我们将看到如何更好地设计我们的应用程序架构,因此在整个组件树中管理依赖注入,使用数据服务,利用新的 Angular 路由器在需要时显示和隐藏组件,并管理用户输入和身份验证。

然而,这一章是 Angular 开发的支柱,我们希望您和我们一样喜欢它,当我们写关于模板语法、基于属性和事件的组件 API、视图封装、管道和指令时。现在,准备好迎接新的挑战——我们将从学习如何编写组件转向发现如何使用它们来构建更大的应用程序,同时强调良好的实践和合理的架构。我们将在下一章中看到所有这些。

第六章:使用 Angular 组件构建应用程序

我们已经达到了一个阶段,在这个阶段,我们可以通过在其他组件中嵌套组件来成功开发更复杂的应用程序,形成一种组件树。然而,将所有组件逻辑捆绑在一个唯一的文件中绝对不是正确的方法。我们的应用程序很快可能变得难以维护,并且正如我们将在本章后面看到的那样,我们将错过 Angular 的依赖管理机制可以为游戏带来的优势。

在本章中,我们将看到如何基于组件树构建应用程序架构,以及新的 Angular 依赖注入机制如何帮助我们以最小的工作量和最佳结果声明和使用应用程序中的依赖项。

在本章中,我们将涵盖以下主题:

  • 目录结构和命名约定的最佳实践

  • 依赖注入的不同方法

  • 将依赖项注入到我们的自定义类型中

  • 在整个组件树中覆盖全局依赖项

  • 与宿主组件交互

  • 概述指令生命周期

  • 概述组件生命周期

介绍组件树

基于 Web 组件架构的现代 Web 应用程序通常符合一种树形层次结构,其中顶层主要组件(通常放置在主 HTML 索引文件的某个位置)充当全局占位符,子组件成为其他嵌套子组件的宿主,依此类推。

这种方法有明显的优势。一方面,可重用性不会受到损害,我们可以轻松地在组件树中重用组件。其次,由此产生的细粒度减少了构想、设计和维护更大型应用程序所需的负担。我们可以简单地专注于单个 UI 部分,然后将其功能包装在新的抽象层周围,直到我们从头开始包装一个完整的应用程序。

或者,我们可以从另一个角度来处理我们的 Web 应用程序,从更通用的功能开始,最终将应用程序拆分为更小的 UI 和功能部分,这些部分成为我们的 Web 组件。后者已成为构建基于组件的架构时最常见的方法。我们将在本书的其余部分坚持这一方法,将架构视为下图所示的架构:

Application bootstrap
Root module
 Root component that is Application component
 Component A
 Component B
 Component B-I
 Component B-II
 Component C
 Component D
Feature module
 Component E
 Component F
Common module
 Component G
 Component H

为了清晰起见,本章将借用我们在前几章中编写的代码,并将其拆分为组件层次结构。我们还将为最终应用程序中所有支持类和模型分配一些空间,以塑造我们的番茄工具。这将成为学习 Angular 中内置的依赖注入机制的绝佳机会,我们将在本章后面看到。

可扩展应用程序的通用约定

公平地说,我们已经解决了现代网页开发人员在构建应用程序时所面临的许多常见问题,无论是小型还是大型应用程序。因此,定义一个架构来将上述问题分离成单独的领域文件夹,满足媒体资产和共享代码单元的需求是有意义的。

Angular 将代码和资产分离的方法是通过将它们组织到不同的文件夹中,同时引入 Angular 模块的概念。在这些模块中注册构造。通过引入模块,我们的组件中的许多噪音已经消失,我们的组件可以自由地使用同一模块中的其他构造,有时甚至可以使用其他模块中的构造,前提是导入其所在的模块。

值得强调的是,当我们谈论 Angular 模块时,我们指的是@NgModule装饰器,当我们谈论模块时,我们指的是 ES2015 构造。

有时,两个上下文可能需要共享相同的实体,这是可以接受的(只要在我们的项目中不成为常见情况,这将表示严重的设计问题)。还值得强调的是,我们使用“上下文”一词来描述构造的逻辑边界。上下文最好保留在一个 Angular 模块中。因此,每当使用“上下文”一词时,都要考虑在代码中将其转换为一个 Angular 模块。

以下示例应用于我们之前在番茄工作法组件上的工作,基本上构成了我们整个应用程序的上下文和不同构造。

  • 任务上下文:

  • 任务模块

  • 任务模型

  • 任务服务

  • 任务表组件

  • 任务番茄钟组件

  • 任务工具提示指令

  • 计时器上下文:

  • 计时器模块

  • 计时器功能

  • 计时器组件

  • 管理员上下文:

  • 管理员模块

  • 认证服务

  • 登录组件

  • 编辑器组件

  • 共享上下文:

  • 共享模块

  • 跨功能共享的组件

  • 跨功能共享的管道

  • 跨功能共享的指令

  • 全局模型和服务

  • 共享媒体资产

正如我们所看到的,第一步是定义应用程序需要的不同功能,要记住的是,每个功能在与其他功能隔离时应该是有意义的。一旦我们定义了所需的功能集,我们将为每个功能创建一个模块。然后,每个模块将填充代表其特征的组件、指令、管道、模型和服务。在定义功能集时,请始终记住封装和可重用性的原则。

最初,在启动项目时,您应该根据它们的名称命名您的构造,所以说我们有Admin上下文,它应该看起来像这样:

//admin/

admin.module.ts
authentication.service.ts
login.component.ts
editor.component.ts

通过快速浏览,您应该能够看到构造包含的内容,因此使用类似于以下的命名标准:

<name>.<type>.ts // example login.service.ts

当然,这不是唯一的方法。还有另一种完全可以接受的方法,即为每种类型创建子目录,因此您之前的admin目录可能看起来像这样:

//admin/

admin.module.ts
services/
 authentication.service.ts
components/
 login.component.ts
 login.component.html
 editor.component.ts
 create-user.component.ts
pipes/
 user.pipe.ts

值得注意的是,为了便于调试,您应该在文件名中保留类型。否则,当在浏览器中寻找特定文件以设置断点时,比如登录服务,如果您开始输入login.ts,然后出现以下情况可能会相当令人困惑:

  • components/login.ts

  • services/login.ts

  • pipes/login.ts

有一个官方的样式指南,告诉您应该如何组织代码以及如何命名您的构造。遵循指南肯定有好处;对新手来说很容易,代码看起来更一致等等。您可以在这里阅读更多信息;angular.io/guide/styleguide。请记住,无论您选择是否完全遵循此样式指南,一致性都很重要,因为这将使维护代码变得更容易。

文件和 ES6 模块命名约定

我们的每个功能文件夹将托管各种文件,因此我们需要一致的命名约定,以防止文件名冲突,同时确保不同的代码单元易于定位。

以下列表总结了社区强制执行的当前约定:

  • 每个文件应包含一个代码单元。简而言之,每个组件、指令、服务、管道等都应该存在于自己的文件中。这样,我们有助于更好地组织代码。

  • 文件和目录以小写 kebab-case 命名。

  • 表示组件、指令、管道和服务的文件应该在它们的名称后面添加一个类型后缀:video-player.ts将变成video-player.component.ts

  • 任何组件的外部 HTML 模板或 CSS 样式表文件名都将与组件文件名匹配,包括后缀。例如,我们的video-player.component.ts可能会有video-player.component.cssvideo-player.component.html

  • 指令选择器和管道名称采用驼峰式命名,而组件选择器采用小写 kebab-case 命名。此外,强烈建议添加我们选择的自定义前缀,以防止与其他组件库发生名称冲突。例如,跟随我们的视频播放器组件,它可以表示为<vp-video-player>,其中vp-(代表 video-player)是我们的自定义前缀。

  • 模块的命名遵循 PascalCased 规则

自描述名称,以及它所代表的类型。例如,如果我们看到一个名为VideoPlayerComponent的模块,我们可以轻松地知道它是一个组件。在选择器中使用的自定义前缀(在我们的示例中为vp-)不应该成为模块名称的一部分。

  • 模型和接口需要特别注意。根据您的应用程序架构,模型类型的相关性会更多或更少。诸如 MVC、MVVM、Flux 或 Redux 的架构从不同的角度和重要性等级处理模型。最终,您和您选择的架构设计模式将决定以一种方式或另一种方式处理模型和它们的命名约定。本书在这方面不会表达观点,尽管我们在示例应用程序中强制执行接口模型,并将为它们创建模块。

  • 我们应用程序中的每个业务逻辑组件和共享上下文都旨在以简单直接的方式与其他部分集成。每个子域的客户端都不关心子域本身的内部结构。例如,如果我们的定时器功能发展到需要重新组织成不同的文件夹层次结构,其功能的外部消费者应该保持不受影响。

从 facade/barrel 到 NgModule

随着应用程序的增长,有必要将构造分组为逻辑组。随着应用程序的增长,您还意识到并非所有构造都应该能够相互通信,因此您还需要考虑限制这一点。在框架中添加@NgModule之前,自然的做法是考虑外观模块,这基本上意味着我们创建了一个具有决定将被导出到外部世界的唯一目的的特定文件。这可能看起来像下面这样:

import TaskComponent from './task.component';
import TaskDetailsComponent from './task-details.component';
// and so on
export {
 TaskComponent,
 TaskDetailsComponent,
 // other constructs to expose
}

一切未明确导出的内容都将被视为私有或内部特性。使用其中一个导出的构造将像输入一样简单:

import { TaskComponent } from './task.component.ts';
// do something with the component above

这是一种处理分组和限制访问的有效方式。当我们深入研究下一小节中的@NgModule时,我们将牢记这两个特性。

使用 NgModule

随着@NgModule的到来,我们突然有了一种更合乎逻辑的方式来分组我们的构造,并且也有了一种自然的方式来决定什么可以被导出或不导出。以下代码对应于前面的外观代码,但它使用了@NgModule

import { NgModule } from  '@angular/core'; import { TaskDetailComponent } from  './task.detail.component'; import { TaskDetailsComponent } from  './task.details.component'; import { TaskComponent } from  './task.component';   @NgModule({
  declarations: [TaskComponent, TaskDetailsComponent], exports: [TaskComponent, TaskDetailComponent] })
export  class  TaskModule { }

这将创建相同的效果,该构造称为特性模块。exports关键字表示了什么是公开访问的或不是。然而,获取公开访问的内容看起来有点不同。而不是输入:

import { TaskDetailComponent } from 'app/tasks/tasks';

我们需要将我们的特性模块导入到我们的根模块中。这意味着我们的根模块将如下所示:

import { TaskModule } from './task.module';

@NgModule({
  imports: [ TasksModule ]
 // the rest is omitted for brevity
}) 

这将使我们能够在模板标记中访问导出的组件。因此,在您即将构建的应用程序中,请考虑什么属于根模块,什么是特性的一部分,以及什么是更常见的并且在整个应用程序中都使用。这是您需要拆分应用程序的方式,首先是模块,然后是适当的构造,如组件、指令、管道等。

在 Angular 中依赖注入是如何工作的

随着我们的应用程序的增长和发展,我们的每一个代码实体在内部都需要其他对象的实例,这在软件工程领域更为常见的称为依赖关系。将这些依赖关系传递给依赖客户端的行为称为注入,它还涉及另一个名为注入器的代码实体的参与。注入器将负责实例化和引导所需的依赖关系,以便在成功注入客户端后立即可以使用。这非常重要,因为客户端对如何实例化自己的依赖关系一无所知,只知道它们实现的接口以便使用它们。

Angular 具有一流的依赖注入机制,可以轻松地将所需的依赖关系暴露给 Angular 应用程序中可能存在的任何实体,无论是组件、指令、管道还是任何其他自定义服务或提供者对象。事实上,正如我们将在本章后面看到的,任何实体都可以利用 Angular 应用程序中的依赖注入(通常称为 DI)。在深入讨论这个主题之前,让我们先看看 Angular 的 DI 试图解决的问题。

让我们看看我们是否有一个音乐播放器组件,它依赖于一个“播放列表”对象来向用户播放音乐:

import { Component } from  '@angular/core'; import { Playlist } from  './playlist.model'; @Component({
  selector:  'music-player', templateUrl:  './music-player.component.html' })
export  class  MusicPlayerComponent { playlist:  Playlist; constructor() { this.playlist  =  new  Playlist();
 }}
}

“播放列表”类型可能是一个通用类,在其 API 中返回一个随机的歌曲列表或其他内容。现在这并不重要,因为唯一重要的是我们的MusicPlayerComponent实体确实需要它来提供功能。不幸的是,先前的实现意味着这两种类型紧密耦合,因为组件在自己的构造函数中实例化了播放列表。这意味着如果需要,我们无法以整洁的方式更改、覆盖或模拟“播放列表”类。这也意味着每次我们实例化一个MusicPlayerComponent时都会创建一个新的“播放列表”对象。在某些情况下,这可能是不希望的,特别是如果我们希望在整个应用程序中使用单例并因此跟踪播放列表的状态。

依赖注入系统试图通过提出几种模式来解决这些问题,而构造函数注入模式是 Angular 强制执行的模式。前面的代码片段可以重新思考如下:

import { Component } from  '@angular/core'; import { Playlist } from  './playlist.model'; @Component({
 selector: 'music-player',
 templateUrl: './music-player.component.html'
})
export class MusicPlayerComponent {
 constructor(private playlist: Playlist) {}
}

现在,Playlist是在我们的组件外部实例化的。另一方面,MusicPlayerComponent期望在组件实例化之前已经有这样一个对象可用,以便通过其构造函数注入。这种方法使我们有机会覆盖它或者模拟它。

基本上,这就是依赖注入的工作原理,更具体地说是构造函数注入模式。但是,这与 Angular 有什么关系呢?Angular 的依赖注入机制是通过手动实例化类型并通过构造函数注入它们吗?显然不是,主要是因为我们也不会手动实例化组件(除非编写单元测试时)。Angular 具有自己的依赖注入框架,顺便说一句,这个框架可以作为其他应用程序的独立框架使用。

该框架提供了一个实际的注入器,可以审视构造函数中用于注释参数的标记,并返回每个依赖类型的单例实例,因此我们可以立即在类的实现中使用它,就像前面的例子一样。注入器不知道如何创建每个依赖项的实例,因此它依赖于在应用程序引导时注册的提供者列表。这些提供者实际上提供了对标记为应用程序依赖项的类型的映射。每当一个实体(比如一个组件、一个指令或一个服务)在其构造函数中定义一个标记时,注入器会在该组件的已注册提供者池中搜索与该标记匹配的类型。如果找不到匹配项,它将委托给父组件的提供者进行搜索,并将继续向上进行提供者的查找,直到找到与匹配类型的提供者或者达到顶层组件。如果提供者查找完成后没有找到匹配项,Angular 将抛出异常。

后者并不完全正确,因为我们可以使用@Optional参数装饰器在构造函数中标记依赖项,这种情况下,如果找不到提供者,Angular 将不会抛出任何异常,并且依赖参数将被注入为 null。

每当提供程序解析为与该令牌匹配的类型时,它将返回此类型作为单例,因此将被注入器作为依赖项注入。公平地说,提供程序不仅仅是将令牌与先前注册的类型进行配对的键/值对集合,而且还是一个工厂,它实例化这些类型,并且也实例化每个依赖项自己的依赖项,以一种递归依赖项实例化的方式。

因此,我们可以这样做,而不是手动实例化Playlist对象:

import { Component } from  '@angular/core'; import { Playlist } from  './playlist'; @Component({
  selector:  'music-player', templateUrl:  './music-player.component.html', providers: [Playlist**]** })
export  class  MusicPlayerComponent { constructor(private  playlist:  Playlist) {} }

@Component装饰器的providers属性是我们可以在组件级别注册依赖项的地方。从那时起,这些类型将立即可用于该组件的构造函数注入,并且,正如我们将在接下来看到的,也可用于其子组件。

关于提供程序的说明

在引入@NgModule之前,Angular 应用程序,特别是组件,被认为是负责其所需内容的。因此,组件通常会要求其需要的依赖项以正确实例化。在上一节的示例中,MusicPlayerComponent请求一个Playlist依赖项。虽然这在技术上仍然是可能的,但我们应该使用我们的新@NgModule概念,而不是在模块级别提供构造。这意味着先前提到的示例将在模块中注册其依赖项,如下所示:

@NgModule({
 declarations: [MusicComponent, MusicPlayerComponent]
 providers: [Playlist, SomeOtherService]
})

在这里,我们可以看到PlaylistSomeOtherService将可用于注入,对于在 declarations 属性中声明的所有构造。正如你所看到的,提供服务的责任在某种程度上已经转移。正如之前提到的,这并不意味着我们不能在每个组件级别上提供构造,存在这样做有意义的用例。然而,我们想强调的是,通常情况是将需要注入的服务或其他构造放在模块的providers属性中,而不是组件中。

跨组件树注入依赖项

我们已经看到,provider 查找是向上执行的,直到找到匹配项。一个更直观的例子可能会有所帮助,所以让我们假设我们有一个音乐应用程序组件,在其指令属性(因此也在其模板中)中托管着一个音乐库组件,其中包含我们下载的所有曲目的集合,还托管着一个音乐播放器组件,因此我们可以在我们的库中播放任何曲目:

MusicAppComponent
 MusicLibraryComponent
 MusicPlayerComponent

我们的音乐播放器组件需要我们之前提到的Playlist对象的一个实例,因此我们将其声明为构造函数参数,并方便地用Playlist标记进行注释:

MusicAppComponent
 MusicLibraryComponent
 MusicPlayerComponent(playlist: Playlist)

MusicPlayerComponent实体被实例化时,Angular DI 机制将会遍历组件构造函数中的参数,并特别关注它们的类型注解。然后,它将检查该类型是否已在组件装饰器配置的 provider 属性中注册。代码如下:

@Component({
 selector: 'music-player',
 providers: [Playlist]
})
export class MusicPlayerComponent {
 constructor(private playlist: Playlist) {}
}

但是,如果我们想在同一组件树中的其他组件中重用Playlist类型呢?也许Playlist类型在其 API 中包含了一些不同组件在应用程序中同时需要的功能。我们需要为每个组件在 provider 属性中声明令牌吗?幸运的是不需要,因为 Angular 预见到了这种必要性,并通过组件树带来了横向依赖注入。

在前面的部分中,我们提到组件向上进行 provider 查找。这是因为每个组件都有自己的内置注入器,它是特定于它的。然而,该注入器实际上是父组件注入器的子实例(依此类推),因此可以说 Angular 应用程序不是一个单一的注入器,而是同一个注入器的许多实例。

我们需要以一种快速且可重用的方式扩展Playlist对象在组件树中的注入。事先知道组件从自身开始执行提供者查找,然后将请求传递给其父组件的注入器,我们可以通过在父组件中注册提供者,甚至是顶级父组件中注册提供者来解决这个问题,这样依赖项将可用于每个子组件的注入。在这种情况下,我们可以直接在MusicAppComponent中注册Playlist对象,而不管它是否需要它进行自己的实现:

@Component({
 selector: 'music-app',
 providers: [Playlist],
 template: '<music-library></music-library>'
})
export class MusicAppComponent {}

即使直接子组件可能也不需要依赖项进行自己的实现。由于它已经在其父MusicAppComponent组件中注册,因此无需再次在那里注册:

@Component({
 selector: 'music-library',
 template: '<music-player></music-player>'
})
export class MusicLibraryComponent {}

最后,我们到达了我们的音乐播放器组件,但现在它的providers属性中不再包含Playlist类型作为注册令牌。实际上,我们的组件根本没有providers属性。它不再需要这个,因为该类型已经在组件层次结构的某个地方注册,立即可用于所有子组件,无论它们在哪里:

@Component({
 selector: 'music-player'
})
export class MusicPlayerComponent {
 constructor(private playlist: playlist) {}
}

现在,我们看到依赖项如何向下注入组件层次结构,以及组件如何执行提供者查找,只需检查其自己注册的提供者并将请求向上冒泡到组件树中。但是,如果我们想限制这种注入或查找操作呢?

限制依赖项向下注入组件树

在我们之前的例子中,我们看到音乐应用组件在其提供者集合中注册了播放列表令牌,使其立即可用于所有子组件。有时,我们可能需要限制依赖项的注入,仅限于层次结构中特定组件旁边的那些指令(和组件)。我们可以通过在组件装饰器的viewProviders属性中注册类型令牌来实现这一点,而不是使用我们已经看到的 providers 属性。在我们之前的例子中,我们可以仅限制Playlist的向下注入一级:

@Component({
 selector: 'music-app',
 viewProviders : [Playlist],
 template: '<music-library></music-library>'
})
export class MusicAppComponent {}

我们正在告知 Angular,Playlist提供程序只能被位于MusicAppComponent视图中的指令和组件的注入器访问,而不是这些组件的子级。这种技术的使用是组件的专属,因为只有它们具有视图。

限制提供程序查找

就像我们可以限制依赖注入一样,我们可以将依赖查找限制在仅限于直接上一级。为此,我们只需要将@Host()装饰器应用于那些我们想要限制提供程序查找的依赖参数:

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

@Component {
 selector: 'music-player'
}
export class MusicPlayerComponent {
 constructor(@Host() playlist:Playlist) {}
}

根据前面的例子,MusicPlayerComponent注入器将在其父组件的提供程序集合(在我们的例子中是MusicLibraryComponent)中查找Playlist类型,并在那里停止,抛出异常,因为Playlist没有被父级注入器返回(除非我们还用@Optional()参数装饰器装饰它)。

为了澄清这个功能,让我们做另一个例子:

@Component({
 selector: 'granddad',
 template: 'granddad <father>'
 providers: [Service]
})
export class GranddadComponent {
 constructor(srv:Service){}
}

@Component({
 selector: 'father',
 template: 'father <child>'
})
export class FatherComponent {
 constructor(srv:Service) {} // this is fine, as GranddadComponent provides Service
}

@Component({
 selector: 'child',
 template: 'child'
})
export class ChildComponent {
  constructor(@Host() srv:Service) {} // will cause an error
}

在这种情况下,我们会得到一个错误,因为Child组件只会向上查找一级,尝试找到服务。由于它向上两级,所以找不到。

在注入器层次结构中覆盖提供程序

到目前为止,我们已经看到了 Angular 的 DI 框架如何使用依赖标记来内省所需的类型,并从组件层次结构中可用的任何提供程序集中返回它。然而,在某些情况下,我们可能需要覆盖与该标记对应的类实例,以便需要更专业的类型来完成工作。Angular 提供了特殊工具来覆盖提供程序,甚至实现工厂,该工厂将返回给定标记的类实例,不一定匹配原始类型。

我们在这里不会详细涵盖所有用例,但让我们看一个简单的例子。在我们的例子中,我们假设Playlist对象应该在组件树中的不同实体中可用。如果我们的MusicAppComponent指令托管另一个组件,其子指令需要Playlist对象的更专业版本,该怎么办?让我们重新思考我们的例子:

MusicAppComponent
 MusicChartsComponent
 MusicPlayerComponent
 MusicLibraryComponent
 MusicPlayerComponent

这是一个有点牵强的例子,但它肯定会帮助我们理解覆盖依赖项的要点。 Playlist实例对象从顶部组件向下都是可用的。 MusicChartsComponent指令是一个专门为畅销榜中的音乐提供服务的组件,因此其播放器必须仅播放热门歌曲,而不管它是否使用与MusicLibraryComponent相同的组件。我们需要确保每个播放器组件都获得适当的播放列表对象,这可以在MusicChartsComponent级别通过覆盖与Playlist标记对应的对象实例来完成。以下示例描述了这种情况,利用了provide函数的使用:

import { Component } from '@angular/core';
import { Playlist } from './playlist';

import { TopHitsPlaylist } from './top-hits/playlist';

@Component({
 selector: 'music-charts',
 template: '<music-player></music-player>',
 providers: [{ provide : Playlist, useClass : TopHitsPlaylist }]
})
export class MusicChartsComponent {}

provide关键字创建了一个与第一个参数中指定的标记(在本例中为Playlist)映射的提供程序,而useClass属性本质上是用来从该组件和下游重写播放列表为TopHitsPlaylist

我们可以重构代码块以使用viewProviders,以确保(如果需要)子实体仍然接收Playlist的实例,而不是TopHitsPlaylist。或者,我们可以走额外的路线,并使用工厂根据其他要求返回我们需要的特定对象实例。以下示例将根据布尔条件变量的评估返回Playlist标记的不同对象实例:

function playlistFactory() {
 if(condition) { 
 return new Playlist(); 
 }
 else { 
 return new TopHitsPlaylist(); 
 }
}

@Component({
 selector: 'music-charts',
 template: '<music-player></music-player>',
 providers: [{ provide : Playlist, useFactory : playlistFactory }]
})
export class MusicChartsComponent {}

所以,你可以看到这有多强大。例如,我们可以确保在测试时,我们的数据服务突然被模拟数据服务替换。关键是很容易告诉 DI 机制根据条件改变其行为。

扩展注入器支持到自定义实体

指令和组件需要依赖项进行内省、解析和注入。其他实体,如服务类,通常也需要这样的功能。在我们的示例中,我们的Playlist类可能依赖于与第三方通信的 HTTP 客户端的依赖项,以获取歌曲。注入这种依赖的操作应该像在类构造函数中声明带注释的依赖项一样简单,并且有一个注入器准备好通过检查类提供程序或任何其他提供程序来获取对象实例。

只有当我们认真思考后者时,我们才意识到这个想法存在一个漏洞:自定义类和服务不属于组件树。因此,它们不会从任何内置的注入器或父注入器中受益。我们甚至无法声明提供者属性,因为我们没有用@Component@Directive装饰器修饰这些类型的类。让我们看一个例子:

class Playlist {
 songs: Song[];
 constructor(songsService: SongsService) {
 this.songs = songsService.fetch();
 }
}

我们可能会尝试这样做,希望当实例化这个类以将其注入到MusicPlayerComponent中时,Angular 的 DI 机制会内省Playlist类构造函数的songsService参数。不幸的是,我们最终得到的只是这样的异常:

It cannot resolve all parameters for Playlist (?). Make sure they all have valid type or annotations.

这有点误导,因为Playlist中的所有构造函数参数都已经被正确注释了,对吧?正如我们之前所说,Angular DI 机制通过内省构造函数参数的类型来解析依赖关系。为了做到这一点,需要预先创建一些元数据。每个被装饰器修饰的 Angular 实体类都具有这些元数据,这是 TypeScript 编译装饰器配置细节的副产品。然而,还需要其他依赖项的依赖项没有装饰器,因此也没有为它们创建元数据。这可以通过@Injectable()装饰器轻松解决,它将为这些服务类提供 DI 机制的可见性。

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

@Injectable()
class Playlist {
 songs: string[];

 constructor(private songsService: SongsService) {
 this.songs = this.songsService.fetch();
 }
}

你会习惯在你的服务类中引入装饰器,因为它们经常依赖于与组件树无关的其他依赖项,以便提供功能。

实际上,无论构造函数是否具有依赖关系,都将所有服务类装饰为@Injectable()是一个很好的做法。这样,我们可以避免因为忽略这一要求而导致的错误和异常,一旦服务类增长,并且在将来需要更多的依赖关系。

使用bootstrapModule()初始化应用程序

正如我们在本章中所看到的,依赖查找一直冒泡直到顶部的第一个组件。这并不完全正确,因为 DI 机制还会检查bootstrapModule()函数的额外步骤。

据我们所知,我们使用 bootstrapModule() 函数来通过在其第一个参数中声明根模块来启动我们的应用程序,然后指出根组件,从而启动应用程序的组件树。

在文件 main.ts 中,典型的引导看起来像下面这样:

import { enableProdMode } from  '@angular/core'; import { platformBrowserDynamic } from  '@angular/platform-browser-dynamic'; import { AppModule } from  './app/app.module'; import { environment } from  './environments/environment'; if (environment.production) {
  enableProdMode(); }

platformBrowserDynamic().bootstrapModule(AppModule);

从上述代码中可以得出的结论是,Angular 已经改变了引导的方式。通过添加 @NgModule,我们现在引导一个根模块而不是一个根组件。然而,根模块仍然需要指向一个应用程序启动的入口点。让我们来看看根模块是如何做到这一点的:

import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';

@NgModule({
 bootstrap: [AppComponent]
 // the rest omitted for brevity
})

注意 bootstrap 键的存在,我们如何指出根组件 AppComponent。还要注意 bootstrap 属性是一个数组。这意味着我们可以有多个根组件。每个根组件都将具有自己的注入器和服务单例集,彼此之间没有任何关系。接下来,让我们谈谈我们可以在其中进行修改的不同模式。

在开发和生产模式之间切换

Angular 应用程序默认在开发模式下引导和初始化。在开发模式下,Angular 运行时会向浏览器控制台抛出警告消息和断言。虽然这对于调试我们的应用程序非常有用,但当应用程序处于生产状态时,我们不希望显示这些消息。好消息是,可以禁用开发模式,转而使用更为安静的生产模式。这个操作通常是在引导我们的应用程序之前执行的:

import { environment } from './environments/environment';
// other imports omitted for brevity
if(environment.production) {
 enableProdMode();
}

//bootstrap
platformBrowserDynamic().bootstrapModule(AppModule);

我们可以看到,调用 enableProdMode() 是启用生产模式的方法。

Angular CLI 中的不同模式

值得注意的是,将不同的环境配置保存在不同的文件中是一个好主意,如下所示:

import { environment } from './environments/environment';

environments 目录包括两个不同的文件:

  • environment.ts

  • environment.prod.ts

第一个文件看起来像这样:

export const environment = {
 production: false
}

第二个文件看起来像这样:

export const environment = {
 production: true
}

根据我们调用 ng build 命令的方式,将使用其中的一个文件:

ng build --env=prod // uses environment.prod.ts
ng build // by default uses environment.ts 

要找出哪些文件映射到哪个环境,您应该查看 angular-cli.json 文件:

// config omitted for brevity
"environments" : {
 "dev": "environments/environment.ts",
 "prod": "environments/environment.prod.ts"
}

介绍应用程序目录结构

在前几章和本章的各个部分中,我们已经看到了布局 Angular 应用程序的不同方法和良好实践。这些准则涵盖了从命名约定到如何组织文件和文件夹的指针。从现在开始,我们将通过重构所有不同的接口、组件、指令、管道和服务,将所有这些知识付诸实践,使其符合最常见的社区约定。

到本章结束时,我们将拥有一个最终的应用程序布局,将我们迄今所见的一切都包含在以下站点架构中:

app/
 assets/ // global CSS or image files are stored here
 core/
 (application wide services end up here)
 core.module.ts
 shared/
 shared.module.ts // Angular module for shared context
 timer/
 ( timer-related components and directives )
 timer.module.ts // Angular module for timer context
 tasks/
 ( task-related components and directive )
 task.module.ts // Angular module for task context
 app
 app.component.ts
 app.module.ts // Angular module for app context
 main.ts // here we bootstrap the application
 index.html
 package.json
 tsconfig.json
 typings.json

很容易理解项目的整体原理。现在,我们将组合一个应用程序,其中包含两个主要上下文:计时器功能和任务列表功能。每个功能可以包含不同范围的组件、管道、指令或服务。每个功能的内部实现对其他功能或上下文是不透明的。每个功能上下文都公开了一个 Angular 模块,该模块导出了每个上下文提供给上层上下文或应用程序的功能部分(即组件,一个或多个)。所有其他功能部分(内部指令和组件)对应用程序的其余部分是隐藏的。

可以说很难划清界限,区分哪些属于特定上下文,哪些属于另一个上下文。有时,我们构建功能部分,比如某些指令或管道,可以在整个应用程序中重用。因此,将它们锁定到特定上下文并没有太多意义。对于这些情况,我们确实有共享上下文,其中存储着任何旨在在应用程序级别可重用的代码单元,而不是与组件无关的媒体文件,如样式表或位图图像。

app.component.ts文件包含并导出应用程序根组件,该组件声明并在其自己的注入器中注册其子组件所需的依赖项。正如您已经知道的,所有 Angular 应用程序必须至少有一个根模块和一个根组件,由bootstrapModule()函数初始化。这个操作实际上是在main.ts文件中执行的,该文件由index.html文件触发。

在这样的上下文中定义一个组件或一组相关组件可以提高可重用性和封装性。唯一与应用程序紧密耦合的组件是顶级根组件,其功能通常非常有限,基本上是在其模板视图中呈现其他子组件或作为路由器组件,正如我们将在后续章节中看到的那样。

最后一部分是包含 TypeScript 编译器、类型和npm配置的 JSON 文件。由于 Angular 框架的版本不断发展,我们不会在这里查看这些文件的实际内容。你应该知道它们的目的,但一些具体内容,比如对等依赖版本,经常会发生变化,所以最好参考本书的 GitHub 仓库获取每个文件的最新版本。不过,package.json文件需要特别提及。有一些常见的行业惯例和流行的种子项目,比如 Angular 官方网站提供的项目。我们提供了几个npm命令来简化整个安装过程和开发工作。

按照 Angular 的方式重构我们的应用程序

在本节中,我们将把我们在前几章中创建的代码分割成代码单元,遵循单一职责原则。因此,除了将每个模块分配到其自己的专用文件中之外,不要期望代码有太多变化。这就是为什么我们将更多地关注如何分割事物,而不是解释每个模块的目的,你应该已经知道了。无论如何,如果需要,我们将花一分钟讨论变化。

让我们从在你的工作文件夹中创建与前一节中看到的相同的目录结构开始。我们将在路上为每个文件夹填充文件。

共享上下文或将所有内容存储在一个公共模块中

共享上下文是我们存储任何构造的地方,其功能旨在一次被多个上下文使用,因为它对这些上下文也是不可知的。一个很好的例子是我们一直在用来装饰我们组件的番茄钟位图,它应该存储在app/shared/assets/img路径下(顺便说一句,请确实将它保存在那里)。

另一个很好的例子是对模型数据建模的接口,特别是当它们的模式可以在不同功能上下文中重复使用时。例如,当我们在第四章中定义了QueuedOnlyPipe时,我们只对记录集中项目的排队属性进行了操作。然后,我们可以认真考虑实现一个Queued接口,以便以后在具有该属性的模块中提供类型检查。这将使我们的管道更具重用性和模型无关性。代码如下:

//app/shared/queueable.model.ts

export interface Queueable {
 queued: boolean;
}

请注意这个工作流程:首先,我们定义与这个代码单元对应的模块,然后导出它,并将其标记为默认,这样我们就可以从其他地方按名称导入它。接口需要以这种方式导出,但在本书的其余部分,我们通常会在同一语句中声明并导出模块。

有了这个接口,我们现在可以安全地重构QueuedOnlyPipe,使其完全不依赖于Task接口,以便在任何需要过滤记录集的上下文中完全重用,无论它们代表什么。代码如下:

// app/shared/queued.only.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
import { Queueable } from '../interfaces/queuable';

@Pipe({ name : 'queuedOnly' })
export class QueuedOnlyPipe implements PipeTransform {
 transform(queueableItems: Queueable[], ...args) :Queueable[] {
 return queuableItems.filter( 
 queueableItem:Queueable => queueableItem.queued === args[0]
 )
 }
}

正如您所看到的,每个代码单元都包含一个单一的模块。这个代码单元符合 Angular 文件名的命名约定,清楚地以驼峰命名法陈述了模块名称,再加上类型后缀(在这种情况下是.pipe)。实现也没有改变,除了我们用Queuable类型注释了所有可排队的项目,而不是之前的任务注释。现在,我们的管道可以在任何实现Queueable接口的模型存在的地方重复使用。

然而,有一件事情需要引起您的注意:我们不是从源位置导入Queuable接口,而是从一个名为shared.ts的文件中导入,该文件位于上一级目录。这是共享上下文的门面文件,我们将从该文件公开所有公共共享模块,不仅供消费共享上下文模块的客户端使用,还供共享上下文内部的模块使用。这是一个情况:如果共享上下文内的任何模块更改其位置,我们需要更新门面,以便任何其他引用该模块的元素在同一上下文中保持不受影响,因为它通过门面来消费它。现在是一个很好的时机来介绍我们的共享模块,以前它将是一个门面文件:

//app/shared/shared.module.ts

import { QueuedOnlyPipe } from './pipes/queued-only.pipe';

@NgModule({
 declarations: [QueuedOnlyPipe],
 exports: [QueuedOnlyPipe]
})
export class SharedModule {}

与门面文件的主要区别在于,我们可以通过向SharedModule添加方法和注入服务等方式向其添加各种业务逻辑。

到目前为止,我们只通过SharedModule的 exports 属性公开了管道、指令和组件,但是其他东西如类和接口呢?嗯,我们可以在需要时直接要求它们,就像这样:

import { Queueable } from '../shared/queueable';

export class ProductionService {
 queueable: Queueable;
}

现在我们有一个可工作的Queuable接口和一个SharedModule,我们可以创建其他接口,这些接口将在整本书中使用,对应于Task实体,以及我们需要的其他管道:

//app/task/task.model.ts

import { Queueable } from './queueable';

export interface Task extends Queueable {
 name: string;
 deadline: Date;
 pomodorosRequired: number;
}

我们通过使用 extends(而不是 implements)在 TypeScript 中将一个接口实现到另一个接口上。现在,对于FormattedTimePipe

//app/shared/formatted.time.pipe.ts

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

@Pipe({ name : 'formattedTime' })
export class FormattedTimePipe {
 transform(totalMinutes: number) {
 let minutes: number = totalMinutes % 60;
 let hours: number = Math.floor( totalMinutes / 60 );
 return `${hours}h:${minutes}m`;
 }
}

最后,我们需要更新我们的SharedModule,以包含这个Pipe

//app/shared/shared.module.ts

import { QueuedOnlyPipe } from './pipes/queued-only.pipe';
import { FormattedTimePipe } from './pipes/formatted-time.pipe';

@NgModule({
 declarations: [QueuedOnlyPipe, FormattedTimePipe],
 exports: [QueuedOnlyPipe, FormattedTimePipe]
})
export class SharedModule {}

总结一下我们在这里做的事情,我们创建了两个接口,TaskQueueable。我们还创建了两个管道,QueuedOnlyPipeFormattedTimePipe。我们将后者添加到我们的@NgModule的 declarations 关键字中,至于接口,我们将使用import关键字根据需要将它们引入应用程序。不再需要通过门面文件公开它们。

共享上下文中的服务

让我们谈谈在共享上下文中拥有服务的影响,以及@NgModule的添加带来了什么。我们需要关心两种类型的服务:

  • 一个瞬态服务;这个服务创建自己的新副本,可能包含内部状态,对于每个创建的副本,它都有自己的状态

  • 一个单例,只能有一个此服务,如果它有状态,我们需要确保在整个应用程序中只有一个此服务的副本

在 Angular 中使用依赖注入,将服务放在模块的提供者中将确保它们最终出现在根注入器上,因此如果我们有这种情况,它们将只创建一个副本:

// app/task/task.module.ts

@NgModule({
 declarations: [TaskComponent],
 providers: [TaskService]
})
export class TaskModule {} 

早些时候,我们在TaskModule中声明了一个TaskService。让我们来定义另一个模块:

@NgModule({
 declarations: [ProductsComponent]
 providers: [ProductsService] 
})
export class ProductsModule {}

只要我们在根模块中导入这两个模块,就像这样:

//app/app.module.ts

@NgModule({
 imports: [TaskModule, ProductsModule]
})
export class AppModule {}

我们现在已经创建了一个情况,ProductsServiceTaskService可以被注入到ProductsComponentTaskComponent的构造函数中,这要归功于ProductsModuleTaskModule都被导入到AppModule中。到目前为止,我们还没有问题。然而,如果我们开始使用延迟加载,我们就会遇到问题。在延迟加载中,用户导航到某个路由,我们的模块与其构造一起被加载到包中。如果延迟加载的模块或其构造之一实际上注入了,比如ProductsService,那么它将不是TaskModuleProductsModule正在使用的相同ProductsService实例,这可能会成为一个问题,特别是如果状态是共享的。解决这个问题的方法是创建一个核心模块,一个被AppModule导入的模块;这将确保服务永远不会因错误而被再次实例化。因此,如果ProductsService在多个模块中使用,特别是在延迟加载的模块中使用,建议将其移动到核心模块。因此,我们从这样做:

@NgModule({
 providers: [ProductsService],
})
export class ProductsModule {}

将我们的ProductService移动到核心模块:

@NgModule({
 providers: [ProductsService]
})
export class CoreModule {}

当然,我们需要将新创建的CoreModule添加到我们的根模块中,就像这样:

@NgModule({
 providers: [],
 imports: [CoreModule, ProductsModule, TasksModule]
})
export class AppModule {}

有人可能会认为,如果我们的应用程序足够小,早期创建一个核心模块可能被视为有点过度。反对这一观点的是,Angular 框架采用移动优先的方法,作为开发人员,你应该延迟加载大部分模块,除非有充分的理由不这样做。这意味着当你处理可能被共享的服务时,你应该将它们移动到一个核心模块中。

在上一章中,我们构建了一个数据服务来为我们的数据表填充任务数据集。正如我们将在本书后面看到的那样,数据服务将被应用程序的其他上下文所使用。因此,我们将其分配到共享上下文中,并通过我们的共享模块进行暴露:

//app/task/task.service.ts

import { Injectable } from '@angular/core';
import { Task } from '../interfaces/task';

@Injectable()
export class TaskService {
 taskStore: Task[] = [];
 constructor() {
 const tasks = [
 {
 name : 'task 1',
 deadline : 'Jun 20 2017 ',
 pomodorosRequired : 2
 },
 {
 name : 'task 2',
 deadline : 'Jun 22 2017',
 pomodorosRequired : 3
 }
 ];

 this.taskStore = tasks.map( task => {
 return {
 name : task.name,
 deadline : new Date(task.deadline),
 queued : false,
 pomodorosRequired : task.pomodorosRequired
 }
 });
 }
}

请注意我们如何导入Injectable()装饰器并在我们的服务上实现它。它在构造函数中不需要任何依赖项,因此依赖于此服务的其他模块在声明构造函数时不会有任何问题。原因很简单:在我们的服务中默认应用@Injectable()装饰器实际上是一个很好的做法,以确保它们在开始依赖其他提供者时仍然能够无缝注入,以防我们忘记对它们进行装饰。

从中央服务配置应用程序设置

在之前的章节中,我们在我们的组件中硬编码了很多东西:标签、持续时间、复数映射等等。有时,我们的上下文意味着具有高度的特定性,并且在那里拥有这些信息是可以接受的。但是,有时我们可能需要更灵活和更方便的方式来全局更新这些设置。

对于这个例子,我们将使所有l18n管道映射和设置都可以从共享上下文中的一个中央服务中获得,并像往常一样从shared.ts门面暴露出来。

以下代码描述了一个将保存应用程序所有配置的SettingsService

// app/core/settings.service.ts
import { Injectable } from '@angular/core';

@Injectable()
export class SettingsService {
 timerMinutes: number;
 labelsMap: any;
 pluralsMap: any;

 contructor() {
 this.timerMinutes = 25;
 this.labelsMap = {
 timer : {
 start : 'Start Timer',
 pause : 'Pause Timer',
 resume : 'Resume Countdown',
 other : 'Unknown'
 }
 };

 this.pluralsMap = {
 tasks : {
 '=0' : 'No pomodoros',
 '=1' : 'One pomodoro',
 'other' : '# pomodoros'
 }
 }
 }
}

请注意我们如何将与上下文无关的映射属性暴露出来,这些属性实际上是有命名空间的,以更好地按上下文分组不同的映射。

将此服务分成两个特定的服务并将它们放置在各自的上下文文件夹中,至少就l18n映射而言,这是完全可以的。请记住,诸如时间持续等数据将在不同的上下文中使用,正如我们将在本章后面看到的那样。

在我们的共享模块中将所有内容整合在一起

通过所有最新的更改,我们的shared.module.ts应该是这样的:

// app/shared/shared.module.ts

import { NgModule } from '@angular/core';
import { FormattedTimePipe } from './pipes/formatted-time-pipe';
import { QueuedOnlyPipe } from './pipes/queued-only-pipe';

import { SettingsService } from './services/settings.service';
import { TaskService } from './services/task.service';

@NgModule({
 declarations: [FormattedTimePipe, QueuedOnlyPipe],
  providers: [SettingsService, TaskService],
  exports: [FormattedTimePipe, QueuedOnlyPipe]
})
export class SharedModule {}

我们的SharedModule从前面暴露了FormattedTimePipeQueuedOnlyPipe,但是有一些新的添加;即,我们添加了provider关键字的内容。我们添加了我们的服务,SettingsServiceTaskService

现在,当这个模块被另一个模块消耗时,会发生一件有趣的事情;所以,让我们在下面的代码中看看这样的情景:

// app/app.module.ts

import { NgModule } from '@angular/core';
import { SharedModule } from './shared/shared.module';

@NgModule({
  imports: [SharedModule]
 // the rest is omitted for brevity
})
export class AppModule {}

从前面部分部分知道了导入另一个模块的影响。我们知道SharedModule中包含的所有内容现在都可以在AppModule中使用,但还有更多。SharedModuleprovider关键字中提到的任何内容都可以被注入。所以,假设我们有以下app.component.ts文件:

// app/app.component.ts

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

@Component({
 selector: 'app',
 template: 'app'
})
export class AppComponent {
 constructor(
    private settingsService:SettingsService, 
 private taskService: TaskService
 ) {}
}

正如你所看到的,现在我们可以自由地注入来自其他模块的服务,只要它们是:

  • 在其模块的provider关键字中提到

  • 它们所在的模块被另一个模块导入

总之,到目前为止,我们已经学会了如何将组件和服务添加到共享模块中,还学会了我们需要在声明和export关键字中注册组件,对于服务,我们需要将它们放在provider关键字中。最后,我们需要import它们所在的模块,你的共享构件就可以在应用程序中使用了。

创建我们的组件

有了我们共享的上下文,现在是时候满足我们的另外两个上下文了:定时器和任务。它们的名称足够描述它们的功能范围。每个上下文文件夹将分配组件、HTML 视图模板、CSS 和指令文件,以提供它们的功能,还有一个外观文件,导出此功能的公共组件。

生命周期钩子简介

生命周期钩子是你在指令或组件的生命周期中监视阶段的能力。这些钩子本身是完全可选的,但如果你了解如何使用它们,它们可能会有很大的帮助。有些钩子被认为是最佳实践,而其他钩子则有助于调试和理解应用程序中发生的情况。一个钩子带有一个定义你需要实现的方法的接口。Angular 框架确保调用钩子,只要你将接口添加到组件或指令中,并通过实现接口指定的方法来履行合同。因为我们刚刚开始学习如何构建你的应用程序,现在可能还没有理由使用某些钩子。所以,我们将有理由在后面的章节中返回这个主题。

你可以使用的钩子如下:

  • OnInit

  • OnDestroy

  • OnChanges

  • DoCheck

  • AfterContentInit

  • AfterContentChecked

  • AfterViewInit

  • AfterViewChecked

在本节中,我们将涵盖本章中的前三个钩子,因为其余的涉及到更复杂的主题。我们将在本书的后续章节中重新讨论剩下的五个钩子。

OnInit - 一切开始的地方

使用这个钩子就像添加OnInit接口并实现ngOnInit()方法一样简单:

export class ExampleComponent implements OnInit {
 ngOnInit() {}
}

不过,让我们谈谈为什么存在这个钩子。构造函数应该相对空,并且除了设置初始变量之外不应包含逻辑。在构造对象时不应该有任何意外,因为有时您构造的是用于业务使用的对象,有时它是在单元测试场景中创建的。

以下是在类的构造函数中执行的适当操作的示例。在这里,我们展示了对类成员变量的赋值:

export class Component {
 field: string;
 constructor(field: string) {
 this.field = field;
 }
}

以下示例显示了不应该做的事情。在代码中,我们在构造函数中订阅了一个 Observable。在某些情况下,这是可以接受的,但通常更好的做法是将这种代码放在ngOnInit()方法中:

export class Component {
 data:Entity;
 constructor(private http:Http) {
 this.http.get('url')
 .map(mapEntity)
 .subscribe( x => this.data = x);
 }
}

最好建立订阅,如之前使用OnInit接口提供的ngOnInit()方法所示。

当然,这是一个建议,而不是一项法律。如果您没有使用这个钩子,那么显然您需要使用构造函数或类似的方法来执行前面的 HTTP 调用。除了仅仅说构造函数应该为空以美观和处理测试时,还有另一个方面,即输入值的绑定。输入变量不会立即设置,因此依赖于构造函数中的输入值会导致运行时错误。让我们举例说明上述情景:

@Component({
 selector: 'father',
 template: '<child [prop]='title'></child>'
})
export class FatherComponent {
 title: string = 'value';
}

@Component({
 selector: 'child',
 template: 'child'
})
export class ExampleComponent implements OnInit {
 @Input prop;

 constructor(private http:Http) {
    // prop NOT set, accessing it might lead to an error
 console.log('prop constructor',prop) 
 }

 ngOnInit() {
    console.log('prop on init', prop) // prop is set and is safe to use
 }
}

在这个阶段,您可以确保所有绑定已经正确设置,并且可以安全地使用 prop 的值。如果您熟悉 jQuery,那么ngOnInit的作用很像$(document).ready()的构造,总的来说,当组件设置完成时发生的仪式在这一点上已经发生。

OnDestroy - 当组件从 DOM 树中移除时调用

这种典型用例是在组件即将离开 DOM 树时进行一些自定义清理。它由OnDestroy接口和ngOnDestroy()方法组成。

为了演示其用法,让我们看一下下面的代码片段,我们在其中实现了OnDestroy接口:

@Component({
 selector: 'todos',
 template: `
 <div *ngFor="let todo of todos">
 <todo [item]="todo" (remove)="remove($event)">
 </div>
 `
})
export class TodosComponent {
 todos;

 constructor() {
 this.todos = [{
 id : 1,
 name : 'clean'
 }, {
 id : 2,
 name : 'code' 
 }]
 }

 remove(todo) {
    this.todos = this.todos.filter( t => t.id !== todo.id );
 }
}

@Component({
 selector: 'todo',
 template: `
 <div *ngIf="item">{{item.name}} <button (click)="remove.emit(item)">Remove</button></div>
 `
})
export class TodoComponent implements OnDestroy {
 @Output() remove = new EventEmitter<any>();
 @Input() item;
  ngOnDestroy() { console.log('todo item removed from DOM'); }
}

我们之前的片段试图突出显示当TodoComponent的一个实例从 DOM 树中移除时。TodosComponent渲染了一个TodoComponents列表,当调用remove()方法时,目标TodoComponent被移除,从而触发TodoComponent上的ngOnDestroy()方法。

好的,很好,所以我们有一种方法来捕获组件被销毁的确切时刻...那又怎样呢?

这是我们清理资源的地方;通过清理,我们的意思是:

  • 超时,间隔应该在这里被取消订阅

  • 可观察流应该被取消订阅

  • 其他清理

基本上,任何导致印记的东西都应该在这里清理。

OnChanges - 发生了变化

这个钩子的使用方式如下:

export class ExampleComponent implements OnChanges {
 ngOnChanges(changes:  SimpleChanges) { }
}

注意我们的方法如何接受一个名为changes的输入参数。这是一个对象,其中所有已更改的属性作为changes对象的键。每个键指向一个对象,其中包含先前值和当前值,如下所示:

{
 'prop' : { currentValue : 11, previousValue : 10 }
 // below is the remaining changed properties
}

上述代码假设我们有一个带有prop字段的类,如下所示:

export class ExampleComponent {
 prop: string;
}

那么,是什么导致事物发生变化?嗯,这是绑定的变化,也就是说,我们设置了@Input属性,如下所示:

export  class  TodoComponent  implements  OnChanges { @Input() item; ngOnChanges(changes:  SimpleChanges) { for (let  change  in  changes) { console.log(` '${change}' changed from
 '${changes[change].previousValue}' to
 '${changes[change].currentValue}' `
 ) }
 }
}

这里值得注意的一点是,我们跟踪的是引用的变化,而不是对象的属性变化。例如,如果我们有以下代码:

<todo [item]="todoItem">

如果todoItem上的 name 属性发生了变化,使得todoItem.name变为code而不是coding,这不会导致报告变化。然而,如果整个项目被替换,就像下面的代码一样:

this.todoItem = { ...this.todoItem, { name : 'coding' });

那么这将导致一个变化事件被发出,因为todoItem现在指向一个全新的引用。希望这能稍微澄清一点。

计时器功能

我们的第一个功能是属于计时器功能的,这也是最简单的功能。它包括一个独特的组件,其中包含我们在前几章中构建的倒计时计时器:

import { Component } from  '@angular/core'; import { SettingsService } from  "../core/settings.service"; @Component({
  selector:  'timer-widget', template: ` <div  class="text-center"> <h1> {{ minutes }}:{{ seconds  |  number }}</h1> <p>
 <button  (click)="togglePause()"  class="btn btn-danger"> {{ buttonLabelKey  |  i18nSelect: buttonLabelsMap }} </button>
 </p>
 </div>
 `
})
export  class  TimerWidgetComponent  {
 minutes:  number; seconds:  number; isPaused:  boolean; buttonLabelKey:  string; buttonLabelsMap:  any; constructor(private  settingsService:  SettingsService) { this.buttonLabelsMap  =  this.settingsService.labelsMap.timer; }

 ngOnInit() { this.reset(); setInterval(()  =>  this.tick(),  1000); }

 reset() { this.isPaused  =  true; this.minutes  =  this.settingsService.timerMinutes  -  1; this.seconds  =  59; this.buttonLabelKey  =  'start'; }

 private  tick():  void  { if  (!this.isPaused) { this.buttonLabelKey  =  'pause'; if  (--this.seconds  <  0) {
 this.seconds  =  59;
 if  (--this.minutes  <  0) {
 this.reset();
 }
 }
 }
 }

 togglePause():  void  {
 this.isPaused  =  !this.isPaused;
 if  (this.minutes  <  this.settingsService.timerMinutes  ||
 this.seconds  <  59
 ) {
 this.buttonLabelKey  =  this.isPaused  ?  'resume'  :  'pause';
 }
 }
}

正如你所看到的,实现方式与我们在第一章中已经看到的在 Angular 中创建我们的第一个组件基本相同,唯一的区别是通过OnInit接口钩子在 init 生命周期阶段初始化组件。我们利用l18nSelect管道更好地处理定时器每个状态所需的不同标签,从SettingsService中消耗标签信息,该服务在构造函数中注入。在本章的后面部分,我们将看到在哪里注册该提供程序。分钟数也是从服务中获取的,一旦后者绑定到类字段。

通过我们将其添加到declarations关键字以及exported关键字,后者用于启用外部访问,该组件通过TimerModule文件timer.module.ts公开导出:

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

@NgModule({
 // tell other constructs in this module about it
 declarations: [TimerWidgetComponent], 
 // usable outside of this module
 exports: [TimerWidgetComponent] 
})
export class TimerModule() {}

我们还需要记住将我们新创建的模块导入到app.module.ts中的根模块中:

import { NgModule } from '@angular/core';
import { TimerModule } from './timer/timer.module';

@NgModule({
  imports: [TimerModule]
 // the rest is omitted for brevity
})

在这一点上,我们已经创建了一个很好的结构,然后我们将为定时器功能创建更多构造。

任务功能

任务功能包含了一些更多的逻辑,因为它涉及两个组件和一个指令。让我们从创建TaskTooltipDirective所需的核心单元开始:

import { Task } from  './task.model'; import { Input, Directive, HostListener } from  '@angular/core'; @Directive({
  selector:  '[task]' })
export  class  TaskTooltipDirective { private  defaultTooltipText:  string;
 @Input() task:  Task;
 @Input() taskTooltip:  any;

 @HostListener('mouseover')
 onMouseOver() {
 if (!this.defaultTooltipText  &&  this.taskTooltip) {
 this.defaultTooltipText  =  this.taskTooltip.innerText;
 }
 this.taskTooltip.innerText  =  this.defaultTooltipText;
 }
}

指令保留了所有原始功能,并只导入了 Angular 核心类型和所需的任务类型。现在让我们来看一下TaskIconsComponent

import { Component, Input, OnInit } from '@angular/core';
import { Task } from './task.model';

@Component({
 selector: 'task-icons',
 template: `
 <img *ngFor="let icon of icons"
 src="/app/shared/assets/img/pomodoro.png"
 width="{{size}}">`
})
export class TaskIconsComponent implements OnInit {
 @Input() task: Task;
 @Input() size: number;
 icons: Object[] = [];

 ngOnInit() {
 this.icons.length = this.task.noRequired;
 this.icons.fill({ name : this.task.name });
 }
}

到目前为止一切顺利。现在,让我们转到TasksComponent。这将包括:

  • 组件文件tasks.component.ts,其中用 TypeScript 描述了逻辑

  • CSS 文件tasks.component.css,其中定义了样式

  • 模板文件tasks.component.html,其中定义了标记

从 CSS 文件开始,它将如下所示:

// app/task/tasks.component.css

h3, p {
 text-align: center;
}

.table {
 margin: auto;
 max-width: 860px;
}

继续 HTML 标记:

// app/task/tasks.component.html

<div  class="container text-center"> <h3>
 One point = 25 min, {{ queued | i18nPlural: queueHeaderMapping }} 
 for today
 <span  class="small" *ngIf="queued > 0">
 (Estimated time : {{ queued * timerMinutes | formattedTime }})
 </span>
 </h3>
 <p>
 <span  *ngFor="let queuedTask of tasks | queuedOnly: true"> <task-icons
 [task]="queuedTask" [taskTooltip]="tooltip"
 size="50">
 </task-icons>
 </span>
 </p>
 <p  #tooltip  [hidden]="queued === 0">
 Mouseover for details
 </p>
 <h4>Tasks backlog</h4>
 <table  class="table">
 <thead>
 <tr>
 <th>Task ID</th>
 <th>Task name</th>
 <th>Deliver by</th>
 <th>Points required</th>
 <th>Actions</th>
 </tr>
 </thead>
 <tbody>
 <tr  *ngFor="let task of tasks; let i = index">
 <th  scope="row">{{ (i+1) }}
 <span  *ngIf="task.queued"  class="label label-info">
 Queued</span>
 </th>
 <td>{{ task.name | slice:0:35 }}
 <span  [hidden]="task.name.length < 35">...</span>
 </td>
 <td>{{ task.deadline | date: 'fullDate' }}
 <span  *ngIf="task.deadline < today"  class="label label-danger">
 Due</span>
 </td>
 <td  class="text-center">{{ task.noRequired }}</td>
 <td>
 <button  type="button"  class="btn btn-default btn-xs"  [ngSwitch]="task.queued"  (click)="toggleTask(task)">
 <ng-template  [ngSwitchCase]="false">
 <i  class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 <ng-template  [ngSwitchCase]="true">
 <i  class="glyphicon glyphicon-minus-sign"></i>
 Remove
 </ng-template>
 <ng-template  ngSwitchDefault>
 <i  class="glyphicon glyphicon-plus-sign"></i>
 Add
 </ng-template>
 </button>
 </td>
 </tr>
 </tbody>
 </table>
</div>

请花一点时间查看应用于外部组件文件的命名约定,文件名与组件自身匹配,以便在上下文文件夹内的扁平结构中识别哪个文件属于什么。还请注意我们如何从模板中移除了主位图,并用名为timerMinutes的变量替换了硬编码的时间持续。这个变量在绑定表达式中计算完成所有排队任务的时间估计。我们将看到这个变量是如何在以下组件类中填充的:

// app/task/tasks.component.ts

import { Component, OnInit } from  '@angular/core'; import { TaskService } from  './task.service'; import { Task } from  "./task.model"; import { SettingsService } from  "../core/settings.service"; @Component({
  selector:  'tasks', styleUrls: ['tasks.component.css'], templateUrl:  'tasks.component.html' })
export  class  TasksComponent  implements  OnInit { today:  Date;
 tasks:  Task[];
 queued:  number;
 queueHeaderMapping:  any;
 timerMinutes:  number; constructor( private  taskService:  TaskService,
 private  settingsService:  SettingsService) {
 this.tasks  =  this.taskService.taskStore;
 this.today  =  new  Date();
 this.queueHeaderMapping  =  this.settingsService.pluralsMap.tasks;
 this.timerMinutes  =  this.settingsService.timerMinutes;
 }

 ngOnInit():  void  { this.updateQueued(); }

 toggleTask(task:  Task):  void  { task.queued  =  !task.queued;
 this.updateQueued();
 }

 private  updateQueued():  void  { this.queued  =  this.tasks
 .filter((Task:  Task)  =>  Task.queued)
 .reduce((no:  number,  queuedTask:  Task)  =>  {
 return  no  +  queuedTask.noRequired;
 },  0);
 }
}

TasksComponent的实现有几个值得强调的方面。首先,我们可以在组件中注入TaskServiceSettingsService,利用 Angular 的 DI 系统。这些依赖项可以直接从构造函数中注入访问器,立即成为私有类成员。然后从绑定的服务中填充任务数据集和时间持续时间。

现在让我们将所有这些构造添加到TaskModule中,也就是文件task.module.ts,并导出所有指令或组件。然而,值得注意的是,我们这样做是因为我们认为所有这些构造可能需要在应用的其他地方引用。我强烈建议您认真考虑在exports关键字中放什么,不要放什么。您的默认立场应该是尽量少地进行导出:

import { NgModule } from '@angular/core';
@NgModule({
  declarations: [TasksComponent, TaskIconsComponent, TasksTooltipDirective],
  exports: [TasksComponent],
 providers: [TaskService]
 // the rest omitted for brevity
})

我们现在已经将构造添加到declarations关键字中,以便模块知道它们,还有exports关键字,以便导入我们的TaskModule的其他模块能够使用它们。下一个任务是设置我们的AppComponent,或者也称为根组件。

定义顶级根组件

准备好所有功能上下文后,现在是时候定义顶级根组件了,它将作为整个应用程序的启动组件,以树形层次结构的一簇组件展开。根组件通常具有最少的实现。主要子组件最终会演变成子组件的分支。

以下是根组件模板的示例。这是您的应用程序将驻留在其中的主要可视组件。在这里,定义应用程序标题、菜单或用于路由的视口是有意义的。

//app/app.component.ts

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

@Component({
 selector: 'app',
 template: `
 <nav class="navbar navbar-default navbar-static-top">
 <div class="container">
 <div class="navbar-header">
 <strong class="navbar-brand">My App</strong>
 </div>
 </div>
 </nav>
 <tasks></tasks>
 `
})
export class AppComponent {}

之前已经提到过,但值得重复。我们在app.component.ts文件中使用的任何构造都不属于AppModule,都需要被导入。从技术上讲,被导入的是这些构造所属的模块。您还需要确保这些构造通过在所述模块的exports关键字中提到而得到适当的暴露。通过前面的根组件,我们可以看到在app.component.ts的模板中使用了两个不同的组件,即<timer-widget><pomodoro-tasks>。这两个组件属于不同的模块,第一个组件属于TimerModule,第二个组件属于TaskModule。这意味着AppModule需要导入这两个模块才能编译。因此,app.module.ts应该如下所示:

import { NgModule } from '@angular/core';
import { TimerModule } from './timer/timer.module';
import { TasksModule } from './tasks/tasks.module';

@NgModule({
 imports: [ TimerModule, TasksModule ]
 // omitted for brevity
})
export class AppModule {}

总结

本章确实为您从现在开始将在 Angular 上构建的所有优秀应用奠定了基础。实际上,Angular 依赖管理的实现是这个框架的一大亮点,也是一个节省时间的工具。基于组件树的应用架构不再是什么高深的技术,我们在构建其他框架(如 AngularJS 和 React)中的 Web 软件时在某种程度上也遵循了这种模式。

本章结束了我们对 Angular 核心及其应用架构的探索,建立了我们在这个新的令人兴奋的框架上构建应用时将遵循的标准。

在接下来的章节中,我们将专注于非常具体的工具和模块,这些工具和模块可以帮助我们解决日常问题,从而打造我们的 Web 项目。我们将看到如何使用 Angular 开发更好的 HTTP 网络客户端。