精通 Angular 组件第二版(一)
原文:
zh.annas-archive.org/md5/74e15f35f78fc549e292088a2a9a4e5f译者:飞龙
前言
网络组件长期以来一直被誉为网络开发的下一个重大飞跃。随着 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并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入本书的名称,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本解压缩或提取文件夹:
-
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-erra…,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将非常感激您能提供位置地址或网站名称。请通过链接至材料与我们联系至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类,它们都持有特定的行为。现在,你想要创建一个继承自Fisher和Developer的FishingDeveloper类。除非你使用支持多重继承的语言(例如 C++,它在一定程度上这样做),否则你将无法使用继承重用这个功能。没有办法告诉语言你的新类应该从两个超类继承。使用组合,你可以轻松解决这个问题。你不需要使用继承,而是组合一个新的FishingDeveloper类,将所有行为委托给内部的Developer和Fisher实例:
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();
经验告诉我们,组合可能是重用代码最有效的方式。与继承、装饰和其他提高可重用性的方法相比,组合可能是最不侵入性和最灵活的。
一些语言的最新版本也支持一种称为特质的模式,即混合。特质允许你以类似于多重继承的方式重用其他类中的某些功能性和属性。
如果我们思考组合的概念,它不过是设计生物体。我们有两个Developer和Fisher生物体,并将它们的行为统一到一个单一的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)中关于 import 和 export 关键字的说明。
模板字符串
模板字符串非常简单,但它们是 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 项目。
- 让我们从在你的系统上安装 Angular CLI 开始。在你的命令行中执行以下命令:
npm install -g @angular/cli@6.0.8
- 在安装了 Angular CLI 工具之后,你现在可以使用它来搭建一个新的 Angular 项目。你可以在终端中输入
ng来访问工具的可执行文件。让我们打开另一个终端窗口,使用 Angular CLI 工具创建一个新的 Angular 项目:
ng new my-first-app --prefix mac
- 前一步需要一些时间,因为你的项目所有依赖项都需要先安装。完成后,我们现在可以使用 CLI 工具来启动本地开发服务器:
cd my-first-app
ng serve
- 你现在可以启动你喜欢的浏览器,并打开地址
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:
- 打开控制台窗口并导航到我们的项目适当的工作空间。让我们使用 Angular CLI 来创建我们的初始项目结构:
ng new mastering-angular-components --prefix=mac
- 在项目成功创建后,让我们进入项目文件夹,并使用
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 函数,我们的根组件是如何被渲染到正确位置的:
-
首先,我们在我们的平台实例上调用
bootstrapModule函数,将我们的主应用程序模块作为参数传递 -
Angular 将检查我们的主应用程序模块元数据,并在
NgModule配置的bootstrap属性中找到列出的AppComponent -
通过评估
AppComponent上的元数据,查看selector属性,Angular 将知道在哪里渲染我们的根组件 -
AppComponent被渲染为我们的根组件,位于index.html文件中,与组件元数据中selector属性匹配的宿主元素
运行应用程序
为了确保我们对主组件 AppComponent 的修改按预期工作,并且我们没有破坏任何东西,让我们使用 Angular CLI 启动我们的应用程序。打开命令行,将其指向你的项目目录。然后,以服务模式启动 CLI:
ng serve
如果一切顺利,你将拥有一个显示“欢迎使用 mac!”的打开网页浏览器。
回顾
让我们回顾一下到目前为止我们已经做了什么:
-
我们使用 Angular CLI 初始化了一个新项目
-
我们修改了
src/app/app.component.ts中的主应用程序组件,以包含ViewEncapsulation.None以启用全局样式 -
我们已经查看了生成的
MainModule以及我们主入口文件src/main.ts中的启动过程 -
最后,我们使用 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 的服务模式,如果你还没有运行它的话。
概述
让我们看看在之前的构建块中我们做了什么。通过遵循以下步骤,我们实现了在封装组件内对任务的简单列表:
-
我们创建了包含组件逻辑的组件 TypeScript 文件
-
我们在单独的 HTML 文件中创建了组件的视图
-
我们将组件的 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 提供的接口。我们通过以下步骤执行了这些操作:
-
我们创建了一个任务子组件
-
我们使用了任务子组件与任务列表组件。
-
我们在任务组件中使用了输入绑定和 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的方法来使用我们的任务服务更新任务。到目前为止,我们一直在任务组件内部直接更新任务数据。我们的复选框上的输出绑定直接从视图中更新任务的状态。在我们塑造应用程序的过程中,以更受控的方式修改应用程序的状态变得至关重要。想象一下,在你的应用程序中有数十个组件,每个组件都在修改应用程序的状态。这将是一场真正的维护噩梦。
那么,我们应该如何最好地解决这个问题呢?答案是委托。我们将状态操作委托给父组件,直到我们达到应该处理操作的组件。组件输出非常适合这个用例。我们可以通过发出输出值来告诉父组件发生了变化。在我们的情况下,这意味着以下流程应该发生:
-
复选框组件将告诉任务组件复选框已被勾选
-
任务组件将告诉任务列表组件任务应该被更新
-
任务列表组件将调用服务来更新任务数据
首先,我们将修复在任务组件中发生的状态操作。打开位于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 服务:
-
我们创建了一个任务服务来存储和处理我们的任务数据
-
我们将状态操作从任务组件委托给了任务列表组件,然后该组件与我们的服务进行交互
-
我们了解了
@Injectable及其如何在应用级别提供依赖项 -
我们在任务列表组件的构造函数中注入了我们的任务服务,在那里我们使用它来获取数据
过滤任务
在本节中,我们将为我们的任务列表实现一些过滤功能。为了控制活动过滤标准,我们首先构建了一个切换按钮列表组件。让我们继续使用 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 数组扩展函数中了解到一些这些函数算子,例如 map 和 filter。使用算子,你可以模拟整个转换流程,直到你最终订阅数据。
我经常在谈论 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 调用中获得的结果任务列表最初通过我们的行为主题发出。我们还在addTask和updateTask方法完成 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 发出的值对解构为 tasks 和 activeTaskFilterType 变量,并根据该数据进行过滤。结果过滤列表被返回,并将由 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组成。数据从我们的容器组件流向组件A和B。每当容器提供新的数据时,这些数据就会通过它们的输入渗透到您的用户界面组件中。
现在,这里是这个概念中棘手的部分。用户界面组件,如我们插图中的组件A和B,永远不会直接操作数据。它们总是会委托给父组件。我经常将这个概念解释为一种控制反转(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方法都只是发出输出,没有其他操作。
我们用于任务的原则也应用于过滤类型列表和活动过滤类型。我们使用输入属性taskFilterTypes和activeTaskFilterType,以便我们可以从父组件传递这些信息。任务列表不再负责控制活动过滤类型的状态,我们可以从父容器组件控制这个状态。
由于我们现在假定通过任务输入属性传递给组件的任务已经过过滤,因此我们需要对我们的组件模板进行一些小的修改。此外,我们不再需要在任务列表组件中使用异步管道,因为我们的组件将直接接收过滤任务的解析数组。我们将让容器组件处理可观察对象。让我们打开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 组件,以便在项目内查看和编辑任务。