精通-Angular-组件第二版-全-

精通 Angular 组件第二版(全)

原文:zh.annas-archive.org/md5/74e15f35f78fc549e292088a2a9a4e5f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

网络组件长期以来一直被誉为网络开发的下一个重大飞跃。随着 Angular 框架的新版本,我们比以往任何时候都更接近这一目标。在过去的几年里,网络开发社区中关于网络组件的讨论很多。Angular 中的新组件样式指令将改变开发者的工作流程和他们对阴影 DOM 中共享的可重用自定义 HTML 块的看法。通过从头开始构建整个应用程序,这本书是一种实用的学习方法,给读者提供了构建自己组件的机会。通过《精通 Angular 组件》,学习者将专注于一个关键领域,从而在新的网络开发浪潮中走在前列。

《精通 Angular 组件》教导读者在开发用户界面时以组件为基础进行思考。这本关于 Angular 中新的以组件为中心的做事方式的丰富指南,教导读者如何为他们的 Web 项目发明、构建和管理共享的可重用组件。这本书将改变开发者对如何在 Angular 中完成事情的看法,读者将通过对有用的和有趣的示例组件进行工作。

本书面向对象

本书面向已经对基本前端网络技术有良好理解的开发者,如 JavaScript、HTML 和 CSS。你将了解 Angular 中的新组件化架构以及如何使用它来构建现代、干净的用户界面。

本书涵盖内容

第一章,基于组件的用户界面,简要介绍了 UI 开发的历史以及基于组件的用户界面的一般概念。我们将看到 Angular 2 如何处理这个概念。

第二章,准备,出发!,让读者开始他们的旅程,构建一个基于组件的 Angular 2 应用程序。它涵盖了使用组件结构化应用程序的基本要素。

第三章,处理数据和状态,专注于如何将干净的数据和状态架构构建到我们的应用程序中。我们将学习使用 RxJS 进行响应式编程、纯组件、容器组件以及许多其他概念和工具,这些都可以用来对抗应用程序状态混乱。

第四章,项目思维,专注于用户界面结构和其基本组件。读者将通过将应用程序布局组织成组件、建立组件的组成以及创建可重用标签组件来结构化应用程序界面来构建一个应用程序。读者还将构建一个可重用编辑组件和评论系统。

第五章,基于组件的路由,解释了组件如何响应路由,并使读者能够向任务管理应用程序中的现有组件添加简单路由。读者还将处理登录过程,并了解如何使用路由器保护组件。

第六章,跟上活动,涵盖了创建将在项目和任务级别上可视活动流的组件。

第七章,用户体验组件,指导读者创建许多小型可重用组件,这些组件将对任务管理应用程序的整体用户体验产生重大影响。包括文本字段的就地编辑、无限滚动、弹出通知和拖放支持等好处。

第八章,时间会证明一切,专注于创建时间跟踪组件,这些组件有助于在项目和任务级别上估算时间,同时也让用户能够记录他们在任务上花费的时间。

第九章,飞船仪表盘,专注于使用第三方库 Chartist 创建组件以在任务管理应用程序中可视化数据。

第十章,对事物进行测试,涵盖了测试 Angular 组件的一些基本方法。我们将探讨为测试而模拟/覆盖组件特定部分的可选方案。

为了充分利用本书

本书需要在您的 Windows、Mac 或 Linux 机器上安装 Node.js 的基本版本。由于本书依赖于 Angular CLI 6.0.8,至少需要 Node.js 8.9.0。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”标签。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入本书的名称,并遵循屏幕上的说明。

下载文件后,请确保使用最新版本解压缩或提取文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Angular-Components-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“让我们将主组件的视图封装更改到使用ViewEncapsulation.None模式。”

代码块按照以下方式设置:

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

@Component({
  selector: 'mac-root',
  templateUrl: './app.component.html',
  encapsulation: ViewEncapsulation.None
})
export class AppComponent {
  title = 'mac';
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

...
import {TaskService} from './tasks/task.service';
...
@NgModule({
...
 providers: [TaskService],
 ...
})
export class AppModule {
}

有时,在需要您在现有代码文件中实现代码更改的大型代码摘录中,我们使用以下格式:

  • 新或替换的代码部分将以粗体标记

  • 已经存在且不相关的代码部分使用省略号字符隐藏。

任何命令行输入或输出都按照以下方式编写:

ng new mastering-angular-components --prefix=mac

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“你应该能够看到带有欢迎消息“欢迎使用 mac!”的生成应用程序 app。”

警告或重要注意事项看起来像这样。

小贴士和技巧看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请发送电子邮件至questions@packtpub.com

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过链接至材料与我们联系至copyright@packtpub.com

如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

评论

请留下评论。一旦您阅读并使用过这本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问packtpub.com

第一章:基于组件的用户界面

虽然本书将涵盖许多与 Angular 相关的主题,但重点将主要放在创建基于组件的用户界面。理解像 Angular 这样的框架是一回事,但使用基于组件的架构建立有效的流程则是另一回事。在本书中,我将尝试解释 Angular 组件背后的核心概念以及我们如何利用这种架构来创建现代、高效和可维护的用户界面。

除了学习 Angular 背后的所有必要概念外,我们还将一起从头创建一个任务管理应用程序。这将使我们能够探索使用 Angular 提供的组件系统解决常见 UI 问题的方法。

在本章中,我们将探讨基于组件的用户界面如何帮助我们构建更强大的应用程序。在本书的整个过程中,我们将一起构建一个 Angular 应用程序,我们将充分利用基于组件的方法。本章还将介绍本书中使用的各种技术。

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

  • 基于组件的用户界面简介

  • 使用基于组件的用户界面进行封装和组合

  • UI 框架的演变

  • 标准的 Web 组件

  • Angular 组件系统的简介

  • 编写你的第一个 Angular 组件

  • NgModule 的基础知识

  • ECMAScript 和 TypeScript 的概述及历史

  • ECMAScript 7 装饰器作为元注释

  • 使用 Angular CLI 的基于 Node.js 的工具简介

以组件思维

今天的用户界面不仅仅是一堆拼凑到屏幕上的表单元素。现代用户在体验设计创新视觉展示的交互式内容时,对技术的挑战比以往任何时候都要大。

可惜,当我们为网络应用程序构思概念时,我们几乎总是倾向于以页面为单位思考,比如印刷书籍中的页面。思考一本书,这可能是传达此类内容和中介信息最有效的方式。你可以逐页浏览,无需任何真正的体力劳动,逐段阅读,只需扫描那些你不感兴趣的部分即可。

过度思考页面的问题在于,这个从书籍中借用的概念并没有很好地转化为现实世界中事物的工作方式。世界是由形成组件系统的组件构成的。

以我们的身体为例。我们主要由相互通过电和化学信号交互的独立器官组成。器官本身由蛋白质组成,这些蛋白质本身就像机器一样工作,形成一个系统。从分子、原子、质子到夸克,我们实际上无法确定哪里开始哪里结束。我们可以肯定的是,这全部都是关于具有相互依赖性的组件系统,而不是关于页面。

现代用户界面非常类似于现实世界中的组件系统。在设计时,它们在哪里、如何分布到页面上是次要的。此外,它们应该能够独立工作,并且应该在相互依赖的水平上相互交互。

组件——用户界面的器官

“我们不是在设计页面,我们是在设计组件系统。”

  • 斯蒂芬·海

这句话来自斯蒂芬·海在 2012 年奥兰多的 BDConf 上的发言,它点明了关键。界面设计实际上并不是关于页面。为了创建既高效又便于维护的用户界面,不仅是为了用户,也是为了维护它们的开发者,我们需要从组件系统的角度思考。组件是独立的,但当它们组合在一起时,可以相互交互并形成更大的组件。我们需要从整体上看待用户界面,而使用组件使我们能够做到这一点。

在接下来的主题中,我们将探讨组件的一些基本方面。其中一些已经从其他概念中得知,例如面向对象编程OOP),但在考虑组件时它们呈现出不同的光景。

封装

封装是在考虑系统维护时一个非常重要的因素。拥有经典的 OOP 背景,我了解到封装意味着将逻辑和数据捆绑到一个隔离的容器中。这样,我们可以从外部操作容器,并把它当作一个封闭系统来对待。

在可维护性和可访问性方面,这种方法有许多积极方面。处理封闭系统对于我们的代码组织很重要。然而,这甚至更重要,因为我们可以在编写代码的同时组织自己:

图片

以封装的组件组织系统使我们能够更容易地对其进行分析

我的记忆力相当差,因此在编写代码时找到合适的专注程度对我来说非常重要。即时记忆研究告诉我们,人类大脑平均一次可以记住大约七个项目。因此,我们编写代码的方式必须允许我们一次专注于更少、更小的部分。

清晰的封装帮助我们组织代码。我们可能忘记封闭系统的所有内部细节以及我们放入其中的逻辑和数据类型。我们应该只关注其表面,这使我们能够在更高的抽象级别上工作。类似于之前的图示,如果不使用封装组件的层次结构,我们的所有代码都会在同一级别上拼凑在一起。

封装鼓励我们将小型且简洁的组件隔离出来,构建一个组件系统。在开发过程中,我们可以专注于一个组件的内部,只需处理其他组件的接口。

有时候,我们会忘记我们实际进行的所有编码组织都是为了我们自己,而不是运行此代码的计算机。如果是为了计算机,那么我们可能都会重新开始用机器语言编写。强大的封装帮助我们轻松访问特定代码,专注于代码的一层,并信任胶囊中的底层实现。

下面的 TypeScript 示例展示了如何使用封装来编写可维护的应用程序。让我们假设我们在一个 T 恤工厂,我们需要一些代码来生成具有背景和前景颜色的 T 恤。此示例使用了一些 TypeScript 的语言特性。如果您不熟悉 TypeScript 的语言特性,请不要过于担心这一点。我们将在本章的后面学习这些内容:

// This class implements data and logic to represent a color 
// which establishes clean encapsulation. 
class Color { 
  constructor(private red: number, private green: number, private blue: number) {} 

  // Using this function we can convert the internal color values 
  // to a hex color string like #ff0000 (red). 
  getHex(): string { 
    return '#' + Color.getHexValue(this.red) + Color.getHexValue(this.green) + 
      Color.getHexValue(this.blue); 
  } 

  // Static function on Color class to convert a number from 
  // 0 to 255 to a hexadecimal representation 00 to ff 
  static getHexValue(number): string { 
    const hex = number.toString(16); 
    return hex.length === 2 ? hex : '0' + hex; 
  } 
} 

// Our TShirt class expects two colors to be passed during 
// construction that will be used to render some HTML 
class TShirt { 
  constructor(private backgroundColor: Color, private foregroundColor: Color) {} 

  // Function that returns some markup which represents our T-Shirts 
  getHtml(): string { 
    return ` 
      <t-shirt style="background-color: ${this.backgroundColor.getHex()}"> 
        <t-shirt-text style="color: ${this.foregroundColor.getHex()}"> 
          Awesome Shirt! 
        </t-shirt-text> 
      </t-shirt> 
    `; 
  } 
} 

// Instantiate a blue colour 
const blue: Color = new Color(0, 0, 255); 
// Instantiate a red color 
const red: Color = new Color(255, 0, 0); 
// Create a new shirt using the above colours 
const awesomeShirt: TShirt = new TShirt(blue, red); 
// Adding the generated markup of our shirt to our document 
document.body.innerHTML = awesomeShirt.getHtml(); 

使用干净的封装,我们现在可以处理 T 恤上的颜色抽象。我们不需要担心如何计算 T 恤级别的颜色十六进制表示,因为Color类已经完成了这项工作。这使得应用程序易于维护,并且非常开放,便于更改。

如果您还没有这样做,我强烈建议您阅读有关 SOLID 原则的内容。正如其名称所暗示的,这个原则的集合是一个强大的工具,可以极大地改变您组织代码的方式。您可以在罗伯特·C·马丁的《敏捷原则、模式和实践》一书中了解更多关于 SOLID 原则的内容。

组合性

组合是一种特殊的可重用性。你不是扩展现有组件,而是通过将许多较小的组件组合在一起形成一个组件系统来创建一个新的、更大的组件。

在面向对象编程语言中,组合通常用于解决大多数面向对象编程语言都存在的多重继承问题。子类多态性总是很好的,直到你达到你的设计不再符合项目最新要求的地步。让我们看看一个简单的例子,说明这个问题。

你有一个Fisher类和一个Developer类,它们都持有特定的行为。现在,你想要创建一个继承自FisherDeveloperFishingDeveloper类。除非你使用支持多重继承的语言(例如 C++,它在一定程度上这样做),否则你将无法使用继承重用这个功能。没有办法告诉语言你的新类应该从两个超类继承。使用组合,你可以轻松解决这个问题。你不需要使用继承,而是组合一个新的FishingDeveloper类,将所有行为委托给内部的DeveloperFisher实例:

interface IDeveloper {
  code(): void;
}

interface IFisher {
  fish(): void;
}

class Developer implements IDeveloper { 
 constructor(private name: string) {}

 code(): void { 
   console.log(`${this.name} writes some code!`); 
 } 
} 

class Fisher implements IFisher {
 constructor(private name: string) {}

 fish(): void { 
   console.log(`${this.name} catches a big fish!`); 
 } 
} 

class FishingDeveloper implements IFisher, IDeveloper { 
 constructor(private name: string) { 
   this.name = name; 
   this.developerStuff = new Developer(name); 
   this.fisherStuff = new Fisher(name); 
 } 

 code(): void { 
   this.developerStuff.code(); 
 } 

 fish(): void { 
   this.fisherStuff.fish(); 
 } 
} 

var bob: FishingDeveloper = new FishingDeveloper('Bob'); 
bob.code(); 
bob.fish(); 

经验告诉我们,组合可能是重用代码最有效的方式。与继承、装饰和其他提高可重用性的方法相比,组合可能是最不侵入性和最灵活的。

一些语言的最新版本也支持一种称为特质的模式,即混合。特质允许你以类似于多重继承的方式重用其他类中的某些功能性和属性。

如果我们思考组合的概念,它不过是设计生物体。我们有两个DeveloperFisher生物体,并将它们的行为统一到一个单一的FishingDeveloper生物体中。

组件,自然界发明

组件、拥抱封装和组合是构建可维护应用的有效方式。由组件组成的应用对变化的负面影响具有很强的抵抗力,而变化是每个应用都会发生的事情。你的设计最终将受到变化效应的挑战,这只是时间问题;因此,编写尽可能平滑地处理变化的代码非常重要。

自然是最好的老师。几乎所有技术发展的成就都源于对自然界解决问题方式的观察。如果我们看看进化,它就是通过适应外部力量和约束对物质进行持续重新设计。自然界通过突变和自然选择来通过不断变化解决这个问题。

如果我们将进化的概念投射到应用开发中,我们可以说自然界实际上在每一刻都在重构其代码。这实际上是每个产品经理的梦想——一个可以经历持续变化但不会失去任何效率的应用。

我认为有两个关键概念在自然界中起着重要作用,使得它能够在设计中不断变化而不会失去太多效率。这使用了封装和组合。回到我们身体的例子,我们实际上可以告诉我们的器官使用了一种非常清晰的封装。它们使用膜来创建隔离,使用静脉来输送营养,使用突触来发送信息。此外,它们有相互依赖性,并且通过电化学信息进行交流。最明显的是,它们形成了更大的系统,这是组合的核心概念。

当然,还有许多其他因素,我并不是生物学的教授。然而,我认为看到我们学会了以与自然界组织物质相似的方式组织我们的代码,这是一件非常有趣的事情。

创建可重用 UI 组件的想法相当古老,并且在各种语言和框架中得到了实现。可能最早使用 UI 组件的系统是 20 世纪 70 年代的 Xerox Alto 系统。它使用了可重用的 UI 组件,允许开发者通过在用户可以与之交互的屏幕上组合它们来创建应用程序:

图片

20 世纪 70 年代的 Xerox Alto 系统上的文件管理器用户界面

早期的前端 UI 框架,如 DHTMLX、Ext JS 或 jQuery UI,以更有限的方式实现组件,这并没有提供很大的灵活性或可扩展性。这些框架中的大多数只是提供了小部件库。UI 小部件的问题在于它们大多数并没有充分拥抱组合模式。你可以在页面上排列小部件,并且它们提供了封装,但大多数工具包中,你不能通过嵌套来创建更大的组件。一些工具包通过提供一种特殊类型的小部件来解决此问题,这通常被称为容器。然而,这并不等同于允许你创建系统内系统的完整组件树。实际上,容器是为了提供视觉布局容器而不是复合容器来形成更大的系统。

通常,当我们在应用程序的页面上处理小部件时,我们会有一个大控制器来控制所有这些小部件、用户输入和状态。然而,我们只剩下两个层次的组合,我们无法以更细粒度的方式结构化我们的代码。这里有页面,这里有小部件。仅仅有一堆 UI 小部件是不够的,我们几乎回到了创建满是表单元素页面的状态。

我已经使用 JavaServer Faces 多年了,尽管它存在许多问题,但拥有可重用自定义元素的概念是革命性的。使用 XHTML,可以编写所谓的复合组件,这些组件由其他复合组件或原生 HTML 元素组成。开发者可以通过组合获得极高的可重用性。在我看来,这个技术的重大问题是它没有足够解决前端的问题,以至于无法真正用于复杂的用户交互。事实上,这样的框架应该完全存在于前端。

我的 UI 框架愿望清单

通常,当 UI 框架被比较时,它们会根据指标相互比较,例如小部件计数、主题功能和异步数据检索功能。每个框架都有其优点和缺点,但抛开所有额外功能,将其简化为 UI 框架的核心关注点,我只剩下几个指标想要评估。当然,这些指标并不是今天 UI 开发中唯一重要的指标,但它们也是构建支持变化原则的清晰架构的主要因素:

  • 我可以创建具有清晰接口的封装组件

  • 我可以通过组合来创建更大的组件

  • 我可以让组件在其层次结构内相互交互

如果你正在寻找一个能够让你充分利用基于组件的 UI 开发的框架,你应该寻找这三个关键指标。

首先,我认为了解网络的主要目的及其演变过程非常重要。如果我们回想一下 20 世纪 90 年代的早期网络,它可能只是关于超文本。有一些非常基本的语义可以用来结构化信息并将其显示给用户。HTML 被创建来存储结构和信息。对信息定制视觉呈现的需求导致了 CSS 在 HTML 开始广泛使用后不久的发展。

布兰登·艾奇在 20 世纪 90 年代中期发明了 JavaScript,并且它最初是在 Netscape Navigator 中实现的。通过提供实现行为和状态的方法,JavaScript 成为了实现完整网页定制的最后一块缺失的拼图:

技术 关注点
HTML 结构和信息
CSS 布局
JavaScript 行为和状态

我们已经学会了尽可能地将这些关注点分开,以保持清晰的架构。尽管对此有不同的看法,并且一些最近的技术也开始偏离这一原则,但我认为这些关注点的清晰分离对于创建可维护的应用程序非常重要。

把这个视图放在一边,面向对象编程中封装的标准定义只是关注逻辑和数据耦合与隔离。这可能很好地适用于经典软件组件。然而,一旦我们将用户界面视为架构的一部分,就会增加一个新的维度。

经典的 MVC 框架以视图为中心,开发者根据页面组织代码。你可能会继续创建一个新的视图来表示一个页面。当然,你的视图需要一个控制器和模型,所以你也会创建它们。按页面组织的问题在于,几乎没有获得复用性的收益。一旦你创建了一个页面,你只想重用页面的一部分,你需要一种方法来封装这个模型的具体部分——视图和控制器。

UI 组件很好地解决了这个问题。我喜欢把它们看作是 MVC 的模块化方法。尽管它们仍然遵循 MVC 模式,但它们也建立了封装和可组合性。这样,视图本身就是一个组件,但它也由组件组成。通过组合组件的视图,可以最大限度地提高复用性:

UI 组件拥抱 MVC,但它们在更低的层面上也支持封装和组合。

技术上,使用 Web 技术实现组件时存在一些挑战。JavaScript 始终足够灵活,可以实施不同的模式和范式。与封装和组合一起工作根本不是问题,组件的控制部分和模型可以轻松实现。例如,揭示模块模式、命名空间、原型或最近的 ECMAScript 6 模块等方法,都提供了从 JavaScript 方面需要的所有工具。

然而,对于组件的视图部分,我们面临一些限制。尽管 HTML 在可组合性方面提供了很大的灵活性,因为 DOM 树本质上就是一个大型的组合,但我们无法重用这些组合。我们只能创建一个大的组合,即页面本身。HTML 只是从服务器端交付的最终视图,这从来就不是真正的问题。今天的应用程序要求更高,我们需要在浏览器中运行一个完全封装的组件,它还包含部分视图。

我们在 CSS 上也面临着同样的问题。在编写 CSS 时,没有真正的模块化和封装,我们需要使用命名空间和前缀来隔离我们的 CSS 样式。尽管如此,CSS 的整个级联特性很容易破坏我们试图通过 CSS 结构模式引入的任何封装。

新标准的时间到了

在过去几年里,Web 标准已经发生了巨大的变化。有如此多的新标准,浏览器已经成为一个如此庞大的多媒体框架,以至于其他平台很难与之竞争。

我甚至可以说,Web 技术实际上将在未来取代其他框架,并且它可能将被重新命名为多媒体技术或类似的东西。我们没有理由需要使用不同的原生框架来创建用户界面和演示。Web 技术集成了许多功能,很难找到不使用它们的理由。只需看看 Firefox OS 或 Chrome OS,它们都是设计用来使用 Web 技术运行的。我认为这只是时间问题,直到更多操作系统和嵌入式设备开始利用 Web 技术来实现它们的软件。这就是为什么我相信在某个时刻,Web 技术 这个术语是否仍然合适,或者我们应该用更通用的术语来替代它,将变得可疑。

尽管我们通常只看到浏览器中新功能的出现,但它们背后有一个非常开放且冗长的标准化过程。标准化功能非常重要,但这需要花费大量时间,尤其是在人们对于解决问题的不同方法存在分歧时。

回到组件的概念,这是我们真正需要 Web 标准支持以突破当前限制的地方。幸运的是,W3C(万维网联盟)也有同样的想法,一群开发者开始在名为 Web 组件 的伞形规范下制定规范。

以下主题将为您简要概述两个在 Angular 组件中也起到作用的规范。Angular 的核心优势之一是它更像是一个 Web 标准的超集,而不是一个完全独立的框架。

模板元素

模板元素允许您在 HTML 中定义不会由浏览器渲染的区域。然后,您可以使用 JavaScript 实例化这些文档片段,并将生成的 DOM 放置在文档中。

当浏览器实际上正在解析模板内容时,它只是为了验证 HTML。解析器通常执行的所有即时操作都不会被执行。在模板元素的内容中,图像不会加载,脚本也不会执行。只有当模板被实例化后,解析器才会采取必要的行动,如下所示:

<body> 
<template id="template"> 
  <h1>This is a template!</h1> 
</template> 
</body> 

这个简单的 HTML 模板元素示例不会在您的页面上显示标题。因为标题位于模板元素内部,我们首先需要实例化模板,并将生成的 DOM 添加到我们的文档中:

var template = document.querySelector('#template'); 
var instance = document.importNode(template.content, true); 
document.body.appendChild(instance); 

使用这三行 JavaScript,我们可以实例化模板并将其附加到我们的文档中。

Angular 使用模板元素来实例化用户界面的动态部分。这将在使用 ngIf 指令有条件地渲染模板的部分时发生,或者通过使用 ngFor 指令重复模板时发生。

阴影 DOM

这部分 Web 组件规范是创建适当 DOM 封装和组合所缺失的部分。有了阴影 DOM,我们可以创建隔离的 DOM 部分,这些部分可以防止外部常规 DOM 操作。此外,CSS 不会自动进入阴影 DOM,我们可以在我们的组件内创建局部 CSS。

如果你将style标签添加到阴影 DOM 内部,样式将限定在阴影 DOM 的根元素内,并且它们不会泄露到外部。这为 CSS 提供了非常强大的封装。

内容插入点使得从阴影 DOM 组件的外部控制内容变得容易,并且它们提供了一种传递内容的接口。

在撰写本书时,大多数浏览器都支持阴影 DOM,尽管在 Firefox 中仍需要启用。

Angular 的组件架构

对我来说,Angular 第一版中指令的概念改变了前端 UI 框架的游戏规则。这是我第一次感觉到有一个简单而强大的概念,允许创建可重用的 UI 组件。指令可以与 DOM 事件或消息服务进行通信。它们允许你遵循组合原则,你可以嵌套指令并创建由较小指令组合而成的较大指令。实际上,指令是浏览器中组件的一个非常好的实现。

在本节中,我们将探讨 Angular 的组件化架构以及我们关于组件所学的知识如何融入 Angular。

一切都是组件

作为 Angular 的早期采用者,在与其他人谈论它时,我经常被问及与第一版最大的区别是什么。我对这个问题的回答总是相同的。一切都是组件:

图片

在 Angular 架构中,组件是一个具有附加视图的指令。

对我来说,这种范式转变是简化并丰富了框架的最相关变化。当然,Angular 还有很多其他的变化。然而,作为一个基于组件的用户界面倡导者,我发现这个变化是最有趣的。当然,这个变化也伴随着许多架构上的变化。

Angular 支持从整体上看待用户界面的想法,并鼓励使用组件进行组合。然而,与第一版最大的区别是,现在你的页面不再是全局视图;它们只是由其他组件组装而成的组件。如果你一直在跟随本章,你会注意到这正是整体方法对用户界面所要求的。不再有页面,而是组件系统。

Angular 仍然使用指令的概念,尽管指令现在确实如其名称所暗示的那样。它们是浏览器附加给定行为的命令。组件是一种带有视图的特殊指令。

您的第一个组件

按照传统,在我们开始一起构建真实的应用程序之前,我们应该使用 Angular 编写我们的第一个hello world组件:

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

@Component({ 
  selector: 'hello-world', 
  template: '<div>Hello {{name}}</div>' 
}) 
class HelloWorldComponent {
  name: string = 'World';
}

这已经是一个完全工作的 Angular 组件。我们使用了 ECMAScript 6 类来创建组件所需的封装。你还可以看到用于声明性配置我们的组件的元注解。这个看起来像是一个带有at符号前缀的函数调用的语句,实际上来自 ECMAScript 7 装饰器提案。目前,你可以将装饰器视为将元数据附加到我们的组件类的一种方式。

在撰写本书时,ECMAScript 7 装饰器仍然非常实验性。我们在本书的示例中使用了 TypeScript,它已经通过轻微的修改实现了装饰器提案。Angular 核心团队决定采用这种实验性技术,因为它减少了代码总量,并为 Angular API 引入了面向方面的风味。

重要的是要理解,一个元素只能绑定到一个单一组件。因为组件总是带有视图,所以我们无法将多个组件绑定到元素上。另一方面,一个元素可以绑定到多个指令,因为指令不带有视图——它们只附加行为。

Component装饰器中,我们需要配置与描述我们的组件相关的所有内容,以便 Angular 使用。这当然也包括我们的视图模板。在前面的示例中,我们直接在 JavaScript 中以字符串的形式指定了我们的模板。我们还可以使用templateUrl属性来指定模板应该从中加载的 URL。

第二种配置,通过使用selector属性应用,允许我们指定一个 CSS 选择器,Angular 使用这个选择器将组件附加到我们视图中的某些元素上。每次 Angular 遇到与组件选择器匹配的元素时,它都会将给定的组件渲染到该元素中。

现在,让我们稍微增强我们的示例,以便我们可以看到我们如何从更小的组件中组合我们的应用程序:

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

@Component({ 
  selector: 'shout-out', 
  template: '<strong>{{words}}</strong>' 
}) 
class ShoutOutComponent { 
  @Input() words: string; 
} 

@Component({ 
  selector: 'hello-world' 
  template: '<shout-out words="Hello, {{name}}!"></shout-out>'
}) 
class HelloWorldComponent { 
  name: string = 'World';
}

你可以看到,我们现在创建了一个小组件,允许我们像我们喜欢的那样大声喊出单词。在我们的Hello World应用程序中,我们使用这个组件来大声喊出 Hello, World!

在我们的 hello world 组件的模板中,我们通过放置一个与喊话组件的 CSS 元素选择器匹配的 HTML 元素来包含喊话组件。

在本书的整个过程中,以及编写我们的任务管理应用时,我们将学习更多关于组件配置和实现的知识。然而,在我们开始 第二章 “准备,设置,启动!”之前,我们应该看看本书中我们将使用的一些工具和语言特性。

Angular NgModule

仅通过组合组件来组织应用会带来一些挑战。Angular 支持应用模块的概念,本质上这些模块只是组件的容器,有助于结构化你的应用。

NgModule 的概念引入主要是为了解决以下问题:

  • 显式模板解析

    通过使用模块并声明应用模块内部使用的所有组件、指令、管道和提供者,Angular 能够非常明确地解析 HTML 模板。这在调试时非常有帮助。假设你在组件模板中包含了一个元素,而这个元素与模块内组件指定的任何选择器都不匹配。现在 Angular 可以断言一个错误,因为你明确地告诉了它模块内可用的组件。如果不告诉 Angular 哪些组件属于你的应用模块,它将无法知道你是否在模板中包含了不存在的组件。

  • 更简单的依赖解析

    由于 Angular 现在可以简单地解析主应用模块以找出应用中存在哪些组件,因此事情变得简单多了。想象一下,你有一个由数百个组件组成的非常复杂的应用。没有模块,Angular 需要逐个跟踪每个组件,以找出它们之间的依赖关系。有了模块,Angular 可以简单地检查模块内部声明的组件,以找到所有组件。

  • 使用 Angular 路由的懒加载

    Angular 的路由器能够在需要时懒加载应用的部分。这是一个非常强大的功能,但它要求你声明一个包含组件或指令等应用实体的包,以便在主应用启动后异步加载。在这个时候,NgModule 非常有用。通过使用 NgModule 创建一个单独的模块,你现在可以定义应用的一部分,包括新的组件和其他实体。在应用的构建过程中,这个模块将单独构建成自己的 JavaScript 资源,然后可以在运行时由路由器异步加载。

你的应用至少需要一个主模块,该模块声明了所有应用组件。让我们来看一个非常简单的例子,并构建 HelloWorld 组件的主模块:

import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {HelloWorldComponent} from './hello-world.component';
import {ShoutOutComponent} from './shout-out.component';

@NgModule({
  declarations: [HelloWorldComponent, ShoutOutComponent],
  imports: [BrowserModule],
  bootstrap: [HelloWorldComponent]
})
export class HelloWorldAppModule { }

与组件定义类似,我们使用 ES6 类和装饰器来定义 Angular 模块。我们主应用程序模块的NgModule装饰器有三个配置属性:

图片

模块依赖树:模块 A 导入模块 B 和 C,以便所有组件都对模块 A 可用

declarations属性用于告诉 Angular 该模块中存在哪些组件、指令和管道。如果我们的应用程序由 30 个组件组成,我们需要将它们全部添加到NgModule的声明中。每次你创建一个新的组件时,你也需要将其添加到应用程序模块中的声明数组中。

imports属性的数组中,我们可以告诉 Angular 导入其他NgModule。这样,如果你喜欢,你可以从许多较小的模块中组合你的应用程序模块。然而,除非你将子模块作为库导出,或者你正在使用之前讨论过的路由器的懒加载功能,否则将应用程序结构化为子模块并没有真正的优势。在主应用程序模块中始终导入 Angular 的BrowserModule是至关重要的。BrowserModule包含所有在浏览器环境中运行应用程序所需的 Angular 核心组件、指令和其他依赖项。

最后,bootstrap属性告诉 Angular 哪些组件应该首先渲染。你应该在这里指定你的主应用程序组件,它代表你应用程序的根组件。在这本书的第二章中,我们将更详细地探讨 Angular 的引导机制。

未来的 JavaScript

不久前,有人问我我们是否真的应该使用 ECMAScript 5.1 的 bind 函数,因为这样我们可能会遇到浏览器兼容性问题。网络发展非常快,我们需要跟上节奏。我们不能编写不使用最新特性的代码,即使这会在旧浏览器中引起问题。

负责编写 ECMAScript 规范的技术委员会 TC39 的杰出人士们已经出色地逐步增强了 JavaScript 语言。这一点,加上 JavaScript 的灵活性,使我们能够使用所谓的 polyfills 和 shims 来使我们的代码在旧浏览器中运行。

ECMAScript 6(也称为 ECMAScript 2015)于 2015 年 6 月发布,正好是其前身四年后。它新增了大量 API 以及许多新的语言特性。这些语言特性是语法糖,ECMAScript 6 可以被转换为之前的版本,在旧浏览器中运行得很好。在撰写本书时,当前所有浏览器版本都没有完全实现 ECMAScript 6,但完全没有理由不将其用于生产应用程序。

语法糖是一种设计方法,我们在不破坏向后兼容性的情况下演进编程语言。这允许语言设计者提出新的语法,这丰富了开发者的体验,但不会破坏网络。每个新特性都需要转换成旧语法。这样,所谓的 transpilers 就可以用来将代码转换成旧版本。

我说 JavaScript,请翻译!

当编译器将高级语言编译成低级语言时,transpiler 或 transcompiler 更像是一个转换器。它是一种源到源的编译器,可以将代码转换成在另一个解释器中运行的代码。

最近,在将新语言编译成 JavaScript 并在浏览器中运行的新语言之间,确实存在一场真正的战斗。我使用 Google Dart 有一段时间了,我必须承认,我真的很喜欢这个语言特性。非标准化语言的问题在于它们严重依赖于社区采用和炒作。此外,它们几乎肯定永远不会在浏览器中本地运行。这也是我为什么更喜欢标准 JavaScript,以及使用 transpilers 和 polyfills 的未来的 JavaScript 的原因。

有些人认为 transpilers 引入的代码性能不佳,因此建议您根本不要使用 ECMAScript 6 和 transpilers。我不同意这种观点,原因有很多。通常,这关乎微秒甚至纳秒级别的性能,对于大多数应用来说这通常并不重要。

我并不是说性能不重要,但性能总是需要在特定语境下讨论。如果你试图通过将处理时间从 10 微秒减少到 5 微秒来优化应用程序中的循环,而你永远不会迭代超过 100 个项目,那么你可能正在浪费时间在错误的事情上。

此外,一个非常重要的是事实是,transpiled 代码是由那些比我更了解微性能优化的人设计的,我确信他们的代码运行速度比我快。在此基础上,transpiler 可能也是你想要进行性能优化的正确地方,因为这段代码是自动生成的,你不会因为性能问题而失去代码的可维护性。

我想在这里引用唐纳德·克努特的话,说过早的优化是万恶之源。我强烈建议你阅读他关于这个主题的论文(唐纳德·克努特,1974 年 12 月,使用 goto 语句的结构化编程)。仅仅因为 goto 语句被从所有现代编程语言中废除,并不意味着这不是一篇好读的文章。

在本章的后面部分,你将了解一些工具,这些工具可以帮助你轻松地在项目中使用 transpilers,我们还将看看 Angular 在源代码方面做出的决策和方向。

让我们看看 ECMAScript 6 带来的几个语言特性,这些特性让我们的生活变得更加容易。

类是 JavaScript 中最受欢迎的功能之一,我也是投票支持它的人之一。嗯,由于我来自面向对象背景,并且习惯于在类中组织一切,所以很难让我放手。尽管如此,在一段时间内使用现代 JavaScript 之后,你会将它们的使用减少到最低限度,并且只用于它们被制造的目的——继承。

ECMAScript 6 中的类为你提供了语法糖,以处理原型、构造函数、super 调用和对象属性定义,让你产生一种错觉,认为 JavaScript 可以是一个基于类的面向对象语言:

class Fruit { 
  constructor(name) { this.name = name; } 
} 
const apple = new Fruit('Apple'); 

正如我们在关于转译器的上一个主题中学到的,ECMAScript 6 可以被去糖化为 ECMAScript 5。让我们看看转译器从这个简单例子中会产生什么:

function Fruit(name) { this.name = name; } 
var apple = new Fruit('Apple'); 

这个简单的例子可以很容易地使用 ECMAScript 5 构建。然而,一旦我们使用基于类的面向对象语言的更复杂特性,去糖化过程就会变得相当复杂。

ECMAScript 6 类引入了简化的语法来编写类成员函数(静态函数),使用 super 关键字,以及使用 extends 关键字进行继承。

如果你想要了解更多关于类和 ECMAScript 6 中功能的信息,我强烈推荐你阅读 Dr. Axel Rauschmayer 的文章(www.2ality.com/)。

模块

模块提供了一种封装你的代码和创建隐私的方法。在面向对象语言中,我们通常使用类来做这件事。然而,我实际上认为这与其说是一种好的实践,不如说是一种反模式。类应该用于需要继承的地方,而不仅仅是用来结构化你的代码。

我相信你已经遇到了很多不同的 JavaScript 模块模式。其中最受欢迎的一种是使用立即执行函数表达式(IIFE)的函数闭包来创建隐私的揭示模块模式。如果你想了解更多关于这个以及其他一些优秀的模式,我推荐阅读 Addy Osmani 的书籍《Learning JavaScript Design Patterns》。

在 ECMAScript 6 中,我们现在可以使用模块来达到这个目的。我们只需为每个模块创建一个文件,然后我们使用导入和导出关键字将我们的模块连接起来。

在 ECMAScript 6 模块规范中,我们可以从每个模块中导出我们喜欢的东西。然后我们可以从任何其他模块导入这些命名的导出。每个模块可以有一个默认导出,这特别容易导入。默认导出不需要命名,导入时也不需要知道它们的名称:

import SomeModule from './some-module.js'; 
var something = SomeModule.doSomething(); 
export default something; 

使用模块的方式有很多种。在接下来的章节中,我们将一起在任务管理应用程序的开发过程中发现其中的一些。如果您想看到更多关于如何使用模块的示例,我可以推荐 Mozilla 开发者网络文档(developer.mozilla.org)中关于 importexport 关键字的说明。

模板字符串

模板字符串非常简单,但它们是 JavaScript 语法中一个极其有用的补充。它们主要有三个用途:

  • 编写多行字符串

  • 字符串插值

  • 标签模板字符串

在模板字符串出现之前,编写多行字符串相当繁琐。您需要手动拼接字符串片段,并在行尾添加换行符:

const header = '<header>\n' + 
  '  <h1>' + title + '</h1>\n' + 
  '</header>'; 

使用模板字符串,我们可以大大简化这个例子。我们可以编写多行字符串,还可以使用之前用于连接的字符串插值功能:

const header = ` 
  <header> 
    <h1>${title}</h1> 
  </header> 
`; 

注意,我们使用了反引号而不是之前的单引号。模板字符串始终用反引号书写,解析器将解释它们之间的所有字符作为结果字符串的一部分。这样,源文件中存在的换行符也会自动成为字符串的一部分。

您还可以看到,我们使用了美元符号后跟花括号来插值我们的字符串。这允许我们在字符串中写入任意 JavaScript 代码,并在构建 HTML 模板字符串时非常有帮助。

您可以在 Mozilla 开发者网络上了解更多关于模板字符串的信息。

TypeScript

TypeScript 是由 Anders Hejlsberg 在 2012 年创建的,旨在实现 ECMAScript 6 的未来标准,同时也提供了一组超集的语法和特性,这些特性原本并不包含在规范中。

TypeScript 中有许多特性是 ECMAScript 6 标准的超集,包括但不限于以下内容:

  • 带有类型注解的可选静态类型

  • 接口

  • 枚举类型

  • 泛型

重要的是要理解 TypeScript 提供的所有作为超集的特性都是可选的。您可以编写纯 ECMAScript 6 代码,而不必利用 TypeScript 提供的附加特性。TypeScript 编译器仍然会将纯 ECMAScript 6 代码无错误地转换为 ECMAScript 5。

TypeScript 中看到的大多数特性实际上在其他语言中已经存在,例如 Java 和 C#。TypeScript 的一个目标是为大型应用程序提供支持工作流程和更好的可维护性的语言特性。

任何非标准语言的缺点在于,没有人能确定这种语言将维持多久,以及它在未来的势头将有多快。就支持而言,TypeScript,凭借其赞助商微软,实际上可能会拥有很长的一生。然而,仍然没有保证语言的势头和趋势会以合理的速度持续发展。显然,对于标准的 ECMAScript 6 来说,这个问题并不存在,因为它是未来网络的构成部分,以及浏览器将原生支持的语言。

尽管如此,如果你想要解决以下明显超过项目未来不确定性的负面影响的问题,使用 TypeScript 的扩展功能是有充分理由的:

  • 经历大量更改和重构的大型应用程序

  • 在编码时需要严格治理的大型团队

  • 创建基于类型的文档,否则将难以维护

当前的 Angular 版本完全是基于 TypeScript 的,因此如果你开始使用 Angular 作为你的框架,这是你的最佳选择。即使不使用编译器,也有方法使用 Angular 与纯 ECMAScript,但你将错过一些出色的语言特性和支持。

在这本书中,我们使用 TypeScript 来展示所有示例,以及创建我们的任务管理系统。我们将要使用的大多数功能已经在本章中或将要向你解释。TypeScript 的类型系统相当直观,然而,如果你想了解更多关于 TypeScript 及其功能的信息,我强烈建议你访问他们官方网站上的 TypeScript 文档:www.typescriptlang.org

Angular 中的 TypeScript 历史

当 Angular 项目开发时,核心团队包括他们能得到的最佳语言支持是很重要的。在评估不同的语言时,他们实际上已经考虑了 Google Dart 和 TypeScript 作为实现框架的潜在候选人。然而,在 TypeScript 提供的超集中缺少了一个主要功能。让我们再次看看我们在上一节中编写的第一个 Angular 组件:

@Component({ 
  selector: 'hello-world', 
  template: '<div>Hello World</div>' 
}) 
class HelloWorld {}

一个 Angular 组件始终由一个 ECMAScript 6 类以及用于配置我们的组件的@Component装饰器组成。当 Google 开始开发 Angular 项目时,还没有 ECMAScript 7 装饰器提案,TypeScript 也不支持类似的功能。尽管如此,Angular 团队不想错过这样一个可以简化并简化他们框架 API 使用的语言特性。这标志着 AtScript 的诞生。AtScript 是由 Angular 核心团队创建的,它是 TypeScript 的一个分支,增加了使用 at 符号编写元注释的可能性。同时,ECMAScript 7 装饰器提案被创建,以向 JavaScript 标准提出类似的功能。仅在几个月后,随着 TypeScript 1.5 版本的发布,微软宣布他们将在 TypeScript 转换器中包含对装饰器的实验性支持。

现在,Angular 已经完全切换到 TypeScript、AtScript 以及 Dart,后者在核心项目中不再受支持。他们已经更改了代码,以便在 TypeScript 的实验性装饰器支持下运行,不再依赖于自定义解决方案。

从这段相当冗长的历史中,你可以了解到 Angular 核心团队为了能够使用装饰器语言特性而进行了艰苦的斗争。他们成功了。鉴于这个特性的重要性,我们将在下一节中简要讨论我们在 ECMAScript 7 装饰器中拥有的可能性。

装饰器

装饰器不是 ECMAScript 6 规范的一部分,但它们被提议在 2016 年的 ECMAScript 7 标准中。它们为我们提供了一种在设计时装饰类和属性的方法。这允许开发者在编写类时使用元注释,并声明性地将功能附加到类及其属性上。

装饰器是以最初在 Erich Gamma 及其同事所著的《设计模式:可复用面向对象软件元素》(Design Patterns: Elements of Reusable Object-Oriented Software)一书中描述的装饰器模式命名的,这本书也被称为四人帮GoF)。

装饰的原则是拦截现有的过程,装饰器有机会委托、提供替代过程,或者两者兼而有之:

图片

以简单的访问过程为例,在动态环境中可视化装饰

ECMAScript 7 中的装饰器可以用来注释类和类属性。请注意,这还包括类方法,因为类方法也是类原型对象属性的一部分。装饰器被定义为常规函数,并且可以使用at符号附加到类或类属性上。每次装饰器被放置时,我们的装饰器函数都会使用关于包含位置的上下文信息被调用。

让我们来看一个简单的例子,它说明了装饰器的使用:

function logAccess(obj, prop, descriptor) { 
  const delegate = descriptor.value; 
  descriptor.value = function() { 
    console.log(`${prop} was called!`); 
    return delegate.apply(this, arguments); 
  }; 
} 

class MoneySafe { 
  @logAccess 
  openSafe() { 
    this.open = true; 
  } 
} 

const safe = new MoneySafe(); 
safe.openSafe(); // openSafe was called! 

我们创建了一个 logAccess 装饰器,它将记录所有带有装饰器的函数调用。如果我们查看 MoneySafe 类,我们可以看到我们已经用我们的 logAccess 装饰器装饰了 openSafe 方法。

logAccess 装饰器函数将在我们代码中的每个注解属性上执行。这使我们能够拦截给定属性的属性定义。让我们看看我们的装饰器函数的签名。放置在类属性上的装饰器函数将以属性定义的目标对象作为第一个参数被调用。第二个参数是实际定义的属性名,后面是最后一个参数,即应该应用于对象的描述符对象。

装饰器给了我们拦截属性定义的机会。在我们的情况下,我们使用这种能力来交换描述符值(即注解函数)与一个在调用原始函数之前记录函数调用的代理函数。为了简化起见,我们实现了一个非常简单但又不完整的函数代理。对于现实世界的场景,建议使用更好的代理实现,例如 ECMAScript 6 代理对象。

装饰器是利用面向方面概念并声明式地在设计时向我们的代码添加行为的一个很好的特性。

让我们看看第二个例子,其中我们使用了一种不同的方式来声明和使用装饰器。我们可以将装饰器视为函数表达式,其中我们的装饰器函数被重写为一个工厂函数。这种使用形式在需要将配置传递给装饰器时特别有用,该配置在装饰器工厂函数中可用:

function delay(time) { 
  return function(obj, prop, descriptor) { 
    const delegate = descriptor.value; 
    descriptor.value = function() { 
      const context = this; 
      const args = arguments; 
      return new Promise(function(success) { 
        setTimeout(function() { 
          success(delegate.apply(context, arguments)); 
        }, time); 
      }); 
    }; 
  }; 
} 

class Doer { 
  @delay(1000) 
  doItLater() { 
    console.log('I did it!'); 
  } 
} 

const doer = new Doer(); 
doer.doItLater(); // I did it! (after 1 second) 

我们现在已经学会了如何使用 ECMAScript 7 装饰器帮助你编写具有面向方面特性的声明式代码。这大大简化了开发过程,因为我们现在可以在设计时考虑添加到我们类中的行为,当我们实际上将类作为一个整体来思考并编写类的初始存根时。

TypeScript 中的装饰器与 ECMAScript 7 中的装饰器略有不同。它们不仅限于类和类属性,还可以放置在类方法内的参数上。这允许你注解函数参数,这在某些情况下可能很有用:

class TypeScriptClass { 
  constructor(@ParameterDecorator() param) {} 
}  

Angular 使用这个特性来简化类构造函数上的依赖注入。由于所有指令、组件和服务类都是由 Angular 依赖注入实例化,而不是直接由我们实例化,这些注解帮助 Angular 找到正确的依赖。对于这个用例,函数参数装饰器实际上非常有意义。

目前,类方法参数上装饰器的实现仍然存在问题,这也是为什么 ECMAScript 7 不支持它的原因。TypeScript 编译器已经解决了这个问题,但目前并不符合 ECMAScript 7 的提案。

工具

为了利用所有这些未来的技术,我们需要一些工具来支持我们。我们之前已经讨论了 ECMAScript 6 和装饰器,我们实际上更喜欢 TypeScript 装饰器,因为它们支持 Angular 使用的构造函数参数装饰器。尽管 ECMAScript 6 语法支持模块,我们仍然需要某种模块加载器,它实际上会在浏览器中加载所需的模块或帮助我们生成可执行的包。

Node.js 和 npm

Node.js 是增强版的 JavaScript。最初,Node.js 是 Google Chrome 浏览器中的 V8 JavaScript 引擎的一个分支,后来扩展了更多功能,特别是为了使 JavaScript 在服务器端变得有用。文件处理、流、系统 API 和庞大的用户生成包生态系统只是使这项技术成为你网络开发杰出伙伴的一些事实。

节点包管理器,NPM,是通往超过 20 万个包和库的大门,这些包和库可以帮助你构建自己的应用程序或库。Node.js 的哲学与 UNIX 哲学非常相似,即包应该保持小巧而锋利,但它们应该通过组合来实现更大的目标。

为了构建我们的应用程序,我们将依赖 Node.js 作为我们将要使用的工具的主机。因此,我们应该确保在我们的机器上安装 Node.js,以便为下一章做好准备,我们将开始构建我们的任务管理应用程序。

本书中的代码使用 Node.js 8.9.0 编写。请确保你在系统上安装了一个等效的 Node.js 版本。你可以从他们的网站 nodejs.org 获取 Node.js,按照网站上的说明安装应该非常简单。

一旦你安装了 Node.js,我们可以执行一个简单的测试来检查一切是否正常运行。打开终端控制台并执行以下命令:

node -e "console.log('Hello World');"

Angular CLI

有许多方法可以开始一个新的 Angular 项目。最方便的方法可能是使用 Angular CLI。正如名称所暗示的,CLI 是一个命令行界面,用于创建新项目以及现有项目中的新工件。

以下说明将指导你使用 Angular CLI 工具创建你的第一个 Angular 项目。

  1. 让我们从在你的系统上安装 Angular CLI 开始。在你的命令行中执行以下命令:
npm install -g @angular/cli@6.0.8
  1. 在安装了 Angular CLI 工具之后,你现在可以使用它来搭建一个新的 Angular 项目。你可以在终端中输入 ng 来访问工具的可执行文件。让我们打开另一个终端窗口,使用 Angular CLI 工具创建一个新的 Angular 项目:
ng new my-first-app --prefix mac
  1. 前一步需要一些时间,因为你的项目所有依赖项都需要先安装。完成后,我们现在可以使用 CLI 工具来启动本地开发服务器:
cd my-first-app
ng serve
  1. 你现在可以启动你喜欢的浏览器,并打开地址 http://localhost:4200,你应该会看到欢迎来到 mac 的消息。

恭喜!你刚刚使用 Angular CLI 工具创建了你第一个 Angular 应用程序!正如我之前告诉你的,以这种方式启动 Angular 项目的便利性真的很棒。

CLI 工具可以被视为一个脚手架工具,它帮助你设置必要的工具以及项目的结构。让我们看看当你使用 CLI 创建项目时,你会免费获得的最重要功能:

  • TypeScript: 可能很明显,但为了使用转换器,你需要进行许多手动步骤来设置必要的工具。

  • Webpack: 这款强大的工具正在解决你可能还没有考虑到的许多问题。除了 TypeScript 转换,它主要关注的是加载 ECMAScript 模块,并提供一个开发服务器来预览和编辑你的项目。最后,它也是帮助你为生产使用创建项目优化打包版本的工具。

  • Karma, Jasmine, and Protractor: 这三个组合在测试方面是无敌的!当 Karma 运行你的可执行规范时,Jasmine 帮助你编写测试。另一方面,Protractor 可以用来创建完整的端到端、集成测试。

你也可以使用 ECMAScript 5 风格编写 Angular 应用程序,这将允许你立即开发应用程序而无需额外的工具。然而,如果你想充分利用 Angular 的潜力,你应该用 TypeScript 而不是 JavaScript 来编写应用程序。Angular API 针对使用未来 JavaScript 版本和 TypeScript 的功能进行了优化,以提供最佳的开发便利性。

请继续探索使用 Angular CLI 生成的源代码。在本书的章节中,我们将获得更深入的知识,这将帮助你理解和将这些组件组合在一起。目前,我们只关注 Angular CLI 的安装,并进行了快速测试运行。

摘要

在本章中,我们探讨了基于组件的方法来构建用户界面。我们讨论了理解为什么我们要随着网络标准和框架,如 Angular,走向这个方向所必需的背景知识。我们还确保我们为本书后续章节中将要使用的所有技术做好了准备。您已使用 Angular CLI 工具创建了您的第一个简单的 Angular 应用程序。现在,我们准备开始利用组件化架构的潜力来构建我们的任务管理系统。

在下一章中,我们将开始使用 Angular 组件构建我们的任务管理应用程序。我们将查看创建 Angular 应用程序所需的初始步骤,并完善前几个组件,以便构建任务列表。

第二章:准备,设置,出发!

在本章中,我们将开始构建我们的任务管理应用程序。我们将直接进入应用程序的核心,并创建管理简单任务列表所需的初始组件。

在阅读本章的过程中,你将了解以下主题:

  • NgModule 简介

  • 使用主模块引导 Angular 应用程序

  • 组件输入和输出

  • 主属性绑定

  • 样式和视图封装

  • 使用 EventEmitter 发射自定义事件

  • 组件生命周期

管理任务

在从上一章掌握基础知识之后,我们现在将继续在接下来的章节中一起创建一个任务管理应用程序。在这些章节中,你将学习一些概念,然后通过实际示例来使用它们。你还将学习如何使用组件来构建应用程序。这从文件夹结构开始,以设置组件之间的交互结束。

视觉

在本书的整个过程中,我们将要创建的任务管理应用程序应使用户能够轻松地管理任务,并帮助他们组织小型项目。可用性是任何应用程序的核心方面;因此,你需要设计一个现代且灵活的用户界面,以支持用户管理他们的任务:

图片

我们将要构建的任务管理应用程序预览

我们的任务管理应用程序将包含组件,使我们能够设计一个平台,为管理任务提供良好的用户体验。让我们定义我们应用程序的核心功能:

  • 在多个项目中管理任务并提供项目概览

  • 简单的排程以及时间和努力跟踪机制

  • 使用图形图表概述 DASHBOARD

  • 跟踪活动并提供可视审计日志

  • 一个将在不同组件间工作的简单评论系统

任务管理应用程序是本书的主要示例。因此,本书中的构建块应仅包含与本书主题相关的代码。当然,除了组件之外,应用程序还需要其他功能,如视觉设计、数据、会话管理和其他重要部分,才能运行。虽然每章所需的代码都可以在线下载,但我们只讨论与本书将要学习的话题相关的代码。

从零开始

让我们从使用 Angular CLI 创建一个新的 Angular 项目开始。我们将将其命名为 mastering-angular-components

  1. 打开控制台窗口并导航到我们的项目适当的工作空间。让我们使用 Angular CLI 来创建我们的初始项目结构:
ng new mastering-angular-components --prefix=mac
  1. 在项目成功创建后,让我们进入项目文件夹,并使用 ng serve 命令开始提供服务:
cd mastering-angular-components
ng serve

在完成前面的步骤后,您应该能够打开浏览器并将它指向 http://localhost:4200。您应该能够看到生成的应用程序 app,并显示欢迎信息:欢迎使用 mac!。

开发过程中,建议您始终运行 CLI 的服务模式。由于底层 webpack 在重新编译输出包时会使用缓存,这将大大加快您的开发过程。我建议您始终打开一个第二个命令行窗口,并在其中启动 Angular CLI 的服务模式。

让我们检查 Angular CLI 工具为我们创建的内容。除了将在后续章节中介绍的大量文件外,Angular CLI 工具还创建了组装简单 Angular 应用程序所需的核心文件。以下目录列表显示了所有关键文件,您也将在生成的项目文件夹中找到这些文件:

mastering-angular-components
├── node_modules
├── package.json
└── src
 ├── app
 │   ├── app.component.css
 │   ├── app.component.html
 │   ├── app.component.ts
 │   └── app.module.ts
 ├── index.html
 ├── styles.css
 └── main.ts

让我们快速查看这些依赖项、开发依赖项及其用途:

文件 描述
package.json node_modules 由于 Angular CLI 使用 Node.js 作为工具,因此我们的项目包含一个 package.json 文件来存储所有必需的依赖项及其版本。Node 依赖项安装在 node_modules 文件夹中。如果您想检查与您的项目一起安装的 Angular 版本,可以检查 package.json 文件中的依赖项。
src/index.html 这是您项目的主体 HTML 文件。在此文件中,您将找到根组件的主元素。这是您根或主要组件将被渲染的地方。只需打开文件,您就会注意到一个名为 <mac-root> 的元素。由于我们使用 Angular CLI 创建项目时指定了前缀 mac,因此我们所有的组件以及所有组件的主元素都包含此前缀。
src/main.ts 这是我们的 TypeScript 项目代码的主要入口文件。它包含启动 Angular 和引导主应用程序模块所需的所有必要代码。
src/styles.css 我们希望应用到我们的应用程序网站上的任何全局 CSS 样式都放在这里。
src/app/app.module.ts 这是您的 Angular 项目的主体 NgModule。当您的应用程序启动时,会引导此模块。它包含对您的项目组件的引用,并指定了启动时应渲染的主要入口组件。
src/app/app.component.ts src/app/app.component.html src/app/app.component.css 这是您的 Angular 应用程序的主要组件。该组件代表最外层的组件,有时也称为 app 或根组件。TypeScript、HTML 和 CSS 代码默认被分隔到不同的文件中。这也可以更改,以便所有内容都嵌入到 TypeScript 文件中。然而,遵循良好的分离实践,将所有关于组件的关注点放在单独的文件中是完全有意义的。

主要应用程序组件

让我们来看看我们的主要应用程序组件。你可以将其视为应用程序的最外层组件。它被称为主要组件,因为它代表了整个应用程序。这是组件树的根本所在,因此有时也被称为根组件。

首先,让我们看看位于 src/app/app.component.ts 的组件 TypeScript 文件:

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

@Component({
  selector: 'mac-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'mac';
} 

这里与我们之前在上一章中学到的组件结构化方法没有太大区别。然而,与之前创建组件的方式相比,这里有两个主要的不同点。我们不再使用 template 属性来内联编写我们的 HTML 模板,而是使用 templateUrl 属性告诉 Angular 从指定的文件加载 HTML。第二件事是我们还没有涉及到的,那就是如何为组件加载 CSS。styleUrls 属性允许我们指定一个 URL 数组,这些 URL 被解析以组装组件的样式。类似于 HTML 模板机制,我们也可以使用一个名为 styles 的属性,在组件 TypeScript 文件内内联编写我们的样式。

对于我们的应用程序,我们希望稍微改变我们处理样式的行为。创建组件时组织样式的默认方式是每个组件包含其自己的封装样式。然而,对于我们的项目,我们希望使用全局的 styles.css 文件来添加所有组件的样式。这将使与书籍源代码库一起工作变得更加容易,并消除了在本书中包含 CSS 代码片段的需要。

默认情况下,Angular 在我们的组件上使用阴影 DOM 模拟,这阻止了组件内的样式泄漏到外部并影响其他组件。然而,这种行为可以通过配置组件的视图封装来轻松更改。

Angular 有三种处理视图封装的方法,每种方法都有其优缺点。让我们看看不同的设置:

封装类型 描述
ViewEncapsulation.Emulated 如果组件设置为模拟视图封装,它将通过将生成的属性附加到组件元素并修改 CSS 选择器以包含这些属性选择器来模拟样式封装。这将启用某些形式的封装,尽管如果存在其他全局样式,外部样式仍然可能泄漏到组件中。这种视图封装模式是默认模式,除非有其他指定。
ViewEncapsulation.Native 原生视图封装应该是 Angular 中视图封装概念的最终目标。它使用上一章中描述的 Shadow DOM 来为整个组件创建一个隔离的 DOM。此模式依赖于浏览器原生支持 Shadow DOM,因此并不总是可以使用。还重要的是要注意,全局样式将不再被尊重,并且局部样式需要放置在组件的行内样式标签中(或使用组件注解上的 styles 属性)。
ViewEncapsulation.None 此模式告诉 Angular 不提供任何模板或样式封装。在我们的应用程序中,我们依赖于来自全局 CSS 的样式;因此,我们为大多数组件使用此模式。既不使用 Shadow DOM,也不使用属性来创建样式封装;我们只需简单地使用全局 CSS 文件中指定的类即可。

让我们更改主组件的视图封装模式,使用 ViewEncapsulation.None 模式。由于我们将所有样式放入全局的 src/styles.css 文件中,我们也可以完全从组件配置中移除 styleUrls 属性:

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

@Component({
  selector: 'mac-root',
  templateUrl: './app.component.html',
  encapsulation: ViewEncapsulation.None
})
export class AppComponent {
  title = 'mac';
}

资源下载

本书前几章的目标是从零开始构建我们的应用程序。有一些构建应用程序所需的代码,这些代码并不完全符合本书的主题,但为了掌握创建稳固的组件架构,它们是必要的。其中之一就是 CSS 样式。尽管它是使用网络技术构建的一切的组成部分,但在这本书中,它绝对不是需要过多关注的东西。

为了这个目的,我已经准备了本书中创建的所有组件所使用的所有 CSS 样式。在你继续工作于你的应用程序之前,你应该下载这些样式并将它们应用到你的项目中。请在第十一章 任务管理应用程序源代码 的下载部分找到确切的下载链接。

将下载的 StyleSheet 放入项目的 src 文件夹中,它将替换现有的 styles.css 文件。

主要应用 NgModule

让我们再看看由 Angular CLI 生成的主 NgModule。你可以在路径 src/app/app.module.ts 中找到它:

import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppComponent} from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule],
  bootstrap: [AppComponent],
  providers: []
})
export class AppModule { }

如果你一直在关注本书第一章中关于 Angular NgModule 的部分,那么在查看我们生成的主应用程序模块时,不应该有任何惊喜。

我们的应用目前仅包含一个组件,即 AppComponent,我们在 AppModule 中声明了这个组件。我们还指定当此模块正在启动时,应该启动此组件。

启动

我们项目的起点位于 src/main.ts 文件中。此文件负责启动 Angular 框架并启动我们的应用程序主模块。

我们可以继续启动我们的 Angular 应用程序,提供我们的主应用程序模块 AppModule

为了启动一个 Angular 模块,我们首先需要创建一个平台。对于不同的平台和环境,有许多创建平台的方法。如果你想要创建一个浏览器平台,这是浏览器环境的默认平台,我们需要从 @angular/platform-browser-dynamic 模块导入平台工厂函数 platformBrowserDynamic。只需调用平台工厂函数,我们就会收到一个新创建的平台实例。在平台实例上,我们可以调用 bootstrapModule 函数,将我们的主应用程序模块作为参数传递:

import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {AppModule} from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.log(err));

让我们更详细地看看 Angular 启动机制中涉及的步骤。我们应该尝试理解通过在平台实例上调用 bootstrapModule 函数,我们的根组件是如何被渲染到正确位置的:

  1. 首先,我们在我们的平台实例上调用 bootstrapModule 函数,将我们的主应用程序模块作为参数传递

  2. Angular 将检查我们的主应用程序模块元数据,并在 NgModule 配置的 bootstrap 属性中找到列出的 AppComponent

  3. 通过评估 AppComponent 上的元数据,查看 selector 属性,Angular 将知道在哪里渲染我们的根组件

  4. AppComponent 被渲染为我们的根组件,位于 index.html 文件中,与组件元数据中 selector 属性匹配的宿主元素

运行应用程序

为了确保我们对主组件 AppComponent 的修改按预期工作,并且我们没有破坏任何东西,让我们使用 Angular CLI 启动我们的应用程序。打开命令行,将其指向你的项目目录。然后,以服务模式启动 CLI:

ng serve

如果一切顺利,你将拥有一个显示“欢迎使用 mac!”的打开网页浏览器。

回顾

让我们回顾一下到目前为止我们已经做了什么:

  1. 我们使用 Angular CLI 初始化了一个新项目

  2. 我们修改了 src/app/app.component.ts 中的主应用程序组件,以包含 ViewEncapsulation.None 以启用全局样式

  3. 我们已经查看了生成的 MainModule 以及我们主入口文件 src/main.ts 中的启动过程

  4. 最后,我们使用 Angular CLI 启动了我们的应用程序

创建任务列表

现在我们已经设置了主应用程序组件,我们可以继续完善我们的任务管理应用程序。我们将要创建的第二个组件将负责列出任务。遵循组合的概念,我们将创建一个任务列表组件作为主应用程序组件的子组件。

让我们使用 Angular CLI 生成器功能创建一个新的任务列表组件。我们希望按区域结构化我们的应用程序,将所有与任务相关的组件放入一个 tasks 子文件夹中:

ng generate component --spec false -ve none tasks/task-list

在生成我们的组件时使用--spec false选项,我们可以跳过创建测试规范。由于我们将在后面的章节中介绍测试,所以我们目前跳过这个步骤。此外,通过使用-ve none参数,我们可以告诉 Angular 使用ViewEncapsulation.None作为默认的封装设置来创建组件。

如果你使用 Angular CLI 工具来生成组件,它们将自动添加到你的主模块中。这非常方便,可以为你节省大量的样板工作。如果你是手动创建组件,你永远不应该忘记在你的NgModule声明中包含新创建的组件。

让我们打开生成的文件src/app/tasks/task-list.ts并对其进行一些修改:

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

@Component({ 
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None 
})
export class TaskListComponent {
  tasks = [
    {id: 1, title: 'Task 1', done: false}, 
    {id: 2, title: 'Task 2', done: true}
  ];
}

我们创建了一个非常简单的任务列表组件,它内部存储了一个任务列表。这个组件将被附加到匹配 CSS 元素选择器mac-task-list的 HTML 元素上。

现在,让我们为这个组件创建一个视图模板来显示任务。如你所见,从组件元数据中的templateUrl属性,我们正在寻找一个名为task-list.component.html的文件。

让我们更改这个文件的内容,以匹配以下摘录:

<div *ngFor="let task of tasks"> 
  <input type="checkbox" [checked]="task.done"> 
  <div>{{task.title}}</div> 
</div> 

我们使用NgFor指令重复最外层的 DIV 元素,以匹配我们组件任务列表中的任务数量。Angular 中的NgFor指令将从其底层内容创建一个模板元素,并根据表达式评估的结果实例化模板中的元素。我们目前在任务列表组件中有两个任务,所以这将创建我们模板的两个实例。

为了使我们的任务列表工作,我们剩下的工作就是在主应用程序组件中包含任务列表组件。我们可以继续修改我们的src/app/app.component.html文件,并将其内容更改为以下内容:

<mac-task-list></mac-task-list>

这是我们为了让任务列表组件工作而需要做的最后一个更改。要查看你的更改,你可以启动 Angular CLI 的服务模式,如果你还没有运行它的话。

概述

让我们看看在之前的构建块中我们做了什么。通过遵循以下步骤,我们实现了在封装组件内对任务的简单列表:

  1. 我们创建了包含组件逻辑的组件 TypeScript 文件

  2. 我们在单独的 HTML 文件中创建了组件的视图

  3. 我们将组件的 HTML 元素包含在我们的主应用程序视图模板中

组件的正确大小

我们的任务列表显示正确,我们用来实现这一点的代码看起来相当不错。然而,如果我们想遵循更好的组合方法,我们应该重新思考任务列表组件的设计。如果我们划一条线来列举任务列表的责任,我们会得到诸如列出任务、向列表添加新任务、对任务列表进行排序或过滤等事情;然而,操作并不是在单个任务本身上执行的。此外,渲染任务本身超出了任务列表的责任范围。任务列表组件应该只作为任务的容器。

如果我们再次查看我们的代码,我们会发现我们违反了单一职责原则,在任务列表组件中渲染了整个任务体。让我们看看我们如何通过增加组件的粒度来修复这个问题。

目前的目标是进行代码重构练习,也称为提取。我们将任务的相关模板从任务列表模板中提取出来,并创建一个新的组件来封装任务。

让我们使用 Angular CLI 创建一个新的任务组件。打开命令行并进入我们应用程序的根目录。执行必要的代码来创建任务组件:

ng generate component --spec false -ve none tasks/task

这将生成一个新的文件夹,其中包含我们新任务组件的所有代码。现在,让我们打开位于路径src/app/tasks/task/task.component.html的 HTML 模板,并将其内容更改为表示单个任务:

<input type="checkbox" [checked]="task.done"> 
<div>{{task.title}}</div> 

我们新的task.component.html文件的内容基本上与我们任务列表组件中已有的内容相同。然而,在新建的任务组件中,我们只关心任务的外观,而不是整个任务列表。

让我们更改位于路径src/app/tasks/task/task.component.ts的任务组件 TypeScript 文件:

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

@Component({ 
  selector: 'mac-task',
  templateUrl: './task.component.html', 
  encapsulation: ViewEncapsulation.None 
}) 
export class TaskComponent {
  @Input() task: any; 
}

在本书的前一章中,我们讨论了封装和为 UI 组件建立干净封装的先决条件。这些先决条件之一是能够在组件内外设计适当的接口。这些输入和输出方法是使组件在组合中工作所必需的。这就是组件如何接收和发布信息的方式。

如您从我们的任务组件实现中看到的,我们现在正在使用类实例属性上的@Input装饰器来构建这样的接口。为了使用这个装饰器,我们首先需要从 angular 核心模块中导入它。

Angular 中的输入属性允许我们将模板中的表达式绑定到组件的类实例属性上。这样,我们可以通过组件的模板从组件外部传递数据到组件内部。这可以被视为单向绑定的一个例子,即从父组件视图到子组件实例的绑定。

如果我们在常规 DOM 属性上使用属性绑定,Angular 会直接将表达式绑定到元素的 DOM 属性上。我们使用这种类型的绑定将任务完成标志绑定到复选框的input元素的checked属性:

用法 描述
@Input() inputProp; 这允许我们将inputProp属性绑定到父组件内的组件元素。Angular 假设宿主元素上的属性与input属性的名称相同。
@Input('inp') inputProp; 你也可以覆盖应映射到此输入的属性名称。在这里,组件宿主元素的inp属性被映射到组件的输入属性inputProp

为了使用我们新创建的任务组件,最后缺失的部分是修改任务列表的现有模板。

我们通过使用在任务组件中指定的选择器内的<mac-task>元素,将任务组件包含在我们的任务列表模板中。此外,我们还需要在任务组件上创建一个输入绑定,将当前NgFor迭代中的task对象传递到task组件的task输入。我们需要将src/app/tasks/task-list/task-list.component.html文件中所有现有的内容替换为以下代码行:

<mac-task *ngFor="let task of tasks"
          [task]="task"></mac-task> 

恭喜!你已经通过将任务提取到其自己的组件中并建立了干净的组合、封装和单一职责成功地重构了你的任务列表。此外,我们现在可以说我们的任务列表是由任务组成的。

如果你考虑可维护性和可重用性,这实际上是我们构建应用程序过程中的一个非常重要的步骤。你应该不断寻找这样的组合机会,如果你觉得某件事可以被组织成多个子组件,你可能会选择这样做。当然,你也可以做得太过分。实际上没有金科玉律来确定组合的粒度应该是多少。

组件架构的组件组合和封装的正确粒度始终取决于上下文。我个人的建议是使用 OOP 中的已知原则,如单一职责,为你的组件树的良好设计打下基础。始终确保你的组件只做它们应该做的事情,正如它们的名称所暗示的那样。任务列表有列出任务和为列表提供一些过滤器或其他控件的责任。操作单个任务数据并渲染必要视图的明确责任属于任务组件,而不是任务列表。

回顾

在这个构建块中,我们清理了组件树并使用子组件建立了干净的封装。然后,我们使用输入绑定设置了 Angular 提供的接口。我们通过以下步骤执行了这些操作:

  1. 我们创建了一个任务子组件

  2. 我们使用了任务子组件与任务列表组件。

  3. 我们在任务组件中使用了输入绑定和 DOM 元素属性绑定来建立单向数据绑定。

添加任务

我们的任务列表看起来已经很不错了,但如果用户无法向列表中添加新任务,那将毫无用处。让我们一起创建一个用于输入新任务的组件。让我们创建一个新的组件,该组件负责处理将新任务添加到列表中所需的所有 UI 逻辑。

让我们使用 Angular CLI 工具创建一个新的组件占位符:

ng generate component --spec false -ve none tasks/enter-task

打开位于 src/app/tasks/enter-task/enter-task.component.html 的新创建的组件模板,并应用以下更改:

<input type="text"
       placeholder="Enter new task title..." 
       #titleInput> 
<button (click)="enterTask(titleInput)"> 
  Add Task 
</button>

此模板包含一个输入字段以及一个用于输入新任务的按钮。如果你仔细观察输入字段,你会发现我们添加了一个名为 #titleInput 的特殊属性。这被称为局部视图引用,我们可以在当前组件视图中使用此引用,或者在我们的组件代码中查询该元素。

在这种情况下,我们实际上使用局部视图引用将输入字段 DOM 元素传递给我们在“添加任务”按钮的点击事件上调用的 enterTask 函数。所有局部视图引用都作为变量在组件视图的表达式中可用。

让我们看看我们组件类的实现,用于输入新任务。为此,我们需要将 src/app/tasks/enter-task/enter-task.component.ts 文件中生成的代码替换为以下代码:

import {Component, Output, ViewEncapsulation, EventEmitter} from '@angular/core'; 

@Component({ 
  selector: 'mac-enter-task',
  templateUrl: './enter-task.component.html', 
  encapsulation: ViewEncapsulation.None 
}) 
export class EnterTaskComponent {
  @Output() outEnterTask = new EventEmitter<string>();
  enterTask(titleInput: HTMLInputElement) { 
    this.outEnterTask.emit(titleInput.value); 
    titleInput.value = '';  
    titleInput.focus();
  } 
} 

对于此组件,我们选择了一种设计方法,其中我们使用与任务列表的松散关系,实际的任务将在其中创建。尽管此组件与任务列表密切相关,但最好尽可能保持组件之间的松散耦合。

控制反转的最简单形式之一是回调函数或事件监听器,这是一个建立松散耦合的绝佳原则。在这个组件中,我们使用 @Output 装饰器创建一个事件发射器。输出属性需要是实例属性,在组件中持有事件发射器。然后,在组件的主元素上,我们可以使用事件绑定来捕获任何发射的事件。这为我们提供了极大的灵活性,我们可以利用它创建一个干净的应用程序设计,通过视图中的绑定将组件粘合在一起:

大多数情况下,你的输出名称将与你的组件实例方法名称冲突。为此,建议你在命名输出和触发输出的方法时遵循一些命名约定。在本书中,我们遵循了在所有输出名称前缀为“out”的命名约定。这样,我们可以避免名称冲突,同时保持名称相似。

用法 描述

| @Output() outputProp = new EventEmitter(); | 当调用 outputProp.emit() 时,组件上会发出一个名为 outputProp 的自定义事件。Angular 将在组件的 HTML 元素(组件使用的地方)上查找事件绑定并执行它们:

<my-comp (outputProp)="doSomething()">   

在事件绑定表达式中的表达式,你将始终可以访问一个名为 $event 的合成变量。这个变量是对事件发射器发出的数据的引用。|

| @Output('out') outputProp = new EventEmitter(); | 使用这种方式声明你的输出属性,如果你想将事件名称与属性名称区分开来。在这个例子中,当调用 outputProp.emit() 时,将触发一个名为 out 的自定义事件:

<my-comp (out)= "doSomething()">   

|

好的,让我们使用我们新创建的组件向我们的任务列表组件添加新任务。首先,让我们修改任务列表组件的现有模板。打开任务列表模板文件,src/app/tasks/task-list/task-list.component.html。我们需要将 enter-task 组件添加到模板中,并处理我们将要触发的自定义事件,一旦输入了新任务:

<mac-enter-task (outEnterTask)="addTask($event)"></mac-enter-task>
<div class="tasks">
  <mac-task *ngFor="let task of tasks"  
            [task]="task"></mac-task> 
</div>

由于进入任务组件中的输出属性名为 outEnterTask,我们可以将其与组件宿主元素上的事件绑定属性 (outEnterTask)="" 绑定。

在事件绑定表达式中,我们调用任务列表组件上的 addTask 函数。我们还使用了合成变量 $event,它包含来自进入任务组件的任务标题。现在,每次我们在进入任务组件中按下按钮并从组件中发出事件时,我们都会在事件绑定中捕获该事件,并在任务列表组件中处理它。

我们还需要对任务列表组件的 TypeScript 文件做一些小的修改。我们需要实现 addTask 函数,该函数在任务列表组件的模板中被调用。让我们打开 src/app/tasks/task-list/task-list.component.ts 并进行以下修改:

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

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None
})
export class TaskListComponent {
  tasks = [
    {id: 1, title: 'Task 1', done: false},
    {id: 2, title: 'Task 2', done: true}
  ];

  addTask(title: string) {
    this.tasks.push({
      title, done: false
    });
  }
}

我们创建了一个名为 addTask 的函数,该函数将使用传递给函数的标题将新任务添加到我们的任务列表中。现在,循环已关闭,并且来自进入任务组件的事件已转发到任务列表组件的视图中。

如果你还没有预览你的更改,现在是时候了。尝试向列表中添加新任务,并将行为与你对代码所做的更改联系起来。

回顾

我们添加了一个新的进入任务组件,该组件负责提供添加新任务的 UI 逻辑。我们涵盖了以下主题:

  • 我们创建了一个使用输出属性和事件发射器松散耦合的子组件

  • 我们学习了 @Output 装饰器及其如何用于创建输出属性

  • 我们使用事件绑定来响应组件输出并执行操作

自定义 UI 元素

浏览器中的标准 UI 元素很棒,但有时,现代 Web 应用程序需要比浏览器内可用的更智能和更复杂的输入元素。

现在,我们将创建两个特定的自定义 UI 元素,我们将从现在开始在应用程序中使用它们,以提供良好的用户体验:

  • 复选框:浏览器中已经有一个原生的复选框输入,但有时很难将其融入应用程序的视觉设计中。原生复选框在样式方面有限,因此很难使它们看起来很棒。有时,正是那些细微之处让应用程序看起来吸引人。

  • 切换按钮:这是一个切换按钮列表,列表中只有一个按钮可以被切换。它们也可以用原生的单选按钮列表表示。然而,就像原生复选框一样,单选按钮有时并不是解决这个问题的最佳视觉解决方案。一个既代表单选用户输入元素又表示切换按钮列表的列表更加现代,并提供了我们所需的视觉方面。此外,谁不喜欢按按钮呢?

让我们首先创建我们的自定义复选框 UI 元素。由于我们可能会想出几个自定义 UI 元素,我们将引入一个新的顶级 UI 文件夹。通过使用正确的参数调用 Angular CLI 生成器,我们可以在正确的文件夹中创建复选框组件的占位符:

ng generate component --spec false -ve none ui/checkbox

让我们从我们新组件的模板开始,并更改src/app/ui/checkbox/checkbox.component.html的内容:

<label class="label">
  <input class="input" type="checkbox"
         [checked]="checked"
         (change)="check($event.target.checked)">
  <span class="text">{{label}}</span>
</label>

在复选框输入上,我们有两个绑定。首先,我们在 DOM 元素上有一个checked属性的属性绑定。我们将 DOM 属性绑定到我们将要创建的组件的checked成员字段上。

此外,我们在输入元素上有一个事件绑定,我们监听复选框变化的 DOM 事件,并在我们的组件实例上调用check方法。我们使用合成变量$event传递复选框 DOM 元素的checked属性,其中变化事件发生。

接下来,让我们编辑组件类实现,修改路径src/app/ui/checkbox/checkbox.component.ts上的 TypeScript 文件:

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

@Component({
  selector: 'mac-checkbox',
  templateUrl: './checkbox.component.html',
  encapsulation: ViewEncapsulation.None
})
export class CheckboxComponent {
  @Input() label: string;
  @Input() checked: boolean;
  @Output() outCheck = new EventEmitter<boolean>();

  check(checked: boolean) {
    this.outCheck.emit(checked);
  }
}

这个组件类并没有什么特别之处。它使用一个输入属性从外部设置选中状态,并且它还有一个带有事件发射器的输出属性,允许我们通知外部组件关于选中状态的变化。

让我们把我们的复选框集成到任务组件中,以替换我们目前在那里使用的原生复选框输入。为此,我们需要修改src/app/tasks/task/task.component.html文件,用以下代码替换其之前的内容:

<mac-checkbox [checked]="task.done"
              (outCheck)="task.done = $event"></mac-checkbox>
<div class="title">{{task.title}}</div>

你现在应该已经能够在浏览器中看到变化,并看到我们漂亮的自定义复选框组件在行动。作为下一步,我们希望当任务被标记为完成时应用一些样式更改。这比仅仅勾选复选框提供了更好的视觉反馈。为此,我们正在考虑一个新的概念来操作组件的主元素。让我们打开路径src/app/tasks/task/task.component.ts上的任务组件类,并将以下代码添加到TaskComponent类的主体中:

@HostBinding('class.done') 
get done() { 
  return this.task && this.task.done; 
}

使用@HostBinding装饰器,我们可以在组件的主元素上根据我们组件的成员创建属性绑定。让我们使用这个装饰器来创建一个条件性地在组件的 HTML 元素上设置名为done的类的绑定。这用于在我们的样式中对完成的任务进行一些视觉区分。

现在是检查你的结果并在任务列表中尝试这些新的大复选框的好时机。这难道不是比激活常规复选框更有趣吗?不要低估一个令人愉悦的用户界面对产品使用的影响。这可能会对你的产品使用产生非常积极的影响:

图片

添加我们的自定义复选框组件后的任务列表

概述

在本节中,你学习了如何构建通用且松耦合的自定义 UI 组件,以便它们可以作为子组件在其他组件中使用。我们还完成了以下任务:

  • 我们创建了一个子组件,该组件通过输出属性和事件发射器进行松耦合

  • 我们学习了@Output装饰器的含义以及如何使用它来创建输出属性

  • 我们使用事件绑定从组件的视图中将行为链接在一起

任务数据服务

我们已经学到了很多关于构建基本组件以及如何将它们组合在一起以形成更大组件的知识。在上一个构建块中,我们创建了一个可重用的复选框 UI 组件,我们用它来增强任务列表的可用性。

在这个主题中,我们将使用切换按钮组件来为我们的任务列表创建一个过滤器。但在我们开始向应用程序引入更多复杂性之前,我们将通过引入数据服务来重构我们的应用程序。随着应用程序的扩大,集中我们的数据操作和简化我们的数据流变得至关重要。服务非常有用,因为它们允许我们存储状态,这些状态可以通过 Angular 的依赖注入在所有组件中访问。

从现在开始,我们将在应用程序中处理大量的数据。在构建类型安全的数据结构方面,TypeScript 为我们提供了非常好的支持。到目前为止,我们一直将任务数据作为对象字面量处理,TypeScript 从那里提取类型信息。然而,当我们开始在应用程序的不同区域使用我们的数据时,在中央位置对数据进行建模是有意义的。为此,我们将创建我们的第一个 TypeScript 接口来表示我们的任务数据。在 TypeScript 中,我们不仅可以使用接口来实现类和多态,我们还可以仅使用它来为对象字面量和对象操作添加类型安全。这被认为是一种非常有用的实践,并将帮助我们避免未来许多潜在的错误。

让我们在路径 src/app/model.ts 上创建一个新的 TypeScript 文件,并将以下内容添加到该文件中:

export interface Task {
  id?: number;
  title: string;
  done: boolean;
}

到目前为止,我们一直在任务列表组件中直接存储任务列表数据,但在这里让我们改变这一点,并使用一个为我们提供任务数据的服务。通常,将数据直接存储在组件中并不是一个好主意。将我们的数据重构到服务中只是迈向清晰组件架构的第一步,我们将在本书的后续章节中学习如何存储我们的状态和数据的不同方法。

为了使用我们即将创建的服务,我们将首次使用 Angular 的依赖注入。Angular CLI 在这里也很有用。我们可以使用它为我们生成一个服务占位符:

ng generate service --spec false tasks/task

这将在路径 src/app/tasks/task.service.ts 上为我们生成一个文件。让我们在编辑器中打开这个文件,并使用以下更改进行编辑:

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

@Injectable()
export class TaskService {
  private tasks: Task[] = [
    {id: 1, title: 'Task 1', done: false},
    {id: 2, title: 'Task 2', done: false},
    {id: 3, title: 'Task 3', done: true},
    {id: 4, title: 'Task 4', done: false}
  ];

  getTasks(): Task[] {
    return this.tasks.slice();
  }

  addTask(task: Task) {
    this.tasks.push({
      ...task,
      id: this.tasks.length + 1
    });
  }

  updateTask(task: Task) {
    const index = this.tasks
      .findIndex((t) => t.id === task.id);
    this.tasks[index] = task;
  }
}

我们已经将所有任务数据移动到了新创建的服务中。为了使我们的服务类可注入,我们需要用 @Injectable 装饰器对其进行装饰。

我们还使用 Task 接口来处理我们的任务,以便在处理任务对象时具有更好的类型安全。为了保持我们的数据封装和安全,当我们将其暴露给任何消费者时,我们将创建内部任务列表的副本。在 JavaScript 中,我们可以简单地调用 Array.prototype.slice 来创建现有数组的副本。

在我们可以在组件中使用任务服务之前,我们需要将其作为依赖项提供。依赖项通常在应用程序级别提供。为了在应用程序级别提供依赖项,我们需要对我们的主应用程序模块(位于路径 src/app/app.module.ts)进行一些修改。模块的更改在以下代码摘录中突出显示。省略号字符表示现有文件中还有更多代码,但这些代码对我们应用更改不相关:

...
import {TaskService} from './tasks/task.service';
...
@NgModule({
...
 providers: [TaskService],
 ...
})
export class AppModule {
}

由于我们已经将任务服务作为依赖项提供给主模块,因此它现在将可用于应用程序注入器中的注入。

现在,我们可以继续修改我们的任务列表组件以消费我们的任务服务。所有任务现在都存储在任务服务中,我们需要从任务列表组件中移除之前嵌入的数据。

让我们将更改应用到我们的任务列表组件,并修改src/app/tasks/task-list/task-list.component.ts文件。以下摘录包含了任务列表组件的全部代码。更改和新部分被突出显示:

import {Component, ViewEncapsulation} from '@angular/core';
import {TaskService} from '../task-list.service';
import {Task} from '../../model';

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None
})
export class TaskListComponent {
 tasks: Task[];

 constructor(private taskService: TaskService) {
 this.tasks = taskService.getTasks();
 }

  addTask(title: string) {
 const task: Task = {
 title, done: false
 };
 this.taskService.addTask(task);
 this.tasks = this.taskService.getTasks();
  }

 updateTask(task: Task) {
 this.taskService.updateTask(task);
 this.tasks = this.taskService.getTasks();
 }
}

我们现在不再在任务列表组件中存储所有任务,而是只声明tasks成员。在我们的组件构造函数中,我们使用依赖注入来注入我们新创建的任务服务。在构造函数体中,我们通过在服务上调用getTasks方法来检索任务数据。然后,这个结果列表被存储在我们的组件的tasks成员中。

addTask方法中,我们不再直接修改我们的任务列表。相反,我们正在使用服务来添加一个新任务。之后,我们需要通过再次调用getTasks来从服务中获取更新后的列表。

我们还创建了一个名为updateTask的方法来使用我们的任务服务更新任务。到目前为止,我们一直在任务组件内部直接更新任务数据。我们的复选框上的输出绑定直接从视图中更新任务的状态。在我们塑造应用程序的过程中,以更受控的方式修改应用程序的状态变得至关重要。想象一下,在你的应用程序中有数十个组件,每个组件都在修改应用程序的状态。这将是一场真正的维护噩梦。

那么,我们应该如何最好地解决这个问题呢?答案是委托。我们将状态操作委托给父组件,直到我们达到应该处理操作的组件。组件输出非常适合这个用例。我们可以通过发出输出值来告诉父组件发生了变化。在我们的情况下,这意味着以下流程应该发生:

  1. 复选框组件将告诉任务组件复选框已被勾选

  2. 任务组件将告诉任务列表组件任务应该被更新

  3. 任务列表组件将调用服务来更新任务数据

首先,我们将修复在任务组件中发生的状态操作。打开位于src/app/tasks/task/task.component.html的任务组件模板,并执行以下更改:

<mac-checkbox [checked]="task.done"
              (outCheck)="updateTask($event)"></mac-checkbox>
<div class="title">{{task.title}}</div>

现在,我们在任务组件中添加了一个新的输出,并在src/app/tasks/task/task.component.ts中实现了updateTask方法:

...
export class TaskComponent {
  @Input() task: Task;
  @Output() outUpdateTask = new EventEmitter<Task>();
  ...

  updateTask(done: boolean) {
 this.outUpdateTask.emit({
 ...this.task,
 done
 });
 }
}

太好了!现在我们唯一要做的就是捕获任务列表组件模板中的outUpdateTask输出并调用我们已添加到组件类中的updateTask方法。让我们编辑文件src/app/tasks/task-list/task-list.component.html

<mac-enter-task (outEnterTask)="addTask($event)"></mac-enter-task>
<div class="tasks">
  <mac-task *ngFor="let task of filteredTasks"
            [task]="task"
            (outUpdateTask)="updateTask($event)"></mac-task>
</div>

现在是预览您更改的好时机。我们的任务列表应该又能完全正常工作了。尝试添加新任务和标记任务为完成。由于我们不在组件内部存储任何数据,我们的任务列表组件已经变得更加简洁。相反,我们使用了一个服务,这个服务也可以在其他组件中使用。

概述

在本节中,我们并没有在应用程序的用户界面中创建任何新内容。然而,这仍然是本章中较为重要的部分之一。我们学习了关于清洁数据流、数据和状态的最佳实践,并创建并集成了我们的第一个 Angular 服务:

  1. 我们创建了一个任务服务来存储和处理我们的任务数据

  2. 我们将状态操作从任务组件委托给了任务列表组件,然后该组件与我们的服务进行交互

  3. 我们了解了@Injectable及其如何在应用级别提供依赖项

  4. 我们在任务列表组件的构造函数中注入了我们的任务服务,在那里我们使用它来获取数据

过滤任务

在本节中,我们将为我们的任务列表实现一些过滤功能。为了控制活动过滤标准,我们首先构建了一个切换按钮列表组件。让我们继续使用 Angular CLI 创建一个新的组件:

ng generate component --spec false -ve none ui/toggle

在您的控制台运行 Angular CLI 生成器命令后,让我们编辑新创建组件的 HTML 模板src/app/ui/toggle/toggle.component.html

<button class="toggle-button" 
        *ngFor="let button of buttonList" 
        [class.active]="button === activeButton" 
        (click)="activate(button)">{{button}}</button> 

实际上这里没有什么特别的!我们通过迭代一个名为buttonList的实例字段来重复一个按钮,使用NgFor指令。这个按钮列表将包含我们的切换按钮的标签。条件性地,我们使用属性绑定设置一个名为active的类,并检查它是否与迭代中的当前按钮匹配一个名为selectedButton的实例字段。当按钮被点击时,我们在组件实例上调用一个名为activate的方法,并传递迭代中的当前按钮标签。

现在,让我们更改组件类路径src/app/ui/toggle/toggle.component.ts的代码:

import {Component, Input, Output, ViewEncapsulation, EventEmitter, OnInit} from '@angular/core';

@Component({
  selector: 'mac-toggle',
  templateUrl: './toggle.component.html',
  encapsulation: ViewEncapsulation.None
})
export class ToggleComponent implements OnInit {
  @Input() buttonList: string[];
  @Input() activeButton: string;
  @Output() outActivate = new EventEmitter<string>();

  ngOnInit() {
    if (!this.activeButton) {
      this.activeButton = this.buttonList[0];
    }
  }

  activate(button: string) {
    this.outActivate.emit(button);
  }
}

在我们的切换组件中,我们依赖于buttonList输入是一个按钮标签字符串数组。我们在模板中使用这个数组,通过NgFor指令。

预期的activeButton输入应设置为当前在切换列表中激活的按钮标签字符串。我们还创建了一个名为outActivate的输出,以通知外界关于活动切换按钮状态的变化。

activate 函数中,我们只发出 outActivate 输出。从组件外部的绑定,我们期望 activeButton 输入相应地更新。重要的是要理解,我们的切换组件仅与父组件通信有关被激活的按钮。实际上并没有更新任何状态。我们期望使用我们的切换组件的父组件相应地更新 activeButton 输入。

ngOnInit 方法由 Angular 在指令和组件的生命周期中自动调用。这也是我们的切换组件类实现生命周期钩子接口 OnInit 的原因。在 activeButton 输入属性未指定的情况下,我们将添加一个检查并从可用的按钮列表中选择第一个按钮。由于 activeButton 以及 buttonList 都是输入属性,我们需要等待它们被初始化才能执行此逻辑。重要的是不要在组件构造函数中执行此初始化。只有在生命周期钩子 OnInit 中,我们才能保证我们的输入属性已经被设置。它只为每个创建的组件调用一次。

Angular 会自动调用你在组件中实现的任何生命周期钩子。每个生命周期钩子可用的接口仅有助于确保你已经为每个生命周期钩子实现了所有所需的回调。

以下图表展示了 Angular 组件的生命周期。在组件构建过程中,所有生命周期钩子将按照图中的顺序被调用,除了 OnDestroy 钩子,它将在组件销毁时被调用。

变更检测也会启动生命周期钩子的一部分,其中在创建过程中调用的某些钩子将被跳过:

  • doCheck

  • afterContentChecked

  • afterViewChecked

  • onChanges (如果有任何更改被检测到)

有关生命周期钩子和它们的目的的详细描述,可在 Angular 文档网站上找到:angular.io/guide/lifecycle-hooks

图片

Angular 组件生命周期的示意图

好的!我们已经创建了一个新的 UI 组件来渲染切换按钮列表。现在,是时候继续本章的主要目标,在我们的任务列表组件中实现一个过滤系统。

首先,我们应该考虑过滤器的模型。我们希望包含三种状态:全部、打开和完成,每种状态都应导致任务列表的不同视图。让我们打开位于 src/app/model.ts 的模型文件,并添加以下更改:

export interface Task {
  id?: number;
  title: string;
  done: boolean;
}

export type TaskListFilterType = 'all' | 'open' | 'done';

我们定义了一个类型别名,它代表一个有效过滤器类型的列表。TypeScript 类型别名对于使某些事情更加类型安全非常有帮助。特别是当你处理字符串类型时,你可以使用类型别名来创建字符串字面量类型。通过创建一个类型别名TaskListFilterType,并在过滤的上下文中使用它,我们可以在过滤时指定哪些字符串是有效的。这将防止在我们应用程序中处理任务过滤器类型字符串时出现任何错误。

现在,是时候实现我们的过滤功能了。让我们打开位于src/app/tasks/task-list/task-list.component.ts的任务列表组件文件,并应用一些代码更改。同样,更改的部分代码被突出显示,以便您更容易看到有效的更改:

import {Component, ViewEncapsulation} from '@angular/core';
import {TaskService} from '../task.service';
import {Task, TaskListFilterType} from '../../model';

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None
})
export class TaskListComponent {
  tasks: Task[];
  filteredTasks: Task[];
 taskFilterTypes: TaskListFilterType[] = ['all', 'open', 'done'];
 activeTaskFilterType: TaskListFilterType = 'all';

  constructor(private taskService: TaskService) {
    this.tasks = taskService.getTasks();
 this.filterTasks();
  }

  activateFilterType(type: TaskListFilterType) {
 this.activeTaskFilterType = type;
 this.filterTasks();
 }

  filterTasks() {
 this.filteredTasks = this.tasks
 .filter((task: Task) => {
 if (this.activeTaskFilterType === 'all') {
 return true;
 } else if (this.activeTaskFilterType === 'open') {
 return !task.done;
 } else {
 return task.done;
 }
 });
 }

  addTask(title: string) {
    const task: Task = {
      title, done: false
    };
    this.taskService.addTask(task);
    this.tasks = this.taskService.getTasks();
 this.filterTasks();
  }

  updateTask(task: Task) {
    this.taskService.updateTask(task);
    this.tasks = this.taskService.getTasks();
 this.filterTasks();
  }
}

在组件内部,我们希望存储一个任务过滤器可能拥有的类型列表。这个列表将作为我们切换按钮列表的输入。如果你还记得我们的切换按钮的输入属性,我们有一个接受按钮标签列表的buttonList输入。为了存储当前选中的过滤器类型,我们使用一个名为activeTaskFilterType的实例字段。

我们需要添加到任务列表组件中的最后一部分是实际的任务过滤。为此,我们引入了一个名为filteredTasks的新成员,它将始终更新为当前过滤的任务子集。在filterTasks方法中,我们通过评估存储在activeTaskFilterType中的活动过滤器标准来计算过滤任务的子集。过滤的结果将存储在我们的filteredTasks成员中。

我们还创建了一个名为activateFilterType的方法,我们可以调用它来切换活动过滤器标准。然后,这个方法将调用filterTasks方法来更新我们的过滤任务子集。

好了,这就是我们将在组件类中进行的所有更改。尽管如此,我们仍然需要更改我们的视图模板。我们需要在过滤器标准更改时渲染我们的切换组件并执行过滤。由于我们想要渲染过滤后的任务子集而不是整个任务列表,我们还需要更改NgFor的源,它在视图中重复我们的任务。让我们打开模板文件src/app/tasks/task-list/task-list.html,并按照以下更改进行修改:

<mac-toggle [buttonList]="taskFilterTypes"
 [activeButton]="activeTaskFilterType"
 (outActivate)="activateFilterType($event)">
</mac-toggle>
<mac-enter-task (outEnterTask)="addTask($event)"></mac-enter-task>
<div class="tasks">
  <mac-task *ngFor="let task of filteredTasks"
            [task]="task"
            (outUpdateTask)="updateTask($event)"></mac-task>
</div>

让我们快速讨论一下这些更改。首先,我们将存储在任务列表组件类中可能过滤类型列表的taskFilterTypes属性绑定到切换组件的buttonList输入属性。这将使切换组件渲染所有过滤类型作为切换按钮。

我们还将任务列表的 activeTaskFilterType 实例字段绑定到切换组件的 activeButton 输入属性。这样,activeTaskFilterType 属性的更改将在切换组件中反映出来。同时,当用户在切换组件内部更改活动切换按钮时,我们捕获切换组件的 outActivate 输出,并在任务列表组件上调用 activateFilterType 方法。

就这样,恭喜!你已经成功通过使用我们新创建的切换组件添加了过滤机制到你的任务列表中。在你的浏览器中预览更改;你应该看到一个功能齐全的任务列表,你可以标记任务为完成,添加新任务,并通过激活我们漂亮的切换按钮来过滤列表:

图片

新增切换按钮组件的过滤任务状态的截图

复习

在本节中,我们已经在任务列表组件中构建了一个过滤系统。我们还创建了一个额外的 UI 组件来渲染切换按钮,我们将这些按钮展示给用户以选择过滤标准:

  • 我们创建了一个新的切换组件来渲染一组切换按钮。

  • 我们使用 @HostBinding 装饰器从我们的组件类内部声明性地创建属性绑定。

  • 我们了解了 Angular 组件生命周期以及我们如何使用 OnInit 生命周期钩子在第一次处理输入后初始化组件。

摘要

在本章中,你学习了使用 Angular 构建基于 UI 组件的应用程序的新概念。我们还构建了我们任务管理应用程序的核心组件,即任务列表本身。你了解了输入和输出属性的概念以及如何使用它们来建立适当的组件通信。

我们还涵盖了 Angular 组件生命周期的基本知识以及如何在 OnInit 钩子中通过生命周期钩子执行初始化后的步骤。

作为最后一步,我们在任务列表中集成了切换按钮列表组件以过滤任务状态。我们将任务列表组件重构为使用服务来获取任务数据。为此,我们使用了 Angular 的依赖注入。

在下一章中,我们将探讨如何改进我们对数据和状态的处理方式。处理应用程序状态的方法有很多,我们将学习如何最好地解决这个问题。

第三章:处理数据和状态

在本章中,我们将进一步构建我们的应用程序结构,并专注于作为我们任务管理系统基础的数据架构。到目前为止,我们已经从我们在上一章中创建的任务服务同步获取了任务数据。然而,在现实世界的场景中,这种情况很少发生。在实际应用中,我们会以异步方式获取数据,我们需要管理客户端状态,并确保我们的状态和数据始终保持完整性。在本章中,我们将探讨如何重新构建我们的应用程序以使用 Angular 中的 HTTP 客户端模块处理 RESTful 接口。我们将使用内存数据库来模拟我们的 HTTP 后端。此外,我们还将探讨一些关键概念,如响应式编程、不可变性和“纯”组件,以帮助我们构建一个在小规模和大规模上都能发光的数据架构。

在本章中,我们将探讨以下主题:

  • 响应式编程、RxJS 的基础知识及其操作符,用于处理异步数据

  • 重新构建我们的应用程序以处理对内存数据库的模拟 HTTP 调用

  • 不可变性的概念

  • 在 Angular 中使用纯组件

  • 引入容器组件以将我们的用户界面与应用程序状态分离

  • 为纯组件使用 ChangeDetectionStrategy.OnPush

使用 RxJS 进行响应式编程

到目前为止,我们在创建的任务列表中使用了简单的数组数据结构。这并不是我们在现实世界场景中会遇到的。在实际应用中,我们必须处理来自服务器的异步数据。

在应用程序中处理数据的行为与流非常相似。你接收输入,转换它,组合它,合并它,最后将其写入输出。在这样的系统中,输入通常是连续的,有时甚至是无限期的。以实时流为例;这种类型的数据是连续流动的,数据也是无限流动的。函数式和响应式编程是帮助我们更干净地处理这类数据的范式:

图片

一个简单的可观察订阅,带有值发射和转换

Angular 在其核心是响应式的,整个变更检测和绑定都是使用响应式架构构建的。我们在上一章中学到的组件的输入和输出,实际上就是一个使用响应式事件驱动方法建立的数据流。Angular 使用 RxJS,这是一个用于 JavaScript 的函数式和响应式编程库,来实现这种数据流。实际上,我们用来从组件内部发送输出的 EventEmitter,只是 RxJS 可观察对象的一个包装器。

在我们在任务管理系统内玩弄 RxJS 之前,让我们先看看一个简单的 RxJS 示例,看看我们如何处理可观测流:

import {from} from 'rxjs';
import {map, filter} from 'rxjs/operators';

from([1, 2, 3, 4])
  .pipe(
    map((num) => num * num),
    filter((num) => num < 10)
  )
  .subscribe((num) => console.log(num));

// This script is finishing with the following output on the console:
// 1
// 4
// 9

这个简单的脚本将从数字数组生成一个可观测序列。我们逐个将数字通过可观测流传递,使用两个简单的算子在我们订阅可观测量并打印结果到控制台之前。map 算子将每个数字平方,这些数字通过可观测流流动。然后,filter 算子过滤掉大于或等于 10 的项。

可观测量提供了一大批所谓的算子,这些算子允许你转换源自源可观测量的数据流。你可能已经从 ECMAScript 5 数组扩展函数中了解到一些这些函数算子,例如 mapfilter。使用算子,你可以模拟整个转换流程,直到你最终订阅数据。

我经常在谈论 RxJS 可观测量时使用水管的类比。如果你认为你的转换算子是管道中的部件,那么 subscribe 函数就是管道中的最终排水阀。如果你不打开水管的排水口,水就不会流动。RxJS 的行为非常相似。如果没有最后的订阅调用,RxJS 不会执行任何算子。只有当你订阅一个可观测量时,它才会变得活跃。在订阅回调中,你可以使用通过流流动的结果项。

现在,构建管道带来了显著的优势。像管道一样构建的转换系统期待输入,并将产生一些输出。然而,我们不会立即执行任何操作。相反,我们正在设置一个系统,该系统知道如何处理通过它的数据,当有数据流动时。这个管道系统是完全无状态的并且是响应式的——响应式意味着它会对外来数据进行响应,并为每个输入产生新的输出。

我们可以将任何随时间发出项的源视为可观测量。让我们看看另一个例子:

import {fromEvent} from 'rxjs';
import {throttleTime, map} from 'rxjs/operators';

fromEvent(window, 'mousemove')
  .pipe(
    throttleTime(200),
    map((event: MouseEvent) => `Move(${event.screenX}, ${event.screenY})`)
  )
  .subscribe((move) => console.log(move));

在这个例子中,我们使用 fromEvent 可观测量辅助函数从窗口对象的鼠标移动事件创建一个可观测源。对于每个鼠标移动事件,事件对象将通过可观测流发出。然后,我们将使用 throttleTime 算子限制流发出的事件数量。这个算子将在给定的时间框架内阻止后续的发出,因此减缓了流。在 map 算子中,我们格式化发出的鼠标事件,并最终订阅将结果写入控制台。

仅用几行代码,我们就实现了一个优秀的管道,它将源转换成可用的结果。这就是观察者、响应式编程和 RxJS 的力量。我们可以以非常优雅和声明性的方式解决有关构建响应式系统的一些难题。

HTTP 客户端和内存中的 Web API

在本章的开头,我们决定我们想要改变我们在应用程序中处理数据的方式。目前,我们的任务数据嵌入在我们的任务服务中,检索以及操作都是同步发生的。从现在开始,我们想要改变这一点,尽可能接近现实世界的情况。同时,我们还应该关注我们解决方案的复杂性成本。

Angular 为这些用例提供了一个非常棒的实用工具。使用内存中的 Web API 模块,我们可以创建一个模拟的后端服务,这将允许我们以连接到真实服务器相同的方式使用 RESTful 接口。然而,所有使用 Angular HTTP 客户端进行的远程调用都将重定向到我们的本地内存数据库。我们处理数据的方式将完全真实。在某个时候,我们甚至可以创建一个真正的后端服务器,并将我们的应用程序连接到它,同时我们的前端代码保持不变。

让我们看看实现我们的数据层所需的内存中 Web API 的必要更改。作为第一步,我们需要使用 npm 安装该包。打开命令行并导航到您的项目目录。然后,执行以下命令:

npm install --save angular-in-memory-web-api@0.5.1

运行此命令将安装内存中的 Web API 包并将其保存到我们的项目 package.json 文件中。作为下一步,我们想要创建我们应用程序的内存数据库。我们在路径 src/app/database.ts 上创建一个新的 TypeScript 文件,并添加以下内容:

import {InMemoryDbService} from 'angular-in-memory-web-api';
import {Task} from './model';

export class Database implements InMemoryDbService {
  createDb() {
    const tasks: Task[] = [
      {id: 1, title: 'Task 1', done: false},
      {id: 2, title: 'Task 2', done: false},
      {id: 3, title: 'Task 3', done: true},
      {id: 4, title: 'Task 4', done: false}
    ];
    return {tasks};
  }
}

使用 Angular 内存中的 Web API,我们可以创建一个类来存储所有初始数据。这个类实现了 InMemoryDbService 接口,要求我们创建一个名为 createDb 的方法。在这个函数中,我们可以创建资源,这些资源将以 RESTful 风格提供给 Angular HTTP 客户端使用。

接下来,我们将更新位于路径 src/app/app.module.ts 的主应用程序模块,并设置应用程序以使用我们新创建的内存中的 Web API 和数据库。你应该只添加以下代码摘录中突出显示的部分。省略号字符表示存在更多代码,但这些代码对你需要应用到代码中的更改不相关:

...
import {HttpClientModule} from '@angular/common/http';
import {HttpClientInMemoryWebApiModule} from 'angular-in-memory-web-api';

import {Database} from './database';
...

@NgModule({
  ...
  imports: [
    BrowserModule,
    HttpClientModule,
 HttpClientInMemoryWebApiModule.forRoot(Database, {
 delay: 0
 })
  ],
  ...
})
export class AppModule {
}

我们在我们的主应用程序模块的导入部分添加了两个额外的模块。我们添加了 Angular HTTP 客户端模块,我们将使用它来调用数据库中的模拟 REST 端点。如前所述,如果我们要调用远程服务器,这个库也会以相同的方式使用。

我们导入的第二个模块是内存中 Web API 模块的 HTTP 客户端适配器。此模块将拦截 Angular HTTP 客户端执行的所有 HTTP 调用,并将请求重定向到我们的本地数据库。我们使用工厂方法HttpClientInMemoryWebApiModule.forRoot在导入之前配置适配器模块。在工厂函数的第一个参数中,我们传递我们创建的数据库类。在第二个参数中,我们可以为适配器提供一些额外的选项。在我们的例子中,我们将延迟设置为零。使用更高的值将人为地延迟数据库的响应,如果你想要模拟网络延迟,这会很有用。

使用行为主题

HTTP 客户端正在使用 RxJS 为所有 HTTP 请求方法返回可观察流。响应体将通过可观察流发射,我们可以订阅这些流以检索结果:

this.http.get<Task[]>('/api/tasks')
  .subscribe((tasks) => console.log(tasks));

由于我们知道如何在组件中处理可观察流,我们可以继续直接返回 HTTP 客户端调用产生的可观察流。

然而,相反,我们想要利用一个名为BehaviorSubject的 RxJS 类。直接从 HTTP 客户端返回可观察流的问题在于,当任务从服务器加载时,我们总是返回一个新的可观察流。这将是不可行的,并且在重新加载任务以执行更新或添加新任务后,我们希望能够重用相同的可观察流来重新发射更新的任务列表。这样,当我们的任务重新加载时,系统中的所有组件都将被通知。你可以使用行为主题来创建自己的可观察流源。你可以控制应该发射什么以及何时发射。让我们看看一个简化的例子,看看如何使用行为主题:

const subject = new BehaviorSubject<number>(0);
subject.asObservable().subscribe(num => console.log(`Item: ${num}`));
// console output -> Item: 0

subject.next(1);
// console output -> Item: 1

subject.next(2);
// console output -> Item: 2

subject.asObservable().subscribe(num => console.log(`Second subscription: ${num}`));
// console output -> Second subscription: 2

在行为主题的构造函数中,我们可以指定初始值或项目,这些值或项目将被最初发射给所有订阅者。行为主题也总是向新订阅者发射它们最新的项目。

行为主题既是观察者也是可观察的。因此,你可以在主题上直接调用subscribe方法。然而,如果你想将你的主题再次转换为普通的可观察流,你可以使用asObservable方法。这对于封装特别有用。当你返回你的可观察流以在你的程序逻辑之外使用时,你不想给外部世界发射项目的权力。应该只能观察流。

最后,无论何时你想通过可观察流发射新的项目,你都可以在主题上使用next方法。

在任务服务中加载数据

是时候改变我们的任务服务并利用 Angular HTTP 客户端从我们的数据库中获取任务数据了。让我们打开src/app/tasks/task.service.ts文件,并将文件内容更改为以下内容:

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject} from 'rxjs';
import {Task} from '../model';

@Injectable()
export class TaskService {
  private tasks = new BehaviorSubject<Task[]>([]);

  constructor(private http: HttpClient) {
    this.loadTasks();
  }

  private loadTasks() {
    this.http.get<Task[]>('/api/tasks')
      .subscribe((tasks) => this.tasks.next(tasks));
  }

  getTasks() {
    return this.tasks.asObservable();
  }

  addTask(task: Task) {
    return this.http
      .post<Task>('/api/tasks', task)
      .subscribe(() => this.loadTasks());
  }

  updateTask(task: Task) {
    return this.http
      .post(`/api/tasks/${task.id}`, task)
      .subscribe(() => this.loadTasks());
  }
}

我们在我们的构造函数中注入 Angular HTTP 客户端,以便我们可以在服务中使用它。在loadTasks方法中,我们正在对由我们的数据库提供的 RESTful 任务资源执行 GET 调用。

我们服务的tasks成员持有初始化为空数组的行为主题。每次我们调用内部的loadTasks方法时,结果的任务列表数组将通过调用next方法通过我们的行为主题发出。

loadTasks方法首先在服务构造函数中被调用。这将确保从 HTTP 调用中获得的结果任务列表最初通过我们的行为主题发出。我们还在addTaskupdateTask方法完成 POST 请求后调用loadTasks方法。这将保证我们从“服务器”重新加载更新后的任务列表并通过我们的行为主题发出。

getTasks方法中,我们将主题转换为可观察对象并返回它。这样,我们可以确保服务外部没有人有权力通过我们的主题发出项。使用行为主题的可观察对象,我们可以有数百个组件订阅,当数据发生变化时,所有这些组件都将接收到最新的任务列表。

当我们通过向任务资源执行 POST 请求添加新任务时,内存中的 Web API 将自动为我们生成任务 ID。这意味着当我们用任务对象调用addTask方法时,我们可以跳过添加 ID 属性,内存数据库将为我们找到下一个可能的 ID 值。

现在,让我们在我们的任务列表组件中使用更新的任务服务。打开src/app/tasks/task-list/task-list.component.ts文件并应用以下更改。同样,有效的更改以粗体显示:

import {Component, ViewEncapsulation} from '@angular/core';
import {TaskService} from '../../tasks/task.service';
import {Task, TaskListFilterType} from '../../model';
import {Observable, combineLatest, BehaviorSubject} from 'rxjs';
import {map} from 'rxjs/operators';

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None
})
export class TaskListComponent {
  tasks: Observable<Task[]>;
  filteredTasks: Observable<Task[]>;
  taskFilterTypes: TaskListFilterType[] = ['all', 'open', 'done'];
  activeTaskFilterType = new BehaviorSubject<TaskListFilterType>('all');

  constructor(private taskService: TaskService) {
    this.tasks = taskService.getTasks();

    this.filteredTasks = combineLatest(this.tasks, this.activeTaskFilterType)
 .pipe(
 map(([tasks, activeTaskFilterType]) => {
 return tasks.filter((task: Task) => {
 if (activeTaskFilterType === 'all') {
 return true;
 } else if (activeTaskFilterType === 'open') {
 return !task.done;
 } else {
 return task.done;
 }
 });
 })
 );
  }

  activateFilterType(type: TaskListFilterType) {
    this.activeTaskFilterType.next(type);
  }

  addTask(title: string) {
    const task: Task = {
      title, done: false
    };
    this.taskService.addTask(task);
    // Two lines got removed from there
  }

  updateTask(task: Task) {
    this.taskService.updateTask(task);
 // Two lines got removed from there
  }
}

我们已经更改了tasks成员的类型,现在它持有带有任务数组泛型类型的可观察对象。在 TypeScript 中,RxJS 使用泛型来指定将通过可观察流发出的项的类型。tasks成员将存储我们通过调用任务服务获得的可观察流,它将成为我们在组件内部过滤的基础。

在我们的任务列表组件构造函数中,我们仍然在调用我们的服务中的getTasks方法。然而,这次,我们不会同步接收到任务列表。相反,我们是一个可观察的流,当订阅时将发出任务列表。由于我们在任务服务内部使用了一个行为主题,我们将永远不需要再次调用任务服务来获取任务。如果任务列表数据有更新,我们将通过连接的可观察流接收到一个新项目,其中包含最新的任务列表。

我们还将我们的activeTaskFilterType成员更改为行为主题。这将帮助我们在我们组件内构建一致的反应性数据流。我们不是直接存储活动过滤器类型,而是使用主题来发射过滤器类型。在activateFilterType方法中,我们正是这样做的。当这个方法从我们的视图中被调用,作为一个过滤器切换按钮被点击时,我们将使用行为主题发射新的活动过滤器类型。

我们的tasks可观察对象在底层数据发生变化时始终会发射最新的列表。此外,当改变活动任务过滤器时,activeTaskFilterType主题会发射一个项。现在,我们可以将这两个流合并以实现我们的过滤逻辑反应性。再次,想象一个管道系统。我们不是立即过滤,而是在构建一个网络,该网络将在新数据到达时进行过滤。那么,我们如何将两个可观察流合并成一个呢?使用 RxJS 提供的广泛操作符有很多种方法可以做到这一点。然而,在我们的当前情况下,combineLatest操作符将工作得最好。

让我们看看这个操作符如何将多个可观察流组合成一个单一的可观察流的小示例:

图片

通过使用combineLatest操作符将两个可观察合并成一个

combineLatest操作符将两个或多个输入可观察对象组合成一个输出可观察对象。当所有输入可观察对象都至少发射了一个项时,输出可观察对象将发射第一个项。输出可观察对象上发射的项始终是一个数组,包含所有输入可观察对象的最新的或最新的项。在前面的示例中,你可以看到当Observable 2发射带有(a)标记的项时,第一个项被发射。发射的项是一个包含(1)Observable 1(a)Observable 2的值的数组。在第一个组合项被发射之后,如果输入流中的任何一个正在发射一个新项,combineLatest的输出可观察对象将发射一个更新的项,该项再次包含所有输入可观察对象的最新项。

这正是我们在构建我们的过滤可观察对象时想要的精确行为。只需将前一个示例中的Observable 1替换为我们的任务可观察对象,Observable 2替换为我们的活动过滤器类型。现在,如果我们输入一个可观察对象、任务可观察对象、活动过滤器主题或发射一个新项,我们的过滤输出可观察对象也将产生一个新项。这是反应式编程的最佳实践。我们再也不需要担心更新我们的状态了。这一切都是通过反应流来处理的。

由于 combineLatest 只会生成由输入可观察对象发出的所有最新值的数组,我们需要使用一个额外的 map 操作符来提供所需的过滤列表输出。我们将 combineLatest 发出的值对解构为 tasksactiveTaskFilterType 变量,并根据该数据进行过滤。结果过滤列表被返回,并将由 map 操作符的输出可观察对象发出。

使用异步管道在视图中订阅

我们已经了解了 RxJS 的可观察对象,并且知道如果我们不订阅它们,它们就不会开始发出项目。你可以将这个类比于水管排水阀。如果你不打开排水阀,水就不会流动。

在我们更新的任务列表组件中,我们现在有一个 filteredTasks 可观察对象,我们可以订阅它并获取最新的过滤后的任务。然而,处理 RxJS 订阅有一个稍微更好的方法,我们现在将要看看。

订阅的问题在于它们总是想要被清理。想象一下,你的订阅正在导致许多事件处理程序被添加,以及其他可能为观察你的流而分配的资源。调用 subscribe 方法将返回一个订阅对象,在该订阅对象上,你会找到一个名为 unsubscribe 的方法。通常,当你不再需要可观察对象订阅时,调用这个方法总是一个好主意。在 Angular 组件的情况下,我们可以这样说,当组件从视图中移除时,清理可观察对象订阅是一个好时机。

幸运的是,有一个名为 OnDestroy 的生命周期钩子,用于检测组件何时从视图中移除。我们可以使用这个钩子来清理对 RxJS 可观察对象的任何订阅。让我们看看一个组件在 OnDestroy 生命周期钩子中订阅可观察对象并取消订阅的简单示例:

import {OnDestroy} from '@angular/core';
import {Observable, Subscription, fromEvent} from 'rxjs';

...
export class MousePositionComponent implements OnDestroy {
  mouseObservable: Observable<MouseEvent> = fromEvent(window, 'mousemove')
    .map(e => `${e.screenX}, ${e.screenY}`);
  mousePosition: string;
  mouseSubscription: Subscription = this.mouseObservable
    .subscribe((position: string) => this.mousePosition = position);

  ngOnDestroy() {
    this.mouseSubscription.unsubscribe();
  }
}

在前面的例子中,我们正在从窗口对象的鼠标移动事件创建一个可观察对象流。我们想要做的只是显示由可观察对象流发出的最新鼠标位置,在我们的组件视图中。你可以立即看到,仅仅为了处理一个可观察对象,就需要编写大量的代码。对于每个可观察对象,我们需要存储三件事:

  • 可观察对象本身

  • 一个用于存储流最近发出的项目的属性

  • 订阅对象,允许我们在组件被销毁时取消订阅并清理

如果我们只处理一个单一的可观察对象,这可能没问题,但是想象一下,如果你的组件需要同时处理多个可观察对象。这将变得相当混乱。

另一个问题是我们需要手动使用 OnDestroy 生命周期钩子来取消组件的订阅。这是一个手动且容易出错的流程,我们很容易就会失去对订阅的跟踪。

幸运的是,Angular 为这个问题提供了一个天才的解决方案。我们不需要手动处理订阅,我们将使用一个名为 AsyncPipe 的视图管道直接在我们的组件视图中进行订阅。这意味着我们不需要在我们的组件类中进行订阅并手动提取最新发出的项目。相反,异步管道将为我们提取项目,并在有新项目通过流传入时自动更新我们的视图。异步管道还将内部存储订阅,并在检测到组件已被销毁时自动为我们取消订阅。

让我们看看之前的相同示例,但现在使用异步管道。组件类将看起来像这样:

import {Observable, fromEvent} from 'rxjs';

...
export class MousePositionComponent implements OnDestroy {
  mouseObservable: Observable<MouseEvent> = fromEvent(window, 'mousemove')
    .map(e => `${e.screenX}, ${e.screenY}`);
}

哇!这是一个激进的简化,不是吗?我们现在只需要存储可观察对象本身。提取最新发出的项目以及从流中取消订阅都由异步管道处理。让我们看看我们如何需要更改我们的视图来使用异步管道:

<strong>Mouse position:</strong>
<p>{{mouseObservable | async}}</p>

这有多酷!仅通过在我们的视图中使用异步管道,我们就可以创建对可观察对象的订阅,渲染流发出的最新项目,并在我们的组件被销毁时取消订阅。此外,从功能和响应式角度来看,我们还以我们不在我们的组件类中创建任何副作用的方式增强了我们的代码。我们不保留任何中间状态,我们存储的只是可观察对象流本身。异步管道是处理异步数据时你工具集的一个优秀补充,你应该始终在工作与 RxJS 可观察对象一起时使用它。

好的,我希望你感受到了在前一个示例中使用异步管道的强大和简单。现在,我们将使用这些知识重构我们的任务列表组件,以便在组件视图中使用异步管道来订阅我们的可观察对象。

由于我们已经更新了我们的组件逻辑以公开一个可观察对象来发出我们的过滤任务列表,我们可以直接进入我们的任务列表组件视图并应用更改以使用异步管道。让我们打开src/app/tasks/task-list/task-list.component.html文件并实现以下更改:

<mac-toggle [buttonList]="taskFilterTypes"
            [activeButton]="activeTaskFilterType | async"
            (outActivate)="activateFilterType($event)">
</mac-toggle>
<mac-enter-task (outEnterTask)="addTask($event)"></mac-enter-task>
<div class="tasks">
  <mac-task *ngFor="let task of filteredTasks | async"
            [task]="task"
            (outUpdateTask)="updateTask($event)"></mac-task>
</div>

我们添加了两个异步管道。第一个是订阅我们的 activeTaskFilterType 行为主题。异步管道将直接从视图创建订阅,并且每当有新项目通过流发出时,它将自动更新我们的绑定。

第二个异步管道直接用于 NgFor 指令的绑定。我们正在订阅我们的 filteredTasks 可观察对象,它将始终发出过滤任务列表的最新结果。

概述

恭喜!我们已经成功更新了我们的代码,使用内存 Web API 和 Angular HTTP 客户端在我们的应用中建立反应性数据流。我们正在使用 RxJS 可观察对象,使用操作符转换它们,并使用 Angular 异步管道直接在视图中解决数据。这次重构是一个相当技术性但重要的变化。我们现在遵循一个非常干净的方法来响应应用状态的变化。我们的可观察流直接路由到视图,然后我们使用异步管道进行订阅。如果 Angular 销毁我们的任务列表组件,异步管道也将处理必要的取消订阅。我们已经学习了以下主题:

  • 使用 Angular 内存 API 模拟 RESTful 后端,并使用 HTTP 客户端获取数据

  • RxJS 基础,基本操作符,以及行为主题和 combineLatest 操作符

  • 使用异步管道从组件视图订阅

  • 在我们的应用中建立端到端反应性数据架构

不变性

在本节中,我们将学习不变性的概念。这些知识将帮助我们进行应用即将到来的重构练习。

不变数据最初是函数式编程的核心概念。本节不会深入探讨不变数据,但会解释这一核心概念,以便我们能够讨论如何将这一理念应用于 Angular 组件。

不变数据结构迫使你在修改数据之前创建数据的完整副本。你永远不会直接操作数据,而是操作这个相同数据的副本。这种方法相对于可变数据操作有许多优点,最明显的大概是干净的应用状态管理。当你始终操作新的数据副本时,你就不可能弄乱你不想修改的数据。

让我们来看一个简单的例子,它说明了对象引用可能引起的问题:

const list = [1, 2, 3]; 
console.log(list === list.reverse()); // true 

虽然这乍一看似乎很奇怪,但这个案例的输出有效是有道理的。Array.reverse() 是一个可变操作,它将修改数组的内部结构。实际的引用将保持不变,因为 JavaScript 不会创建数组的副本来反转它。虽然从技术上讲这很有道理,但这并不是我们在查看这段代码时最初预期的。

我们可以通过在反转数组之前创建数组的副本,快速将这个例子改为一个不可变过程:

const list = [1, 2, 3]; 
console.log(list === list.slice().reverse()); // false 

引用的问题在于它们可以引起很多意外的副作用。此外,如果我们回到第一章的封装主题,即基于组件的用户界面,对象引用完全违反了封装的概念。尽管我们可能认为将复杂的数据类型传递到胶囊中是安全的,但这并不正确。因为我们在这里处理的是引用,数据仍然可以从外部被修改,我们的胶囊将不会拥有完全的所有权。考虑以下示例:

class Sum { 
  constructor(data) { 
    this.data = data; 
    this.data.sum = data.a + data.b; 
  } 
  getSum() { 
    return this.data.sum; 
  } 
} 

const data = {a: 5, b: 8}; 
var sum = new Sum(data); 
console.log(sum.getSum()); // 13 
console.log(data.sum); // 13 

即使我们的目标只是在我们自己的Sum类中内部存储数据,我们也会产生引用和修改外部数据对象的副作用,这会带来不希望的结果。多个sum实例也会共享外部相同的数据并引起更多的副作用。作为一个开发者,你已经学会了正确地处理对象引用,但它们仍然可以引起很多问题。

对于不可变数据,我们不会遇到这些问题,这可以通过 JavaScript 中的原始数据类型轻松说明。原始数据类型不使用引用,并且按设计是不可变的:

let originalString = 'Hello there!'; 
let modifiedString = originalString.replace(/e/g, 3); 
console.log(originalString); // Hello there! 
console.log(modifiedString); // H3llo th3r3! 

我们无法修改字符串的一个实例。我们对字符串进行的任何修改都会生成一个新的字符串,这可以防止不希望出现的副作用。

那么,为什么我们仍然在编程语言中有对象引用,尽管它们会引起很多问题?为什么我们不只在不可变数据上执行所有这些操作,而不是只处理值而不是对象引用?

当然,可变数据结构也有其好处,并且是否带来价值总是取决于上下文。

人们经常反对不可变数据的一个主要原因是其糟糕的性能。当然,如果我们每次想要修改数据时都需要创建大量数据的副本,这会消耗一些性能。然而,有一些显著的优化技术可以消除我们从不可变数据结构中通常期望的性能问题。使用允许内部结构共享的树数据结构,数据副本将在内部共享。这项技术允许非常高效的内存管理,在某些情况下甚至可以超越可变数据结构。如果你想了解更多关于不可变数据结构中的性能的信息,我强烈推荐阅读 Chris Okasaki 关于纯函数数据结构的论文。

JavaScript 本身不支持不可变的数据结构。然而,你可以使用库,例如 Facebook 的Immutable.js,它为你提供了一个出色的 API 来处理不可变数据。Immutable.js甚至实现了结构共享,如果你决定在你的应用程序中构建不可变架构,它将是一个完美的强大工具。

正如每个范式一样,都有其优缺点,并且根据上下文,一个概念可能比另一个更适合。在我们的应用中,我们不会使用第三方库提供的不可变数据结构,但我们会借鉴以下不可变习惯用法中获得的一些好处:

  • 理解不可变数据更容易:你总能知道你的数据为什么处于某种状态,因为你知道确切的转换路径。这可能听起来无关紧要,但在实践中,这对人类编写代码以及编译器和解释器优化代码来说都是巨大的好处。

  • 使用不可变对象使变更检测变得更快:如果我们依赖不可变模式来处理我们的数据,我们可以依赖对象引用检查来检测变更。我们不再需要执行复杂的数据分析和比较来进行脏检查,而可以完全依赖引用检查。我们有保证,只有当对象身份发生变化时,对象属性才不会发生变化。这使得变更检测变得和 oldObject === newObject 一样简单。

使用 TypeScript 的不可变性

在 TypeScript 2 中,添加了新的类型特性,这些特性可以帮助你拥抱不可变操作。使用 readonly 类型修饰符,我们可以实现编译时不可变性保护。

让我们看看以下如何使用 readonly 修饰符来定义一些不可变数据结构的示例:

export interface Person {
  readonly firstName: string;
  readonly lastName: string;
}

let person: Person = {
  firstName: 'Peter',
  lastName: 'Griffin'
};

// This will result in a compile time error
person.firstName = 'Mag';

如前例所示,我们可以使用 readonly 修饰符来防止对象属性被修改。相反,如果我们想修改 person 对象,我们需要创建该对象的副本。然而,有许多方法可以做到这一点,但使用对象属性展开操作符可能是最方便的。让我们看看我们如何使用对象属性展开操作符以不可变的方式更新我们的 person 对象:

export interface Person {
    readonly firstName: string;
    readonly lastName: string;
}

let person: Person = {
    firstName: 'Peter',
    lastName: 'Griffin'
};

person = {
  ...person,
  firstName: 'Mag'
};

使用对象属性展开操作符,我们可以将现有的人对象的所有现有属性及其值展开到新对象字面量中。在相同步骤中,我们还可以在展开操作之后覆盖任何属性。这使我们能够轻松地创建现有对象的副本并添加或覆盖特定属性。前面的代码也可以通过使用 Object.assign 来编写:

person = Object.assign({}, person, {
  firstName: 'Meg'
});

实际上,这就是对象展开操作符在 JavaScript 中解构的方式。然而,使用展开操作符比使用 Object.assign 更方便。对象展开操作符已被提出作为未来的 JavaScript 标准,目前处于第 3 阶段。

纯组件

“纯”组件的想法是,其整个状态由其输入表示,其中所有输入都是不可变的。这实际上是一个无状态组件,但除此之外,所有输入都是不可变的。

我喜欢称这样的组件为“纯”组件,因为它们的行为可以与函数式编程中纯函数的概念相比较。纯函数具有以下特性:

  • 它不依赖于函数作用域之外的状态

  • 如果输入参数没有改变,它总是表现相同并返回相同的结果

  • 它永远不会在函数作用域之外改变任何状态(副作用)

使用纯组件,我们有一个简单的保证。纯组件在没有其输入参数改变的情况下永远不会改变。坚持这种关于组件的想法给我们带来了几个优点。除了对你的组件状态有完全的信任之外,我们还可以通过优化 Angular 的变更检测来获得一些性能上的好处。我们知道,如果组件的输入没有改变,它将渲染出完全相同的结果。这意味着,如果没有输入变化,我们可以忽略所有组件及其子组件的变更检测。

理解纯组件非常简单。它们的行为可以很容易地预测。让我们看看一个只有纯组件的组件树的简单示例:

纯函数示例

具有不可变组件的组件树

通常,Angular 会对组件树中所有组件的每个绑定执行变更检测。它会在每个浏览器事件上执行,这些事件可能会改变你的系统状态。这最终会带来很大的性能开销。

如果我们保证树中的每个组件在不可变的输入属性改变之前都有一个稳定的状态,我们就可以安全地忽略 Angular 通常会触发的变更检测。这种组件唯一可能改变的方式是如果组件的输入发生了变化。假设有一个事件导致根组件(A)改变组件(B)的输入绑定值,这将改变组件(E)上的绑定值。这个事件和由此产生的程序将标记我们的组件树中的某个路径以供变更检测检查:

组件树示例

变更检测的标记路径(黑色)与“纯”组件

尽管根组件的状态发生了变化,这也导致了两个级别的子组件的输入属性发生变化,但在考虑系统可能的变化时,我们只需要关注给定路径。纯组件给我们一个承诺,即如果它们的输入没有变化,它们就不会改变。不可变性在这里起着重要作用。想象一下,你正在将一个可变对象绑定到组件(B),而组件(A)会改变这个对象的属性。由于我们使用对象引用和可变对象,该属性也会为组件(B)改变。然而,组件(B)无法注意到这种变化,并且它会使我们的组件树处于不稳定状态。基本上,我们需要再次回到整个树的常规脏检查。

由于我们知道所有组件都是纯组件,并且它们的输入是不可变的,我们可以告诉 Angular 在输入属性值发生变化之前禁用变更检测。这使得我们的组件树非常高效,Angular 可以有效地优化变更检测。当考虑大型组件树时,这可能会在惊人的快速应用程序和慢速应用程序之间产生差异。

Angular 的变更检测非常灵活,每个组件都有自己的变更检测器。我们可以通过指定组件装饰器的 changeDetection 属性来配置组件的变更检测。

使用 ChangeDetectionStrategy,我们可以从适用于我们组件变更检测的两个策略中选择。为了告诉 Angular,我们的组件只有在不可变输入发生变化时才应该被检查,我们可以使用 OnPush 策略。这种变更检测模式是专门为纯组件设计的。

让我们看看组件变更检测策略的两种不同配置可能性以及一些可能的用例:

变更检测策略 描述

| OnPush | 这种策略告诉 Angular,给定的组件子树只有在以下条件之一成立时才会改变:

  • 其中一个输入属性发生变化,需要保持不可变。输入始终会检查引用变化(使用三元等号运算符 ===

  • 组件子树内的一个事件绑定正在接收一个事件。这种条件告诉 Angular,组件内部可能发生了变化,并且它将触发变更检测,即使没有任何输入发生变化。

|

默认 Angular 的变更检测默认策略将对应用程序内发生的每个浏览器事件执行变更检测。

引入容器组件

本书的主要主题是学习如何使用 Angular 组件创建可扩展的用户界面。你可能已经在这个章节中看到了一个趋势。从一个拥有自身状态的简单任务列表组件,我们正逐渐过渡到一个更严肃且可维护的应用程序架构。我们已经进行了一些主要的重构,可以总结如下:

  • 创建一个简单的任务列表组件来列出一些来自简单对象列表的任务

  • 将任务列表组件拆分为各种子组件,并找到我们组件的正确大小(任务列表、任务、复选框、切换)

  • 引入一个服务来存储我们的任务数据,并移除任何直接嵌入到我们的组件中的数据

  • 使用 Angular HTTP 客户端和内存中的 Web API 来模拟异步数据获取,并在我们的服务和组件中使用 RxJS 可观察对象

在本节中,我们将学习另一个概念,这将进一步增强我们的可维护性。容器组件的概念帮助我们分离用户界面和应用状态。这可能在开始时听起来有些困难,但实际上这是一个很好地融入我们现有方法的理念。随着容器组件的引入,我们在状态管理方面明确了责任。让我们看一下以下插图,以了解这一概念的实际应用:

图片

容器组件与常规 UI 组件的交互

容器组件负责您的应用程序状态。它们是系统中唯一允许操作状态和数据的组件。它们通过组件输入将状态和数据传递到您的用户界面组件中。在前面的插图中,我们有一个围绕组件 A的容器组件。组件 A再次由一个子组件B组成。数据从我们的容器组件流向组件AB。每当容器提供新的数据时,这些数据就会通过它们的输入渗透到您的用户界面组件中。

现在,这里是这个概念中棘手的部分。用户界面组件,如我们插图中的组件AB,永远不会直接操作数据。它们总是会委托给父组件。我经常将这个概念解释为一种控制反转IoC)。我们不是直接执行由用户控制的用户界面触发的操作,而是委托给父组件,并告诉它执行这个操作。如果父组件也是一个简单的 UI 组件,我们再次委托。这样一直进行,直到我们达到容器组件。容器组件然后能够有效地在应用程序状态上执行所需的操作。一旦执行,更新的数据就会通过组件树向下渗透。这种构建用户界面的方法给您的应用程序架构带来了惊人的积极影响:

  • 所有您的数据操作都在一个中心位置处理

    这非常有好处,因为如果我们需要更改处理状态和数据的方式,我们总是可以只去一个地方。

  • 所有用户界面组件都可以是"纯"组件

    由于我们不会有任何直接操作数据的用户界面组件,并且它们只依赖于流入其输入的组件树中的数据,我们大多数情况下可以构建"纯"组件。这带来了"纯"组件的所有好处,包括性能提升。

  • 容器组件作为适配层

    由于容器组件是唯一与您的数据服务、数据库、状态机或您用于管理状态和数据的任何其他组件交互的组件,我们可以将它们视为您的应用程序用户界面到数据层的适配器。当您决定改变您的状态管理和数据源时,您唯一需要应用更改的地方就是您的容器组件。

  • 状态和用户界面的分离

    将您应用程序的状态与您的用户界面分离被远远低估了。通过构建一个简单的 UI 组件,它只通过其输入接受数据,我们可以构建高度灵活和可重用的组件。如果我们想将它们包含在完全不同的状态和数据上下文中,我们只需创建另一个容器组件。

纯化我们的任务列表

在前面的三个部分中,我们探讨了使用不可变数据结构的基本方法,以及 Angular 可以被配置为假设组件只有在它们的输入发生变化时才会改变。我们学习了"纯"组件的概念以及我们如何配置 Angular 的变更检测以获得一些性能优势。我们还学习了容器组件的概念,以将我们的 UI 组件与应用程序状态分离。

在本节中,我们希望重构我们的应用程序,以包括我们新学的关于不可变性、"纯"组件和容器组件的技能。

让我们从现有的任务列表组件开始。目前,这个组件直接与来自任务服务的数据交互。然而,我们已经了解到,“纯”UI 组件永远不应该直接检索或操作应用程序的状态或数据。相反,它们应该只依赖于它们的输入来检索数据。

打开src/app/tasks/task-list/task-list.component.ts文件并应用以下更改。代码更改以粗体显示:

import {Component, ChangeDetectionStrategy, EventEmitter, Input, Output, ViewEncapsulation} from '@angular/core';
import {Task, TaskListFilterType} from '../../model';

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None,
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListComponent {
  @Input() taskFilterTypes: TaskListFilterType[];
 @Input() activeTaskFilterType: TaskListFilterType;
 @Input() tasks: Task[];
 @Output() outAddTask = new EventEmitter<string>();
 @Output() outActivateFilterType = new EventEmitter<TaskListFilterType>();
 @Output() outUpdateTask = new EventEmitter<Task>();

  addTask(title: string) {
    this.outAddTask.emit(title);
  }

  activateFilterType(filterType: TaskListFilterType) {
    this.outActivateFilterType.emit(filterType);
  }

  updateTask(task: Task) {
    this.outUpdateTask.emit(task);
  }
}

你可以立即看出,我们的组件现在要简单得多。不再包含所有过滤逻辑,我们只是依赖于通过tasks输入传递给组件的任务。我们的任务列表组件现在假定传入组件输入的任务已经过过滤,并且它不再控制过滤过程本身。然而,它仍然渲染过滤条件,正如从activateFilterType方法中可以看到的,我们现在使用输出属性将过滤操作委托给父组件。我们还添加了添加任务以及更新任务的输出。我们从上一节关于容器组件的内容中了解到,我们的 UI 组件使用控制反转。这正是这里发生的事情。我们不再直接操作我们的状态,而是通过输出属性将操作委托给父组件。addTask方法和updateTask方法都只是发出输出,没有其他操作。

我们用于任务的原则也应用于过滤类型列表和活动过滤类型。我们使用输入属性taskFilterTypesactiveTaskFilterType,以便我们可以从父组件传递这些信息。任务列表不再负责控制活动过滤类型的状态,我们可以从父容器组件控制这个状态。

由于我们现在假定通过任务输入属性传递给组件的任务已经过过滤,因此我们需要对我们的组件模板进行一些小的修改。此外,我们不再需要在任务列表组件中使用异步管道,因为我们的组件将直接接收过滤任务的解析数组。我们将让容器组件处理可观察对象。让我们打开src/app/tasks/task-list/task-list.component.html文件并应用一些更改。更改的代码以粗体显示,省略号符号表示更多隐藏但无关的代码:

...
<div class="tasks">
  <mac-task *ngFor="let task of tasks"
            [task]="task"
            (outUpdateTask)="updateTask($event)"></mac-task>
</div>

在我们的任务列表组件中,这已经足够了。我们现在只依赖于输入属性来获取渲染组件所需的数据。这使我们的组件变得如此简单,不是吗?

让我们继续处理我们的任务列表容器组件。我们正在使用 Angular CLI 创建一个新的组件。这次,我们将组件创建到一个名为container的单独子文件夹中。随着我们的应用程序的增长,我们需要创建更多的容器组件,我们将它们全部放入这个文件夹中。

此外,请注意,我们现在开始使用 Angular CLI 的-cd onpush选项来生成组件。这将为我们生成的组件存根添加OnPush更改检测策略:

ng generate component --spec false -ve none -cd onpush container/task-list-container

任务列表容器现在负责处理渲染任务列表组件所需的数据。它还将执行所有必要的状态和数据操作,以覆盖我们的任务列表的行为。让我们打开生成的组件类文件:

import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {TaskService} from '../../tasks/task.service';
import {Task, TaskListFilterType} from '../../model';
import {Observable, combineLatest, BehaviorSubject} from 'rxjs';
import {map} from 'rxjs/operators';

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  tasks: Observable<Task[]>;
  filteredTasks: Observable<Task[]>;
  taskFilterTypes: TaskListFilterType[] = ['all', 'open', 'done'];
  activeTaskFilterType = new BehaviorSubject<TaskListFilterType>('all');

  constructor(private taskService: TaskService) {
    this.tasks = this.taskService.getTasks();

    this.filteredTasks = combineLatest(this.tasks, this.activeTaskFilterType)
      .pipe(
        map(([tasks, activeTaskFilterType]) => {
          return tasks.filter((task: Task) => {
            if (activeTaskFilterType === 'all') {
              return true;
            } else if (activeTaskFilterType === 'open') {
              return !task.done;
            } else {
              return task.done;
            }
          });
        })
      );
  }

  activateFilterType(type: TaskListFilterType) {
    this.activeTaskFilterType.next(type);
  }

  addTask(title: string) {
    const task: Task = {
      title, done: false
    };
    this.taskService.addTask(task);
  }

  updateTask(task: Task) {
    this.taskService.updateTask(task);
  }
}

当查看我们的新任务列表容器组件的代码时,你应该会注意到一些东西。代码是我们之前在任务列表组件中拥有的代码的精确副本。嗯,这看起来对吗?如果你再次查看代码,现在我们已经了解了如何将用户界面关注点从我们的应用程序状态中分离出来,你会注意到这些代码实际上并不是 UI 任务列表组件的责任。这是主要关注数据操作和检索的代码。实际上,这些代码永远不应该成为我们任务列表 UI 组件的一部分。这些代码显然属于容器组件。

下一步是创建我们的容器组件的视图模板。实际上,容器组件的模板中不应包含太多代码。理想情况下,你希望在容器组件的视图中做的唯一事情是渲染你在这个特定容器中关心的 UI 组件。让我们打开src/app/container/task-list-container/task-list-container.component.html文件,并将其内容更改为以下内容:

<mac-task-list
  [tasks]="filteredTasks | async"
  [taskFilterTypes]="taskFilterTypes"
  [activeTaskFilterType]="activeTaskFilterType | async"
  (outUpdateTask)="updateTask($event)"
  (outActivateFilterType)="activateFilterType($event)"
  (outAddTask)="addTask($event)">
</mac-task-list>

正如你所注意到的,在我们任务列表容器组件的视图中,我们唯一关心的事情是渲染任务列表 UI 组件。我们将过滤后的任务列表传递给任务列表组件。由于我们在容器组件中使用了一个可观察对象,我们再次使用异步管道来订阅并解析最新的过滤后的任务列表。同样,我们传递过滤类型列表和当前活动过滤器,我们两者现在都存储在容器中,并将其传递到任务列表组件中。

另一方面,当我们在收到任务更新、过滤器更改和新添加的任务的通知时,我们将绑定任务列表 UI 组件的输出,并在容器中调用必要的函数。任务列表 UI 组件只是告诉我们做什么,而在容器组件内部,我们知道如何去做。

摘要

在本章中,我们学习了如何以最适合我们应用程序可维护性的方式处理数据和应用程序状态。我们将任务服务从直接在服务内存储的任务上的同步操作切换到使用 Angular 内存网络 API 和 HTTP 客户端。

我们学习了如何从概念中获利,例如响应式编程、可观察的数据结构和不可变对象,以便使我们的应用程序性能更佳,最重要的是,简单且易于推理。

我们还学习了将用户界面与应用程序状态分离,并将容器组件的概念应用到我们的应用程序中。

在下一章中,我们将以更大的规模组织我们的应用程序。通过引入一个新的项目层,我们可以开始组织项目内的任务。我们将创建必要的状态和 UI 组件,以便在项目内查看和编辑任务。

第四章:以项目思维思考

是时候考虑更宏大的目标了。到目前为止,我们围绕简单任务列表的概念创建了应用程序中的所有内容。然而,我们想要构建比这更大的东西。用户应该能够将他们的任务组织到项目中。在本章中,我们将围绕我们的任务列表引入一个框架,使其感觉像一个完整的应用程序。通过引入项目组件、主要导航、标签界面和用户区域,我们正朝着最终应用程序的外观迈出重要一步。

我们将创建一个可重用的就地编辑器组件,并将其应用于我们应用程序中的许多现有区域。借助这个编辑器,我们系统的用户体验将大大提升,我们的用户将开始感受到底层响应式架构。

在本章中,我们还将创建一个评论系统,我们将以允许我们在任何我们希望用户放置评论的地方放置它的方式构建它。

应用程序安全和适当用户管理不在此书的范围之内。然而,我们将创建一个模拟已登录用户的虚拟用户服务。这个服务将被评论系统使用,并且我们将重构现有的组件以利用它。

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

  • 引入新的项目组件和额外的容器组件

  • 两个新的 RxJS 操作符switchMaptake

  • 为项目详情导航创建一个标签界面组件

  • 使用内容投影创建导航 UI 组件

  • 使用contenteditable创建就地编辑器

  • 使用@HostBinding@HostListener将组件成员绑定到宿主元素属性和事件

  • 使用@ViewChild装饰器获取视图元素

  • 通过使用ElementRef DOM 抽象执行 DOM 操作

  • 创建一个允许我们在应用程序的不同区域收集用户评论的评论系统

  • 使用第三方库 Moment.js 总结一个简单的管道来格式化相对时间间隔

  • 创建一个允许用户就地编辑文本字段的编辑器

进入项目

在这个主题中,我们将实现将我们的简单任务列表移动到一个按项目组织结构的所需更改。为此,我们需要修改我们组件的主要布局,并引入一个代表我们项目的新的组件。

项目服务

首先,让我们更新我们的应用程序模型以包括项目数据。为此,我们将创建一个项目的新模型,并更新我们的任务模型以添加一个项目 ID。

打开src/app/model.ts文件并应用以下更改:

export interface Task {
  readonly id?: number;
  readonly projectId?: number;
  readonly title: string;
  readonly done: boolean;
}

export type TaskListFilterType = 'all' | 'open' | 'done';

export interface Project {
 readonly id?: number;
 readonly title: string;
 readonly description: string;
}

每个任务现在都包括一个指向项目的引用。项目实体包括一个 ID、单个标题和描述属性。让我们也更新我们的内存中 Web API 数据库。打开src/app/database.ts文件并应用以下更改:

import {InMemoryDbService} from 'angular-in-memory-web-api';
import {Project, Task} from './model';

export class Database implements InMemoryDbService {
  createDb() {
    const projects: Project[] = [
 {id: 1, title: 'My first project', description: 'This is your first project.'},
 {id: 2, title: 'My second project', description: 'This is your second project.'}
 ];

    const tasks: Task[] = [
      {id: 1, projectId: 1, title: 'Task 1', done: false},
      {id: 2, projectId: 1, title: 'Task 2', done: false},
      {id: 3, projectId: 1, title: 'Task 3', done: true},
      {id: 4, projectId: 1, title: 'Task 4', done: false}
    ];

    return {projects, tasks};
  }
}

我们在我们的数据库中添加了两个项目,并更新了所有任务以包含对两个项目中的第一个项目的引用。

现在,我们需要一个服务来访问我们的项目,我们还应该更新我们的任务服务以包括一个允许我们查询属于特定项目任务的方法。

首先,让我们将更改应用到现有的任务服务中。打开src/app/tasks/task.service.ts文件并实现以下更改。有效的更改以粗体显示,省略号字符表示与更改应用无关的更多代码:

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject} from 'rxjs';
import {map} from 'rxjs/operators';
import {Task} from '../model';

@Injectable()
export class TaskService {
  …

 getProjectTasks(projectId: number) {
 return this.tasks
 .asObservable()
 .pipe(
 map((tasks) => tasks.filter((task) => task.projectId === projectId))
 );
 }
}

添加的getProjectTasks方法提供了一个映射的可观察对象,它接受我们的源任务主题,并将每个任务数组映射以生成一个过滤后的任务数组,该数组仅包含特定项目的任务。

好的,现在我们需要创建一个新的服务,允许我们从我们的内存中 Web API 数据库获取项目信息。让我们使用 Angular CLI 创建一个新的服务:

ng generate service --spec false project/project

Angular CLI 应该在src/app/project/project.service.ts路径上创建我们的服务。让我们打开该文件并将其内容替换为以下代码:

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject, Observable, combineLatest} from 'rxjs';
import {map} from 'rxjs/operators';
import {Project} from '../model';

@Injectable()
export class ProjectService {
  private projects = new BehaviorSubject<Project[]>([]);
  private selectedProjectId = new BehaviorSubject<number>(1);
  private selectedProject: Observable<Project>;

  constructor(private http: HttpClient) {
    this.loadProjects();
    this.selectedProject = combineLatest(this.projects, this.selectedProjectId)
      .pipe(
        map(([projects, selectedProjectId]) =>
        projects.find((project) => project.id === selectedProjectId)
      )
    );
  }

  private loadProjects() {
    this.http.get<Project[]>('/api/projects')
      .subscribe((projects) => this.projects.next(projects));
  }

  selectProject(id: number) {
    this.selectedProjectId.next(id);
  }

  getSelectedProject() {
    return this.selectedProject;
  }
}

让我们简要讨论前面的代码更改。我们的项目服务包含三个成员:

  • projects: BehaviorSubject<Project[]>

    projects成员行为主题在从我们的数据库加载后发射整个项目列表。这个主题是我们服务中所有操作的基础。

  • selectedProjectId: BehaviorSubject

    由于我们需要知道在我们的应用程序中哪个项目当前被选中,因此我们需要在我们的服务中存储这个信息。我们使用行为主题来发射当前选中的项目 ID。这允许我们通过selectedProjectId简单地发射一个项目 ID,如果我们想选择一个特定的项目。

  • selectedProject: Observable

    selectedProject可观察对象将始终发射当前选中的项目。我们将使用combineLatest来确保如果projectsselectedProjectId发射了变化。我们将通过selectedProject可观察对象流重新发射更新的选中项目。

在我们的服务构造函数中,我们首先调用loadProjects方法来进行 HTTP 调用到我们的内存中 Web API 数据库以获取项目列表。在loadProjects方法中,我们遵循与我们的任务服务相同的模式。我们订阅 HTTP 服务可观察对象,并通过我们内部的projects主题发射结果项。

在构造函数中执行 loadProjects 方法之后,我们将创建 selectedProject 可观察对象。我们将使用已经在上一章中讨论过的 combineLatest 来将 projectsselectedProjectId 主题组合成一个单一的可观察流。每当这两个输入可观察对象中的任何一个发出事件时,combineLatest 将将两个输入可观察对象的最新结果组合成一个单一的项目,并通过输出可观察流发出。我们使用 map 操作符从项目列表中提取选定的项目,并将其作为项目返回到可观察输出流中。

最后,selectProject 方法仅仅是通过 selectedProjectId 主题发出新的项目 ID。由于我们在这个使用 combineLatest 创建的 selectedProject 可观察对象中使用这个主题,这个变化将导致 selectedProject 可观察对象重新发出当前选定的项目。

作为最后一步,我们需要将我们的新服务添加到应用模块提供者中。让我们打开 src/app/app.module.ts 文件并应用以下更改:

…
import {ProjectService} from './project/project.service';

…
  providers: [ProjectService, TaskService],
…

现在,关于我们项目服务的部分就到这里了。我们以高度反应性的方式创建了我们的服务,其中我们存储所有状态在 RxJS 行为主题中,并响应变化、转换流,并在需要通信更新时发出。

项目组件

我们已经实现了处理我们项目所需的模型、数据和状态的所有必要更改。现在,是时候继续到显示我们的项目和它们所属的任务所需的组件了。

让我们从使用 Angular CLI 创建项目组件开始:

ng generate component --spec false -ve none -cd onpush project/project

首先,让我们通过替换以下代码来更改位于 src/app/project/project/project.component.ts 的组件类文件:

import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core';
import {Project} from '../../model';

@Component({
  selector: 'mac-project',
  templateUrl: './project.component.html',
  styleUrls: ['./project.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectComponent {
  @Input() project: Project;
}

这里没有什么特别之处。我们创建了一个简单的项目组件,它接受一个项目对象作为输入。让我们继续我们的新组件模板,并更改位于 src/app/project/project/project.component.ts 的文件内容:

<header class="header">
  <h2 class="title">{{project.title}}</h2>
  {{project.description}}
</header>
<mac-task-list-container>
</mac-task-list-container>

在我们的模板中,我们首先渲染项目标题,它由我们项目的标题和描述组成。在项目标题之后,我们想要渲染所有项目任务。我们可以通过包含任务列表容器组件来实现这一点。

更新任务列表容器

关于我们的项目组件的部分就到这里了。然而,我们的任务列表容器仍然以处理所有可用任务的方式实现,并不知道任何关于项目的信息。让我们通过修改位于 src/app/container/task-list-container.component.ts 的组件类来改变这一点。更改的部分以粗体显示,而无关部分则被省略号字符隐藏:

…
import {Project, Task, TaskListFilterType} from '../../model';
import {map, switchMap, take} from 'rxjs/operators';
import {ProjectService} from '../../project/project.service';

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  selectedProject: Observable<Project>;
  …

  constructor(private taskService: TaskService, private projectService: ProjectService) {
    this.selectedProject = this.projectService.getSelectedProject();

 this.tasks = this.selectedProject.pipe(
 switchMap((project) => this.taskService.getProjectTasks(project.id))
 );

    …
  }

  …

  addTask(title: string) {
    this.selectedProject
 .pipe(
 take(1)
 )
 .subscribe((project) => {
 const task: Task = {
 projectId: project.id, title, done: false
 };
 this.taskService.addTask(task);
 });
  }

  …
}

首先,我们引入一个名为 selectedProject 的新成员,它是一个我们从项目服务获取的可观察流。在我们的组件构造函数中,我们注入项目服务,并通过在服务上调用 getSelectedProject 方法来接收可观察对象。如果你还记得上一个主题,这个可观察对象将始终在我们的应用程序中发射当前所选项目。

在构造函数中,我们还需要改变我们获取任务列表的方式。从现在开始,我们将在容器内使用的任务列表始终依赖于所选项目。为了实现这一点,我们将研究一个新的 RxJS 操作符,称为 switchMap

让我先尝试用一句话解释 switchMap 的工作原理。它接受一个可观察流,并为每个发射的项目连接一个不同的可观察流到输出。这可能听起来有些令人困惑,所以为了让你更好地掌握这个新概念,让我们看看一个简化的例子:

import {from, interval} from 'rxjs';
import {switchMap} from 'rxjs/operators';

const o1 = interval(1000);
const o2 = from(['a', 'b']);

o1.pipe(
 switchMap(() => o2)
).subscribe((item) => console.log(item));

之前的例子使用 switchMap 将一个可观察对象 (o2) 连接到一个新的输出可观察对象,每次源可观察对象 (o1) 发射一个项目时。我们使用 interval 辅助函数创建一个每秒发射一个项目的可观察对象。对于第二个可观察对象 (o2),我们使用 from 辅助函数生成一个发射字符串 ab 作为单独项目的可观察对象序列。之后,我们使用 switchMap 操作符,它将创建一个新的输出可观察对象,将我们的 (o2) 可观察对象连接到输出,每当源可观察对象 (o1) 发射新项目时。因此,之前的例子将每秒将字符串 ab 记录到控制台:

图片

一个流图显示了 switchMap 操作符的行为

如果你习惯了 Promises,那么你也可以将 switchMap 操作符视为在 then 函数中返回新的 Promises 来构建异步 Promise 链:

function timer(time) {
  return new Promise(resolve => setTimeout(resolve, time));
}

timer(1000)
 .then(() => timer(1000))
 .then(() => timer(1000))
 .then(() => timer(1000))
 .then(() => console.log('After 4 seconds!'));

通过链式调用 Promises,我们可以按顺序执行四个不同的异步操作。switchMap 操作符的行为几乎相同,但我们可以链式调用四个不同的可观察对象:

import {timer} from 'rxjs';
import {switchMap} from 'rxjs/operators';

timer(1000).pipe(
 switchMap(() => timer(1000)),
 switchMap(() => timer(1000)),
 switchMap(() => timer(1000))
).subscribe((item) => console.log('After 4 seconds!'));

好的,我们已经了解了 switchMap 操作符以及我们如何使用它来按顺序链式调用多个异步操作。现在,让我们回到我们的任务列表容器组件,看看我们如何将这个概念应用到获取项目任务。以下核心摘录仅显示我们在任务列表容器组件中已经使用的相关更改部分:

…  
this.tasks = this.selectedProject.pipe(
 switchMap((project) => this.taskService.getProjectTasks(project.id))
);
…

在我们的情况下,我们使用switchMap操作符将selectedProject可观察对象作为输入,并切换到通过getProjectTasks调用获得的可观察对象。这项技术允许我们将两个异步操作,获取选择的项目和接收任务,按顺序串联起来。在switchMap操作符的回调中,我们接收选择的项目对象,并使用项目的 ID 来获取项目任务的可观察对象。

现在,每当selectedProject可观察对象发出新的值时,我们的任务可观察对象也会发出所选项目的最新任务。非常好,非常响应式。

好的!只剩下一个变更需要讨论。让我们专注于addTask方法中的变更。由于我们的任务模型现在也包括一个projectId属性,我们需要确保每次创建任务时都添加正确的项目 ID。

addTask方法的代码变更中,我们使用了另一个未知的 RxJS 操作符take。不用担心,take操作符的工作方式比switchMap操作符更直接。再次,让我们从简化开始:

import {from} from 'rxjs';
import {take} from 'rxjs/operators';

from([1, 2, 3]).pipe(
 take(1)
).subscribe((item) => console.log(item));

之前示例中的代码将在控制台打印数字一。在经过take操作符转换后,数字二和三将不会被输出流发出。take操作符将创建一个新的可观察对象流,该流将只发出通过传递给它的参数指定的项目数。在指定的项目数发出后,该流将自行关闭,即使调用取消订阅所有父流:

图片

使用take操作符将具有三个发射的输入可观察对象转换为只有一个发射的输出可观察对象

take操作符在我们的行为主题上特别引人注目,当你想在发出的项上执行一次性操作时。由于行为主题总是在新的订阅中发出最新的项,我们可以使用take操作符立即提取行为主题的最新值并对其进行操作。

让我们再次查看更新后的addTask方法的代码,看看我们是如何以及为什么在那里使用take操作符的:

…
addTask(title: string) {
  this.selectedProject
 .pipe(
 take(1)
 )
 .subscribe((project) => {
 const task: Task = {
 projectId: project.id, title, done: false
 };
 this.taskService.addTask(task);
 });
}
…

首先,我们需要了解addTask方法在什么时间点被调用。这个调用发生在用户在用户界面中点击添加任务按钮时。我们现在真正想要做的就是获取我们selectedProject可观察对象的最新的值,以便我们可以使用项目 ID 来创建与该项目关联的任务。然而,如果我们以通常的方式订阅了selectedProject可观察对象,我们会在addTask方法中保持这些订阅永远活跃。这种行为意味着当我们继续调用此方法时,比如说,十次,每次选择项目发生变化时,这十个订阅都会再次触发并创建一些不想要的重复任务。

使用take操作符,我们可以防止这种行为。它允许我们创建一个可观察对象流,一旦达到所需数量的项目通过,它将自动取消订阅。当我们将数字一作为项目计数参数传递给take操作符时,我们只需从源流中取一个项目,并创建一个仅对这一个项目有效的订阅。

那就是任务列表容器组件中的所有更改。到目前为止,我们已经创建了一个新的项目服务,更新了现有的任务服务,创建了一个项目组件,并更新了我们的任务列表容器组件以处理当前选中的项目。最后要做的就是将我们的项目组件作为顶级组件集成到整体应用程序中。

集成项目组件

在这个主题中,我们将整合到目前为止应用到我们的应用程序中的更改。目前,我们的根组件正在直接渲染任务列表容器组件,这我们需要更改以渲染我们的项目。

我们可以直接在我们的应用程序根组件中渲染项目 UI 组件,然而,我们已经了解到我们永远不应该在没有容器组件来分离我们的状态关注点的情况下包含顶级组件。

让我们修复这个问题,并使用 Angular CLI 创建一个项目容器组件:

ng generate component --spec false -ve none -cd onpush container/project-container

在组件占位符生成后,打开src/app/container/project-container/project-container.component.ts文件,并应用以下更改:

import {Component, ChangeDetectionStrategy, ViewEncapsulation} from '@angular/core';
import {Observable} from 'rxjs';
import {Project} from '../../model';
import {ProjectService} from '../../project/project.service';

@Component({
  selector: 'mac-project-container',
  templateUrl: './project-container.component.html',
  styleUrls: ['./project-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectContainerComponent {
  selectedProject: Observable<Project>;

  constructor(private projectService: ProjectService) {
    this.selectedProject = projectService.getSelectedProject();
  }
}

这里没有太多花哨的东西。我们正在使用项目服务来获取当前选中项目的可观察对象。我们将使用这个可观察对象来渲染我们的项目 UI 组件。让我们打开位于src/app/container/project-container/project-container.component.html的新容器组件的视图模板,并用以下代码替换占位符模板:

<mac-project [project]="selectedProject | async">
</mac-project>

此外,视图模板极其简单。然而,我们不应因为事物看起来过于简单而不值得包装就跳过数据容器组件。你应该始终坚持规则,即每个顶级组件都需要一个容器组件来处理数据和状态问题。这将在未来为你节省大量时间。

现在,是时候将项目容器组件包含到我们的根组件视图中了。同时,我们也会稍微调整我们的应用程序布局,并进一步接近我们最终的应用程序外观。

让我们打开位于src/app/app.component.html的根组件视图模板,并应用以下更改:

<aside class="side-nav"></aside>
<main class="main">
  <mac-project-container></mac-project-container>
</main>

我们还添加了两个布局元素以及我们新创建的项目容器组件。这就是我们将项目组件集成到我们的应用程序中的所有更改。

概述

恭喜!你已经成功地将项目功能的第一个版本实现到了我们的应用程序中。在这个主题中,我们创建了很多东西。除了构建模型和数据库、服务和组件之外,我们还通过创建和包含一个新的项目容器组件成功集成了我们的功能。请继续在你的浏览器中预览我们的更改。我希望你对结果感到满意,并期待进一步的改进。

让我们总结一下在这个主题中我们所做的工作:

  • 创建并更新了我们的模型以包含项目

  • 实现了一个新的项目服务来获取项目数据,并更新了现有的任务服务以处理项目任务

  • 我们创建了一个新的项目组件以及一个项目容器组件,以将我们的 UI 与状态和数据关注点分离

  • 我们更新了任务列表容器组件,使其使用活动项目信息来仅显示相关的项目任务

  • 我们学习了 RxJS 中的switchMaptake操作符以及如何在我们的应用程序中使用它们

  • 我们将根组件视图更新为包含我们的项目容器组件,而不是任务列表容器

创建一个分页界面组件

让我们向项目中引入一个新的 UI 组件,该组件将为我们提供一个分页界面,我们可以用它来在项目组件内部进行导航。我们希望将项目视图划分为不同的区域,这些区域可以通过这个分页界面访问:

我们将要创建的分页界面截图

在我们创建一个新的组件来渲染标签之前,我们将更新我们的模型以声明一个接口,该接口用于表示单个标签。打开src/app/model.ts文件并应用以下更改:

…
export interface Tab {
 readonly id: any;
 readonly title: string;
}

我们的标签将始终包含一个标题和一个 ID,这在以后我们需要区分单个标签时将非常有用。接下来,我们将创建我们的标签组件。使用 Angular CLI,我们可以创建我们新组件的占位符:

ng generate component --spec false -ve none -cd onpush ui/tabs/tabs

让我们打开位于src/app/ui/tabs/tabs.component.ts的组件类,并添加以下代码:

import {
  ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewEncapsulation
} from '@angular/core';
import {Tab} from '../../model';

@Component({
  selector: 'mac-tabs',
  templateUrl: './tabs.component.html',
  styleUrls: ['./tabs.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TabsComponent {
  @Input() tabs: Tab[];
  @Input() activeTab: Tab;
  @Output() outActivateTab = new EventEmitter<Tab>();

  activateTab(tab: Tab) {
    this.outActivateTab.emit(tab);
  }
}

我们遵循简单和“纯”组件的概念,并且只使用输入来确定我们应该如何渲染我们的标签。此外,当一个标签被激活时,我们将调用activateTab方法,然后通过outActivateTab输出发出。到现在为止,你应该非常熟悉这个组件类及其背后的概念。tabs输入预期是一个标签对象的列表,我们已经在模型中创建了一个接口。让我们打开位于src/app/ui/tabs/tabs.component.html的组件视图模板,并用以下代码替换其内容:

<ul class="tab-list">
  <li *ngFor="let tab of tabs"
      class="tab-list-item">
    <button class="tab-button"
            [class.tab-button-active]="activeTab?.id === tab.id"
            (click)="activateTab(tab)">{{tab.title}}</button>
  </li>
</ul>

我们正在遍历我们的标签列表,并为每个标签渲染一个按钮作为有效的标签元素。在标签按钮上,我们根据当前重复的标签是否与传递给我们的组件作为活动标签的标签相同来设置一个类。这将使我们的活动标签看起来与其它不同。当用户点击其中一个标签时,我们将调用activateTab方法并将点击的标签对象作为参数传递。回到组件代码,你可以看到我们随后通过outActivateTab输出发射这个标签。

好的,这就是我们标签组件所需的所有内容。让我们在我们的项目组件中使用标签,并提供一个标签界面来访问项目详情。

首先,让我们打开位于src/app/project/project/project.component.ts的项目组件类,并按照以下更改更新我们的代码:

import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewEncapsulation} from '@angular/core';
import {Project, Tab} from '../../model';

…
export class ProjectComponent {
  @Input() project: Project;
 @Input() tabs: Tab[];
 @Input() activeTab: Tab;
 @Output() outActivateTab = new EventEmitter<Tab>();

 activateTab(tab: Tab) {
 this.outActivateTab.emit(tab);
 }
}

我们将在我们的项目组件中使用标签组件,因此我们需要提供必要的输入并处理标签组件的输出。因此,我们的项目组件现在也将包含输入tabsactiveTab

我们从上一章第三章,处理数据和状态中学到了,我们的 UI 组件应该始终将输出委托到容器组件。遵循这个概念将确保我们将状态和数据操作与 UI 组件分离,这将显著提高代码的整体可维护性。由于我们的标签组件会通知我们活动标签的变化,我们将通过委托并仅重新发射outActivateTab输出来响应这一变化。

好的,让我们打开位于src/app/project/project/project.comonent.html的项目视图模板,并按照以下更改进行编辑:

<header class="header">
  <h2 class="title">{{project.title}}</h2>
  {{project.description}}
</header>
<mac-tabs [tabs]="tabs"
 [activeTab]="activeTab"
 (outActivateTab)="activateTab($event)">
</mac-tabs>
<mac-task-list-container *ngIf="activeTab.id === 'tasks'">
</mac-task-list-container>

我们正在渲染我们的标签组件,将标签对象和接收到的当前活动标签作为输入传递给项目组件。我们还将监听标签组件上的标签激活输出,并在项目组件内调用activateTab方法。

我们还使用活动标签的信息来决定是否渲染任务列表容器组件。只有当当前活动标签的 ID 等于“tasks”时,我们才会渲染任务列表。

好的,现在我们需要确保我们将所需的数据传递给项目组件。我们还需要确保当一个标签被激活时,我们对应用程序状态进行必要的更改。项目容器组件渲染项目组件,所以让我们打开位于src/app/container/project-container/project-container.component.ts的组件类,并应用以下更改:

…
import {Project, Tab} from '../../model';

…
export class ProjectContainerComponent {
  selectedProject: Observable<Project>;
 tabs: Tab[] = [
 {id: 'tasks', title: 'Tasks'},
 {id: 'comments', title: 'Comments'},
 {id: 'activities', title: 'Activities'}
 ];
 activeTab: Tab = this.tabs[0];

  …
 activateTab(tab: Tab) {
 this.activeTab = tab;
 }
}

由于我们现在处于容器组件中,存储状态并在该状态上执行操作是可以的。我们添加了一个标签对象列表,我们已经提前思考了一些。评论标签页将用于切换到我们在本章后面将要构建的评论系统。活动标签页将在本书的后续章节中使用。

我们还使用activeTab成员存储当前激活的标签页,并用我们标签列表中的第一个标签初始化它。

activateTab方法中的代码看起来几乎太简单,以至于不像是正确的。我们只是在接受一个标签对象作为参数,并更新我们的activeTab成员。

现在,为了使用我们的标签组件,我们只剩下更新项目容器组件内的绑定。让我们打开位于src/app/container/project-container/project-container.component.html的组件视图模板,并应用以下更改:

<mac-project [project]="selectedProject | async"
             [tabs]="tabs"
 [activeTab]="activeTab"
 (outActivateTab)="activateTab($event)">
</mac-project>

这很简单,对吧?这次最后的更改已经包含了提供友好标签页界面所需的所有内容,针对我们的项目组件。我们将利用activeTab属性来激活我们项目更多的详细视图。目前,任务标签页是唯一一个在我们的用户界面中启用功能的标签页,但随着我们继续添加功能,我们也将使用其他两个标签页进行导航。

构建主要导航

在本章中,我们已经在我们的应用程序中添加了一个项目功能,我们还添加了一个标签页界面来导航项目的详细信息。然而,目前还没有方法在项目之间进行导航。这正是我们现在要改变的地方。在本节中,我们将创建组件来构建我们的主要导航。然后我们将将其集成并使用它来在应用程序中导航到所有现有的项目:

图片

我们将在本书的这一节中构建的主要导航的截图

我们正在设计我们的导航组件,以便我们可以在任何情况下快速使用它们。为了实现这个目标,我们将探讨 Angular 中的一个新概念,称为内容投影。通过使用内容投影,我们可以达到组件组合的新水平。

使用内容投影进行组合

内容投影允许你将父组件中的一个视图部分插入到其子组件中。当涉及到组合时,这个概念是一个强大的工具。通过所谓的插槽,我们可以在子组件中标记一个位置,以便给父组件提供传递视图部分的机会。

让我们看看一个简单的内容投影示例,这有助于我们理解它能做什么:

@Component({ 
  selector: 'mac-reveal-content', 
  template: ` 
    <h2 (click)="showContent = !showContent">{{revealTitle}}</h2>
    <div *ngIf="showContent" class="content">
      <ng-content></ng-content>
    </div>
  ` 
}) 
export class RevealContentComponent {
  @Input() title: string;
  showContent = false;
} 

@Component({ 
  selector: 'mac-root', 
  template: ` 
    <mac-reveal-content revealTitle="Click to show more">
      <p>I'm the content which is shown or hidden when you click the above title</p>
    </mac-reveal-content>
  `
})
export class AppComponent {}

前面的示例展示了一个简单的 UI 组件,称为内容揭示组件。它显示了一个我们可以点击以显示或隐藏内容的标题。在许多情况下,这种行为都是有利的,并且类似于手风琴或可折叠组件的概念。

在这个示例中有两个独特的地方,你应该感到不熟悉。我还为你突出显示了这两行代码,这样你可以立即看到它们。

在我们的内容揭示组件中,我们使用<ng-content>元素来标记从父组件投影内容的位置。这个元素也被称为内容投影槽,你可以在组件视图中拥有多个这样的槽。

第二件事,这是我们迄今为止从未做过的事情,是在前一个示例的应用组件中。我们在内容揭示组件的开启和关闭标签之间放置了一些模板代码。通常,一个组件会消除放入其宿主元素中的任何模板代码。然而,在这样做之前,它首先检查子组件是否包含内容投影槽。如果子组件中有内容投影槽,它将把在宿主元素中找到的任何视图部分投影到子组件指定的位置。这就是我们使用<ng-content>元素标记的位置。

内容投影和投影槽是从 Web 组件 Shadow DOM 规范中借用的概念。Angular 目前默认不使用 Shadow DOM,因此这种行为是模拟的。然而,概念是相同的。如果你熟悉 Angular.js,Angular 的第一个版本,内容投影与 Angular.js 中的 transclusion 概念非常相似。

此外,我们可以在<ng-content>元素上使用一个选择属性来设置类似于 CSS 的选择器。这个选择器将被用来选择性地选择那些应该被投影到特定内容槽中的元素。通过这种方式,你可以拥有多个内容槽,它们覆盖不同的需求。

来自组件宿主元素的元素只能投影一次,并且这种内容投影是通过按顺序通过所有<ng-content>元素进行投影来实现的,任何匹配的元素都会被投影。如果你在模板中有多个竞争的内容投影槽,第一个将获胜并接收被投影到你的组件中的元素。

让我们看看另一个示例,以说明这种行为:

@Component({ 
  selector: 'mac-multi-projection', 
  template: `
    <header class="title">
      <ng-content select="h1"></ng-content>
    </header>
    <main class="rest-content">
      <ng-content></ng-content>
    </main>
  `
}) 
export class MultiProjectionComponent {}

@Component({
  selector: 'mac-root',
  template: `
    <mac-multi-projection>
      <h1>This title will be projected in the first slot</h1>
      <p>Any other element will be projected into the second, generic slot</p>
    </mac-multi-projection>
  `
})
export class AppComponent {}

前面的示例展示了如何使用<ng-content>元素上的select属性进行选择性的内容投影。由于我们使用了一个匹配所有h1元素的 CSS 选择器,h1标题元素被投影到第一个内容投影槽中。第二个内容槽没有包含select属性,因此接受所有其他被投影到组件中的元素。

创建导航组件

我们将从三个单独的组件构建我们的导航。让我们快速看看它们的目的:

  • 导航项组件:代表我们导航中的一个导航项。其职责是渲染导航项的标题以及当项被激活时的行为。

  • 导航部分组件:这个组件用于在视觉上将属于一起的导航项分开。我们可以使用这个组件将导航项分组在标题下。这种分组使得我们的用户更容易找到他们正在寻找的导航项。

  • 导航组件:这个组件负责将整个导航组合在一起。它只是一个容器,将我们的导航部分组件组合在一起。

通过使用选择性的内容投影,我们可以提供一个优秀的内容 API,这使得在任何情况下使用我们的导航组件变得容易。构建基于内容的 API 的好处是我们可以通过在视图模板中包含它们来组合我们的组件。

让我们从创建三个组件中的最低级组件开始,即导航项:

ng generate component --spec false -ve none -cd onpush ui/navigation-item/navigation-item

让我们打开位于src/app/ui/navigation-item/navigation-item.component.html的视图模板,并使用以下代码更改其内容:

{{title}}

是的,我知道,这看起来太简单以至于不真实。但这正是我们导航项组件视图中所需要的所有内容。让我们继续到位于src/app/ui/navigation-item/navigation-item.component.ts的导航项组件类:

import {
  ChangeDetectionStrategy, Component, EventEmitter, HostListener, Input, Output,
  ViewEncapsulation
} from '@angular/core';

@Component({
  selector: 'mac-navigation-item',
  templateUrl: './navigation-item.component.html',
  styleUrls: ['./navigation-item.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class NavigationItemComponent {
  @Input() title: string;
  @Input() navId: any;
  @Output() outActivateNavigationItem = new EventEmitter<any>();

  @HostListener('click')
  activateNavigationItem() {
    this.outActivateNavigationItem.emit(this.navId);
  }
}

除了title输入外,该组件还接受一个名为navId的输入,该输入将在激活时使用。这个 ID 将帮助我们父组件检测哪个导航项被激活。

我们在组件中使用了一个之前未使用过的新装饰器。@HostListener装饰器是 Angular 工具集的一个方便的补充。借助主机监听器,你可以在组件的主机元素上创建事件绑定,但处理它们在你的组件内部。通常,主机元素并不直接受我们的组件控制。主机元素始终位于我们的组件放置的父组件中。然而,有时需要绑定到该元素的属性或事件。就像在我们的案例中,我们希望绑定到我们组件的主机元素的点击事件。你可以在一个方法上使用@HostListener装饰器来绑定该方法到主机元素上的特定事件。将你想要绑定的事件名称作为参数传递给装饰器。在我们的案例中,我们绑定到导航项主机元素的点击事件。每次点击事件被触发时,我们的activateNavigationItem方法将被执行。

activateNavigationItem方法内部,当点击一个导航项后,我们通过outActivateNavigationItem输出发射特定导航项的 ID。

好的,关于我们的导航项就到这里。让我们在导航组件树中再往上一层,创建导航部分组件:

ng generate component --spec false -ve none -cd onpush ui/navigation-section/navigation-section

导航部分组件负责将导航项组合在一起。让我们首先打开位于src/app/ui/navigation-section/navigation-section.component.html的视图模板并应用必要的更改:

<div class="title">{{title}}</div>
<div class="item-list">
  <ng-content select="mac-navigation-item"></ng-content>
</div>

每个导航部分都包含一个标题以及一系列导航项。现在,我们正在使用内容投影,这是我们在上一节中讨论过的。我们在视图模板中标记了一个位置,以便从父组件中投影视图部分到导航部分组件。由于我们知道我们只想将导航项组件投影到我们的导航部分,所以我们正在使用<ng-content>元素上的select属性。通过将select属性设置为mac-navigation-item,Angular 将只投影导航项组件到这个内容槽中。

让我们也对我们的导航部分组件类src/app/ui/navigation-section/navigation-section.component.ts应用更改:

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

@Component({
  selector: 'mac-navigation-section',
  templateUrl: './navigation-section.component.html',
  styleUrls: ['./navigation-section.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class NavigationSectionComponent {
  @Input() title: string;
}

这个组件类很简单。我们在这里做的唯一一件事是提供一个标题输入,该输入在导航部分视图中被渲染。我们组件的其余逻辑是通过内容投影处理的,它为我们提供了一个美丽的内容 API。

现在,我们已经在应用中使用这两个组件,并将它们排列在我们的应用中的任何视图模板中。例如,我们可以编写一个导航来访问可爱的狗狗图片:

<mac-navigation-section title="Dogs">
  <mac-navigation-item navId="pug" title="Pug"></mac-navigation-item>
  <mac-navigation-item navId="french-bulldog" title="French Bulldog"></mac-navigation-item>
  <mac-navigation-item navId="corgi" title="Corgi"></mac-navigation-item>
</mac-navigation-section>

这就是基于内容 API 的强大之处。使用内容投影,我们提供了组合复杂组件树和仅使用视图模板配置行为的能力。这带来了显著的使用便利性。不出所料,许多 Angular UI 库都在使用内容投影来创建它们的 API,仅仅是因为使用 Angular 模板声明性地配置所需的一切要容易得多。

好的,现在,让我们创建我们三个组件中的最后一个——导航组件。导航组件为我们的导航添加了一个框架,并将导航部分组合在一起:

ng generate component --spec false -ve none -cd onpush ui/navigation/navigation

首先,让我们打开位于src/app/ui/navigation/navigation.component.html的导航组件的视图模板,并将其内容更改为以下代码:

<nav>
  <ng-content select="mac-navigation-section"></ng-content>
</nav>

我们再次使用内容投影将元素投影到我们的导航视图中。这次,我们只选择导航部分组件。让我们也更改src/app/ui/navigation/navigation.component.ts中的组件类:

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

@Component({
  selector: 'mac-navigation',
  templateUrl: './navigation.component.html',
  styleUrls: ['./navigation.component.css'],
  encapsulation: ViewEncapsulation.None
})
export class NavigationComponent {

}

好的,这就像一个组件类可能的那样简单。这个类是空的,我们没有提供任何特定的组件配置。你可能想知道为什么我们甚至需要这个组件。嗯,为了决定我们组件存在的权利,我们需要查看所有定义组件的部分。组件类可能不包含任何行为内容。然而,样式和组件视图可以提供使组件值得存在的材料。如果你使用内容投影,你通常会在你的类中拥有非常少的代码,这是完全可以接受的。只要内容投影或甚至仅封装的样式为我们开发者提供了更方便的 API,我们就应该对我们的架构感到满意。

回到整体导航组件树,事情可能会开始变得有些混乱,当涉及多个层级时这是正常的。只是为了快速回顾:

  • 导航项正在渲染标题并处理当项被激活时的行为

  • 导航部分渲染部分标题和项目导航项

  • 导航仅投影导航部分,并作为我们导航的容器

现在,我们已经准备好使用我们的导航组件,并在我们的应用程序中将它们付诸实践!

提供项目导航

在本节中,我们将使用我们刚刚创建的导航组件来提供项目导航。为此,我们首先将在src/main/app/app.component.ts中找到的应用程序组件类中引入必要的更改。这是我们第一次处理应用程序或根组件类。由于这是我们根组件,我们将其视为容器组件,并获得了处理状态和数据的特权:

import {Component, ViewEncapsulation} from '@angular/core';
import {Observable} from 'rxjs';
import {Project} from './model';
import {ProjectService} from './project/project.service';

@Component({
  selector: 'mac-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  encapsulation: ViewEncapsulation.None
})
export class AppComponent {
  projects: Observable<Project[]>;
  selectedProject: Observable<Project>;

  constructor(private projectService: ProjectService) {
    this.projects = projectService.getProjects();
    this.selectedProject = this.projectService.getSelectedProject();
  }

  selectProject(id: number) {
    this.projectService.selectProject(id);
  }
}

在我们的应用程序组件中,我们存储了两个可观察对象。使用项目服务,我们获取了一个发出可用项目列表的可观察对象。我们还存储了一个发出当前选中项目的可观察对象。

selectProject方法接受一个项目 ID 作为参数,并将调用委托给我们的项目服务。这将导致所有订阅了选中项目可观察对象的观察者接收到新选中的项目。如果你记得,我们已经在我们的任务列表容器组件中使用了这个可观察对象。

让我们继续到我们的应用程序组件的视图模板,位于src/app/app.component.html,并应用以下更改:

<aside class="side-nav">
 <mac-navigation>
 <mac-navigation-section title="Projects">
 <mac-navigation-item *ngFor="let project of projects | async"
 [navId]="project.id"
 [title]="project.title"
 [class.active]="project.id === (selectedProject | async).id"
 (outActivateNavigationItem)="selectProject($event)">
 </mac-navigation-item>
 </mac-navigation-section>
 </mac-navigation>
</aside>
<main class="main">
  <mac-project-container></mac-project-container>
</main>

我们正在使用我们的导航组件在为主导航预留的空间中创建项目导航。目前,我们正在渲染属于我们项目的单个导航部分。通过使用NgFor指令,我们在可用项目列表中为每个项目重复一个导航项组件。我们将项目 ID 绑定到navId输入,以及将项目标题绑定到导航项的title输入。此外,如果特定项目当前被选中,我们还在导航项组件上设置了一个活动类。

如果导航项之一被激活,我们调用应用程序组件的selectProject方法,并将outActivgateNavigationItem发出的 ID 作为$event传递。

就这样!你已经成功构建了一个项目导航。现在是时候在浏览器中预览你的更改了。你应该已经能够在我们数据库中添加的两个项目之间进行导航。由于我们的任务列表容器已经利用了所选项目的可观察性,因此项目之间的切换逻辑应该已经非常流畅。你可以尝试导航到第二个项目,该项目中没有任何任务。尝试添加任务并在两个项目之间切换。

概述

在本节中,我们创建了我们的导航组件,并使用它们来创建我们的项目导航。你已经了解了以下概念:

  • 使用内容投影为你的组件构建基于内容的 API

  • 内容投影槽和<ng-content>的使用理念

  • 使用内容槽的select属性来选择性地投影内容

  • 使用@HostListener装饰器将宿主元素事件绑定到组件方法

一个编辑器统治一切

我们将在我们的应用程序中处理大量的用户输入。因此,为我们的用户提供愉快的创作体验至关重要。用户需要能够编辑项目标题、描述和任务标题。在我们在本章后面创建的评论系统中,用户还需要能够编辑他们的评论。我们可以使用常规文本区域输入并使用对话框来编辑这些字段,但这对于现代用户界面来说似乎过于过时。原生输入字段很棒,但有时它们并不提供出色的用户体验。我们正在寻找一种在原地编辑内容的方法:

图片

我们原地编辑器正在工作的截图,显示了保存和取消按钮

为了构建我们的原地编辑器,我们将使用 contenteditable API,这将使用户能够直接在 HTML 元素中修改内容。

以下示例说明了我们如何使用contenteditable属性来使 HTML 元素可编辑:

<h1 contenteditable>I'm an editable title</h1> 
<p>I can't be edited</p> 

在一个空白 HTML 页面上运行前面的示例,并点击h1文本。你会看到该元素已经变为可编辑状态,你可以输入来修改其内容。

通知可编辑元素中的变化相对简单。每个可编辑的 DOM 元素都会发出一个输入事件,这将使我们能够轻松地做出反应:

const h1 = document.querySelector('h1'); 
h1.addEventListener('input', event => console.log(h1.textContent); 

通过这个例子,我们已经创建了一个简单的原地编辑器实现,我们可以监控用户应用的变化。在本主题中,我们将使用这项标准技术来构建一个可重用的组件,我们可以在任何需要使内容可编辑的地方使用它。

创建一个编辑器组件

首先,让我们使用 Angular CLI 为我们的编辑器创建一个新的占位符组件:

ng generate component --spec false -ve none -cd onpush ui/editor/editor

文件生成后,让我们打开位于src/ui/editor/editor.component.ts的组件类,并应用以下代码更改:

import {
  AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostBinding, HostListener, 
  Input, OnChanges, Output, SimpleChanges, ViewChild, ViewEncapsulation
} from '@angular/core';

@Component({
  selector: 'mac-editor',
  templateUrl: './editor.component.html',
  styleUrls: ['./editor.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditorComponent implements OnChanges, AfterViewInit {
  @ViewChild('editableContentElement') editableContentElement: ElementRef;
  @HostBinding('class.edit-mode') editMode = false;
  @Input() content: string;
  @Input() showControls: boolean;
  @Output() outSaveEdit = new EventEmitter<string>();
  @Output() outCancelEdit = new EventEmitter<never>();

  ngOnChanges(changes: SimpleChanges) {
    if (changes.content && this.editableContentElement) {
      this.setEditableContent(this.content);
    }
  }

  ngAfterViewInit() {
    this.setEditableContent(this.content);
  }

  @HostListener('click')
  focusEditableContent() {
    if (this.editMode) {
      this.editableContentElement.nativeElement.focus();
    }
  }

  saveEdit() {
    this.editMode = false;
    this.outSaveEdit.emit(this.getEditableContent());
  }

  cancelEdit() {
    this.editMode = false;
    this.setEditableContent(this.content);
    this.outCancelEdit.emit();
  }

  beginEdit() {
    this.editMode = true;
  }

  private getEditableContent() {
    return this.editableContentElement.nativeElement.textContent;
  }

  private setEditableContent(content: string) {
    this.editableContentElement.nativeElement.textContent = content;
  }
}

好吧,这有很多新的代码。让我们逐步分析编辑器组件的不同部分。

在我们的编辑器组件中,我们需要与原生 DOM 元素交互,我们可以通过 contenteditable API 使其可编辑。完成这一点的最简单也是最安全的方法是使用@ViewChild装饰器通过局部视图引用来检索 DOM 元素:

@ViewChild('editableContentElement') editableContentElement: ElementRef; 

@ViewChild装饰器可以用来搜索我们的组件视图中的特定 DOM 元素或组件实例。当我们想要在视图中搜索特定的组件实例时,我们可以将组件类作为参数传递给装饰器。当搜索 DOM 元素时,我们需要传递一个字符串,该字符串应与组件视图中的一个元素的局部视图引用匹配。当我们搜索视图中的 DOM 元素时,Angular 返回一个名为ElementRef的类型。这个对象只是原生 DOM 元素的包装,然后你可以使用它来访问 DOM 元素。

如果我们想要在组件子树中搜索单个 DOM 元素或组件实例,我们可以使用@ViewChild装饰器。我们也可以使用@ViewChildren装饰器来搜索 DOM 元素或组件实例的列表:

查询装饰器 描述
@ViewChildren (选择器) 将查询当前组件的视图以获取指令或组件,并返回一个类型为QueryList的对象。如果视图是动态更新的,列表也会更新。当选择器参数是指令或组件类型时,查询装饰器将在我们的组件视图中搜索匹配的指令或组件,并返回指令或组件实例的列表。选择器参数也可以是一个字符串,然后用作局部视图引用名称,以查找包含此局部视图引用的 DOM 元素。
@ViewChildren (选择器) 将仅查询第一个匹配的组件或指令实例或包含特定局部视图引用的 DOM 元素。

如果您需要直接与视图子组件通信,可以使用 @ViewChild@ViewChildren 装饰器来获取这些组件。有时,这可能是必需的,但应谨慎使用此技术。仅当您有无法通过使用组件输入和输出满足的要求时,直接组件通信才有意义。

有时,在组件初始化后,您需要在视图子组件上运行一些初始化代码。在这种情况下,您可以使用 AfterViewInit 生命周期钩子。尽管您的组件类的视图子属性在组件的构造函数中仍然是未定义的,但它们将在 AfterViewInit 生命周期回调之后填充和初始化。

让我们回到我们的编辑器组件代码。接下来我们要看的是编辑器组件中另一个名为 editMode 的属性:

@HostBinding('class.edit-mode') editMode = false;

我们的编辑器以两种模式运行。editMode 属性用于我们的组件内部,以确定编辑器是在编辑模式还是显示模式。编辑器组件的默认模式是显示模式。用户可以将编辑器切换到编辑模式,在那里他/她将能够编辑编辑器内的内容。编辑完成后,他/她可以保存或取消更改。此操作也将组件切换回显示模式(此时 editorMode 再次为 false)。

我们使用 @HostBinding 装饰器有条件地设置组件宿主元素的 CSS 类 edit-mode。这个状态类将帮助我们根据编辑器模式不同地样式化我们的组件。

接下来,让我们看看我们组件的输入属性:

@Input() content: string;
@Input() showControls: boolean;

content 输入属性可以用来从父组件设置我们的编辑器内容。此输入是必需的,用于设置编辑器在显示模式下出现的初始内容。

第二个输入属性,showControls,控制编辑器是否应显示控制功能。当此输入设置为 false 时,编辑器不会显示任何控制功能,因此以只读模式运行。当此属性设置为 true 时,在我们的编辑器中会显示三个 UI 控制:

  • 编辑按钮:如果编辑器处于显示模式,则编辑按钮是编辑器上唯一可见的控制按钮。当用户点击此按钮时,编辑器组件将通过将 editMode 设置为 true 切换到编辑模式。

  • 保存按钮:如果组件处于编辑模式,则只会显示此控制按钮。它用于保存用户执行的所有更改,并将组件切换回显示模式。

  • 取消按钮:当组件处于编辑模式时,此控制按钮会与保存按钮一起显示。如果激活,组件将切换回显示模式,撤销用户在编辑器中输入的所有更改。

除了我们的输入属性外,我们还需要一些输出属性来通知外部世界关于我们编辑器内的更改。让我们快速讨论我们在组件上公开的两个输出属性:

@Output() outSaveEdit = new EventEmitter<string>();
@Output() outCancelEdit = new EventEmitter<never>();

当用户对编辑器内容进行任何更改并点击保存按钮后,outSaveEdit 输出会发出更新后的内容。

我们的编辑器组件以简单的方式工作。如果组件处于编辑模式,它显示一个可以使用 contenteditable API 进行编辑的元素。然而,一旦编辑器切换回显示模式,我们看到的是一个不同的元素,该元素无法编辑。CSS 通过将修饰类设置为通过宿主元素属性绑定到 editMode 标志来控制可见性。

Angular 无法控制我们可编辑元素内的内容。我们通过使用原生 DOM 操作手动控制此内容。让我们看看我们是如何做到这一点的:

private getEditableContent() {
  return this.editableContentElement.nativeElement.textContent;
}

private setEditableContent(content: string) {
  this.editableContentElement.nativeElement.textContent = content;
}

这两个私有方法可以在我们的编辑器组件内部使用,分别用于获取我们可编辑 DOM 元素的内容或设置其内容。

我们已经讨论了我们的编辑器组件的 editableContentElement 属性以及我们是如何使用 @ViewChild 装饰器在我们的组件视图中获取可编辑元素的引用。

Angular 并没有直接提供我们一个 DOM 元素引用,而是一个类型为 ElementRef 的包装对象。它是原生 DOM 元素的包装,包含与 Angular 相关的附加信息。使用 nativeElement 属性,我们可以获取到底层原生 DOM 元素的引用。

ElementRef 包装器在 Angular 的平台无关架构中起着至关重要的作用。它允许你在不同的环境中运行 Angular(例如,在服务器上、原生移动设备、web workers 或其他环境)。它是组件及其视图之间的抽象层的一部分。

让我们看看我们是如何使用 setEditableContent 方法在我们的组件视图中设置可编辑内容的。首先,我们想确保当我们的视图准备就绪时,我们使用 content 组件输入中的文本初始化可编辑内容。为此,我们可以使用 AfterViewInit 生命周期钩子。我们还需要一种方式来响应 content 输入属性的变化,并基于此更新可编辑内容元素。我们可以使用生命周期钩子 OnChanges 来实现这个目的:

ngOnChanges(changes: SimpleChanges) {
  if (changes.content && this.editableContentElement) {
    this.setEditableContent(this.content);
  }
}

ngAfterViewInit() {
  this.setEditableContent(this.content);
}

ngOnChanges 回调函数中,一旦检测到内容输入属性的变化,我们就可以将更改的内容反映到我们的可编辑元素上。在调用 setEditableContent 之前,检查 editableContentElement 输入是否已经设置是很重要的。由于 OnChanges 生命周期钩子是在 AfterViewInit 生命周期之前调用的,因此通过我们的 @ViewChild 查询获得的 editableContentElement 还不可用。因此,我们还需要使用 ngAfterViewInit 回调函数来设置我们可编辑元素的初始内容,一旦我们的组件视图已初始化。

好的,到目前为止,我们已经看到了如何将我们的组件内容输入反映到编辑器组件视图中的可编辑元素。现在,让我们看看编辑器中三个用户操作的实施。这些是编辑、保存和取消按钮的动作:

saveEdit() {
  this.editMode = false;
  this.outSaveEdit.emit(this.getEditableContent());
}

cancelEdit() {
  this.editMode = false;
  this.setEditableContent(this.content);
  this.outCancelEdit.emit();
}

beginEdit() {
  this.editMode = true;
}

当我们的编辑器处于显示模式时,用户只能激活编辑模式。我们正在使用beginEdit方法来处理这个问题。如果用户点击编辑器组件上的编辑按钮,我们唯一需要做的就是将编辑器切换到编辑模式。我们可以通过仅将editMode属性设置为true来实现这一点。

到目前为止,我们关于代码的讨论已经足够我们设置一个完全功能性的组件。然而,代码的最后部分,我们还没有讨论的部分,与确保我们的编辑器更好的可访问性有关。由于我们的编辑器组件比可编辑元素大一些,我们还想确保在编辑器组件内部任何地方的点击都会使可编辑元素获得焦点。以下代码实现了这一点:

@HostListener('click')
focusEditableContent() {
  if (this.editMode) {
    this.editableContentElement.nativeElement.focus();
  }
}

使用@HostListener装饰器,我们在组件主机元素上注册了一个事件绑定,该绑定调用focusEditableContent方法。在这个方法内部,我们使用对可编辑 DOM 元素的引用并触发焦点。

那就是我们在组件类中讨论的最后一块缺失的代码。让我们看看我们的组件模板,它位于src/app/ui/editor/editor.component.html文件中,看看我们组件类中刚刚创建的代码是如何与组件视图相关的:

<div #editableContentElement
     class="editable-content"
     contenteditable="true"></div>
<div class="output">{{content || '-'}}</div>
<div *ngIf="showControls && !editMode"
     class="controls">
  <button (click)="beginEdit()" class="icon-edit"></button>
</div>
<div *ngIf="showControls && editMode"
     class="controls">
  <button (click)="saveEdit()" class="icon-save"></button>
  <button (click)="cancelEdit()" class="icon-cancel"></button>
</div>

编辑器组件模板中的逻辑相当简单。如果你一直在关注组件代码,你现在应该能够识别出组成这个组件视图的不同元素。

CSS 类editable-content中的第一个元素是我们的具有contenteditable属性的可编辑元素。当编辑器处于编辑模式时,用户将能够在此元素中输入。需要注意的是,我们使用本地视图引用#editableContentElement对其进行了注释,这是我们用于视图子查询的。

CSS 类output中的第二个元素仅用于显示编辑器内容,并且仅在编辑器处于显示模式时可见。这两个元素的可见性都使用 CSS 控制,基于edit-mode修饰类,如果你还记得组件类代码,它是通过主机属性绑定根据editMode属性设置的。

三个控制按钮使用NgIf指令有条件地显示。showControls输入属性需要为true,并且根据editMode标志,视图将显示编辑按钮或保存和取消按钮。

集成编辑器组件

现在,是时候将我们刚刚创建的编辑器组件整合到我们的当前项目中。我们将使用编辑器组件来使我们的应用程序中的三个区域就地可编辑:

  • 我们任务列表中的任务标题

  • 项目的标题

  • 项目的描述

让我们首先将我们的就地编辑器组件整合到任务列表组件中,并使我们的任务标题可编辑。打开位于src/app/tasks/task/task.component.html的文件,并应用以下更改:

<mac-checkbox [checked]="task.done"
              (outCheck)="updateTask($event)"></mac-checkbox>
<div class="title">
 <mac-editor [content]="task.title"
 [showControls]="true"
 (outSaveEdit)="updateTitle($event)"></mac-editor>
</div>

我们现在将使用我们的编辑器组件来渲染任务标题,而不是直接渲染任务标题。我们将任务标题绑定到编辑器的content输入属性,并在编辑器发出outSaveEdit输出时调用新的updateTitle方法。showControls输入属性需要设置为true,否则编辑器将始终保持在只读模式。

让我们在src/app/tasks/task/task.component.ts中位于任务组件类的位置添加新的updateTitle方法。以下代码摘录以粗体显示了添加的方法;未更改的代码部分使用省略号字符隐藏:

…
export class TaskComponent {
  …
  updateTitle(title: string) {
 this.outUpdateTask.emit({
 ...this.task,
 title
 });
 }
}

updateTitle方法与已存在的updateTask方法非常相似。区别在于我们正在发出一个包含从我们的编辑器组件更新后的标题的更新后的任务模型。updateTask方法正在发出一个更新后的任务模型,其中已更新了完成标志。

我们能够重用outUpdateTask输出使得将编辑器组件集成到我们的系统中变得非常简单。为了帮助您回忆现在在我们组件树中发生的当前数据流,让我们再次查看所有涉及的不同部分:

  1. 用户在任务组件内的编辑器组件中点击编辑按钮

  2. 在编辑并点击保存按钮后,编辑器从编辑器组件发出带有更新标题的outSaveEdit输出,并调用我们的updateTitle方法

  3. updateTitle方法正在使用outUpdateTask输出发出一个包含更新标题的新任务对象

  4. 任务列表组件正在将具有相同名称的输出outUpdateTask委派给其父组件。

  5. 任务列表容器组件正在捕获更新后的任务,并在我们的任务列表服务上调用updateTask方法

  6. 在服务已更新我们内存中的 Web API 数据库中的数据后,更新后的任务列表将再次沿着组件树传递,直到我们刚刚更新的任务再次到达特定的任务组件,其中它也将更新编辑器的content输入

再次强调,当涉及到状态架构时,我们的响应式单向数据流再次证明其价值。通过最小的改动,我们添加了一个新功能,使得我们的任务标题可以就地编辑。此外,通过实施这一变更,我们的数据流复杂性并未增加。

让我们应用类似的变化来使我们的项目标题和描述可编辑。让我们从实现位于 src/app/project/project/project.component.html 的项目组件模板的变化开始。同样,变化以粗体显示,未更改的部分使用省略号字符隐藏:

<header class="header">
  <h2 class="title">
    <mac-editor [content]="project.title"
 [showControls]="true"
 (outSaveEdit)="updateTitle($event)"></mac-editor>
  </h2>
 <mac-editor [content]="project.description"
 [showControls]="true"
 (outSaveEdit)="updateDescription($event)"></mac-editor>
</header>
…

这次,我们使用了两个编辑器,一个用于项目标题,一个用于描述。其余的更改与我们应用到任务组件的更改非常相似。我们使用了两个新方法 updateTitleupdateDescription,我们现在需要将它们添加到位于 src/app/project/project/project.component.ts 的项目组件类中:

…
export class ProjectComponent {
  …
  @Output() outUpdateProject = new EventEmitter<Project>();

  activateTab(tab: Tab) {
    this.outActivateTab.emit(tab);
  }

  updateTitle(title: string) {
 this.outUpdateProject.emit({
 ...this.project,
 title
 });
 }

 updateDescription(description: string) {
 this.outUpdateProject.emit({
 ...this.project,
 description
 });
 }
}

由于我们之前在我们的项目组件中没有要更新的内容,我们首先需要添加一个新的输出 outUpdateProject 来通知我们的父组件关于项目更新。两个方法 updateTitleupdateDescription 都使用更新的信息发出项目数据的更新版本。

现在,我们需要更新我们的项目容器组件以处理新引入的项目更新。让我们从更改位于 src/app/container/project-container/project-container.component.html 的视图模板开始:

<mac-project [project]="selectedProject | async"
             [tabs]="tabs"
             [activeTab]="activeTab"
             (outActivateTab)="activateTab($event)"
             (outUpdateProject)="updateProject($event)">
</mac-project>

我们处理项目组件的新 outUpdateProject 输出,并调用一个新的方法 updateProject。让我们看看这个方法在位于 src/app/container/project-container/project-container.component.ts 的项目容器组件类中的实现:

…
export class ProjectContainerComponent {
  …
  updateProject(project: Project) {
 this.projectService.updateProject(project);
 }
}

在新添加的方法中,我们在项目服务上调用 updateProject 方法,并传递由项目 UI 组件最初发出的项目对象。

最后,让我们在我们的项目服务中实现 updateProject 方法,使用 Angular HTTP 服务更新项目数据。让我们打开 src/app/project/project.service.ts 文件并应用以下更改:

…
export class ProjectService {
  …
  updateProject(project: Project) {
 this.http.post(`/api/projects/${project.id}`, project)
 .subscribe(() => this.loadProjects());
 }
}

这里没有新内容。我们正在使用 Angular HTTP 服务来执行对内存中 Web API 的 POST 请求。在成功调用后,我们在我们的服务上调用 loadProjects 方法,这将使用 projects 行为主题发出更新的项目列表。这将导致我们所有的反应式订阅接收更新的项目列表并相应地更新。

恭喜!你已经成功创建并集成了我们的编辑器组件,使项目标题、描述和任务标题可编辑。现在是时候在浏览器中预览你的更改并尝试使用编辑器组件添加的编辑功能了。使用内联编辑器,我们简化了用户在我们应用程序中的创作过程。此外,通过使用反应式数据架构,编辑感觉非常响应和直观。

概述

在这个模块中,我们创建了一个原地编辑组件,我们可以使用它来使应用中的任何内容可编辑。它允许我们为用户提供上下文编辑功能,这将带来出色的用户体验。

我们还学习了以下主题:

  • 使用 contenteditable HTML5 属性来启用原地编辑

  • 使用 @ViewChild@ViewChildren 查询视图子元素

  • 使用 ElementRef 依赖项执行原生 DOM 操作

  • 使用 OnChange 以及 AfterViewInit 生命周期钩子实现逻辑,以在 Angular 和 Angular 不直接控制的内容之间反映数据

处理用户

从现在开始,我们将开始处理应用中的用户生成内容,因此需要最小化设置,以便正确处理用户。我们正在创建一个表示用户的模型以及一个简单的用户服务,该服务将告诉我们有关当前登录用户的信息。我们的服务将仅作为模拟用户服务,而注册、登录和身份验证等问题超出了本书的范围。

让我们遵循本书中建立的习惯,首先介绍用户的模型。让我们打开 src/app/model.ts 文件,并在文件末尾添加以下接口:

export interface User {
  readonly id?: number;
  readonly name: string;
  readonly pictureUrl: string;
}

让我们尽可能保持简洁。我们的用户将只包含 ID、名称和指向个人头像的 URL。作为下一步,让我们向内存 Web API 数据库中添加一个新的用户资源。打开 src/app/database.ts 文件并应用以下更改。更新的内容以粗体显示,省略号字符表示代码中未更改的隐藏部分:

import {InMemoryDbService} from 'angular-in-memory-web-api';
import {Project, Task, User} from './model';

export class Database implements InMemoryDbService {
  createDb() {
    const users: User[] = [
 {id: 1, name: 'You', pictureUrl: '/assets/user.svg'}
 ];

    …

    return {users, projects, tasks};
  }
}

我们已经使用新创建的用户模型向内存数据库中添加了一个新的用户资源。目前,我们将在用户资源中仅存储一个用户。

您可以从添加的用户对象中的 pictureUrl 中看到,我们引用了一个名为 /assets/user.svg 的路径。您可以从本书的在线资源库中下载此资源。请在第十三章 任务管理应用程序源代码 的下载部分找到确切的下载链接。

您也可以选择您个人的头像,而不是书中资源中提供的通用头像。无论哪种方式,在准备图像后,您需要将其存储在路径 src/assets/ 下,并将文件名设置为与内存数据库中用户对象中指定的名称相匹配。

用户服务

好的,我们已经为应用中处理用户准备好了模型和数据库。现在,我们可以创建一个新的服务,该服务用于在容器组件中获取当前登录用户。让我们使用 Angular CLI 创建一个用户服务:

ng generate service --spec false user/user

让我们打开位于 src/app/user/user.service.ts 路径上生成的占位符服务,并用以下代码替换其内容:

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {User} from '../model';

@Injectable()
export class UserService {
  constructor(private http: HttpClient) {

  }

  getCurrentUser() {
    return this.http.get<User>('/api/users/1');
  }
}

在这里我们保持简单。我们提供的唯一方法是我们服务中的getCurrentUser方法,它将执行对内存中的 Web API 的调用,并返回用户资源中的第一个用户。

对于我们的用户服务来说,这已经足够了。接下来,我们将创建两个简单的 UI 组件来在我们的应用程序中显示已登录用户。

用户区域组件

我们已经创建了表示我们应用程序中用户所需的数据结构。现在我们将利用这些结构来创建一个用户区域,显示当前登录用户的用户配置文件。用户区域将放置在我们应用程序布局的左侧区域的主导航上方:

用户区域组件将被放置在我们的主要导航上方

首先,我们将创建一个可重用的组件来渲染用户配置文件图片。让我们使用 Angular CLI 来生成我们的配置文件图片组件的占位符:

ng generate component --spec false -ve none -cd onpush user/profile-picture/profile-picture

好的,让我们打开位于src/app/user/profile-picture/profile-picture.component.ts的组件类文件,并将其内容更改为以下代码:

import {
  Component, ViewEncapsulation, ChangeDetectionStrategy, Input, SimpleChanges,
  OnChanges
} from '@angular/core';
import {User} from '../../model';
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';

@Component({
  selector: 'mac-profile-picture',
  templateUrl: './profile-picture.component.html',
  styleUrls: ['./profile-picture.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProfilePictureComponent implements OnChanges {
  @Input() user: User;
  pictureSafeUrl: SafeResourceUrl;

  constructor(private sanitizer: DomSanitizer) {

  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes.user) {
      this.pictureSafeUrl = this.sanitizer
        .bypassSecurityTrustResourceUrl(this.user.pictureUrl);
    }
  }

我们的配置文件图片组件有一个简单的职责:渲染用户的配置文件图片。它接受一个输入user,这是我们的User模型类型。

Angular 框架关注安全性,当我们渲染与安全相关的 HTML 时,我们需要告诉 Angular 这个特定操作应该被视为可信的。在我们的情况下,我们将渲染图片源属性,使用 Angular 动态显示用户配置文件图片。图片源属性被认为是易受攻击的,因为它们执行 GET 请求,可能包含用户生成的内容,因此也可能包含一些攻击向量。

当我们在 HTML 资源元素(如图片、脚本或链接元素)中使用普通字符串 URL 时,Angular 会引发错误。如果我们想在 Angular 模板中将表达式渲染到图片源属性中,我们需要使用 Angular 的 DOM sanitizer 首先创建一个可信的 URL。

让我们看看使用 Angular 的DomSanitizer标记易受攻击的资源为可信资源的不同用例:

用例 组件类 模板使用
渲染 HTML
safeHtml: SafeHtml = 
  bypassSecurityTrustHtml(
    '<strong>Important</strong>'
  );

|

<div [innerHtml]="safeHtml">
</div>

|

渲染样式
safeStyle: SafeStyle = 
  bypassSecurityTrustStyle(
    'url(/assets/image.jpg)'
  );

|

<div [style.background]="safeStyle">
</div>

|

渲染链接
safeUrl: SafeUrl = 
  bypassSecurityTrustUrl(
    'https://google.com'
  );

|

<a [href]="safeUrl">Google</a>

|

资源
safeUrl: SafeResourceUrl = 
  bypassSecurityTrustResourceUrl(
    '/assets/image.jpg'
  );

|

<img [attr.src]="safeUrl" src="">

|

注意,绕过安全措施并不总是好的解决方案。如果你正在渲染可能包含攻击向量的用户生成内容,你应该考虑清理这些内容。Angular 的DomSanitizer提供了一个sanitize方法,允许你为不同的上下文清理内容。它将移除并转义内容中的安全相关部分,并返回清理后的版本。

在我们的个人照片组件类中,我们正在使用 DOM 清洁器创建一个受信任的资源 URL,然后我们可以在模板中使用它。我们在 OnChanges 生命周期钩子中清洁用户个人照片 URL。这样,如果我们更改用户输入,受信任的资源 URL 也会更新。

让我们打开位于 src/app/user/profile-picture/profile-picture.component.html 的个人照片组件模板,并用以下代码替换内容:

<img [attr.src]="pictureSafeUrl" src="">

这就是我们在个人照片组件模板中所需的所有内容。我们现在可以在 src 属性中使用受信任的图片 URL,并在图像元素上绑定。

好的,让我们继续到我们希望在应用布局左侧区域创建的主要组件。我们再次使用 Angular CLI 来创建用户区域组件的占位符:

ng generate component --spec false -ve none -cd onpush user/user-area/user-area

让我们直接跳转到位于 src/app/user/user-area/user-area.component.ts 的组件类文件,并用以下代码替换内容:

import {Component, ViewEncapsulation, ChangeDetectionStrategy, Input} from '@angular/core';
import {User} from '../../model';

@Component({
  selector: 'mac-user-area',
  templateUrl: './user-area.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserAreaComponent {
  @Input() user: User;
  @Input() openTasksCount: number;
}

这里没有什么特别的。用户区域组件接受一个 user 输入以渲染特定用户的个人资料信息。此外,我们还在组件中添加了一个 openTasksCount 输入。这将用于显示应用程序项目中当前存在多少个开放任务的消息。

让我们修改位于 src/app/user/user-area/user-area.component.html 的用户区域组件模板,并用以下代码替换内容:

<div class="profile">
  <mac-profile-picture [user]="user"></mac-profile-picture>
</div>
<div class="information">
  <p class="welcome-text">Hi {{user.name}}!</p>
  <p *ngIf="openTasksCount !== 0">You got <strong>{{openTasksCount}}</strong> open tasks.</p>
  <p *ngIf="openTasksCount === 0">No open tasks. Hooray!</p>
</div>

首先,我们通过使用我们新创建的个人照片组件来渲染用户的个人照片。我们只是将我们提供给用户区域组件的用户对象向下传递到个人照片组件。

在信息容器 DIV 元素内部,我们正在为我们的用户渲染一条欢迎信息以及显示开放任务数量的信息。

这并不复杂。我们只为用户功能创建了两个 UI 组件。用户区域 UI 组件正在渲染主要布局侧边区域内的一个部分,以显示当前登录用户的某些个人资料信息。我们还构建了一个可重用的个人照片组件,该组件根据用户对象渲染用户的个人照片。

集成用户区域组件

现在,是时候将我们的用户组件整合到我们的主要布局中。为此,我们将修改我们的根应用程序组件,该组件充当最外层的容器组件。

让我们打开位于 src/app/app.component.ts 的应用程序组件类,并应用以下更改:

…
import {map} from 'rxjs/operators';
import {UserService} from './user/user.service';
import {Project, Task, User} from './model';
…
export class AppComponent {
 openTasksCount: Observable<number>;
 user: Observable<User>;
  projects: Observable<Project[]>;
  selectedProject: Observable<Project>;

  constructor(taskListService: TaskService,
 userService: UserService,
              private projectService: ProjectService) {
 this.openTasksCount = taskListService.getTasks()
 .pipe(
 map((tasks: Task[]) => {
 return tasks
 .filter((task) => !task.done)
 .length;
 })
 );
    this.projects = projectService.getProjects();
    this.selectedProject = this.projectService.getSelectedProject();
 this.user = userService.getCurrentUser();
  }

  selectProject(id: number) {
    this.projectService.selectProject(id);
  }
}

我们在应用程序组件中引入了两个新的可观察对象。

openTasksCount可观察对象是基于我们从任务列表服务调用getTasks方法获得的任务列表可观察对象。任务列表可观察对象正在发出应用程序中所有任务的最新列表。基于此,我们使用 RxJS 的 map 操作符将任务列表作为输入映射到打开任务的计数作为输出。这导致了一个始终发出应用程序中最新打开任务数量的可观察流。

第二个可观察对象user是通过在我们的新创建的用户服务上调用getCurrentUser方法获得的。

让我们来看看我们应用组件模板中的变化。打开src/app/app.component.html文件并应用以下更改。有效的更改以粗体显示,而一些未更改的代码部分则使用省略号字符隐藏。请确保您只更新突出显示的代码部分:

<aside class="side-nav">
  <mac-user-area [user]="user | async"
 [openTasksCount]="openTasksCount | async">
 </mac-user-area>
  …
</aside>
…

我们正在渲染用户区域组件,并通过在视图中直接订阅我们的可观察对象来传递所需的输入,使用async管道。现在,一旦登录用户或打开任务的数量发生变化,我们的用户区域组件将接收更新的值作为输入。

干得好!您已成功创建了一个用户区域组件,您现在可以在浏览器中预览它。尝试标记任务为完成,并观察根据打开任务的总数正在更新的用户。

我们的应用程序主布局现在已完成,所有进一步的功能增强都将基于这个基本布局。

构建一个评论系统

在本章中,我们创建了主要项目组件、一个可重用的编辑器组件,在前一个主题中,我们创建并集成了用户区域组件。在本主题中,我们将创建一个允许用户在项目上写评论的评论系统。评论系统将使用我们的编辑器组件来使现有评论可编辑。我们还使用我们的用户特性和个人资料图片组件来可视化用户评论。根据评论的作者,我们将决定登录用户是否可以编辑现有评论:

图片

我们评论系统组件子树的示意图

上述图表展示了我们即将创建的评论系统中组件树的架构。

评论组件将负责列出所有现有评论以及创建新评论。评论组件代表每个评论本身。评论组件使用一个编辑器,允许用户在创建后编辑评论。

当使用编辑器的控制按钮保存可编辑内容时,编辑器组件会发出一个名为 outSaveEdit 的输出。在评论组件中,我们将捕获这个输出并将新的输出向上传递到我们的评论组件。从那里,我们将进一步将输出委托给我们在本主题中创建的新项目评论容器组件。项目评论容器将负责使用项目服务存储和检索评论。

评论组件还将负责提供创建新评论所需的必要 UI 逻辑。进一步评论的创建也将通过组件输出与项目评论容器进行通信。在那里,我们将使用项目服务添加新创建的评论。

介绍评论模型

在我们创建应用程序中的更多组件之前,让我们首先定义我们用来表示评论的模型和数据。

让我们打开位于 src/app/model.ts 的模型文件,并应用以下更改:

…

export interface Project {
  readonly id?: number;
  readonly title: string;
  readonly description: string;
  readonly comments: Comment[];
}

…

export interface Comment {
 readonly time: number;
 readonly user: User;
 readonly content: string;
}

export interface CommentUpdate {
 readonly index: number;
 readonly comment: Comment;
} 

我们添加了两个新的接口来表示我们的评论。Comment 接口包含一个 time 属性,它保存评论创建时的数字时间戳。我们还存储了评论的用户和当然评论本身,使用名为 content 的属性。

CommentUpdate 接口用于在组件和服务之间传递评论更新。

我们还更新了项目的模型,包括一个额外的字段 comments。所有项目评论都将存储在那里。

由于我们正在将 Project 接口更改为包含一个名为 comments 的新属性,因此我们还需要确保我们的数据库已更新此更改。让我们打开位于 src/app/database.ts 文件的数据库,并应用以下更改:

import {InMemoryDbService} from 'angular-in-memory-web-api';
import {Project, Task, User} from './model';

export class Database implements InMemoryDbService {
  createDb() {
    …

    const projects: Project[] = [
      {id: 1, title: 'My first project', description: 'This is your first project.', comments: []},
      {id: 2, title: 'My second project', description: 'This is your second project.', comments: []}
    ];

    …
  }
}

在这个文件中,唯一的区别是我们为我们的项目对象添加了新的 comments 属性。对于这两个项目,我们只是添加了一个空的数组,没有预存的评论。

好的,现在我们数据和模型中所需的所有更改都已经完成。让我们继续创建我们的评论系统。

构建评论组件

让我们从完善评论组件开始,构建我们的评论系统。除了评论本身,我们还想显示用户的个人资料、评论者和评论时间。

为了显示时间,我们将使用相对时间格式,因为这会给我们的用户更好的时间感觉。相对时间格式以 "5 分钟前" 或 "1 个月前" 的格式呈现时间戳,与绝对时间戳相比,

如 "25.12.2015 18:00"。使用一个著名的第三方库 Moment.js,我们将创建一个视图管道,我们可以在组件模板中使用它将时间戳和日期转换为相对时间格式。

视图管道是丰富 Angular 模板语言的好方法。它们允许你创建辅助函数,然后你可以在组件模板的视图中使用这些函数。到目前为止,我们只使用了一个视图管道,即 AsyncPipe,它被用来在组件视图中直接订阅可观察流。在本节中,我们将创建我们的视图管道,它可以用来使用 Moment.js 库格式化日期。

首先,我们需要将 Moment.js 作为生产依赖项安装到我们的项目中。在你的项目根目录中打开一个终端,并执行以下命令:

npm install --save moment@2.19.3

在安装了 Moment.js 之后,我们可以继续创建我们的第一个视图管道。我们还可以使用 Angular CLI 为我们生成管道的占位代码:

ng generate pipe --spec false pipes/from-now

好的,Angular CLI 应该已经为我们创建了一个名为 fromNow 的新视图管道。让我们打开位于 src/app/pipes/from-now.pipe.ts 文件中的视图管道代码,并将其内容更改为以下代码:

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

@Pipe({
  name: 'fromNow'
})
export class FromNowPipe implements PipeTransform {
  transform(value: any) {
    return moment(value).fromNow();
  }
}

所有视图管道都需要使用 Angular 的 @Pipe 装饰器进行装饰。在配置对象内部,我们可以将其传递给装饰器工厂,并指定管道的名称。这个名称是我们如何在组件模板中引用视图管道的方式。

视图管道类应该始终实现一个名为 PipeTransform 的接口。这个接口将确保在我们的管道类中实现了强制性的 transform 方法。transform 方法是每个视图管道的核心部分。这个函数将在管道在组件视图模板中使用时被调用。让我们看看如何在模板中使用视图管道的一个基本示例:

<div>{{name | toUpperCase}}</div>

在这个基本案例中,我们使用了一个名为 toUpperCase 的视图管道。Angular 将取表达式左侧的值,并将其作为第一个参数传递给 toUpperCase 管道的 transform 方法。然后,从 transform 方法返回的值将被评估并渲染到视图中。我们还可以通过将它们一个接一个地链在一起来组合多个视图管道:

<div>{{name | reverse | toUpperCase}}</div>

在这个示例中,名称将被传递给 reverse 管道的 transform 方法。然后,reverse 管道 transform 方法的返回值将被传递给 toUpperCase 管道 transform 方法的第一个参数。这样,你可以将不定数量的管道链在一起,直接在组件视图模板中转换组件属性。

视图管道默认被视为无状态的。Angular 使用这个假设在后台执行一些优化。如果管道及其transform方法可以被认为是无状态和纯的,Angular 可以缓存管道转换的结果,并在以后重新使用这些缓存的值。这种行为在大多数情况下都是可以接受的,我们不想错过这种性能优化。然而,有时需要依赖于某些服务或其他状态源来确定管道转换的结果。在这种情况下,管道的transform方法不是纯的,因此不能被缓存。为了告诉 Angular 我们的管道不是纯的,你可以在管道上使用一个额外的配置属性,称为pure。如果你将此属性设置为 false,你的管道转换将不会被缓存,Angular 将在每次视图渲染时执行你的transform方法。

让我们使用我们新创建的fromNow管道来格式化评论组件中的时间戳。我们再次使用 Angular CLI 来创建评论组件的结构:

ng generate component --spec false -ve none -cd onpush comments/comment/comment

让我们打开位于src/app/comments/comment/comment.component.html的组件模板文件,并用以下代码替换其内容:

<div class="meta">
  <div class="user-picture">
    <mac-profile-picture [user]="comment.user"></mac-profile-picture>
  </div>
  <div class="user-name">{{comment.user.name}}</div>
  <div class="time">
    {{comment.time | fromNow}}
  </div>
</div>
<div class="main">
  <div class="content">
    <mac-editor [content]="comment.content"
                [showControls]="comment.user.id === user.id"
                (outSaveEdit)="updateComment($event)">
    </mac-editor>
  </div>
</div>

我们正在重用我们的个人资料图片组件来渲染评论作者用户。为了以相对格式显示评论时间,我们将使用我们之前创建的fromNow管道。

最后,我们将使用内联编辑器组件来显示评论的内容,并使其可编辑。我们将评论内容属性绑定到编辑器的content输入属性。同时,我们将监听编辑器的outSaveEdit输出,并在我们的评论组件类上调用updateComment方法。

我们还在决定当前登录的用户作为user输入传递给我们的组件是否是给定评论的作者,然后使用这些信息来启用或禁用编辑器上的控件,使用showControls输入属性。这样,当前用户只有在他/她也是该评论的作者时才能编辑评论。

让我们同时创建我们的组件类,并打开src/app/comments/comment/comment.component.ts文件,用以下代码替换其内容:

import {
  Component, ViewEncapsulation, ChangeDetectionStrategy, Input, Output, EventEmitter
} from '@angular/core';
import {Comment, User} from '../../model';

@Component({
  selector: 'mac-comment',
  templateUrl: './comment.component.html',
  styleUrls: ['./comment.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CommentComponent {
  @Input() comment: Comment;
  @Input() user: User;
  @Output() outUpdateComment = new EventEmitter<Comment>();

  updateComment(content: string) {
    this.outUpdateComment.emit({
      ...this.comment,
      content
    });
  }
}

组件代码相当简单。作为输入,我们期望一个用户对象,该对象被传递到user输入属性。这个属性代表当前登录的用户,并在我们的模板中使用,以确定用户是否有权编辑评论。comment输入属性预期是一个类型为Comment的对象。从那里,我们收集评论内容、创建时间和评论作者用户。

回到我们评论组件的视图模板,我们可以观察到编辑组件上的 outSaveEdit 输出将调用我们评论组件中的 updateComment 方法。在 updateComment 方法内部,我们创建评论对象的副本并使用编辑器输出更新其内容。然后,这个副本通过评论组件的 outUpdateComment 输出发出。

这就是我们评论组件所需的所有内容。接下来,我们将创建评论组件,它代表评论列表并包含创建新评论所需的必要 UI 元素。

构建评论组件

我们评论系统最后缺失的拼图是评论组件,它将列出所有评论并提供创建新评论的 UI:

图片

评论组件允许用户向项目添加评论

让我们使用 Angular CLI 为我们的评论组件创建结构:

ng generate component --spec false -ve none -cd onpush comments/comments/comments

首先,让我们编辑位于 src/app/comments/comments/comments.component.html 的我们评论组件的模板:

<div class="title">Add new comment</div>
<div class="add-comment-section">
  <div #commentContentEditable
       class="add-comment-box"
       contenteditable="true">
  </div>
  <button (click)="createComment()"
          class="button" >Add comment</button>
</div>

<ng-container *ngIf="comments.length > 0">
  <div class="title">All comments</div>
  <mac-comment *ngFor="let comment of comments; let index = index"
               [comment]="comment"
               [user]="user"
               (outUpdateComment)="updateComment(index, $event)">
  </mac-comment>
</ng-container>

为了允许用户创建新评论,我们通过添加一个包含 contenteditable 属性的 DIV 元素来使用 contenteditable API。我们在该元素上使用一个局部视图引用 #commentContentEditable,以便我们可以在组件类内部快速引用它。

要添加新评论,我们将使用一个带有点击事件绑定的按钮,该按钮在组件类中调用 createComment 方法。

在用户可以添加新评论的部分下方,我们将创建另一个部分来列出所有现有评论。如果没有评论存在,我们就不渲染该部分。

我们使用一个特定的视图元素 <ng-container> 来将所有评论标题与评论列表组合在一起。这个容器元素在需要使用模板元素(如使用 NgIf)但不想为此创建人工 HTML 元素的情况下很有用。使用 <ng-container> 元素与 NgIf 结合允许您有条件地显示或隐藏元素列表,而无需有一个共同的 HTML 父元素。

通过使用 NgFor 指令,我们遍历所有现有评论并为每个重复渲染一个评论组件。我们将评论对象绑定到我们的评论组件,并添加一个输出绑定来处理更新的评论。

让我们通过更改 src/app/comments/comments/comments.component.ts 文件的内容来创建我们评论组件的类:

import {
  Component, ViewEncapsulation, ChangeDetectionStrategy, Input, Output,
  EventEmitter, ViewChild, ElementRef
} from '@angular/core';
import {Comment, CommentUpdate, User} from '../../model';

@Component({
  selector: 'mac-comments',
  templateUrl: './comments.component.html',
  styleUrls: ['./comments.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CommentsComponent {
  @Input() user: User;
  @Input() comments: Comment[];
  @Output() outUpdateComment = new EventEmitter<CommentUpdate>();
  @Output() outCreateComment = new EventEmitter<Comment>();
  @ViewChild('commentContentEditable') commentContentEditable: ElementRef;

  createComment() {
    this.outCreateComment.emit({
      user: this.user,
      time: +new Date(),
      content: this.commentContentEditable.nativeElement.textContent
    });
    this.commentContentEditable.nativeElement.textContent = '';
  }

  updateComment(index: number, comment: Comment) {
    this.outUpdateComment.next({
      index,
      comment
    });
  }
}

我们的组件类有两个输入。user 输入预期设置为当前登录用户。评论输入是一个列表,用于在我们的组件视图中迭代和渲染评论组件。

我们组件中还有两个输出。第一个,outUpdateComment,用于通知我们的父组件某个评论已被更新。输出值是 CommentUpdate 类型,它包含被更新的评论的索引和评论对象本身。在 updateComment 方法中,该方法由我们视图中的评论组件的绑定调用,我们构建了一个评论更新对象并通过 outUpdateComment 输出发射它。

outCreateComment 输出用于通知我们的父组件创建一个新的评论。createComment 方法用于创建一个新的评论对象并通过该输出发射它。此外,我们使用视图查询来获取组件视图中的可编辑 DOM 元素并将其存储到成员属性 commentContentEditable 中。当使用 createComment 方法创建新评论后,在发射新创建的评论对象之后,我们使用可编辑项的引用来重置其文本内容。这样,在创建新评论后,可编辑的内容元素将再次为空。

将评论系统集成到项目中

到目前为止,我们已经创建了我们的评论模型并更新了我们的内存中 Web API 数据库。我们创建了评论和评论组件,以及一个新的视图管道 fromNow,用于在评论视图模板中格式化相对时间。唯一缺少的部分是将我们的评论组件集成到项目中。为此,我们正在使用项目组件上的第二个标签页,我们已经为我们的评论系统准备好了。为了保持我们的数据流紧凑,我们引入了一个最终组件来填补项目与评论组件之间的差距。

让我们使用 Angular CLI 创建项目评论容器组件:

ng generate component --spec false -ve none -cd onpush container/project-comments-container

在组件文件生成后,让我们打开位于 src/app/container/project-comments-container/project-comments-container.component.ts 的类文件,并用以下代码替换其内容:

import {Component, ViewEncapsulation, ChangeDetectionStrategy} from '@angular/core';
import {ProjectService} from '../../project/project.service';
import {UserService} from '../../user/user.service';
import {Observable} from 'rxjs';
import {Comment, CommentUpdate, Project, User} from '../../model';
import {map, take} from 'rxjs/operators';

@Component({
  selector: 'mac-project-comments-container',
  templateUrl: './project-comments-container.component.html',
  styleUrls: ['./project-comments-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectCommentsContainerComponent {
  user: Observable<User>;
  selectedProject: Observable<Project>;
  projectComments: Observable<Comment[]>;

  constructor(private projectService: ProjectService, private userService: UserService) {
    this.user = userService.getCurrentUser();
    this.selectedProject = projectService.getSelectedProject();
    this.projectComments = this.selectedProject
      .pipe(
        map((project) => project.comments)
      );
  }

  createComment(comment: Comment) {
    this.selectedProject
      .pipe(
        take(1)
      )
      .subscribe((project) => this.projectService.updateProject({
        ...project,
        comments: [...project.comments, comment]
      }));
  }

  updateComment(update: CommentUpdate) {
    this.selectedProject
      .pipe(
        take(1)
      )
      .subscribe((project) => {
        const updatedComments = project.comments.slice();
        updatedComments[update.index] = update.comment;
        this.projectService.updateProject({
          ...project,
          comments: updatedComments
        });
      });
  }
}

在我们的项目评论容器中,我们存储了三个不同的可观察对象。让我们逐一查看它们及其用途。

user 可观察对象发射当前登录的用户。在我们的容器组件的构造函数中,我们调用用户服务上的 getCurrentUser 方法来获取可观察对象。

selectedProject 可观察对象是通过在项目服务上调用 getSelectedProject 获得的。这个可观察对象将始终发射当前选定的项目。如果我们正在选择主导航中的不同项目,新选定的项目将通过这个可观察对象发射。

最后,projectComments 可观察对象是一个转换后的可观察对象,其源为 selectedProject 可观察对象。我们希望直接访问项目评论,以便我们可以将它们用作我们评论系统的输入。通过在管道转换中使用 map 操作符,我们可以选择选定项目的 comments 字段,并创建一个新的输出可观察对象,该对象始终发出选定项目的评论列表。

最后,我们提供了两个方法,createCommentupdateComment,用于通过项目服务创建或更新评论。我们通过使用 take 操作符获取 selectedProject 可观察对象的最新值,并分别使用项目服务更新现有评论或添加新评论。

我们组件类的代码就到这里。你应该对我们容器组件内部的逻辑相当熟悉。我们使用的模式与我们现有的任务列表容器中的模式非常相似,或者与我们的应用程序组件中存在的模式相似。

让我们打开位于 src/app/container/project-comments-container/project-comments-container.component.html 的我们的项目评论容器组件视图模板,并将其内容更改为以下代码:

<mac-comments [user]="user | async"
              [comments]="projectComments | async"
              (outCreateComment)="createComment($event)"
              (outUpdateComment)="updateComment($event)">
</mac-comments>

对于一个常规的容器组件模板,我们不会期望有任何意外。我们只是在渲染一个评论 UI 组件,并将组件的所有输入和输出绑定到容器组件类内部的逻辑和数据上。再次强调,我们通过使用容器组件将状态和数据问题分离,使我们的评论 UI 组件高度可重用。

好的,我们几乎完成了评论系统的集成。最后缺少的部分是将我们的项目评论容器组件包含在我们的项目组件中。让我们打开位于 src/app/project/project/project.component.html 的项目组件视图模板,并应用必要的更改以渲染我们的评论系统:

<header class="header">
  <h2 class="title">
    <mac-editor [content]="project.title"
                [showControls]="true"
                (outSaveEdit)="updateTitle($event)"></mac-editor>
  </h2>
  <mac-editor [content]="project.description"
              [showControls]="true"
              (outSaveEdit)="updateDescription($event)"></mac-editor>
</header>
<mac-tabs [tabs]="tabs"
          [activeTab]="activeTab"
          (outActivateTab)="activateTab($event)">
</mac-tabs>
<mac-task-list-container *ngIf="activeTab.id === 'tasks'">
</mac-task-list-container>
<mac-project-comments-container *ngIf="activeTab.id === 'comments'">
</mac-project-comments-container>

我们只是重用了之前用来渲染任务列表容器的相同逻辑。我们确保通过放置一个带有相应条件的 NgIf 来激活项目组件内的选项卡界面,从而激活项目评论容器组件。

好的!这并不复杂,对吧?我们最终在我们的项目上创建并集成了评论系统。由于我们构建评论系统的方式,我们可以在任何我们想要的位置集成评论。

在浏览器中预览您的更改,并稍微尝试一下评论功能,以熟悉您刚刚构建的内容:

评论系统截图

我们项目组件中集成的评论系统的截图

概述

在这个主题中,我们成功创建了一个完整的评论系统,可以放置在我们应用程序的各个区域以启用评论。用户可以通过原地编辑器与评论中的内容进行交互,从而获得极佳的用户体验。

在编写我们的评论系统代码时,我们学习了以下主题:

  • 创建一个可重复使用的评论系统,可以在我们应用程序的每个区域添加评论功能

  • 使用@Pipe装饰器和 Moment.js 库实现一个简单的管道,以提供相对时间格式化

  • 使用<ng-container>元素在模板指令内分组元素

  • 在评论组件内重复使用编辑器组件作为原地编辑器

摘要

本章的主要内容是介绍项目。我们创建了必要的模型和数据层来处理项目以分组单个任务。我们还创建了用于显示项目信息的 UI 组件和一个可重复使用的标签式用户界面组件,我们可以在需要的地方重复使用它。我们学习了内容投影的概念,这允许我们为我们的组件构建基于内容的美观 API。

我们进一步结构化我们的主要布局,并引入了一些我们目前正在使用的导航组件,以提供项目导航。

我们创建了一个简单的原地编辑器,在我们的应用程序中提供了出色的创作体验。展望未来,我们可以在任何需要让用户可编辑内容的地方使用编辑器组件。他们不需要跳入干扰性的对话框或单独的配置页面,可以直接在当前上下文中进行编辑。这是一个增强我们用户体验的绝佳工具。

除了我们闪亮的新编辑器组件外,我们还创建了一个可以轻松集成到我们应用程序中需要提供评论功能的区域的完整评论系统。我们已经在我们的项目中添加了评论系统,用户现在可以通过导航到项目详情中的“评论”标签来对项目进行评论。

我们构建的项目导航和标签式界面都非常出色。然而,我们会觉得将导航关注点从我们的常规应用程序组合中分离出来会更好。我们还想利用浏览器的位置 URL 和浏览器历史的原生功能。在下一章中,我们将解决这个问题,并学习将 Angular 路由器集成到我们的应用程序所需的所有内容。

第五章:基于组件的路由

路由是当今前端应用程序的一个基本组成部分。一般来说,一个路由器有三个主要用途:

  • 它使您的应用程序可导航,以便用户可以使用浏览器的后退按钮,并在应用程序中存储和共享链接

  • 它将应用程序组合的部分卸载,以便路由器根据路由和路由参数负责组合您的应用程序

  • 它将您应用程序的部分状态存储在浏览器 URL 中

随 Angular 一起提供的路由器支持许多不同的用例,并附带一个易于使用的 API。它支持与 Angular UI-Router 嵌套状态、Ember.js 嵌套路由或 Durandal 框架中的子路由类似的子路由。与组件树相关联的路由器还利用其自己的树结构来存储状态和解析请求的 URL。

在本章中,我们将重构我们的代码以使用 Angular 的基于组件的路由器。我们将探讨路由器的核心元素以及如何使用它们来在我们的应用程序中启用路由。

本章将涵盖以下主题:

  • 介绍 Angular 路由器

  • 路由器、容器和纯组件

  • 概述在应用程序中启用路由所需的重构

  • 创建路由配置文件并探讨不同的路由配置可能性

  • 使用RouterOutlet指令创建由路由器控制的插入点

  • 使用RouterLink指令和路由器 DSL 创建导航链接

  • 使用响应式路由属性来获取路由参数

  • 使用RouterActive指令以及程序化路由器 API 来响应激活的路由路径

  • 使用路由器 API 进行编程导航

  • 为项目容器创建路由守卫

Angular 路由器的介绍

Angular 中的路由器与我们的组件树紧密耦合。Angular 路由器的设计基于这样的假设:组件树直接与我们的 URL 结构相关。这在大多数情况下都是正确的。如果我们有一个组件B,它嵌套在组件A中,表示我们位置的 URL 很可能就是/a/b

要指定模板中我们希望启用路由实例化组件的位置,我们可以使用所谓的outlets。只需包含一个<router-outlet>元素,我们就可以标记模板中的位置,Angular 路由器将在该位置实例化组件。

基于我们可以在主模块中提供的某些路由配置,路由器随后决定哪些组件需要实例化并放置到相应的路由出口中。路由也可以参数化,我们可以在实例化的组件中访问这些参数。

使用我们的组件树和路由配置,我们可以构建分层路由,并使子路由与其父路由解耦。这种嵌套路由使得在元级别上组合我们的应用程序布局成为可能,并可以重用父组件以供多个子路由使用。通过使用路由器,我们可以为我们的应用程序添加另一层组合。请看以下图表:

图片

通过组件树和路由出口建立的路由层次

让我们再次更详细地看看路由器的元素:

  • 路由配置:在将路由导入我们的应用程序模块时进行路由配置。通过配置子路由,我们可以轻松地构建解耦的嵌套路由。

  • 路由出口:出口是路由器将管理的组件的位置。基于路由配置实例化的组件将被放置到这些出口中。

  • 路由链接:这些是用 DSL 风格符号构建的链接,使开发者能够通过路由树构建复杂的链接。

在本章中,我们将探讨 Angular 路由的不同概念,并重构我们的应用程序以实现适当的路由。

使用路由进行组合

到目前为止,我们通过在组件模板中直接包含子组件来实现组合。然而,我们现在希望将控制权交给路由器来组合我们的主要应用程序布局。

以下图表提供了我们应用程序组件架构的概述,我们将为路由器启用它:

图片

显示路由容器组件(实线)和通过路由出口包含的组件的组件树

在我们即将实施的变化之后,项目容器组件不再直接包含在我们的应用程序组件中。相反,我们在应用程序组件的模板中使用路由出口。这样,我们可以将控制权交给路由器,让它决定哪个组件应该放置到出口中。目前,我们只有项目组件作为一级路由,但在后续章节中,当我们为应用程序添加更多功能时,这将会改变。

项目组件将包含另一个路由出口,这使我们能够进行嵌套子路由。在那里,我们将能够创建子路由,使我们能够在不同的项目详情视图之间切换。

使用容器组件进行路由

我们迄今为止处理过的组合完全是基于通过模板包含的实例化。我们使用了输入和输出属性来解耦和封装组件,并遵循了良好的可重用模式。

使用动态实例化组件的路由器,我们不能再在路由组件上使用模板绑定。虽然我们之前依赖于输入和输出属性来连接我们的组件,但使用路由器后,我们失去了这种可能性,不能再绑定到输入或输出。

幸运的是,我们已经了解了容器组件的概念。容器组件将我们的状态和数据连接到我们的用户界面组件。它们不应该有任何输入或输出属性,并作为顶级组件。它们是定义上完美的路由组件候选者:

  • 由于容器组件不依赖于输入或输出属性,它们在由路由器实例化时将直接工作。

  • 我们可以简单地将路由器视为一个不同的状态源,并将 URL 状态信息传递到我们的 UI 组件中

路由器配置

Angular 使用树形数据结构来表示路由状态。你可以想象,在你的应用程序中,每次导航都会激活这个树中的一个分支。让我们看看以下示例。

我们有一个由四个可能的路由组成的应用程序:

  • /:这是应用程序的根路由,由称为A的组件处理。

  • /b/:id:这是我们可以访问b详细视图的路由,该视图由称为B的组件处理。在 URL 中,我们可以传递一个id参数(即/b/100)。

  • /b/:id/c:这是b详细视图有另一个导航可能性的路由,它揭示了更具体的细节,我们称之为c。这由C组件处理。

  • /b/:id/d:这是我们可以导航到b详细视图中的d视图的路由。这由称为D的组件处理:

图片

由激活路由 /b/100/d 的路由段组成的路由树

让我们假设我们通过导航 URL /b/100/d 在我们的示例中激活一个路由。在这种情况下,我们会激活一个反映前面图中概述的状态的路由。请注意,路由段B实际上由两个 URL 段组成。这是因为我们指定了我们的路由B实际上由b标识符和一个:id路由参数组成。

使用这种树形数据结构,我们有一个完美的抽象来处理导航树。我们可以比较树,检查某些段是否存在于树中,并提取激活路由上存在的参数。

在每个路由组件内部,我们有注入该级别激活路由的可能性。假设我们想在组件B中访问:id参数。我们可以将ActivatedRoute注入到我们组件的构造函数中,并使用以下代码从那里提取路由参数:

@Component({
  selector: 'mac-b',
  templateUrl: './b.component.html'
})
export class BComponent {
  constructor(route: ActivatedRoute) {
 route.params.subscribe(params => console.log(params.id));
 }
}

默认情况下,Angular 路由器会重用组件实例。这意味着如果路由器再次激活相同的组件但带有不同的参数,Angular 不会销毁之前的组件实例。它会重用之前的实例,并将更新的参数提供给我们的组件。这就是为什么 ActivatedRoute 对象上的 params 属性是一个可观察的流。我们可以简单地订阅这个可观察的流,这允许我们对路由参数的变化做出反应。

如你所见,路由器 API 非常灵活,它允许我们以非常细粒度的方式检查路由活动。路由器中使用的树结构使得我们可以在不担心底层复杂性的情况下比较应用程序中的复杂路由状态。

回到路由

好的,现在是我们为应用程序实现路由的时候了!在接下来的主题中,我们将为我们的应用程序创建以下路由:

路由路径 描述
/projects/:projectId 此路由将在我们的根应用程序组件的出口中激活项目容器组件。这包括 projects URL 段以及 :projectId URL 段来指定项目 ID。
/projects/:projectId/tasks 此路由将在我们的项目组件内部激活 TaskListContainer 组件。虽然我们目前直接在项目组件的模板中渲染任务列表,但我们将使用另一个路由出口。
/projects/:projectId/comments 此路由将在我们的项目组件内部激活 ProjectCommentsContainer 组件。项目组件中的相同路由出口用于实例化项目评论容器组件。

你可以直接将前面的路由配置与之前主题中图示的组成联系起来,使用路由进行组成

要使用 Angular 的路由器,我们首先需要做的是创建一个路由配置。让我们在路径 src/app/routes.ts 上创建一个新文件,并添加我们的初始路由配置,使用以下代码:

import {Route} from '@angular/router';
import {ProjectContainerComponent} from './container/project-container/project-container.component';

export const routes: Route[] = [{
  path: 'projects/:projectId',
  component: ProjectContainerComponent
}, {
  path: '',
  pathMatch: 'full',
  redirectTo: '/projects/1'
}];

目前我们只将为项目配置路由,稍后会将子路由添加到项目评论和任务中。

在我们的路由配置对象中,path 属性被用作模式来匹配浏览器中的 URL。路由器将观察浏览器中的 URL 变化,然后尝试将我们的配置中的每个路径与新的 URL 进行匹配。

Angular 路由器始终采用“首次匹配获胜”的策略。这意味着你可以有配置场景,其中多个配置结果都匹配。然而,只有列表中第一个匹配的配置会被激活。

通过我们的路由配置中的组件属性,我们可以告诉 Angular 当激活特定路由时应实例化哪个组件。

对于我们的项目路由,我们也使用一个参数段来传递我们想要显示的项目 ID。

我们配置中的第二个路由是一个特殊路由,当用户在根 URL(在我们的开发服务器的情况下是http://localhost:4200/)进入我们的应用程序时,它将用户重定向到我们的项目列表中的第一个项目。

在重定向路由配置中,我们可以省略component属性,但指定redirectTo属性来告诉 Angular 我们希望将用户重定向到不同的 URL。在重定向路由中,你始终需要指定你想要如何匹配 URL 路径。默认情况下,Angular 使用前缀匹配策略进行匹配。然而,在许多情况下,你想要匹配整个 URL,而不仅仅是前缀。你可以使用pathMatch属性并将其设置为'full'来实现这一点。

通过指定一个空路径模式,我们可以告诉 Angular 当浏览器 URL 中有空路径段时激活一个路由。然而,当使用默认的前缀匹配策略时,这个模式总是会匹配。只有当我们将pathMatch属性设置为'full'时,我们才能在用户导航到根 URL 时引起匹配。

好的,让我们继续,并在我们的应用程序中包含路由器。我们可以使用我们刚刚创建的路由配置来初始化 Angular 路由。让我们打开位于路径src/app/app.module.ts上的主模块,并应用以下更改。未更改的不相关代码部分在代码摘录中隐藏,并用省略号字符标记。代码中的有效更改用粗体标出:

…
import {RouterModule} from '@angular/router';
import {routes} from './routes';

@NgModule({
  declarations: [
    …
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    HttpClientInMemoryWebApiModule.forRoot(Database, {
      delay: 0
    }),
    RouterModule.forRoot(routes)
  ],
  providers: [TaskService, UserService, ProjectService],
  bootstrap: [AppComponent]
})
export class AppModule {
}

在对主模型进行的前置代码更改中,我们只是简单地从@angular/router导入了我们准备的路由配置和 Angular 路由模块。通过调用模块工厂函数RouterModule.forRoot,我们可以将我们的路由配置传递给路由器,并将生成的路由模块导入到我们的应用程序中。

这很简单!你已经成功创建了你的第一个路由配置,并将路由器包含在了你的应用程序中。当你预览你的更改并重新加载浏览器时,你应该已经看到重定向配置开始生效。你的浏览器 URL 应该被重定向到http://localhost:4200/projects/0。然而,我们还没有利用路由器的组合功能,我们的项目导航也需要进行更改。

使用路由器组合项目

我们已经为导航我们的项目准备了路由配置。下一步是使路由器能够根据用户导航正确处理项目的组合。我们将一起执行以下三个步骤来实现这一点:

  1. 在我们的根组件中使用<router-outlet>元素,允许路由器放置实例化组件。

  2. 使用路由链接指令使我们的项目导航与路由器协同工作。

  3. 在我们的项目服务中移除选中的项目状态,转而依赖 URL 状态,该状态现在包括导航项目的项目 ID。然后我们可以重构我们的项目容器组件以利用这个路由参数。

让我们从根组件的模板开始。目前,我们直接在模板中包含项目容器组件。由于我们希望让路由决定哪个组件对用户可见,我们需要进行更改,并在模板中包含一个 <router-outlet>

让我们打开根组件的模板,它位于 src/app/app.component.html,并应用以下更改。同样,省略号符号表示保持不变的无关代码部分:

…
<main class="main">
  <router-outlet></router-outlet>
</main>

我们已经移除了项目容器组件的静态包含,并添加了一个路由出口元素。这样 Angular 路由就知道它应该在模板的这个位置实例化激活的组件。

我们列表上的下一件事是使用路由链接指令来启用项目导航。幸运的是,我们已经在正确的位置进行这个更改。项目导航是根组件模板的一部分,我们需要在那里添加路由链接指令。在模板 src/app/app.component.html 中,我们执行以下更改:

…
<mac-navigation-section title="Projects">
  <mac-navigation-item *ngFor="let project of projects | async"
                       [navId]="project.id"
                       [title]="project.title"
 routerLinkActive="active"
 [routerLink]="['/projects', project.id]">
  </mac-navigation-item>
</mac-navigation-section>
…

路由链接指令允许我们使任何元素表现得像链接一样,激活一个给定的路由。通过使用路由 DSL,我们可以在数组中指定一个路由作为单独的段元素。由于我们正在遍历所有项目以渲染导航项组件,我们可以使用项目 ID 来构建激活我们之前配置的路由路径 /projects/:projectId 的链接。

第二个更改是在我们的导航项上使用 routerLinkActive 指令。这个指令是一个简单的辅助工具,它将 CSS 类添加到任何存在路由链接指令的元素上。如果配置的路由链接 URL 与浏览器中的 URL 匹配,则将添加 CSS 类。您可以在 routerLinkActive 属性值中指定 CSS 类名称。这有助于我们样式化活动的导航项,以便用户始终可以看到当前导航的项目。

太棒了!您已经成功更新了根组件中的导航以使用 Angular 路由指令。在预览更改时,您已经可以看到当您在项目导航中导航到不同的项目时,浏览器中的 URL 已经更新。

虽然到目前为止我们一直依赖我们的项目服务来告诉我们哪个项目当前被选中,但现在我们正在利用路由的 URL 状态来存储这个信息。让我们从 src/app/app.component.ts 中的根组件中移除 selectedProject 成员和对项目服务的调用。我们也可以去掉 selectProject 方法,因为现在路由负责选择项目:

…
@Component({
  selector: 'mac-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  encapsulation: ViewEncapsulation.None
})
export class AppComponent {
  openTasksCount: Observable<number>;
  user: Observable<User>;
  projects: Observable<Project[]>;

  constructor(taskListService: TaskService,
              userService: UserService,
              private projectService: ProjectService) {
    this.openTasksCount = taskListService.getTasks()
      .pipe(
        map((tasks: Task[]) => {
          return tasks
            .filter((task) => !task.done)
            .length;
        })
      );
    this.projects = projectService.getProjects();
    this.user = userService.getCurrentUser();
  }
}

完成切换到项目组合路由器的一步还缺失。如果你已经预览了我们迄今为止所做的更改,你会注意到我们已经在更改 URL,并且项目导航项被正确激活。然而,我们总是在项目组件中看到第一个项目标题和描述。项目容器组件目前仍然依赖于项目服务来获取所选项目。我们需要改变这一点,以便我们使用路由器的状态。

让我们在容器组件src/app/container/project-container/project-container.component.ts中实现更改,以从激活的路由中获取项目 ID 参数,并在导航后显示正确的项目:

…
import {ActivatedRoute} from '@angular/router';
import {combineLatest} from 'rxjs';
import {map} from 'rxjs/operators';

@Component({
  selector: 'mac-project-container',
  templateUrl: './project-container.component.html',
  styleUrls: ['./project-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectContainerComponent {
  …

  constructor(private projectService: ProjectService,
 private route: ActivatedRoute) {
    this.selectedProject = combineLatest(
 projectService.getProjects(),
 route.params
 ).pipe(
 map(([projects, routeParams]) =>
 projects.find((project) => project.id === +routeParams.projectId)
 )
 );
  }

  …
}

我们仍然使用一个类型为Observable<Project>的成员selectedProject来表示当前所选项目。然而,我们不再直接从项目服务中获取这个可观察对象。相反,我们使用 RxJS 的combineLatest辅助函数将两个可观察流组合在一起,以产生一个输出流,该流发出所选项目。

我们正在将激活路由的可观察路由参数与我们的项目服务中的项目列表结合起来。然后我们可以使用map运算符,使用从路由参数中获得的 ID 在项目列表中找到正确的项目。结果的可观察流将发出所选项目,并在路由参数或项目列表更改时重新发出。

现在,请在浏览器中再次预览更改。你应该现在看到,使用左侧项目导航的路由器导航也应该更新我们的项目组件。它应该始终显示正确的项目信息,取决于浏览器 URL 中存在的项目 ID。

项目详情的子路由

在本节中,我们将使用路由器在项目上导航详细视图。我们目前在项目上有两个详细视图:

  • 使用任务列表容器组件的项目任务视图

  • 使用项目评论容器组件的项目评论视图

我们还需要确保我们能够通过项目上的标签界面激活子视图。我们的当前解决方案是在项目容器组件中存储激活的标签。基于此,我们在项目组件的模板中决定显示哪个详细视图。在那里,我们使用简单的ngIf指令来确定显示两个子容器组件中的哪一个。

让我们从包括我们详细视图的子路由配置开始重构。打开路径src/app/routes.ts上的路由器配置文件,并执行以下更改:

import {Route} from '@angular/router';
import {ProjectContainerComponent} from './container/project-container/project-container.component';
import {TaskListContainerComponent} from './container/task-list-container/task-list-container.component';
import {ProjectCommentsContainerComponent} from './container/project-comments-container/project-comments-container.component';

export const routes: Route[] = [{
  path: 'projects/:projectId',
  component: ProjectContainerComponent,
 children: [{
 path: 'tasks',
 component: TaskListContainerComponent
 }, {
 path: 'comments',
 component: ProjectCommentsContainerComponent
 }, {
 path: '**',
 redirectTo: 'tasks'
 }]
}, {
  path: '',
  pathMatch: 'full',
  redirectTo: '/projects/1'
}];

通过路由配置上的 children 属性,我们可以配置嵌套路由。它允许我们告诉 Angular,在路由父组件下方某处将有一个可以用于实例化由子路由激活的组件的路由出口。

假设用户使用我们的新配置导航到路径 /projects/1/tasks。这将激活我们的路由配置中的一个路径。项目容器组件被激活并实例化到根组件内的路由出口中。此外,路由参数 :projectId 被设置为值 1。由于我们已配置了一个匹配的子路由,路径为 tasks,因此此子路由也将被激活。现在 Angular 路由正在搜索项目容器组件下方的嵌套路由出口,以便在那里实例化任务列表容器组件。

让我们再次看一下上一节中的图,即本章的 使用路由进行组合。这张图反映了我们使用路由出口和实例化组件的最终目标,并说明了我们需要添加嵌套路由出口元素的位置。

显示路由容器组件(实线)和通过路由出口包含的组件的组件树

现在我们对项目组件应用一些更改,以便使我们的子路由工作。涉及三个更改:

  1. 在项目组件中移除任务视图和注释视图的静态包含,并使用嵌套路由出口代替。

  2. 更新项目容器组件,从子路由名称中获取活动标签。我们再次使用路由来存储当前哪个标签是活动的状态。

  3. 更新项目注释容器组件以及任务列表容器组件,从路由而不是项目服务中获取所选项目。

让我们从第一步开始,这是一个相当简单的步骤。让我们打开文件 src/app/project/project/project.component.ts 并将静态包含的详情视图替换为路由出口:

<header class="header">
  <h2 class="title">
    <mac-editor [content]="project.title"
                [showControls]="true"
                (outSaveEdit)="updateTitle($event)"></mac-editor>
  </h2>
  <mac-editor [content]="project.description"
              [showControls]="true"
              (outSaveEdit)="updateDescription($event)"></mac-editor>
</header>
<mac-tabs [tabs]="tabs"
          [activeTab]="activeTab"
          (outActivateTab)="activateTab($event)">
</mac-tabs>
<router-outlet></router-outlet>

好的,现在路由器控制着在我们的嵌套路由出口中实例化正确的项目详情组件。

我们将要应用的第二个更改是关于项目组件内的标签界面组件。由于我们的标签组件是一个纯组件,它依赖于项目容器组件来提供激活的标签。此外,当我们激活一个标签时,项目容器组件正在实现激活时应发生的事情。到目前为止,哪个标签当前激活的状态一直存储在项目容器组件中。现在,我们希望改变这种行为,以便我们使用路由来存储这个状态。标签的激活应该也会触发路由更改。让我们打开文件 src/app/container/project-container/project-container.component.ts 并实现以下更改:

…
import {ActivatedRoute, Router} from '@angular/router';
import {combineLatest} from 'rxjs';
import {map} from 'rxjs/operators';

@Component({
  selector: 'mac-project-container',
  templateUrl: './project-container.component.html',
  styleUrls: ['./project-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectContainerComponent {
  selectedProject: Observable<Project>;
  tabs: Tab[] = [
    {id: 'tasks', title: 'Tasks'},
    {id: 'comments', title: 'Comments'},
    {id: 'activities', title: 'Activities'}
  ];
  activeTab: Observable<Tab>;

  constructor(private projectService: ProjectService,
              private route: ActivatedRoute,
 private router: Router) {
    this.selectedProject = combineLatest(
      projectService.getProjects(),
      route.params
    ).pipe(
      map(([projects, routeParams]) =>
        projects.find((project) => project.id === +routeParams.projectId)
      )
    );

 this.activeTab = combineLatest(
 this.selectedProject,
 route.url
 ).pipe(
 map(([project]) =>
 this.tabs.find((tab) =>
 router.isActive(
 `/projects/${project.id}/${tab.id}`,
 false
 )
 )
 )
 );
  }

  …
}

标签组件是一个纯组件,这次我们不希望使用路由链接指令来使我们的项目详情标签可导航。保持组件的纯净,不将特定上下文的路由链接配置污染 UI 组件,在扩展应用程序时可能会带来好处。相反,我们希望在容器组件中程序化地使用路由来触发导航。我们还希望有一种方法来确定哪个标签应该是当前激活的,根据路由状态。

在前面的代码更改中,我们在组件构造函数中注入了路由实例。此外,我们将 activeTab 属性更改为 Observable<Tab> 类型。现在我们需要一种方法来响应路由 URL 更改并确定 URL 更改后哪个标签是激活的。router.url 后面的可观察对象正是我们开始我们的响应式管道所寻找的。在每次导航导致路由更改浏览器 URL 时,这个可观察对象将发出一个项。然而,我们还需要一个当前所选项目的引用来确定哪个标签当前被激活。为此,我们将 URL 更改的可观察对象与我们的 selectedProject 可观察对象结合起来。现在,我们有一个在 URL 更改和所选项目更改时发出项的流。然后,在 map 操作符中,我们使用 router.isActive 方法来确定哪个标签当前是激活的。我们可以将 URL 字符串传递给 isActive 方法,它告诉我们该 URL 字符串是否在路由中当前是激活的。我们使用所选项目的项目 ID 以及各个标签的 ID 来构造这个测试 URL 字符串。在操作符链的末尾,可观察对象输出激活的标签对象或 null,如果没有标签是激活的。

好的,我们几乎完成了!我们需要应用的最后一个更改是为了使我们的标签再次工作,那就是重构项目容器组件类中的 activateTab 方法。我们不再更新本地状态来表示激活的标签,我们现在需要通过使用以下代码来程序化地触发路由导航:

…
import {map, take} from 'rxjs/operators';

@Component({
  selector: 'mac-project-container',
  templateUrl: './project-container.component.html',
  styleUrls: ['./project-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectContainerComponent {
  …

  activateTab(tab: Tab) {
    this.selectedProject
 .pipe(take(1))
 .subscribe((project: Project) => {
 this.router.navigate([
 '/projects',
 project.id,
 tab.id
 ]);
 });
  }

  …
}

为了以编程方式导航到新的 URL,我们可以使用 router.navigate 方法并使用路由 DSL 构建所需路由的 URL 段。除了激活标签的 ID 之外,我们还需要所选项目的 ID 来构建目标 URL。因为所选项目被表示为一个可观察的流,我们可以使用 take 操作符进行转换,并订阅输出流以获取当前所选的项目对象。现在,在订阅中,我们手头有所有东西来执行编程导航。

由于我们现在使用可观察对象来表示活动标签,我们需要修改路径 src/app/container/project-container/project-container.component.html 上的模板,并使用异步管道订阅可观察对象,如下所示:

<mac-project [project]="selectedProject | async"
             [tabs]="tabs"
             [activeTab]="activeTab | async"
             (outActivateTab)="activateTab($event)"
             (outUpdateProject)="updateProject($event)">
</mac-project>

你已经成功重构了我们的标签页以与路由和配置的子路由一起工作。如果你在浏览器中预览你的更改,你应该能够再次在标签页之间导航,并且在导航的同时浏览器 URL 应该会更新。你也可以尝试使用一个直接导航到特定标签页的特定 URL 重新加载你的浏览器——这同样适用于一个使用书签导航到项目特定标签页的用户。尝试,例如,导航到 http://localhost:4200/projects/2/comments 并看看你是否会到达你预期的位置。

完成我们的子路由重构的三个步骤中的最后一个仍然悬而未决。目前,两个详情视图仍然依赖于项目服务来确定所选项目。我们需要将它们都改为使用路由,并从路由中提取所选项目 ID,就像我们在项目容器组件中已经做的那样。让我们从文件 src/app/container/task-list-container/task-list-container.component.ts 中的任务列表容器组件开始:

…
import {combineLatest} from 'rxjs';
import {ActivatedRoute} from '@angular/router';

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  …

  constructor(private taskService: TaskService,
              private projectService: ProjectService,
 private route: ActivatedRoute) {
    this.selectedProject = combineLatest(
 projectService.getProjects(),
 route.parent.params
 ).pipe(
 map(([projects, routeParams]) =>
 projects.find((project) => project.id === +routeParams.projectId)
 )
 );

    …
  }

 …
}

我们不再从项目服务中获取所选项目的可观察对象,而是将项目列表可观察对象与路由参数结合起来,以找到所选项目。这应该看起来非常熟悉,因为我们几乎使用了与项目容器组件中相同的代码。唯一的区别是我们需要首先访问父路由。通过调用 route.parent.params,我们可以访问父路由并从那里获取参数。这是必需的,因为我们正在使用嵌套路由,而任务列表容器是项目容器的子视图,其中 :projectId 参数可用。

让我们将同样的更改应用到我们的项目评论容器组件上。打开文件 src/app/container/project-comments-container/project-comments-container.component.ts 并使用以下更改更新代码:

…
import {combineLatest} from 'rxjs';
import {ActivatedRoute} from '@angular/router';

@Component({
  selector: 'mac-project-comments-container',
  templateUrl: './project-comments-container.component.html',
  styleUrls: ['./project-comments-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectCommentsContainerComponent {
  …

  constructor(private projectService: ProjectService,
              private userService: UserService,
 private route: ActivatedRoute) {
    this.user = userService.getCurrentUser();
    this.selectedProject = combineLatest(
 projectService.getProjects(),
 route.parent.params
 ).pipe(
 map(([projects, routeParams]) =>
 projects.find((project) => project.id === +routeParams.projectId)
 )
 );
    this.projectComments = this.selectedProject
      .pipe(
        map((project) => project.comments)
      );
  }
  …

}

恭喜!我们已经成功实现了所有必要的更改,以提供一个包括子路由在内的完全可导航的项目结构。我们不再依赖于项目服务来存储应用中的所选项目。作为最后一步,我们可以从项目服务中移除不必要的代码,因为我们的组件不再依赖于它了。让我们打开文件 src/app/project/project.service.ts 并移除所有与选择项目相关的代码:

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject} from 'rxjs';
import {Project} from '../model';

@Injectable()
export class ProjectService {
  private projects = new BehaviorSubject<Project[]>([]);

  constructor(private http: HttpClient) {
    this.loadProjects();
  }

  private loadProjects() {
    this.http.get<Project[]>('/api/projects')
      .subscribe((projects) => this.projects.next(projects));
  }

  getProjects() {
    return this.projects.asObservable();
  }

  updateProject(project: Project) {
    this.http.post(`/api/projects/${project.id}`, project)
      .subscribe(() => this.loadProjects());
  }
}

没有什么比移除废弃的代码更让人感觉良好的了,这是对我们重构和清理代码的辛勤工作的回报。让我们在浏览器中预览我们的更改并测试我们新添加的路由功能。现在你应该能够像重构之前一样使用应用程序。然而,我们现在在浏览器 URL 中存储所选项目和活动项目详情标签。在不同的视图之间导航,并尝试在浏览器中使用后退和前进按钮。能够这样导航感觉真是太好了。

保护我们的项目

有时候,防止某些路由被导航,并为这些场景提供回退导航是个好主意。这尤其适用于你的路由包括动态路由参数的情况,这些参数可能会随时间变化,意味着用户可能仍然保留着那些旧 URL 的过时书签。

守卫是防止这些导航错误的完美助手。虽然守卫可以帮助你防止访问某些路由并相应地重定向,但你永远不应该依赖它们为你的应用程序提供任何类型的保护。安全性始终需要来自服务器。使用守卫,你只需提供用户访问可能遇到错误区域的必要可用性,可能是因为缺少调用后端 Web 服务的权限,或者简单地因为通过项目 ID 导航的详情视图不再存在。

在本节中,我们将创建一个守卫来防止导航到不存在的项目。我们已经有段时间没有使用 Angular CLI 了。让我们好好利用它,用它来创建我们的项目容器守卫的占位符:

ng generate guard --spec=false --module=app guards/project-container

这将生成一个占位符守卫并将其包含到我们的主应用模块的提供者部分。让我们打开位于 src/app/guards/project-container.guard.ts 的守卫文件,并将其内容更改为以下内容:

import {Injectable} from '@angular/core';
import {CanActivate, ActivatedRouteSnapshot, Router} from '@angular/router';
import {ProjectService} from '../project/project.service';
import {map} from 'rxjs/operators';

@Injectable()
export class ProjectContainerGuard implements CanActivate {
  constructor(private projectService: ProjectService,
              private router: Router) {}

  canActivate(next: ActivatedRouteSnapshot) {
    return this.projectService.getProjects()
      .pipe(
        map(projects => {
          const projectExists = !!projects.find(project => project.id === +next.params.projectId);
          if (!projectExists) {
            this.router.navigate(['/projects', projects[0].id]);
          }
          return projectExists;
        })
      );
  }
}

在这个简单的项目守卫中,我们正在实现来自路由模块的 CanActivate 接口。通过实现这个接口,我们可以编写一个守卫,使我们能够控制用户是否可以导航到某个路由。作为 canActivate 方法的第一个参数,我们接收目标路由的激活路由快照对象。该方法应返回一个类型为 Observable<boolean> 的可观察对象。如果我们想防止导航,我们可以通过返回的可观察流发射 false。

在我们的案例中,我们使用从项目服务获取的项目列表可观察对象作为输入可观察对象。然后我们映射这个可观察对象,以确定从目标路由快照参数中提取的项目 ID 是否存在。我们使用变量 projectExists 来存储这个信息,并将其作为映射函数的结果返回。此外,如果导航的 ID 对应的项目不存在,我们将重定向到项目列表中的第一个项目。我们可以通过使用 router.navigate 方法来实现这一点。

现在,我们只剩下激活我们的守卫这一步,只需将其包含在我们的路由配置中。让我们打开文件 src/app/routes.ts 并添加以下更改:

…
import {ProjectContainerGuard} from './guards/project-container.guard';

export const routes: Route[] = [{
  path: 'projects/:projectId',
  component: ProjectContainerComponent,
  canActivate: [ProjectContainerGuard],
  children: [{
    path: 'tasks',
    component: TaskListContainerComponent
  }, {
    path: 'comments',
    component: ProjectCommentsContainerComponent
  }, {
    path: '**',
    redirectTo: 'tasks'
  }]
}, {
  path: '',
  pathMatch: 'full',
  redirectTo: '/projects/1'
}];

每个路由配置都支持一个 canActivate 属性,该属性可以设置为实现 CanActivate 接口的守卫列表。我们所需做的只是将我们的守卫添加到项目路由配置中。

就这些了!我们已经保护了我们的项目容器组件,因此不再可能因为导航到不存在的项目而引发错误。您可以在浏览器中预览您的更改并尝试导航到一个不存在的项目。例如,尝试导航到 http://localhost:4200/projects/100。您应该会被重定向到第一个项目概览。

摘要

在本章中,我们学习了 Angular 中路由的基本概念。我们探讨了如何在嵌套路由场景中使用现有的组件树来配置子路由。我们还了解了路由出口元素和基本的路由链接指令。

我们已经重构了现有的导航元素,例如项目导航和项目视图上的标签页界面。我们使用了路由链接和程序化导航来满足不同的导航场景。

我们研究了常见的路由配置细节以及路由链接 DSL 的基础知识。我们还了解了路由匹配模式、参数占位符以及如何在激活的路由中访问这些参数。

最后但同样重要的是,我们创建了一个简单的守卫,防止用户使用无效的项目 ID 访问项目详情。

在下一章中,我们将学习 SVG 以及如何在 Angular 应用程序中使用这个网络标准来绘制图形。我们将使用 SVG 可视化应用程序活动日志,并了解 Angular 如何通过启用可组合性使这项技术更加出色。

第六章:保持活动更新

在本章中,我们将使用 可伸缩矢量图形SVG)和 Angular 构建图形组件来构建我们的任务管理系统中的活动日志。SVG 是处理复杂图形内容的完美选择,通过使用 Angular 组件,我们可以轻松构建封装和可重用的内容。

由于我们希望记录我们应用程序中的所有活动,例如添加评论或重命名任务,我们将创建一个活动中央存储库。然后我们可以使用 SVG 显示这些活动并将它们渲染为活动时间线。

为了添加所有活动的概述并提供用户输入以缩小显示活动的范围,我们将创建一个交互式滑块组件。此组件将使用投影在滑块的背景上渲染时间戳,以形式为刻度和活动。我们还将使用 SVG 在组件内渲染元素。

本章将涵盖以下主题:

  • SVG 的基本介绍

  • 使 SVG 可与 Angular 组件组合

  • 在组件模板中使用命名空间

  • 创建一个简单的管道,使用 Moment.js 格式化日历时间

  • 使用 @HostListener 装饰器处理用户输入事件,以创建交互式滑块元素

  • 使用 ViewEncapsulation.Native 来利用 Shadow DOM,以创建原生样式封装

创建用于记录活动的服务

本章的目标是提供一个方法来跟踪任务管理应用程序中的所有用户活动。为此,我们需要一个系统,允许我们在组件内记录活动并访问先前记录的活动。

在本章中,我们只跟踪项目上的活动。然而,活动跟踪器可以用于我们应用程序中的任何功能。我们将使用 TypeScript 区分联合来描述我们的活动。让我们直接进入正题,首先创建我们新活动功能中使用的模型。

打开位于 src/app/model.ts 的模型文件,并添加以下更改:

…
export type ActivityAlignment = 'left' | 'right';

export interface ActivitySliderSelection {
  start: number;
  end: number;
}

export interface ActivityBase {
  kind: string;
  id?: number;
  user: User;
  time: number;
  category: string;
  title: string;
  message: string;
}

export interface ProjectActivity extends ActivityBase {
  kind: 'project';
  projectId: number;
}

export type Activity = ProjectActivity;

作为实体,活动应该是相当通用的,并且应该具有以下字段及其相应用途:

  • user:负责此活动的用户对象。

  • time:活动的时间戳。我们将对此时间戳进行格式化以供可读的显示格式,但当我们绘制活动滑块时,我们还将使用它进行投影数学。

  • category:此字段提供了一种额外的标记活动的方式。对于项目,我们目前将使用两个类别;评论任务

  • title:这指的是活动的标题,它将提供关于活动内容的非常简短的摘要。这可能像 新任务已添加评论已添加 这样的内容。

  • message:这是活动真正内容所在字段。它应该包含足够的信息,以便在活动期间提供良好的可追溯性。

此外,我们还在创建一个接口ActivitySliderSelection,我们将在与我们的自定义滑块 UI 组件通信选择变化时使用它。

自定义类型ActivityAlignment将被用来存储关于活动在时间线上的位置信息。

让我们也更新我们的内存数据库,以便在创建活动视图的 UI 组件时有东西可以工作。打开位于src/app/database.ts的文件,并应用以下更改:

import {InMemoryDbService} from 'angular-in-memory-web-api';
import {Activity, Project, Task, User} from './model';

export class Database implements InMemoryDbService {
  createDb() {
    …

    const now = +new Date();

 const activities: Activity[] = [{
 id: 1,
 kind: 'project',
 user: users[0],
 time: now - 1000 * 60 * 60 * 8,
 projectId: 1,
 category: 'tasks',
 title: 'A task was updated',
 message: 'The task \'Task 1\' was updated on #project-1.'
 }, {
 id: 2,
 kind: 'project',
 user: users[0],
 time: now - 1000 * 60 * 60 * 5,
 projectId: 2,
 category: 'tasks',
 title: 'A task was updated',
 message: 'The task \'Task 1\' was updated on #project-2.'
 }, {
 id: 3,
 kind: 'project',
 user: users[0],
 time: now - 1000 * 60 * 60 * 2,
 projectId: 2,
 category: 'tasks',
 title: 'A task was updated',
 message: 'The task \'Task 2\' was updated on #project-2.'
 }];

    return {users, projects, tasks, activities};
  }
}

现在,我们可以继续创建一个服务来加载活动和记录新活动。让我们使用 Angular CLI 来创建我们服务的占位符:

ng generate service --spec false activities/activities

这将在路径src/app/activities/activities.service.ts上生成一个新的服务类。让我们打开那个文件,并添加必要的代码来实现我们的服务:

import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {BehaviorSubject} from 'rxjs';
import {Activity, ProjectActivity, User} from '../model';
import {UserService} from '../user/user.service';
import {map, mergeMap, take} from 'rxjs/operators';

@Injectable()
export class ActivitiesService {
  private activities = new BehaviorSubject<Activity[]>([]);

  constructor(private http: HttpClient, private userService: UserService) {
    this.loadActivities();
  }

  private loadActivities() {
    this.http.get<Activity[]>('/api/activities')
      .subscribe((activities) => this.activities.next(activities));
  }

  getActivities() {
    return this.activities
      .asObservable().pipe(
        map(activities => activities.sort((a, b) => b.time - a.time))
      );
  }

  logProjectActivity(projectId: number, category: string, title: string, message: string) {
    this.userService.getCurrentUser()
      .pipe(
        take(1),
        mergeMap((user: User) => this.http
          .post('/api/activities', <ProjectActivity>{
            kind: 'project',
            time: +new Date(),
            projectId,
            user,
            category,
            title,
            message
          })
        )
      ).subscribe(() => this.loadActivities());
  }
}

这里没有太多需要讨论的。我们的服务与我们已经创建的任务列表或项目服务非常相似。另外,当我们获取我们的活动行为主题的可观察对象时,我们正在对发出的活动列表进行排序。我们总是希望按活动时间发出排序后的活动列表。

由于活动不能被编辑或删除,我们只需要关注新添加的活动。

logProjectActivity方法中,我们只是使用 Angular HTTP 客户端将一个新的活动发布到我们的内存中 Web 数据库。用户服务将为我们提供当前登录用户的信息。

数据方面就到这里。我们创建了一个简单的平台,将帮助我们跟踪应用程序中的活动。在本书的后面部分,我们可以使用活动服务来跟踪所有类型的活动。现在,我们只关心与项目相关的活动。

记录活动

我们已经创建了一个很好的系统来记录活动。现在,让我们继续在我们的组件中使用它,以保持对项目上下文中发生的所有活动的审计。

首先,让我们使用我们的活动服务来记录当项目任务被更新和创建时的活动。记录活动可以被视为应用程序的副作用,我们不希望在纯 UI 组件中引起副作用。相反,容器组件是执行这些操作的理想场所。

让我们打开位于src/app/container/task-list-container/task-list-container.component.ts的任务列表容器组件,并应用以下更改:

…
import {ActivitiesService} from '../../activities/activities.service';
import {limitWithEllipsis} from '../../utilities/string-utilities';

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  …

  constructor(private taskService: TaskService,
              private projectService: ProjectService,
              private route: ActivatedRoute,
 private activitiesService: ActivitiesService) {
    …
  }

  activateFilterType(type: TaskListFilterType) {
    this.activeTaskFilterType.next(type);
  }

  addTask(title: string) {
    this.selectedProject
      .pipe(
        take(1)
      )
      .subscribe((project) => {
        const task: Task = {
          projectId: project.id, title, done: false
        };
        this.taskService.addTask(task);
 this.activitiesService.logProjectActivity(
 project.id,
 'tasks',
 'A task was added',
 `A new task "${limitWithEllipsis(title, 30)}" was added to #project-${project.id}.`
 );
      });
  }

  updateTask(task: Task) {
    this.taskService.updateTask(task);
 this.activitiesService.logProjectActivity(
 task.projectId,
 'tasks',
 'A task was updated',
 `The task "${limitWithEllipsis(task.title, 30)}" was updated on #project-${task.projectId}.`
 );
  }
}

使用我们活动服务的logProjectActivity方法,我们可以轻松地记录创建和更新任务的活动。

在我们的活动消息体中,我们使用了一个新的实用函数limitWithEllipsis,我们从这个名为string-utilities的新模块中导入它。这个函数截断输入字符串,并在第二个参数指定的位置截断。此外,它还在截断字符串的末尾添加一个省略号字符()。这是一个当我们需要创建可能很长的文本预览时的有用工具。

让我们快速构建这个小小的辅助函数,并在路径src/app/utilities/string-utilities.ts上创建一个新文件。打开文件,并添加以下代码:

export function limitWithEllipsis(str: string, limit: number): string {
  if (str.length > limit) {
    return str.slice(0, limit - 1) + '…';
  } else {
    return str;
  }
}

到此为止。我们已经成功地在任务创建和更新时记录活动。我们还将使用项目评论容器组件中的活动服务来创建添加和编辑评论的日志。由于涉及的步骤与我们刚刚为任务列表容器组件所做的工作非常相似,我们将跳过这一部分。您始终可以查看本章的最终代码库,为项目评论容器组件添加活动日志。

利用 SVG 的力量

SVG 自 1999 年以来一直是开放网络平台标准的一部分,并于 2001 年首次推荐,当时是 SVG 1.0 标准。SVG 是两个基于 XML 的矢量图像格式的独立提案的整合。精确图形标记语言PGML),主要由 Adobe 和 Netscape 开发,以及矢量标记语言VML),主要由 Microsoft 和 Macromedia 代表,都是不同的 XML 格式,服务于相同的目的。W3C 联盟拒绝了这两个提案,转而支持新开发的 SVG 标准,将两者的优点统一到一个标准中:

展示 SVG 标准发展的时间线

所有这三个标准都有一个共同的目标,那就是为网络提供一个格式,以便在浏览器中显示矢量图形。SVG 是一种声明性语言,它使用 XML 元素和属性来指定图形对象。

让我们看看如何使用 SVG 创建一个黑色圆形的简单示例:

<?xml version="1.0" encoding="utf-8"?>   
<svg version="1.1"   
     width="20px" height="20px"> 
  <circle cx="10" cy="10" r="10" fill="black" /> 
</svg> 

这个相当简单的例子代表了一个 SVG 图像,其中有一个黑色的圆形,其中心位于cx="10" px 和cy="10" px。圆的半径是10 px,这使得这个圆的宽度和高度都是20 px。

SVG 坐标系的原点位于左上角,其中y轴朝南方向,x轴向东:

SVG 内部的坐标系

不仅可以使用原始形状,如圆形、线条和矩形,还可以使用复杂的多边形,创建图形内容的可能性几乎是无限的。

SVG 不仅用于网络,而且已经成为不同应用程序之间交换矢量图形的重要中间格式。几乎任何支持矢量图形的应用程序都支持导入和导出 SVG 文件。

当我们不将 SVG 文件作为 HTML 图像元素包含在内,而是直接在我们的 DOM 中包含 SVG 内容时,SVG 的真正力量才显现出来。由于 HTML5 直接支持 HTML 文档中的 SVG 命名空间,并将渲染我们在 HTML 中定义的图形,因此出现了一系列新的可能性。现在,我们可以使用 CSS 来样式化 SVG,使用 JavaScript 来操作 DOM,并轻松地使 SVG 交互式。

将我们之前的圆形图像示例提升到下一个层次,我们可以通过改变圆的颜色(通过点击它)使其交互式。首先,让我们创建一个最小的 HTML 文档,并将我们的 SVG 元素直接包含在 DOM 中:

<!doctype html> 
<title>Minimalistic Circle</title> 
<svg width="20px" height="20px"> 
  <circle id="circle" cx="10" cy="10" r="10" fill="black"> 
</svg> 
<script> 
  document 
    .getElementById('circle') 
    .addEventListener('click', function(event) { 
      event.target.setAttribute('fill', 'red'); 
    }); 
</script> 

正如你所见,当我们直接在我们的 HTML 文档的 DOM 中使用 SVG 时,我们可以去掉版本和 XML 命名空间声明。这里有趣的是,我们可以将 SVG 视为非常普通的 HTML。我们可以为 SVG 元素分配一个 ID,甚至类,并从 JavaScript 中访问它们。

在我们的 HTML 文档的script标签中,我们可以直接使用之前分配给它的 ID 访问我们的circle元素。我们可以像处理常规 HTML 元素一样为 SVG 元素添加事件监听器。在这个例子中,我们添加了一个click事件监听器,并将我们的圆的颜色改为红色。

为了简化,我们在本例中使用了内联script标签。当然,使用一个单独的 JavaScript 文件来进行脚本编写会更为整洁。

SVG 样式

在网络中关注点的分离方面,我是一个纯粹主义者。我仍然坚信结构(HTML)、外观(CSS)和行为(JavaScript)的分离,以及遵循这一实践时产生最易于维护的应用程序。

首先,在 HTML 中包含 SVG 看起来有些奇怪,你可能会认为这打破了清晰的分离合同。为什么只有外观相关的数据组成的图形内容,会坐在我的 HTML 中,而 HTML 本应只包含原始信息?在处理了大量 DOM 中的 SVG 之后,我得出结论,我们可以通过将外观责任分为以下两个子组来使用 SVG 建立清晰的分离:

  • 图形结构:这个子组处理定义你的图形内容基本结构的过程。这是关于形状和布局。

  • 视觉外观:这个子组处理定义我们图形结构的外观和感觉的过程,例如颜色、线宽、线型和文本对齐。

如果我们将 SVG 的关注点分为这些组,我们实际上可以获得很好的可维护性。图形结构由 SVG 形状本身定义。它们直接写入我们的 HTML 中,但没有特定的外观和感觉。我们只在 HTML 中存储基本的结构信息。

幸运的是,我们不需要在 SVG 元素上使用属性,所有与视觉外观相关的属性,例如颜色,也可以使用相应的 CSS 属性来指定。这允许我们将结构与外观相关的所有方面都卸载到 CSS 中。

让我们回到我们画黑色圆圈的例子;我们将稍作调整,以满足我们对关注点分离的需求,以便我们可以区分图形结构和图形外观:

<!doctype html> 
<title>Minimalistic Circle</title> 
<svg width="20px" height="20px"> 
  <circle class="circle" cx="10" cy="10" r="10"> 
</svg> 

现在我们可以通过使用 CSS 来样式化我们的图形结构,包括以下内容的样式表:

.circle { 
  fill: black; 
} 

这真是太棒了,因为我们不仅可以重用一些图形结构,还可以使用 CSS 应用不同的视觉外观参数,类似于我们通过仅更改一些 CSS 就成功重用了一些语义 HTML 时的启发时刻。

让我们看看我们可以用来样式化 SVG 形状的最重要 CSS 属性:

  • fill: 当与实心 SVG 形状一起工作时,始终有形状填充和轮廓选项可用;fill属性指定了形状填充的颜色。

  • stroke: 这个属性指定了 SVG 形状轮廓的颜色。

  • stroke-width: 这个属性指定了 SVG 形状轮廓的宽度,对于实心形状而言。对于非实心形状,例如线条,这可以被视为线条宽度。

  • stroke-dasharray: 这指定了线条的虚线模式。虚线模式是由空格分隔的值,定义了一个模式。

  • stroke-dashoffset: 这指定了虚线模式的偏移量,该偏移量由stroke-dasharray属性指定。

  • stroke-linecap: 这个属性定义了线条端点应该如何渲染。它们可以渲染为方形、平头或圆角端点。

  • stroke-linejoin: 这个属性指定了路径内线条的连接方式。

  • shape-rendering: 使用这个属性,你可以覆盖用于渲染形状的形状渲染算法,正如其名所示。如果你需要在形状上获得清晰的边缘,这特别有用。

要获取有关可用外观相关 SVG 属性的完整参考,请访问 Mozilla 开发者网站developer.mozilla.org/en-US/docs/Web/SVG/Attribute

我希望这个简短的介绍让你对 SVG 及其带来的强大功能有了更好的感觉。在本章中,我们将使用其中的一些功能来创建漂亮的、交互式的图形组件。如果你想了解更多关于 SVG 的信息,我强烈建议你阅读 Sara Soueidan 的精彩文章。

构建 SVG 组件

当使用 SVG 模板构建 Angular 组件时,有一些事情你需要注意。首先也是最明显的一点是 XML 命名空间。现代浏览器在解析 HTML 时非常智能。除了可能是计算机科学历史上最容错的解析器之外,DOM 解析器在识别标记并决定如何处理它方面也非常聪明。它们会根据元素名称自动为我们决定正确的命名空间,因此我们编写 HTML 时不需要处理它们。

如果你稍微玩过 DOM API,你可能已经注意到有两种方法可以创建新元素。例如,在文档对象中,有一个 createElement 函数,但还有一个 createElementNS,它接受一个额外的命名空间 URI 参数。此外,每个创建的元素都有一个 namespaceURI 属性,它告诉你特定元素的命名空间。这很重要,因为 HTML5 是一个至少包含三个命名空间的标准:

  • HTML:这是标准的 HTML 命名空间,具有 www.w3.org/1999/xhtml URI。

  • SVG:它包含所有 SVG 元素和属性,并使用 www.w3.org/2000/svg URI。有时你可以在 svg 元素的 xmlns 属性中看到这个命名空间 URI。实际上,这并不是必需的,因为浏览器足够智能,可以自己决定正确的命名空间。

  • MathML:这是一个基于 XML 的格式,用于描述数学公式,并且大多数现代浏览器都支持它。它使用 www.w3.org/1998/Math/MathML 命名空间 URI。

我们可以在单个文档中混合来自不同标准和命名空间的所有这些元素,并且当浏览器在 DOM 中创建元素时,它会自己确定正确的命名空间。

如果你想要更多关于命名空间的信息,我建议你阅读 Mozilla 开发者网络上的 命名空间快速入门 文章,网址为 developer.mozilla.org/en/docs/Web/SVG/Namespaces_Crash_Course

由于 Angular 会为我们编译模板,并使用 DOM API 将元素渲染到 DOM 中,因此它需要在这个过程中了解命名空间。类似于浏览器,Angular 在创建元素时提供了一些智能来决定正确的命名空间。然而,在某些情况下,你可能需要帮助 Angular 识别正确的命名空间。

为了说明一些这种行为,让我们将我们一直在工作的圆形示例转换成一个 Angular 组件:

@Component({ 
  selector: 'awesome-circle', 
  template: ` 
    <svg [attr.width]="size" [attr.height]="size"> 
      <circle [attr.cx]="size/2" [attr.cy]="size/2" 
              [attr.r]="size/2" fill="black" /> 
    </svg> 
  ` 
}) 
export class AwesomeCircle { 
  @Input() size; 
} 

我们已经将圆形 SVG 图形封装成了一个简单的 Angular 组件。size 输入参数通过控制 SVG 的 widthheight 属性以及圆形的 cxcyr 属性来确定圆形的实际宽度和高度。

要使用我们的圆形组件,只需在另一个组件中使用以下模板:

<awesome-circle [size]="20"></awesome-circle>

需要注意的是,我们需要在 SVG 元素上使用属性绑定,而不能直接设置 DOM 元素的属性。这是由于 SVG 元素具有特殊的属性类型(例如,SVGAnimatedLength),可以使用同步多媒体集成****语言SMIL)进行动画。我们不需要干扰这些相对复杂的元素属性,而可以简单地使用属性绑定来设置 DOM 元素的属性值。

让我们回到我们的命名空间讨论。Angular 将知道它需要使用 SVG 命名空间来创建这个模板内的元素。它将以这种方式工作,仅仅因为我们正在使用svg元素作为组件内的根元素,并且它可以在模板解析器中自动切换任何子元素的命名空间。

然而,在某些情况下,我们需要帮助 Angular 确定我们想要创建的元素的正确命名空间。当我们创建不包含根svg元素的嵌套 SVG 组件时,我们会遇到这种情况:

@Component({ 
  selector: '[awesomeCircle]', 
  template: ` 
      <svg:circle [attr.cx]="size/2" [attr.cy]="size/2" 
                  [attr.r]="size/2" fill="black" /> 
  ' 
}) 
export class AwesomeCircle { 
  @Input('awesomeCircle') size; 
} 

@Component({ 
  selector: 'app' 
  template: ` 
   <svg width="20" height="20"> 
    <g [awesomeCircle]="20"></g> 
   </svg> 
  `, 
  directives: [AwesomeCircle] 
}) 
export class App {} 

在这个例子中,我们嵌套 SVG 组件,我们出色的圆形组件没有svg根元素来告诉 Angular 切换命名空间。这就是为什么我们在我们的应用程序组件中创建了svg元素,然后在一个 SVG 组中包含了出色的圆形组件。

我们需要明确告诉 Angular 在我们的圆形组件中切换到 SVG 命名空间,我们可以通过在前面代码摘录的高亮部分中看到的方式,将命名空间名称作为冒号分隔的前缀来做到这一点。

如果你需要在 SVG 命名空间中显式创建多个元素,你可以依赖 Angular 也会为子元素应用命名空间的事实,并将所有元素与一个 SVG 组元素组合在一起。因此,你只需要在组元素前加上<svg:g> ... </svg:g>前缀,而不是包含的任何 SVG 元素。

当处理 SVG 时,这就是了解 Angular 内部结构的足够信息。让我们继续前进,创建一些真正的组件!

构建一个交互式活动滑块组件

在前面的主题中,我们介绍了与 SVG 一起工作的基础知识以及处理 Angular 组件中的 SVG。现在,是时候将我们的知识应用到任务管理应用程序中,并使用 SVG 创建一些出色的组件了。

在这个背景下,我们将创建的第一个组件是一个交互式滑块,允许用户选择他或她感兴趣检查的活动时间范围。显示一个简单的 HTML5 范围输入可能是一个解决方案,但既然我们已经获得了一些 SVG 超级能力,我们可以做得更好!我们将使用 SVG 来渲染我们自己的滑块,它将在滑块上显示现有活动作为刻度。让我们看看我们将要创建的滑块组件的模拟图:

图片

活动滑块组件的模拟图

我们的滑块组件实际上将有两个用途。它应该是一个用户控件,并提供一种选择时间范围以过滤活动的方法。然而,它还应该提供所有活动的概述,以便用户可以更直观地过滤范围。通过绘制代表活动的垂直条,我们已经在用户心中建立了他或她感兴趣的范围感。

让我们使用 Angular CLI 工具创建我们的新活动滑块组件:

ng generate component --spec false -ve none -cd onpush activities/activity-slider

打开路径src/app/activities/activity-slider/activity-slider.component.ts上生成的组件类,并添加以下代码:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  constructor(private elementRef: ElementRef) {}
}

我们应该首先提到的,与迄今为止我们所写的所有其他组件不同,我们为这个组件使用了ViewEncapsulation.Native。正如我们在第二章的“创建我们的应用程序组件”部分所学的,在准备,设置,出发!当我们为组件封装使用ViewEncapsulation.Native时,Angular 实际上使用阴影 DOM 来创建组件。我们也在第一章的“基于组件的用户界面”部分的“阴影 DOM”中简要介绍了这一点。

使用阴影 DOM(Shadow DOM)为我们组件带来的优势是:我们的组件将完全封装,从 CSS 方面来说。这不仅意味着全局 CSS 不会泄漏到我们的组件中;这也意味着我们需要创建局部样式,以便为我们的组件进行样式设计。

到目前为止,我们使用了来自全局样式表的样式,该样式表是为本书准备的。我们在该文件中使用了组件 CSS 命名约定,以避免与 CSS 类名冲突。然而,当使用阴影 DOM 时,我们可以省略前缀和其他命名约定来避免冲突,因为我们只在我们组件的局部应用样式。

Chrome 从版本 35 开始原生支持阴影 DOM。在 Firefox 中,可以通过访问about:config页面并打开dom.webcomponents.enabled标志来启用阴影 DOM。IE、Edge 和 Safari 完全不支持这个标准;然而,我们可以通过包含一个名为webcomponents.js的 polyfill 来设置它们以处理阴影 DOM。你可以在github.com/webcomponents/webcomponentsjs上找到有关此 polyfill 的更多信息。

现在,让我们添加我们将在活动滑块组件中使用的局部 CSS 样式。打开文件src/app/activities/activity-slider/activity-slider.component.css,并添加以下代码:

:host {
  display: block;
}

.slide {
  fill:#f9f9f9;
}

.activity {
  fill:#3699cb;
}

.time {
  fill:#bbb;
  font-size:14px;
}

.tick {
  stroke:#bbb;
  stroke-width:2px;
  stroke-dasharray:3px;
}

.selection-overlay {
  fill:#d9d9d9;
}

通常,这样的短类名可能会在我们的项目中导致名称冲突,但由于样式将局限于我们组件的阴影 DOM 中,我们不再需要担心名称冲突。

你可以看到我们在我们的样式中使用了特殊的伪选择器:host。这个选择器是 CSS 的 Shadow DOM 规范的一部分,它允许我们样式化 shadow root 的主机元素。这变得非常方便,因为我们可以在样式化时将主机元素视为组件内部的一部分。

让我们回到活动滑块组件内部的其余代码。作为一个输入参数,我们定义了将用于的活动的列表,这不仅用于确定滑块中的可用范围,还用于在滑块的背景上渲染活动。

一旦用户做出选择,我们的组件将使用outSelectionChange输出通知外界关于变化。

在构造函数中,我们正在注入主机元素以供以后使用。我们需要它来访问滑块的本地 DOM 元素,以便进行一些宽度计算。

时间投影

我们的滑块组件需要能够将时间戳投影到 SVG 的坐标系中。此外,当用户点击时间轴选择范围时,我们需要能够将坐标投影回时间戳。为此,我们需要在我们的组件内创建两个投影函数,这些函数将使用一些辅助函数和状态来计算值,从坐标到时间,反之亦然:

图片

计算中重要变量和函数的可视化

虽然我们将使用百分比来定位滑块组件上的 SVG 元素,但两侧的内边距需要以像素为单位指定。totalWidth函数将返回该区域的像素总宽度;这是我们将在其中绘制活动指示器的地方。timeFirsttimeLasttimeSpan变量也将被计算使用,并以毫秒为单位指定。

让我们在滑块中添加一些代码来处理我们的活动在滑块上的投影:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
 timeFirst: number;
 timeLast: number;
 timeSpan: number;

  constructor(private elementRef: ElementRef) {}

 totalWidth() {
 return this.elementRef.nativeElement.clientWidth - this.padding * 2;
 }

 projectTime(time: number) {
 const position = this.padding +
 (time - this.timeFirst) / this.timeSpan * this.totalWidth();
 return position / this.elementRef.nativeElement.clientWidth * 100;
 }

 projectLength(length: number) {
 return this.timeFirst + (length - this.padding) / this.totalWidth() * this.timeSpan;
 }
}

由于我们已经忽略了主机元素的引用,我们可以使用其clientWidth属性来获取组件的全宽并减去内边距。这将给我们想要的绘制活动指示器的区域的全宽,以像素为单位。

projectTime函数中,我们首先将时间戳通过简单的三段式规则转换为位置。因为我们可以访问第一个活动的时间戳以及总时间跨度,这将是一个相当简单的任务。一旦我们这样做,我们可以通过将其除以总组件宽度然后乘以 100 来将我们的位置值(以像素为单位)转换为百分比。

要将像素值投影回时间戳,我们可以基本上执行projectTime的逆操作,只不过我们这里不处理百分比,而是假设projectLength函数的长度参数是以像素单位。

我们在我们的预测代码中使用了某些成员变量(timeFirsttimeLasttimeSpan),但如何设置这些成员变量呢?由于我们有一个activities组件输入,预期它将是一个相关活动的列表,我们可以观察输入的变化并根据输入设置值。为了观察该组件输入的变化,我们可以使用OnChanges生命周期钩子:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, Output, OnChanges, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
 if (changes.activities && this.activities) {
 if (this.activities.length === 1) {
 this.timeFirst = this.timeLast = this.activities[0].time;
 } else if (this.activities.length > 1) {
 this.timeFirst = this.activities[this.activities.length - 1].time;
 this.timeLast = this.activities[0].time;
 } else {
 this.timeFirst = this.timeLast = new Date().getTime();
 }

 this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
 }
 }

  …
}

首先,我们需要检查更改是否包括对activities输入的更改,以及当前输入值是否有效。在检查输入值之后,我们可以确定我们的成员变量,即timeFirsttimeLasttimeSpan。我们将timeSpan变量限制至少为1,否则我们的预测计算将会出错。

上述代码将确保当activities输入更改时,我们将始终重新计算我们的成员变量,并且我们将使用最新的数据渲染活动。

渲染活动指示器

我们已经实现了组件的基本功能,并为将时间信息绘制到组件的坐标系中奠定了基础。现在是时候使用我们的投影函数,并使用 SVG 在滑块上绘制活动指示器了。

让我们在src/app/activities/activity-slider/activity-slider.component.html中打开活动滑块的模板,并添加以下代码:

<svg width="100%" height="70px">
  <rect x="0" y="30" width="100%" height="40" class="slide"></rect>
  <rect *ngFor="let activity of activities"
        [attr.x]="projectTime(activity.time) + '%'"
        height="40" width="2px" y="30" class="activity"></rect>
</svg>

由于我们需要为活动列表中的每个活动创建一个指示器,我们可以简单地使用ngFor指令重复表示活动指示器的矩形。

正如我们在前一个主题中构建活动服务类时所知,创建用于记录活动的服务,活动总是包含一个带有活动时间戳的time字段。在我们的组件中,我们已创建了一个将时间转换为相对于组件宽度的百分比的投影函数。我们可以在rect元素的x属性绑定中简单地使用projectTime函数,以将活动指示器定位在正确的位置。

通过仅使用 SVG 模板和我们的背景函数来预测时间,我们已经创建了一个小巧的图表,该图表显示活动指示器在时间轴上的位置。

你可以想象,如果我们有很多活动,我们的滑块实际上看起来会很拥挤,很难感觉到这些活动可能发生的时间。我们需要有一种类型的网格,帮助我们把图表与时间轴关联起来。

如我们的滑块组件的模拟所示,现在,我们将在滑块背景上引入一些刻度,将滑块分成几个部分。我们还将为每个刻度标注日历时间。这将使用户在查看滑块上的活动指示器时对时间有一个大致的感觉。

让我们看看我们活动滑块类中的代码更改,这将启用我们的刻度渲染:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;
 ticks: number[];

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
    if (changes.activities && this.activities) {
      if (this.activities.length === 1) {
        this.timeFirst = this.timeLast = this.activities[0].time;
      } else if (this.activities.length > 1) {
        this.timeFirst = this.activities[this.activities.length - 1].time;
        this.timeLast = this.activities[0].time;
      } else {
        this.timeFirst = this.timeLast = new Date().getTime();
      }

      this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
      this.computeTicks();
    }
  }

  computeTicks() {
 const count = 5;
 const timeSpanTick = this.timeSpan / count;
 this.ticks = Array.from({length: count}).map((element, index) => {
 return this.timeFirst + timeSpanTick * index;
 });
 }

  …
}

首先,我们需要创建一个函数来计算一些刻度,我们可以将它们放置在时间轴上。为此,我们需要创建computeTicks方法,它将整个时间轴分成五个相等的部分,并生成表示各个刻度时间位置的戳记。我们将这些刻度存储在一个新的ticks成员变量中。有了这些时间戳的帮助,我们可以在视图中轻松渲染刻度。

我们使用Array.from ES6 函数来创建一个具有所需长度的新数组,并使用功能数组额外函数map从该数组生成刻度模型对象。使用Array.from是一个很好的技巧,可以创建一个给定长度的初始数组,这可以用来建立一种功能风格。

让我们看看我们的活动滑块组件的模板,以及我们如何使用我们的时间戳数组在滑块组件上渲染刻度:

<svg width="100%" height="70px">
  <rect x="0" y="30" width="100%" height="40" class="slide"></rect>
 <g *ngFor="let tick of ticks">
 <text [attr.x]="projectTime(tick) + '%'" y="14" class="time">
 {{tick | calendarTime}}
 </text>
 <line [attr.x1]="projectTime(tick) + '%'" [attr.x2]="projectTime(tick) + '%'"
 y1="30" y2="70" class="tick"></line>
 </g>
  <rect *ngFor="let activity of activities"
        [attr.x]="projectTime(activity.time) + '%'"
        height="40" width="2px" y="30" class="activity"></rect>
</svg>

为了渲染我们的刻度,我们使用了一个 SVG 组元素来放置我们的ngFor指令,该指令重复我们在ticks成员变量中存储的刻度戳记。

对于每个刻度,我们需要放置一个标签,以及跨越滑块背景的线。我们可以使用 SVG 文本元素来渲染带有时间戳的标签,并将其放置在滑块上方。在我们的text元素的x属性绑定中,我们使用了我们的projectTime投影函数来接收从时间戳中得到的投影百分比值。我们的text元素的y坐标固定在一个位置,标签将正好位于滑块上方。

SVG 线由四个坐标组成:x1x2y1y2。共同定义了两个坐标点,一条线将从一点画到另一点。

现在,我们越来越接近我们在本主题开头所指定的最终滑块。最后缺失的拼图碎片是使我们的滑块交互式,以便用户可以选择一系列活动。

使其生动起来

到目前为止,我们已经涵盖了滑块背景的渲染,以及活动指示器的渲染。我们还生成了刻度并使用网格线和标签显示它们,以显示每个刻度的日历时间。

嗯,这并不真正是一个滑块,对吧?当然,我们还需要处理用户输入,并使滑块交互式,以便用户可以选择他们想要显示活动的时间范围。

要做到这一点,请向组件类添加以下更改:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;
  ticks: number[];
 selection: ActivitySliderSelection;

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
    if (changes.activities && this.activities) {
      if (this.activities.length === 1) {
        this.timeFirst = this.timeLast = this.activities[0].time;
      } else if (this.activities.length > 1) {
        this.timeFirst = this.activities[this.activities.length - 1].time;
        this.timeLast = this.activities[0].time;
      } else {
        this.timeFirst = this.timeLast = new Date().getTime();
      }

      this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
      this.computeTicks();

 this.selection = {
 start: this.timeFirst,
 end: this.timeLast
 };
 this.outSelectionChange.next(this.selection);
    }
  }

  …
}

当我们在OnChanges生命周期钩子中检测到activities输入属性的变化时,我们为我们的滑块组件中的用户选择初始化一个ActivitySliderSelection对象。它包含一个startend属性,两者都包含表示我们在活动滑块上所选范围的戳记。

一旦我们设置了初始选区,我们需要使用outSelectionChange输出属性来发出事件。这样,我们可以让父组件知道滑动条内的选区已更改。

为了显示选定的范围,我们在模板中使用一个覆盖矩形,它将放置在滑动背景之上。如果你再次查看滑动条的模拟图像,你会注意到这个覆盖层被涂成灰色:

<svg width="100%" height="70px">
  <rect x="0" y="30" width="100%" height="40" class="slide"></rect>
  <rect *ngIf="selection"
 [attr.x]="projectTime(selection.start) + '%'"
 [attr.width]="projectTime(selection.end) - projectTime(selection.start) + '%'"
 y="30" height="40" class="selection-overlay"></rect>
  <g *ngFor="let tick of ticks">
    <text [attr.x]="projectTime(tick) + '%'" y="14" class="time">
      {{tick | calendarTime}}
    </text>
    <line [attr.x1]="projectTime(tick) + '%'" [attr.x2]="projectTime(tick) + '%'"
          y1="30" y2="70" class="tick"></line>
  </g>
  <rect *ngFor="let activity of activities"
        [attr.x]="projectTime(activity.time) + '%'"
        height="40" width="2px" y="30" class="activity"></rect>
</svg>

这个矩形将放置在我们的滑动背景之上,并使用我们的投影函数来计算xwidth属性。由于我们需要等待变化检测在OnChanges生命周期钩子中初始化我们的选区,我们将通过使用ngIf指令来检查有效的选区对象。

现在,我们需要开始处理我们的活动滑动组件的用户输入。存储状态和渲染选区的机制已经就位,因此我们可以实现所需的主监听器来处理用户输入。由于我们已经逐步应用了许多更改,让我们看看组件类的最终完整版本。需要添加用户交互的缺失更改以粗体显示:

import {
  ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostListener,
  Input, OnChanges, Output, SimpleChanges, ViewEncapsulation
} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../../model';

@Component({
  selector: 'mac-activity-slider',
  templateUrl: './activity-slider.component.html',
  styleUrls: ['./activity-slider.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.Native
})
export class ActivitySliderComponent implements OnChanges {
  @Input() activities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();
  padding = 20;
  timeFirst: number;
  timeLast: number;
  timeSpan: number;
  ticks: number[];
  selection: ActivitySliderSelection;
  modifySelection: boolean;

  constructor(private elementRef: ElementRef) {}

  ngOnChanges(changes: SimpleChanges) {
    if (changes.activities && this.activities) {
      if (this.activities.length === 1) {
        this.timeFirst = this.timeLast = this.activities[0].time;
      } else if (this.activities.length > 1) {
        this.timeFirst = this.activities[this.activities.length - 1].time;
        this.timeLast = this.activities[0].time;
      } else {
        this.timeFirst = this.timeLast = new Date().getTime();
      }

      this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
      this.computeTicks();

      this.selection = {
        start: this.timeFirst,
        end: this.timeLast
      };
      this.outSelectionChange.next(this.selection);
    }
  }

  computeTicks() {
    const count = 5;
    const timeSpanTick = this.timeSpan / count;
    this.ticks = Array.from({length: count}).map((element, index) => {
      return this.timeFirst + timeSpanTick * index;
    });
  }

  totalWidth() {
    return this.elementRef.nativeElement.clientWidth - this.padding * 2;
  }

  projectTime(time: number) {
    const position = this.padding +
      (time - this.timeFirst) / this.timeSpan * this.totalWidth();
    return position / this.elementRef.nativeElement.clientWidth * 100;
  }

  projectLength(length: number) {
    return this.timeFirst + (length - this.padding) / this.totalWidth() * this.timeSpan;
  }

 @HostListener('mousedown', ['$event'])
 onMouseDown(event) {
 this.selection.start = this.selection.end = this.projectLength(event.offsetX);
 this.outSelectionChange.next(this.selection);
 this.modifySelection = true;
 event.stopPropagation();
 event.preventDefault();
 }

 @HostListener('mousemove', ['$event'])
 onMouseMove(event) {
 if (this.modifySelection) {
 this.selection.end = Math.max(this.selection.start, this.projectLength(event.offsetX));
 this.outSelectionChange.next(this.selection);
 event.stopPropagation();
 event.preventDefault();
 }
 }

 @HostListener('mouseup')
 onMouseUp() {
 this.modifySelection = false;
 }

 @HostListener('mouseleave')
 onMouseLeave() {
 this.modifySelection = false;
 }
}

在前面的代码片段中,我们在滑动组件的主元素上处理了总共四个事件:

  • onMouseDown: 我们将选择模型startend属性设置为相同的值。由于我们使用时间戳来设置这些属性,我们首先将鼠标位置投影到时间空间中。鼠标位置以像素为单位,相对于滑动组件的起点。由于我们知道滑动条的宽度和显示的总时间长度,我们可以轻松地将它转换为时间戳。我们使用projectLength方法来完成这个目的。通过传递第二个参数到@HostListener装饰器,我们指定了我们将把 DOM 事件传递给我们的onMouseDown方法。我们还在我们的组件中设置了一个状态标志modifySelection,以指示正在进行的选区。

  • onMouseMove: 如果组件处于选区模式(modifySelection标志为true),你可以调整selection对象的结束属性。在这里,我们还确保通过使用Math.max和限制选区的结束不小于开始,排除了创建负选区的可能性。

  • onMouseUp: 当用户释放鼠标按钮时,组件退出选区模式。这可以通过将modifySelection标志设置为false来完成。

  • onMouseLeave: 这与onMouseUp事件相同;区别在于这里,组件将仅退出选区模式。

使用@HostListener装饰器,我们能够处理所有必要的用户输入,以完成我们组件中仍缺少的交互元素。

概述

在这个主题中,我们学习了如何使用 SVG 来创建具有 Angular 的图形和交互式组件。通过在我们的 SVG 元素上创建属性绑定,并使用 ngForngIf 指令控制图形元素的实例化,我们构建了一个自定义滑动组件,为我们提供了活动的好概述。同时,我们还学习了如何使用 @HostListener 装饰器来处理用户输入,以便使我们的组件具有交互性:

图片

完成后的活动滑动组件的截图

总结一下,我们学习了以下概念:

  • 使用 ViewEncapsulation.Native 封装组件视图并导入本地样式

  • 覆盖一些基本的时间戳到屏幕坐标的投影,用于与 SVG 元素一起使用

  • 处理用户输入并使用 @HostListener 装饰器创建自定义选择机制

构建活动时间线

到目前为止,我们已经构建了一个用于记录活动的服务和一个滑动组件来选择时间范围,并使用活动指示器提供概述。由于我们需要在滑动组件内执行许多绘图任务,SVG 对于这个用例来说是一个完美的选择。为了完成我们的活动组件树,我们仍然需要渲染使用活动滑动组件选择的活动。

让我们继续完善我们的活动组件树。我们将创建一个新的组件,该组件负责在活动时间线中渲染单个活动。让我们使用 Angular CLI 来创建我们的活动组件:

ng generate component --spec false -ve none -cd onpush activities/activity

现在,让我们从组件模板开始。打开文件 src/app/activities/activity/activity.component.html,并添加以下代码:

<img [attr.src]="activity.user.pictureUrl"
     [attr.alt]="activity.user.name"
     class="user-image">
<div class="info" [class.info-align-right]="isAlignedRight()">
  <h3 class="title">{{activity.title}}</h3>
  <p class="author">by {{activity.user.name}} {{activity.time | fromNow}}</p>
  <p>{{activity.message}}</p>
</div>

每个活动将包括一个用户图像,以及一个包含活动标题、消息和作者详情的信息框。

我们的活动将使用一个输入来确定其对齐方式。这允许我们从组件外部对齐活动。isAlignedRight 方法帮助我们设置一个额外的 CSS 类,info-align-right,在活动信息框上。

让我们在文件 src/app/activities/activity/activity.component.ts 中创建我们的组件类:

import {Component, Input, HostBinding, ChangeDetectionStrategy} from '@angular/core';
import {Activity, ActivityAlignment} from '../../model';

@Component({
  selector: 'mac-activity',
  templateUrl: './activity.component.html',
  styleUrls: ['./activity.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ActivityComponent {
  @Input() activity: Activity;
  @Input() alignment: ActivityAlignment;
  @Input() @HostBinding('class.start-mark') startMark;
  @Input() @HostBinding('class.end-mark') endMark;

  isAlignedRight() {
    return this.alignment === 'right';
  }
}

我们的活动组件期望有四个输入:

  • activity:这个属性接受需要用组件渲染的活动数据模型。这是我们使用活动服务创建的活动。

  • alignment:这个输入属性应该设置为包含单词leftright的字符串。我们使用这个属性来确定是否需要在模板中添加一个额外的 CSS 类,以便将活动信息框对齐到右侧。

  • startMark:这个输入属性同时充当输入和宿主绑定。如果这个输入设置为true,活动将获得一个额外的 CSS 类,start-mark,这将在时间线上方产生一个小标记,以指示时间线的终止。

  • endMark:与 startMark 相同,这个输入使用主机绑定来设置一个额外的 CSS 类,end-mark,这将导致时间线底部出现一个小标记,以指示时间线的终止。

在模板中使用了 isAlignedRight 方法,用于确定我们是否需要为信息框添加一个额外的 CSS 类,以便将其对齐到右侧。

我们使用在 第四章 “在项目中思考” 中创建的 FromNow 管道格式化了活动的日期时间戳。

我们现在几乎有了显示活动所需的所有组件。然而,还缺少一些东西,那就是将活动滑块与我们的活动组件结合在一起的东西。为此,我们将创建一个新的组件,称为 activities

ng generate component --spec false -ve none -cd onpush activities/activities

在 Angular CLI 生成组件文件后,让我们打开位于 src/app/activities/activities/activities.component.ts 的组件类,并添加以下代码:

import {Component, Input, ChangeDetectionStrategy, EventEmitter, Output} from '@angular/core';
import {Activity, ActivitySliderSelection} from '../model';

@Component({
  selector: 'mac-activities',
  templateUrl: './activities.component.html',
  styleUrls: ['./activities.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ActivitiesComponent {
  @Input() activities: Activity[];
  @Input() selectedActivities: Activity[];
  @Output() outSelectionChange = new EventEmitter<ActivitySliderSelection>();

  selectionChange(selection: ActivitySliderSelection) {
    this.outSelectionChange.emit(selection);
  }
}

由于这个组件将仅作为组合组件,用于排列滑块并渲染所有活动,所以我们里面没有太多逻辑。这是一个纯组件,它依赖于父容器组件来确定哪些活动应该显示/选择。我们还重新发出在活动滑块中产生的 outSelectionChange 事件。

让我们也看看位于 src/app/activities/activities/activities.component.ts 的模板:

<mac-activity-slider [activities]="activities"
                     (outSelectionChange)="selectionChange($event)">
</mac-activity-slider>
<div class="l-container">
  <mac-activity *ngFor="let activity of selectedActivities, let odd = odd; let first = first; let last =  
                        last"
                [activity]="activity"
                [alignment]="odd ? 'left' : 'right'"
                [startMark]="first"
                [endMark]="last">
  </mac-activity>
</div>

再次强调,这只是一个简单的组合。我们正在渲染活动滑块,并使用 ngFor 指令来渲染我们的活动时间线。借助局部视图变量 oddfirstlast,我们可以设置活动组件上所需的所有必要格式化输入。

好的!我们几乎完成了。我们已经准备好了所有的活动 UI 组件。然而,我们仍然需要为我们的活动创建一个容器组件,并添加必要的路由配置,以便用户可以导航到项目活动标签页。

让我们再次使用 Angular CLI 工具,为我们的活动容器组件创建文件:

ng generate component --spec false -ve none -cd onpush container/project-activities-container

打开位于 src/app/container/project-activities-container/project-activities-container.component.ts 的组件类文件,并应用以下代码:

import {Component, ViewEncapsulation, ChangeDetectionStrategy} from '@angular/core';
import {ProjectService} from '../../project/project.service';
import {Observable, combineLatest, BehaviorSubject} from 'rxjs';
import {Activity, ActivitySliderSelection} from '../../model';
import {map} from 'rxjs/operators';
import {ActivatedRoute} from '@angular/router';
import {ActivitiesService} from '../../activities/activities.service';

@Component({
  selector: 'mac-project-activities-container',
  templateUrl: './project-activities-container.component.html',
  styleUrls: ['./project-activities-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectActivitiesContainerComponent {
  activities: Observable<Activity[]>;
  selection = new BehaviorSubject<ActivitySliderSelection | null>(null);
  selectedActivities: Observable<Activity[]>;

  constructor(private projectService: ProjectService,
              private activitiesService: ActivitiesService,
              private route: ActivatedRoute) {
    this.activities = combineLatest(
      this.activitiesService.getActivities(),
      route.parent.params
    ).pipe(
      map(([activities, routeParams]) =>
        activities
          .filter(activity => activity.kind === 'project' &&
            activity.projectId === +routeParams.projectId)
      )
    );

    this.selectedActivities = combineLatest(
      this.activities,
      this.selection
    ).pipe(
      map(([activities, selection]) => {
        if (selection) {
          return activities.filter(
            (activity) => activity.time >= selection.start && activity.time <= selection.end
          );
        } else {
          return activities;
        }
      })
    );
  }

  selectionChange(selection: ActivitySliderSelection) {
    this.selection.next(selection);
  }
}

虽然这看起来像很多代码,但实际上应该看起来非常熟悉。我们的其他容器组件几乎与这个相同。我们正在从活动服务访问活动可观察对象,并将可观察对象与父路由参数组合,以获取所选的项目 ID。

这个容器组件的特殊之处在于我们存储了一个行为主题,selection,它用于发出我们从活动滑块组件接收到的最新选择。在 selectedActivities 可观察对象中,我们使用这个选择与映射函数一起,以过滤出我们选择范围内的活动。

正如容器组件通常所做的那样,这个组件的模板非常简单。我们只是渲染我们的活动组件,并在我们的容器可观察对象上使用异步管道创建绑定。打开文件 src/app/container/project-activities-container/project-activities-container.component.html,并应用以下更改:

<mac-activities [activities]="activities | async"
                [selectedActivities]="selectedActivities | async"
                (outSelectionChange)="selectionChange($event)">
</mac-activities>

好的;我们的容器部分就到这里。现在,我们只需要将新创建的活动容器组件添加到路由配置中。让我们打开我们的路由配置文件 src/app/routes.ts,并应用以下更改:

import {Route} from '@angular/router';
import {ProjectContainerComponent} from './container/project-container/project-container.component';
import {TaskListContainerComponent} from './container/task-list-container/task-list-container.component';
import {ProjectCommentsContainerComponent} from './container/project-comments-container/project-comments-container.component';
import {ProjectContainerGuard} from './guards/project-container.guard';
import {ProjectActivitiesContainerComponent} from './container/project-activities-container/project-activities-container.component';

export const routes: Route[] = [{
  path: 'projects/:projectId',
  component: ProjectContainerComponent,
  canActivate: [ProjectContainerGuard],
  children: [{
    path: 'tasks',
    component: TaskListContainerComponent
  }, {
    path: 'comments',
    component: ProjectCommentsContainerComponent
  }, {
 path: 'activities',
 component: ProjectActivitiesContainerComponent
 }, {
    path: '**',
    redirectTo: 'tasks'
  }]
}, {
  path: '',
  pathMatch: 'full',
  redirectTo: '/projects/1'
}];

我们的“活动”页面就到这里!我们已经创建了三个组件,它们组合在一起并显示活动流,提供了一个滑块来筛选日期相关的活动。在浏览器中预览您的更改,现在您应该能够导航到项目中的“活动”标签页。此外,尝试通过添加新任务或更新它们来记录一些活动。点击并拖动活动滑块以更改您的选择:

完成后的活动视图截图

摘要

在本章中,我们使用 SVG 创建了一个交互式滑块组件。在这个过程中,我们学习了 SVG 的基础知识以及 SVG 在 DOM 中的强大功能。利用 Angular,我们能够使 SVG 具有可组合性,这是它本身所不具备的。我们学习了命名空间、Angular 如何处理它们,以及如何告诉 Angular 我们希望显式地使用命名空间。

除了为我们的滑块组件使用 SVG 之外,我们还学习了如何使用 Shadow DOM 来创建原生视图封装。因此,我们能够为我们的组件使用本地样式。当我们使用本地样式时,我们不需要担心 CSS 命名冲突、特异性和全局 CSS 的副作用。

在下一章中,我们将增强到目前为止所构建的内容。我们将创建一些组件来丰富我们应用程序中的用户体验。

第七章:用户体验组件

对于今天构建应用程序的开发者来说,用户体验应该是一个核心关注点。我们不再生活在一个用户对仅仅能工作的应用程序就感到满意的世界里。现在的期望要高得多。一个应用程序需要高度可用,并且应该提供高效的流程;用户甚至期望它在执行任务时能给他们带来愉悦。

在本章中,我们将探讨构建一些组件,这些组件将提高我们任务管理系统的整体可用性。这些特性将丰富当前的功能并提供更高效的流程。

我们将开发以下两个技术特性,并将它们嵌入到我们当前的应用程序中, wherever applicable:

  • 标签管理:我们将启用在生成内容(如评论、活动和其他可能有用的地方)中使用标签的功能。标签将帮助用户在内容和导航快捷方式之间建立链接。

  • 拖放:我们将构建通用的组件,使使用拖放功能变得轻而易举。通过启用拖放功能,我们将允许用户以更高的效率完成某些任务。

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

  • 创建一个标签管理系统以输入和显示标签

  • 使用服务渲染标签创建一个有状态的管道

  • 创建一个组件,在用户输入时自动完成标签

  • 使用ViewChild装饰器上的read属性来查询指令实例

  • 掌握 HTML5 拖放 API 的基础

  • 为可拖动元素和拖放目标创建指令

  • 使用dataTransfer对象和自定义属性来启用选择性拖放目标

标签管理

经典的标签形式允许你在系统中建立分类法。它帮助你组织内容。它允许你有一个可以快速管理的多对多关联,你可以在以后用它来过滤相关信息。

在我们的任务管理系统里,我们将使用一个略有不同的标签版本。我们的目标是提供一个在应用程序内允许语义快捷方式的方法。借助标签,用户应该能够在不同部分的数据之间交叉引用信息,提供所引用实体的摘要以及导航快捷方式。

例如,我们可以在用户评论中包含一个项目标签。用户可以通过简单地输入项目 ID 来输入标签。当评论显示时,我们将看到项目的标题,当我们点击标签时,我们可以直接导航到任务所在的项目详情页面。

在本节中,我们将开发所需的组件系统,以提供一种使用项目标签在评论中交叉引用其他项目的方法。我们还将使用标签管理,这是我们在上一章“跟上活动”中创建的。

我们标签的模型

让我们从表示我们系统中标签的标签模型开始。打开位于src/app/model.ts的模型模块文件,并添加以下接口:

export interface Tag {
  type: string;
  hashTag: string;
  title: string;
  link: string;
}

此接口表示标签;每次我们存储标签信息时,我们都会使用此接口。让我们看看各个字段并详细说明它们的使用:

  • hashTag:这是标签的文本表示。我们需要使用此文本表示来唯一标识所有标签。我们可以将标签的文本表示定义为如下:

    • 标签符号始终以井号符号(#)开头。

    • 标签符号只包含字母字符或减号符号(-)。

    • 标签的所有其他细节,由titlelinktype属性定义,都可以从hashTag属性中推导出来。因此,标签符号可以被认为是一个唯一的标识符。

  • title:这是标签的比较长的文本表示。它应该包含尽可能多的关于主题的细节。在项目标签的情况下,这可能意味着项目标题、开放标签计数、分配者和其他重要信息。由于这是如果标签呈现给用户时将被渲染的字段,因此保持内容相对紧凑将是有益的。

  • link:一个有效的 URL,当标签被渲染时将使用它。此 URL 将使链接可点击并启用快捷导航。在我们将要创建的项目标签的情况下,这将是一个链接到给定项目视图的 URL。

  • type:用于区分不同的标签,并为我们提供一种在更高粒度级别组织标签的方法。

到目前为止,一切顺利。我们现在有一个数据模型,我们可以用它来传输关于标签的信息。

创建标签服务

实现我们的标签系统的下一步是编写标签服务。该服务将负责收集我们应用程序中所有可能存在的标签。然后,可以在我们的编辑器组件中向用户展示可用的标签列表。这样,用户就可以在我们的应用程序中添加标签到评论和其他可编辑字段。标签服务还应用于将包含简单标签符号的文本转换为 HTML。这样,标签可以渲染为链接,允许我们在应用程序中导航到详细视图。我们的标签服务的责任可以分为两大主要领域。让我们详细看看这些责任:

  • 提供标签列表:目前,我们只想在我们的标签系统中启用项目。因此,我们的标签服务需要为我们的项目服务中的每个项目创建一个项目标签。这个系统将是可扩展的,其他标签来源可以轻松实现。

  • 解析和渲染标签:标签服务的解析功能负责在输入字符串中查找哈希标签。在解析输入字符串时,服务将检查匹配的标签,然后使用标签对象的 titlelink 字段来渲染它们的 HTML 表示。

让我们使用 Angular CLI 工具来创建我们新服务的占位符:

ng generate service --spec false tags/tags

现在,让我们添加以下代码作为我们服务的起点:

import {Injectable} from '@angular/core';
import {ProjectService} from '../project/project.service';
import {Project, Tag} from '../model';
import {Observable} from 'rxjs';
import {map} from 'rxjs/operators';
import {limitWithEllipsis} from '../utilities/string-utilities';

@Injectable()
export class TagsService {
  tags: Observable<Tag[]>;

  constructor(private projectService: ProjectService) {
    this.tags = this.projectService.getProjects().pipe(
      map((projects: Project[]) => projects.map(project => ({
        type: 'project',
        hashTag: `#project-${project.id}`,
        title: limitWithEllipsis(project.title, 20),
        link: `/projects/${project.id}/tasks`
      })))
    );
  }
}

我们标签服务类的 tags 成员是一个可观察对象,具有泛型类型 Tag[]。这个可观察对象将始终发出我们应用程序中可用的最新标签列表。在我们的构造函数中,我们使用项目服务中的项目列表可观察对象作为基础,将所有项目转换为项目标签。

在项目的情况下,我们将我们的标签对象的类型设置为 'project'。在我们项目的后期阶段,我们也可以使用除项目之外的其他来源来生成标签,但就目前而言,我们只关注项目。

对于 hashTag 属性,我们使用前缀 '#project-' 并附加项目的 ID。这样,我们的标签可以被识别为项目标签,并且通过附加的 ID,我们还可以确定引用的是哪个具体项目。对于 title 字段,我们使用了一个辅助函数 limitWithEllipsis,该函数会截断超过 20 个字符的项目标题。对于标签对象的 link 字段,我们指定了将导航到项目详情视图的 URL。

渲染标签

现在我们有一个使用响应式方法从可用项目中生成标签的服务。这已经解决了我们服务的第一个问题。让我们看看它的其他责任,即解析文本内容以查找标签并渲染 HTML。

在我们开始在标签服务中编写 parse 方法之前,我们需要创建一个用于字符串替换的小型实用函数。打开文件 src/app/utilities/string-utilities.ts,其中我们已经创建了 limitWithEllipsis 函数,并添加以下代码:

export function replaceAll(
  target: string,
  search: string,
  replacement: string): string {
  return target.split(search).join(replacement);
}

前面的方法使用了一个小的 JavaScript 技巧来替换字符串中所有出现的字符串。不幸的是,这不可能使用字符串的默认 replace 函数。

让我们继续我们的标签服务。渲染标签并不是什么大问题,因为我们已经以一种干净的方式抽象了标签的数据模型。由于标签有指向位置的 URL,我们将使用锚点 HTML 元素来表示我们的标签。这些元素还具有帮助我们以不同于常规内容的样式来样式化标签的类。让我们在标签服务中创建另一个方法,该方法可以用于解析文本,识别文本内容中的标签,并将它们渲染成 HTML。打开位于 src/app/tags/tags.service.ts 的标签服务文件,并应用以下更改:

import {Injectable} from '@angular/core';
import {ProjectService} from '../project/project.service';
import {Project, Tag} from '../model';
import {Observable, of} from 'rxjs';
import {map} from 'rxjs/operators';
import {limitWithEllipsis, replaceAll} from '../utilities/string-utilities';

@Injectable()
export class TagsService {
  tags: Observable<Tag[]>;

  constructor(private projectService: ProjectService) {
    this.tags = this.projectService.getProjects().pipe(
      map((projects: Project[]) => projects.map(project => ({
        type: 'project',
        hashTag: `#project-${project.id}`,
        title: limitWithEllipsis(project.title, 20),
        link: `/projects/${project.id}/tasks`
      })))
    );
  }

  parse(textContent: string): Observable<string> {
 const hashTags: string[] = textContent.match(/#[\w\/-]+/g);
 if (!hashTags) {
 return of(textContent);
 }

 return this.tags.pipe(
 map((tags: Tag[]) => {
 hashTags.forEach(hashTag => {
 const tag = tags.find(t => t.hashTag === hashTag);
 if (tag) {
 textContent = replaceAll(
 textContent,
 hashTag,
 `<a class="tag tag-${tag.type}" 
              href="${tag.link}">${tag.title}</a>`
 );
 }
 });
 return textContent;
 })
 );
 }
}

让我们快速回顾前面的更改,并逐步查看 parse 方法:

  1. 首先,我们在传递给 parse 方法的文本内容中搜索标签,并将发现的标签列表存储在一个名为 hashTags 的变量中。

  2. 如果没有发现任何标签,我们立即返回一个包含传递给方法的原文本内容的新可观察流。我们使用 RxJS 的 of 辅助函数来完成这个操作。

  3. 下一步是在我们的服务中渲染所有发现的标签与相应的标签对象。我们不会直接在我们的服务中存储标签,而是使用一个可观察的流将不同的来源转换为标签。我们使用 map 操作符来获取标签列表,然后将所有发现的标签渲染到 HTML 中。

  4. 我们使用 Array.prototype.forEach 来遍历初始文本内容中的所有发现的标签。然后我们尝试在可用项目标签列表中找到一个匹配的标签对象。我们通过简单地比较文本中找到的标签与我们的标签对象上的 hashTag 属性来完成这个操作。

  5. 如果找到了匹配的标签,我们使用我们新创建的 replaceAll 辅助函数来替换所有给定标签的所有出现,并用该标签的渲染 HTML 版本来替换。我们使用标签对象的 typelinktitle 字段来渲染一个锚点 HTML 元素。

  6. 在所有标签都被替换为这些标签的 HTML 版本之后,我们从可观察的映射函数中返回渲染的 HTML 内容。

这就是我们的标签服务的全部内容。作为下一步,我们将创建一个管道,该管道将使用我们的服务直接在组件视图中渲染标签。

使用管道集成标签

我们的任务服务的所有问题现在都已经得到解决,并且它已经开始为可用的项目存储标签。现在我们可以继续将我们的服务集成到应用程序中。

由于我们的标签服务将带有简单标签的文本转换为带有链接的 HTML,因此管道将是一个完美的辅助工具,用于在组件中集成该功能。

让我们使用 Angular CLI 工具创建一个新的管道:

ng generate pipe --spec false pipes/tags

打开位于 src/app/pipes/tags.pipe.ts 的生成文件,并添加以下代码:

import {Pipe, PipeTransform} from '@angular/core';
import {TagsService} from '../tags/tags.service';
import {DomSanitizer} from '@angular/platform-browser';
import {map} from 'rxjs/operators';

@Pipe({
  name: 'tags',
  pure: false
})
export class TagsPipe implements PipeTransform {
  constructor(private tagsService: TagsService,
              private sanitizer: DomSanitizer) {}

  transform(value) {
    if (typeof value !== 'string') {
      return value;
    }
    return this.tagsService.parse(value).pipe(
      map(parsed => this.sanitizer.bypassSecurityTrustHtml(parsed))
    );
  }
}

我们已经创建了一些管道。然而,这个管道有点不同,因为它不是一个纯管道。如果管道的 transform 函数总是对给定的输入返回相同的输出,则认为管道是纯的。这意味着 transform 函数不应依赖于任何其他可能影响转换结果的外部来源,唯一的依赖是输入值。但我们的标签管道并不符合这一点。它依赖于标签服务来转换输入,并且可以在任何时间将新标签存储在标签服务中。连续的转换可以成功渲染刚刚还不存在的标签。

通过告诉 Angular 我们的管道不是纯的,我们可以禁用它在纯管道上执行的性能优化。这也意味着 Angular 需要在每次变更检测时重新验证管道的输出。这可能导致性能问题;因此,纯标志应该谨慎使用。

在我们的管道中,我们注入了标签服务,这有助于我们将简单文本转换为渲染的 HTML。然而,Angular 有一些安全机制阻止我们直接在模板中使用这个 HTML 字符串。为了确保 Angular 我们知道我们在做什么,我们可以使用 DOM 清理器实例来创建可信的 HTML,然后我们可以在innerHTML绑定中渲染它。通过在清理器上调用bypassSecurityTrustHtml,传递我们的生成的 HTML 字符串,我们可以告诉 Angular 对这个实例忽略任何安全担忧,然后我们可以在视图中渲染 HTML。

好的;就标签的渲染而言,我们已经准备好了。让我们将我们的标签功能集成到我们的编辑组件中,这样我们就可以在注释系统中使用它们。

我们真正需要做的只是在我们编辑组件模板中包含标签管道。让我们打开位于 src/app/ui/editor/editor.component.html 的编辑器模板,并应用以下更改:

<div #editableContentElement
     class="editable-content"
     contenteditable="true"></div>
<div class="output" [innerHTML]="content ? (content | tags | async) : '-'"></div>
<div *ngIf="showControls && !editMode"
     class="controls">
  <button (click)="beginEdit()" class="icon-edit"></button>
</div>
<div *ngIf="showControls && editMode"
     class="controls">
  <button (click)="saveEdit()" class="icon-save"></button>
  <button (click)="cancelEdit()" class="icon-cancel"></button>
</div>

我们在模板中做的唯一更改是我们显示编辑器内容的地方。我们正在使用属性绑定到我们的编辑器输出 HTML 元素的innerHTML属性。这允许我们渲染由我们的标签服务生成的 HTML 内容。由于标签管道返回一个可观察对象,我们需要链式连接一个异步管道。

恭喜!您的标签系统已经完成了一半!我们已经创建了一个标签服务,它收集应用程序中的可用标签,并且与我们的新创建的管道一起,在编辑组件中渲染标签。在浏览器中预览您的更改,并尝试在项目的注释标签页上添加一些注释的标签。目前,在我们的应用程序中只有两个项目。尝试将以下标签添加到注释中——#project-2——并在编辑器中保存更改。现在您应该能够在注释中看到渲染的标签。如果您再次编辑注释,您将看到标签的文本表示。

让我们暂时偏离一下主题。我们已经创建了一个标签系统,并且我们刚刚通过使用标签管道将其集成到我们的编辑组件中。如果用户在任何注释中写入项目标签,它们现在将由标签服务渲染。这太棒了!用户现在可以在注释中建立与其他项目的交叉链接,这些链接将自动渲染为链接并显示截断的项目标题。用户需要做的只是将项目标签的文本表示添加到注释中。

以下两个截图展示了注释系统的示例。第一个截图是编辑模式下编辑器的示例,在注释系统中输入了一个文本标签:

输入文本标签的一个示例

第二个截图是使用我们的编辑器集成在评论系统中启用渲染标签的一个示例:

通过编辑器集成渲染标签的一个示例

在本节中,我们探讨了以下概念:

  • 我们构建了一个标签服务,它可以生成、缓存和渲染标签

  • 我们使用pure标志构建了一个有状态的管道

  • 我们使用了[innerHTML]属性绑定来将 HTML 内容渲染到元素中

  • 我们使用了 DOM 清理器来绕过使用innerHTML绑定时的安全检查

我们还没有完成标签输入的工作。我们不能期望我们的用户知道系统中所有可用的标签,然后手动在评论中输入它们。让我们看看在下一节中我们如何改进这一点。

支持标签输入

在这里,我们将构建一个组件(及其支持结构),以使用户输入标签的过程变得顺畅。到目前为止,他们可以编写项目标签,但这需要他们知道项目 ID,这使得我们的标签管理变得毫无用处。我们希望提供一些选择,当用户准备编写标签时。理想情况下,我们将显示可用的标签,一旦他们开始通过输入哈希符号(#)编写标签。

起初听起来很简单的事情实际上是一个相当棘手的问题。我们的标签输入需要处理以下挑战:

  • 处理输入事件以监控标签创建。我们需要知道用户何时开始编写标签,以及当使用无效的标签字符时,输入的标签名称何时被更新或取消。

  • 计算用户输入光标的位置。是的,我知道这听起来很简单,但实际上并不是。计算用户输入光标的视口偏移位置需要使用浏览器的 Selection API,这是一个相当底层的 API,需要一些抽象。

为了应对这些挑战,我们将引入一个实用指令,我们可以使用它来处理那些相当复杂的底层用户输入事件。

创建一个标签输入指令

由于在用户输入中识别标签并不是一个简单的任务,我们将创建一个指令来帮助我们完成这个任务。这实际上是我们共同创建的第一个指令!如果你还记得第一章中的组件化用户界面,指令是用来创建自定义行为而不需要自己视图的。我们的标签输入指令将收集并识别用户输入中的标签,但它实际上并不渲染自己的视图。

让我们在src/app/model.ts中的模型文件中添加两个更多接口,以帮助我们与标签用户输入进行通信:

export interface InputPosition {
  top: number;
  left: number;
  caretOffset: number;
}

export interface HashTagInput {
  hashTag: string;
  position: InputPosition;
}

对于我们指令识别为 hashtag 输入的每个用户输入,我们将使用 hashtag 输入对象进行通信。除了 hashtag 的实际文本内容外,我们还发送一个由以下属性组成的输入位置:

  • topleft: 表示光标位置的实际输入发生的屏幕顶部和左侧,以像素为单位。

  • caretOffset: 描述了在可编辑元素的文本内容中,hashtag 的字符偏移量。当我们要在可编辑元素中替换 hashtag 并实现自动完成的感觉时,这将非常有用。

InputPosition接口的topleft属性中,你可以看到我们想要计算实际用户输入发生的坐标。这听起来非常简单,但实际上并非如此。为了帮助我们进行这个计算,我们将引入一个新的辅助函数,我们将在一个新文件中创建这个函数,路径为src/app/utilities/dom-utilities.ts。创建这个新文件并添加以下内容:

import {InputPosition} from '../model';

export function getRangeBoundingClientRect(): InputPosition | null {
  if (window.getSelection) {
    const selection = window.getSelection();
    if (!selection.rangeCount) {
      return null;
    }

    const range = selection.getRangeAt(0);
    const rect = range.getBoundingClientRect();

    if (!range.collapsed) {
      return {
        top: rect.top,
        left: rect.left,
        caretOffset: range.startOffset
      };
    }

    const dummy = document.createElement('span');
    range.insertNode(dummy);
    const pos: InputPosition = {
      top: rect.top,
      left: rect.left,
      caretOffset: range.startOffset
    };
    dummy.parentNode.removeChild(dummy);
    return pos;
  }

  if (document['selection']) {
    return document['selection']
      .createRange()
      .getBoundingClientRect();
  }
}

在这里我们就不深入细节了。这段代码基本上是尝试找到描述光标位置相对于视口的toprightbottomleft偏移量的DOMRect对象,即边界框。问题是,Selection API 不允许我们直接获取光标的位置;它只允许我们获取当前选择的位置。如果光标放置不正确,我们需要在光标位置插入一个虚拟元素,并返回虚拟元素的边界框DOMRect对象。当然,在返回DOMRect对象之前,我们需要再次移除虚拟元素。

因此,这就是我们需要编写的标签输入指令的所有内容。让我们使用 Angular CLI 创建我们的第一个指令。创建指令的命令与创建组件的命令非常相似:

ng generate directive --spec false tags/tags-input

这生成了我们新指令的存根。让我们打开文件src/app/tags/tags-input.directive.ts,并添加以下代码:

import {Directive, HostListener} from '@angular/core';
import {getRangeBoundingClientRect} from '../utilities/dom-utilities';
import {HashTagInput} from '../model';
import {BehaviorSubject} from 'rxjs';

@Directive({
  selector: '[macTagsInput]'
})
export class TagsInputDirective {
  private hashTagInput: HashTagInput | null = null;
  private hashTagSubject = new BehaviorSubject<HashTagInput>(this.hashTagInput);
  hashTagChange = this.hashTagSubject.asObservable();
}

私有hashTagInput属性是一个内部状态,用于存储当前的 hashtag 输入信息。hashTagSubject成员是一个行为主题,我们内部使用它来发布 hashtag 输入更改。我们使用主题上的asObservable方法来公开一个可观察的流,该流在每次更改时都会发出 hashtag 输入对象。我们将这个派生可观察流存储在具有公共可见性的hashTagChange成员中。其他组件可以访问这个属性并订阅,以便在 hashtag 输入事件发生时得到通知。

现在,让我们逐步添加更多到我们的指令中。首先,让我们添加一个重置方法,当 hashtag 输入应该被重置时我们可以调用这个方法。这个方法将在内部使用,当输入被取消时,也可以从外部,从另一个组件中调用,以取消标签输入:

reset() {
  this.hashTagInput = null;
  this.hashTagSubject.next(this.hashTagInput);
}

下一个方法用于根据用户输入更新内部 hashtag 输入对象:

  private updateHashTag(hashTag, position = this.hashTagInput.position) {
    this.hashTagInput = {hashTag, position};
    this.hashTagSubject.next(this.hashTagInput);
  }

现在,让我们向我们的标签输入指令添加两个主要方法,以收集用户输入。我们使用 HostListener 装饰器在宿主元素上创建 keydownkeypress 事件的绑定:

updateTextTag(textTag, position = this.position) { 
  this.textTag = textTag; 
  this.position = position; 
} 

keyDown 方法将由宿主事件绑定调用 keydown 事件。我们关注的是退格键,它应该也会移除当前输入的标签的最后字符。如果我们能检测到退格键(字符代码 8),我们将调用我们的 updateHashTag 方法,并使用 Array.prototype.slice 函数更新当前哈希标签,移除最后一个字符:

@HostListener('keydown', ['$event'])
keyDown(event: KeyboardEvent) {
  if (this.hashTagInput && event.which === 8) {
    this.updateHashTag(this.hashTagInput.hashTag.slice(0, -1));
  }
}

keyPress 方法是在 keypress 事件上从宿主元素的事件绑定中调用的。这就是这个支持指令的主要逻辑所在。在这里,我们处理两种不同的情况:

  • 如果按下的键是哈希符号,我们将从头开始一个新的标签

  • 如果按下的键不是有效的单词字符或哈希符号,我们将将其重置为其初始状态,这将取消标签输入

  • 任何其他有效字符,我们将添加到当前的文本标签字符串中

将以下代码添加到标签输入指令中:

@HostListener('keypress', ['$event'])
keyPress(event: KeyboardEvent) {
  const char = String.fromCharCode(event.which);
  if (char === '#') {
    this.updateHashTag('#', getRangeBoundingClientRect());
  } else if (!/[\w-]/i.test(char)) {
    this.reset();
  } else if (this.hashTagInput) {
    this.updateHashTag(this.hashTagInput.hashTag + char);
  }
}

当输入一个新的哈希标签(如果用户插入哈希符号)时,我们将更新内部哈希标签输入对象,并使用我们的实用函数 getRangeBoundingClientRect 将输入对象的位置设置为当前光标位置。

好的;现在我们有了处理标签输入所需的所有支持。然而,我们仍然需要一个方法来向用户展示标签服务中的可用标签。为此,我们将创建一个新的标签选择组件。它将向用户展示可用标签的列表,并利用我们支持指令发出的标签输入变化来过滤和定位列表。

创建一个标签选择组件

为了帮助用户找到正确的标签,我们将提供一个包含可用标签的下拉菜单。为此,我们需要使用我们标签输入指令发出的标签输入对象。让我们简要地看看这个组件的要求:

  • 它应该在工具提示/呼出框中显示从我们的标签服务中收集到的可用标签

  • 它应该支持显示标签的限制

  • 它应该接收一个带标签输入对象,用于过滤可用标签并使用标签输入对象上的位置数据定位自身

  • 它应该在用户点击列表中的标签时发出一个事件

  • 如果过滤器无效,或者没有元素匹配过滤器,组件应该隐藏:

图片

完成标签选择组件,通过用户输入过滤

让我们先更新我们的应用程序模型,位于 src/app/model.ts,以包括一个用于通信标签选择的新接口。将以下代码添加到文件中:

export interface TagSelection {
  tag: Tag;
  hashTagInput: HashTagInput;
}

如果一个标签被选中,我们想知道被选中的是哪个标签对象,同时,相应的标签输入对象也应该传递。这些数据是必要的,以便我们能够正确地响应选中的标签并正确更新可编辑元素。

让我们继续我们的组件,并创建组件类。首先,让我们使用 Angular CLI 构建一个新的组件:

ng generate component --spec false -cd onpush tags/tags-select

这将生成一个新的组件,我们将打开位于src/app/tags/tags-select/tags-select.component.ts的组件类文件,并添加以下代码:

import {ChangeDetectionStrategy, Component, EventEmitter, HostBinding, Input, OnChanges, Output} from '@angular/core';
import {HashTagInput, Tag, TagSelection} from '../../model';

const tagListLimit = 4;

@Component({
  selector: 'mac-tags-select',
  templateUrl: './tags-select.component.html',
  styleUrls: ['./tags-select.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TagsSelectComponent implements OnChanges {
  @Input() tags: Tag[];
  @Input() hashTagInput: HashTagInput | null;
  @Output() outSelectTag = new EventEmitter<TagSelection>();

  filteredTags: Tag[];

  filterTags() {
    const filter = this.hashTagInput.hashTag.slice(1).toLowerCase();
    this.filteredTags = this.tags
      .filter(tag =>
        tag.hashTag.toLowerCase().includes(filter) ||
        tag.title.toLowerCase().includes(filter)
      )
      .slice(0, tagListLimit);
  }

  selectTag(tag: Tag) {
    this.outSelectTag.next({
      tag,
      hashTagInput: this.hashTagInput
    });
  }

  ngOnChanges(changes) {
    if ((changes.hashTagInput || changes.tags) && this.hashTagInput) {
      this.filterTags();
    }
  }
}

我们的组件有两个输入元素。tags输入用于将所有可用的标签传递到标签选择组件中。这是当用户选择可用标签时将展示给用户的标签列表。hashTagInput输入是我们从之前创建的标签输入指令中获取的标签输入对象。我们将从该对象中提取当前用户输入以过滤显示的标签。我们还将使用该对象的位置数据将组件定位到用户开始编写标签输入的插入点屏幕坐标。

输出outSelectTag用于在用户从标签列表中选择标签时发出事件。filteredTags属性用于计算过滤后的标签列表。当hashTagInputtags输入对象发生变化时,我们调用filterTags方法。在这里,我们使用当前标签输入数据过滤所有标签列表。由于这只是一个计算状态,我们的组件仍然是一个纯组件,我们仍然可以使用OnPush变更检测策略。

当用户从过滤后的标签列表中选择标签时,会从视图中调用selectTag方法。在那里,我们发出一个新的标签选择对象,该对象包含选中的标签以及标签输入对象。

让我们继续并添加一些访问器属性到我们的组件中,我们使用这些属性来创建宿主元素样式绑定。以下访问器属性hasFilteredTags绑定到宿主元素的显示样式属性。它将控制组件是显示还是隐藏。只有当过滤器有效且过滤后的标签至少包含一个标签时,我们才会显示组件:

  @HostBinding('style.display')
  get hasFilteredTags() {
    return this.filteredTags && this.filteredTags.length > 0 ? 'block' : 'none';
  }

以下两个访问器属性使用宿主绑定来设置宿主元素的topleft样式,基于组件的hashTagInput输入:

  @HostBinding('style.top')
  get topPosition() {
    return this.hashTagInput && this.hashTagInput.position ?
      `${this.hashTagInput.position.top}px` : 0;
  }

  @HostBinding('style.left')
  get leftPosition() {
    return this.hashTagInput && this.hashTagInput.position ?
      `${this.hashTagInput.position.left}px` : 0;
  }

我们的组件模板相当简单。让我们打开存储在src/app/tags/tags-select/tags-select.component.html中的视图模板,并应用以下更改:

<ul class="list">
  <li *ngFor="let tag of filteredTags"
      (click)="selectTag(tag)"
      class="item">{{tag.title}}</li>
</ul>

我们使用了NgFor指令来遍历filteredTags成员中的所有标签。如果点击了一个标签,我们需要执行selectTag方法并传递当前迭代的标签。在列表中,我们只显示标签标题,这有助于用户识别他们想要使用的标签。

现在,我们已经构建了我们需要的所有组件,以实现用户平滑地输入标签。然而,我们仍然需要将所有这些组件连接起来。下一步是在我们的项目评论中启用标签选择。

在编辑器组件中集成标签选择

作为第一步,我们应该修改我们的编辑器组件,以便利用我们刚刚创建的标签选择组件与标签输入指令一起使用。

在我们开始更改编辑器之前,让我们看看一个新的字符串辅助函数splice,它允许我们传递文本中的特定位置,在那里我们想要用从所选标签对象中获取的最终标签替换用户输入的部分标签。

splice方法与Array.prototype.splice函数类似,允许我们在字符串中删除一定部分并添加新部分到该字符串的同一位置。这允许我们非常具体地替换字符串中的某些区域,这正是我们在这个情况下所需要的。让我们在我们的字符串实用模块src/app/utilities/string-utilities.ts中实现这个小小的辅助函数:

…
export function splice(
  target: string,
  index: number,
  deleteCount: number,
  content: string): string {
  return target.slice(0, index) +
    content +
    target.slice(index + deleteCount);
}

让我们回到我们的编辑器组件,看看在src/app/ui/editor/editor.component.html中组件模板内的更改。模板中的有效更改以粗体标注:

<div #editableContentElement
     class="editable-content"
     contenteditable="true"
 macTagsInput></div>
<mac-tags-select
 *ngIf="tags && tagsInput.hashTagChange | async"
 [hashTagInput]="tagsInput.hashTagChange | async"
 [tags]="tags"
 (outSelectTag)="selectTag($event)">
</mac-tags-select>
<div class="output" [innerHTML]="content ? (content | tags | async) : '-'"></div>
<div *ngIf="showControls && !editMode"
     class="controls">
  <button (click)="beginEdit()" class="icon-edit"></button>
</div>
<div *ngIf="showControls && editMode"
     class="controls">
  <button (click)="saveEdit()" class="icon-save"></button>
  <button (click)="cancelEdit()" class="icon-cancel"></button>
</div>

我们需要添加的第一件事是我们的标签输入指令,它将帮助我们收集用户在可编辑内容字段中输入的标签数据。

在可编辑内容元素下方,我们添加了新的标签选择组件。只有当将标签列表作为输入提供给编辑器组件时,我们才会渲染标签选择组件。我们使用从标签输入指令中提取的标签输入对象,并将其传递到标签选择的hashTagInput输入。如果标签选择组件发出outSelectTag事件,我们将调用一个新的selectTag方法,我们将在我们的编辑器组件中实现它。

现在,让我们将必要的更改应用到我们的组件类中,位于src/app/ui/editor/editor.component.html。省略号字符()表示未更改的代码部分。有效更改以粗体标注:

…
import {TagsInputDirective} from '../../tags/tags-input.directive';
import {Tag, TagSelection} from '../../model';
import {splice} from '../../utilities/string-utilities';

@Component({
  selector: 'mac-editor',
  templateUrl: './editor.component.html',
  styleUrls: ['./editor.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class EditorComponent implements OnChanges, AfterViewInit {
  @ViewChild('editableContentElement') editableContentElement: ElementRef;
  @ViewChild('editableContentElement', {
 read: TagsInputDirective
 }) tagsInput: TagsInputDirective;
  …

  saveEdit() {
    this.editMode = false;
    this.tagsInput.reset();
    this.outSaveEdit.emit(this.getEditableContent());
  }

  cancelEdit() {
    this.editMode = false;
 this.tagsInput.reset();
    this.setEditableContent(this.content);
    this.outCancelEdit.emit();
  }
  …

  selectTag(tagSelection: TagSelection) {
 this.setEditableContent(
 splice(
 this.getEditableContent(),
 tagSelection.hashTagInput.position.caretOffset,
 tagSelection.hashTagInput.hashTag.length,
 tagSelection.tag.hashTag
 ));
 this.tagsInput.reset();
 }
}

首先,我们为我们的可编辑内容元素添加了另一个视图查询,使用视图引用editableContentElement。然而,这次我们在视图查询装饰器中使用了额外的配置对象。视图查询选项中的read属性允许我们指定我们不想选择默认的ElementRef对象,而是选择一个在元素上存在的组件实例或指令实例的引用。在我们的例子中,我们想要获取到放置在可编辑内容元素上的标签输入指令的引用。

在我们的编辑器的saveEditcancelEdit方法中,我们现在可以额外调用我们的标签输入指令的 reset 方法。这将确保当用户保存或取消编辑时,我们不会持久化任何之前的标签条目。

最后,我们添加了一个新的方法:selectTag。这个方法是从编辑视图调用的,作为对来自标签选择组件的outSelectTag事件的响应。我们在这里所做的一切就是用标签选择对象中发出的标签替换掉用户输入的可编辑内容元素中的哈希标签部分。

太棒了!我们已经完成了对标签选择组件的工作,并将其与我们的标签输入指令集成到编辑组件中。

在项目评论中集成标签选择

由于编辑组件现在依赖于一个要作为输入传递的可用标签列表,我们需要对我们的项目评论组件应用一些更改。

让我们从项目评论容器组件开始,该组件位于src/app/container/project-comments-container/project-comments-container.component.ts。省略号字符隐藏了无关的代码部分,而有效的更改以粗体显示:

…
import {Comment, CommentUpdate, Project, Tag, User} from '../../model';
…
import {TagsService} from '../../tags/tags.service';

@Component({
  …
})
export class ProjectCommentsContainerComponent {
  …
  tags: Observable<Tag[]>;

  constructor(private projectService: ProjectService,
              private userService: UserService,
              private route: ActivatedRoute,
              private activitiesService: ActivitiesService,
 private tagsService: TagsService) {
    …
    this.tags = this.tagsService.tags;
  }
  …
}

现在,让我们看看容器组件中的视图模板更改,该组件位于src/app/container/project-comments-container/project-comments-container.component.ts

<mac-comments [user]="user | async"
              [comments]="projectComments | async"
              [tags]="tags | async"
              (outCreateComment)="createComment($event)"
              (outUpdateComment)="updateComment($event)">
</mac-comments>

真的很简单!我们只是从我们的标签服务中获取了标签可观察对象,在视图中订阅,并将生成的标签列表传递到我们的评论组件中。

让我们将我们的评论组件更改为接受该标签列表作为输入。打开文件src/app/comments/comments/comments.component.ts,并添加以下更改:

…
import {Comment, CommentUpdate, Tag, TagSelection, User} from '../../model';
import {TagsInputDirective} from '../../tags/tags-input.directive';
import {splice} from '../../utilities/string-utilities';

@Component({
  …
})
export class CommentsComponent {
  …
  @Input() tags: Tag[];
 @ViewChild('commentContentEditable', {
 read: TagsInputDirective
 }) tagsInput: TagsInputDirective;
  …

  selectTag(tagSelection: TagSelection) {
 this.commentContentEditable.nativeElement.textContent =
 splice(
 this.commentContentEditable.nativeElement.textContent,
 tagSelection.hashTagInput.position.caretOffset,
 tagSelection.hashTagInput.hashTag.length,
 tagSelection.tag.hashTag
 );
 this.tagsInput.reset();
 }
}

使用我们在编辑器中使用的相同机制,我们也在评论可编辑元素中启用了标签选择。让我们看看评论组件视图模板中的更改,该模板位于src/app/comments/comments/comments.component.html

<div class="title">Add new comment</div>
<div class="add-comment-section">
  <div #commentContentEditable
       class="add-comment-box"
       contenteditable="true"
       macTagsInput>
  </div>
 <mac-tags-select
 *ngIf="tags && tagsInput.hashTagChange | async"
 [hashTagInput]="tagsInput.hashTagChange | async"
 [tags]="tags"
 (outSelectTag)="selectTag($event)">
 </mac-tags-select>
  <button (click)="createComment()"
          class="button" >Add comment</button>
</div>

<ng-container *ngIf="comments.length > 0">
  <div class="title">All comments</div>
  <mac-comment *ngFor="let comment of comments; let index = index"
               [comment]="comment"
               [user]="user"
               [tags]="tags"
               (outUpdateComment)="updateComment(index, $event)">
  </mac-comment>
</ng-container>

除了为评论可编辑内容元素实现我们自己的标签选择外,我们还向下传递了从我们的父容器组件接收到的标签,作为每个评论组件的输入。

让我们继续并完成标签系统的集成。打开位于src/app/comments/comment/comment.component.ts的评论组件类,并应用以下更改:

…
import {Comment, Tag, User} from '../../model';

@Component({
  …
})
export class CommentComponent {
  …
  @Input() tags: Tag[];
  …
}

我们需要添加一个额外的输入来接收我们的标签列表。让我们也将必要的更改反映到我们的评论组件视图模板中,该模板位于src/app/comments/comment/comment.component.html

…
<div class="main">
  <div class="content">
    <mac-editor [content]="comment.content"
                [showControls]="comment.user.id === user.id"
                [tags]="tags"
                (outSaveEdit)="updateComment($event)">
    </mac-editor>
  </div>
</div>

好的!为了集成,确实有很多更改,但它们都很简单,现在我们可以使用我们的标签系统了!

完成我们的标签系统

恭喜!你现在已经成功实现了三个可用性组件中的第一个。

在标签输入指令的帮助下,我们隐藏了用户输入的低级编程和用户光标位置的处理。然后,我们创建了一个组件来向用户显示可用的标签,并提供了他们可以通过点击来选择标签的方式。在我们的编辑器组件中,我们使用了标签输入指令以及标签选择组件,以在编辑注释时平滑地输入标签。

在本节中,我们涵盖了以下概念:

  • 我们在指定的指令中处理了复杂的用户输入,以将逻辑从我们的组件中卸载

  • 我们使用宿主绑定来设置位置样式属性

  • 我们使用ViewChild装饰器上的read属性来查询指令实例

  • 我们实现了完全响应式组件,这些组件依赖于可观察对象,并在变更检测期间不产生副作用

在下一节中,我们将探讨如何将拖放功能集成到我们的应用程序中。我们将构建 Angular 指令,这将帮助我们轻松地将拖放功能集成到任务管理应用程序的任何区域。

拖放

我们已经学会了高效地使用我们的计算机鼠标和键盘。使用键盘快捷键、不同的点击动作和上下文鼠标菜单,可以为我们执行任务提供支持。然而,在当前的移动和触摸设备热潮中,有一个模式最近受到了更多的关注。拖放动作是一种非常直观和逻辑的表达动作的方式,例如移动或复制项目。在用户界面执行的一个特定任务受益于拖放:在列表中对项目进行排序。如果我们需要通过动作菜单对项目进行排序,会变得非常混乱。逐步移动项目,使用上下按钮,效果很好,但需要花费很多时间。如果你可以拖动项目并将它们拖放到你希望它们重新排序的位置,你可以非常快速地对项目列表进行排序。

在本主题中,我们将构建启用应用程序内拖放所需的基本元素。我们将使用拖放功能来启用用户重新排序他们的任务列表。通过开发可重用的指令来提供此功能,我们可以在应用程序的任何位置启用该功能。

为了实现我们的指令,我们将利用 HTML5 拖放 API,该 API 在撰写本书时得到了所有主要浏览器的支持。

由于我们希望在多个组件上重用我们的拖放行为,我们将使用指令来实现。在本节中,我们将创建两个指令:

  • 可拖动指令:此指令应附加到可以启用拖动的组件

  • 可拖动放置区域指令:此指令应附加到将作为放置目标的组件

我们还将实现一个功能,可以让我们选择性地决定哪些内容可以被拖放到哪些位置。为此,我们将在我们的可拖动指令中使用类型属性,以及在目标区域中使用接受类型属性。

更新我们的任务排序模型

作为第一步,我们应该使我们的任务模型支持排序。通过在我们的任务对象中引入一个 order 字段,然后我们可以使用该字段相应地排序任务。让我们对我们的模型文件进行以下更改,该文件位于 src/app/model.ts

export interface Task {
  readonly id?: number;
  readonly projectId?: number;
  readonly title: string;
  readonly done: boolean;
  readonly order: number;
}
…

export type DraggableType = 'task';

我们还添加了一个新的类型别名,DraggableType,我们使用它来识别我们应用程序内可以拖动的对象。我们将使用此类型来确保我们只能将拖放操作到支持给定类型的区域。

由于我们已经更改了任务模型以包含 order 属性,我们需要对我们的现有应用程序状态管理进行一些更改,以便与 order 属性一起工作。

首先,让我们更改我们的内存数据库,并打开文件 src/app/database.ts 以应用以下更改:

…

export class Database implements InMemoryDbService {
  createDb() {
    …

    const tasks: Task[] = [
      {id: 1, projectId: 1, title: 'Task 1', done: false, order: 1},
      {id: 2, projectId: 1, title: 'Task 2', done: false, order: 2},
      {id: 3, projectId: 1, title: 'Task 3', done: true, order: 3},
      {id: 4, projectId: 1, title: 'Task 4', done: false, order: 4}
    ];

    …
  }
}

现在,我们所有的初始任务都包含了一个 order 属性。现在,我们需要处理两件额外的事情:

  • 当创建新任务时,我们需要计算下一个可用的顺序值并使用它来创建新任务

  • 我们需要将任务列表更改为使用 order 属性进行排序

我们可以在任务列表容器组件中实现这两个更改。让我们打开文件 src/app/container/task-list-container/task-list-container.component.ts,并应用一些更改。不相关的代码部分使用省略号字符隐藏,而有效的更改以粗体显示:

…

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  …

  constructor(private taskService: TaskService,
              private projectService: ProjectService,
              private route: ActivatedRoute,
              private activitiesService: ActivitiesService) {
    …

    this.tasks = this.selectedProject.pipe(
      switchMap((project) => this.taskService.getProjectTasks(project.id)),
 map(tasks => tasks.sort((a: Task, b: Task) => b.order - a.order))
    );

    …
  }

  …

  addTask(title: string) {
    combineLatest(this.selectedProject, this.tasks)
      .pipe(
        take(1)
      )
      .subscribe(([project, tasks]) => {
        const position = tasks.reduce(
 (max, t: Task) => t.order > max ? t.order : max, 0
 ) + 1;
        const task: Task = {
          projectId: project.id, title, done: false, order: position
        };
        this.taskService.addTask(task);
        this.activitiesService.logProjectActivity(
          project.id,
          'tasks',
          'A task was added',
          `A new task "${limitWithEllipsis(title, 30)}" was added to 
         #project-${project.id}.`
        );
      });
  }

  …
}

好了;目前就是这样。我们已经成功引入了一个新的 order 属性,现在它被用来排序我们的任务列表。当我们想要使用拖放功能来排序任务列表时,这个顺序变得非常重要。

实现可拖动指令

draggable 指令将被附加到我们希望启用拖放功能的元素上。让我们使用 Angular CLI 工具创建一个新的指令开始:

ng generate directive --spec false draggable/draggable

让我们打开位于 src/app/draggable/draggable.directive.ts 的指令类文件,并添加以下代码:

import {Directive, HostBinding, HostListener, Input} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggable]'
})
export class DraggableDirective {
  @HostBinding('draggable') draggable = 'true';
  @Input() draggableData: any;
  @Input() draggableType: DraggableType;
  @HostBinding('class.dragging') dragging = false;
}

通过将 HTML 属性 draggable 设置为 true,使用宿主绑定,我们告诉浏览器我们正在考虑这个元素为可拖动元素。这个 HTML 属性已经是浏览器拖放 API 的一部分。

draggableData 输入用于指定表示可拖动元素的的数据。一旦拖动操作完成,这些数据将被序列化为 JSON 并传输到我们的目标区域。

通过使用我们引入到模型中的 draggableType 输入指定可拖动类型,当元素拖动到目标区域时,我们可以更加选择性地进行。在目标区域内,我们可以包括一个控制可接受的拖放类型的对应元素。

此外,我们可以使用主机绑定来设置一个名为dragging的类,这将应用一些特殊样式,使得识别出被拖动的元素变得容易。

现在,我们需要在我们的指令中处理两个事件,以实现可拖动元素的行为。以下 DOM 事件由拖放 DOM API 触发:

  • dragstart:这个事件在元素被抓住并在屏幕上移动时发出

  • dragend:如果之前启动的元素拖动因为成功的放置或释放到有效的放置目标之外而结束,这个 DOM 事件将被触发

让我们使用HostListener装饰器来实现dragstart事件的逻辑:

import {Directive, HostBinding, HostListener, Input} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggable]'
})
export class DraggableDirective {
  @HostBinding('draggable') draggable = 'true';
  @Input() draggableData: any;
  @Input() draggableType: DraggableType;
  @HostBinding('class.dragging') dragging = false;

 @HostListener('dragstart', ['$event'])
 dragStart(event) {
 event.dataTransfer.effectAllowed = 'move';
 event.dataTransfer.setData('application/json', JSON.stringify(this.draggableData));
 event.dataTransfer.setData(`draggable-type:${this.draggableType}`, '');
 this.dragging = true;
 }
}

现在,让我们讨论在实现我们的主机监听器时可以执行的不同操作。

我们需要在我们的主机监听器中访问 DOM 事件对象。如果我们要在模板中创建这个绑定,我们可能需要写一些类似这样的代码:(dragstart)="dragStart($event)"。在事件绑定中,我们可以利用合成变量$event,它是触发事件绑定的事件的引用。如果我们使用HostListener装饰器在我们的主机元素上创建事件绑定,我们需要通过装饰器的第二个参数来构造绑定的参数列表。

我们事件监听器的第一个操作是在数据传输对象上设置所需的effectAllowed属性。目前,我们只支持move效果,因为我们的主要关注点是使用拖放重新排序任务列表。拖放 API 非常特定于系统,但通常,如果用户在启动拖动时按住修饰键(如CtrlShift),会有不同的拖放效果。在我们的draggable指令中,我们可以强制所有拖动操作都使用move效果。

在下一个代码片段中,我们设置了通过拖动应该传输的数据。理解拖放 API 的核心目的是非常重要的。它不仅提供了一种在 DOM 元素中实现拖放的方法,而且还支持将文件和其他对象拖入浏览器。正因为如此,API 存在一些限制,其中之一是使得除了简单的字符串值之外的数据传输变得不可能。为了使我们能够传输复杂对象,我们将使用JSON.stringifydraggableData输入的数据序列化。

由于 API 中的一些安全约束导致的另一个限制是,数据只能在成功释放后读取。这意味着如果用户只是悬停在元素上,我们无法检查数据。然而,当悬停在释放区域上时,我们需要了解一些关于数据的事实。我们需要知道当进入释放区域时,可拖动元素的类型。这样我们就可以控制某些可拖动元素只能被放置在特定的释放区域中。我们为此问题使用了一个小的解决方案。拖放 API 在拖动数据到释放目标上时隐藏数据。然而,它告诉我们数据的类型。了解这一事实后,我们可以使用setData函数来编码我们的可拖动类型。仅访问数据键被认为是安全的,因此可以在所有释放区域事件中执行。

最后,我们将拖动标志设置为true,这将导致类绑定重新验证并添加dragging类到元素上。

在处理完dragstart事件后,我们现在需要处理dragend事件,以完成我们的可拖动指令。在绑定到dragend事件的dragEnd方法中,我们唯一做的事情是将拖动成员设置为false。这将导致dragging类从宿主元素中移除:

import {Directive, HostBinding, HostListener, Input} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggable]'
})
export class DraggableDirective {
  …

 @HostListener('dragend')
 onDragEnd() {
 this.dragging = false;
 }
}

这就是我们的可拖动指令的行为。现在,我们需要创建其对应指令,以提供释放区域的行为。

实现释放目标指令

释放区域将作为容器,其中可拖动元素可以被放置。为此,我们将创建一个新的可拖动释放区域指令。让我们使用 Angular CLI 来创建指令:

ng generate directive --spec false draggable/draggable-drop-zone

让我们打开位于src/app/draggable/draggable-drop-zone.directive.ts的指令文件,并添加以下代码:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  @Input() dropAcceptType: DraggableType;
  @Output() outDropDraggable = new EventEmitter<any>();
  @HostBinding('class.over') over = false;

  dragEnterCount = 0;
}

使用dropAcceptType输入,我们可以指定在这个释放区域中我们接受哪些类型的可拖动元素。这将帮助用户识别他们是否能够将可拖动元素放置在释放区域上。

在成功将项目拖放到释放区域后,我们需要发出一个事件,以便使用我们的拖放功能的组件可以相应地做出反应。为此,我们将使用dropDraggable输出属性。

over成员字段将存储如果被接受元素正在拖动到释放区域上时所处的状态。我们使用宿主绑定在宿主元素上设置类over。这样,当我们将项目拖放到释放区域上时,释放区域元素可以有不同的样式。

现在,让我们添加一个方法来检查我们的释放区域是否应该接受任何给定的拖放事件,通过检查我们的dropAcceptType成员。记得我们在创建可拖动指令时需要解决的那些安全问题吗?现在,我们正在实现其对应部分,从拖动事件中提取可拖动类型并检查拖动的项目是否被这个释放区域支持:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  @Input() dropAcceptType: DraggableType;
  @Output() outDropDraggable = new EventEmitter<any>();
  @HostBinding('class.over') over = false;

  dragEnterCount = 0;

 private typeIsAccepted(event: DragEvent) {
 const draggableType = Array.from(event.dataTransfer.types).find((key) =>
 key.indexOf('draggable-type') === 0);
 return draggableType && draggableType.split(':')[1] === this.dropAcceptType;
 }
}

我们只能读取拖动事件中数据传输对象内的键,其中数据本身在成功的drop事件发生之前是隐藏的。为了绕过这个安全限制,我们将可拖动类型信息编码到数据键本身中。由于我们可以通过使用数据传输对象的types字段安全地列出所有数据键,因此提取编码的可拖动类型信息并不太难。我们寻找一个以'draggable-type'开头的数据类型键,然后通过列字符进行分割。列字符后面的值是我们的类型信息,然后我们可以将其与dropAcceptType指令输入属性进行比较。

我们将使用两个事件来确定可拖动元素是否被移动到我们的拖放区域:

  • dragenter:当另一个元素被拖动到它上面时,该事件由一个元素触发

  • dragleave:当之前进入的元素再次离开时,该事件由一个元素触发

前面的事件有一个问题,就是它们实际上会冒泡,如果拖动的元素被移动到我们的拖放区域内的子元素中,我们将会收到一个dragleave事件。由于冒泡,我们还会从子元素那里收到dragenterdragleave事件。在我们的情况下,这并不是我们想要的,我们需要构建一些功能来改善这种行为。我们使用一个计数成员字段dragEnterCount,它在所有dragenter事件上增加计数,在dragleave事件上减少计数。这样,我们现在可以说,只有在dragleave事件中,当计数器变为零时,用户的鼠标光标才会离开拖放区域。让我们看看以下图表,它说明了这个问题:

我们计算的重要变量和函数的可视化

让我们实现这个逻辑,以在我们的拖放区域内为dragenterdragleave事件构建适当的行为:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  @Input() dropAcceptType: DraggableType;
  @Output() outDropDraggable = new EventEmitter<any>();
  @HostBinding('class.over') over = false;

  dragEnterCount = 0;

  private typeIsAccepted(event: DragEvent) {
    const draggableType = Array.from(event.dataTransfer.types).find((key) =>
      key.indexOf('draggable-type') === 0);
    return draggableType && draggableType.split(':')[1] === this.dropAcceptType;
  }

  @HostListener('dragenter', ['$event'])
 dragEnter(event: DragEvent) {
 if (this.typeIsAccepted(event)) {
 this.over = true;
 this.dragEnterCount++;
 }
 }

 @HostListener('dragleave', ['$event'])
 dragLeave(event: DragEvent) {
 if (this.typeIsAccepted(event) && --this.dragEnterCount === 0) {
 this.over = false;
 }
 }
}

在这两个事件中,我们首先检查事件是否携带数据传输对象,我们接受其类型。在通过我们的typeIsAccepted方法验证类型后,我们处理计数器,并在需要时设置over成员字段。

我们需要处理另一个事件,这对于拖放功能非常重要。dragover事件帮助我们设置当前拖动操作的接受dropEffect。这将告诉我们的浏览器,从我们的可拖动元素发起的拖动操作适合这个拖放区域。同样重要的是,我们需要防止默认的浏览器行为,这样就不会干扰我们自定义的拖放实现。让我们添加另一个主机监听器来覆盖这些关注点:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  …

 @HostListener('dragover', ['$event'])
 dragOver(event: DragEvent) {
 if (this.typeIsAccepted(event)) {
 event.preventDefault();
 event.dataTransfer.dropEffect = 'move';
 }
 }
}

最后,我们需要处理拖放区域中最重要的事件,即当用户将可拖动项拖入我们的拖放区域时触发的drop事件:

import {Directive, EventEmitter, HostBinding, HostListener, Input, Output} from '@angular/core';
import {DraggableType} from '../model';

@Directive({
  selector: '[macDraggableDropZone]'
})
export class DraggableDropZoneDirective {
  …

  @HostListener('dragover', ['$event'])
  dragOver(event: DragEvent) {
    if (this.typeIsAccepted(event)) {
      event.preventDefault();
      event.dataTransfer.dropEffect = 'move';
    }
  }
  @HostListener('drop', ['$event'])
 drop(event: DragEvent) {
 if (this.typeIsAccepted(event)) {
 const data = JSON.parse(event.dataTransfer.getData('application/json'));
 this.over = false;
 this.dragEnterCount = 0;
 this.outDropDraggable.next(data);
 }
 }
}

在检查放下元素是否为接受类型之后,我们可以继续从事件中读取数据传输对象数据。这些数据之前由可拖动指令设置,需要使用 JSON.parse 进行反序列化。

由于放置成功,我们可以重置我们的 dragEnterCount 成员并将 over 标志设置为 false

最后,我们将使用我们的 outDropDraggable 输出属性发出来自可拖动元素的已反序列化数据。

这就是我们构建高度可重用拖放行为所需的所有内容。现在,我们可以将可拖动和可拖动放置区域附加到我们应用程序中任何需要启用拖放的组件上。

在下一节中,我们将集成应用程序中的拖放功能。

集成拖放

现在,我们可以在任务列表组件中使用可拖动和可拖动放置区域指令,这样我们就可以通过拖放来启用任务的重新排序。

我们可以通过将这两个指令都附加到任务列表组件模板中的任务元素上来做到这一点。没错!我们希望使我们的任务组件可拖动,同时也是一个放置区域。这样,我们就可以将任务拖放到其他任务上,这为我们提供了重新排序的基础。我们将执行的操作是在放置时重新排序列表,以便放置的任务将挤入它被放置的任务之前的位置。

首先,让我们将拖放指令应用到任务列表组件模板中的任务宿主元素上。打开文件 src/app/tasks/task-list/task-list.component.html,并应用以下更改:

<mac-toggle [buttonList]="taskFilterTypes"
            [activeButton]="activeTaskFilterType"
            (outActivate)="activateFilterType($event)">
</mac-toggle>
<mac-enter-task (outEnterTask)="addTask($event)"></mac-enter-task>
<div class="tasks">
  <mac-task *ngFor="let task of tasks"
            [task]="task"
            (outUpdateTask)="updateTask($event)"
            macDraggable
 draggableType="task"
 [draggableData]="task"
 macDraggableDropZone
 dropAcceptType="task"
 (outDropDraggable)="dropTask(task, $event)"></mac-task>
</div>

好的;使用前面的属性,我们使我们的任务不仅可拖动,同时也可以作为放置区域。通过将 draggableTypedropAcceptType 都指定为 'task' 字符串,我们告诉我们的拖放行为这些任务元素可以被拖放到其他任务元素中。我们的可拖动放置区域指令被设置为在有效可拖动元素被放下时发出一个 outDropDraggable 事件。在成功放置后,我们将在任务列表组件中调用一个新的方法 dropTask,我们将传递当前任务和放置区域事件对象。可拖动放置区域指令将发出之前使用可拖动指令的 draggableData 输入设置的任何数据。换句话说,dropTask 方法以目标任务作为第一个参数,源任务作为第二个参数被调用。

让我们在组件类中实现 dropTask 方法,该类位于 src/app/tasks/task-list/task-list.component.ts

…

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListComponent {
  …

  dropTask(target: Task, source: Task) {
 if (target.id === source.id) {
 return;
 }

 this.outUpdateTask.emit({
 ...target,
 order: source.order
 });
 this.outUpdateTask.emit({
 ...source,
 order: target.order
 });
 }
}

让我们详细说明任务列表组件中的实现:

  1. 如果你再次检查模板,你会看到我们使用以下表达式绑定到dropTask方法:(outDropDraggable)="dropTask(task, $event)"。由于拖放区域发出的事件包含使用可拖动输入属性draggableData绑定的反序列化数据,我们可以安全地假设我们将收到一个被拖放到拖放区域的任务的副本。作为我们绑定的第一个参数,我们添加了本地视图变量task,它实际上是作为拖放区域起作用的任务。因此,我们可以说我们的dropTask方法的第一个参数代表目标,而第二个参数代表源任务。

  2. 在我们的方法中,我们首先比较源 ID 和目标 ID,如果它们匹配,我们可以假设任务被拖放到自身,我们不需要执行任何进一步的操作。

  3. 现在,我们只需从我们的任务列表组件中发出两个更新任务事件,以重新排列源任务和目标任务。我们通过在源任务和目标任务之间切换顺序属性来实现这一点。这只是重新排列的一种方式,我们也可以以不同的方式实现。

这有多棒?我们已经成功地在任务列表中实现了拖放,为用户提供了一个非常有用的功能来重新排列任务。

拖放回顾

通过使用低级拖放 API,使用事件和数据传输对象,我们已经实现了两个指令,现在可以在我们的应用程序中执行平滑的拖放功能,无论我们希望在何处。

几乎没有花费任何力气,我们就已经在任务列表上实现了拖放行为,为用户提供了一个很好的功能来重新排列列表中的任务。除了连接指令之外,我们唯一需要做的事情是实现一个方法,我们可以根据可拖动拖放区域指令的输出信息重新排列任务。

在本节中,我们使用了以下概念:

  • 我们学习了 HTML5 拖放 API 的基础知识

  • 我们使用数据传输对象在拖放事件中安全地传输数据

  • 我们使用指令构建了可重用的行为模式

  • 我们通过提供我们自己的自定义选择机制,使用自定义数据类型来编码可拖动类型信息,丰富了标准的拖放 API

摘要

在本章中,我们构建了两个功能来增强我们应用程序的可用性。用户现在可以使用标签,轻松地使用可导航的项目注释注释,这些项目提供了主题的摘要。他们还可以使用拖放,在任务列表组件中重新排列任务。

用户体验是当今应用的关键资产,通过提供高度封装和可重用的组件来解决用户体验问题,我们可以在构建这些应用时使生活变得更加轻松。在处理用户体验时,从组件的角度思考是非常好的,这不仅有助于简化开发,还有助于建立一致性。一致性本身在使应用易于使用方面发挥着重要作用。

在下一章中,我们将创建一些巧妙的组件来管理任务管理系统中的时间。这还将包括一些新的用户输入组件,以实现简单的工时输入字段。

第八章:时间会证明一切

我们的任务管理系统正在成形。然而,到目前为止,我们还没有关注到管理项目的一个关键方面。时间在所有项目中都起着重要作用,这可能是最复杂的管理方面。

在本章中,我们将向我们的任务管理系统添加一些功能,帮助用户更有效地管理时间。通过重用我们之前创建的一些组件,我们将能够提供一致的用户体验来管理时间。

在更高层次上,我们将开发以下功能,以在我们的应用程序中实现时间管理:

  • 任务详情:到目前为止,我们没有包括任务详情页面,因为所有关于任务的信息都可以在我们的项目页面上的任务列表中显示。由于我们的时间管理将大大增加任务的复杂性,我们将创建一个新的项目任务详情视图,该视图也将通过路由访问。

  • 努力管理:我们将包括一些新的任务数据来管理任务上的努力。努力总是由估计的时间持续和实际花费的时间来表示。我们将使努力的这两个属性都是可选的,以便它们可以独立存在。我们将创建新的组件,使用户能够轻松地提供时间持续时间输入。

本章将涵盖以下主题:

  • 创建一个项目任务详情组件来编辑任务详情并启用新的路由

  • 修改我们的标签管理系统以包括任务标签

  • 创建新的管道来处理格式化时间持续时间

  • 创建任务信息组件,在现有的任务组件上显示任务概述信息

  • 创建一个时间持续时间组件,使用户能够通过无 UI 用户交互方法轻松输入时间持续时间

  • 在任务上实施时间努力跟踪

  • 创建一个 SVG 组件来显示任务进度

任务详情

到目前为止,我们的任务列表足以直接在列表中显示所有任务的详细信息。然而,由于在本章中我们将向任务添加更多详细信息,现在是时候提供一个详细视图,让用户可以编辑任务。

我们已经在 第五章 中为项目导航打下了基础,基于组件的路由。添加一个新路由组件,我们将在项目上下文中使用它,将是一件轻而易举的事情。

在我们开始创建新的任务详情组件之前,让我们在我们的任务模型中引入一个新的状态。用户应该有额外的选项,除了任务标题外,还可以提供任务描述。这个描述将在任务详情视图中可编辑。

让我们打开位于 src/app/model.ts 的模型文件,并给我们的任务接口添加一个可选的描述字段:

…

export interface Task {
  readonly id?: number;
  readonly projectId?: number;
  readonly title: string;
 readonly description?: string;
  readonly done: boolean;
  readonly order: number;
}

…

好的,所以现在我们可以为每个任务存储一个描述。然而,当在任务列表中创建新任务时,我们仍然只会存储任务的标题。用户需要访问新的详情视图来更新任务的描述。

好的,现在让我们使用 Angular CLI 创建一个用于任务详细视图的新组件:

ng generate component --spec false -cd onpush tasks/task-details

让我们在src/app/tasks/task-details/task-details.component.ts中打开生成的组件类文件,并添加以下代码:

import {
  ChangeDetectionStrategy, Component, EventEmitter, Input, Output,
  ViewEncapsulation
} from '@angular/core';
import {Tag, Task} from '../../model';

@Component({
  selector: 'mac-task-details',
  templateUrl: './task-details.component.html',
  styleUrls: ['./task-details.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskDetailsComponent {
  @Input() task: Task;
  @Input() tags: Tag[];
  @Output() outUpdateTask = new EventEmitter<Task>();

  updateTitle(title: string) {
    this.outUpdateTask.emit({
      ...this.task,
      title
    });
  }

  updateDescription(description: string) {
    this.outUpdateTask.emit({
      ...this.task,
      description
    });
  }
}

在任务详情中,我们允许用户编辑任务的标题和描述。我们正在开发一个简单的 UI 组件来表示任务详情视图。因此,我们期望将任务对象设置为组件输入。outUpdateTask输出用于将任务更新通知给我们的父组件。我们还添加了两个方法,用于在我们的视图中更新任务标题和描述。

我们还希望支持用户编辑任务描述时的标签选择。为此目的,我们添加了一个tags输入,它可以由父组件提供。

好的,现在让我们看看我们组件的模板,看看我们将如何处理任务数据以提供一个编辑详情的界面。打开位于src/app/tasks/task-details/task-details.component.html的模板文件,并添加以下内容:

<h3 class="title">Task Details of task #{{task.id}}</h3>
<div class="content">
  <div class="label">Title</div>
  <mac-editor [content]="task.title"
              [showControls]="true"
              (outSaveEdit)="updateTitle($event)"></mac-editor>
  <div class="label">Description</div>
  <mac-editor [content]="task.description"
              [showControls]="true"
              [tags]="tags"
              (outSaveEdit)="updateDescription($event)"></mac-editor>
</div>

通过重用我们在第四章中创建的编辑器组件,项目思维,我们可以依赖简单的 UI 组合来使我们的任务标题和描述可编辑。

当我们将任务数据存储到我们组件的task成员变量中时,我们可以引用titledescription字段来创建与我们的编辑器组件的content输入属性的绑定。

虽然标题应该只包含纯文本,但我们可以支持在任务的描述字段中我们创建的第七章中,用户体验组件的标签功能。为此,我们只需将可用标签的列表传递给编辑器组件。

我们使用编辑器组件的outSaveEdit输出属性来调用我们的更新函数,分别用于更新标题或描述。

到目前为止,一切顺利。我们创建了一个任务详情组件,使用我们的编辑器 UI 组件可以轻松地编辑任务的标题和描述。现在,我们需要创建一个任务详情视图的容器组件,并在我们的应用程序中将其启用为子路由。

让我们使用 Angular CLI 创建一个任务详情容器组件:

ng generate component --spec false -cd onpush container/task-details-container

如往常一样,我们容器组件的视图非常简单。我们只是渲染一个 UI 组件并传递所需的数据。让我们通过编辑位于src/app/container/task-details-container/task-details-container.component.html的任务详情容器组件的模板来实现这一点:

<mac-task-details [task]="task | async"
                  [tags]="tags | async"
                  (outUpdateTask)="updateTask($event)">
</mac-task-details>

任务详情 UI 组件需要传递一个任务对象作为输入,以及我们应用程序支持的标签列表。我们将使用从相应服务提供的可观察对象获取这些数据。当任务更新时,我们将收到outUpdateTask输出事件,并在我们的容器组件上调用updateTask方法。让我们打开位于src/app/container/task-details-container/task-details-container.component.ts的组件类,并添加以下代码:

import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {TaskService} from '../../tasks/task.service';
import {Observable, combineLatest} from 'rxjs';
import {Tag, Task} from '../../model';
import {map} from 'rxjs/operators';
import {TagsService} from '../../tags/tags.service';

@Component({
  selector: 'mac-task-details-container',
  templateUrl: './task-details-container.component.html',
  styleUrls: ['./task-details-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskDetailsContainerComponent {
  task: Observable<Task>;
  tags: Observable<Tag[]>;

  constructor(private taskService: TaskService,
              private route: ActivatedRoute,
              private tagsService: TagsService) {
    this.task = combineLatest(
      this.taskService.getTasks(),
      route.params
    ).pipe(
      map(([tasks, routeParams]) =>
        tasks.find((task) => task.id === +routeParams.taskId)
      )
    );
    this.tags = this.tagsService.tags;
  }

  updateTask(task: Task) {
    this.taskService.updateTask(task);
  }
}

这里没有新内容。我们正在使用一个名为taskId的路由参数来从我们的任务服务中获取特定的任务。

要启用我们的新任务详情视图,我们只需在路由配置中创建一个子路由。让我们打开位于src/app/routes.ts的路由配置,进行必要的修改:

…
import {TaskDetailsContainerComponent} from './container/task-details-container/task-details-container.component';

export const routes: Route[] = [{
  path: 'projects/:projectId',
  component: ProjectContainerComponent,
  canActivate: [ProjectContainerGuard],
  children: [{
    path: 'tasks',
    component: TaskListContainerComponent
  }, {
 path: 'tasks/:taskId',
 component: TaskDetailsContainerComponent
 }, {
    path: 'comments',
    component: ProjectCommentsContainerComponent
  }, {
    path: 'activities',
    component: ProjectActivitiesContainerComponent
  }, {
    path: '**',
    redirectTo: 'tasks'
  }]
}, {
  path: '',
  pathMatch: 'full',
  redirectTo: '/projects/1'
}];

我们在我们的项目视图中添加了一个新的子路由,现在可以通过 URL /projects/:projectId/tasks/:taskId访问。通过在路由配置中包含参数:taskId,我们可以将相关的任务 ID 传递给任务详情容器组件。

太棒了!我们已经创建了一个新的任务详情视图,并在我们的路由器中启用了它。您可以在浏览器中预览您的更改,并通过在浏览器中输入以下 URL 来访问任务的详情视图:http://localhost:4200/projects/1/tasks/1。现在您应该可以看到我们第一个项目中的第一个任务的详情视图。在我们继续集成任务详情视图之前,尝试编辑标题和描述。

导航到任务详情

为了使我们的任务详情路由可导航,我们需要修改任务列表中的任务组件。我们希望在任务组件中添加一个小的按钮,以便导航到详情视图。让我们从模板更改开始;打开我们的任务组件模板,位于src/app/tasks/task/task.component.html,并应用以下更改:

<mac-checkbox [checked]="task.done"
              (outCheck)="updateTask($event)"></mac-checkbox>
<div class="content">
  <div class="title">
    <mac-editor [content]="task.title"
                [showControls]="true"
                (outSaveEdit)="updateTitle($event)"></mac-editor>
  </div>
  <button class="button button-small" (click)="showDetails()">Details</button>
  <button class="delete" (click)="deleteTask()"></button>
</div>

让我们直接进入组件类并实现新的showDetails方法。打开文件src/app/tasks/task/task.component.html并应用以下更改:

…

@Component({
  selector: 'mac-task',
  templateUrl: './task.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskComponent {
  …
  @Output() outShowDetails = new EventEmitter<Task>();

  …

 showDetails() {
 this.outShowDetails.emit(this.task);
 }
}

好的,现在我们需要确保将显示任务详情的事件一路传递到任务列表容器组件。我们的下一步是任务列表 UI 组件,我们需要委派我们的任务组件的outShowDetails输出。

让我们从src/app/tasks/task-list/task-list.component.html中的模板更改开始,并应用以下更改:

…
<div class="tasks">
  <mac-task *ngFor="let task of tasks"
            [task]="task"
            (outUpdateTask)="updateTask($event)"
            (outDeleteTask)="deleteTask($event)"
 (outShowDetails)="showDetails($event)"
            macDraggable
            draggableType="task"
            [draggableData]="task"
            macDraggableDropZone
            dropAcceptType="task"
            (outDropDraggable)="dropTask(task, $event)"></mac-task>
</div>

这很简单!现在,让我们将更改添加到位于src/app/tasks/task-list/task-list.component.ts的任务列表组件类中:

…

@Component({
  selector: 'mac-task-list',
  templateUrl: './task-list.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListComponent {
  …
  @Output() outShowDetails = new EventEmitter<Task>();

  …

  showDetails(task: Task) {
 this.outShowDetails.emit(task);
 }
}

太好了,我们组件树中的下一个停靠点是任务列表容器,我们将在这里实现导航到详情视图的逻辑。让我们打开位于src/app/container/task-list-container/task-list-container.component.html的任务列表容器模板,并创建必要的绑定:

<mac-task-list
  [tasks]="filteredTasks | async"
  [taskFilterTypes]="taskFilterTypes"
  [activeTaskFilterType]="activeTaskFilterType | async"
  (outUpdateTask)="updateTask($event)"
  (outDeleteTask)="deleteTask($event)"
  (outShowDetails)="showDetails($event)"
  (outActivateFilterType)="activateFilterType($event)"
  (outAddTask)="addTask($event)">
</mac-task-list>

最后,我们需要在位于 src/app/container/task-list-container/task-list-container.component.ts 的容器组件类中实现 showDetails 方法:

…

@Component({
  selector: 'mac-task-list-container',
  templateUrl: './task-list-container.component.html',
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListContainerComponent {
  …

  showDetails(task: Task) {
 this.selectedProject
 .pipe(take(1))
 .subscribe(selectedProject => {
 this.router.navigate(['/projects', selectedProject.id, 'tasks', task.id]);
 });
 }
}

欢呼!我们终于通过在任务组件上添加一个新的导航按钮使任务详情视图可导航了!

现在,你可能想知道,为什么我们要实现这样一个疯狂的输出委托路径,只是为了触发导航?直接在任务 UI 组件中使用 routerLink 指令不是更容易吗?

当然,那会更容易。然而,在简单 UI 组件中使用路由链接存在一个小问题。这样做,我们依赖于系统中存在特定的路由配置。在 UI 组件中直接使用路由链接将它们限制在一个非常特定的上下文中。我们希望避免这种情况,以便在不同的上下文中重用我们的组件。解决这个问题的唯一有意义的方法是使用输出委托,并在容器组件中程序化地控制导航:

新创建的任务详情视图,具有可编辑的标题和描述

为任务启用标签

到目前为止,我们在第七章 [70aeeb16-2a1e-4d20-b523-00df06a8398a.xhtml],用户体验组件 中创建的标签管理系统仅支持项目标签。既然我们已经为任务创建了一个详情视图,那么在标签系统中直接支持任务标签也会很棒。我们的标签系统非常灵活,我们可以以非常少的努力实现新的标签。

让我们修改位于 src/app/tags/tags.service.ts 的标签服务,以便为我们的任务启用标签:

import {Injectable} from '@angular/core';
import {ProjectService} from '../project/project.service';
import {Project, Tag, Task} from '../model';
import {Observable, of, combineLatest} from 'rxjs/Observable';
import {map} from 'rxjs/operators';
import {limitWithEllipsis, replaceAll} from '../utilities/string-utilities';
import {TaskService} from '../tasks/task.service';

@Injectable()
export class TagsService {
  tags: Observable<Tag[]>;

  constructor(private projectService: ProjectService,
 private taskService: TaskService) {
    this.tags = combineLatest(
 this.projectService.getProjects().pipe(
 map((projects: Project[]) => projects.map(project => ({
 type: 'project',
 hashTag: `#project-${project.id}`,
 title: limitWithEllipsis(project.title, 20),
 link: `/projects/${project.id}/tasks`
 })))
 ),
 this.taskService.getTasks().pipe(
 map((tasks: Task[]) => tasks.map(task => ({
 type: 'task',
 hashTag: `#task-${task.id}`,
 title: `${limitWithEllipsis(task.title, 20)} (${task.done ? 'done' : 'open'})`,
 link: `/projects/${task.projectId}/tasks/${task.id}`
 })))
 )
 ).pipe(
 map(([projectTags, taskTags]) => [...projectTags, ...taskTags])
 );
  }

  …
}

除了项目服务外,我们还在标签服务的构造函数中注入了任务服务。这与我们为项目所做的工作非常相似,我们需要为应用中的每个任务创建一个标签对象。我们使用任务服务提供的可观察对象,并使用 map 操作符将所有任务转换为标签。

最后,我们使用 combineLatest 可观察对象辅助工具将项目标签和任务标签合并为单个可观察流。在最终的 map 操作符中,我们将这两个标签列表连接成一个数组。

这并不复杂,对吧?这个相对简单的更改为我们用户带来了巨大的改进。现在,他们可以在我们系统中任何启用了标签的地方引用单个任务。通过点击任务标签,我们正在导航到我们新创建的任务详情视图:

项目评论标签页的编辑组件显示新添加的任务标签

管理努力

在本节中,我们将创建一些组件,帮助我们跟踪工作努力。主要,我们将使用这些组件来管理任务上的努力,但这也可以应用于我们应用中需要跟踪时间的任何部分。

在我们的语境中,努力总是包括两个方面:

  • Estimated duration:这是任务最初估计的持续时间

  • Effective duration:这是在给定任务上花费的时间长度

对于时间持续时间,我们假设一些时间单位和规则,这将简化时间的处理并符合某些工作标准。这里的目的是不提供锐利的时管理,而是提供足够准确以带来价值的东西。为此,我们定义以下工作时间单位:

  • Minute:一分钟是常规的 60 秒

  • Hour:一小时总是代表 60 分钟

  • Day:一天代表八小时的常规工作日

  • Week:一周相当于五个工作日(5 * 8 小时)

当处理持续时间时,我们希望有一些类型安全,因此我们向我们的模型引入了一些新的接口。让我们打开位于src/app/model.ts的模型,并添加以下两个接口:

…

export interface TimeUnit {
 short: string;
 milliseconds: number;
}

export interface TimeEfforts {
 estimated: number;
 effective: number;
} 

TimeUnit接口上的short属性将用于识别用户的时间输入。我们将使用字母 m、h、d 和 w 来表示不同的时间单位,并存储每个时间框架包含的毫秒数。

由于我们希望跟踪在任务上花费的时间和精力,让我们也修改一下我们的任务模型。在我们的模型文件中找到Task接口,并添加以下属性:

export interface Task {
  readonly id?: number;
  readonly projectId?: number;
  readonly title: string;
  readonly description?: string;
  readonly done: boolean;
  readonly order: number;
 readonly efforts?: TimeEfforts;
}

…

我们在我们的任务接口中添加了一个新的可选的efforts属性,用于存储在任务上花费的精力。

好的,我们已经为即将到来的时间跟踪功能准备好了我们的模型。在下一节中,我们将实现我们的第一个 UI 组件来输入时间持续时间。

无 UI 时间持续时间输入

现在我们可以开始编写一个复杂的用户界面组件,用户可以在不同的输入元素中输入单独的时间单位。然而,我相信用无 UI 方法处理时间持续时间输入会更加方便。

简单来说,无 UI 意味着我们使用常规文本输入来提供用户交互。嗯,这没什么新奇的?文本输入是一切开始的地方。然而,最近有一个显著的趋势是提供无 UI 交互。通过结合我们对用户文本输入的处理智能,无 UI 交互实际上可以非常高效,并且远远超过复杂的用户界面。

而不是构建一个复杂的用户界面来输入时间持续时间,我们可以简单地约定一个文本简写形式来编写持续时间。我们可以让用户输入一些内容,例如1.5d5h 30m,以提供输入。按照我们在本节开头建立的约定,管理精力,我们可以构建一个简单的解析器来处理这种输入。

这种方法有几个优点。除了这些,这也是输入时间长度最有效的方法之一,并且也易于我们实现。我们可以简单地重用我们的编辑器组件来从用户那里收集文本输入。然后,我们使用转换过程来解析输入的时间长度。

让我们启动一个新的实用模块,帮助我们处理这些转换。让我们首先在路径src/app/utilities/time-utilities.ts上创建一个新的空文件。

首先,我们需要一个常量来定义我们转换过程中需要的所有单位。将以下代码片段添加到我们新创建的文件中:

import {TimeUnit} from '../model';

export const UNITS: TimeUnit[] = [{
  short: 'w',
  milliseconds: 5 * 8 * 60 * 60 * 1000
}, {
  short: 'd',
  milliseconds: 8 * 60 * 60 * 1000
}, {
  short: 'h',
  milliseconds: 60 * 60 * 1000
}, {
  short: 'm',
  milliseconds: 60 * 1000
}];

我们使用我们在模型中创建的TimeUnit接口。

这些是我们目前需要处理的全部单位。您可以看到运行时计算的毫秒数。我们也可以将毫秒数写成数字字面量,但就地计算它们可以让我们更清晰地了解我们如何得到这些值,并且我们可以添加一些注释。

让我们看看我们的解析函数,我们可以用它将文本输入解析成时间长度。将以下代码添加到我们的文件中:

export function parseDuration(formattedDuration: string): number {
  const pattern = /[\d\.]+\s*[wdhm]/g;
  let timeSpan = 0;
  let result;
  while (result = pattern.exec(formattedDuration)) {
    const chunk = result[0].replace(/\s/g, '');
    const amount = Number(chunk.slice(0, -1));
    const unitShortName = chunk.slice(-1);
    timeSpan += amount * UNITS.find((unit) => unit.short === unitShortName).milliseconds;
  }
  return timeSpan || null;
}

让我们简要分析一下前面的代码,解释我们在这里做了什么:

  1. 首先,我们定义一个正则表达式,帮助我们分解时间长度的文本表示。这个模式将提取文本输入中的重要部分,以计算文本表示背后的时间长度。这些块总是由一个数字,后跟wdhm组成。因此,文本10w 3d 2h 30m将被分割成10w3d2h30m这些块。

  2. 我们使用0初始化timeSpan变量,这样我们可以将发现的块中的所有毫秒数加在一起,然后稍后返回这个总和。

  3. 对于之前提取的每个块,我们现在将数字组件提取到一个名为amount的变量中,将单位(wdhm)提取到一个名为unitShortName的变量中。

  4. 现在,我们可以查找UNITS常量中我们将要处理的块的单位,将单位毫秒数乘以我们从块中提取的数量,然后将这个结果添加到我们的timeSpan变量中。

嗯,这是我们在这里构建的一个相当整洁的函数。它接受一个格式化的时间长度字符串,并将其转换为毫秒。这已经是我们需要处理时间长度文本表示的一半了。第二部分是刚刚构建内容的相反。我们需要一个函数将毫秒数的时间长度转换为格式化的时间长度字符串。让我们将另一段代码添加到我们的文件中:

export function formatDuration(timeSpan: number): string {
  return UNITS.reduce((str, unit) => {
    const amount = timeSpan / unit.milliseconds;
    if (amount >= 1) {
      const fullUnits = Math.floor(amount);
      const formatted = `${str} ${fullUnits}${unit.short}`;
      timeSpan -= fullUnits * unit.milliseconds;
      return formatted;
    } else {
      return str;
    }
  }, '').trim();
} 

让我们也简要解释一下formatDuration函数做了什么:

  • 我们使用Array.prototype.reduce函数来格式化一个包含所有时间单位和它们数量的字符串。我们遍历UNITS常量中所有可用的单位,从最大的单位“周”开始。

  • 我们然后将 timeSpan 变量(以毫秒为单位)除以单位的毫秒数,得到给定单位的金额。

  • 如果金额大于或等于 1,我们可以将给定金额和单位简称添加到我们的格式化字符串中。

  • 由于金额的小数部分可能留下一些分数,这些分数我们需要编码成更小的单位,我们在返回到 reduce 函数之前,从 timeSpan 中减去金额的向下取整版本。

  • 这个过程会为每个单元重复,其中每个单元只有在金额大于或等于 1 时才会提供格式化的输出。

这就是我们需要的,可以在格式化的时间长度和以毫秒表示的时间长度之间相互转换。

在我们创建实际组件以输入时间长度之前,我们还将做一件事。我们将创建一个简单的管道,它基本上只是包装我们的 formatTime 函数。为此,我们将使用 Angular CLI 创建一个新的管道:

ng generate pipe --spec false pipes/format-duration

让我们打开生成的文件 src/app/pipes/format-duration.pipe.ts 并添加以下内容:

import {Pipe, PipeTransform} from '@angular/core';
import {formatDuration} from '../utilities/time-utilities';

@Pipe({
  name: 'formatDuration'
})
export class FormatDurationPipe implements PipeTransform {
  transform(value) {
    if (value == null || typeof value !== 'number') {
      return value;
    }

    return formatDuration(value);
  }
}

使用我们新时间实用模块的 formatTime 函数,我们现在能够在组件模板中直接格式化时间长度(毫秒)。

管理努力的组件

好了,目前的时间数学就到这里。现在让我们使用我们创建的元素来构建一些组件,这些组件将帮助我们收集用户输入。

在本节中,我们将创建两个组件来管理努力:

  • Duration 组件:Duration 组件是一个简单的 UI 组件,它使用我们在前一个主题中处理过的格式化时间字符串,允许用户输入时间长度。它使用编辑器组件来启用用户输入,并使用时间格式化管道以及 parseDuration 实用函数。

  • Efforts 组件:Efforts 组件只是两个表示估计努力和实际在给定任务上花费的努力的持续时间组件的组合。遵循严格的组合规则,这个组件对我们来说很重要,这样我们就不需要重复自己,而是组合一个更大的组件。

让我们从时间组件开始,并使用 Angular CLI 来构建我们的初始组件结构:

ng generate component --spec false -cd onpush ui/duration

好的,现在让我们打开位于 src/app/ui/duration/duration.component.ts 的组件类,并应用以下更改:

import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewEncapsulation} from '@angular/core';
import {parseDuration} from '../../utilities/time-utilities';

@Component({
  selector: 'mac-duration',
  templateUrl: './duration.component.html',
  styleUrls: ['./duration.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class DurationComponent {
  @Input() duration: number;
  @Output() outDurationChange = new EventEmitter<number>();

  editSaved(formattedDuration: string) {
    this.outDurationChange.emit(parseDuration(formattedDuration));
  }
}

实际上这个组件并没有什么特别之处,因为我们已经创建了大部分逻辑,我们只是组合了一个高级组件。

对于 duration 输入,我们期望以毫秒为单位的时间长度,而 outDurationChange 输出属性将在用户输入一些内容时发出更新后的时间长度(毫秒)。

editSaved 方法用于绑定到底层的编辑组件。每当用户在编辑组件上保存他的编辑时,我们将这个输入,使用 parseDuration 函数将格式化的时间持续时间转换为毫秒,并使用 outDurationChange 输出属性重新发射转换后的值。

让我们看看我们的组件模板 src/app/ui/duration/duration.component.ts

<mac-editor [content]="duration | formatDuration"
            [showControls]="true"
            (outSaveEdit)="editSaved($event)"></mac-editor>

对我们模板的简单性感到惊讶吗?嗯,这正是我们通过建立良好的基础组件,使用高阶组件应该达到的效果。良好的组织结构极大地简化了我们的代码。我们在这里处理的就是我们那熟悉的编辑组件。

我们将我们的持续时间组件的 duration 输入属性绑定到编辑组件的 content 输入属性。由于我们希望传递格式化的时间持续时间而不是毫秒数,我们在绑定表达式中使用 formatDuration 管道进行转换。

如果编辑组件通知我们关于保存的编辑,我们在我们的持续时间组件上调用 editSaved 方法,这将解析输入的持续时间并将结果值重新发射为毫秒。

由于我们最初定义所有努力都由估计和有效持续时间组成,我们现在想创建另一个组件,该组件将这两个持续时间结合在一个组件中。

使用 Angular CLI 工具创建一个新的努力组件:

ng generate component --spec false -cd onpush efforts/efforts

打开生成的组件的模板文件,位于 src/app/efforts/efforts/efforts.component.html,并添加以下内容:

<div class="label">Estimated:</div>
<mac-duration [duration]="efforts?.estimated"
              (outDurationChange)="estimatedChange($event)"></mac-duration>
<div class="label">Effective:</div>
<mac-duration [duration]="efforts?.effective"
              (outDurationChange)="effectiveChange($event)"></mac-duration>
<button class="button small" (click)="addEffectiveHours(1)">+1h</button>
<button class="button small" (click)="addEffectiveHours(4)">+4h</button>
<button class="button small" (click)="addEffectiveHours(8)">+1d</button>

首先,我们添加两个持续时间组件,第一个用于收集估计时间的输入,后者用于有效时间。

此外,我们还提供了三个小按钮,通过简单的点击来增加有效持续时间。这样,用户可以快速添加一到四小时(半个工作日)或完整的工作日(我们定义为八小时)。

打开文件 src/app/efforts/efforts/efforts.component.ts 并实现组件类:

import {Component, ViewEncapsulation, Input, Output, EventEmitter, ChangeDetectionStrategy} from '@angular/core';
import {UNITS} from '../../utilities/time-utilities';
import {TimeEffort} from '../../model';

@Component({
  selector: 'mac-efforts',
  templateUrl: './efforts.component.html',
  styleUrls: ['./efforts.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class EffortsComponent {
  @Input() efforts: TimeEffort;
  @Output() outEffortsChange = new EventEmitter<TimeEffort>();

  estimatedChange(estimated: number) {
    this.outEffortsChange.emit({
      ...this.efforts,
      estimated
    });
  }

  effectiveChange(effective: number) {
    this.outEffortsChange.emit({
      ...this.efforts,
      effective
    });
  }

  addEffectiveHours(hours: number) {
    const hourMilliseconds = UNITS.find((unit) => unit.short === 'h').milliseconds;
    let effective = this.efforts && this.efforts.effective ? this.efforts.effective : 0;
    effective += hours * hourMilliseconds;

    this.outEffortsChange.emit({
      ...this.efforts,
      effective
    });
  }
}

组件提供了一个名为 efforts 的输入,以提供 TimeEfforts 对象。如果你再次查看组件模板,努力对象的 estimatedeffective 属性直接绑定到持续时间组件的输入属性。

estimatedChangeeffectiveChange 方法用于创建到相应持续时间组件的 outDurationChange 输出属性的绑定。我们在这里所做的一切就是发射一个包含有效和估计时间(以毫秒为单位)的聚合数据对象,使用 outEffortsChange 输出属性。

addEffectiveHours 方法用于模板中的快速按钮。我们通过将工作小时数的毫秒数乘以传递给函数的小时数来计算有效属性。我们使用来自时间实用模块的 UNITS 常量来获取每小时毫秒数。在计算了新的有效持续时间后,我们使用 outEffortsChange 输出来传播更新。

这就是我们提供用户输入以管理任务努力所需的所有内容。为了完成这个主题,我们将把新创建的努力组件添加到任务详情组件中,以便管理任务的努力。

让我们先看看位于 src/app/tasks/task-details/task-details.component.html 的任务详情组件模板中的代码更改:

<h3 class="title">Task Details of task #{{task.id}}</h3>
<div class="content">
  <div class="label">Title</div>
  <mac-editor [content]="task.title"
              [showControls]="true"
              (outSaveEdit)="updateTitle($event)"></mac-editor>
  <div class="label">Description</div>
  <mac-editor [content]="task.description"
              [showControls]="true"
              [tags]="tags"
              (outSaveEdit)="updateDescription($event)"></mac-editor>
  <div class="label">Efforts</div>
 <mac-efforts [efforts]="task.efforts"
 (outEffortsChange)="updateEfforts($event)">
 </mac-efforts>
</div>

我们只是简单地将新的努力组件包含在任务详情模板中。由于我们已经在任务对象上有了努力对象,我们可以创建必要的绑定,而无需在组件类中进行任何额外更改。然而,当用户更改估计或实际持续时间时更新努力,我们需要实现一个新的方法 updateEfforts

让我们打开位于 src/app/tasks/task-details/task-details.component.html 的组件类,并实现新的方法:

import {
  ChangeDetectionStrategy, Component, EventEmitter, Input, Output,
  ViewEncapsulation
} from '@angular/core';
import {Tag, Task, TimeEfforts} from '../../model';

@Component({
  selector: 'mac-task-details',
  templateUrl: './task-details.component.html',
  styleUrls: ['./task-details.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskDetailsComponent {
  …

  updateEfforts(efforts: TimeEfforts) {
 this.outUpdateTask.emit({
 ...this.task,
 efforts
 });
 }
}

我们添加了一个新的 updateEfforts 方法,用于处理由努力组件提供的输出。因为努力属性是任务对象的一部分,我们可以简单地重用 outUpdateTask 输出来委派更新。我们的容器组件已经准备好处理任务更新。

真棒!我们已经成功实现了一个无 UI 组件,并最好地使用了组合的概念。你现在应该能够在浏览器中预览更改并管理任务详情视图中的努力:

图片

由两个持续时间输入组件组成的新 Efforts 组件

努力时间线视觉表示

尽管我们迄今为止创建的用于管理努力的组件提供了编辑和显示努力和时间持续期的良好方式,但我们仍然可以通过一些视觉指示来改进这一点。

在本节中,我们将使用 SVG 创建一个视觉努力时间线。这个时间线应显示以下信息:

  • 作为灰色背景条的总体估计持续时间

  • 作为绿色条覆盖在总估计持续时间条上的总有效持续时间

  • 一个黄色条表示任何加班(如果实际持续时间大于估计持续时间)

以下两个图示说明了我们的努力时间线组件的不同视觉状态:

图片

当估计持续时间大于实际持续时间时的视觉状态

如果任务的实际时间将超过最初估计的时间,我们可以用不同的颜色显示加班时间:

当实际持续时间超过估计持续时间时的视觉状态(加班以黄色条形图显示)

让我们通过使用 Angular CLI 创建我们的组件占位符来具体化我们的视觉时间线组件:

ng generate component --spec false -cd onpush efforts/efforts-timeline

好的,我们首先来看位于 src/app/efforts/efforts-timeline/efforts-timeline.component.ts 的组件类,并实现必要的逻辑来渲染我们的 SVG 时间线:

import {Component, Input, ViewEncapsulation, ChangeDetectionStrategy, OnChanges, SimpleChanges} from '@angular/core';
import {TimeEfforts} from '../../model';

@Component({
  selector: 'mac-efforts-timeline',
  templateUrl: './efforts-timeline.component.html',
  styleUrls: ['./efforts-timeline.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class EffortsTimelineComponent implements OnChanges {
  @Input() efforts: TimeEfforts;

  done: number;
  overtime: number;

  ngOnChanges(changes: SimpleChanges) {
    this.done = 0;
    this.overtime = 0;

    if (
      !this.efforts.estimated && this.efforts.effective ||
      (this.efforts.estimated && this.efforts.estimated === this.efforts.effective)
    ) {
      this.done = 100;
    } else if (this.efforts.estimated < this.efforts.effective) {
      this.done = this.efforts.estimated / this.efforts.effective * 100;
      this.overtime = 100 - this.done;
    } else {
      this.done = this.efforts.effective / this.efforts.estimated * 100;
    }
  }
}

我们的组件只有一个输入。efforts 输入是一个 TimeEfforts 对象,我们应在图形时间线上渲染它。我们需要的其他所有属性都来自这个努力对象。

OnChanges 生命周期钩子中,我们设置了两个基于估计和实际时间的组件成员字段:

  • 完成度:这包含绿色条的宽度百分比,显示没有超过估计时间的有效持续时间

  • 加班:这包含黄色条的宽度百分比,显示任何超过估计时间的加班时间

让我们看看努力时间线组件的模板,看看我们现在如何使用 doneovertime 成员字段来绘制我们的时间线。

打开位于 src/app/efforts/efforts-timeline/efforts-timeline.component.ts 的模板文件,并应用以下更改:

<svg width="100%" height="10">
  <rect height="10"
        x="0" y="0" width="100%"
        class="remaining"></rect>
  <rect *ngIf="done" x="0" y="0" [attr.width]="done + '%'" height="10"
        class="done"></rect>
  <rect *ngIf="overtime" [attr.x]="done + '%'" y="0"
        [attr.width]="overtime + '%'" height="10"
        class="overtime"></rect>
</svg>

我们的模板是基于 SVG 的,它包含我们想要显示的每个条形图的三个矩形。我们努力时间线的背景条始终会显示。

在剩余条形图上方,我们根据计算出的宽度有条件地显示完成度和加班条形图。

这就是我们表示努力时间线所需的所有内容。现在,我们可以将努力时间线组件包含在我们的努力组件中。这样,当用户编辑估计或实际持续时间时,他们将获得视觉反馈,并有一个概览感。

让我们打开位于 src/app/efforts/efforts/efforts.component.html 的努力组件模板,并应用以下更改:

<div class="label">Estimated:</div>
<mac-duration [duration]="efforts?.estimated"
              (outDurationChange)="estimatedChange($event)"></mac-duration>
<div class="label">Effective:</div>
<mac-duration [duration]="efforts?.effective"
              (outDurationChange)="effectiveChange($event)"></mac-duration>
<button class="button small" (click)="addEffectiveHours(1)">+1h</button>
<button class="button small" (click)="addEffectiveHours(4)">+4h</button>
<button class="button small" (click)="addEffectiveHours(8)">+1d</button>
<mac-efforts-timeline *ngIf="efforts"
 [efforts]="efforts">
</mac-efforts-timeline>

由于我们在组件中已经有了努力对象,我们可以简单地创建一个绑定到努力时间线组件的 efforts 输入属性。

恭喜!你刚刚用这个最后的更改给蛋糕加上了樱桃。现在,我们的用户不仅可以通过我们的努力组件输入估计和实际工作时间,还能看到时间的可用性、已花费时间和加班的视觉反馈。在浏览器中启动应用程序,并稍微尝试一下新的努力组件。我相信你会喜欢我们刚刚创建的基于文本的无 UI 输入元素。让我们看看我们的努力时间线在实际操作中的截图:

显示我们新创建的努力时间线组件的 Efforts 组件(六小时加班以黄色条形图可视化)

努力管理回顾

在本节中,我们创建了允许用户轻松管理努力并添加简单但强大的时间跟踪到我们的任务的组件。为了实现这一点,我们做了以下工作:

  • 我们实现了一些实用函数来处理时间数学,以便将时间时长从毫秒转换为格式化时间时长,反之亦然

  • 我们创建了一个管道,使用我们的实用函数格式化以毫秒为单位的时间时长

  • 我们创建了一个时长 UI 组件,它封装了一个编辑器组件,并使用我们的时间工具来提供一个无 UI 的输入元素以输入时长

  • 我们创建了一个努力组件,它作为两个时长组件的组合,用于估计和有效时间,并提供额外的按钮以快速添加有效花费时间

  • 我们将努力组件集成到任务详情组件中,以便管理任务上的努力

  • 我们使用 SVG 创建了一个可视化的努力时间线组件,它显示任务的整体进度

摘要

在本章中,我们实现了一些组件,帮助我们的用户跟踪时间。他们现在可以在任务上记录努力并管理项目上的里程碑。我们创建了一个新的任务详情视图,可以通过任务列表上的导航链接访问。

使用具有适当封装的组件实现新功能时,突然变得如此简单,这不是很好吗?面向组件的开发的好处在于,你为新的功能开发时间随着你已创建的可重用组件数量的增加而减少。

再次体验了使用组件和重用现有组件的力量,我们能够轻松实现提供更复杂功能的高级组件。

在下一章中,我们将探讨如何使用图表库 Chartist 并创建一些包装组件,使我们能够构建可重用的图表。此外,我们还将为我们的任务管理系统构建一个仪表板,在那里我们将看到我们的图表组件在行动。

第九章:太空船仪表盘

当我还是个孩子的时候,我喜欢玩太空船飞行员。我把一些旧的纸箱堆起来,并装饰内部使其看起来像太空船驾驶舱。我用记号笔在箱子的内侧画了一个太空船仪表盘,我记得我在那里玩了好几个小时。

驾驶舱和太空船仪表盘的设计特别之处在于,它们需要在非常有限的空间内提供对整个太空船的概述和控制。我认为这同样适用于应用程序仪表盘。仪表盘应该为用户提供对整体状况的概述和感觉。

在本章中,我们将为我们的任务管理应用程序创建这样一个仪表盘。我们将利用开源图表库 Chartist 来创建外观美观、响应式的图表,并提供开放任务和项目状态的概述:

图片

我们将在本章中构建的任务图表预览

在更高层次上,在本章中我们将创建以下组件:

  • 项目摘要:这是提供对整体项目状态快速洞察的项目摘要。通过汇总项目中所有任务的投入,我们可以提供一个很好的整体投入状态,为此我们在上一章中创建了组件。

  • 项目活动图表:没有标签或刻度,这个柱状图将快速传达过去 24 小时内项目活动的感觉。

  • 项目任务图表:这个图表将提供项目任务进度的概述。我们将使用折线图显示一定时间内的开放任务数量。通过我们在第二章“准备,出发!”中创建的切换组件,我们将为用户提供一个简单的方法来切换图表上显示的时间范围。

Chartist 简介

在本章中,我们将创建一些组件来渲染图表,并且我们应该寻求一些帮助来渲染它们。当然,我们可以采用与我们在第六章“跟上活动”中所使用的类似方法,当时我们绘制了活动时间线。然而,当涉及到更复杂的数据可视化时,最好依赖于库来完成繁重的工作。

我们将使用一个名为 Chartist 的库来填补这个空白,这并不令人惊讶,因为我几乎花了两年时间来编写它。作为 Chartist 的作者,我感到非常幸运,我们在这本书中找到了一个完美的位置来利用它。

在我们深入实现仪表盘组件之前,我想借此机会简要介绍一下 Chartist。

Chartist 声称提供简单的响应式图表。幸运的是,在存在三年之后,这仍然是事实。我可以告诉你,维护这个库最困难的工作可能是保护它免受功能膨胀的影响。开源社区中有许多伟大的运动、技术和想法,要抵制库范围的膨胀并始终专注于初始声明并不容易。

让我向您展示一个非常基础的例子,说明您如何在网站上包含 Chartist 脚本后创建简单的折线图:

const chart = new Chartist.Line('#chart', { 
  labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], 
  series: [ 
    [10, 7, 2, 8, 5] 
  ] 
}); 

为此示例所需的相应 HTML 标记如下所示:

<body> 
<div id="chart" class="ct-golden-section"></div> 
</body> 

下图显示了 Chartist 生成的图表:

使用 Chartist 生成的简单折线图

我认为,通过说我们将坚持简单,我们并没有承诺太多。

让我们看看 Chartist 的第二个核心关注点,即完美响应。Chartist 尽可能地坚持清晰的关注点分离,这意味着它使用 CSS 来控制外观,SVG 来控制基本的图形结构,JavaScript 来控制任何行为。通过遵循这个原则,我们已经实现了很多响应性。我们可以使用 CSS 媒体查询在不同的媒体上为我们的图表应用不同的样式。

虽然 CSS 对于视觉样式来说很棒,但在渲染图表的过程中有很多元素,遗憾的是,这些元素无法通过 CSS 控制。毕竟,这就是我们使用 JavaScript 库来渲染图表的原因。

那么,如果我们无法在 CSS 中控制这一点,我们如何控制 Chartist 在不同媒体上渲染图表的方式呢?嗯,Chartist 提供了称为 响应式配置覆盖 的功能。使用浏览器的 matchMedia API,Chartist 能够提供一个配置机制,允许您指定仅在特定媒体(移动、桌面等)上使用的选项。

让我们看看如何使用移动优先的方法轻松实现响应式行为的一个简单例子:

const chart = new Chartist.Line('#chart', { 
  labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], 
  series: [ 
    [10, 7, 2, 8, 5]
  ]
}, {
  showPoint: true,
  showLine: true
}, [
  ['screen and (min-width: 400px)', {
    showPoint: false
  }],
  ['screen and (min-width: 800px)', {
    lineSmooth: false
  }]
]);

在这个例子中,Chartist.Line 构造函数的第二个参数设置初始选项;我们可以提供覆盖选项,以数组形式通过构造函数的第三个参数使用媒体查询进行注释。在这个例子中,我们将覆盖宽度大于 400 像素的任何媒体的 showPoint 选项。宽度大于 800 像素的媒体将同时接收 showPoint 覆盖和 lineSmooth 覆盖。

我们不仅可以通过指定真实的媒体查询来触发设置更改,还可以使用与 CSS 非常相似的覆盖机制。这样,我们可以实现各种方法,如范围或排他性媒体查询、移动优先或桌面优先。响应式选项机制可以用于 Chartist 中所有可用的选项:

在三种不同的媒体上显示前一个图表,从左到右,包括小于 400 像素的媒体(A)、小于 800 像素的媒体(B)和大于 800 像素的媒体(C)

如你所见,使用 Chartist 实现复杂的响应式行为非常简单。尽管我们的任务管理应用原本并不打算成为一个响应式网络应用,但我们仍然可以从中受益,以优化我们的内容。

关于 Chartist 的介绍就到这里,如果你想了解更多关于我的库的信息,我建议你查看项目的网站gionkunz.github.io/chartist-js。在网站上,你还可以访问实时示例页面gionkunz.github.io/chartist-js/examples.html,在那里你可以直接在浏览器中修改一些图表。

项目仪表板

在本章中,我们将创建一个项目仪表板,它将包括以下组件:

  • 项目仪表板:这是仪表板中的主要组件,代表了我们整个仪表板视图。它是其他组件的组合。

  • 项目摘要:这是我们展示每个项目摘要的地方,我们将概述最重要的信息。我们的项目摘要组件还将包括一个活动图表组件,用于可视化项目活动。

  • 项目仪表板容器:我们还需要创建一个新的容器组件,以便将我们的新组件树暴露给路由器,并将其连接到我们的数据库。

  • 任务图表:这是我们提供开放任务随时间视觉概述的地方。所有项目将以折线图的形式表示,显示开放任务的进度。我们还将提供一些用户交互功能,以便用户可以选择不同的时间段。

  • 活动图表:此组件在 24 小时的时间范围内以条形图的形式可视化活动。这将帮助我们的用户快速识别整体项目活动和峰值。

创建项目仪表板组件

让我们遵循我们确立的传统,首先建模我们将在组件中使用的数据。我们希望创建一个新的接口来总结项目。这包括项目数据、任务、活动和简短描述。

打开位于 src/app/model.ts 的模型文件,并添加以下接口:

…

export interface ProjectSummary {
 project: Project;
 description: string;
 tasks: Task[];
 activities: ProjectActivity[];
}

使用此界面,我们可以将所有与项目相关的数据聚合到一个单一的对象中,这极大地简化了我们的开发工作。

让我们继续前进,创建我们的主要仪表板组件。项目仪表板组件负责组合主要仪表板布局,通过包含我们的仪表板子组件。它将我们项目仪表板中的所有部件组合在一起。

让我们使用 Angular CLI 工具创建我们的新项目仪表板组件:

ng generate component --spec false -ve none -cd onpush projects-dashboard/projects-dashboard

打开位于路径src/app/projects-dashboard/projects-dashboard/projects-dashboard.component.ts的生成组件类,并用以下代码替换占位符代码:

import {Component, ViewEncapsulation, ChangeDetectionStrategy, Input, EventEmitter, Output} from '@angular/core';
import {Project, ProjectSummary} from '../model';

@Component({
  selector: 'mac-projects-dashboard',
  templateUrl: './projects-dashboard.component.html',
  styleUrls: ['./projects-dashboard.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None
})
export class ProjectsDashboardComponent {
  @Input() projectSummaries: ProjectSummary[];
  @Output() outActivateProject = new EventEmitter<Project>();

  activateProject(project: Project) {
    this.outActivateProject.emit(project);
  }
}

我们的仪表板组件接受一个projectSummaries输入,这是一个符合我们刚刚在TypeScript模型文件中创建的ProjectSummary接口的项目摘要对象列表。

用户可以通过点击项目摘要组件来激活一个项目。我们的项目仪表板组件使用输出outActivateProject将事件委派给容器,这是我们稍后要创建的。

让我们看看组件的视图,并将文件src/app/projects-dashboard/projects-dashboard/projects-dashboard.component.html中的内容更改为以下内容:

<header class="dashboard-header">
  <h2 class="dashboard-title">Dashboard</h2>
</header>
<div class="dashboard-main">
  <h3 class="dashboard-sub-title">Projects>h3>
 <ul class="dashboard-list">
    <li *ngFor="let projectSummary of projectSummaries"
        class="dashboard-list-item">
      <strong>projectSummary.project.title</strong>
      <p>projectSummary.description</p>
    </li>
  </ul>
</div>

目前,我们只显示了项目标题和我们将计算的项目摘要对象中的描述。在下一节中,我们将创建一个新的项目摘要组件,该组件将处理一些更复杂的渲染。

项目摘要组件

在本节中,我们将创建一个项目摘要组件,该组件将为项目提供一些概述信息。在我们的项目仪表板组件树中的容器组件中,我们将确保汇总所有必要的信息来总结项目。我们的项目摘要 UI 组件渲染项目摘要对象中提供的数据,以创建看起来很棒的项目概览卡片。

让我们使用 Angular CLI 工具开始构建我们的组件:

ng generate component --spec false -ve none -cd onpush projects-dashboard/project-summary

让我们打开位于src/app/projects-dashboard/project-summary/project-summary.component.ts的组件类,并用以下代码替换其内容:

import {ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges, ViewEncapsulation} from '@angular/core';
import {ProjectSummary, TimeEfforts} from '../../model';

@Component({
  selector: 'mac-project-summary',
  templateUrl: './project-summary.component.html',
  styleUrls: ['./project-summary.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None
})
export class ProjectSummaryComponent implements OnChanges {
  @Input() projectSummary: ProjectSummary;

  totalEfforts: TimeEfforts;

  ngOnChanges(changes: SimpleChanges) {
    if (changes.projectSummary && this.projectSummary) {
      this.totalEfforts = this.projectSummary.tasks.reduce((totalEfforts, task) => {
        if (task.efforts) {
          totalEfforts.estimated += task.efforts.estimated || 0;
          totalEfforts.effective += task.efforts.effective || 0;
        }

        return totalEfforts;
      }, {
        estimated: 0,
        effective: 0
      });
    }
  }
}

projectSummary输入允许将项目摘要对象传递到我们的新 UI 组件中。在那里,我们有显示项目概览所需的所有必要信息。

如果您还记得上一章,第八章,《时间会证明》,我们已经使用 SVG 创建了一个不错的努力时间线组件。现在,在我们的项目摘要中,我们希望重用这个组件。然而,我们需要从我们项目的底层任务中计算总工作量。

我们需要做的是将所有任务工作量累积到一个总工作量中。使用Array.prototype.reduce函数,我们可以相对容易地累积所有任务工作量。我们依赖于项目摘要对象中存在的任务,该对象通过输入projectSummary传递给我们。由于我们希望在项目信息更改时重新计算总工作量,我们可以使用OnChanges生命周期钩子并实现ngOnChanges方法。

让我们创建组件的模板,看看我们将如何使用总工作量数据来显示我们的努力时间线组件。打开路径src/app/projects-dashboard/project-summary/project-summary.component.ts的文件,并添加以下内容:

<div class="summary-title">{{projectSummary.project.title}}</div>
<div class="summary-description">
  {{projectSummary.description}}
</div>
<div class="summary-label">Total Efforts</div>
<mac-efforts-timeline [efforts]="totalEfforts">
</mac-efforts-timeline>
<p>{{totalEfforts | formatEfforts}}</p>

在显示项目标题和项目摘要的描述之后,我们包含了我们的努力时间线组件。我们只需将我们的计算出的 totalEfforts 传递到 efforts 输入中,努力时间线组件就会负责渲染。这个时间线现在将显示在给定项目的所有任务上记录的总努力量。

除了时间线之外,我们还渲染了格式化的努力文本,就像我们在上一章的努力组件中已经渲染的那样。为此,我们使用了 formatEfforts 管道。

现在,我们仍然需要将我们的项目摘要组件集成到项目仪表板组件中。

让我们打开位于 src/app/projects-dashboard/projects-dashboard/projects-dashboard.component.html 的项目仪表板模板,并修改模板以包含我们的项目摘要组件:

<header class="dashboard-header">
 <h2 class="dashboard-title">Dashboard</h2>
</header>
<div class="dashboard-main">
  <h3 class="dashboard-sub-title">Projects</h3>
  <ul class="dashboard-list">
    <li *ngFor="let projectSummary of projectSummaries"
        class="dashboard-list-item">
 <mac-project-summary [projectSummary]="projectSummary"
 (click)="activateProject(projectSummary.project)">
 </mac-project-summary>
    </li>
  </ul>
</div>

我们可以直接将项目摘要对象转发到我们新创建的项目摘要组件。此外,我们在项目摘要组件上添加了一个点击事件绑定,这将触发项目仪表板组件上的 activateProject 方法。这将允许我们在容器组件内实现程序化导航,这是接下来要实现的功能:

图片

一个显示两个项目摘要组件的项目仪表板,包含汇总的总努力量

好吧;到目前为止,一切顺利。我们创建了两个新的 UI 组件,并重用了我们的努力时间线组件来创建总任务努力的汇总视图。现在,是时候通过创建一个新的容器组件并配置应用程序的路由来集成我们的组件了。

集成项目仪表板

我们已经创建了初始的项目仪表板组件,并将开始将它们集成到我们的应用程序中。我们需要一个新的容器组件,我们也会在路由配置中公开它。我们还需要更新应用程序的导航组件,以便显示指向仪表板视图的新导航链接。

让我们从我们的新容器组件开始,并使用 Angular CLI 工具为其创建占位符:

ng generate component --spec false -ve none -cd onpush container/projects-dashboard-container

打开位于 src/app/container/projects-dashboard-container/projects-dashboard-container.component.ts 的生成组件类,并用以下代码替换其内容:

import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {ProjectService} from '../../project/project.service';
import {Observable, combineLatest} from 'rxjs';
import {Project, ProjectSummary} from '../../model';
import {map} from 'rxjs/operators';
import {Router} from '@angular/router';
import {ActivitiesService} from '../../activities/activities.service';
import {TaskService} from '../../tasks/task.service';
import {limitWithEllipsis} from '../../utilities/string-utilities';

@Component({
  selector: 'mac-projects-dashboard-container',
  templateUrl: './projects-dashboard-container.component.html',
  styleUrls: ['./projects-dashboard-container.component.css'],
  encapsulation: ViewEncapsulation.None,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectsDashboardContainerComponent {
  projectSummaries: Observable<ProjectSummary[]>;

  constructor(private projectService: ProjectService,
              private taskService: TaskService,
              private activitiesService: ActivitiesService,
              private router: Router) {
    this.projectSummaries = combineLatest(
      this.projectService.getProjects(),
      this.taskService.getTasks(),
      this.activitiesService.getActivities()
    ).pipe(
      map(([projects, tasks, activities]) =>
        projects
          .map(project => ({
            project,
            description: limitWithEllipsis(project.description, 100),
            tasks: tasks.filter(task => task.projectId === project.id),
            activities: activities.filter(activity => activity.projectId === project.id)
          }))
      )
    );
  }

  activateProject(project: Project) {
    this.router.navigate(['/projects', project.id]);
  }
}

我们新创建的容器负责收集创建项目摘要对象列表所需的所有信息。我们使用 RxJS 可观察对象创建一个反应性流的项目摘要对象。RxJS 工具 combineLatests 允许我们将项目、任务和活动合并到一个单一流中。在这个合并流中,我们使用 map 操作符为从项目服务中获取的每个项目创建一个项目摘要对象。

我们正在使用我们的limitWithEllipsis辅助函数将项目描述转换为(如果需要)截断版本,并将其直接添加到我们的项目摘要对象中。

我们还将在我们的容器组件中注入路由,并使用它来进行程序性导航到项目视图。我们已经实现了一个名为activateProject的方法,我们将从我们的视图中调用它。

让我们也更改容器组件的模板,其中我们想要渲染项目仪表板 UI 组件,并创建必要的绑定来将我们的项目摘要数据传递到 UI 组件树中。打开文件src/app/container/projects-dashboard-container/projects-dashboard-container.component.html,并用以下代码替换其内容:

<mac-projects-dashboard
  [projectSummaries]="projectSummaries | async"
  (outActivateProject)="activateProject($event)">
</mac-projects-dashboard>

我们需要做的只是渲染我们的项目仪表板 UI 组件。我们将生成的项目摘要对象传递到组件输入中。由于我们使用可观察流实现了这一点,我们需要使用async管道。

当项目在项目仪表板 UI 组件中被激活时,我们收到一个outActivateProject输出事件,然后我们可以使用它来调用我们的activateProject方法。在那里,我们使用路由导航到指定的项目视图。

好的;现在,我们已经准备好了所有组件来渲染我们的项目仪表板。还有两件事要做。我们需要配置我们的路由来激活我们新创建的容器组件,并在我们的应用程序根组件中创建一个新的导航项。

让我们从路由配置开始。打开位于src/app/routes.ts的路由配置文件,并应用以下更改:

…

import {ProjectsDashboardContainerComponent} from './container/projects-dashboard-container/projects-dashboard-container.component';

export const routes: Route[] = [{
 path: 'dashboard',
 component: ProjectsDashboardContainerComponent
}, {
  path: 'projects/:projectId',
  component: ProjectContainerComponent,
  canActivate: [ProjectContainerGuard],
  children: [{
    path: 'tasks',
    component: TaskListContainerComponent
  }, {
    path: 'tasks/:taskId',
    component: TaskDetailsContainerComponent
  }, {
    path: 'comments',
    component: ProjectCommentsContainerComponent
  }, {
    path: 'activities',
    component: ProjectActivitiesContainerComponent
  }, {
    path: '**',
    redirectTo: 'tasks'
  }]
}, {
  path: '',
  pathMatch: 'full',
  redirectTo: '/dashboard'
}];

我们添加了一个新的路由配置来激活我们的项目仪表板容器组件。此外,我们将默认的重定向 URL 更改为重定向到我们的仪表板,而不是第一个项目详情视图。

好的;让我们继续前进,并使用我们的新路由在我们的应用程序根组件中创建一个导航项。打开位于src/app/app.component.html的应用组件模板,并执行以下更改:

<aside class="side-nav">
 <mac-user-area [user]="user | async"
                 [openTasksCount]="openTasksCount | async">
  </mac-user-area>
  <mac-navigation>
    <mac-navigation-section title="Main">
 <mac-navigation-item title="Dashboard"
 navId="dashboard"
 routerLinkActive="active"
 [routerLink]="['/dashboard']">
 </mac-navigation-item>
 </mac-navigation-section>
    <mac-navigation-section title="Projects">
      <mac-navigation-item *ngFor="let project of projects | async; trackBy: trackByProjectId"
                           [navId]="project.id"
                           [title]="project.title"
                           routerLinkActive="active"
                           [routerLink]="['/projects', project.id]">
      </mac-navigation-item>
    </mac-navigation-section>
  </mac-navigation>
</aside>
<main class="main">
  <router-outlet></router-outlet>
</main>

太棒了!你已经完成了集成我们第一套项目仪表板组件所需的所有步骤。你现在可以在浏览器中预览你的更改。在应用程序的主要导航中应该有一个新的导航项可用。另外,当你启动应用程序时,你应该自动被重定向到仪表板视图。

你现在可以尝试玩转努力聚合,并尝试修改项目的新的任务努力,看看摘要会如何受到影响。

在下一节中,我们将用漂亮的 Chartist 图表丰富我们的项目摘要组件。

创建你的第一个图表

在本节中,我们将使用 Chartist 创建我们的第一个图表,以提供过去 24 小时的项目活动概述。这个条形图将只提供一些关于项目活动的视觉线索,我们的目标不是提供详细的信息。因此,我们将配置它隐藏任何标签、刻度和网格线。唯一可见的部分应该是条形图的条形。

处理活动数据

在我们开始创建活动图本身之前,我们需要看看我们应该如何转换和准备我们的数据以供图表使用。

让我们看看我们系统中已有的数据。就活动而言,它们都在time字段中存储了一个时间戳。然而,对于我们的图表来说,一个时间戳列表是不够的。我们想要的是一个显示过去 24 小时内每小时一个条形的图表。每个小时的条形应该代表该时间段内的活动数量。

下面的插图显示了我们的源数据,基本上是活动事件的时序流。在下面的箭头上,我们可以看到我们需要为我们的图表结束的数据:

图片

一幅显示活动作为时间流的插图,其中点代表活动。通过将事件光栅化到一小时的切片中,我们得到我们称之为光栅化计数的东西,如底部箭头所示。

首先,我们将在我们的应用程序模型中引入一个新的接口。我们希望在一个值列表中表示一个数据值,该值正在被光栅化。

我们使用“光栅化”这个术语来描述将底层数据采样到光栅上的过程。这与数字相机如何采样光线的光子并将它们累积在称为像素的光栅上非常相似。

由于我们不仅想为光栅化使用时间戳,而且根据情况不同,对数据值进行不同的加权,因此我们将引入以下接口到我们的模型中,位于src/app/model.ts

…

export interface RasterizationData {
  time: number;
  weight: number;
}

在前面的图中,我们计算了给定小时内所有的活动并将它们加起来。然而,我们需要一个更具体的解决方案,允许我们在计数时加入权重。这特别有用,如果你想使某些活动比其他活动更重要。通过在光栅化过程的数据输入上使用一个名为weight的属性,我们可以完成加权计数。实际上,我们不再在timeframe内计数数据值;我们正在将它们的权重加起来,以获得给定timeframe的总权重。这允许我们使用负权重,这将从总权重中减去。这对于我们将在稍后阶段创建的第二张图表将非常重要。

让我们实现执行概述数据转换的函数。我们将把这个函数添加到我们的时间实用模块中,位于src/app/utilities/time-utilities.ts

…

export function rasterize(
  timeData: RasterizationData[],
  timeFrame: number,
  quantity: number,
  now: number = +new Date(),
  fill: number = 0): number[] {

  now = Math.floor(now / timeFrame) * timeFrame;

  return timeData.reduce((rasterized: number[], data: RasterizationData) => {
    const index = Math.ceil((now - data.time) / timeFrame);
    if (index < quantity) {
      rasterized[index] = (rasterized[index] || 0) + data.weight;
    }
    return rasterized;
  }, <number[]>Array.from({length: quantity}).fill(fill)).reverse();
}

让我们简要地看看我们新创建的函数的输入参数:

  • timeData:此参数预期是一个包含一个time属性,设置为应计数的事件的戳记的光栅化数据对象数组。对象还应包含一个weight属性,用于计数。使用此属性,我们可以将一个事件计为两个,甚至可以计数负值以减少光栅化帧中的计数。

  • timeFrame:此参数指定每个光栅化帧的时间跨度,以毫秒为单位。如果我们想有 24 个光栅化帧,每个帧包含一小时,则此参数需要设置为 3,600,000(1 小时=60 分钟=3,600 秒=3,600,000 毫秒)。

  • quantity:此参数设置输出数组中应存在的光栅化帧的数量。在每小时 24 帧的情况下,此参数应设置为 24。

  • now:此参数应设置为光栅化过程开始的时间点。光栅化将始终向后移动时间,因此这个时间标记了我们的光栅化结束时间。now参数应是一个以毫秒为单位的时间戳。

  • fill:这是我们指定我们希望如何初始化我们的光栅化输出数组的方式。在我们的活动计数中,我们希望将其设置为零。

我们刚刚创建的函数对于创建活动图表是必要的。转换帮助我们为图表的输入数据准备项目活动。

创建活动图表

是时候使用 Chartist 创建我们的第一个图表组件了!然而,在我们进入组件之前,我们需要确保 Chartist 已安装在我们的项目中。让我们使用 npm 将 Chartist 作为依赖项安装:

npm install chartist@0.11.0 @types/chartist@0.9.40 --save

Chartist 目前是用纯 JavaScript 编写的。幸运的是,社区为 Chartist 创建了一个非常复杂的类型定义,因此您可以在使用 TypeScript 与 Chartist 一起使用时受益于类型化接口。

好吧;这就是我们开始使用 Chartist 所需的所有内容。现在,让我们使用 Angular CLI 创建我们的活动图表:

ng generate component --spec false -ve none -cd onpush projects-dashboard/activity-chart

让我们打开位于路径src/app/projects-dashboard/activity-chart/activity-chart.component.html上生成的模板,并用以下代码替换其内容:

<div #chartContainer></div> 

由于我们将所有渲染工作都交给了 Chartist,这实际上是我们所需要的全部。Chartist 需要一个元素作为容器来创建图表。我们设置了一个chartContainer局部视图引用,这样我们就可以从我们的组件中引用它并将其传递给 Chartist。

让我们继续创建图表。打开位于src/app/projects-dashboard/activity-chart/activity-chart.component.ts的组件类,并添加以下代码:

import {
  Component, ViewEncapsulation, ViewChild, ElementRef, Input, ChangeDetectionStrategy
} from '@angular/core';
import * as Chartist from 'chartist';
import {IChartistBarChart} from 'chartist';
import {Activity} from '../../model';

@Component({
  selector: 'mac-activity-chart',
  templateUrl: './activity-chart.component.html',
  styleUrls: ['./activity-chart.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None
})
export class ActivityChartComponent {
  @Input() activities: Activity[];
  @ViewChild('chartContainer') chartContainer: ElementRef;

  chart: IChartistBarChart;
}

除了从 Angular 核心模块导入之外,我们还导入了 Chartist 命名空间对象以及接口IChartistBarChart

我们的组件接受一个活动列表作为输入,我们将使用我们新的光栅化函数对其进行转换。然后,使用 Chartist 可视化转换后的数据。我们使用一个成员,chart,来存储创建后的 Chartist 实例。

使用ViewChild装饰器,我们从组件视图中获取 DOM 元素,该元素将用作创建图表的容器。

让我们继续通过添加一个方法将活动列表转换为 Chartist 可以处理的内容。在同一个组件类文件中,追加以下方法:

import {
  Component, ViewEncapsulation, ViewChild, ElementRef, Input, ChangeDetectionStrategy
} from '@angular/core';
import * as Chartist from 'chartist';
import {IChartistBarChart, IChartistData} from 'chartist';

import {rasterize, UNITS} from '../../utilities/time-utilities';
import {Activity, RasterizationData} from '../../model';

@Component({
  selector: 'mac-activity-chart',
  templateUrl: './activity-chart.component.html',
  styleUrls: ['./activity-chart.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None
})
export class ActivityChartComponent {
  @Input() activities: Activity[];
  @ViewChild('chartContainer') chartContainer: ElementRef;

  chart: IChartistBarChart;

 createChartData(): IChartistData {
 const timeData: RasterizationData[] = this.activities.map((activity) => {
 return {
 time: activity.time,
 weight: 1
 };
 });

 return {
 series: [
 rasterize(
 timeData,
 UNITS.find((unit) => unit.short === 'h').milliseconds,
 24,
 +new Date())
 ]
 };
 }
}

createChartData方法中,我们首先从我们的组件输入提供的活动列表创建一个光栅化数据对象列表。我们可以使用活动时间作为时间戳,并且我们所有的活动目前计数相同,所以我们使用固定的权重1

现在,我们希望提取过去 24 小时内每小时的活动数量。我们可以使用我们的rasterize函数,并带上必要的参数,将我们的活动转换为正好是那种格式的。rasterize函数将始终返回一个数字列表,表示在所需时间段内的活动数量。与光栅化数据一起,我们传递一个小时的毫秒数,总共 24 帧,以及当前时间作为时间起点。

我们的方法返回一个IChartistData类型的对象,其中包含我们想要使用 Chartist 可视化的数据。我们的rasterize函数的输出正好是表示 Chartist 中数据系列的正确格式。

让我们添加剩余的代码以完成我们的组件类。代码更改以粗体显示,而省略号字符表示无关的、隐藏的代码:

import {
  Component, ViewEncapsulation, ViewChild, ElementRef, Input, ChangeDetectionStrategy,
  OnChanges, AfterViewInit
} from '@angular/core';
…

…
export class ActivityChartComponent implements OnChanges, AfterViewInit {
  @Input() activities: Activity[];
  @ViewChild('chartContainer') chartContainer: ElementRef;

  chart: IChartistBarChart;

  ngOnChanges() {
 this.createOrUpdateChart();
 }

 ngAfterViewInit() {
 this.createOrUpdateChart();
 }

  createOrUpdateChart() {
 if (!this.activities || !this.chartContainer) {
 return;
 }

 const data = this.createChartData();

 if (this.chart) {
 this.chart.update(data);
 } else {
 this.createChart(data);
 }
 }

  createChart(data: IChartistData) {
 this.chart = new Chartist.Bar(this.chartContainer.nativeElement, data, {
 width: '100%',
 height: 60,
 axisY: {
 onlyInteger: true,
 showGrid: false,
 showLabel: false,
 offset: 0
 },
 axisX: {
 showGrid: false,
 showLabel: false,
 offset: 0
 },
 chartPadding: {
 top: 0,
 right: 0,
 bottom: 0,
 left: 0
 }
 });

 this.chart.on('draw', (context) => {
 if (context.type === 'bar' && context.value.y === 0) {
 context.element.attr({
 y2: context.y2 - 1
 });
 }
 });
 }

  …
}

让我们更详细地查看代码,并逐步进行。

createChart方法使用传递给方法的数据创建一个新的图表实例。为了创建一个新的条形图,我们可以使用Chartist.Bar构造函数。作为第一个参数,我们传递容器视图子元素的 DOM 元素。Chartist 将在该容器元素中创建我们的图表。第二个参数是我们的数据,它来自我们的方法参数。在图表选项中,我们将设置一切以实现一个非常简单的图表外观,没有任何详细的信息。我们禁用网格,隐藏标签,并移除任何填充。

此外,我们使用 Chartist 的绘制事件来控制零值条形的绘制方式。默认情况下,Chartist 在条形的值为零时不会绘制条形。我们可以通过实现自己的自定义绘制事件逻辑来控制并更改此行为。

createOrUpdateChart 方法检查图表是否已经创建,并且只有在我们需要创建新的图表实例时才需要更新。这大大简化了我们的处理。由于我们从 AfterViewInitOnChanges 生命周期钩子中被调用,我们需要确保在继续之前 chartContainer 视图子组件和 activities 输入都已就绪。

如果 chart 成员已经设置为之前创建的图表,我们可以使用 Chartist 实例上的 update 函数来使用新数据更新它。如果没有图表对象,我们需要创建一个新的图表。我们可以简单地调用我们的 createChart 方法。

这太棒了!我们已经使用 Chartist 创建了我们的第一个图表组件!现在,我们可以回到我们的项目摘要组件,并在其中集成活动图表,以提供活动概述。打开位于文件 src/app/projects-dashboard/project-summary/project-summary.component.html 的项目摘要组件模板,并应用以下更改:

<div class="summary-title">{{projectSummary.project.title}}</div>
<div class="summary-description">
  {{projectSummary.description}}
</div>
<div class="summary-label">Total Efforts</div>
<mac-efforts-timeline [efforts]="totalEfforts">
</mac-efforts-timeline>
<p>{{totalEfforts | formatEfforts}}</p>
<div class="summary-label">Activity last 24 hours</div>
<mac-activity-chart [activities]="projectSummary.activities"></mac-activity-chart>

我们在现有模板的底部添加了我们的活动图表组件。我们还创建了必要的绑定,将我们已经在项目摘要对象上可用的活动传递到组件中。

恭喜!您已成功将 Chartist 库集成到您的项目中,并使用它在我们项目摘要组件中可视化项目活动。

在下一节中,我们将更深入地探讨 Chartist 的图表功能,并使用 Angular 提供一些交互性。

可视化开放任务

在本节中,我们将使用 Chartist 创建一个折线图组件,该组件将显示项目随时间推移的开放任务进度。为此,我们将使用具有特定插值的折线图,它提供量化步骤,而不是直接连接点的线条。

我们还将提供一些交互性,使用户能够通过切换按钮来切换显示的时间范围。这将允许我们重用我们在第二章,“准备,设置,出发!”中创建的切换 UI 组件。

准备任务数据

首先,让我们看看我们系统中关于项目任务可用的数据。created 属性设置为任务创建时的戳记。然而,如果一个任务被标记为已完成,我们目前并不保存那一刻的戳记。为了生成我们想要的图表,我们需要知道任务何时完成。

让我们在任务模型上引入一个新的属性来达到这个目的。打开位于 src/app/model.tsmodel 文件,并应用以下更改:

export interface Task {
  readonly id?: number;
  readonly projectId?: number;
  readonly title: string;
  readonly description?: string;
  readonly done: boolean;
  readonly order: number;
  readonly created: number;
 readonly completed?: number;
  readonly efforts?: TimeEfforts;
}

…

现在,我们需要确保在任务完成时在正确的时间设置completed属性。让我们打开位于src/app/tasks/task/task.component.ts的任务组件类,并应用以下更改。只修改以下代码摘录中突出显示的部分:

…
export class TaskComponent {
  …

  updateTask(done: boolean) {
    this.outUpdateTask.emit({
      ...this.task,
      done,
 completed: done ? +new Date() : this.task.completed
    });
  }

  …
}

好的;为了完成这个练习,我们还想更新内存数据库中的初始数据,以反映一个更准确的场景。打开文件src/app/database.ts,并应用以下更改。同样,只更改突出显示的部分:

import {InMemoryDbService} from 'angular-in-memory-web-api';
import {Activity, Project, Task, User} from './model';

export class Database implements InMemoryDbService {
  createDb() {
    …

    const hour = 3600000;
    const tasks: Task[] = [
      {id: 1, projectId: 1, title: 'Task 1', done: false, order: 1, created: +new Date() - hour * 8},
      {id: 2, projectId: 1, title: 'Task 2', done: false, order: 2, created: +new Date() - hour * 6},
      {id: 3, projectId: 1, title: 'Task 3', done: true, order: 3, created: +new Date() - hour * 12, completed: +new Date() - hour * 3},
      {id: 4, projectId: 1, title: 'Task 4', done: false, order: 4, created: +new Date() - hour * 20}
    ];

    …
  }
}

问题建模

首先,让我们思考一下如何显示随时间变化的打开任务数量的问题。因为我们只对任何给定时间的打开任务数量感兴趣,我们可以使用一个模型,其中我们将所有任务放在一个单独的时间线上,并且我们只关心任务创建或完成的事件。让我们通过以下插图来更好地理解这个问题:

图片

一幅插图展示了我们如何使用创建和完成的事件来表示所有任务的时间线,其中创建的事件计为+1,而完成的事件计为-1。

下面的箭头表示时间线上创建和完成事件的全部任务。我们现在可以使用这些信息作为输入到我们的rasterize函数中,以获取我们图表所需的数据。因为用作函数输入的栅格化数据对象也支持weight属性,我们可以使用它来表示创建(+1)或完成(-1)的事件。

我们需要对我们的rasterize函数进行一些小的修改。到目前为止,rasterize函数只在帧中一起计算事件。然而,对于打开任务计数,我们将查看随时间的累积。如果任务计数发生变化,我们需要保持该值,直到它再次变化。在上一个章节中活动的转换中,我们没有使用这种相同的逻辑。在那里,我们只计算帧内的事件,但没有累积。

让我们通过以下插图来查看与我们在处理活动时应用的栅格化之间的差异:

图片

一幅插图展示了我们如何随着时间的推移累积打开任务的数量。

我们可以随着时间的推移一起计算栅格化数据对象(事件)的每个weight属性。如果累积值发生变化,我们将当前累积值写入栅格化输出数组。

让我们将这个累积功能实现到我们的rasterize函数中。打开时间实用模块,路径为src/app/utilities/time-utilities.ts,并应用以下更改:

export function rasterize(
  timeData: RasterizationData[],
  timeFrame: number,
  quantity: number,
  now: number = +new Date(),
  fill: number = 0,
 accumulate: boolean = false): number[] {

  now = Math.floor(now / timeFrame) * timeFrame;
  let accumulatedValue = 0;

  if (accumulate) {
 timeData = timeData.slice().sort((a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
 }

  return timeData.reduce((rasterized: number[], data: RasterizationData) => {
    accumulatedValue += data.weight;
    const index = Math.ceil((now - data.time) / timeFrame);
    if (index < quantity) {
      rasterized[index] = accumulate ? accumulatedValue : (rasterized[index] || 0) + data.weight;
    }
    return rasterized;
  }, <number[]>Array.from({length: quantity}).fill(fill)).reverse();
}

让我们回顾一下我们对rasterize函数所做的更改。

首先,我们向我们的函数中添加一个新的参数,命名为 accumulate。我们使用 ES6 默认参数来设置参数为 false,如果函数被调用时没有传入任何值。

我们定义一个新的 accumulatedValue 变量,并将其初始化为零。这个变量用于跟踪随时间累积的所有 weight 值的总和。

下面的代码片段非常重要。如果我们想要随时间累积所有 weight 值的总和,我们需要确保这些值按顺序到来。为了确保这一点,我们按 timeData 列表的 time 属性对它进行排序。

reduce 回调中,我们将 accumulatedValue 变量增加当前 timeData 对象的 weight 值。

如果 timeData 对象落入栅格化框架中,我们不会像之前那样增加这个框架的计数。在累积模式下,我们将框架计数设置为 accumulatedValue 中的当前值。这将导致所有更改的累积值都反映在栅格化输出数组中。

这就是我们创建开放任务图表所需的所有准备工作。我们能够重构我们的 rasterize 函数,现在它能够处理时间数据并生成适用于各种应用的栅格化数据系列。通过使用负 weight 属性,我们现在可以创建基于项目内开放任务的增加和减少的数据系列。

创建开放任务图表

让我们看看我们要创建的折线图:

图片

使用我们的任务图表组件和 Chartist 的步进插值可视化开放任务

我们将利用前一个主题中重构的 rasterize 函数,并使用新的累积模式来跟踪随时间变化的开放任务数量。

让我们使用 Angular CLI 工具创建我们的新任务图表组件:

ng generate component --spec false -ve none -cd onpush projects-dashboard/tasks-chart

让我们编辑组件类,在路径 src/app/projects-dashboard/tasks-chart/tasks-chart.component.ts 上,并将其内容更改为以下内容:

import {
  AfterViewInit, ChangeDetectionStrategy, Component, ElementRef,
  Input, OnChanges, SimpleChanges, ViewChild, ViewEncapsulation
} from '@angular/core';
import * as Chartist from 'chartist';
import {IChartistData, IChartistLineChart} from 'chartist';
import * as moment from 'moment';

import {rasterize} from '../../utilities/time-utilities';
import {ProjectSummary, RasterizationData} from '../../model';

@Component({
  selector: 'mac-tasks-chart',
  templateUrl: './tasks-chart.component.html',
  styleUrls: ['./tasks-chart.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None
})
export class TasksChartComponent implements OnChanges, AfterViewInit {
  @Input() projectSummaries: ProjectSummary[];
  @ViewChild('chartContainer') chartContainer: ElementRef;

  chart: IChartistLineChart;

  ngOnChanges(changes: SimpleChanges) {
    this.createOrUpdateChart();
  }

  ngAfterViewInit() {
    this.createOrUpdateChart();
  }

  createOrUpdateChart() {
    if (!this.projectSummaries || !this.chartContainer) {
      return;
    }

    const data = this.createChartData();
    if (this.chart) {
      this.chart.update(data);
    } else {
      this.createChart(data);
    }
  }

  createChartData(): IChartistData {
    const now = +new Date();
    return {
      series: this.projectSummaries.map(projectSummary => {
        const tasks = projectSummary.tasks
          .filter(task => task.projectId === projectSummary.project.id);
        const timeData: RasterizationData[] = tasks.reduce((data, task) => {
          data.push({
            time: task.created,
            weight: 1
          });

          if (task.done) {
            data.push({
              time: task.completed,
              weight: -1
            });
          }
          return data;
        }, []);

        return rasterize(timeData, 600000, 144, now, null, true);
      }),
      labels: Array.from({
        length: 144
      }).map((e, index) => now - index * 600000).reverse()
    };
  }

  createChart(data: IChartistData) {
    this.chart = new Chartist.Line(this.chartContainer.nativeElement, data, {
      width: '100%',
      height: 300,
      lineSmooth: Chartist.Interpolation.step({
        fillHoles: true
      }),
      axisY: {
        onlyInteger: true,
        low: 0,
        offset: 70,
        labelInterpolationFnc: value => `${value} tasks`
      },
      axisX: {
        labelInterpolationFnc: (value, index) =>
          index % Math.floor(144 / 2) === 0 ?
            moment(value).calendar() : null,
        labelOffset: {
          y: 10
        }
      }
    }, [
      ['screen and (min-width: 1200px)', {
        axisX: {
          labelInterpolationFnc: (value, index) =>
            index % Math.floor(144 / 4) === 0 ?
              moment(value).calendar() : null
        }
      }], ['screen and (min-width: 1500px)', {
        axisX: {
          labelInterpolationFnc: (value, index) =>
            index % Math.floor(144 / 6) === 0 ?
              moment(value).calendar() : null
        }
      }]
    ]);
  }
}

上述代码的基本结构应该已经让你很熟悉了。我们使用与之前图表相同的结构。然而,我们现在要创建的折线图包含更多详细的信息。我们将渲染轴标签和一些刻度。我们图表的 x 轴将是一个时间线,我们将使用 Moment.js 库将时间戳格式化为可读格式。

让我们更仔细地看看 createChartDatacreateChart 方法。这里有很多代码!让我们一步一步地走一遍,以便更好地理解正在发生的事情。

我们使用 projectSummaries 输入作为数据可视化的基础。我们使用更新的 rasterize 函数转换任务数据,以便为我们的折线图准备数据。

首先,我们需要通过映射项目摘要列表来创建我们的转换后的系列数据。系列数组应该包括每个项目的数据数组。每个数据数组将包含随时间变化的开放项目任务。

由于rasterize函数期望一个光栅化数据对象的列表,我们首先需要将项目任务列表转换成这种格式。我们将利用rasterize函数的权重功能。我们可以简单地为每个创建的任务创建一个权重为1的光栅化数据对象。对于完成的任务,我们创建一个权重为-1的光栅化数据对象。这为我们rasterize函数提供了所需输入数据。

准备好数据后,我们可以调用rasterize函数,以创建一定数量的光栅化帧的开放任务列表。我们使用十分钟的时间框架(600,000 毫秒)和 144 帧进行光栅化。这总共是 24 小时。所以,这就是那些两个神奇数字的来源!然而,当我们引入图表的交互性时,此代码将略有变化。

除了系列数据外,我们还需要为我们的图表添加标签。我们创建一个新的数组,并使用 144 个时间戳初始化它,所有这些时间戳都设置为我们在图表上显示的 144 个光栅化帧的开始。

我们现在有了系列数据和标签,接下来要做的就是渲染我们的图表。在createChart方法中,我们使用Chartist.Line构造函数创建我们的折线图实例。

使用lineSmooth配置,我们可以为我们的折线图指定一种特殊的插值。步进插值不会直接连接我们折线图中的每个点,而是将我们的数据以离散的步骤绘制出来,从一个点到另一个点。这正是我们渲染随时间变化的开放任务计数所需要的效果。

在步骤插值中设置fillHoles选项为true非常重要。使用此设置,我们实际上可以告诉 Chartist 它应该关闭数据中的任何间隙(实际上是空值)并将线连接到下一个有效值。如果没有此设置,我们会在图表中看到数据数组中任务计数变化之间的间隙。

好的;目前我们组件类中需要的就是这些。让我们继续到任务图表组件相对简单的模板。打开位于路径src/app/projects-dashboard/tasks-chart/tasks-chart.component.html的文件,并更改其内容如下:

<div #chartContainer class="chart-container"></div>

与活动图表组件类似,我们只创建一个简单的图表容器元素,我们已经在组件类中使用视图子装饰器引用了它。

我们的任务图表组件现在已准备好集成到我们的仪表板中。我们可以通过修改项目仪表板组件的模板来实现这一点。让我们打开位于路径src/app/projects-dashboard/projects-dashboard/projects-dashboard.component.html的文件,并应用以下更改:

<header class="dashboard-header">
  <h2 class="dashboard-title">Dashboard</h2>
</header>
<div class="dashboard-main">
 <h3 class="dashboard-sub-title">Tasks Overview</h3>
 <div class="dashboard-tasks">
 <mac-tasks-chart [projectSummaries]="projectSummaries">
 </mac-tasks-chart>
 </div>
  <h3 class="dashboard-sub-title">Projects</h3>
 <ul class="dashboard-list">
    <li *ngFor="let projectSummary of projectSummaries"
        class="dashboard-list-item">
      <mac-project-summary [projectSummary]="projectSummary"
                           (click)="activateProject(projectSummary.project)">
      </mac-project-summary>
    </li>
  </ul>
</div>

很好!这基本上就是我们需要的,让我们的新创建的任务图表出现在我们的仪表板上。你已经创建了一个简单的折线图来可视化随时间推移的开放任务。

让我们进一步增强我们的图表,通过渲染图例并使图表对用户交互化。

创建图表图例

目前,我们无法确切知道图表上的哪一行代表哪个项目。我们可以看到每个项目有一条彩色线,但我们无法将这些颜色关联起来。我们需要的是一个简单的图例,帮助我们的用户将折线图的颜色与项目关联起来。

让我们看看实现图表图例所需的代码更改。打开位于路径 src/app/projects-dashboard/tasks-chart/tasks-chart.component.ts 的任务图表组件类,并应用以下更改。不相关的更改部分使用省略号字符隐藏,而有效的更改部分用粗体标出:

…

export interface ChartLegendItem {
 title: string;
 class: string;
}

@Component({
  selector: 'mac-tasks-chart',
  templateUrl: './tasks-chart.component.html',
  styleUrls: ['./tasks-chart.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None
})
export class TasksChartComponent implements OnChanges, AfterViewInit {
  …

  legend: ChartLegendItem[];

  ngOnChanges(changes: SimpleChanges) {
    if (changes.projectSummaries && this.projectSummaries) {
 this.legend = this.projectSummaries.map((projectSummary, index) => ({
 title: projectSummary.project.title,
 class: `series-${index + 1}`
 }));
 }

    this.createOrUpdateChart();
  }

  …
}

我们在我们的组件模块中添加了一个本地接口来表示单个图例项。title 属性将在图例中的每个项中显示。class 属性用于设置适当的 CSS 类,以便以正确的颜色渲染每个图例项,以匹配图表中线条的颜色。

OnChanges 生命周期钩子中,我们简单地将项目摘要对象映射到一个图例对象列表。模板字符串 `series-${index + 1}` 将生成必要的类名,以便在我们的图例中渲染正确的颜色。

使用这个图例信息,我们现在可以继续实施必要的模板更改,以在我们的任务图表组件中渲染图例。打开位于路径 src/app/projects-dashboard/tasks-chart/tasks-chart.component.html 的模板,并应用以下更改:

<ul class="series-list">
  <li *ngFor="let series of legend"
      class="series {{series.class}}">{{series.title}}</li>
</ul>
<div #chartContainer class="chart-container"></div>

嗯,这很简单,对吧?然而,结果证明了一切。我们仅用几分钟就为图表创建了一个漂亮的图例:

图片

带有我们添加的图例的开放任务图表

在下一个主题中,我们将向我们的图表添加一些用户交互功能,并让我们的用户控制我们用来渲染数据的时段。

使任务图表交互化

目前,我们硬编码了开放任务图表的时段为 144 帧,每帧 10 分钟,总共显示给用户 24 小时。然而,也许我们的用户想要更改这个视图。

在这个主题中,我们将使用我们的切换组件创建一个简单的输入控制,这将允许我们的用户更改图表的时间段设置。

我们将提供以下视图作为选择选项:

  • :此视图将转换为 144 帧,每帧 10 分钟,总共 24 小时

  • :此视图将转换为 168 帧,每帧一小时,总共七天

  • :此视图将转换为 360 帧,每帧代表一天

让我们从修改位于路径src/app/projects-dashboard/tasks-chart/tasks-chart.component.ts的任务图表组件的TypeScript文件开始实施:

…

export interface ChartTimeFrame {
 name: string;
 timeFrame: number;
 amount: number;
}

@Component({
  selector: 'mac-tasks-chart',
  templateUrl: './tasks-chart.component.html',
  styleUrls: ['./tasks-chart.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None
})
export class TasksChartComponent implements OnChanges, AfterViewInit {
  …

 timeFrames: ChartTimeFrame[] = [{
 name: 'day',
 timeFrame: 600000,
 amount: 144
 }, {
 name: 'week',
 timeFrame: 3600000,
 amount: 168
 }, {
 name: 'year',
 timeFrame: 86400000,
 amount: 360
 }];
 timeFrameNames = this.timeFrames.map((timeFrame) => timeFrame.name);
 selectedTimeFrame = this.timeFrames[0];

  …

  selectTimeFrame(timeFrameName: string) {
 this.selectedTimeFrame = this.timeFrames.find((timeFrame) => timeFrame.name === timeFrameName);
 this.createOrUpdateChart();
 }

  createChartData(): IChartistData {
    const now = +new Date();
    return {
      series: this.projectSummaries.map(projectSummary => {
        …

       return rasterize(timeData, this.selectedTimeFrame.timeFrame, 
 this.selectedTimeFrame.amount, now, null, true);
      }),
      labels: Array.from({
        length: this.selectedTimeFrame.amount
      }).map((e, index) => now - index * this.selectedTimeFrame.timeFrame).reverse()
    };
  }

  …
}

让我们简要回顾这些更改。首先,我们添加了另一个本地接口来表示用户所看到的timeframe选择。ChartTimeFrame接口包含一个名称属性,我们将使用它来向用户展示。我们还为每个图表timeframe对象存储了timeFrameamount属性。这两个属性分别代表每个帧的毫秒数和帧计数。

新的timeFrames成员被设置为timeframe对象数组。这些是我们将向用户展示的选择,它们反映了本节开头讨论的设置。timeFrameNames成员包含时间范围名称列表,这是直接从timeFrames列表派生出来的。最后,我们有一个selectedTimeFrame成员,它简单地指向第一个可用的时间范围对象以开始。

createOrUpdateChart函数中,我们不再依赖于硬编码的值来进行任务栅格化,而是参考selectedTimeFrame对象中的数据。通过更改此对象引用并再次调用createOrUpdateChart函数,我们现在可以动态地切换底层数据的视图。

最后,我们添加了一个新的selectTimeFrame方法,我们将从组件视图中调用它以切换到不同的时间范围。

让我们看看启用时间范围切换所需的模板更改。我们使用我们在本书开头创建的toggle UI 组件:

<mac-toggle [buttonList]="timeFrameNames"
 [activeButton]="selectedTimeFrame.name"
 (outActivate)="selectTimeFrame($event)"></mac-toggle>
<ul class="series-list">
  <li *ngFor="let series of legend"
      class="series {{series.class}}">{{series.title}}</li>
</ul>
<div #chartContainer class="chart-container"></div>

从对toggle组件的绑定中,你可以看出我们依赖于组件的timeFrameNames成员来表示所有可选择的时帧。我们还使用任务图表组件的selectedTimeFrame属性绑定到toggle组件的activeButton输入属性。当用户激活一个切换按钮时,我们调用selectTimeFrame函数,在那里时帧被切换,图表被更新。

这就是我们需要的所有内容,以启用在图表上切换时间范围。现在,用户可以在年、周和日视图之间进行选择。

优秀的成果!你通过重用toggle UI 组件为任务图表添加了交互性。让我们看看我们工作的最终结果:

图片

最终的任务图表,现在在应用程序仪表板中渲染

摘要

在本章中,我们学习了 Chartist 及其如何与 Angular 结合使用来创建外观美观且功能齐全的图表。我们可以利用这两个世界的力量来创建可重用的图表组件,这些组件封装得很好。

就像在大多数实际案例中一样,我们总是有大量的数据可用。然而,将那些数据转换成正确的形状有时是有些棘手的。在这一章中,我们学习了如何将现有数据转换成适合视觉表示的优化形式。

在下一章中,我们将探讨如何在我们的应用程序中构建一个插件系统。这将使我们能够开发可移植的功能,并将其打包成插件。我们的插件系统将动态渲染新插件,我们将用它来开发一个简单的敏捷估算插件。

第十章:将事物置于测试之中

编写测试对于代码的可维护性至关重要。众所周知,拥有一个广泛的测试范围,覆盖了大部分功能,与功能本身一样重要。

当想到测试时,首先想到的可能就是代码质量保证。你测试你写的代码,所以这当然是在确保你的代码质量。然而,编写测试还有很多其他重要的方面:

  • 抵抗意外变化:你的测试定义了你的代码应该做什么。它们测试你的代码是否符合你的规范。这有几个好处,其中最明显的好处可能是抵抗未来意外变化的能力。如果你将来修改代码,你不太可能破坏现有的代码,因为你的测试将验证现有功能是否仍然按指定的方式工作。

  • 文档:你的测试定义了你的代码应该做什么。同时,它们显示了使用相关功能所需的 API 调用。这是任何开发者的完美文档。每当我想要了解一个库是如何真正工作的,我首先看的就是测试。

  • 避免不必要的代码:编写测试迫使你将代码限制在满足你的规范要求内,不再需要更多。你应用中任何在自动化测试中未触及的代码都可以被认为是死代码。如果你坚持无情地重构方法,你将尽快移除这些未使用的代码。

到目前为止,我们在这本书中根本就没有考虑过测试,鉴于其重要性,你可能想知道为什么我会在书的最后一章现在才来探讨这个问题。在实际项目中,我们肯定会更早地创建测试,如果不是一开始就做的话。然而,我希望你能理解,在这本书中,我们之所以将这个相当重要的主题推迟到结尾,是有原因的。我真的很喜欢测试,但鉴于我们主要关注 Angular 的组件架构,将这一章节放在最后似乎更合理。

在本章中,我们将探讨如何对你的组件进行适当的单元测试。我们将专注于单元测试;自动化、端到端测试超出了本书的范围。我们将探讨如何测试用户与组件的交互,但不会达到端到端测试的水平。

在本章中,我们将深入探讨以下主题:

  • Jasmine 测试框架简介

  • 为组件编写简单测试

  • 创建 Jasmine 间谍并观察组件输出属性

  • 了解 Angular 测试工具,例如 injectasyncTestBedComponentFixtureDebugElement

  • 模拟组件

  • 模拟现有服务

  • 为我们的Efforts UI 组件创建测试

  • 为我们的TaskListContainer组件创建测试

Jasmine 和 Karma 简介

Jasmine 是一个非常简单的测试框架,它附带了一个 API;它允许你编写行为驱动开发BDD)风格的测试。BDD 是一种敏捷软件开发过程,用于以书面格式定义规范。

在 BDD 中,我们定义敏捷用户故事由多个场景组成。这些场景与故事的可接受标准密切相关,甚至可以替代它们。它们在更高层次上定义了需求,并且主要是叙事性的。每个场景由三个部分组成:

  • 给定:这部分用于描述场景的初始状态。测试代码是我们执行测试场景所需的所有设置的执行地方。

  • :这部分反映了我们对正在测试的系统所做的更改。通常,这部分包括一些 API 调用和反映系统用户行为的操作。

  • 然后:这部分指定了在给定状态和部分应用的变化之后,系统应该看起来是什么样子。在我们的代码中,这部分通常是测试函数的末尾,我们在这里使用断言库来验证系统的状态。

Jasmine 附带了一个 API,使得根据 BDD 风格编写测试变得非常容易。让我们看看一个非常简单的例子,看看我们如何使用 Jasmine 为购物车系统编写测试:

describe('Buying items in the shop', () => { 
  it('should increase the basket count', () => { 
    // Given 
    const shop = new Shop(); 
    // When 
    shop.buy('Toothpast'); 
    shop.buy('Shampoo'); 
    // Then 
    expect(shop.basket.length).toBe(2); 
    expect(shop.basket).toContain('Toothpaste'); 
    expect(shop.basket).toContain('Shampoo'); 
  }); 
}); 

Jasmine 为我们提供了一个describe函数,它允许我们将同一主题的某些场景分组。在这个例子中,我们使用describe函数为关于在商店购买物品的测试注册了一个新的测试套件。

使用it函数,我们可以注册我们希望进行测试的个别场景。在describe回调函数内部,我们可以注册任意数量的场景。我们还可以嵌套describe调用,以便进一步分组我们的场景。

在 Jasmine it函数的回调函数内部,我们可以开始编写我们的测试。我们可以在测试内部使用 BDD 风格注释来结构化代码。

你不一定要在浏览器中运行 Jasmine,但如果你这样做,你将得到所有测试及其状态的简洁总结报告:

图片

Jasmine 提供了一个很好的视觉报告,显示了所有的测试规范,还允许你重新运行单个测试,并提供更多选项

Jasmine 附带与我们相关的三个部分:

  • Jasmine 核心:这包含测试定义 API、断言库以及测试框架的所有其他核心部分

  • Jasmine HTML:这是 HTML 报告器,它将所有测试结果写入浏览器文档,并提供重新运行单个测试的选项

  • Jasmine 启动:这是启动浏览器中的 Jasmine 框架并执行与 HTML 报告器相关的任何设置的文件

Karma 和 Angular CLI 的集成

Jasmine 是与 Angular CLI 工具一起打包的默认测试框架。CLI 工具会安装 Jasmine,以及流行的测试运行器 Karma,这允许你在你选择的浏览器中运行你的测试。

通过使用 Karma,我们不需要在我们的项目中安装和配置 Jasmine。Karma 会处理这些,并且还提供很多额外功能。

使用 Angular CLI 生成的项目包含两个与使用 Karma 和 Jasmine 运行测试相关的文件:

  • /karma.conf.js:此文件包含 Karma 测试运行器的配置。它已经创建好了,用于在浏览器中运行 Angular 测试。默认情况下,它配置为在真实的 Chrome 浏览器中运行测试。但是,你可以将其更改为 Chrome 的无头版本、不同的浏览器,甚至 PhantomJS,这是一个非常快速且轻量级的无头浏览器。

  • /src/test.ts:此文件是运行你的测试的主要入口文件。当你使用 CLI 创建新项目时,它会自动为你生成。此文件包含运行 Angular 测试所需的全部准备工作,以便在你的浏览器中使用 Karma 运行。它还使用动态导入,以发现项目中的任何测试文件(文件名以 .spec.ts 结尾),并将它们传递给 Karma 以执行。

Angular CLI 带有一组预定义的命令,用于在项目中执行测试。它支持一次性运行所有测试,以验证你的当前状态。然而,有时在开发应用程序时保持测试运行是非常有用的。这支持你进行测试驱动开发方法,其中你将希望从测试中获得持续的反馈循环。

通过使用 Angular CLI 工具运行两个不同的命令来开始测试。确保你处于你的项目文件夹中,并执行以下命令:

ng test --watch false

以下代码将在你的项目中启动单次测试运行。由于我们还没有添加任何测试,你应该看到类似于以下输出的结果:

使用 Angular CLI 进行单次执行(单次运行),没有测试存在

当你即将发布新版本或想要验证代码的某个状态的质量时,单次运行你的测试是很有用的。在本章中,我们将向现有的组件添加一些测试,并在我们进行更改时看到测试的评估结果将非常令人满意。为此,我们开始使用 Karma 的文件监视功能。通过运行以下 Angular CLI 命令,你可以以监视模式启动 Karma。这将使你的终端忙于测试,类似于 Angular CLI 中的 serve 命令。

通过运行以下命令来以测试监视模式启动 Angular CLI:

ng test

如你所见,我们这次完全省略了 watch 命令行参数。默认情况下,当你执行 test 命令时,Angular CLI 将以监视模式运行 Karma。

如果您希望在运行测试时获得持续的反馈,您应该始终启动两个终端会话,一个执行 Angular CLI 服务器(ng serve),另一个以监视模式运行 Karma(ng test)。

编写我们的第一个测试

现在我们已经设置好了测试环境,我们可以开始编写我们的第一个测试。在本节中,我们将为我们在第八章中创建的努力组件创建第一个测试,时间会证明一切

由于 Angular 组件只是类,我们可以通过实例化组件类并测试其方法来测试它们的大部分功能。可以通过这种方式进行的测试应该首先考虑。不涉及您组件渲染逻辑的测试可以在 Angular 编译和引导组件之前运行。

努力组件使用一个方法向任务上实际工作的小时数添加一定量的小时。在努力组件的模板中,我们提供了三个按钮,用于添加一些常见的小时数。让我们为我们的努力组件中的 addEffectiveHours 方法创建我们的第一个测试。为此测试,我们想从简单开始,手动创建组件类实例。这允许我们在不需要启动 Angular 模板编译器、变更检测和其他

当使用 Angular CLI 创建新组件时,将为您创建一个相应的 spect.ts 文件,其中包含一些基本测试。然而,我们始终使用标志 --spec false 创建新组件,这防止了这一步骤。让我们手动创建我们的第一个规范文件,通过在路径 src/app/efforts/efforts/efforts.component.spec.ts 上创建一个新文件。

打开创建的文件,并添加以下内容:

import {EffortsComponent} from './efforts.component';
import {TimeEfforts} from '../../model';

describe('EffortsComponent', () => {
  it('should add eight hours correctly', () => {
    // Given
    const hour = 3600000;
    const component = new EffortsComponent();
    component.efforts = {
      estimated: 0,
      effective: 0
    };
    component.outEffortsChange.subscribe((efforts: TimeEfforts) => {
      // Then
      expect(efforts.effective).toBe(hour * 8);
    });

    // When
    component.addEffectiveHours(8);
  });
});

Karma 加载我们的规范文件,并在执行我们的测试之前已经加载了 Jasmine。因此,我们可以安全地依赖 Jasmine 暴露的全局 describeitexpect 函数。

如您所见,我们实际上不需要引导 Angular 来测试一些组件的功能。只需测试组件类实例,我们就可以执行一些我们的规范。

为了说明我们如何以 BDD 方式构建测试,我们添加了一些注释,指出了“给定”、“当”和“然后”部分。

在给定部分,我们创建一个新的努力组件实例。然后,我们将组件的努力输入属性设置为一个新的努力对象,其中包含零的估计和实际持续时间。在当部分,我们调用 addEffectiveHours 方法,参数为 8 小时。

现在,我们想测试我们的方法调用是否按预期执行。我们的努力组件是一个纯 UI 组件,它不存储任何状态。相反,它使用输出属性委托任何状态变化。在我们的测试上下文中,outEffortsChange 输出在调用 addEffectiveHours 方法后触发。

由于事件发射器在组件输出属性上呈现的只是可观察的,我们可以简单地订阅输出以验证我们的测试。我们使用 Jasmine 辅助函数 expect 来断言更新后的有效努力属性是否是我们测试后期望的。

如果你编写这个第一个测试时 Angular CLI 测试命令仍在运行,你现在应该在终端输出中看到成功的测试执行。否则,只需再次启动测试运行器,使用终端上的 ng test 命令:

终端和 Angular CLI ng test 命令,显示我们的第一个测试成功运行

监视组件输出

在测试中,一个常见的做法是在测试执行期间监视函数调用,然后评估这些调用,检查是否所有函数都被正确调用。

Jasmine 为我们提供了一些有用的辅助函数,以便使用 spy 函数调用。我们可以使用 Jasmine 的 spyOn 函数用 spy 函数替换原始函数。spy 函数将记录任何调用,我们可以评估它们被调用的次数以及调用时的参数。

让我们看看如何使用 spyOn 函数的一个简单例子:

class Calculator { 
  multiply(a, b) { 
    return a * b; 
  } 

  pythagorean(a, b) { 
    return Math.sqrt(this.multiply(a, a) + this.multiply(b, b)); 
  } 
} 

我们将测试一个简单的计算器类,它有两个方法。multiply 方法简单地乘以两个数字并返回结果。pythagorean 方法计算直角三角形的斜边,该三角形有两个边长 ab

你可能还记得从小学时代起就学过的勾股定理公式:

我们将使用这个公式通过计算 a*a + b*b 的平方根来从 ab 产生 c。对于乘法,我们将使用我们的 multiply 方法,而不是直接使用算术运算符。

现在,我们想要测试我们的计算器 pythagorean 方法,因为它使用 multiply 方法来乘以 ab,我们可以监视该方法以深入验证我们的测试结果:

describe('Calculator pythagorean function', () => { 
  it('should call multiply function correctly', () => { 
    // Given 
    const calc = new Calculator(); 
    spyOn(calc, 'multiply').and.callThrough(); 
    // When 
    const result = calc.pythagorean(6, 8); 
    // Then 
    expect(result).toBe(10); 
    expect(calc.mul).toHaveBeenCalled(); 
    expect(calc.mul.calls.count()).toBe(2); 
    expect(calc.mul.calls.argsFor(0)).toEqual([6, 6]); 
    expect(calc.mul.calls.argsFor(1)).toEqual([8, 8]); 
  }); 
}); 

Jasmine 的 spyOn 函数接受一个对象作为第一个参数,以及我们想要监视的对象上的函数名作为第二个参数。

这将有效地用 Jasmine 的新 spy 函数替换我们类实例中的原始 multiply 函数。默认情况下,spy 函数只会记录函数调用,并且不会将调用委托给原始函数。我们可以使用 .and.callThrough() 函数来指定我们希望 Jasmine 调用原始函数。这样,我们的 spy 函数将充当代理,同时记录任何调用。

在测试的 then 部分,我们可以检查 spy 函数。使用 toHaveBeenCalled 匹配器,我们可以检查 spy 函数是否被调用过。

使用 spy 函数的 calls 属性,我们可以更详细地检查并验证调用次数,以及各个调用接收到的参数。

我们可以将我们对 Jasmine 间谍的知识应用到我们的组件测试中。因为我们知道所有组件的输出属性都包含一个事件发射器,我们实际上可以监视它们以检查我们的组件是否发送输出。

在组件内部,我们调用事件发射器的emit方法,以便向父组件绑定发送输出。由于这是一个异步操作,而且我们还想在不涉及父组件的情况下测试我们的组件,我们可以简单地监视输出属性的emit方法。

通常,如果可能的话,避免在单元测试中进行异步操作是一个好的实践。通过使用 Jasmine 间谍,我们可以模拟会导致异步操作的某些函数调用。

让我们将努力组件的第一个测试改为使用 Jasmine 间谍,而不是依赖于可观察的订阅。打开文件src/app/efforts/efforts/efforts.component.spec.ts,并进行以下更改:

import {EffortsComponent} from './efforts.component';

describe('EffortsComponent', () => {
  it('should add eight hours correctly', () => {
    // Given
    const hour = 3600000;
    const component = new EffortsComponent();
    component.efforts = {
      estimated: 0,
      effective: 0
    };
 spyOn(component.outEffortsChange, 'emit');

    // When
    component.addEffectiveHours(8);

    // Then
 expect(component.outEffortsChange.emit).toHaveBeenCalledWith({
 estimated: 0,
 effective: hour * 8
 });
  });
});

我们创建了一个监视outEffortsChange输出属性的 Jasmine 间谍。更确切地说,我们正在监视对底层事件发射器的emit方法的任何调用。

由于我们不再依赖于在事件发射器输出上注册的subscribe方法的回调,我们现在有了更干净的测试代码。测试代码被完美地组织成我们的 BDD 风格测试的给定、当和然后部分,并且不需要使用带有回调函数的订阅。

使用 Jasmine 的toHaveBeenCalledWith断言辅助器,我们可以仔细评估spy函数,并检查记录的调用是否与我们的预期相符。

组件测试的实用工具

到目前为止,我们使用纯 JavaScript 测试了我们的组件。由于组件在常规类中,这使得这一点成为可能。然而,这只适用于非常简单的用例。一旦我们想要测试涉及模板编译、组件上的用户交互、变更检测或依赖注入的组件,我们就需要从 Angular 那里得到一点帮助来执行我们的测试。

Angular 自带一整套测试工具,可以帮助我们在这里解决问题。实际上,Angular 构建的平台无关性允许我们用调试视图适配器替换常规视图适配器。这使得我们能够以这种方式渲染组件,以便我们可以更详细地检查它们。

Angular CLI 已经为我们启用了这个功能,如果你检查src/test.ts文件的内容,你可以看到有一个特殊的准备步骤,它启用了我们的测试 Angular 平台。以下核心摘录显示了我们的默认测试入口点的相关内容,这是我们在创建项目时由 Angular CLI 工具生成的:

…
import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

…

getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);
…

使用@angular/core/testing模块中的initTestEnvironment函数,我们实际上可以初始化一个测试平台注入器,它将在我们的 Angular 测试上下文中使用。

@angular/platform-browser-dynamic/testing 模块,我们可以导入一个特殊的测试平台工厂函数,以及 Angular 浏览器测试模块。

这段代码帮助我们设置一个依赖于特殊平台进行浏览器内测试的测试环境。当我们使用这个平台时,运行时将包含额外的调试信息。

测试中的注入

通过两个辅助函数,将 Angular 依赖注入到测试中变得简单。injectasync 函数通过 @angular/core/testing 包提供,并帮助我们向测试中注入依赖。

让我们看看这个简单的例子,其中我们使用 inject 包装函数注入 document 元素。这个测试对我们应用程序来说并不重要,但它说明了我们如何在测试中使用注入:

import {DOCUMENT} from '@angular/common';
import {inject} from '@angular/core/testing';

describe('Application initialized with test providers', () => {
  it('should inject document', inject([DOCUMENT], (document) => {
 expect(document).toBe(window.document);
 }));
});

我们可以使用 inject 函数来包装我们的测试函数。inject 函数接受一个数组作为第一个参数,该数组应包含一个可注入类型的列表。第二个参数是我们实际的测试函数,它现在将接收注入的文档。

另一方面,async 函数帮助我们处理不同的关注点。如果我们的测试实际上涉及异步操作怎么办?嗯,一个标准的异步 Jasmine 测试看起来如下:

describe('Async test', () => { 
  it('should be completed by calling done', (done) => { 
    setTimeout(() => { 
      expect(true).toBe(true); 
      done(); 
    }, 2000); 
  }); 
}); 

Jasmine 提供了一种很好的方式来指定异步测试。我们可以简单地使用测试函数的第一个参数,它解析为一个名为 done 的回调函数,这是一个惯例。通过调用这个回调函数,我们可以告诉 Jasmine 我们的异步操作已完成,我们希望完成测试。

使用回调来指示我们的异步测试是否完成是一个有效的选项。然而,如果涉及许多异步操作,这可能会使我们的测试相当复杂。有时甚至无法监控底下的所有异步操作,这也使得我们无法确定测试的结束。

这就是 async 辅助函数发挥作用的地方。Angular 使用一个名为 Zone.js 的库来监控浏览器中的任何异步操作。简单来说,Zone.js 钩入任何异步操作,并监控它们何时启动以及何时完成。有了这些信息,Angular 可以确切地知道有多少挂起的异步操作。

如果我们使用 async 辅助函数,我们可以告诉 Angular 当测试中的所有异步操作都完成后自动完成我们的测试。这个辅助函数使用 Zone.js 创建一个新的区域,并确定该区域内执行的所有微任务是否已完成。

让我们看看我们如何在测试中将注入与异步操作结合起来:

import {describe, expect, it, inject, async} from '@angular/core/testing'; 
import {DOCUMENT} from '@angular/platform-browser'; 

describe('Application initialized with test providers', () => {
  it('should inject document', async(inject([DOCUMENT], (document) => {
    setTimeout(() => {
      expect(document).toBe(window.document);
    }, 2000);
  })));
});

通过将injectasync(包装)结合使用,我们现在可以在测试中测试异步操作,而无需任何麻烦。async辅助函数将使我们的测试等待直到所有异步操作完成。我们不需要依赖回调,并且我们有保证,即使在测试完成之前,内部异步操作也会完成。

Zone.js 旨在与浏览器中的所有异步操作一起工作。它修补所有核心 DOM API,并确保每个操作都通过一个区域。Angular 还依赖于 Zone.js 来启动变更检测。它等待区域(浏览器内的任何异步操作)终止,以便执行变更检测。这非常聪明,因为应用程序状态可能发生变化的唯一可能时刻是在异步操作终止之后。

使用 TestBed 运行 Angular 测试

Angular 附带另一个非常重要的测试工具。到目前为止,我们只测试了组件的组件类。然而,一旦我们需要在我们的应用程序中测试组件及其行为,就会涉及更多的事情:

  • 测试组件视图:有时需要测试组件的渲染视图。由于我们的视图中包含所有绑定,使用模板指令和内容投影进行动态实例化,编写确定性测试可能很困难。

  • 测试变更检测:一旦我们在组件类中更新我们的模型,我们就会想要测试通过变更检测执行更新的操作。这涉及到我们组件的整个变更检测行为。

  • 用户交互:我们的组件模板可能包含一组事件绑定,这些绑定在用户交互中触发某些行为。我们需要一种方法来测试用户交互后的状态。

  • 覆盖和模拟:在测试场景中,有时需要模拟组件中的某些区域,以便为测试创建适当的隔离。在单元测试中,我们只应关注我们想要测试的具体行为。

通过@angular/core/testing包提供的TestBed类帮助我们解决了之前列出的问题。它是我们测试组件的主要工具。

让我们看看如何使用TestBed类来测试一个简单模拟组件的视图渲染的一个非常简单的例子:

import {Component} from '@angular/core';
import {TestBed, async} from '@angular/core/testing';

@Component({
  selector: 'mac-dummy',
  template: 'dummy'
})
class DummyComponent {}

describe('DummyComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [DummyComponent],
    }).compileComponents();
  }));

  it('should render its view correctly', async(() => {
    const fixture = TestBed.createComponent(DummyComponent);
    expect(fixture.nativeElement.textContent).toBe('dummy');
  }));
}); 

beforeEach函数是一个 Jasmine 辅助函数,它允许我们在给定测试套件中的每个测试之前执行某些操作。在beforeEach回调中,我们使用从@angular/core/testing导入的TestBed类来准备我们的测试环境。

Testbed类上的configureTestingModule方法允许我们配置一个动态 Angular 模块,该模块定义了我们的测试的整个上下文。传递给该方法的配置对象与我们传递给@NgModule装饰器工厂定义 Angular 模块时传递的对象完全相同。它支持importprovidersdeclarations以及 Angular 模块的所有其他属性。这允许我们创建一个非常具体和隔离的测试环境,在那里我们可以只测试我们真正需要测试的内容。

此外,请注意,我们正在使用 Angular 的async辅助函数来包装我们的beforeEach回调函数。这是必要的,因为compileComponents方法调用是一个异步操作,它编译我们动态测试模块中存在的所有组件。这将确保当我们进入测试方法时,beforeEach回调中执行的编译已完成,我们可以开始测试。

在测试组件时,你需要确保在由configureTestingModule调用创建的动态模块中包含所有必要的依赖项。这包括测试组件中存在的任何子组件、管道、服务以及组件的任何其他依赖项。

组件固定对象

在我们的测试中,我们使用Testbed类的createComponent方法来创建我们虚拟组件的新组件实例。由于该方法调用,我们将收到一个组件固定对象,这将帮助我们进一步对我们的组件进行测试。

组件固定对象是我们正在测试的组件的包装器,它允许我们进一步检查和操作我们的测试用例。现在我们可以使用固定对象的nativeElement属性来访问创建的组件的根 DOM 元素,并断言该元素的文字内容,以验证组件是否正确渲染。

让我们更详细地看看ComponentFixture类型,以及可用的属性和方法:

成员 描述
detectChanges() 此方法在固定上下文中创建的根组件上执行变更检测。使用Testbed类创建组件后,模板绑定不会自动评估。触发变更检测是我们的责任。即使我们更改了组件的状态,我们也需要再次触发变更检测。这种手动变更检测可能听起来很繁琐,但对我们测试期间执行的任何操作拥有完全控制权是很重要的。我们总是希望测试是完全确定性的。
destroy() 此方法销毁底层组件并执行所需的任何清理操作。这可以用来测试OnDestroy组件的生命周期。
componentInstance 此属性指向组件类实例,如果我们想与组件交互,它是我们的主要交互点。
nativeElement 这是对创建的组件根部的原生 DOM 元素的引用。这个属性可以用来直接检查我们组件的渲染 DOM。
elementRef 这是创建的组件根元素的ElementRef包装器。
debugElement 这个属性指向在测试浏览器平台创建组件视图渲染管道时创建的DebugElement实例。调试元素为我们提供了一些检查渲染元素树和测试用户交互的便利工具。我们将在另一个部分中更详细地探讨这一点。

模拟子组件

我们现在已经查看了一个非常简单的哑组件,以及如何使用 Angular 的TestBed以及injectasync辅助函数来测试它。

这很好,但它并没有真正反映我们在测试真实组件时面临的复杂性。真实组件比我们的哑组件有更多的依赖。我们依赖于子指令,以及可能注入的服务来获取数据。

当然,Angular 的TestBed也为我们提供了测试更复杂组件所需的工具,同时在单元测试中保持必要的隔离。

首先,让我们看一个例子,我们想要测试一个使用子组件来渲染数字列表的父组件。在这个测试中,我们只想测试父组件,并且对子组件如何渲染列表不感兴趣。我们想要通过在测试期间为子组件提供一个模拟组件来移除子组件的行为,这样我们就可以轻松地验证数据是否被子组件接收:

@Component({
  selector: 'mac-child',
  template: '<ul><li *ngFor="let n of numbers">Item: {{ n }}</li></ul>'
})
class ChildComponent {
  @Input() numbers;
}

@Component({
  selector: 'mac-parent',
  template: '<mac-child [numbers]="numbers"></mac-child>'
})
class ParentComponent {
  numbers = [1, 2, 3];
}

这是我们的起点。我们有两个组件,我们只对测试父组件感兴趣。然而,父组件需要子组件,并且它意味着渲染由父组件传递的数字的一种非常具体的方式。我们只想测试我们的数字是否成功传递给了子组件。我们不想将子组件的渲染逻辑涉及到我们的测试中。这非常重要,因为只更改子组件可能会破坏我们的父组件测试,这正是我们想要避免的。

现在,我们想在测试的上下文中创建我们子组件的模拟:

@Component({
  selector: 'mac-child',
  template: '{{ numbers.toString() }}'
})
class MockChildComponent {
  @Input() numbers;
}

在我们的模拟子组件中,使用与真实组件相同的选择器属性是很重要的。否则,模拟将不会工作。在模板中,我们使用一个非常简单的数字输入输出,这使得检查变得容易。数组上的toString方法将简单地渲染所有元素,用逗号分隔。

同样重要的是,我们需要提供与原始组件相同的输入属性。否则,我们无法正确地模仿真实组件。

现在,我们可以继续准备我们的测试。通过简单地将我们的模拟子组件添加到我们的动态测试模块的声明中,我们可以确保我们的父组件使用我们的模拟组件,而不是真实的子组件:

describe('ParentComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        ParentComponent,
        MockChildComponent
      ],
    }).compileComponents();
  }));

  it('should pass numbers to child correctly', async(() => {
    const fixture = TestBed.createComponent(ParentComponent);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toBe('1,2,3');
  }));
}); 

因此,在我们的测试上下文中,我们将父组件与子组件解耦。我们需要这种级别的分离,以便正确隔离我们的单元测试。由于我们的模拟子组件只是简单地渲染传递数组的字符串表示,我们可以轻松地测试我们的固定内容的文本。

单元测试的定义是对单个单元进行测试并隔离该单元与任何依赖项。如果我们想坚持这种范式,我们需要为每个依赖组件创建一个模拟。这很容易让我们陷入需要维护更多复杂性的情况,仅仅是为了我们的测试。关键在于找到正确的平衡。你应该模拟对主题有重大影响的依赖项,并忽略对您想测试的功能影响较低的依赖项。

模拟服务

让我们看看一个不同的用例,其中我们有一个注入服务以获取数据的组件。由于我们只想测试我们的组件,而不是它所依赖的服务,我们 somehow 需要偷偷地引入一个模拟服务而不是真实的服务。由于我们可以为我们的需求定义我们的动态测试模块,我们可以在我们的模块定义上简单地使用providers属性来引入模拟服务。

首先,我们应该声明我们的基本组件及其依赖的服务。在这个例子中,数字组件注入一个数字服务,其中它获取一个包含数字的数组:

@Injectable()
class NumbersService {
  numbers = [1, 2, 3, 4, 5, 6];
}

@Component({
  selector: 'mac-numbers',
  template: '{{ numbers.toString() }}'
})
class NumbersComponent {
  numbers: number[];

  constructor(numbersService: NumbersService) {
    this.numbers = numbersService.numbers;
  }
}

现在,我们需要创建一个模拟服务,它提供测试所需的数据,并隔离我们的组件与原始服务:

@Injectable()
class MockNumbersService {
  numbers = [1, 2, 3];
}

在这个简化的例子中,我们只是提供了一组不同的数字。然而,在真实的模拟案例中,我们可以排除很多不必要的步骤,这些步骤可能会产生副作用。使用模拟服务还确保了我们的测试,即专注于数字组件的测试,不会因为真实数字服务的更改而中断。

现在,让我们看看测试用例,看看我们如何可以使用TestBed提供我们的模拟服务,而不是真实的服务:

describe('NumbersComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [NumbersComponent],
      providers: [{
        provide: NumbersService,
        useClass: MockNumbersService
      }]
    }).compileComponents();
  }));

  it('should render numbers correctly', async(() => {
    const fixture = TestBed.createComponent(NumbersComponent);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toBe('1,2,3');
  }));
});

在动态测试模块定义中使用providers属性,我们可以为正在测试的组件提供依赖项。这允许我们替换组件中使用的依赖项。我们可以在依赖注入请求数字服务时,简单地创建一个提供者,提供我们的替代模拟数字服务。

有时候,我们无法完全控制我们的依赖项;例如,当我们通过导入的模块获取预定义的提供者集合时。TestBed通过提供额外的覆盖方法来帮助我们处理这种情况。让我们再次看看前面的例子;这次,我们使用覆盖机制悄悄地引入我们的模拟服务:

describe('NumbersComponent', () => {
  beforeEach(async(() => {
 TestBed.configureTestingModule({
 declarations: [NumbersComponent],
 providers: [NumbersService]
 });
 TestBed.overrideProvider(NumbersService, {useValue: new MockNumbersService()});
 TestBed.compileComponents();
  }));

  it('should render numbers correctly', async(() => {
    const fixture = TestBed.createComponent(NumbersComponent);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toBe('1,2,3');
  }));
});

使用overrideProvider方法允许我们用我们的模拟服务覆盖之前指定的真实服务。当你测试依赖于导入的 Angular 模块的大型应用程序,而这些模块不完全受你控制时,这是一个重要的工具。

Angular 的Testbed类允许我们以非常简单、隔离和灵活的方式执行测试。它在编写组件的单元测试时扮演着重要角色。如果你想了解更多关于Testbed类中可用方法的信息,你可以访问官方文档网站angular.io/api/core/testing/TestBed

现在,是时候使用我们对Testbed实用类的新的知识,开始测试我们的应用程序组件的实际操作了!

测试组件的实际操作

在上一个主题中,我们学习了Testbed实用类,以及如何使用它在一个隔离的测试环境中测试组件。我们学习了injectasync辅助工具,以及如何模拟组件和服务。

现在,让我们用这个知识来处理我们关于努力组件的测试。如果你看一下我们努力组件的模板,你会记得我们依赖于两个子组件:

  • 持续时间:模板中的两个持续时间组件用于输入估计和实际任务上花费的努力的持续时间值。当你想要对努力组件进行测试时,模拟该组件是个好主意。持续时间组件本身依赖于相对复杂的编辑组件。

  • 努力时间线:由于这是一个纯图形组件,用于表示我们的任务努力,我们并不希望它参与我们努力组件的测试。然而,由于这个组件实际上并不干扰我们的努力行为,我们不一定需要模拟它。让我们在这个特定实例中使用真实组件。

好的;让我们继续编写我们努力组件的测试。到目前为止,我们只测试了努力组件的类方法。我们现在想继续通过模拟持续时间组件并使用 Angular 的TestBed来创建一个新的组件实例。

如果你还没有启动,现在是时候使用 Angular CLI 工具启动测试框架了。在项目文件夹内,使用命令行中的ng test

打开位于路径src/app/efforts/efforts/efforts.component.spec.ts的测试文件,并执行以下更改:

import {Component, EventEmitter, Input, Output} from '@angular/core';
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
import {EffortsComponent} from './efforts.component';
import {EffortsTimelineComponent} from '../efforts-timeline/efforts-timeline.component';

@Component({
 selector: 'mac-duration',
 template: '{{ duration }}'
})
class MockDurationComponent {
 @Input() duration: number;
 @Output() outDurationChange = new EventEmitter<number>();
}

describe('EffortsComponent', () => {
  let fixture: ComponentFixture<EffortsComponent>;

 beforeEach(async(() => {
 TestBed.configureTestingModule({
 declarations: [
 EffortsComponent,
 MockDurationComponent,
 EffortsTimelineComponent
 ],
 }).compileComponents();
 fixture = TestBed.createComponent(EffortsComponent);
 }));

  …
});

我们新引入的模拟持续时间组件可能看起来有点脆弱,但实际上这正是我们当前对努力组件测试所需的所有内容。持续时间组件应该只接受一个 duration 输入并暴露一个 ourDurationChange 输出。这是我们努力组件模板中预期的接口。由于我们不希望依赖任何持续时间组件的内部实现,包括使用编辑器组件,我们的模拟持续时间组件的模板简单地渲染持续时间输入。这样,我们可以轻松地验证我们的模拟组件中是否渲染了正确的持续时间。

现在,让我们使用 Testbed 创建的组件实例和 fixture 创建我们的第一个测试,对努力测试文件应用以下更改:

…

describe('EffortsComponent', () => {
  …

  it('should render initial efforts correctly', () => {
    // Given
    const component = fixture.componentInstance;
    component.efforts = {
      estimated: 1,
      effective: 2
    };

    // When
    fixture.detectChanges();

    // Then
    expect(fixture.nativeElement.textContent).toBe('Estimated:1Effective:2+1h+4h+1d');
  });

  …

});

在我们的测试中,我们希望测试努力组件是否使用正确的内容初始化持续时间子组件(分别,我们的模拟持续时间组件)。

使用由 Testbed 工具类提供的组件 fixture,我们可以开始与创建的组件进行交互。通过组件 fixture 的 componentInstance 成员,我们可以设置我们努力组件所需输入属性。

由于我们在测试中需要手动处理变更检测,我们使用在 fixture 上的 detectChanges 方法来更新组件视图。这将启动我们组件的变更检测生命周期并执行必要的视图更新。

在两个持续时间组件的视图更新之后,我们可以运行断言以通过获取我们 fixture 的 nativeElement 属性上的文本内容来验证更新的 DOM。

在某些情况下,使用我们整个组件 fixture 的文本内容属性可能很好。然而,有时这并不提供足够粒度来进行良好的断言。在某些场景中,当我们涉及更多的 DOM 元素时,直接断言根组件的 textContent 属性将不足以提供必要的细节。它可能包含大量的噪声,而这并不是我们断言所感兴趣的。我们应该始终尝试将我们的断言缩小到尽可能少的细节。

在我们新创建的测试中,我们需要使用字符串 Estimated:1Effective:2+1h+4h+1d 进行断言,以验证我们组件的正确渲染。然而,这也包括我们添加按钮的标签,而这并不是我们在这里想要测试的。记住,我们总是希望将我们的测试缩小到精确的一个单元,并避免对特定测试案例之外的事物的任何依赖。

让我们尝试增强我们的测试并断言我们组件视图的更具体部分。由于我们可以访问我们 fixture 上的原生 DOM 元素,我们可以简单地使用 DOM API 来选择子元素,以缩小我们的断言:

expect(fixture.nativeElement.querySelector('mac-duration').textContent.trim()).toBe('1'); 

这将成功选择我们的第一个模拟持续时间组件的 DOM 元素,我们只能检查持续时间组件内的文本内容。

尽管这可能会是一个可行的方案,但 Angular 为我们提供了一个更好的方法来解决此问题。

由组件 fixture 提供,我们可以在测试浏览器平台的环境中访问由测试创建的调试元素树。调试元素允许我们对 Angular 在渲染我们的组件时创建的元素树进行高级检查。它还包含一个高级查询 API,允许我们在组件树中搜索特定元素。

让我们重写我们的测试,以使用调试元素提供的先进功能,并单独断言估计和有效持续时间组件视图:

…
import {By} from '@angular/platform-browser';
…

describe('EffortsComponent', () => {
  …

  it('should render initial efforts correctly', () => {
    // Given
    const component = fixture.componentInstance;
    component.efforts = {
      estimated: 1,
      effective: 2
    };
    const [estimatedDurationElement, effectiveDurationElement] = fixture.debugElement
 .queryAll(By.directive(MockDurationComponent));

    // When
    fixture.detectChanges();

    // Then
 expect(estimatedDurationElement.nativeElement.textContent).toBe('1');
 expect(effectiveDurationElement.nativeElement.textContent).toBe('2');
  });

  …

});

每个调试元素对象上可用的queryqueryAll方法使我们能够像查询 DOM 树一样查询 Angular 视图树,使用querySelectorquerySelectorAll。这里的区别在于我们可以

使用谓词辅助器查询匹配元素。使用By辅助类,我们可以创建这些谓词,然后将被用于查询调试元素树。

目前有三种不同的谓词可用,使用By辅助器:

成员 描述
By.all() 这是一个谓词,将导致查询当前DebugElement对象的所有子DebugElement对象。
By.css(selector) 这是一个谓词,将导致使用指定的 CSS 选择器查询DebugElement
By.directive(type) 这是一个谓词,将导致查询包含指定指令的DebugElement

回到我们的测试中,我们现在可以使用 fixture 调试元素上的查询方法,以查询我们的持续时间组件。由于我们已经用模拟的持续时间组件替换了真实的持续时间组件,我们需要查询后者。我们使用谓词By.directive(MockDurationComponent),这将成功查询代表我们两个模拟持续时间组件宿主元素的调试元素对象。

调试元素对象的query方法将始终返回一个新调试元素对象,该对象是第一个找到的元素,如果找到了匹配项。如果没有找到查询的元素,它将返回null

调试元素的queryAll方法将返回一个包含许多调试元素的数组,这些元素将包含所有匹配谓词的元素。如果没有匹配的元素,此方法将返回一个空数组。

测试组件交互

尽管 UI 交互测试可能是端到端测试的一部分,但我们将探讨如何测试与您组件的基本用户交互。在本主题中,我们将测试当用户点击其中一个按钮以添加有效工时小时时,努力组件的行为。

让我们在现有的努力组件测试文件中添加一个新的测试,该文件位于路径src/app/efforts/efforts/efforts.component.spec.ts

  it('should add one day of effective efforts on button click', () => {
    // Given
    const day = 3600000 * 8;
    const component = fixture.componentInstance;
    component.efforts = {
      estimated: 0,
      effective: 0
    };
    const addDayButton = fixture.debugElement
      .queryAll(By.css('button'))[2];
    spyOn(component.outEffortsChange, 'emit');

    // When
    addDayButton.triggerEventHandler('click', null);

    // Then
    expect(component.outEffortsChange.emit).toHaveBeenCalledWith({
      estimated: 0,
      effective: day
    });
  });

我们希望在测试中为outEffortsChange.emit函数设置一个 Jasmine spy。这样,我们就可以稍后检查当我们在按钮上点击以添加一天的有效努力时,我们的努力组件是否成功发射了事件。

在给定的部分,我们正在为测试做准备。我们用一个新的对象初始化组件的efforts输入。我们还查询了努力组件视图中添加一天有效努力的按钮的调试元素。我们使用queryAll方法获取所有三个按钮的列表,并通过访问数组元素索引2来选择第三个。css谓词工厂允许我们传递一个 CSS 选择器来查询调试元素。

在测试的 when 部分,我们可以通过使用triggerEventHandler方法在addDayButton调试元素上模拟一个click事件。使用此方法,你可以触发你希望在底层元素上发射的事件。监听该事件的 Angular 事件监听器将被同步触发。

在我们 BDD 风格测试的当前部分,我们可以简单地评估我们为组件的outEffortsChange输出属性创建的 Jasmine spy。

通过使用调试元素,测试组件上的用户交互变得非常简单。我们还可以通过使用triggerEventHandler辅助方法将我们的测试与底层 DOM 事件架构解耦。

triggerEventHandler方法在 Angular 的虚拟元素树上操作,而不是实际的 DOM 树。因此,我们也可以使用此方法来触发附加到组件输出属性上的事件处理器。

摘要

在本书的最后一章,我们学习了如何为我们的组件编写简洁的单元测试。我们遵循了编写测试的 BDD 风格方法,并且我们还涵盖了 JavaScript 测试框架 Jasmine 的基础知识。

我们了解了 Angular 中可用的调试工具以及如何设置测试环境。使用 Angular 的Testbed实用类,我们能够以非常灵活但精确的方式进行测试。我们还了解了调试视图树和组件固定装置。这些使我们能够执行巧妙的检查并应用实际查询到渲染的视图中,以断言预期的结果。

我们使用了injectasync辅助函数来注入依赖项,并同时运行异步测试。我们构建了模拟组件,以便将我们的测试与应用程序的其他部分隔离开来。

posted @ 2025-09-09 11:32  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报