精通-Angular2-组件-全-
精通 Angular2 组件(全)
原文:
zh.annas-archive.org/md5/d9a8ce871be5683aac72df02ca27a346译者:飞龙
前言
网页组件长期以来一直被誉为网页开发的下一个重大飞跃。随着 Angular 2 的推出,我们比以往任何时候都更接近这一目标。在过去的几年里,在网页开发社区中,关于网页组件的讨论已经持续了很长时间。Angular 2 中的新组件式指令将改变开发者的工作流程,并改变他们对阴影 DOM 中自定义 HTML 共享和可重用块的看法。我们的这本书是第一本引导开发者沿着这条道路前进的书籍。这也是一种实用的学习方法,给读者提供了构建自己组件的机会。通过《精通 Angular 2 组件》,学习者将能够通过紧密关注一个关键领域,在新一波的网页开发中走在前列,这个领域是解锁 Angular 开发力量的关键。
《精通 Angular 2 组件》教导读者以组件化的方式思考。这本丰富的指南介绍了 Angular 中新的以组件为中心的方式做事,教导读者如何为他们的网页项目发明、构建和管理共享和可重用组件。这本书将改变开发者对如何在 Angular 2 中完成事情的看法,读者将在整个过程中工作于有用且有趣的示例组件。
本书涵盖内容
第一章,基于组件的用户界面,简要回顾了 UI 开发的历史,并简要介绍了基于组件的用户界面。我们将看到 Angular 2 如何处理这个概念。
第二章,准备,出发!,将引导读者开始构建基于 Angular 2 组件的应用程序之旅。它涵盖了使用组件结构化应用程序的基本元素。
第三章,使用组件进行组合,读者将开始将用户界面结构化成基本组件。然后,读者将通过将应用程序布局组织成组件,使用 QueryList 建立组件的组成,并创建一个可重用的标签组件来结构化应用程序界面,继续使用组件来构建应用程序。
第四章,请勿评论!,读者将学习如何使用组件构建评论系统。他们将学习创建一个列出评论的组件,以及创建新的评论。
第五章,基于组件的路由,解释了组件如何响应路由,并将使读者能够向任务管理应用程序中现有的组件添加简单路由。读者还将处理登录过程,并理解如何使用路由器保护组件。
第六章,跟上活动,涵盖了创建将在项目和任务级别可视化活动流的组件。
第七章,用户体验组件,是读者将创建许多小型可重用组件的地方,这些组件将对任务管理应用的整体用户体验产生重大影响。这包括文本字段的就地编辑、无限滚动、弹出通知和拖放支持。
第八章,时间会证明一切,专注于创建时间跟踪组件,这有助于在项目和任务级别估算时间,同时也让用户能够记录在任务上花费的时间。
第九章,飞船仪表盘,专注于使用第三方库 Chartist 创建组件以可视化任务管理应用中的某些数据。
第十章,使事物可插入,是读者将了解如何使用简单但强大的模式使组件可插入的地方。通过为 Angular 2 组件创建 DIY 插件架构,我们使我们的任务管理系统可扩展。
第十一章,对事物进行测试,涵盖了测试 Angular 2 组件的一些基本方法。我们将看到为测试而模拟/覆盖组件特定部分的可选方案。
附录,任务管理应用源代码,包含了你需要下载和安装本书附带源代码所需的所有信息。你还可以在那里找到使用和调试代码的说明。
你需要这本书的什么
这本书需要在你的 Windows、Mac 或 Linux 机器上安装 Node.js 的基本版本。
这本书的适用对象
这本书是为已经对基本前端网络技术(如 JavaScript、HTML 和 CSS)有良好理解的 Angular 开发者所写。你将了解 Angular 2 的新组件化架构以及如何使用它来构建现代且简洁的用户界面。
习惯用法
在这本书中,你将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称的显示方式如下:“你有一个Fisher类和一个Developer类,它们都包含特定的行为。”
代码块设置如下:
class Fruit {
constructor(name) { this.name = name; }
}
const apple = new Fruit('Apple');
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
<body>
<template id="template">
<h1>This is a template!</h1>
</template>
</body>
任何命令行输入或输出都按照以下方式编写:
npm install jspm --save-dev
jspm init
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“如果一切顺利,你将有一个显示Hello World!的打开网页浏览器。”
注意
警告或重要提示会出现在这样的框中。
小贴士
技巧和窍门会像这样出现。
读者反馈
我们的读者反馈始终受到欢迎。让我们知道你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要向我们发送一般性反馈,请简单地通过电子邮件发送到 <feedback@packtpub.com>,并在邮件的主题中提及书籍的标题。
如果你在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在你已经是 Packt 图书的骄傲所有者,我们有一些事情可以帮助你从购买中获得最大收益。
下载示例代码
你可以从你的账户中下载这本书的示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以通过以下步骤下载代码文件:
-
使用你的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与勘误。
-
在搜索框中输入书籍的名称。
-
选择你想要下载代码文件的书籍。
-
从下拉菜单中选择你购买这本书的地方。
-
点击代码下载。
你也可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍的名称来访问此页面。请注意,您需要登录到您的 Packt 账户。
文件下载完成后,请确保使用最新版本的软件解压缩或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Mastering-Angular-2-Components。我们还有其他来自我们丰富图书和视频目录的代码包可供下载,网址为 github.com/PacktPublishing/。请查看它们!
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/MasteringAngular2Components_ColorImages.pdf下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。
第一章. 基于组件的用户界面
尽管在这本书中我们将涵盖许多与 Angular 相关的主题,但重点将主要放在创建基于组件的用户界面。理解一个框架,比如 Angular 2,是一回事,但使用基于组件的架构建立有效的流程则是另一回事。在这本书中,我将尝试解释 Angular 2 组件背后的核心概念以及我们如何利用这种架构来创建现代、高效和可维护的用户界面。
除了学习 Angular 2 背后的所有必要概念,我们还将一起从头创建一个任务管理应用。这将使我们能够探索使用 Angular 2 提供的组件系统解决常见 UI 问题的不同方法。

我们将要构建的任务管理应用预览
在这一章中,我们将探讨基于组件的用户界面如何帮助我们构建更强大的应用。在这本书的整个过程中,我们将一起构建一个 Angular 2 应用,我们将充分利用基于组件的方法。本章还将介绍本书中使用的科技。本章我们将涵盖以下主题:
-
基于组件的用户界面简介
-
使用基于组件的用户界面进行封装和组合
-
UI 框架的演变
-
标准和 Web 组件
-
Angular 2 组件系统的简介
-
编写你的第一个 Angular 2 组件
-
ECMAScript 和 TypeScript 的概述和历史
-
ECMAScript 7 装饰器作为元注释
-
使用 JSPM 和 SystemJS 的 Node.js 工具简介
考虑有机体
今天的用户界面不仅仅是一堆拼凑到屏幕上的表单元素。现代用户体验设计和创新的内容交互视觉呈现比以往任何时候都更考验技术。
很遗憾,当我们为 Web 应用构思概念时,我们几乎总是倾向于按页面来思考,比如印刷书籍中的页面。嗯,这可能是传达此类内容和媒介信息最有效的方式。你可以逐页浏览,无需任何真正的体力劳动,逐段阅读,只需扫描那些你不感兴趣的部分。
过度思考页面的问题在于,这个从书籍中借用的概念并没有很好地转化为现实世界中的运作方式。世界是由形成有机体系统的有机体创造的。这个系统本身又形成了一个有机体,只是在更高的层面上。
以我们的身体为例。我们主要由独立的器官组成,这些器官通过电信号和化学信号相互交互。器官本身由蛋白质组成,这些蛋白质本身就像一台机器一样工作,形成一个系统。从分子、原子、质子和夸克来看,我们实际上无法真正判断哪里开始,哪里结束。我们可以肯定的是,这全部都是关于具有相互依赖性的生物系统,而不是关于页面。
我喜欢将用户界面视为生物系统。在它们被分配到页面、在哪里以及如何分配时,这些都是次要的。此外,它们应该能够独立工作,并且应该在相互依赖的水平上相互交互。
组件 – 用户界面的器官
| "我们不是在设计页面,我们是在设计组件系统。" | ||
|---|---|---|
| --Stephen Hay |
这句话来自 Stephen Hay 在 2012 年奥兰多的 BDConf 上的发言,它指出了关键点。界面设计实际上根本不是关于页面。为了为用户以及维护它们的开发者创建高效的用户界面,我们需要从组件系统的角度思考。组件是独立的,但当它们组合在一起时,它们可以相互交互并创建更大的组件。我们需要从整体上看待用户界面,而使用组件使我们能够做到这一点。
在以下主题中,我们将探讨组件的一些基本方面。其中一些已经从其他概念中了解,例如面向对象编程(OOP),但在考虑组件时,它们呈现出略微不同的光景。
封装
封装在考虑系统维护时是一个非常重要的因素。拥有经典的 OOP 背景,我了解到封装意味着将逻辑和数据捆绑到一个隔离的容器中。这样,我们可以从外部操作容器,并把它当作一个封闭的系统。
在可维护性和可访问性方面,这种方法有许多积极方面。处理封闭系统对于我们的代码组织很重要。然而,这甚至更为重要,因为我们可以在编写代码的同时组织自己。

我有一个相当差的记忆力,对我来说,在编写代码时找到正确的关注水平非常重要。立即进行的记忆研究告诉我们,人类大脑平均一次可以记住大约七个项目。因此,对我们来说,以这种方式编写代码至关重要,这样我们就可以一次关注更少、更小的部分。
清晰的封装帮助我们组织代码。我们可能忘记了封闭系统的所有内部细节以及我们放入其中的逻辑和数据类型。我们可以只关注其表面,这使我们能够在一个更高的抽象级别上工作。类似于前面的图示,如果不使用封装组件的层次结构,我们的所有代码都会在同一级别上拼凑在一起。
封装鼓励我们将小型且简洁的组件隔离开来,并构建一个组件系统。在开发过程中,我们可以专注于一个组件的内部,而只需处理其他组件的接口。
有时候,我们会忘记我们实际进行的所有代码组织都是为了我们自己,而不是运行这些代码的计算机。如果是为计算机,那么我们可能都会重新开始用机器语言编写。强大的封装帮助我们轻松访问特定代码,专注于代码的一层,并信任胶囊中的底层实现。
以下 JavaScript 示例展示了如何使用封装来编写可维护的应用程序。假设我们在一个 T 恤工厂,我们需要一些代码来生产带有背景和前景颜色的 T 恤。此示例使用了 ECMAScript 6 的一些新特性。如果你不熟悉 ECMAScript 6 的语言特性,在这个阶段不必过于担心。我们将在本章后面学习这些内容:
// This class implements data and logic to represent a colour
// which establishes clean encapsulation.
class Colour {
constructor(red, green, blue) {
Object.assign(this, {red, green, blue});
}
// Using this function we can convert the internal colour values
// to a hex colour string like #ff0000 (red).
getHex() {
return '#' + Colour.getHexValue(this.red) + Colour.getHexValue(this.green) +
Colour.getHexValue(this.blue);
}
// Static function on Colour class to convert a number from
// 0 to 255 to a hexadecimal representation 00 to ff
static getHexValue(number) {
const hex = number.toString(16);
return hex.length === 2 ? hex : '0' + hex;
}
}
// Our TShirt class expects two colours to be passed during
// construction that will be used to render some HTML
class TShirt {
constructor(backgroundColour, foregroundColour) {
Object.assign(this, {backgroundColour, foregroundColour});
}
// Function that returns some markup which represents our
// T-Shirts
getHtml() {
return `
<t-shirt style="background-color: ${this.backgroundColour.getHex()}">
<t-shirt-text style="color: ${this.foregroundColour.getHex()}">
Awesome Shirt!
</t-shirt-text>
</t-shirt>
`;
}
}
// Instantiate a blue colour
const blue = new Colour(0, 0, 255);
// Instantiate a red colour
const red = new Colour(255, 0, 0);
// Create a new shirt using the above colours
const awesomeShirt = new TShirt(blue, red);
// Adding the generated markup of our shirt to our document
document.body.innerHTML = awesomeShirt.getHtml();
使用干净的封装,我们现在可以处理 T 恤中的颜色抽象。我们不需要担心如何在 T 恤级别计算颜色的十六进制表示,因为这是由Colour类完成的。这使得你的应用程序易于维护,并且非常开放,便于更改。
如果您还没有这样做,我强烈建议您阅读有关 SOLID 原则的内容。正如其名称所暗示的,这个原则的集合是一个强大的工具,可以极大地改变您组织代码的方式。您可以在罗伯特·C·马丁的《敏捷原则、模式和实践》一书中了解更多关于 SOLID 原则的内容。
可组合性
组合是一种特殊的可重用性。你不是扩展现有的组件,而是通过将许多较小的组件组合在一起形成一个组件系统,从而创建一个新的更大的组件。
在面向对象编程语言中,组合通常被用来解决大多数面向对象编程语言都存在的多重继承问题。子类多态性一直很好,直到你达到你的设计不再符合项目最新要求的地步。让我们看看一个简单的例子,说明这个问题。
你有一个Fisher类和一个Developer类,它们都包含特定的行为。现在,你想要创建一个继承自Fisher和Developer的FishingDeveloper类。除非你使用支持多重继承的语言(例如 C++在一定程度上支持),否则你将无法通过继承重用此功能。没有方法告诉语言你的新类应该从这两个超类继承。使用组合,你可以轻松解决这个问题。你不需要使用继承,而是通过组合一个新的FishingDeveloper类,将所有行为委托给内部的Developer和Fisher实例:
class Developer {
code() {
console.log(`${this.name} writes some code!`);
}
}
class Fisher {
fish() {
console.log(`${this.name} catches a big fish!`);
}
}
class FishingDeveloper {
constructor(name) {
this.name = name;
this.developerStuff = new Developer();
this.fisherStuff = new Fisher();
}
code() {
this.developerStuff.code.bind(this)();
}
fish() {
this.fisherStuff.fish.bind(this)();
}
}
var bob = new FishingDeveloper('Bob');
bob.code();
bob.fish();
经验告诉我们,组合可能是重用代码最有效的方式。与继承、装饰和其他提高可重用性的方法相比,组合可能是最不侵入性和最灵活的。
一些语言的最新版本也支持一种称为特质的模式,即混合(mixins)。特质允许你以类似于多重继承的方式,从其他类中重用某些功能属性。
如果我们考虑组合的概念,它不过是设计生物体。我们有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 开发的框架,你应该寻找这三个关键指标。
首先,我认为了解网络的主要目的及其如何演变非常重要。如果我们回想一下 1990 年代的网络早期,它可能只是关于超文本。有一些非常基本的语义可以用来结构化信息并向用户展示。HTML 的创建是为了持有结构和信息。对信息自定义视觉展示的需求导致了 CSS 在 HTML 开始广泛使用后不久的发展。
在 1990 年代中期,布兰登·艾奇发明了 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-structuring 模式引入的任何封装。
新标准的时机
在过去几年中,Web 标准发生了巨大的演变。有如此多的新标准,浏览器已经成为一个如此大的多媒体框架,以至于其他平台很难与之竞争。
我甚至可以说,Web 技术实际上将在未来取代其他框架,并且它可能将被重新命名为多媒体技术或类似的东西。我们没有理由需要使用不同的本地框架来创建用户界面和演示。Web 技术集成了许多功能,很难找到不使用它们进行任何类型应用的理由。只需看看 Firefox OS 或 Chrome OS,它们都是设计用来使用 Web 技术运行的。我认为这只是时间问题,直到更多操作系统和嵌入式设备开始利用 Web 技术来实现它们的软件。这就是为什么我相信在某个时刻,Web 技术这个术语是否仍然合适,或者我们是否应该用更通用的术语来替代它,将变得可疑。
虽然我们通常只看到浏览器中新功能的出现,但它们背后有一个非常开放且冗长的标准化过程。标准化功能非常重要,但这需要很多时间,尤其是在人们就解决问题的不同方法存在分歧时。
回到组件的概念,这是我们真正需要 Web 标准支持来突破当前限制的地方。幸运的是,W3C 也有同样的想法,一群开发者开始在名为Web 组件的伞形规范下制定规范。
以下主题将为您简要概述两个在 Angular 2 组件中也扮演角色的规范。Angular 2 的核心优势之一是它更像是一个 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 代码,我们可以实例化模板并将其附加到我们的文档中。
影子 DOM
这部分 Web 组件规范是创建适当的 DOM 封装和组合所缺失的部分。有了影子 DOM,我们可以创建受保护的 DOM 独立部分,防止外部常规 DOM 操作。此外,CSS 不会自动进入影子 DOM,我们可以在组件内部创建局部 CSS。
小贴士
如果你将一个style标签添加到阴影 DOM 内部,这些样式将仅限于阴影 DOM 的根元素,并且它们不会泄露到外部。这为 CSS 提供了一种非常强大的封装。
内容插入点使得从阴影 DOM 组件的外部控制内容变得容易,并且它提供了一种接口来传递内容。
在撰写这本书的时候,大多数浏览器都支持阴影 DOM,尽管在 Firefox 中仍然需要启用它。
Angular 的组件架构
对我来说,Angular 第一个版本的指令概念改变了前端 UI 框架的游戏规则。这是我第一次感觉到有一个简单而强大的概念,允许创建可重用的 UI 组件。指令可以与 DOM 事件或消息服务进行通信。它们允许你遵循组合原则,你可以嵌套指令并创建仅由较小指令组合在一起的大指令。实际上,指令是浏览器中组件的一个非常好的实现。
在本节中,我们将探讨 Angular 2 的基于组件的架构以及我们关于组件所学的知识如何适应 Angular。
一切都是组件
作为 Angular 2 的早期采用者,在与其他人讨论它的时候,我经常被问及与第一个版本最大的区别是什么。我对这个问题的回答总是相同的。一切都是组件。

对我来说,这种范式转变是简化并丰富了框架的最相关变化。当然,Angular 2 还带来了许多其他变化。然而,作为一个基于组件的用户界面倡导者,我发现这个变化是最有趣的一个。当然,这个变化也伴随着许多架构上的变化。
Angular 2 支持从整体上查看用户界面的想法,并支持组件的组合。然而,与它的第一个版本相比,最大的区别是现在你的页面不再是全局视图,而是由其他组件组装的简单组件。如果你一直在跟随这一章节,你会注意到这正是整体用户界面方法所要求的。不再有页面,而是组件系统。
小贴士
Angular 2 仍然使用指令的概念,尽管现在的指令确实如其名称所暗示的那样。它们是浏览器将特定行为附加到元素上的命令。组件是一种特殊的指令,它带有视图。
您的第一个组件
沿袭传统,在我们开始一起构建真实的应用程序之前,我们应该使用 Angular 编写我们的第一个hello world组件:
// Decorators allow us to separate declarative logic from our
// component implementation logic.
@Component({
selector: 'hello-world',
template: '<div>Hello {{name}}</div>'
})
class HelloWorld {
constructor() {
this.name = 'World';
}
}
这已经是一个完全工作的 Angular 2 应用程序了。我们使用了 ECMAScript 6 类来创建组件所需的封装。你还可以看到一个用于声明性配置我们的组件的元注解。这个看起来像是一个以at符号为前缀的函数调用的语句,实际上来自 ECMAScript 7 装饰器提案。
注意
到本书写作时,ECMAScript 7 装饰器仍然非常实验性。对于本书中的代码,我们实际上使用了 TypeScript 编译器版本 1.5,它已经实现了装饰器,与原始规范略有不同。TypeScript 1.5 也被 Angular 2 团队用于开发 Angular 的核心。
重要的是要理解,一个元素只能绑定到一个单一组件。因为组件总是带有视图,所以我们无法将多个组件绑定到一个元素上。另一方面,一个元素可以绑定到多个指令,因为指令不带有视图,它们只附加行为。
在Component装饰器中,我们需要配置所有与描述我们的组件相关的信息,以便于 Angular。这当然也包括我们的视图模板。在前面的例子中,我们直接在 JavaScript 中以字符串的形式指定我们的模板。我们也可以使用templateUrl属性来指定模板应该从哪个 URL 加载。
现在,让我们稍微增强我们的例子,以便我们可以看到我们如何从更小的组件中组合我们的应用程序:
// Using decorators we can declaratively define our component used
// to write bold text
@Component({
selector: 'shout-out',
template: '<strong>{{words}}</strong>'
})
class ShoutOut {
@Input() words;
}
// This component will be our main application component that
// makes use of the above shout-out component (composition)
@Component({
selector: 'hello-world'
template: '<shout-out words="Hello, {{name}}!"></shout-out>',
directives: [ShoutOut]
})
class HelloWorld {
constructor() {
this.name = 'World';
}
}
你可以看到,我们现在已经创建了一个小组件,允许我们像我们喜欢的那样大声喊出词语。在我们的Hello World应用程序中,我们利用这个组件来大声喊出Hello, World!
提示
在组件视图模板内部使用的每个指令或组件都必须在视图注解的指令属性中显式声明。否则,编译器在遇到模板中的元素时将无法识别指令。
在这本书的整个过程中,以及我们在编写任务管理应用程序时,我们将学习更多关于组件的配置和实现。然而,在我们开始第二章之前,我们应该看看一些我们在这本书中会使用的工具和语言特性。
未来 JavaScript
不久前,有人问我我们是否真的应该使用 ECMAScript 5.1 的 bind 函数,因为这样我们可能会遇到浏览器兼容性问题。网络发展非常快,我们需要跟上节奏。我们不能编写不使用最新特性的代码,即使这会在旧浏览器中引起问题。
来自 TC39 的技术委员会的杰出人士,该委员会负责编写 ECMAScript 规范,他们已经出色地逐步增强了 JavaScript 语言。这一点,加上 JavaScript 的灵活性,使我们能够使用所谓的 polyfills 和 shims 来使我们的代码在旧浏览器上运行。
ECMAScript 6(也称为 ECMAScript 2015)于 2015 年 6 月发布,正好是其前身之后的四年。新增了大量的 API 以及许多新的语言特性。这些语言特性是语法糖,ECMAScript 6 可以被转换为其前一个版本,在旧浏览器上运行得很好。在撰写本书时,当前没有任何浏览器版本完全实现了 ECMAScript 6,但完全没有理由不将其用于生产应用。
小贴士
语法糖是一种设计方法,我们在不破坏向后兼容性的同时演进编程语言。这允许语言设计者提出新的语法,这丰富了开发者的体验,但不会破坏网络。每个新特性都需要转换成旧语法。这样,所谓的转换器就可以用来将代码转换为旧版本。
我说的是 JavaScript,请翻译!
当编译器将高级语言编译为低级语言时,转换器或转换编译器更像是一个转换器。它是一种源到源的编译器,可以将代码转换为在另一个解释器中运行。
最近,在将新语言转换为 JavaScript 并能在浏览器中运行的新语言之间,确实存在一场真正的战斗。我使用 Google Dart 有一段时间了,我必须承认,我真的很喜欢这门语言的特点。非标准化语言的问题在于它们严重依赖于社区采用和炒作。此外,它们几乎肯定永远不会在浏览器中本地运行。这也是我为什么更喜欢标准 JavaScript,以及未来的 JavaScript 使用转换器,这使我能够做到这一点。
有些人认为转换器引入的代码运行性能不佳,因此建议不要使用 ECMAScript 6 和转换器。我不赞同这一点,因为有很多原因。通常,这关乎微秒甚至纳秒级别的性能,而这对于大多数应用程序通常并不重要。
我并不是说性能不重要,但性能需要在一定的背景下进行讨论。如果你试图通过将处理时间从 10 微秒减少到五微秒来优化应用程序中的循环,而你永远不会迭代超过 100 个项目,那么你很可能是在做错事情。
另外,一个非常重要的是事实是,被转换的代码是由那些比我更了解微性能优化的人设计的,我确信他们的代码运行得比我快。除此之外,转换器可能也是你想要进行性能优化的正确地方,因为这段代码是自动生成的,你不会因为性能问题而失去代码的可维护性。
我想在这里引用唐纳德·克努特的话,并说过早优化是万恶之源。我真心推荐你阅读他关于这个主题的论文(唐纳德·克努特,1974 年 12 月,使用 goto 语句的结构化编程)。仅仅因为 goto 语句被从所有现代编程语言中禁止,并不意味着这就不值得一读。
在本章的后面部分,你将了解到一些工具,它们可以帮助你轻松地在项目中使用转换器,我们还将看看 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 中的特性,我强烈推荐你阅读 Axel Rauschmayer 博士的文章(www.2ality.com/)。
模块
模块提供了一种封装你的代码并创建隐私的方法。在面向对象的语言中,我们通常使用类来做这件事。然而,我实际上认为这更像是一个反模式,而不是一个好的实践。类应该用于需要继承的地方,而不仅仅是用来结构化你的代码。
我相信你已经遇到了很多不同的 JavaScript 模块模式。其中最流行的一种,通过使用立即执行函数表达式(IIFE)的函数闭包来创建隐私,可能是揭示模块模式。如果你想了解更多关于这个以及其他一些优秀的模式,我推荐阅读 Addy Osmani 所著的《Learning JavaScript Design Patterns》这本书。
在 ECMAScript 6 中,我们现在可以使用模块来达到这个目的。我们只需为每个模块创建一个文件,然后使用 import 和 export 关键字将我们的模块连接起来。
在 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 开发者网络上了解更多关于模板字符串的信息。
ECMAScript 或 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 的扩展功能是有合理理由的:
-
经历了大量更改和重构的大型应用程序
-
在编写代码时需要严格治理的大型团队
在这本书中,我们将使用 TypeScript 编译器,但我们将使用标准 ECMAScript 6 代码,除了下一节关于装饰器的主题中提到的例外。
装饰器
装饰器不是 ECMAScript 6 规范的一部分,但它们被提议纳入 2016 年的 ECMAScript 7 标准。它们为我们提供了一种在设计时装饰类和属性的方法。这允许开发者在使用类时使用元注解,并声明性地将功能附加到类及其属性上。
装饰器是以最初在 Erich Gamma 和他的同事(也称为“四人帮”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 不支持它的原因。由于这个特性对于构建 Angular 2 应用程序至关重要,我们将使用 TypeScript 编译器来转译我们应用程序的代码。这是我们在这本书中将使用的唯一 TypeScript 特定功能。
工具
为了利用所有这些未来的技术,我们需要一些工具来支持我们。我们之前已经讨论了 ECMAScript 6 和装饰器,实际上我们更倾向于使用 TypeScript 装饰器,因为它们支持 Angular 2 使用的函数参数装饰器。尽管 ECMAScript 6 语法支持模块,我们仍然需要一个模块加载器来实际加载浏览器中所需的模块或帮助我们生成可执行包。
Node.js 和 NPM
Node.js 是增强版的 JavaScript。最初,Node.js 是 Google Chrome 浏览器中 V8 JavaScript 引擎的一个分支,它被扩展了更多的功能,特别是为了使 JavaScript 在服务器端有用。文件处理、流、系统 API 以及庞大的用户生成包生态系统只是使这项技术成为您网络开发杰出伙伴的一些事实。
节点包管理器 NPM 是通往超过 200,000 个包和库的大门,这些包和库可以帮助您构建自己的应用程序或库。Node.js 的哲学与 UNIX 哲学非常相似,即包应该保持小巧而锋利,但它们应该使用组合来实现更高的目标。
为了构建我们的应用程序,我们将依赖 Node.js 作为我们将要使用的工具的主机。因此,我们应该确保在我们的机器上安装 Node.js,以便为下一章做准备,在那里我们将开始构建我们的任务管理应用程序。
注意
您可以从他们的网站nodejs.org获取 Node.js,并且按照他们网站上的说明安装应该非常简单。
一旦安装了 Node.js,我们可以执行一个简单的测试来检查一切是否正常运行。打开终端控制台并执行以下命令:
node -e "console.log('Hello World');"
SystemJS 和 JSPM
目前有许多模块格式和模块加载器,但在我看来,有一个是统治它们的。SystemJS 是基于一个 ES6 模块加载器 polyfill 构建的,因此非常接近即将到来的标准。我坚信标准化,因此更倾向于使用 SystemJS 而不是其他模块加载器,如 RequireJS、Browserify 或 webpack。我们应该尽可能停止使用库,并依赖 polyfills 使我们的浏览器能够运行未来的代码。
SystemJS 是一个通用的模块加载器,能够加载许多不同的模块格式,例如 AMD、CommonJS 和 ECMAScript 6,它还支持一个非常灵活的 shiming 机制来模块化全局 JavaScript。
SystemJS 还支持最流行的转译器,包括 ECMAScript 6 和 TypeScript。这意味着您实际上可以直接在浏览器中加载 ECMAScript 6 代码,由 SystemJS 在运行时进行转译。这在开发期间非常棒,尤其是因为您可以从任何位置加载模块,包括远程 HTTP 位置,如 GitHub 或 NPM 仓库。
JSPM
JavaScript 包管理器不仅仅是一个 JavaScript 的包管理器。这基本上是一个中介和管理器,用于 SystemJS,它帮助您从包仓库(如 Bower 或 NPM)中查找包,并为 SystemJS 创建必要的配置。JSPM 用 Node.js 编写,不附带自己的远程包仓库。由于 SystemJS 需要 URLs 和模块映射来知道从哪里加载模块,因此 JSPM 是您创建这种必要配置并简化包安装的工具。
开始使用 JSPM
让我们一起来创建一个使用 JSPM 的简单应用程序。首先,我们需要使用 NPM 安装两个全局模块。除了 JSPM,我们还将安装一个名为 live-server 的工具,它将通过提供静态文件服务的 HTTP 服务器来帮助我们进行开发。它还内置了文件更改检测功能,一旦检测到文件更改,它将自动重新加载您的浏览器。这提供了一个非常短的反馈循环,使得开发过程变得非常快速:
-
在命令行中运行以下命令:
npm install -g jspm live-server小贴士
注意,在类 UNIX 系统(如 Linux 或 Mac OS X)上,有时需要以超级用户身份运行 NPM。还建议您使用 Node 版本管理器(NVM)来解决这个问题(
github.com/creationix/nvm)。 -
在安装 JSPM 和
live-server包之后,我们可以继续使用 JSPM 创建我们的第一个应用程序。 -
为应用程序创建一个新的目录,并在该目录内打开一个终端控制台。
-
您现在可以在终端控制台中执行以下命令来本地安装 JSPM 并初始化一个新的 JSPM 项目:
npm install jspm --save-dev jspm init -
JSPM 将启动一个向导,引导您完成初始化步骤。您可以用默认答案(只需按Enter键)回答所有问题,除了关于您想使用哪种编译器的问题,您应该回答 TypeScript。
-
在 JSPM 安装所有必要的包之后,我们可以继续创建我们的
index.html文件。导航到您的项目文件夹,并在您最喜欢的编辑器中创建一个新的文件,index.html:<!doctype html> <script src="img/system.js"></script> <script src="img/config.js"></script> <script> System.import('main.js'); </script> -
这非常简约的 HTML 已经是我们的 JSPM Hello World 应用程序的基础。在包含
SystemJS库和由 JSPM 生成的config.js文件之后,我们只需要通过告诉SystemJS要导入哪个文件来引导我们的应用程序。 -
在我们创建主应用程序文件之前,我们将快速安装 jQuery 作为包,只是为了演示第三方库如何容易地使用
SystemJS和 JSPM 进行安装和使用。jspm install jquery -
在安装 jQuery 之后,我们可以在应用程序文件夹内创建我们的
main.js文件:import $ from 'jquery'; class HelloWorld { constructor() { $(document.body).append('<h1>Hello World!</h1>'); } } const helloWorld = new HelloWorld(); -
为了在浏览器中运行此示例,我们现在可以在应用程序文件夹内执行以下命令来启动我们的实时服务器:
live-server
在遵循前面的步骤之后,您应该有一个使用 ECMAScript 6 和 SystemJS 以及 TypeScript 编译器的可工作示例。使用 LiveReload,您的浏览器应该会自动打开并显示我们的 Hello World 应用程序。您现在也可以尝试稍微修改一下代码,并更改写入 DOM 的句子。您会注意到,一旦您保存更改,浏览器将立即重新加载页面。
摘要
在本章中,我们探讨了基于组件的方法来构建用户界面,并讨论了其背景的必要方面,以便理解为什么我们要随着网络标准和框架,如 Angular,向这个方向发展。我们还确保了我们为本书后续章节中将要使用的所有技术都做好了准备。您使用 JSPM、SystemJS、ECMAScript 6 和 TypeScript 编译器创建了自己的第一个简单示例。现在,我们准备利用组件化架构的潜力来构建我们的任务管理系统。
在下一章中,我们将开始使用 Angular 2 组件构建我们的任务管理应用程序。我们将查看从头创建 Angular 2 应用程序所需的初始步骤,并详细说明前几个组件,以便构建任务列表。
第二章:准备,设置,出发!
在本章中,我们将开始构建我们的任务管理应用。我们将直接进入应用的核心,并创建管理简单任务列表所需的初始组件。在阅读本章的过程中,你将学习以下主题:
-
使用主组件引导 Angular 应用
-
组件输入和输出
-
宿主属性绑定
-
样式和视图封装
-
使用 SystemJS 文本加载器导入 HTML 模板
-
使用
EventEmitter发射自定义事件 -
双向数据绑定
-
组件生命周期
管理任务
在上一章学习了基础知识之后,我们现在将在接下来的章节中一起创建一个任务管理应用。你将在章节中学习一些概念,然后通过实际示例来使用它们。你还将学习如何使用组件来构建应用。这从文件夹结构开始,以设置组件之间的交互结束。
视觉
任务管理应用应使用户能够轻松管理任务,并帮助他们组织小型项目。可用性是任何应用的中心环节;因此,你需要设计一个现代且灵活的用户界面,以支持用户。

我们将要构建的任务管理应用的预览
我们的任务管理应用将包含组件,使我们能够设计一个提供出色用户体验的平台,用于管理任务。让我们定义我们应用的核心功能:
-
在多个项目中管理任务并提供项目概览
-
简单的调度以及时间和努力跟踪机制
-
使用图形图表概述仪表板
-
跟踪活动并提供可视化的审计日志
-
一个适用于不同组件的简单评论系统
任务管理应用是本书的主要示例。因此,书中的构建块应仅包含与本书主题相关的代码。当然,除了组件之外,应用还需要其他功能,如视觉设计、数据、会话管理以及其他重要部分,才能运行。虽然每章所需的代码都可以在线下载,但我们只会讨论与书中学习到的主题相关的代码。
从零开始
让我们从创建一个名为 angular-2-components 的新文件夹开始,以便创建我们的应用:
-
在我们新创建的文件夹内打开控制台窗口,并运行以下命令以初始化一个新的 Node.js 项目:
npm init -
通过使用 Enter 键(默认设置)确认所有步骤来完成初始化向导。
-
由于我们使用 JSPM 来管理我们的依赖项,因此我们需要将其作为项目 Node.js 包进行安装:
npm install jspm --save-dev -
让我们在项目文件夹内初始化一个新的 JSPM 项目。请确保为所有设置使用默认设置(只需按 Enter 键),除了被询问你想要使用哪种编译器时。在此阶段输入 TypeScript:
jspm init -
我们现在将使用 JSPM 将相关的 Angular 2 包安装到我们的项目中作为依赖项。我们还将安装一个 SystemJS 加载器插件来加载文本文件作为模块。我们将在稍后提供一些关于此的详细信息:
jspm install npm:@angular/core npm:@angular/common npm:@angular/compiler npm:@angular/platform-browser-dynamic npm:rxjs text
让我们通过使用 NPM 和 JSPM 命令行工具来检查到目前为止我们所创建的内容。
package.json 文件是我们使用的 Node.js 配置文件,我们将其作为与 JSPM(包管理器)和 SystemJS(具有编译器的模块加载器)一起工作的基础。如果你查看 package.json 文件,你将看到 JSPM 依赖项的附加部分:
"jspm": {
"dependencies": {
"@angular/common": "npm:@angular/common@2.0.0-rc.1",
"@angular/compiler": "npm:@angular/compiler@2.0.0-rc.1",
"@angular/core": "npm:@angular/core@2.0.0-rc.1",
"@angular/platform-browser-dynamic": "npm:@angular/platform-browser-dynamic@2.0.0-rc.1",
"text": "github:SystemJS/plugin-text@0.0.7"
},
"devDependencies": {
"typescript": "npm:typescript@1.8.10",
}
}
让我们快速查看我们使用 JSPM 安装的依赖项及其用途:
| 包 | 描述 |
|---|---|
@angular/core |
这是 Angular 2 的核心包,托管在 NPM 上。如果你还记得 第一章,基于组件的用户界面,JSPM 只是一个经纪人,并将任务委托给其他包仓库。核心包包含所有 Angular-core 模块,例如 @Component 装饰器、变更检测、依赖注入等。 |
@angular/common |
Angular 的 common 包为我们提供了基本指令,如 NgIf 和 NgFor。它还包含所有基本管道和控制表单的指令。 |
@angular/compiler |
编译器包包含编译视图模板所需的所有工件。Angular 不仅提供了预编译模板以获得更快的启动时间的能力,而且在运行时使用编译器将文本模板转换为编译后的模板。如果我们需要在运行时编译模板,则需要此包。 |
@angular/platform-browser-dynamic |
此包包括启动功能,将帮助我们启动应用程序。由 platform-browser-dynamic 包启动的引导是动态的,即在运行时编译模板。 |
typescript |
这个开发依赖项是 SystemJS 的 TypeScript 编译器。它将我们的 ECMAScript 6 和 TypeScript 代码编译成 ECMAScript 5,从而可以在浏览器中运行。 |
text |
这个 SystemJS 加载器支持以 JavaScript 字符串的形式加载文本文件。如果你喜欢加载 HTML 模板并避免异步请求,这特别有用。 |
我们在浏览器中显示应用程序的主要入口点是我们的索引站点。index.html 文件完成以下五个动作:
-
从 CDN 加载 ECMAScript 6 兼容库 es6-shim。此脚本是为了确保浏览器理解最新的 ECMAScript 6 API。
-
加载框架所需的 Angular 2 polyfills。这包括运行 Angular 2 应用程序所需的浏览器补丁。在加载我们应用程序中的任何其他代码之前,加载这些 polyfills 非常重要。
-
加载 SystemJS 和包含由 JSPM 生成的映射信息的
config.js文件。 -
使用
System.import函数加载和执行主入口点,即我们的boostrap.js文件。
让我们在项目的根文件夹内创建一个新的index.html文件:
<!doctype html>
<html>
<head lang="en">
<title>Angular 2 Components</title>
</head>
<body>
<script src="img/es6-shim.min.js"></script>
<script src="img/angular2-polyfills.js"></script>
<script src="img/system.js"></script>
<script src="img/config.js"></script>
<script>
System.import('lib/bootstrap.js');
</script>
</body>
</html>
让我们继续到我们的应用程序组件。你可以将其视为应用程序的最外层组件。它是主要组件,因为它代表了整个应用程序。每个应用程序都需要一个,并且只有一个主要组件。这是组件树根的位置。
我们将命名我们的主要组件为App,因为它代表了我们的整个应用程序。让我们继续在我们的项目文件夹中的新lib文件夹内创建这个组件。创建一个名为app.js的文件,并包含以下内容:
// We need the Component annotation as well as the
// ViewEncapsulation enumeration
import {Component, ViewEncapsulation} from '@angular/core';
// Using the text loader we can import our template
import template from './app.html!text';
// This creates our main application component
@Component({
// Tells Angular to look for an element <ngc-app> to create this
// component
selector: 'ngc-app',
// Let's use the imported HTML template string
template,
// Tell Angular to ignore view encapsulation
encapsulation: ViewEncapsulation.None
})
export class App {}
这里与我们之前了解的结构化组件的知识没有不同,这是我们之前章节中学到的。然而,与之前创建组件的方式相比,这里有两个主要区别。如果你查看我们配置的template属性,你可以看出我们没有直接在 JavaScript 文件中的 ECMAScript 6 模板字符串内编写 HTML 模板。相反,我们将使用 SystemJS 中的文本加载插件将模板加载到 JavaScript 字符串中。我们可以通过在常规 ECMAScript 6 导入后附加!text来从文件系统中加载任何文本文件:
import template from './app.html!text';
这将加载当前目录下的app.html文件,并以字符串的形式导出其内容。
第二个区别是我们使用ViewEncapsulation来指定 Angular 应该如何处理视图封装。Angular 有三种处理视图封装的方式,它们提供了不同级别的粒度,并且各有优缺点。具体如下:
| 封装类型 | 描述 |
|---|---|
ViewEncapsulation.Emulated |
如果组件设置为模拟视图封装,它将通过将生成的属性附加到组件元素并修改 CSS 选择器以包含这些属性选择器来模拟样式封装。这将启用某些形式的封装,尽管如果存在其他全局样式,外部样式仍然可能泄漏到组件中。这种视图封装模式是默认模式,除非指定其他方式。 |
ViewEncapsulation.Native |
原生视图封装应该是 Angular 中视图封装概念的最终目标。它使用上一章中描述的 Shadow DOM 来为整个组件创建一个隔离的 DOM。此模式依赖于浏览器原生支持 Shadow DOM,因此不一定总是可以使用。还重要的是要注意,全局样式将不再被尊重,并且局部样式需要放置在组件内的行内样式标签中(或使用组件注释上的styles属性)。 |
ViewEncapsulation.None |
此模式告诉 Angular 不提供任何模板或样式封装。在我们的应用程序中,我们主要依赖于来自全局 CSS 的样式;因此,我们为大多数组件使用此模式。既不使用 Shadow DOM,也不使用属性来创建样式封装;我们可以简单地使用我们全局 CSS 文件中指定的类。 |
由于此组件现在依赖于从文件系统加载的模板,我们需要在lib文件夹中创建一个包含一些初始内容的app.html文件:
<div>Hello World!</div>
目前为止,这就是我们放入模板中的所有内容。我们的目录应该看起来类似于此:
angular-2-components
├── node_modules/
├── jspm_packages/
├── config.js
├── index.html
├── lib
│ ├── app.html
│ └── app.js
└── package.json
现在我们已经创建了我们的主要应用程序组件,我们可以将组件的主元素添加到我们的index.html文件中:
<!DOCTYPE html>
<html>
<head lang="en">
<title>Angular 2 Components</title>
</head>
<body>
<ngc-app></ngc-app>
...
引导
index.html文件将使用 SystemJS 在行内script标签中加载bootstrap.js模块。当与 SystemJS 一起工作时,为您的脚本提供一个主入口点是一个最佳实践。我们的bootstrap.js文件负责加载我们应用程序所需的所有必要 JavaScript 依赖项以及启动 Angular 框架。
我们可以继续引导我们的 Angular 应用程序,通过提供我们的主要应用程序组件App。我们需要从angular2模块中导入bootstrap函数。然后我们可以导入我们的App组件并调用bootstrap函数,将其作为参数传递:
// Import Angular bootstrap function
import {bootstrap} from '@angular/platform-browser-dynamic';
// Import our main app component
import {App} from './app';
// We are bootstrapping Angular using our main application
// component
bootstrap(App);
运行应用程序
我们到目前为止生成的代码现在应该处于可以运行的状态。在我们使用 live-server 模块运行我们的代码之前,让我们确保我们已准备好所有文件。在这个阶段,我们的目录应该看起来像这样:
angular-2-components
├── jspm_packages/
├── node_modules/
├── config.js
├── index.html
├── lib
│ ├── app.html
│ ├── app.js
│ └── bootstrap.js
└── package.json
现在我们开始启动实时服务器,以启动一个服务器和一个带有实时重载功能的浏览器。为此,我们只需在我们的项目文件夹内命令行中执行以下命令即可:
live-server
如果一切顺利,你将打开一个显示Hello World!的网页浏览器。
回顾
让我们回顾一下我们到目前为止所做的工作:
-
我们使用 NPM 和 JSPM 初始化了一个新项目,并使用 JSPM 安装了 Angular 依赖项。
-
我们在
app.js中创建了我们的主要应用程序组件。 -
我们还创建了一个
bootstrap.js脚本,用于包含我们应用程序的 Angular 框架引导。 -
我们通过包含一个与我们的组件选择器属性匹配的元素,将我们的组件添加到
index.html文件中。 -
最后,我们使用了 live server 来启动一个基本的 Web 服务器并打开一个网页浏览器。
创建任务列表
现在我们已经设置了主要的应用组件,我们可以继续完善我们的任务应用。我们将要创建的第二个组件将负责列出任务。遵循组合的概念,我们将创建一个task-list组件,作为我们主要应用组件的子组件。
让我们在lib文件夹中创建一个名为task-list的文件夹和一个名为task-list.js的新 JavaScript 文件,我们将在这里编写我们的组件代码:
import {Component, ViewEncapsulation} from '@angular/core';
import template from './task-list.html!text';
@Component({
selector: 'ngc-task-list',
// The host property allows us to set some properties on the
// HTML element where our component is initialized
host: {
class: 'task-list'
},
template,
encapsulation: ViewEncapsulation.None
})
export class TaskList {
constructor() {
this.tasks = [
{title: 'Task 1', done: false},
{title: 'Task 2', done: true}
];
}
}
我们创建了一个非常简单的task-list组件,该组件内部存储了一个任务列表。这个组件将被附加到匹配 CSS 元素选择器ngc-task-list的 HTML 元素上。
现在,让我们为这个组件创建一个视图来显示任务。正如您从组件 JavaScript 文件中的导入中可以看到,我们正在寻找一个名为task-list.html的文件:
<div *ngFor="let task of tasks" class="task">
<input type="checkbox" [checked]="task.done">
<div class="task__title">{{task.title}}</div>
</div>
我们使用NgFor指令重复具有 class task 的<div>元素,以匹配我们组件任务列表中的任务数量。Angular 中的NgFor指令将从其底层内容创建一个模板元素,并根据表达式评估的结果实例化模板中的元素。我们目前在task-list组件中有两个任务,因此这将创建我们模板的两个实例。
您在lib文件夹内的文件夹结构现在应该类似于这个:
angular-2-components
└── lib
├── app.html
├── app.js
├── bootstrap.js
└── task-list
├── task-list.html
└── task-list.js
为了使我们的任务列表工作,我们剩下的工作是在主要应用组件中包含task-list组件。我们可以继续修改我们的app.js文件,并在其顶部添加以下行:
import {TaskList} from './task-list/task-list';
由于我们想要将task-list组件添加到我们的主要应用视图模板中,我们还需要确保 Angular 在编译视图时知道该组件。为此,我们需要在app.js文件中为主应用组件添加directives属性,并将我们导入的TaskList组件类包含在指令列表中:
...
// Tell Angular to ignore view encapsulation
encapsulation: ViewEncapsulation.None,
directives: [TaskList]
})
...
最后,我们需要在主要应用的模板中包含task-list组件的主机元素,该模板位于app.html文件中:
<ngc-task-list></ngc-task-list>
这些是我们需要做的最后更改,以便使我们的task-list组件工作。要查看您的更改,您可以在angular-2-components目录中执行live-server命令来启动 live server。
回顾
让我们看看我们在上一个构建块中做了什么。通过遵循以下步骤,我们实现了在封装组件内对任务的简单列出:
-
我们创建了包含我们组件逻辑的组件 JavaScript 文件。
-
我们在单独的 HTML 文件中创建了组件的视图。
-
我们在主要应用组件的配置中包含了组件类。
-
我们在主要应用视图模板中包含了组件的 HTML 元素。
正确的封装级别
我们的任务列表显示正确,我们用来实现这一点的代码看起来相当不错。然而,如果我们想遵循更好的组合方法,我们应该重新思考我们的task-list组件的设计。如果我们划一条线来列出任务列表的职责,我们会得到诸如列出任务、向列表添加新任务以及排序和过滤任务列表等内容;然而,操作并不是在单个任务本身上执行的。此外,渲染任务本身超出了任务列表的职责范围。task-list组件应该仅作为任务的容器。
如果我们再次查看我们的代码,我们会发现我们违反了单一职责原则,在task-list组件内部渲染整个任务主体。让我们看看我们如何通过增加封装的粒度来解决这个问题。
目前的目标是进行代码重构练习,也称为提取。我们将任务的相关模板从任务列表模板中提取出来,并创建一个新的组件来封装任务。
为了做到这一点,我们需要在task-list文件夹内创建一个新的子文件夹,命名为task。在这个文件夹内,我们将创建一个名为task.html的模板文件:
<input type="checkbox" [checked]="task.done">
<div class="task__title">{{task.title}}</div>
我们新的task.html文件的内容基本上与我们已经在task-list.html模板中拥有的内容相同。唯一的区别是,我们现在将引用一个新的模型,称为task。
现在,在task文件夹内,让我们创建一个 JavaScript 文件,名为task.js,它将包含我们组件的控制器类:
import {Component, Input, ViewEncapsulation} from '@angular/core';
import template from './task.html!text';
@Component({
selector: 'ngc-task',
host: {
class: 'task'
},
template,
encapsulation: ViewEncapsulation.None
})
export class Task {
// Our task model can be attached on the host within the view
@Input() task;
}
在本书的前一章中,我们讨论了封装和为 UI 组件建立干净封装的先决条件。这些先决条件之一是能够在组件内外设计适当的接口。这些输入和输出方法是使组件在组合中工作所必需的。这就是组件如何接收和发布信息的方式。
如您从我们的任务组件实现中看到的那样,我们现在正在使用类实例字段上的@Input注解来构建这样一个接口。为了使用这个注解,我们首先需要从 angular 核心模块中导入它。
Angular 中的输入属性允许我们将模板中的表达式绑定到组件的类实例字段上。这样,我们可以通过组件的模板从组件外部传递数据到组件内部。这可以被视为从视图到组件的单向绑定示例。
如果我们在常规 DOM 属性上使用属性绑定,Angular 将直接将表达式绑定到元素的 DOM 属性上。我们正在使用这种类型的绑定来将任务完成标志绑定到复选框的input元素的checked属性上:
| 用法 | 描述 |
|---|---|
@Input() inputProp; |
这允许我们将inputProp属性绑定到父组件内的组件元素。Angular 假设元素的属性名与input属性名相同。 |
@Input('inp') inputProp; |
您也可以覆盖映射到此输入的属性的名称。在这里,组件的 HTML 元素的inp属性被映射到组件的输入属性inputProp。 |
使用我们新创建的任务组件的最后一步是修改现有的任务列表模板。
我们通过使用在任务组件中指定的选择器内的<ngc-task>元素,将任务组件包含在我们的任务列表模板中。同时,我们在任务元素上创建了一个属性绑定。在那里,我们将当前NgFor迭代的task对象传递给task组件的task输入。我们需要用以下代码行替换task.html文件中的所有现有内容:
<ngc-task *ngFor="let task of tasks"
[task]="task"></ngc-task>
为了使我们的task-list组件能够识别任务组件元素,我们需要将其添加到task-list.js文件中的task-list组件的directives属性中:
...
import {Task} from './task/task';
@Component({
...
directives: [Task]
})
...
恭喜!您已经通过提取任务到其自己的组件中成功重构了您的任务列表,并建立了清晰的封装。此外,我们现在可以说我们的任务列表是由任务组成的。
如果你考虑可维护性和可重用性,这实际上是我们构建应用程序过程中的一个非常重要的步骤。你应该不断寻找这样的封装机会,如果你觉得某些东西可以组织成多个子组件,那么你很可能应该这样做。当然,你也可以做得太过分。实际上并没有一条金科玉律来确定封装粒度应该是多少。
小贴士
组件架构的封装粒度始终取决于上下文。我个人的建议是使用面向对象编程的已知原则,如单一职责,为你的组件树的良好设计打下基础。始终确保你的组件只做它们应该做的事情,正如它们的名称所暗示的那样。任务列表有列出任务和提供一些过滤器或其他控制列表的责任。操作单个任务数据并渲染必要视图的责任显然属于任务组件,而不是任务列表。
回顾
在这个构建块中,我们通过使用子组件清理了我们的组件树,并建立了清晰的封装。然后,我们通过输入绑定设置了 Angular 提供的接口。我们通过以下步骤执行了这些操作:
-
我们创建了一个任务子组件。
-
我们使用了任务子组件和
task-list组件。 -
我们使用输入绑定和 DOM 元素属性绑定在任务组件中建立单向数据绑定。
输入生成输出
我们的任务列表看起来已经很不错了,但如果用户无法向列表中添加新任务,那将毫无用处。让我们一起创建一个用于输入新任务的组件。由于这个组件属于 task-list 组件,我们将在 task-list 文件夹内创建一个新的文件夹名为 enter-task。这个组件的职责将包括处理所有必要的 UI 逻辑,以便输入一个新任务。
使用与我们的其他组件相同的命名约定,让我们创建一个名为 enter-task.html 的文件来存储我们组件的模板:
<input type="text" class="enter-task__title-input"
placeholder="Enter new task title..."
#titleInput>
<button class="button" (click)="enterTask(titleInput)">
Add Task
</button>
这个模板包括一个输入字段以及一个用于输入新任务的按钮。在这里,我们通过指定我们的输入字段应该具有引用名称 #titleInput 来使用所谓的局部视图变量。我们可以在当前组件视图中通过名称 titleInput 来引用这个变量。
在这种情况下,我们实际上使用这个变量将输入字段 DOM 元素传递给我们在点击 添加任务 按钮时调用的 enterTask 函数。
让我们通过在新建的 enter-task.js 文件中使用以下代码来查看我们用于输入新任务的 Component 类的实现:
import {Component, Output, ViewEncapsulation, EventEmitter} from '@angular/core';
import template from './enter-task.html!text';
@Component({
selector: 'ngc-enter-task',
host: { class: 'enter-task' },
template,
encapsulation: ViewEncapsulation.None
})
export class EnterTask {
// Event emitter that gets fired once a task is entered.
@Output() taskEntered = new EventEmitter();
// This function will fire the taskEntered event emitter
// and reset the task title input field.
enterTask(titleInput) {
this.taskEntered.next(titleInput.value);
titleInput.value = '';
titleInput.focus();
}
}
对于这个组件,我们选择了一种设计方法,即我们使用一种松散的关系来处理任务列表,实际的任务将在其中创建。尽管这个组件与任务列表密切相关,但最好将组件保持得尽可能松散耦合。
控制反转的一种最简单形式是回调函数或事件监听器,这是一个建立松散耦合的绝佳原则。在这个组件中,我们使用 @Output 注解来创建一个事件发射器。输出属性需要是组件中的实例字段,它们持有事件发射器。在组件的 HTML 元素上,我们可以使用事件绑定来捕获任何发出的事件。这为我们提供了极大的灵活性,我们可以用它来创建一个干净的应用程序设计,通过视图中的绑定将组件粘合在一起:
| 使用 | 描述 |
|---|
| @Output() outputProp = new EventEmitter(); | 当调用 outputProp.next() 时,组件上会发出一个名为 outputProp 的自定义事件。Angular 将在组件的 HTML 元素(组件被使用的地方)上查找事件绑定并执行它们:
<my-comp (output-prop)= "doSomething()">
在事件绑定中的表达式内,你将始终可以访问一个名为 $event 的合成变量。这个变量是对 EventEmitter 发射的数据的引用。|
| @Output('out') outputProp = new EventEmitter(); | 如果你希望你的事件名称与属性名称不同,可以使用这种方式声明你的输出属性。在这个例子中,当调用 outputProp.next() 时,将触发一个名为 out 的自定义事件:
<my-comp (out)= "doSomething()">
|
好的,让我们使用这个新创建的组件在我们的 task-list 组件中添加新的任务。首先,让我们修改 task-list 组件的现有模板。在 task-list 组件文件夹中打开文件,task-list.html。我们需要将 EnterTask 组件添加到模板中,并处理我们将要发射的自定义事件,一旦在组件中输入了新的任务:
<ngc-enter-task (taskEntered)="addTask($event)">
</ngc-enter-task>
<ngc-task *ngFor="let task of tasks"
[task]="task"></ngc-task>
由于 enter-task 组件中的输出属性名为 taskEntered,我们可以使用事件绑定属性 (taskEntered)="" 在宿主元素上将其绑定。
在事件绑定表达式中,我们调用 task-list 组件上的 addTask 函数。我们还使用了合成变量 $event,它包含从 enter-task 组件发射的任务标题。现在,每次我们在 enter-task 组件中按下按钮并从组件中发射事件时,我们都会在事件绑定中捕获该事件,并在 task-list 组件中处理它。
我们还需要对 task-list 组件的 JavaScript 文件做一些小的修改。让我们打开 task-list.js 并对其进行以下修改:
...
// The component for entering new tasks
import {EnterTask} from './enter-task/enter-task';
@Component({
...
directives: [Task, EnterTask]
})
export class TaskList {
...
// Function to add a task from the view
addTask(title) {
this.tasks.push({
title, done: false
});
}
}
在 task-list 组件模块中,我们唯一改变的是其能够在指令属性中声明 EnterTask 组件,以便编译器正确识别我们的 enter-task 组件。
我们还添加了一个名为 addTask 的函数,该函数将带有传递给函数的标题的新任务添加到我们的任务列表中。现在闭环完成,我们的 enter-task 组件的事件被路由到 task-list 组件的视图中这个函数。
您现在可以从项目目录中启动 live-server,以使用 live-server 命令测试新添加的功能。
回顾
我们为任务列表添加了一个新的子组件,该组件负责提供添加新任务的 UI 逻辑。换句话说,我们已经涵盖了以下主题:
-
我们创建了一个使用输出属性和事件发射器松散耦合的子组件。
-
我们学习了关于
@Output注解及其如何用于创建输出属性的使用方法。 -
我们使用事件绑定将行为链接在一起,从组件的视图来看。
自定义 UI 元素
浏览器中的标准 UI 元素非常出色,但有时,现代 Web 应用程序需要比浏览器内可用的更复杂和智能的输入元素。
现在,我们将创建两个特定的自定义 UI 元素,我们将使用这些元素在我们的应用程序中提供良好的用户体验:
-
复选框:浏览器中已经有一个原生的复选框输入,但有时很难将其融入应用程序的视觉设计中。原生复选框在样式方面有限制,因此很难使其看起来很棒。有时,正是这些细节让应用程序看起来很有吸引力。
-
切换按钮:这是一个切换按钮列表,其中列表中只能切换一个按钮。它们也可以用原生的单选按钮列表来表示。然而,就像原生的复选框一样,单选按钮有时并不是解决问题的最佳视觉解决方案。一个既代表单选用户输入元素又表示切换按钮列表的列表更加现代,并提供了我们所需的视觉方面。此外,谁不喜欢按按钮呢?
让我们首先创建我们的自定义复选框 UI 元素。由于我们可能会想出几个自定义 UI 元素,首先让我们在lib文件夹内创建一个名为ui的新子文件夹。
在ui文件夹内,我们现在为我们的复选框组件创建一个名为checkbox的文件夹。从我们新组件的模板开始,我们现在在checkbox文件夹内创建一个名为checkbox.html的文件:
<input type="checkbox"
[checked]="checked"
(change)="onChecke
dChange($event.target.checked)">
{{label}}
在checkbox输入上,我们有两个绑定。首先,我们对 DOM 元素上的checked属性进行属性绑定。我们将 DOM 属性绑定到我们即将创建的组件的checked成员字段上。
此外,我们在输入元素上有一个事件绑定,我们监听复选框变化的 DOM 事件,并在我们的组件类上调用onCheckedChange方法。我们使用合成变量$event传递复选框 DOM 元素上的checked属性,其中变化事件发生。
接下来,让我们看看我们的component类实现,我们需要在checkbox文件夹内创建一个名为checkbox.js的文件:
import {Component, Input, Output, ViewEncapsulation, EventEmitter} from '@angular/core';
import template from './checkbox.html!text';
@Component({
selector: 'ngc-checkbox',
host: { class: 'checkbox' },
template,
encapsulation: ViewEncapsulation.None
})
export class Checkbox {
// An optional label can be set for the checkbox
@Input() label;
// If the checkbox is checked or unchecked
@Input() checked;
// Event emitter when checked is changed using the convention
// for two way binding with [(checked)] syntax.
@Output() checkedChange = new EventEmitter();
// This function will trigger the checked event emitter
onCheckedChange(checked) {
this.checkedChange.next(checked);
}
}
如果我们首先看这个component类,它并没有什么特别之处。它使用一个输入属性从外部设置选中状态,并且它还有一个带有事件发射器的输出属性,允许我们通过自定义事件通知外部组件关于选中状态的变化。然而,有一个命名约定使得这个组件有点特别。使用输入属性名同时作为输出属性名,并在其后添加单词change的约定实际上是在启用使用该组件的开发者利用双向数据绑定模板简写。
Angular 并不自带双向数据绑定。然而,创建双向绑定相当容易。实际上,双向数据绑定与组合属性绑定和事件绑定并无不同。
以下示例在输入字段上创建了一个非常简单的双向数据绑定过程:
<input type="text" (input)="value = $event.target.value"
[value]="value">
Angular 的简洁性和扩展浏览器原生功能的一般方法使得实现这一机制变得轻而易举。
在组件及其子组件之间实现双向数据绑定并不太难。我们唯一需要关注的是子组件涉及输入和输出属性。
请查看以下截图:

组件成员变量与子组件之间的双向数据绑定
由于双向数据绑定在 Angular 中是一个高度请求的功能,因此有一个方便的简写来编写它。让我们看看一些如何在组件模板和其子组件之间实现数据绑定的示例:
| 子组件属性 | 组件模板中的绑定 |
|---|---|
@Input() text;``@Output() textOut = new EventEmitter(); |
<sc [text]="myText"
(textOut)="myText = $event">
我们将组件的myText属性绑定到子组件的文本输入。同时,我们捕获从子组件发出的textOut事件并更新我们的myText属性。|
@Input() text;``@Output() textChange = new EventEmitter(); |
|---|
<sc [(text)]="myText">
我们可以通过使用命名约定将“change”一词附加到事件发射器标识符上来简化这种双向数据绑定。这样,我们就可以在模板中使用[(property)]符号来使用双向数据绑定的简写。
如果我们再次查看我们的checkbox组件实现,我们会看到我们正在使用双向数据绑定的命名约定来为我们的组件的选中属性命名。这样,我们就可以在我们的自定义复选框 UI 组件的任何地方使用模板简写来启用双向数据绑定。
让我们将复选框集成到任务组件中,以替换我们目前在该处使用的原生复选框输入。为此,我们需要修改task-list/task文件夹中的task.html文件,将task.html文件中的原生输入复选框替换为以下代码行:
<ngc-checkbox [(checked)]="task.done"></ngc-checkbox>
和往常一样,我们还需要告诉任务组件我们希望在模板中使用该组件。让我们相应地更改task.js文件中的代码:
...
import {..., HostBinding} from '@angular/core';
// Each task has a checkbox component for marking tasks as done.
import {Checkbox} from '../../ui/checkbox/checkbox';
@Component({
...
// We need to specify that this component relies on the Checkbox
// component within the view.
directives: [Checkbox]
})
export class Task {
// Our task model can be attached on the host within the view
@Input() task;
@HostBinding('class.task--done')
get done() {
return this.task && this.task.done;
}
}
我们已经了解了组件上的宿主属性。它允许我们在组件宿主元素上设置属性和事件绑定。宿主元素是我们组件在父组件中初始化的 DOM 元素。
我们还可以通过另一种方式在组件宿主元素上设置属性,这在我们需要根据组件中的某些数据设置属性时非常有用。
使用@HostBinding注解,我们可以在组件宿主元素上创建基于我们组件内部成员的属性绑定。让我们使用这个注解来创建一个条件性地在组件的 HTML 元素上设置task--done类的绑定。这用于在我们的样式中对已完成任务进行一些视觉区分。
这只是将我们自定义的复选框 UI 组件集成到任务组件中的最后一步。现在你可以启动live-server来查看你的更改,并在任务列表中玩转这些新的大复选框。这难道不是比激活常规复选框更有趣吗?不要低估一个令人愉悦的用户界面的效果。这可能会对你的产品使用产生非常积极的影响。

在添加我们的自定义复选框组件后,我们的任务列表
现在我们已经创建了我们的checkbox组件,让我们继续创建另一个用于切换按钮的 UI 组件,我们将在下一个主题中使用它。我们需要在ui文件夹内创建一个名为toggle的文件夹,并在toggle文件夹内创建一个名为toggle.html的模板:
<button class="button button--toggle"
*ngFor="let button of buttonList"
[class.button--active]="button === selectedButton"
(click)="onButtonActivate(button)">{{button}}</button>
这里实际上没有什么特别的!我们通过使用名为buttonList的实例字段并使用NgFor指令迭代来重复一个按钮。这个按钮列表将包含我们的切换按钮的标签。条件性地,我们使用属性绑定并检查它是否与迭代中的当前按钮(名为selectedButton的实例字段)匹配来设置一个名为button--active的类。当按钮被点击时,我们在我们的组件类上调用一个名为onButtonActivate的方法,并传递迭代中的当前按钮标签。
让我们在toggle文件夹内创建toggle.js并实现component类:
import {Component, Input, Output, ViewEncapsulation, EventEmitter} from '@angular/core';
import template from './toggle.html!text';
@Component({
selector: 'ngc-toggle',
host: {
class: 'toggle'
},
template,
encapsulation: ViewEncapsulation.None
})
export class Toggle {
// A list of objects that will be used as button values.
@Input() buttonList;
// Input and state of which button is selected needs to refer to
// an object within buttonList
@Input() selectedButton;
// Event emitter when selectedButton is changed using the
// convention for two way binding with [(selected-button)]
// syntax.
@Output() selectedButtonChange = new EventEmitter();
// Callback within the component lifecycle that will be called
// after the constructor and inputs have been set.
ngOnInit() {
if (this.selectedButton === undefined) {
this.selectedButton = this.buttonList[0];
}
}
// Method to set selected button and trigger event emitter.
onButtonActivate(button) {
this.selectedButton = button;
this.selectedButtonChange.next(button);
}
}
在我们的toggle组件中,我们依赖于buttonList成员是一个对象数组,因为我们正在使用这个数组在我们的模板中通过NgFor指令。buttonList成员被注释为输入属性;这样,我们可以将数组传递到组件中。
对于selectedButton成员,它持有当前选中的buttonList数组中的对象,我们使用双向数据绑定方法。这样,我们不仅可以从组件外部设置切换按钮,还可以通过toggle组件在 UI 中切换按钮时收到通知。
在onButtonActivate函数中,我们设置selectedButton成员并触发事件发射器。
ngOnInit方法实际上是由 Angular 在指令和组件的生命周期内调用的。在selectedButton输入属性未指定的情况下,我们将添加一个检查并从可用的按钮列表中选择第一个按钮。由于selectedButton以及buttonList既是实例字段也是输入属性,我们需要等待它们初始化以执行此逻辑。重要的是不要在组件构造函数内执行此初始化。生命周期钩子OnInit将在指令输入和输出属性第一次检查后调用。它仅在指令构造时被调用一次。
提示
Angular 会自动调用您组件上实现的所有生命周期钩子。
下一个图示说明了 Angular 组件的生命周期。在组件构造时,所有生命周期钩子将按照图示的顺序被调用,除了OnDestroy钩子,它将在组件销毁时被调用。
变更检测也会启动生命周期钩子的一部分,其中将按照以下顺序至少有两个周期:
-
doCheck -
afterContentChecked -
afterViewChecked -
onChanges(如果检测到任何更改)
有关生命周期钩子和它们目的的详细描述可在 Angular 文档网站上找到,网址为angular.io/docs/ts/latest/guide/lifecycle-hooks.html。

Angular 组件生命周期的示意图
概述
在这个模块中,你学习了如何构建通用的、松散耦合的定制 UI 组件,以便它们可以作为子组件在其他组件中使用。我们还完成了以下任务:
-
我们创建了一个使用输出属性和事件发射器的松散耦合子组件。
-
我们学习了
@Output注解是什么以及如何使用它来创建输出属性。 -
我们使用
@HostBinding注解在组件类内部声明性地创建属性绑定。 -
我们使用事件绑定将组件视图的行为链接在一起。
-
我们使用绑定简写构建了双向数据绑定。
-
我们了解了 Angular 组件的生命周期以及如何使用
OnInit生命周期钩子在输入和输出第一次处理完毕后初始化组件。
过滤任务
这是本章的最后一个构建块。我们已经学习了大量关于构建基本组件以及如何将它们组合在一起以形成更大组件的知识。在前一个构建块中,我们创建了可以在其他组件中使用的通用 UI 组件。在本主题中,我们将使用切换按钮组件不仅为我们的任务列表创建过滤器,而且通过使用数据服务来改进接收和存储任务的方式。
让我们继续进行另一个重构练习。到目前为止,我们已经在task-list组件中直接存储了任务列表数据,但在这里让我们改变这一点,并使用一个将为我们提供任务的服务。
我们的服务仍然不会使用数据库,但我们将从组件中获取任务数据。为了使用服务,我们第一次使用了 Angular 的依赖注入。
让我们在应用程序的lib/task-list文件夹中创建一个名为task-list-service.js的新文件:
// Classes which we'd like to provide for dependency injection
// need to be annotated using this decorator
import {Injectable} from '@angular/core';
@Injectable()
export class TaskListService {
constructor() {
this.tasks = [
{title: 'Task 1', done: false},
{title: 'Task 2', done: false},
{title: 'Task 3', done: true},
{title: 'Task 4', done: false}
];
}
}
我们已经将所有任务数据移动到新创建的服务中。为了使我们的服务类可注入,我们需要用@Injectable注解来装饰它。
让我们对task-list组件进行一些修改,并修改task-list文件夹内的task-list.js文件。文件中的修改代码在以下代码摘录中突出显示:
import {..., Inject} from '@angular/core';
// The dummy task service where we get our tasks from
import {TaskListService} from './task-list-service';
...
// We also need a Toggle UI component to provide a filter
import {Toggle} from '../ui/toggle/toggle';
@Component({
...
// Set the TaskListService as host provider
providers: [TaskListService],
// Specify all directives / components that are used in the view
directives: [Task, EnterTask, Toggle]
})
export class TaskList {
// Inject the TaskListService and set our filter data
constructor(@Inject(TaskListService) taskListService) {
this.taskListService = taskListService;
this.taskFilterList = ['all', 'open', 'done'];
this.selectedTaskFilter = 'all';
}
// Method that returns a filtered list of tasks based on the
// selected task filter string.
getFilteredTasks() {
return this.taskListService.tasks ? this.taskListService.tasks.filter((task) => {
if (this.selectedTaskFilter === 'all') {
return true;
} else if (this.selectedTaskFilter === 'open') {
return !task.done;
} else {
return task.done;
}
}) : [];
}
// Method to add a task from the view
addTask(title) {
this.taskListService.tasks.push({
title,
done: false
});
}
}
在我们的模块的导入部分,我们将导入任务列表服务。我们将在组件构造函数中使用依赖注入来接收TaskListService类的实例。为此,我们将使用一个新的注解,它允许我们指定我们想要注入的类型。为了使用@Inject注解,Inject装饰器需要从 Angular 核心模块中导入。如果你查看我们的构造函数,你会发现我们正在那里使用@Inject注解来指定我们想要注入的实例类型。
除了构造函数上的@Inject注解外,我们还需要最后一件事来使注入工作。我们需要将TaskListService注册为@Component注解的providers属性中的一个提供者。
现在我们得到在指令构造时注入的TaskListService,我们可以在实例字段中存储对其的引用。
在组件的构造函数中,我们还想存储任务状态过滤器可以具有的状态列表。此列表也将作为切换按钮列表的输入。如果你还记得我们的切换按钮的输入属性,我们有一个接受按钮标签列表的buttonList输入。为了存储当前选定的过滤器类型,我们使用一个名为selectedTaskFilter的实例字段。
我们需要添加到task-list组件中的最后一部分是getFilteredTasks方法。我们不再需要在实例字段中直接存储任务列表,任务应该只通过此方法在组件中接收。方法内部的逻辑检查selectedTaskFilter属性,并返回满足此条件的过滤列表。
由于我们想要使用在前一个主题中创建的切换按钮组件来创建一个过滤器按钮列表,因此我们需要在导入部分导入切换组件,并将Toggle类添加到我们的directives属性中。现在我们可以在task-list组件的模板中使用切换组件。
好的,这就是我们将在组件实现中进行的所有更改。我们想要更改视图模板,以便使用来自数据服务的数据过滤任务列表,并显示一个切换按钮列表以激活不同的过滤器类型。让我们打开位于task-list文件夹中的模板文件task-list.html,并使用以下内容进行修改:
<ngc-toggle [buttonList]="taskFilterList"
[(selectedButton)]="selectedTaskFilter">
</ngc-toggle>
<ngc-enter-task (taskEntered)="addTask($event)">
</ngc-enter-task>
<ngc-task *ngFor="let task of getFilteredTasks()"
[task]="task"></ngc-task>
由于我们在 task-list 组件的 directives 属性中添加了切换组件,现在我们可以在视图模板中使用它。我们将输入属性 buttonList 绑定到存储在 task-list 组件中的 taskFilterList。此外,我们使用双向数据绑定将切换按钮列表的 selectedButton 输入属性绑定到任务列表的 selectedTaskFilter 实例字段。这样,我们不仅可以从 task-list 组件中以编程方式更新选定的任务过滤器,还可以允许用户通过切换按钮列表来更改值。
现在我们只需要对重复任务列表中的任务元素的 NgFor 指令进行微小修改。由于我们现在需要使用 getFilteredTasks 方法访问 task-list 组件的任务,我们还需要在我们的重复表达式中使用该方法。
就这样了,恭喜!你已经成功通过重用我们在前一个主题中创建的切换组件,为你的任务列表添加了过滤机制。你现在可以启动你的实时服务器(使用 live-server 命令),应该会看到一个功能齐全的任务列表,你可以输入新任务并过滤任务列表:

新增切换按钮组件的截图,用于过滤任务状态
摘要
在本章中,你学习了使用 Angular 构建基于 UI 组件的应用程序的大量新概念。我们还构建了我们任务管理应用程序的核心组件,即任务列表本身。你了解了输入和输出属性的概念以及如何使用它们来构建双向数据绑定。
我们还涵盖了 Angular 组件生命周期的基本知识以及如何使用生命周期钩子来执行初始化后的步骤。
作为最后一步,我们在任务列表中集成了切换按钮列表组件以过滤任务状态。我们将 task-list 组件重构以使用服务来获取任务数据。为此,我们使用了 Angular 的依赖注入。
第三章。使用组件进行组合
在本章中,我们将进一步构建我们的应用程序结构,并处理作为我们任务管理系统基础的布局和架构。除了引入新组件和通过现有组件创建更大的组合外,我们还将探讨我们处理数据的方式。到目前为止,我们已经从上一章中创建的TaskListService同步获取了任务数据。然而,在现实世界的场景中,这种情况很少发生。在实际应用中,数据大多以异步形式检索。通常,我们通过 RESTful Web 服务获取数据,并使用XMLHttpRequest或最近标准化的 fetch API。然而,由于我们试图构建一个前沿的应用程序,我们将更进一步。在本章中,我们将探讨如何使用 RxJS 重构我们的应用程序以处理可观察数据结构——RxJS 是一个在 Angular 中使用的功能性和响应式编程库。
在本章中,我们将探讨以下主题:
-
重构我们的应用程序以处理可观察数据结构
-
RxJS 及其操作符的基本知识,以便构建响应式数据模型
-
在 Angular 中使用纯组件
-
为纯组件使用
ChangeDetectionStrategy.OnPush -
使用内容投影点和
@ContentChildren创建Tab组件 -
创建一个简单的导航组件
-
注入父组件并建立直接组件通信
-
结合内部和外部内容以创建灵活的组件 API
数据 – 从伪造到真实
从本章开始,我们将切换到基于文档的数据库来存储我们的任务和项目数据。作为一个数据存储,我们使用 PouchDB 项目,这是一个设计用于与 IndexedDB 一起运行并在各种回退策略中运行的浏览器数据库。PouchDB 的设计类似于 Apache CouchDB,并且甚至可以与之同步。
为了在你构建应用程序的过程中为你提供优质体验,我们工作在真实生活条件下是非常重要的。这意味着我们应该在我们的组件中使用异步数据,而不是依赖于简单的 JavaScript 数据数组。为了使这个过程尽可能顺畅,整个数据层已经为你设置好了,你不需要过多地担心内部细节。当然,如果你仍然感兴趣,我不会阻止你探索位于data-access文件夹中的源代码。
使用可观察数据结构进行响应式编程
到目前为止,我们在创建的任务列表中使用了简单的数组数据结构。这并不是我们在现实世界场景中会遇到的。在实际应用中,我们必须处理异步数据和需要在用户之间同步的数据变化。现代应用程序的要求有时甚至更严格,还需要实时提供更改数据的视图更新。既然我们正在构建一个现代任务管理系统,我们应该努力跟上这些要求。
这两者,处理异步数据和处理实时数据更新,都需要对我们应用程序中的数据流进行重大重构。使用可观察数据结构,我们使我们的应用程序能够掌握异步数据的挑战,在这些数据中我们需要对变化做出反应。
在应用程序中处理数据的行为与流非常相似。你接收输入,转换它,组合它,合并它,最后将其写入输出。在类似这样的系统中,输入也很有可能是连续的,有时甚至是无限期的。以实时流为例;这类数据持续流动,数据也无限流动。函数式和响应式编程是帮助我们以更干净的方式处理这类数据的范式。

一个简单的可观察订阅,带有值发射和转换
Angular 2 在其核心是响应式的,整个变化检测和绑定都是使用响应式架构构建的。我们在上一章中学到的组件的输入和输出,不过是一个使用响应式事件驱动方法建立的数据流。Angular 使用 RxJS,一个用于 JavaScript 的函数式和响应式编程库,来实现这个数据流。实际上,我们用来从组件内部发送自定义事件的 EventEmitter,只是 RxJS 可观察对象的一个包装。
响应式和函数式编程正是我们为了处理异步数据和数据变化而重新设计应用程序所寻找的。因为我们已经从 Angular 的生产依赖中获得了 RxJS,所以让我们使用它来从我们的数据源到我们的应用程序建立一个连续的数据流。我们项目 data-access 文件夹中存在的 DataProvider 服务使用 RxJS 为我们的数据存储提供了一个很好的包装。由于我们将在整个应用程序中使用此服务,我们可以在 bootstrap.js 文件中直接将其提供给 bootstrap,如下所示:
// Import Angular bootstrap function
import {bootstrap} from '@angular/platform-browser-dynamic';
import {DataProvider} from '../data-access/data-provider';
// Import our main app component
import {App} from './app';
bootstrap(App, [
DataProvider
]);
作为 Angular 的 bootstrap 函数的第二个参数,我们可以提供应用级别的依赖项,这些依赖项将在所有组件和指令中可用。
现在我们使用 DataProvider 服务作为抽象来从 PouchDB 数据存储中获取数据,并创建一个新的服务,负责提供项目数据。
我们将在 lib/project/project-service/project-service.js 路径上创建一个新的 ProjectService 类,如下所示:
import {Injectable, Inject} from '@angular/core';
import {ReplaySubject} from 'rxjs/Rx';
import {DataProvider} from '../../../data-access/data-provider';
@Injectable()
export class ProjectService {
constructor(@Inject(DataProvider) dataProvider) {
…
}
}
查看我们新模块的导入部分,你可以看到我们从 Angular 核心模块中导入了必要的依赖项以进行依赖注入。我们的服务类使用 @Injectable 装饰器,这样我们就可以将其提供给组件的注入器。我们还将在新创建的服务构造函数中注入 DataProvider 服务。
我们从 RxJS 库中导入的 ReplaySubject 类用于使我们的服务变得响应式。在 RxJS 世界中,一个主题(subject)既是观察者(observer)也是可观察的(observable)。它可以观察某些变化,然后向所有订阅者发出进一步的通知。你可以将主题想象成一个代理,它位于变化源和一组观察者之间。每当源发出变化时,主题将通知所有订阅者这些变化。
现在,ReplaySubject 类是一种特殊的主题,它允许你在添加新订阅者时回放一个变化缓冲区。这在始终需要向订阅者提供一些初始数据时特别有用。想象一下我们的数据,我们希望将其传播到 UI 中。我们希望在订阅我们的服务时立即获取初始数据,然后继续,我们也希望得到关于变化的通知。使用仅缓冲一个变化的 ReplaySubject 类,可以完美地满足这种用例。
让我们看看以下图,它说明了 ReplaySubject 的行为:

使用 ReplaySubject 类连接到观察者的源,该类缓冲最新值并在订阅时发出
在前面的图中,你可以看到我们正在将一个 ReplaySubject 类连接到一个随时间发出值变化的源。在两次发出后,一个观察者订阅了我们的 ReplaySubject 类。然后 ReplaySubject 将所有缓冲的变化回放到新订阅者,就像这些事件刚刚发生一样。在这个例子中,我们使用了一个回放缓冲长度为 1。在随后的值发出时,这些值将直接重新发出到 ReplaySubject 类的订阅者。
让我们回到我们的 ProjectService 类,并在构造函数中添加一些逻辑,以便使用 ReplaySubject 类发出项目数据。
我们将开始一些成员字段的初始化,我们需要实现以下逻辑:
this.dataProvider = dataProvider;
this.projects = [];
// We're exposing a replay subject that will emit events whenever
// the projects list change
this.change = new ReplaySubject(1);
注意,我们创建了一个具有一个缓冲长度的 ReplaySubject 类,并将其分配给名为 change 的成员字段。
我们还将之前在构造函数参数中注入的 DataProvider 服务分配给 dataProvider 成员字段。
现在,是时候利用 DataProvider 服务来订阅数据存储中的任何变更了。这建立了一个对我们的 PouchDB 中存储的数据的响应式连接:
// Setting up our functional reactive subscription to receive
// project changes from the database
this.projectsSubscription = this.dataProvider.getLiveChanges()
// First convert the change records to actual documents
.map((change) => change.doc)
// Filter so that we only receive project documents
.filter((document) => document.type === 'project')
// Finally we subscribe to the change observer and deal with
// project changes in the function parameter
.subscribe((changedProject) => {
this.projects = this.projects.slice();
// On every project change we need to update our projects list
const projectIndex = this.projects.findIndex(
(project) => project._id === changedProject._id
);
if (projectIndex === -1) {
this.projects.push(changedProject);
} else {
this.projects.splice(projectIndex, 1, changedProject);
}
// Emit an event on our replay subject
this.change.next(this.projects);
});
由 getLiveChanges() 函数返回的可观测量会以变更的形式在我们的数据存储中发出数据。除此之外,这还会在我们在收到初始数据之后对存储应用任何后续变更时发出这些变更。你可以想象一个持续连接到数据库的连接,每当数据库中的文档被更新时,我们的观察者都会接收到这个变更值。
可观测量提供了一大堆所谓的算子,这些算子允许你转换来自可观测量的数据流。你可能已经从 ECMAScript 5 数组扩展函数中了解到一些这些函数算子,例如 map 和 filter。使用算子,你可以模拟整个转换流程,直到你最终订阅数据。
当我们从数据存储中接收到变更对象时,我们首先需要将它们转换成文档对象。这相当简单,因为每个变更对象都包含一个 doc 属性,实际上它持有我们已接收更新的变更文档的全部数据。使用 map 函数,我们可以在将变更对象返回到数据流之前,将它们转换成项目对象:
.map((change) => change.doc)
DataProvider 将为我们提供存储中所有文档的数据。由于我们目前只对项目数据感兴趣,我们还应用了一个过滤器,过滤掉所有不是 project 类型的文档:
.filter((document) => document.type === 'project')
最后,我们可以订阅转换后的流,如下所示:
.subscribe((changedProject) => {})
subscribe 算子是我们观察路径的终止点。这就像是我们坐下来观察的终点。在我们的 subscribe 函数内部,我们监听项目文档的更新,并将它们纳入 App 组件的 projects 成员属性中。这包括不仅添加到文档存储中的新项目,还包括更新现有项目。每个项目都包含一个可以通过 _id 属性访问的唯一标识符。这使我们能够轻松找到正确的操作。
在更新我们的项目实际视图并将项目列表存储在 projects 成员字段之后,我们可以使用我们的 ReplaySubject 类发出更新后的列表:
this.change.next(this.projects);
我们的 ProjectService 类现在已准备好使用,需要获取项目数据的应用组件可以订阅暴露的 ReplaySubject 类,以便对这些数据变更做出反应。
让我们在 lib/app.js 中重构 App 组件,并移除我们迄今为止使用的假 TaskListService:
import {Component, ViewEncapsulation, Inject} from '@angular/core';
import {ProjectService} from './project/project-service/project-service';
import template from './app.html!text';
@Component({
selector: 'ngc-app',
template,
encapsulation: ViewEncapsulation.None,
providers: [ProjectService]
})
export class App {
constructor(@Inject(ProjectService) projectService) {
this.projectService = projectService;
this.projects = [];
// Setting up our functional reactive subscription to receive
// project changes
this.projectsSubscription = projectService.change
// We subscribe to the change observer of our service
.subscribe((projects) => {
this.projects = projects;
});
}
// If this component gets destroyed, we need to remember to
// clean up the project subscription
ngOnDestroy() {
this.projectsSubscription.unsubscribe();
}
}
在我们的 App 组件中,我们现在通过 ProjectService 类上的变化可观察对象来获取项目列表。通过在服务上使用对 ReplaySubject 的响应式订阅,我们确保存储在 App 组件中的项目列表在发生任何更改后都会更新。
我们使用 OnDestroy 生命周期钩子来取消订阅 ProjectService 的变化可观察对象。如果你喜欢在应用程序中进行适当的清理,这是一个必要的手动步骤。根据来源,忘记取消订阅可能会导致内存泄漏。
通过前面的代码,我们已经为响应式数据架构奠定了基础。我们观察数据存储的变化,并且我们的用户界面会对其做出反应:

这张图展示了从我们的数据存储到视图的端到端响应式数据流。
不可变性
不可变性最初是函数式编程的核心概念。这个主题不会深入探讨不可变性,但它将解释核心概念,以便我们可以讨论如何将这个概念应用到 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一样简单。
纯组件
“纯”组件的想法是它的整个状态由其输入表示,其中所有输入都是不可变的。这实际上是一个无状态组件,但除此之外,所有输入都是不可变的。
我喜欢称这样的组件为“纯”,因为它们的行为可以与函数式编程中纯函数的概念相比较。纯函数是一个具有以下属性的函数:
-
它不依赖于函数作用域之外的状态
-
如果输入参数没有改变,它总是表现相同
-
它永远不会在函数作用域之外改变任何状态(副作用)
使用纯组件,我们有一个简单的保证。纯组件在其输入参数未被更改的情况下永远不会改变。在检测变化时,我们可以忽略组件及其子组件,直到其中一个组件的输入发生变化。坚持这种关于组件的想法给我们带来几个优点。
纯组件的推理非常简单,它们的行为可以很容易地预测。让我们看看一个使用纯组件的组件树简单示例:

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

图中显示了带有“纯”组件的标记路径(黑色)用于变化检测。
尽管根组件的状态发生了变化,这也改变了两级子组件的输入属性,但我们只需要在考虑系统可能发生的变更时关注给定的路径。纯组件给我们一个承诺,即如果它们的输入不会改变,它们就不会改变。不可变性在这里起着重要作用。想象一下,你将一个可变对象绑定到组件 B 上,A 组件会改变这个对象的属性。由于我们使用对象引用和可变对象,B 组件的属性也会改变。然而,B 组件无法注意到这个变化,因为我们无法在组件树中跟踪谁知道我们的对象。基本上,我们可能需要再次回到整个树的常规脏检查。
通过知道所有我们的组件都是纯组件并且它们的输入是不可变的,我们可以告诉 Angular 在输入属性值改变之前禁用变更检测。这使得我们的组件树非常高效,Angular 可以有效地优化变更检测。当考虑大型组件树时,这可能会在惊人的快速应用和慢速应用之间产生差异。|
Angular 的变更检测非常灵活,每个组件都有自己的变更检测器。我们可以通过指定组件装饰器的 changeDetection 属性来配置组件的变更检测。
使用 ChangeDetectionStrategy,我们可以从适用于我们组件变更检测的策略列表中进行选择。为了告诉 Angular 只有在不可变输入被更改时我们的组件才应该被检查,我们可以使用 OnPush 策略,它正是为此目的而设计的。
让我们看看组件变更检测策略的不同配置可能性以及一些可能的用例:
| 变更检测策略 | 描述 |
|---|---|
CheckAlways |
这个策略告诉 Angular 在每个变更检测周期中检查这个组件,这显然是最昂贵的策略。这是唯一保证在应用状态可能发生变化的每一个情况下都会检查组件变更的策略。如果我们不处理无状态或不可变组件,或者我们在应用中使用不一致的数据流,这仍然是最可靠的变更检测方法。变更检测将在运行在此组件作用域内的每个浏览器事件上执行。 |
Detached |
这个策略告诉 Angular 完全从变更检测中分离一个组件子树。这个策略可以用来创建一个手动变更检测机制。 |
| OnPush | 这个策略告诉 Angular 给定的组件子树只有在以下条件之一成立时才会改变:
-
其中一个输入属性发生变化,需要不可变变更
-
组件子树内的一个事件绑定正在接收一个事件
|
默认 |
此策略简单地评估为 CheckAlways |
|---|
纯化我们的任务列表
在前面的主题中,我们将我们的主应用程序组件更改为使用 RxJS Observables 来通知我们数据存储中的数据变化。
我们还研究了使用不可变数据结构的基础知识,以及 Angular 可以被配置为假设组件变化仅发生在组件输入变化时(“纯”组件)。由于我们希望获得这种优化带来的性能优势,让我们重构我们的任务列表组件以利用这一点。
在上一章中,我们构建了我们的TaskList组件并将任务数据直接放置在组件中。然后我们重构了我们的代码,以便将任务数据放入服务中并使用注入来获取数据。
现在,我们正在重新构建我们的TaskList组件,使其“纯”并且仅依赖于其输入属性。由于我们将确保流入组件的数据始终是不可变的,我们可以在重构的组件上使用OnPush变更检测策略。这无疑会给我们的任务列表带来性能提升。
与性能同样重要的是,使用纯组件带来的结构化好处。一个“纯”组件不会直接更改任何数据,因为它不允许修改应用程序状态。相反,它使用输出属性来在数据变化时发出事件。这允许我们的父组件对这些事件做出反应,并执行必要的步骤来处理这些变化。因此,父组件可能会更改纯组件的输入属性。这将触发变更检测并有效地改变纯组件的状态。
起初可能听起来有些过于复杂,但实际上这对我们应用程序的结构带来了巨大的好处。这使得我们能够以高信心推理我们的组件。单向数据流以及无状态特性使得理解、检查和测试我们的组件变得容易。此外,输入和输出的松散性质使得我们的组件极其便携。我们可以决定父组件,我们希望传递给组件的数据,以及我们希望如何处理变化。
让我们来看看我们的TaskList组件以及我们如何将其更改为符合我们的“纯”组件概念:
import {Component, ViewEncapsulation, Input, Output, EventEmitter, ChangeDetectionStrategy} from '@angular/core';
import template from './task-list.html!text';
import {Task} from './task/task';
import {EnterTask} from './enter-task/enter-task';
import {Toggle} from '../ui/toggle/toggle';
@Component({
selector: 'ngc-task-list',
host: {
class: 'task-list'
},
template,
encapsulation: ViewEncapsulation.None,
directives: [Task, EnterTask, Toggle],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskList {
@Input() tasks;
// Event emitter for emitting an event once the task list has
// been changed
@Output() tasksUpdated = new EventEmitter();
constructor() {
this.taskFilterList = ['all', 'open', 'done'];
this.selectedTaskFilter = 'all';
}
ngOnChanges(changes) {
if (changes.tasks) {
this.taskFilterChange(this.selectedTaskFilter);
}
}
taskFilterChange(filter) {
this.selectedTaskFilter = filter;
this.filteredTasks = this.tasks ? this.tasks.filter((task) => {
if (filter === 'all') {
return true;
} else if (filter === 'open') {
return !task.done;
} else {
return task.done;
}
}) : [];
}
// Function to add a new task
addTask(title) {
const tasks = this.tasks.slice();
tasks.push({ created: +new Date(), title, done: null });
this.tasksUpdated.next(tasks);
}
}
我们任务列表组件中的所有操作现在都是不可变的。我们从不直接修改作为输入传递的任务数据,而是创建新的任务数据数组以执行可变操作。
从上一节中学到的知识来看,这实际上使我们的组件成为一个“纯”组件。这个组件本身只依赖于其输入,这使得我们的组件非常容易推理。
你可能已经注意到,我们还配置了组件的变更检测策略。由于我们现在有一个“纯”组件,我们可以相应地配置我们的变更检测策略以节省一些性能:
@Component({
selector: 'ngc-task-list',
…
changeDetection: ChangeDetectionStrategy.OnPush
})
由于我们正在为任务列表中的每个数据记录渲染Task组件,我们也应该检查我们可以在那里进行哪些更改,以使这个功能更加完善。
让我们来看看我们的Task组件的变化:
import {Component, Input, Output, EventEmitter, ViewEncapsulation, HostBinding, ChangeDetectionStrategy} from '@angular/core';
import template from './task.html!text';
import {Checkbox} from '../../ui/checkbox/checkbox';
@Component({
selector: 'ngc-task',
host: {
class: 'task'
},
template,
encapsulation: ViewEncapsulation.None,
directives: [Checkbox],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Task {
@Input() task;
// We are using an output to notify our parent about updates
@Output() taskUpdated = new EventEmitter();
@HostBinding('class.task--done')
get done() {
return this.task && this.task.done;
}
// We use this function to update the checked state of our task
markDone(checked) {
this.taskUpdated.next({
title: this.task.title,
done: checked ? +new Date() : null
});
}
}
我们还为我们 的Task组件使用了OnPush策略,我们可以这样做,因为我们也有一个纯组件。这个组件只依赖于其输入。两个输入都期望原生值(标题为String,完成状态为Boolean),这实际上使它们本质上不可变。任务的变化将通过taskUpdated输出属性进行通信。
现在,这是思考在应用程序中放置任务列表的好时机。因为我们正在编写一个允许用户在项目中管理任务的任务管理系统,我们需要有一个容器来封装项目的关注点。我们在路径lib/project/project.js上创建了一个新的Project组件,它将显示项目详情并将TaskList组件作为子组件渲染:
import {Component, ViewEncapsulation, Input, Output, EventEmitter, ChangeDetectionStrategy} from '@angular/core';
import template from './project.html!text';
import {TaskList} from '../task-list/task-list';
@Component({
selector: 'ngc-project',
host: {
class: 'project'
},
template,
encapsulation: ViewEncapsulation.None,
directives: [TaskList],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Project {
@Input() title;
@Input() description;
@Input() tasks;
@Output() projectUpdated = new EventEmitter();
// This function should be called if the task list of the
// project was updated
updateTasks(tasks) {
this.projectUpdated.next({
title: this.title,
description: this.description,
tasks
});
}
}
再次强调,我们使这个组件的状态仅依赖于其不可变输入,并使用OnPush策略以获得使用纯组件的积极性能影响。还重要的是要注意,updateTasks函数充当我们TaskList组件的某种代理。当我们更新TaskList组件内的任务时,我们在项目模板中捕获该事件并调用updateTasks函数,传入新的更新后的任务列表。从这里,我们只是在组件树中向上发出带有新任务列表的更新后的项目数据。
让我们快速看一下Project组件的模板,以了解这个组件背后的连接:
<div class="project__l-header">
<h2 class="project__title">{{title}}</h2>
<p>{{description}}</p>
</div>
<ngc-task-list [tasks]="tasks"
(tasksUpdated)="updateTasks($event)">
</ngc-task-list>
模板中的绑定逻辑告诉我们整个数据流以及我们的纯净组件是如何工作的。虽然Project组件本身接收任务列表作为输入,但它直接将此数据转发到TaskList组件的tasks输入。如果TaskList组件触发tasksUpdated事件,我们就在Project组件上调用updateTasks方法,这实际上只是再次发出一个projectUpdated事件。
概述
我们的任务列表重构现在已经完成,并且我们应用了关于不可变组件和可观察数据结构的知识,以在这个结构中获得一些性能提升。我们的Task组件将不再进行不必要的脏检查,因为我们切换到了OnPush变更检测策略。
我们还大大简化了TaskList和Task组件的复杂性,现在推理这些组件及其状态要容易得多。
这种重构的另一个好处是我们使用不可变输入达到了很高的封装级别。我们的TaskList组件不再依赖于任何任务容器作为项目。我们也可以给它传递一个包含所有项目的任务列表,并且它仍然可以按预期工作。
使用内容投影进行组合
在本节中,我们将为我们的Project组件创建一个标签界面,这将帮助我们进一步组织应用程序用户界面的结构。为了创建Tabs组件,我们将查看内容投影和内容子注入使用可观察查询列表。
输入和输出属性非常适合建立封装,这是适当组合的主要属性。然而,有时要求不仅仅是传递数据,还需要从组件外部传递内容到组件内部。在 Shadow DOM 中,这通过所谓的插槽来实现。在 Angular 组件中,我们可以使用<ng-content>元素来创建内容投影点。
让我们看看一个简单的内容投影示例,这有助于我们理解这有什么好处:
@Component({
selector: 'child',
template: `
<article>
<header>
<h1><ng-content select="[data-header]"></ng-content></h1>
</header>
<ng-content></ng-content>
</article>
`
})
export class Child {}
@Component({
selector: 'app',
template: `
<child>
<header data-header>Content projection is great</header>
<p>Insert content in a controlled manner</p>
</child>
`,
directives: [Child]
})
export class App {}
在这个示例中查看App组件,我们可以看到我们已经将元素放入了实际的<child>元素中。通常,这些内容会被 Angular 忽略并删除,在它使用模板渲染Child组件之前。
然而,在使用内容投影时,放置在我们组件 HTML 元素内的元素可能会被吸入Child组件。这正是内容投影的全部内容。实际上,内容投影与 Angular 1 中的 transclusion 概念非常相似。
为了在Child组件中启用内容投影,我们只需在其模板中放置一个<ng-content>元素。这样,我们指定了我们希望在组件模板中的哪些位置插入从父组件吸入的内容。
此外,我们可以在<ng-content>元素上使用 select 属性来设置类似于 CSS 的选择器。这个选择器将用于仅吸入与这个选择器匹配的特定元素。这样,你可以有多个插入点,覆盖不同的内容需求。
组件元素中的元素只能插入一次,内容投影通过按顺序遍历所有<ng-content>元素,通过项目匹配元素来实现。如果你在模板中有多个竞争内容投影点,它们对相同的元素感兴趣,那么第一个实际上会获胜。
创建标签界面组件
让我们在项目的ui文件夹中引入一个新的 UI 组件,它将为我们提供一个可用的标签界面,我们可以用它来进行组合。我们使用关于内容投影的知识来使这个组件可重用。
我们实际上将创建两个组件,一个用于Tabs,它本身包含单个Tab组件。
首先,让我们在新的tabs/tab文件夹中的tab.js文件内创建组件类:
import {Component, Input, ViewEncapsulation, HostBinding} from '@angular/core';
import template from './tab.html!text';
@Component({
selector: 'ngc-tab',
host: {
class: 'tabs__tab'
},
template,
encapsulation: ViewEncapsulation.None
})
export class Tab {
@Input() name;
@HostBinding('class.tabs__tab--active') active = false;
}
我们在Tab组件中存储的唯一状态是标签是否处于活动状态。显示在标签上的名称将通过输入属性提供。
我们使用类属性绑定来使标签页可见,基于我们设置的激活标志类;如果没有这个,我们的标签页将是隐藏的。
让我们看看这个组件的tab.html模板文件:
<ng-content></ng-content>
这就是全部了吗?实际上,是的!Tab组件只负责存储其名称和激活状态,以及将宿主元素内容插入到内容投影点。不需要额外的模板。
现在,我们将提升一个级别,创建一个负责将所有Tab组件分组的Tabs组件。由于我们不想在创建标签页界面时直接包含Tab组件,而是使用Tabs组件,因此需要将我们放入宿主元素的内容转发。让我们看看我们如何实现这一点。
在tabs文件夹中,我们将创建一个包含我们的Tabs组件代码的tabs.js文件,如下所示:
import {Component, ViewEncapsulation, ContentChildren} from '@angular/core';
import template from './tabs.html!text';
// We rely on the Tab component
import {Tab} from './tab/tab';
@Component({
selector: 'ngc-tabs',
host: {
class: 'tabs'
},
template,
encapsulation: ViewEncapsulation.None,
directives: [Tab]
})
export class Tabs {
// This queries the content inside <ng-content> and stores a
// query list that will be updated if the content changes
@ContentChildren(Tab) tabs;
// The ngAfterContentInit lifecycle hook will be called once the
// content inside <ng-content> was initialized
ngAfterContentInit() {
this.activateTab(this.tabs.first);
}
activateTab(tab) {
// To activate a tab we first convert the live list to an
// array and deactivate all tabs before we set the new
// tab active
this.tabs.toArray().forEach((t) => t.active = false);
tab.active = true;
}
}
让我们观察这里发生了什么。我们使用了一个新的@ContentChildren注解,以便查询我们插入的内容中与传递给装饰器的类型匹配的指令。tabs属性将包含一个QueryList类型的对象,它是一个可观察的列表类型,如果内容投影发生变化,它将更新。你需要记住内容投影是一个动态过程,因为宿主元素中的内容实际上可以改变,例如,使用NgFor或NgIf指令。
我们使用AfterContentInit生命周期钩子,我们已经在第二章的自定义 UI 元素部分简要讨论过,即第二章 准备,出发! 这个生命周期钩子在 Angular 完成组件的内容投影后被调用。只有在这种情况下,我们才能保证我们的QueryList对象已初始化,我们可以开始使用作为内容投影的子指令。
activateTab函数将设置Tab组件的激活标志,并关闭任何之前激活的标签页。由于可观察的QueryList对象不是一个原生数组,我们在开始处理它之前需要使用toArray()将其转换。
现在,让我们看看我们在tabs目录下名为tabs.html的文件中创建的Tabs组件的模板:
<ul class="tabs__tab-list">
<li *ngFor="let tab of tabs">
<button class="tabs__tab-button"
[class.tabs__tab-button--active]="tab.active"
(click)="activateTab(tab)">{{tab.name}}</button>
</li>
</ul>
<div class="tabs__l-container">
<ng-content select="ngc-tab"></ng-content>
</div>
我们的Tabs组件的结构如下。
-
首先,我们在一个无序列表中渲染所有的
tab按钮。 -
在无序列表之后,我们有一个
tabs容器,它将包含所有通过内容投影和<ng-content>元素插入的Tab组件。请注意,我们使用的选择器实际上是我们为Tab组件使用的选择器。 -
未激活的标签页将不可见,因为我们通过 CSS 在
Tab组件的类属性绑定中控制这一点(参考Tab组件代码)。
这就是我们创建一个灵活且封装良好的标签界面组件所需的所有内容。现在我们可以继续使用这个组件在我们的Project组件中,以提供项目详细信息的一个隔离。
目前我们将创建三个标签页,其中第一个将嵌入我们的任务列表。我们将在后续章节中处理其他两个标签页的内容。
让我们首先修改project.html文件中的Project组件模板。
我们现在不是直接包含TaskList组件,而是使用Tabs和Tab组件将任务列表嵌套到我们的标签界面中:
<ngc-tabs>
<ngc-tab name="Tasks">
<ngc-task-list [tasks]="tasks"
(tasksUpdated)="updateTasks($event)">
</ngc-task-list>
</ngc-tab>
<ngc-tab name="Comments"></ngc-tab>
<ngc-tab name="Activities"></ngc-tab>
</ngc-tabs>
你现在应该已经注意到,我们实际上在这个模板代码中使用内容投影嵌套了两个组件,如下所示:
-
首先,
Tabs组件使用内容投影来选择所有的<ngc-tab>元素。由于这些元素碰巧也是组件(我们的Tab组件将附加到具有此名称的元素上),一旦它们被插入,它们将在Tabs组件内部被识别为这样的组件。 -
然后,我们在
<ngc-tab>元素中嵌套TaskList组件。如果我们回到我们的Task组件模板,它将附加到名为ngc-tab的元素上,我们将有一个通用的投影点,可以插入主机元素中存在的任何内容。我们的任务列表将有效地通过Tabs组件传递到Tab组件。
概述
在这个主题中,我们创建了一个非常实用的标签界面组件,我们可以使用它来隔离用户界面,并为用户提供一个专注的上下文。我们使用了内容投影点,即使用<ng-content>元素。我们还学习了如何使用@ContentChildren注解和QueryList类型来访问插入的组件。
混合投影内容和生成内容
我们的任务管理应用程序支持列出多个项目,用户可以管理任务。我们需要提供一个导航,使用户能够浏览现有项目。由于项目来自我们的数据存储,导航需要动态生成。然而,我们还想在导航中指定一些导航项,作为静态内容与纯模板。
在本节中,我们将创建一个简单的导航组件,它将使用内容投影,这样我们就可以添加静态导航项。同时,导航项可以从数据生成,并与基于静态内容的导航项混合。
让我们首先看看我们将要用来实现导航的架构设计和组成的说明图:

导航组件树和交互的说明
我们将在Navigation和NavigationItem组件之间使用一个中间组件。NavigationSection组件负责将多个项目划分为一个部分。导航部分还有一个标题,它将显示在项目列表的顶部。
插图显示了两个NavigationSection组件,其中左侧的一个使用纯内容投影来创建项目,正如我们在上一节中学到的。右侧的NavigationSection组件使用输入数据结构生成项目,这是一个导航项目模型的列表。
由于我们在Navigation和NavigationItems组件之间有中间组件(我们只能有一个选定的导航),因此我们也在它们之间建立了一条直接的通信路径。我们将使用祖先组件注入来实现这一点。
注意
这种导航的架构方法只是众多可能方法之一。我们选择这种方法是为了向您展示我们如何轻松地混合内容投影和生成内容。在这个例子中,我们不使用 Angular 路由来提供导航状态和路由映射。这将是后续章节的一部分。
让我们从底向上的NavigationItem组件开始,并在新创建的navigation/navigation-section/navigation-item路径下创建一个新的navigation-item.js文件:
// We rely on the navigation component to know if we are active
import {Navigation} from '../../navigation';
@Component({
selector: 'ngc-navigation-item'
})
export class NavigationItem {
@Input() title;
@Input() link;
constructor(@Inject(Navigation) navigation) {
this.navigation = navigation;
}
// Here, we are delegating to the navigation component to see if
// we are active or not
isActive() {
return this.navigation.isItemActive(this);
}
// If this link is activated we need to tell the navigation component
onActivate() {
this.navigation.activateLink(this.link);
}
}
从NavigationItem组件的代码中,我们可以看到我们正在直接与Navigation祖先组件进行通信。我们可以简单地注入NavigationComponent,因为它是组件的一个子组件。由于没有Navigation组件,Navigation项目将永远不会存在,所以我们应该对这种直接依赖感到满意。
让我们继续到NavigationSection组件,它是Navigation组件和项目之间的中间组件,负责将项目分组在一起。
我们将在navigation/navigation-section路径下创建一个名为navigation-section.js的文件:
@Component({
selector: 'ngc-navigation-section',
directives: [NavigationItem]
})
export class NavigationSection {
@Input() title;
@Input() items;
}
等一下!这难道就是全部需要做的吗?我们不是说过,我们希望我们的NavigationSection组件不仅要提供插入内容的方式,还要接受数据以生成项目吗?嗯,这是真的。然而,这实际上是一种纯模板逻辑,并且可以在组件的模板文件中独立完成。我们需要的只是一个可选的输入,其中包含我们将用于生成NavigationItem组件的项目数据。
让我们在名为navigation-section.html的文件中为这个组件创建视图模板:
<h2 class="navigation-section__title">{{title}}</h2>
<ul class="navigation-section__list">
<ng-content select="ngc-navigation-item"></ng-content>
<ngc-navigation-item *ngFor="let item of items"
[title]="item.title"
[link]="item.link"></ngc-navigation-item>
</ul>
嗯,这并不是什么火箭科学,对吧?然而,这展示了我们在 Angular 组件模板中拥有的巨大灵活性:
-
首先,我们创建一个内容投影点,它选择所有匹配名称
ngc-navigation-item的宿主元素中的元素。这意味着NavigationItem组件可以以非常静态的方式放置在组件外部,例如创建静态链接。由于导航项的模型属性直接暴露为NavigationItem元素的可绑定属性,我们还可以将它们以静态方式放置到纯 HTML 模板中,使用常规 DOM 属性。 -
其次,我们可以使用
NgFor指令在组件内部生成NavigationItem组件。在这里,我们只是遍历作为我们组件可选输入的导航项模型列表。我们在项目模型中使用绑定,这样我们甚至可以将更改传播到我们的导航项组件中。
作为最后一步,我们创建了一个使用内容投影点的 Navigation 组件,这样我们就可以从外部管理 NavigationSection 组件。我们创建一个名为 navigation.js 的文件来编写 Navigation 组件的代码:
import {NavigationSection} from './navigation-section/navigation-section';
@Component({
selector: 'ngc-navigation',
directives: [NavigationSection]
})
export class Navigation {
@Input() activeLink;
// Checks if a given navigation item is currently active by its
// link. This function will be called by navigation item child
// components.
isItemActive(item) {
return item.link === this.activeLink;
}
// If a link wants to be activated within the navigation, this
// function needs to be called. This way child navigation item
// components can activate themselves.
activateLink(link) {
this.activeLink = link;
this.activeLinkChange.next(this.activeLink);
}
}
在 Navigation 组件中,我们存储了哪个导航项被激活的状态。这也作为输入提供给组件,以便我们可以通过外部输入绑定设置激活链接。isItemActive 和 activateLink 函数用于监控和更改导航中活动项的状态。这些函数直接在 NavigationItem 组件中使用,这些组件通过祖先组件注入使用导航。
现在,唯一缺少的部分是将我们的导航包含到主应用程序中。为此,我们将编辑组件的 app.html 模板:
<div class="app">
<div class="app__l-side">
<ngc-navigation
[activeLink]="getSelectedProjectLink()"
(activeLinkChange)="selectProjectByLink($event)">
<ngc-navigation-section
title="Projects"
[items]="getProjectNavigationItems()">
</ngc-navigation-section>
</ngc-na
vigation>
</div>
<div class="app__l-main">
…
</div>
</div>
在这里,我们只使用生成方法编写了一个 NavigationSection 组件,我们实际上将导航项模型列表传递给导航组件。这个列表是由我们的主应用程序组件上的 getProjectNavigationItems 函数生成的,它使用可观察数据结构中的可用项目:

新创建的项目导航截图
摘要
在本章中,我们学习了如何从诸如响应式编程、可观察数据结构和不可变对象等概念中获益,以使我们的应用程序性能更佳,最重要的是,简单且易于推理。
我们讨论了不同的变更检测策略,并学习了如何结合不可变数据使用 OnPush 策略来获得更好的性能。
我们构建了一个可重用的选项卡用户界面组件,并学习了内容投影的概念。我们还创建了一个简单的导航组件树,它结合了内容投影和生成。导航项还直接与它们的祖先 Navigation 组件通信,以便使用祖先组件注入来管理它们的状态。
随着我们转向在应用程序内以响应式方法管理数据,我想让你进行一个小实验。如果你已经下载了最后一章的代码,请打开两个指向任务管理应用的浏览器窗口。你会惊讶地发现,我们已经在实时同步方面实现了工作,这使得我们可以在两个浏览器窗口中工作,并且同时更新它们。这一切都得益于我们在组件中处理数据时所采用的响应式和函数式方式。
第四章。请不要评论!
在本章的整个过程中,我们将创建可重用组件,以使评论不仅限于项目,还可以用于我们应用程序中的任何其他实体。我们将以这种方式构建我们的评论系统,以便我们可以将其放置在我们希望用户放置评论的任何位置。为了为用户提供编辑现有评论的功能以及无缝的创作体验,我们将创建一个编辑器 UI 组件,可以用于使应用程序中的任意内容可编辑。
在本章讨论安全和适当用户管理还不在范围之内,但我们将创建一个模拟用户服务,这将帮助我们模拟已登录用户。此服务将由评论系统使用,并且我们将重构现有组件以利用它。
本章我们将涵盖以下主题:
-
使用
contenteditable创建就地编辑器 -
使用
@HostBinding和@HostListener将组件成员绑定到宿主元素属性和事件 -
使用
@ViewChild注解直接与视图子元素通信 -
通过注入和使用
ElementRef执行 DOM 操作 -
创建模拟用户服务并使用
@Injectable注解作为依赖注入提供者 -
在组件输入更改上应用自定义操作,使用
OnChanges生命周期钩子 -
创建一个简单的管道,使用
Moment.js库格式化相对时间间隔
一个编辑器统治一切
由于我们将在应用程序中处理大量用户输入,因此为用户提供良好的创作体验至关重要。在本章即将创建的评论系统中,我们需要一种方式,让用户能够编辑现有评论,以及添加新评论。我们可以使用常规文本区域输入并使用对话框来编辑评论,但这对于我们要构建的现代用户界面来说似乎过于过时,并且并不真正提供良好的用户体验。我们所寻找的是一种就地编辑内容的方法。评论系统不仅将从这种就地编辑器中受益,而且它还将帮助我们以这种方式创建编辑器组件,以便我们可以将其用于我们希望使其可编辑的应用程序中的任何内容。
为了构建我们的就地编辑器,我们将使用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);
通过这个示例,我们已经创建了一个简单的原地编辑器实现,我们可以监控用户应用的变化。在这个主题中,我们将使用这项标准技术来构建一个可重用的组件,我们可以在任何需要使内容可编辑的地方使用它。
创建编辑器组件
首先,让我们在我们的 ui 文件夹中创建一个名为 editor 的新文件夹。在这个文件夹中,我们将创建一个名为 editor.js 的新组件文件:
import {Component, ViewChild, Input, Output, ViewEncapsulation, EventEmitter, HostBinding, HostListener} from '@angular/core';
import template from './editor.html!text';
@Component({
selector: 'ngc-editor',
host: {
class: 'editor'
},
template,
encapsulation: ViewEncapsulation.None
})
export class Editor {
// Using view child reference with local view variable name
@ViewChild('editableContentElement') editableContentElement;
// Content that will be edited and displayed
@Input() content;
// Creating a host element class attribute binding from the
// editMode property
@Input() @HostBinding('class.editor--edit-mode') editMode;
@Input() showControls;
@Output() editSaved = new EventEmitter();
@Output() editableInput = new EventEmitter();
// We need to make sure to reflect to our editable element if
// content gets updated from outside
ngOnChanges() {
if (this.editableContentElement && this.content) {
this.setEditableContent(this.content);
}
}
ngAfterViewInit() {
this.setEditableContent(this.content);
}
// This returns the content of our content editable
getEditableContent() {
return this.editableContentElement.nativeElement.textContent;
}
// This sets the content of our content editable
setEditableContent(content) {
this.editableContentElement.nativeElement.textContent =
content;
}
// This annotation will create a click event listener on the
// host element that will invoke the underlying method
@HostListener('click')
focusEditableContent() {
if (this.editMode) {
this.editableContentElement.nativeElement.focus();
}
}
// Method that will be invoked if our editable element is
// changed
onInput() {
// Emit a editableInput event with the edited content
this.editableInput.next(this.getEditableContent());
}
// On save we reflect the content of the editable element into
// the content field and emit an event
save() {
this.editSaved.next(this.getEditableContent());
this.setEditableContent(this.content);
// Setting editMode to false to switch the editor back to
// viewing mode
this.editMode = false;
}
// Canceling the edit will not reflect the edited content and
// switch back to viewing mode
cancel() {
this.setEditableContent(this.content);
this.editableInput.next(this.getEditableContent());
this.editMode = false;
}
// The edit method will initialize the editable element and set
// the component into edit mode
edit() {
this.editMode = true;
}
}
好的,这有很多新的代码。让我们逐步分析 Editor 组件的不同部分。
在我们的 Editor 组件中,我们需要与可编辑的原生 DOM 元素进行交互。最简单也是最安全的方法是使用 @ViewChild 装饰器来检索具有本地视图引用的元素:
@ViewChild('editableContentElement') editableContentElement;
在上一章中,我们学习了 @ContentChildren 注解,它帮助我们获取内容投影点内所有子组件的列表。如果我们想要对常规视图子组件做同样的事情,我们需要使用等效的 @ViewChildren 注解。虽然 @ContentChildren 在内容投影点内搜索组件,但 @ViewChildren 在组件的常规子树中寻找。
如果我们想要在组件子树中搜索单个组件,我们可以使用 @ViewChild 注解(请注意,@ViewChild 和 @ViewChildren 是不同的)。
| 查询注解 | 描述 |
|---|---|
@ViewChildren(selector) |
将查询当前组件的视图中的指令或组件,并返回一个类型为 QueryList 的对象。如果视图是动态更新的,此列表也将相应更新。 |
@ViewChild(selector) |
将仅查询第一个匹配的组件或指令,并返回其实例。 |
注意
选择器可以是指令或组件类型,或者是一个包含本地视图变量名称的字符串。如果提供了本地视图变量名称,Angular 将会搜索包含视图变量引用的元素。
如果你需要直接与视图子组件通信,使用 @ViewChild 和 @ViewChildren 注解应该是你的首选方式。
小贴士
有时候你需要在组件初始化后对视图子组件运行初始化代码。在这种情况下,你可以使用 AfterViewInit 生命周期钩子。虽然你的组件类中的视图子属性在你的组件构造函数中仍然是未定义的,但它们将在 AfterViewInit 生命周期回调之后填充和初始化。
@ViewChild 和 @ViewChildren 装饰器是直接与你的视图交互的出色工具。你想要与之交互的是 DOM 元素还是组件实例,这并不重要。这两个用例都得到了很好的覆盖,使用了这个声明式 API。
让我们回到我们的Editor组件代码。接下来我们要查看的是组件的输入函数:
@Input() content;
@Input() @HostBinding('class.editor--edit-mode') editMode;
@Input() showControls;
content输入属性是外部与组件交互的主要接口。使用属性绑定,我们可以在编辑器组件中设置任何现有的文本内容。
editMode属性是一个布尔值,用于控制编辑器是否处于编辑或显示模式。我们的编辑器组件将依赖于这个标志来知道内容是否应该被编辑。这允许我们从只读模式切换到编辑模式,然后再切换回来,实现交互式操作。
尽管这是一个输入属性,但这个标志可以从组件外部进行控制。同时,它也可以用来为主元素创建属性绑定。具体来说,我们可以使用这个标志来创建一个类属性绑定,以添加或删除修饰类editor--edit-mode。这个类用于在编辑模式下控制编辑器视觉外观的一些差异。
我们编辑器组件中的三个输入属性中的最后一个,showControls,控制编辑器是否应该显示控制功能。当这个属性评估为真值时,将显示以下三个控制:
-
编辑按钮:当组件处于显示模式时将显示,它将使用
editMode标志将组件切换到编辑模式。 -
保存按钮:只有在组件处于编辑模式时才会显示。这个控件将保存当前编辑模式中应用的所有更改,并将组件切换回显示模式。
-
取消按钮:这与保存按钮相同,并且仅在组件处于编辑模式时显示。如果激活,组件将切换回显示模式,撤销您可能做出的任何更改。
除了我们的输入属性之外,我们还需要一些输出属性来通知外部世界我们编辑器中的更改。以下代码片段帮助我们做到这一点:
@Output() editSaved = new EventEmitter();
@Output() editableInput = new EventEmitter();
当使用保存按钮控件保存编辑内容时,将触发editSaved事件。此外,如果在我们可编辑内容元素中的每次输入更改时都发出事件,那就更好了。为此,我们使用了editableInput输出属性。
我们的编辑器组件以简单的方式工作。如果组件处于编辑模式,它会显示一个可编辑的元素。然而,一旦编辑器切换回显示模式,我们会看到一个不同的元素,这个元素不能被编辑。可见性是通过主机元素属性绑定到editMode标志的修饰类来控制的。
Angular 无法控制我们可编辑元素中的内容。我们通过使用原生 DOM 操作手动控制这个内容。让我们看看我们是如何做到这一点的。首先,我们需要使用代理来访问元素,因为我们很可能会改变我们如何读取和写入可编辑元素。我们使用了以下方法来实现这一点:
getEditableContent() {
return this.editableContentElement.nativeElement.textContent;
}
setEditableContent(content) {
this.editableContentElement.nativeElement.textContent =
content;
}
提示
注意,我们在editableContentElement字段上使用了nativeElement属性,该属性之前由@ViewChild装饰器设置。
Angular 并没有直接提供 DOM 元素引用,而是提供了一个类型为ElementRef的包装对象。它基本上是原生 DOM 元素的包装,包含与 Angular 相关的额外信息。
使用nativeElement访问器,我们可以获取底层 DOM 元素的引用。
注意
ElementRef包装器在 Angular 的平台无关架构中扮演着重要的角色。它允许你在不同的环境中运行 Angular(例如,原生移动设备、web workers 或其它)。它是组件及其视图之间的抽象层的一部分。
我们还需要一种方法来根据我们从内容输入属性接收到的输入来设置可编辑元素的内容。我们可以使用生命周期钩子OnInit,它将在组件初始化后检查输入属性之后被调用。然而,这个生命周期钩子只在初始化后触发一次,我们需要一种方法来帮助我们响应content属性的后续输入更改。请看以下代码片段:
ngOnChanges() {
if (this.editableContentElement && this.content) {
this.setEditableContent(this.content);
}
}
OnChanges生命周期钩子正是我们所需要的。有了它,一旦检测到内容输入属性的变化(这包括初始化后的第一次变化),我们就可以将更改的内容反映到我们的可编辑元素上。
现在我们已经实现了组件内容输入属性到可编辑字段的反射。但反过来怎么办?我们需要找到一种方法来将可编辑元素中的更改反映到我们的组件content属性上。这也与在编辑模式中使用可用控件对组件执行的操作密切相关,这些操作如下:
-
在保存操作中:在这里,我们将可编辑元素中的编辑内容反映回组件的
content属性。 -
在取消操作中:在这里,我们忽略用户在可编辑元素中进行的编辑,并将其内容设置回组件
content属性中的值:
让我们看看这两个操作的代码:
save() {
this.editSaved.next(this.getEditableContent());
this.setEditableContent(this.content);
this.editMode = false;
}
cancel() {
this.setEditableContent(this.content);
this.editableInput.next(this.getEditableContent());
this.editMode = false;
}
除了显示组件content属性和可编辑元素之间反射的代码之外,我们还发出了一些事件,这些事件将帮助我们通知外部世界关于这些变化。在这两个操作中,我们在完成后将editMode标志设置为false。这确保了在任一操作完成后,我们的编辑器将切换到显示模式。
当组件处于显示模式时,edit 方法将从编辑控制按钮被调用。它所做的一切只是将组件切换回编辑模式:
edit() {
this.editMode = true;
}
到目前为止,我们关于代码的讨论已经足够我们设置一个完全功能性的组件。然而,代码的最后部分,我们还没有讨论,它关系到确保我们的编辑器有更好的可访问性。由于我们的编辑器组件比可编辑元素大一些,我们还想确保在编辑器组件内的任何地方点击都会使可编辑元素获得焦点。以下代码实现了这一点:
@HostListener('click')
focusEditableContent() {
if (this.editMode) {
this.editableContentElement.nativeElement.focus();
}
}
使用 @HostListener 装饰器,我们在组件元素上注册了一个事件绑定,该绑定调用 focusEditableContent 方法。在这个方法内部,我们使用了可编辑 DOM 元素的引用并触发了焦点。
让我们看看位于 editor.html 文件中的组件模板,以便了解我们如何与组件内的逻辑交互:
<div (input)="onInput($event)"
class="editor__editable-content"
contenteditable="true"
#editableContentElement></div>
<div class="editor__output">{{content}}</div>
<div *ngIf="showControls && !editMode" class="editor__controls">
<button (click)="edit()" class="editor__icon-edit"></button>
</div>
<div *ngIf="showControls && editMode" class="editor__controls">
<button (click)="save()" class="editor__icon-save"></button>
<button (click)="cancel()" class="editor__icon-cancel"></button>
</div>
编辑器组件模板中的逻辑非常简单。如果您一直在关注组件代码,现在您将能够识别组成此组件视图的不同元素。
带有 editor__editable-content 类的第一个元素是我们具有 contenteditable 属性的可编辑元素。当编辑器处于编辑模式时,用户将能够在此元素中键入。重要的是要注意,我们已使用局部视图变量引用对其进行注释,#editableContentElement,我们在视图子查询中使用它。
带有 editor__output 类的第二元素仅用于显示编辑器内容,并且仅在编辑器处于显示模式时可见。这两个元素的可见性都使用 CSS 控制,基于 editor--edit-mode 修饰类,如果您还记得组件类代码,它是通过主机属性绑定根据 editMode 属性设置的。
三个控制按钮是通过 NgIf 指令条件性地显示的。showControls 输入属性需要设置为 true,并且根据 editMode 标志,屏幕将显示编辑按钮或保存和取消按钮:

我们编辑器组件在行动中的截图
概述
在这个构建块中,我们创建了一个原地编辑器小部件,我们可以使用它来获取应用程序中任何内容的用户输入。它允许我们向用户提供上下文编辑功能,这将带来极佳的用户体验。
我们还学习了以下主题:
-
使用
contenteditableHTML5 属性来启用原地编辑。 -
使用
@ViewChild和@ViewChildren来查询视图子元素。 -
使用
ElementRef依赖项执行原生 DOM 操作。 -
使用
OnChange生命周期钩子实现逻辑,以反映 Angular 和不在 Angular 直接控制下的内容之间的数据。
构建评论系统
在上一主题中,我们创建了一个编辑器组件,该组件将支持用户在我们的应用程序中编辑内容。在这里,我们将创建一个评论系统,该系统将使用户能够在应用程序的各个区域撰写评论。评论系统将使用我们的编辑器组件使评论可编辑,从而帮助用户创建新的评论:

评论系统组件子树的示意图
以下图示展示了我们即将创建的评论系统中的组件树架构。
Comments 组件将负责列出所有现有评论,以及创建新的评论。
每条评论本身都被封装在一个 Comment 组件中。Comment 组件本身使用一个编辑器,允许用户在创建评论后编辑评论。
我们在上一主题中构建的 Editor 组件被 Comment 组件直接使用,以提供添加新评论的输入控制。这允许我们重用我们的编辑器组件的功能来捕获用户输入。
当使用编辑器的控制按钮保存可编辑内容时,Editor 组件会发出 editSaved 事件。在 Comment 组件中,我们将捕获这些事件并将新事件向上传播到 Comments 组件。在那里,我们将进行必要的更新,然后再发出一个新事件来通知我们的父组件。在组件组合中,每个组件都会对更改做出反应,并在必要时委托给父组件。
构建评论组件
让我们通过首先完善 Comment 组件来开始构建我们的评论系统。除了评论本身,我们还想显示评论用户的个人资料,当然,还有评论的时间。
为了显示时间,我们将使用相对时间格式,因为这会给我们的用户更好的时间感。相对时间格式以“5 分钟前”或“1 个月前”的格式显示时间戳,而不是像“25.12.2015 18:00”这样的绝对时间戳。我们将使用 Moment.js 库创建一个管道,我们可以在组件模板中使用它将时间戳和日期转换为相对时间间隔。
让我们在名为 pipes 的新文件夹内创建一个新的管道。该管道需要创建在 pipes 文件夹下的名为 from-now.js 的文件中:
import {Pipe} from '@angular/core';
// We use the Moment.js library to convert dates to relative times
import Moment from 'moment';
@Pipe({
// Specifying the name to be used within templates
name: 'fromNow'
})
// Our pipe will transform dates and timestamps to relative times
// using Moment.js
export class FromNowPipe {
// The transform method will be called when the pipe is used
// within a template
transform(value) {
if (value && (value instanceof Date ||
typeof value === 'number')) {
return new Moment(value).fromNow();
}
}
}
现在,这个管道可以在组件的模板中使用,以将时间戳和日期格式化为相对时间间隔。
让我们使用这个管道和我们在上一主题中创建的 Editor 组件来创建我们的 Comment 组件。在名为 comment.html 的文件中,该文件位于 comments 文件夹下的新 comment 文件夹内,我们将创建 Comment 组件的模板:
<div class="comment__l-meta">
<div class="comment__user-picture">
<img [attr.src]="user.pictureDataUri" src="">
</div>
<div class="comment__user-name">{{user.name}}</div>
<div class="comment__time">
{{time | fromNow}}
</div>
</div>
<div class="comment__l-main">
<div class="comment__message">
<ngc-editor [content]="content"
[showControls]="true"
(editSaved)="onContentSaved($event)">
</ngc-editor>
</div>
</div>
从用户对象中,我们将获取用户的个人资料图片以及用户名。为了以相对格式显示评论的时间,我们将使用我们之前创建的 fromNow 管道。
最后,我们将使用内联编辑器组件来显示评论的内容,并使其可编辑。我们将评论内容属性绑定到编辑器的内容输入属性。同时,我们将监听编辑器的 editSaved 事件,并在我们的评论组件类上调用 onContentSaved 方法。如果你再次查看我们的组件代码,你会注意到我们在方法中重新发出事件,这样外界也会被通知评论的变化。
让我们看一下我们将在名为 comment.js 的文件中创建的组件类:
import {Component, Input, Output, ViewEncapsulation, EventEmitter} from '@angular/core';
import {Editor} from '../../ui/editor/editor';
import template from './comment.html!text';
// We use our fromNow pipe that converts timestamps to relative
// times
import {FromNowPipe} from '../../pipes/from-now';
@Component({
selector: 'ngc-comment',
host: {
class: 'comment'
},
template,
encapsulation: ViewEncapsulation.None,
directives: [Editor],
pipes: [FromNowPipe]
})
export class Comment {
// The time of the comment as a timestamp
@Input() time;
// The user object of the user who created the comment
@Input() user;
// The comment content
@Input() content;
// If a comment was edited this event will be emitted
@Output() commentEdited = new EventEmitter();
onContentSaved(content) {
this.commentEdited.next(content);
}
}
组件代码相当简单。与其他我们迄今为止创建的组件相比,唯一明显的区别是组件注释中的 pipes 属性。在这里,我们指定我们想要使用我们刚刚创建的 FromNowPipe 类。管道始终需要在组件中声明;否则,它们不能在组件的模板中使用。
作为输入,我们期望一个用户对象,它通过 user 输入属性传递。content 输入属性应填充实际的评论作为字符串,而 time 输入属性应设置为反映评论实际时间的戳记。
我们还有一个名为 commentEdited 的输出属性,我们将使用它来通知评论的变化。onEditSaved 方法将由我们的 Editor 组件上的事件绑定调用,然后它将使用 commentEdited 输出属性发出一个事件。
构建 Comments 组件
现在我们已经准备好了所有组件,以便完成构建我们的评论系统。最后缺失的拼图是 Comments 组件,它将列出所有评论并提供一个编辑器来创建新的评论。
首先,让我们看一下我们将在名为 comments 的文件夹中创建的名为 comments.html 的文件中的 Comments 组件模板:
<div class="comments__title">Add new comment</div>
<div class="comments__add-comment-section">
<div class="comments__add-comment-box">
<ngc-editor [editMode]="true"
[showControls]= "false"></ngc-editor>
</div>
<button (click)="addNewComment()"
class="button" >Add comment</button>
</div>
<div *ngIf="comments?.length > 0">
<div class="comments__title">All comments</div>
<ul class="comments__list">
<li *ngFor="let comment of comments">
<ngc-comment [content]="comment.content"
[time]="comment.time"
[user]="comment.user"
(commentEdited)="onCommentEdited(comment, $event)">
</ngc-comment>
</li>
</ul>
</div>
你可以在组件的模板中看到 Editor 组件的直接使用。我们使用这个内联编辑器来提供一个输入组件以创建新的评论。我们也可以在这里使用文本区域,但我们决定重用我们的 Editor 组件。我们将 editMode 属性设置为 true,以便它在编辑模式下初始化。我们还将 showControls 输入设置为 false,因为我们不希望编辑器变得自主。我们只会使用它的内联编辑功能,但将从我们的 Comments 组件中控制它。
要添加一条新评论,我们将使用一个具有点击事件绑定的按钮,该按钮调用我们的组件类上的 addNewComment 方法。
在用户可以添加新评论的部分下方,我们将创建另一个部分,用于列出所有现有的评论。如果没有评论存在,我们简单地不渲染该部分。借助NgFor指令,我们可以显示所有现有的评论,并为每个重复创建一个Comment组件。我们将所有评论数据属性绑定到我们的Comment组件,并添加一个事件绑定来处理更新的评论。
让我们在comments文件夹中创建一个新的名为comments.js的文件,以创建组件类:
import {Component, Inject, Input, Output, ViewEncapsulation, ViewChild, EventEmitter} from '@angular/core';
import template from './comments.html!text';
import {Editor} from '../ui/editor/editor';
import {Comment} from './comment/comment';
import {UserService} from '../user/user-service/user-service';
@Component({
selector: 'ngc-comments',
host: {
class: 'comments'
},
template,
encapsulation: ViewEncapsulation.None,
directives: [Comment, Editor]
})
export class Comments {
// A list of comment objects
@Input() comments;
// Event when the list of comments have been updated
@Output() commentsUpdated = new EventEmitter();
// We are using an editor for adding new comments and control it
// directly using a reference
@ViewChild(Editor) newCommentEditor;
// We're using the user service to obtain the currently logged
// in user
constructor(@Inject(UserService) userService) {
this.userService = userService;
}
// We use input change tracking to prevent dealing with
// undefined comment list
ngOnChanges(changes) {
if (changes.comments &&
changes.comments.currentValue === undefined) {
this.comments = [];
}
}
// Adding a new comment from the newCommentContent field that is
// bound to the editor content
addNewComment() {
const comments = this.comments.slice();
comments.splice(0, 0, {
user: this.userService.currentUser,
time: +new Date(),
content: this.newCommentEditor.getEditableContent()
});
// Emit event so the updated comment list can be persisted
// outside the component
this.commentsUpdated.next(comments);
// We reset the content of the editor
this.newCommentEditor.setEditableContent('');
}
// This method deals with edited comments
onCommentEdited(comment, content) {
const comments = this.comments.slice();
// If the comment was edited with e zero length content, we
// will delete the comment from the list
if (content.length === 0) {
comments.splice(comments.indexOf(comment), 1);
} else {
// Otherwise we're replacing the existing comment
comments.splice(comments.indexOf(comment), 1, {
user: comment.user,
time: comment.time,
content
});
}
// Emit event so the updated comment list can be persisted
// outside the component
this.commentsUpdated.next(comments);
}
}
让我们再次逐个分析代码部分,并讨论每个部分的作用。首先,我们在组件类中声明了一个名为comments的输入属性:
@Input() comments;
comments输入属性是一个包含所有与评论相关数据的评论对象列表。这包括撰写评论的用户、时间戳以及评论的内容。
我们还需要能够在添加评论或修改现有评论后发出一个事件。为此,我们使用了一个名为commentsUpdates的输出属性:
@Output() commentsUpdated = new EventEmitter();
一旦添加了新的评论或修改了现有的评论,我们将从这个输出属性发出一个事件,并附带更新后的评论列表。
我们将要使用的Editor组件将没有自己的控制按钮。我们将使用showControls输入属性来禁用它们。相反,我们将直接从我们的Comments组件控制编辑器。因此,我们需要一种与组件类内部的Editor组件进行通信的方法。
我们再次使用了@ViewChild装饰器来达到这个目的。然而,这次我们没有引用一个包含本地视图变量引用的 DOM 元素,而是直接将我们的组件类型类传递给装饰器。Angular 将在评论视图中搜索任何Editor组件,并为我们提供对编辑器实例的引用。这在上面的代码行中有所体现:
@ViewChild(Editor) newCommentEditor;
由于Comments组件直接在组件模板中托管一个编辑器,我们可以使用@ViewChild注解来获取对其的引用。使用这个引用,我们可以直接与子组件交互。这将允许我们从Comments组件直接控制编辑器。
让我们继续到代码的下一部分,即Comments组件构造函数。我们在这里所做的唯一一件事是注入一个用户服务,该服务将为我们提供一种获取当前登录用户信息的方式。目前,这个功能只是模拟的,我们将收到一个虚拟用户的信息。我们需要在Comments组件中获取这些信息,因为我们需要知道哪个用户实际上输入了新的评论:
constructor(@Inject(UserService) userService) {
this.userService = userService;
}
在代码的下一部分,我们控制了如何响应comments输入属性的变化。实际上,我们绝对不希望评论列表保持未定义状态。如果没有评论,它应该是一个空列表,但输入属性comments绝不能是未定义的。我们通过使用OnChange生命周期钩子并覆盖我们的comments属性来控制这一点,如果它从外部被设置为undefined:
ngOnChanges(changes) {
if (changes.comments &&
changes.comments.currentValue === undefined) {
this.comments = [];
}
}
这个小小的改动使得我们内部处理评论数据的方式变得更加清晰。在处理数组转换函数时,我们不需要额外的检查,并且我们可以始终将comments属性视为一个数组。
由于Comments组件还负责处理与添加新评论过程相关的逻辑,我们需要一个方法来实现这一要求。与此相关,我们使用了我们在上一章中学到的一些不可变实践:
addNewComment() {
const comments = this.comments.slice();
comments.splice(0, 0, {
user: this.userService.currentUser,
time: +new Date(),
content: this.newCommentEditor.getEditableContent()
});
this.commentsUpdated.next(comments);
this.newCommentEditor.setEditableContent('');
}
在这段代码中,有几个关键方面。当点击“添加评论”按钮时,这个方法将从我们的组件视图中被调用。这时,用户已经将一些文本输入到编辑器中,并且已经创建了一个新的评论。
首先,我们将使用在构造函数中注入的用户服务来获取与当前登录用户相关的信息。新创建的评论内容将直接从我们使用@ViewChild注解设置的Editor组件中获取。此外,getEditableContent方法将允许我们接收内联编辑器中可编辑元素的内容。
我们接下来想要做的是将评论列表的更新信息传达给外界。我们使用了commentsUpdated输出属性来发射一个包含更新后评论列表的事件。
最后,我们想要清除用于添加新评论的编辑器。由于Comments组件视图中的内联编辑器仅用于添加新评论,因此每次添加评论后我们都可以清除它。这将给用户一种他的评论已经从编辑器移动到评论列表中的印象。然后,我们再次可以直接使用我们的newCommentEditor属性访问Editor组件,并通过调用setEditableContent方法使用空字符串来清除编辑器。这正是我们在这里所做的事情。
我们的Comments组件将持有所有评论的列表,其视图将为列表中的每个评论创建一个Comment组件。每个Comment组件将使用Editor组件来提供其内容的就地编辑。这些编辑器独立工作,使用它们自己的控件,如果内容被更改或以任何方式修改,它们会发出事件。为了处理这一点,我们需要从Comment组件重新发出名为commentEdited的事件。现在我们只需要在我们的Comments组件中捕获此事件,以便用更改更新评论列表。这在下述代码部分中得到了说明:
onCommentEdited(comment, content) {
const comments = this.comments.slice();
if (content.length === 0) {
comments.splice(comments.indexOf(comment), 1);
} else {
comments.splice(comments.indexOf(comment), 1, {
user: comment.user,
time: comment.time,
content
});
}
this.commentsUpdated.next(comments);
}
这个方法将为使用NgFor指令重复的每个单独的Comment组件调用。从视图中,我们传递一个对相关评论对象的引用,以及我们从Comment组件事件接收到的编辑内容。
评论对象将仅用于确定更新评论在评论列表中的位置。如果新的评论内容为空,我们将从列表中删除该评论。否则,我们只需创建前一个评论对象的副本,用新的编辑内容更改内容,并用副本替换列表中的旧评论对象。
最后,由于我们想要传达评论列表的变化,我们使用commentUpdated输出属性发出了一个事件。
这样,我们就完成了评论系统,现在是时候利用它了。我们已经有了一个空白的标签页用于我们的项目评论,这将是我们将使用评论系统添加评论功能的地方。
首先,让我们修改我们的Project组件模板,project/project.html,以包含评论系统:
...
<ngc-tabs>
<ngc-tab name="Tasks">...</ngc-tab>
<ngc-tab name="Comments">
<ngc-comments [comments]="comments"
(commentsUpdated)="updateComments($event)">
</ngc-comments>
</ngc-tab>
<ngc-tab name="Activities"></ngc-tab>
</ngc-tabs>
这是最简单不过的事情了。由于我们关注的是干净的组件架构,因此我们的评论系统的加入就像微风一样顺畅。我们现在唯一需要确保的是,在我们的项目中提供一个包含评论列表的属性。我们还需要一种方式来响应评论在Comments组件内更新时的变化。为此,我们将创建一个updateComments方法。
让我们看看project/project.js文件中的组件类更改:
export class Project {
...
@Input() comments;
...
updateComments(comments) {
this.projectUpdated.next({
comments
});
}
}
由于我们已经在一般方式上处理项目更新,并且我们的Project组件直接向我们的App组件发出,其中项目数据将被持久化,我们唯一需要实现的是额外的输入属性,以及处理评论更新的方法:

项目组件内集成的评论系统截图
概述
在这个主题中,我们成功创建了一个完整的评论系统,可以放置在我们应用程序的各个区域以启用评论功能。用户可以通过与就地编辑器交互来编辑评论中的内容,这为他们提供了很好的用户体验。
在编写我们的评论系统代码时,我们学习了以下主题:
-
使用
@Pipe注解和Moment.js库实现一个简单的管道,以提供相对时间格式化 -
使用
OnChanges生命周期钩子来防止输入属性中的不希望或不正确的值 -
使用
@ViewChild获取组件子树中组件的引用以建立直接通信 -
在
Comment组件中将Editor组件作为输入字段替代品以及作为独立的就地编辑器进行重用
摘要
在本章中,我们创建了一个简单的就地编辑器,我们可以用它来在我们的应用程序中使内容可编辑。从现在开始,我们可以在任何需要为用户使内容可编辑的地方使用 Editor 组件。他们不需要跳入讨厌的对话框或单独的配置页面,而是可以直接编辑,根据他们当前的环境。这对提升我们用户的用户体验是一个很好的工具。
除了我们闪亮的新 Editor 组件外,我们还创建了一个可以轻松包含在我们希望提供评论功能的应用程序区域的完整评论系统。现在,我们已经将评论系统包含在我们的 Project 组件中,用户现在可以通过导航到项目详情的 评论 选项卡来对项目进行评论。
在下一章中,我们将使用 Angular 的基于组件的路由器来使我们的应用程序可导航。
第五章. 组件式路由
路由是当今前端应用的一个核心部分。一般来说,路由器有两个主要用途:
-
使您的应用可导航,以便用户可以使用浏览器的后退按钮,并在应用内存储和分享链接
-
将应用组合的部分卸载,以便路由器根据路由和路由参数负责组合您的应用
随 Angular 一起提供的路由器支持许多不同的用例,并附带一个易于使用的 API。这支持类似于 Angular UI-Router 嵌套状态、Ember.js 嵌套路由或 Durandal 框架中的子路由的子路由。与组件树绑定,这也利用其自己的树结构来存储状态和解析请求的 URL。
在本章中,我们将重构我们的代码以使用 Angular 的基于组件的路由器。我们将探讨路由器的核心元素以及如何使用它们在我们的应用中启用路由。
本章将涵盖以下主题:
-
Angular 路由简介
-
对启用我们应用中路由的代码重构的概述
-
通过模板组合、通过路由组合,以及如何混合它们
-
使用
Routes装饰器来配置路由和子路由 -
使用
OnActivate路由生命周期钩子来获取路由参数 -
使用
RouterOutlet指令来创建由路由器控制的插入点 -
使用
RouterLink指令和路由 DSL 创建导航链接 -
使用
@ChildView装饰器查询RouterLink指令以获取链接的激活状态
Angular 路由简介
Angular 中的路由器与我们的组件树紧密耦合。Angular 路由器的设计基于这样的假设:组件树直接与我们的 URL 结构相关。这在大多数情况下都是正确的。如果我们看一个嵌套在组件A中的组件B,表示我们位置的 URL 很可能就是/a/b。
为了指定模板中我们希望启用路由器实例化组件的位置,我们可以使用所谓的出口。只需包含一个<router-outlet>元素,我们就可以利用RouterOutlet指令在我们的模板中标记路由插入点。
基于我们可以放置在我们组件上的某些路由配置,路由器随后决定哪些组件需要实例化并放置到路由出口中。路由也可以参数化,我们可以在实例化的组件中访问这些参数。
基于我们的组件树以及该树中组件的路由配置,我们可以构建一个分层路由,并将子路由与其父路由解耦。这种嵌套路由使得在组件树的多个层级上指定路由配置,并重用父组件为多个子路由成为可能。

通过组件树建立的路由层次结构
让我们更详细地看看路由器的元素:
-
路由配置:路由配置放置在组件级别,它包含组件树中此级别的不同路由。通过在组件树的不同组件上放置多个路由配置,我们可以轻松构建解耦的嵌套路由。
-
路由出口:出口是组件中将被路由器管理的位置。基于路由配置的实例化组件将被放置在这些出口中。
-
路由链接:这些是以 DSL 风格符号构建的链接,使开发者能够通过路由树构建复杂的链接。
通过路由进行组合
到目前为止,我们通过在组件模板中包含子组件来实现组合。然而,我们现在希望将控制权交给路由器,以决定哪个组件应该被包含以及在哪里。
以下插图概述了我们应用程序的组件架构,我们将启用它来进行路由:

显示路由组件(实线)和路由出口的组件树
现在的Project组件不再直接与我们的App组件一起包含。相反,我们在App组件的模板中使用路由出口。这样,我们可以将控制权交给路由器,让它决定哪个组件应该放入出口。App组件的路由配置将包含所有顶级路由。在当前应用程序中,我们只有Project组件作为二级组件,但在后续章节中这将会改变。
Project组件包含子路由配置,用于导航到任务和评论视图。然而,它并不直接包含路由出口。我们使用Tabs组件作为任何子视图的导航元素。因此,我们将路由出口放入Tabs组件,并在Project组件的模板中直接包含该组件。
路由与模板组合的比较
我们迄今为止处理的组合完全是基于模板包含的实例化。我们使用输入和输出属性来解耦和封装组件,并遵循了良好的可重用模式。
使用路由器,我们面临一个 Angular 尚未解决的问题,需要我们找到自己的解决方案。当我们把控制权交给路由器来实例化和将组件插入到我们的组件树中时,我们就无法控制实例化组件上的任何绑定。虽然我们之前依赖于使用输入和输出属性进行组件的清洁解耦,但我们不能再这样做。路由器为我们提供的唯一东西是可能设置在激活路由上的路由参数。
这使我们陷入了一个相当糟糕的情况。基本上,在编写组件时,我们需要在两种设计之间做出选择,如下所示:
-
我们纯粹在模板组合中使用给定的组件,因此依赖于输入和输出属性作为父组件之间的粘合剂
-
我们使用由路由器实例化的组件,并依赖于由输入提供的视图路由参数,并且不需要与父组件通信
嗯,上述两种设计方法都不是很好,对吧?在一个理想的世界里,当我们为路由启用组件时,我们不需要对组件本身进行任何更改。路由器应该只是使组件能够进行路由,但不应要求对组件本身进行任何更改。不幸的是,在撰写本书时,对于解决这个问题还没有达成共识。
由于我们不希望失去从TaskList和Comments组件依赖输入和输出所获得的所有组合能力,我们需要找到一个更好的解决方案来在我们的应用程序中启用路由。
-
以下解决方案使我们能够在不修改
TaskList和Comments组件的情况下使用它们,同时它们仍然可以依赖于输入和输出属性。我们不会直接将它们暴露给路由器,而是将构建包装组件,并从我们的路由中引用这些包装组件。这些包装组件遵循一些机制来弥合路由器和我们的组件之间的差距。 -
wrapper组件处理任何可能已在激活的路由中设置的任何路由参数或路由数据。 -
它们的模板应仅包含包装的组件及其输入和输出绑定。
-
它们处理所需的数据和功能,以提供相关组件的输入和输出绑定
-
它们可能使用父组件注入来与父组件建立通信并传播由发出的事件所要求的任何动作。父组件注入应谨慎使用,因为它在一定程度上破坏了组件的解耦。
理解路由树
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路由参数组成。
使用这种树形数据结构,我们有一个完美的抽象来处理导航树。我们可以比较树,检查某些段是否存在于树中,并从解析的路由段中提取参数。
为了演示路由树的使用,让我们看看我们可以在我们的可导航组件上实现的OnActivate路由生命周期钩子:
routerOnActivate(currentRouteSegment,
previousRouteSegment,
currentTree,
previousTree)
当我们在组件上实现此生命周期钩子时,我们可以在路由激活后运行一些代码。currentRouteSegment参数将指向在组件上激活的RouteSegment实例。
让我们再次查看我们的示例,并假设我们想要访问我们B组件的routerOnActivate钩子中的:id参数:
routerOnActivate(currentRouteSegment) {
this.id = currentRouteSegment.getParam('id');
}
在RouteSegment实例上使用getParam函数,我们可以获取在给定段上解析的任何参数。在我们的示例中,这将返回一个100字符串。
让我们看看一个更复杂的例子。如果我们想从d详细视图的D组件中访问:id参数怎么办?在OnActivate生命周期钩子中,我们只会收到与D组件相关的路由段。这仅包括d URL 段,不包括父路由中的:id参数。我们现在可以利用RouteTree实例来找到父路由段并从那里获取参数:
routerOnActivate(currentRouteSegment,
previousRouteSegment,
currentTree) {
this.id = currentTree.parent(currentRouteSegment).getParam('id');
}
使用当前的RouteTree实例,我们可以获取当前路由段的上层路由。结果,我们将收到前面图中所示的父路由段(RouteSegment B),从那里我们可以获取:id参数。
如您所见,路由 API 非常灵活,它允许我们以非常细粒度地检查路由活动。路由器中使用的树结构使得我们能够在应用中比较复杂的路由状态,而无需担心底层复杂性。
返回路由
好的,现在是我们为我们的应用程序实现路由的时候了!在以下主题中,我们将为我们的应用程序创建以下路由:
| 路由路径 | 描述 |
|---|---|
/projects/:projectId |
此路由将在我们的主应用程序组件的出口处激活Project组件。这包括projects URL 段以及:projectId URL 段来指定项目 ID。 |
/projects/:projectId/tasks |
此路由将激活TaskList组件。我们将创建一个ProjectTaskList包装组件,以便将我们的TaskList组件与路由解耦。我们将应用前一小节中描述的程序,即路由与模板组合。 |
/projects/:projectId/comments |
此路由将激活Comments组件。我们将创建一个ProjectComments包装组件,以便将我们的Comments组件与路由解耦。我们将应用前一小节中描述的程序,即路由与模板组合。 |
为了使用 Angular 的路由器,我们首先需要做的事情是将路由提供者添加到我们的应用程序中。我们将在引导时这样做,以确保路由提供者只加载一次。让我们打开我们的boostrap.js文件并添加必要的依赖项:
...
import {bootstrap} from '@angular/platform-browser-dynamic';
import {provide} from '@angular/core';
// Import router dependencies
import {HashLocationStrategy, LocationStrategy} from '@angular/common';
import {ROUTER_PROVIDERS} from '@angular/router';
...
bootstrap(App, [
...
ROUTER_PROVIDERS,
provide(LocationStrategy, {
useClass: HashLocationStrategy
})
]);
从路由模块中,我们导入包含需要作为提供者公开的模块列表的ROUTER_PROVIDERS常量。我们还从通用模块中导入需要手动提供的LocationStrategy和HashLocationStrategy类型。
使用provide函数,我们将HashLocationStrategy类作为对LocationStrategy抽象类的替代。这样,路由器将知道在解析 URL 时使用哪种策略。
目前存在以下两种策略:
-
HashLocationStrategy:当路由应使用 hash URL 时,例如
localhost:8080#/child/something,可以使用此策略。如果由于浏览器或服务器限制而无法使用 HTML5 的 push state,则此位置策略是有意义的。整个导航状态将管理在 URL 的片段标识符中。 -
PathLocationStrategy:如果您想使用 HTML5 的 push state 来处理应用程序 URL,则可以使用此策略。这意味着您的应用程序导航成为 URL 的实际路径。使用前面基于 hash 的 URL 的示例,此策略将允许直接使用
localhost:8080/child/something。由于初始请求将击中服务器,如果状态编码在 URL 的路径中,您需要启用服务器上的正确路由以使其正常工作。
在为我们的应用程序启用路由后,我们需要使我们的根组件可路由。我们可以通过在App组件上包含路由配置来实现这一点。让我们看看完成此操作所需的代码。我们编辑lib文件夹中的app.js文件:
...
import {Project} from './project/project';
import {Routes, Route} from '@angular/router';
@Component({
selector: 'ngc-app',
...
})
@Routes([
new Route({path: 'projects/:projectId', component: Project})
])
export class App {
...
}
在前面的代码中,我们从路由模块中导入了Routes装饰器以及Route类型。
为了在我们的组件上配置路由,我们可以通过传递一个描述此组件上可能存在的子路由的Route对象数组来使用@Routes装饰器。
让我们看看我们可以传递给Route构造函数的可用选项:
| 路由属性 | 描述 |
|---|
| path | 这个属性是必需的。使用路径,我们可以使用路由匹配 DSL 描述浏览器中的导航 URL。这可以包含路由参数占位符。以下是一些示例:
-
当用户在浏览器中导航到
/home时,以下路由会被激活:path: '/home' -
当用户导航到
/child/somethingURL 时,以下路由会被激活,其中something将作为名为id的路由参数可用:
path: '/child/:id'
|
component |
这个属性是必需的,它定义了路由器应该实例化哪个组件。正如在上一节中解释的,路由器不允许我们在这里指定任何绑定到实例化组件的绑定。 |
|---|
我们 App 组件上的路由配置涵盖了在 projects/:projectId 路径上实例化的 Project 组件。这意味着我们在子路由上使用 projectId 参数,它将可用于 Project 组件。
我们还需要修改我们的 App 组件模板,并从中移除直接包含的 Project 组件。我们现在将控制权交给路由器来决定显示哪个组件。为此,我们需要使用 RouterOutlet 指令在我们的模板中提供一个插槽,路由器将在其中实例化组件。
RouterOutlet 指令是路由模块导出的 ROUTER_DIRECTIVES 常量的一部分。让我们将其导入并添加到我们组件的指令列表中:
...
import {Routes, Route, ROUTER_DIRECTIVES} from '@angular/router';
...
@Component({
selector: 'ngc-app',
...
directives: [..., ROUTER_DIRECTIVES],
...
})
...
export class App {
...
}
现在,我们可以在模板中使用 RouterOutlet 指令来指示路由器实例化组件的插入位置。让我们打开我们的 App 组件模板文件,app.html,并进行必要的修改:
<div class="app">
...
<div class="app__l-main">
<router-outlet></router-outlet>
</div>
</div>
下一步是将我们的 Project 组件重构,使其可用于路由。正如我们在上一节中概述的,路由在组件设计方面有一些限制。对于 Project 组件,我们决定以这种方式重新设计它,以便我们只能用它进行路由。在这里这并不是坏事,因为我们排除了它在我们的应用程序的其他地方被重用的可能性。
Project 组件的重设计包括以下步骤:
-
移除组件的所有输入和输出属性。
-
使用
OnActivate路由生命周期钩子从App组件激活的路由段中获取projectId参数。 -
使用
projectId参数直接从数据存储中获取项目数据。 -
直接在组件上处理项目数据的更新,而不是委托给
App组件。
让我们修改位于 lib/project/project.js 中的 Component 类以实现前面的设计更改:
import {Component, ViewEncapsulation, Inject} from '@angular/core';
import template from './project.html!text';
import {Tabs} from '../ui/tabs/tabs';
import {DataProvider} from '../../data-access/data-provider';
import {LiveDocument} from '../../data-access/live-document';
@Component({
selector: 'ngc-project',
host: {class: 'project'},
template,
encapsulation: ViewEncapsulation.None,
directives: [Tabs]
})
export class Project {
constructor(@Inject(DataProvider) dataProvider) {
this.dataProvider = dataProvider;
this.tabItems = [
{title: 'Tasks', link: ['tasks']},
{title: 'Comments', link: ['comments']}
];
}
routerOnActivate(currentRouteSegment) {
this.id = currentRouteSegment.getParam('projectId');
this.document = new LiveDocument(this.dataProvider, {
type: 'project',
_id: this.id
});
this.document.change.subscribe((data) => {
this.title = data.title;
this.description = data.description;
this.tasks = data.tasks;
this.comments = data.comments;
});
}
updateTasks(tasks) {
this.document.data.tasks = tasks;
this.document.persist();
}
updateComments(comments) {
this.document.data.comments = comments;
this.document.persist();
}
ngOnDestroy() {
this.document.unsubscribe();
}
}
除了实现已经描述在重新设计步骤中的这些更改之外,我们还利用了从data-access文件夹导入的新LiveDocument实用类。这有助于我们在关注单个数据实体的变化时保持编程的响应性。使用LiveDocument类,我们可以查询数据库中的单个实体,而LiveDocument实例的变化属性是一个可观察对象,它会通知我们实体的变化。LiveDocument实例还通过data属性暴露实体的数据,可以直接访问。如果我们想对实体进行更新,我们可以在数据对象上添加、修改或删除属性,然后通过调用persist()来存储更改。
由于我们的Project组件现在由App组件中的路由激活,我们可以通过实现一个名为routerOnActivate的方法来使用OnActivate路由生命周期钩子。我们使用当前路由段上的getParam函数来获取路由的:projectId参数。
在我们的LiveDocument实例的变化可观察对象上的subscribe函数中,我们直接在Project组件上暴露项目数据。这简化了后续在视图中的使用。
在OnDestroy生命周期钩子中,我们确保我们取消订阅文档变化可观察对象。
现在,我们可以依赖projectId路由参数传递到我们的组件中,这使得Project组件依赖于路由。我们移除了所有输入属性,然后通过查询我们的数据存储使用项目 ID 来设置必要的数据。
现在,是时候构建我们之前提到的包装组件,以便路由到我们的TaskList和Comments组件。
让我们创建一个新的组件,称为ProjectTaskList,它将作为包装器来启用路由中的TaskList组件。我们将在lib/project/project-task-list路径下创建一个project-task-list.js文件,如下所示:
import {Component, ViewEncapsulation, Inject, forwardRef} from '@angular/core';
import template from './project-task-list.html!text';
import {TaskList} from '../../task-list/task-list';
import {Project} from '../project';
@Component({
selector: 'ngc-project-task-list',
...
directives: [TaskList]
})
export class ProjectTaskList {
constructor(@Inject(forwardRef(() => Project)) project) {
this.project = project;
}
updateTasks(tasks) {
this.project.updateTasks(tasks);
}
}
让我们也看看project-task-list.html文件中的模板:
<ngc-task-list [tasks]="project.tasks"
(tasksUpdated)="updateTasks($event)"></ngc-task-list>
我们将Project父组件注入到我们的包装组件中。由于我们不能再依赖输出属性来发出事件,这是与父Project组件通信的唯一方式。我们在这里处理一个循环引用(Project依赖于ProjectTaskList,而ProjectTaskList依赖于Project),因此我们需要使用forwardRef辅助函数来防止Project类型评估为undefined。
如果我们在模板中接收到tasksUpdated事件,我们将在我们的包装组件上调用updateTasks方法。然后包装组件简单地委托调用到项目组件。
类似地,我们使用项目数据来获取任务列表并创建与TaskList组件的tasks输入属性的绑定。
使用这种包装方法进行路由,我们能够在启用路由时不对组件进行修改。这比仅使任务列表对路由可用要好得多。我们将会失去在项目上下文之外使用任务列表的自由,然后使用纯模板组合。
对于Comments组件,我们执行完全相同的任务,并在lib/project/project-comments路径上创建一个包装器。除了处理评论而不是任务之外,代码与ProjectTaskList包装组件完全相同。
在创建了两个包装组件之后,我们现在可以在我们的Project组件上创建路由配置。让我们修改project/project.js文件以启用路由:
...
import {ProjectTaskList} from './project-task-list/project-task-list';
import {ProjectComments} from './project-comments/project-comments';
import {Routes, Route} from '@angular/router';
...
@Component({
selector: 'ngc-project',
...
})
@Routes([
new Route({ path: 'tasks', component: ProjectTaskList}),
new Route({ path: 'comments', component: ProjectComments})
])
export class Project {
...
}
要启用任务列表并使用路由使评论可导航,我们只需创建一个路由配置来实例化我们的包装组件。我们还指定如果没有选择子路由,则任务路由应该是默认路由。
可路由标签页
好的,如果你已经阅读了这一章到目前为止的内容,你现在可能想知道路由将在哪里实例化子路由的组件。我们尚未在Project组件模板中包含路由出口,这样路由就知道在哪里实例化组件。
我们不会直接在Project组件中包含项目路由的出口。相反,我们将使用我们的Tabs组件来接管这个任务。不同于我们迄今为止在Tabs组件中使用的内容插入,我们现在使用一个路由出口来组合其内容。这将使我们的Tabs组件在非路由情况下不可用,但我们可以通过仅提供路由出口来建立良好的解耦。这样我们仍然可以在其他路由场景中重用Tabs组件:

App 组件直接包含一个路由出口;然而,Project 组件依赖于 Tabs 组件来提供路由出口。
在更高层次上,我们可以描述我们的Tabs组件的新设计,如下所示:
-
它根据路由链接和标题列表渲染所有标签按钮,以提供路由导航
-
它提供了一个路由出口,将被父组件用来实例化导航组件
让我们修改我们的Tabs组件在lib/ui/tabs/tabs.js中的实现这些更改:
...
import {ROUTER_DIRECTIVES} from '@angular/router';
@Component({
selector: 'ngc-tabs',
...
directives: [ROUTER_DIRECTIVES]
})
export class Tabs {
@Input() items;
}
路由模块中的ROUTER_DIRECTIVES常量包含RouterOutlet指令以及RouterLink指令。通过导入这个常量并将其提供给组件指令列表,我们使这两个路由指令可以在我们的模板中使用。
在Tabs组件的模板中使用RouterOutlet指令来指示路由的实例化点。
RouterLink指令可以用来从模板生成路由 URL,使用路由链接 DSL。这允许你在应用程序中生成导航链接,它既可以放在锚标签上,也可以放在其他元素上,点击时将触发导航。
items输入是一个包含标题和路由链接的链接项数组。在我们的父项目组件中,我们已经在构造函数中准备好了这些项。
让我们快速看一下tabs.html文件中我们组件的模板:
<ul class="tabs__tab-list">
<li *ngFor="let item of items">
<a class="tabs__tab-button"
[routerLink]="item.link">{{item.title}}</a>
</li>
</ul>
<div class="tabs__l-container">
<div class="tabs__tab tabs__tab--active">
<router-outlet></router-outlet>
</div>
</div>
当我们让路由器通过路由出口处理活动视图时,就不再需要使用多个可切换的活动标签组件了。我们始终只有一个活动标签,并让路由器处理内容。
让我们看看我们如何在Project组件中使用新的Tabs组件来使配置的路由可导航。首先,我们需要将以下代码添加到Project组件构造函数中,为Tabs组件提供必要的导航项:
this.tabItems = [
{title: 'Tasks', link: ['tasks']},
{title: 'Comments', link: ['comments']}
];
在我们的导航项的链接属性中,我们使用路由链接 DSL 来指定应该导航到哪个路由。由于导航相对于父路由段,并且我们已经在/projects/:projectId路由中,所以我们的路由链接 DSL 中应该只包含到tasks和comments子路由的相对路径。
在我们的Project组件模板中,我们现在可以使用tabItems属性来创建与Tabs组件输入属性的绑定:
<div class="project__l-header">
<h2 class="project__title">{{title}}</h2>
<p>{{description}}</p>
</div>
<ngc-tabs [items]="tabItems"></ngc-tabs>
重新整理导航
作为最后一步,我们还需要重新整理我们的导航组件,使其依赖于路由器。到目前为止,我们使用的是我们自己实现的复杂嵌套导航组件结构中的路由。我们可以使用 Angular 路由器大大简化这一点。
让我们从最小的组件开始,并编辑lib/navigation/navigation-section/navigation-item/navigation-item.html文件中的NavigationItem组件模板:
<a class="navigation-section__link"
[class.navigation-section__link--active]="isActive()"
[routerLink]="link">{{title}}</a>
我们现在不再自己控制链接行为,而是使用RouterLink指令生成一个基于组件link输入属性的链接。为了在导航链接上设置活动类,我们仍然依赖于组件上的isActive方法,并且模板中不需要任何更改。
让我们看看navigation-item.js文件中Component类的更改:
...
import {RouterLink} from '@angular/router/src/directives/router_link';
@Component({
selector: 'ngc-navigation-item',
...
directives: [RouterLink]
})
export class NavigationItem {
@Input() title;
@Input() link;
@ViewChild(RouterLink) routerLink;
isActive() {
return this.routerLink ?
this.routerLink.isActive : false;
}
}
我们现在不再依赖于Navigation组件来管理导航项的活动状态,而是依赖于RouterLink指令。每个RouterLink指令提供一个accessor属性,isActive,它告诉我们由路由链接指定的特定路由是否在浏览器的 URL 中当前激活。使用@ViewChild装饰器,我们可以在视图中查询RouterLink指令,然后查询isActive属性以确定当前导航项是否处于活动状态。
现在,我们只需确保在App组件中将必要的项目传递给Navigation组件,以便使我们的导航再次工作。
需要在app.js文件中的App组件构造函数中更改以下代码:
this.projectsSubscription = projectService.change
.subscribe((projects) => {
this.projects = projects;
// We create new navigation items for our projects
this.projectNavigationItems = this.projects
// We first filter for projects that are not deleted
.filter((project) => !project.deleted)
.map((project) => {
return {
title: project.title,
link: ['/projects', project._id]
};
});
});
通过过滤和映射可用的项目,我们可以创建一个包含title和link属性的导航项列表。link属性包含一个指向在App组件路由配置中配置的项目详情路由的路由链接 DSL。通过将对象字面量作为路由名称的兄弟节点传递,我们可以在路由中指定一些参数。在这里,我们只需将预期的projectId参数设置为项目列表中项目的 ID。
现在,我们的导航组件利用路由器来实现导航功能。我们在Navigation组件中移除了自定义路由功能,并使用路由链接 DSL 来创建导航项。
摘要
在本章中,我们学习了 Angular 中路由器的基本概念。我们探讨了如何使用现有的组件树在嵌套路由场景中配置子路由。通过使用嵌套子路由,我们实现了带有路由配置的组件的重用。
我们还探讨了路由器与模板组合的问题以及如何通过使用包装组件来缓解这个问题。通过这种方式,我们通过一个中间层来缩小路由器和底层组件之间的差距。
我们研究了路由配置的细节和路由链接 DSL 的基础知识。我们还涵盖了RouteTree和RouteSegment类的基础知识以及如何使用它们进行深入的路由分析。
在下一章中,我们将学习 SVG 以及如何在我们的 Angular 应用程序中使用这个网络标准来绘制图形。我们将使用 SVG 可视化我们的应用程序活动日志,并看到 Angular 如何通过启用组合性使这项技术更加出色。
第六章。跟上活动
在本章中,我们将使用可缩放矢量图形(SVG)和 Angular 构建图形组件,在我们的任务管理系统中构建活动日志。SVG 是处理复杂图形内容的理想选择,通过使用 Angular 组件,我们可以构建封装良好且可重用的内容。
由于我们希望在应用程序中记录所有活动,例如添加评论或重命名任务,我们将创建一个中央存储库。然后我们可以显示这些活动,并使用 SVG 将它们渲染为活动时间线。
为了添加所有活动的概述并提供用户输入以缩小显示活动的范围,我们将创建一个交互式滑块组件。此组件将使用投影将时间戳以刻度和活动形式直接渲染到滑块的背景上。我们还将使用 SVG 在组件内部渲染元素。
本章我们将涵盖以下主题:
-
SVG 的基本介绍
-
使 SVG 与 Angular 组件可组合
-
在组件模板中使用命名空间
-
创建一个简单的管道,使用 Moment.js 格式化日历时间
-
使用
@HostListener注解处理用户输入事件以创建交互式滑块元素 -
使用
ViewEncapsulation.Native来利用 Shadow DOM 创建原生风格的封装
创建活动日志服务
本章的目标是提供一个方法来跟踪任务管理应用程序中的所有用户活动。为此,我们需要一个系统,允许我们在组件内记录活动并访问已记录的活动。
作为实体,活动应该是相当通用的,并应具有以下字段及其相应用途:
-
主题:此字段应用于引用活动的主题。这可以是任何标识符,用于识别外部实体。在项目上下文中,我们将在此字段中存储项目 ID。使用活动服务的服务和组件应使用此字段进一步筛选特定活动。
-
类别:此字段提供了一种额外的标记活动的方法。对于项目,我们目前将使用两个类别:评论和任务。
-
标题:这指的是活动的标题,将提供关于活动内容的简要总结。这可能像新任务已添加或评论已删除这样的内容。
-
消息:这是活动真正内容所在字段。它应包含足够的信息,以便在活动期间提供良好的可追溯性。
为了开发我们的系统,我们将在lib文件夹下的activities/activity-service路径下创建一个名为activity-service.js的新文件。在此文件中,我们将创建我们的活动服务类,通过使用@Injectable注解来启用依赖注入:
@Injectable()
constructor(@Inject(DataProvider) dataProvider,
@Inject(UserService) userService) {
export class ActivityService {
// We're exposing a replay subject that will emit events
// whenever the activities list change
this.change = new ReplaySubject(1);
this.dataProvider = dataProvider;
this.userService = userService;
this.activities = [];
// We're creating a subscription to our datastore to get
// updates on activities
this.activitiesSubscription = this.dataProvider.getLiveChanges()
.map((change) => change.doc)
.filter((document) => document.type === 'activity')
.subscribe((changedActivity) => {
this.activities = this.activities.slice();
// Since activities can only be added we can assume that
// this change is a new activity
this.activities.push(changedActivity);
// Sorting the activities by time to make sure there's no
// sync issue messing with the ordering
this.activities.sort((a, b) =>
a.time > b.time ? -1 : a.time < b.time ? 1 : 0);
this.change.next(this.activities);
});
}
// This method is logging a new activity
logActivity(subject, category, title, message) {
// Using the DataProvider to create a new document in our
// datastore
this.dataProvider.createOrUpdateDocument({
type: 'activity',
user: this.userService.currentUser,
time: new Date().getTime(),
subject,
category,
title,
message
});
}
}
在我们活动服务的构造函数中,我们订阅了数据存储的变化,并按类型过滤了任何传入的变化,这样我们只会收到活动更新。
由于活动不能被编辑或删除,我们只需要关注新添加的活动。我们在订阅中更新活动的内部数组,以包含任何添加的活动。这样,我们不仅会收到所有初始活动,还会收到直接从数据存储中添加的活动。其他服务和组件可以随后直接访问系统的活动列表。
为了让其他应用程序组件对活动列表的变化做出反应,我们在change成员字段上公开了一个ReplaySubject可观察对象。
在logActivity方法中,我们只是简单地将一个新的活动添加到数据存储中。UserService将为我们提供有关当前登录用户的信息,我们可以使用DataProvider将数据写入数据存储。
因此,我们创建了一个简单的平台,将帮助我们跟踪应用程序中的活动。由于我们希望在应用程序中只有一个ActivityService实例,让我们将其添加到根App组件的providers列表中。你可以在lib文件夹中的app.js文件中找到这个组件:
@Component({
selector: 'ngc-app',
…
providers: [ProjectService, UserService, ActivityService]
})
由于所有依赖注入器都将继承我们的App组件的依赖项,因此我们可以将其注入到我们应用程序的任何组件中。
记录活动
我们创建了一个很好的系统来记录活动。现在让我们继续在我们的组件中使用它,以记录所有活动。
首先,让我们使用ActivityService在TaskList组件中记录活动。以下代码摘录突出了我们对lib文件夹中的task-list/task-list.js文件内TaskList组件所做的更改:
...
import {ActivityService} from '../activities/activity-service/activity-service';
import {limitWithEllipsis} from '../utilities/string-utilities';
@Component({
selector: 'ngc-task-list',
...
})
export class TaskList {
…
// Subject for logging activities
@Input() activitySubject;
onTaskUpdated(task, updatedData) {
...
// Creating an activity log for the updated task
this.activityService.logActivity(
this.activitySubject.id,
'tasks',
'A task was updated',
'The task "${limitWithEllipsis(oldTask.title, 30)}" was updated on #${this.activitySubject.document.data._id}.'
);
}
onTaskDeleted(task) {
...
// Creating an activity log for the deleted task
this.activityService.logActivity(
this.activitySubject.id,
'tasks',
'A task was deleted',
'The task "${limitWithEllipsis(removed.title, 30)}" was deleted from #${this.activitySubject.document.data._id}.'
);
}
addTask(title) {
...
// Creating an activity log for the added task
this.activityService.logActivity(
this.activitySubject.id,
'tasks',
'A task was added',
'A new task "${limitWithEllipsis(title, 30)}" was added to #${this.activitySubject.document.data._id}.'
);
}
...
}
使用ActivityService的logActivity方法,我们可以轻松地将任何数量的活动记录到现有的TaskList方法中,以修改任务。
在我们活动的消息体中,我们使用了一个新的实用函数,limitWithEllipsis,该函数是从一个新模块导入的,即string-utilities。这个函数接受一个字符串和一个数字作为参数。返回的字符串是输入字符串的截断版本,在第二个参数指定的位置被截断。此外,字符串后面还附加了一个省略号字符(...)。我不会在这个辅助函数的相对简单的代码上打扰你。如果你想知道它是如何实现的,你可以在下载本章代码后查看实现。
如果你回到我们活动日志的规范,你会看到我们总是需要指定一个主题才能记录活动。我们通过引入一个新的输入参数activitySubject在我们的TaskList组件上实现了这一点。这里的假设是每个活动主体都包含存储在文档成员下的LiveDocument。从那里,我们可以从数据存储中获取 ID,并用于我们的活动消息。
如果我们回顾我们的Project组件,你会看到我们已经在遵循活动主体的先决条件。我们在document成员字段下存储了对底层LiveDocument实例的引用。
我们现在需要做的就是更改我们的ProjectTaskList包装组件的模板,以传递TaskList组件的activitySubject项目输入。让我们快速看一下lib/project/project-task-list/project-task-list.html文件中的更改:
<ngc-task-list [tasks]="project.tasks"
[activitySubject]="project"
(tasksUpdated)="updateTasks($event)"></ngc-task-list>
你可能会想知道,如果我们可以直接传递一个项目硬引用并直接使用项目任务和项目 ID,为什么我们还要关心这种处理任务列表相当繁琐的方法。我们当前解决方案的美丽之处在于,我们没有任何对项目的依赖。我们也可以在不涉及项目上下文的情况下使用我们的TaskList组件。我们仍然可以向tasks输入传递任务列表,并为活动日志使用不同的活动主题。
我们还将在Comments组件中使用ActivityService来创建添加、编辑和删除评论的日志。由于涉及的步骤与我们刚刚为TaskList组件所做的工作非常相似,我们将跳过这一部分。你总是可以查看本章的最终代码库,为Comments组件添加活动日志。
利用 SVG 的力量
SVG 自 1999 年以来一直是开放网络平台标准的一部分,并于 2001 年在 SVG 1.0 标准下首次推荐。SVG 是两个独立 XML 矢量图像格式提案的整合。精确图形标记语言(PGML)——主要由 Adobe 和 Netscape 开发——以及矢量标记语言(VML)——主要由微软和 Macromedia 代表——都是服务于相同目的的不同 XML 格式。W3C 联盟拒绝了这两个提案,转而支持新开发的 SVG 标准,该标准将两者的优点统一为一个单一标准:

展示 SVG 标准发展历程的时间线
所有这三个标准都有一个共同的目标,即提供一个格式,让 Web 在浏览器中显示矢量图形。SVG 是一种声明性语言,使用 XML 元素和属性指定图形对象。
让我们看看如何使用 SVG 创建一个黑色圆圈的 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 图像,其中有一个黑色圆圈,其中心位于x = 10 px和y = 10 px。圆圈的半径是 10 px,这使得这个圆圈宽度和高度都是 20 px。
SVG 的坐标系原点位于左上角,其中y轴朝南方向,x轴朝东:

SVG 内的坐标系
不仅使用原始形状,如圆圈、线条和矩形,还使用复杂的多边形,创建图形内容的可能性几乎是无限的。
SVG 不仅用于 Web,而且已成为不同应用程序之间交换矢量图形的重要中间格式。几乎任何支持矢量图形的应用程序都支持导入 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 元素一样添加事件监听器。在这个例子中,我们添加了一个click事件监听器,并将我们的圆圈颜色改为红色。
为了简化,我们在本例中使用了内联script标签。当然,使用一个单独的 JavaScript 文件来做脚本会更干净利落。
SVG 的样式
在 Web 中关注点的分离方面,我是一个纯粹主义者。我仍然坚信结构(HTML)、外观(CSS)和行为(JavaScript)的分离,以及遵循此实践时产生最可维护的应用程序。
首先,在 HTML 中使用 SVG 似乎很奇怪,你可能会认为这打破了干净的分离契约。为什么只有外观相关的数据组成的图形内容,却坐在只应包含原始信息的 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:这是具有
www.w3.org/1999/xhtmlURI 的标准 HTML 命名空间。 -
SVG:这包括所有 SVG 元素和属性,并使用
www.w3.org/2000/svgURI。您有时可以在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 的 width 和 height 属性以及圆形的 cx、cy 和 r 属性来确定圆形的实际宽度和高度。
要使用我们的 Circle 组件,只需在另一个组件中使用以下模板:
<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 组件,我们的 AwesomeCircle 组件没有 svg 根元素来告诉 Angular 切换命名空间。这就是为什么我们在 App 组件中创建了一个 svg 元素,然后在一个 SVG 组中包含了 AwesomeCircle 组件。
我们需要明确告诉 Angular 在我们的 Circle 组件中切换到 SVG 命名空间,我们可以通过在代码片段中高亮显示的部分中看到的那样,将命名空间名称作为冒号分隔的前缀来做到这一点。
如果你需要显式地在 SVG 命名空间中创建多个元素,你可以依赖 Angular 也会为子元素应用命名空间的事实,并且会使用 SVG 组元素将所有你的元素分组。所以,你只需要在组元素 <svg:g> ... </svg:g> 前加前缀,但不要包含任何 SVG 元素。
在处理 SVG 时,这已经足够了解 Angular 的内部机制了。让我们继续前进,创建一些真正的组件!
构建一个交互式活动滑块组件
在前面的主题中,我们已经介绍了使用 SVG 以及在 Angular 组件中处理 SVG 的基础知识。现在,是时候将我们的知识应用到任务管理应用中,并使用 SVG 创建一些组件了。
在这个背景下,我们将首先创建一个交互式滑块,允许用户选择他或她感兴趣检查的活动时间范围。显示一个简单的 HTML5 范围输入可能是一个解决方案,但既然我们已经获得了 SVG 超能力,我们可以做得更好!我们将使用 SVG 来渲染我们自己的滑块,它将在滑块上显示现有活动作为刻度。让我们看看我们将要创建的滑块组件的预览图:

活动滑块组件的预览图
我们的滑块组件实际上将有两个用途。它应该是一个用户控件,并提供一种选择时间范围以过滤活动的方法。然而,它还应该提供所有活动的概览,以便用户可以更直观地过滤范围。通过绘制代表活动的垂直条,我们已经在用户感兴趣的范围上给出了感觉。
首先,我们将在 activities/activity-slider 路径下创建一个新的文件,名为 activity-slider.js,并定义我们的组件类:
import styles from './activity-slider.css!text';
@Component({
selector: 'ngc-activity-slider',
host: {
class: 'activity-slider'
},
styles: [styles],
encapsulation: ViewEncapsulation.Native,
…
})
export class ActivitySlider {
// The input expects a list of activities
@Input() activities;
// If the selection of date range changes within our slider
// component, we'll emit a change event
@Output() selectionChange = new EventEmitter();
constructor(@Inject(ElementRef) elementRef) {
// We'll use the host element for measurement when drawing
// the SVG
this.sliderElement = elementRef.nativeElement;
// The padding on each side of the slider
this.padding = 20;
}
ngAfterViewInit() {
// We'll need a reference to the overlay rectangle so we can
// update its position and width
this.selectionOverlay = this.sliderElement
.shadowRoot.querySelector('.selection-overlay');
}
}
我们应该首先提到的是,这与我们迄今为止编写的所有其他组件都不同,我们正在为这个组件使用 ViewEncapsulation.Native。正如我们在第二章的 创建我们的应用程序组件 部分中学习的,当我们在组件封装中使用 ViewEncapsulation.Native 时,Angular 实际上使用 Shadow DOM 来创建组件。我们在第一章的 组件化用户界面 部分的 Shadow DOM 部分中也简要地讨论了这一点。
使用 Shadow DOM 为我们的组件将带来这个优势:我们的组件将从 CSS 方面完全封装。这不仅意味着没有任何全局 CSS 会泄漏到我们的组件中,而且这也意味着我们需要创建本地样式来为我们的组件进行样式设计。
到目前为止,我们使用了一种名为 BEM 的 CSS 命名约定,它为我们提供了一些必要的前缀,以避免 CSS 中的名称冲突,并建立干净简单的 CSS 特异性。然而,当使用 Shadow DOM 时,我们可以省略前缀以避免名称冲突,因为我们只在本组件内部应用样式。
由于我们在这个组件中使用 Shadow DOM,我们需要有一种方法来定义本地样式。Angular 通过组件注解的 styles 属性为我们提供了一个将样式传递到组件中的选项。
小贴士
Chrome 从版本 35 开始原生支持 Shadow DOM。在 Firefox 中,可以通过访问about:config页面并打开dom.webcomponents.enabled标志来启用 Shadow DOM。IE、Edge 和 Safari 根本不支持这个标准;然而,我们可以通过包含一个名为webcomponents.js的 polyfill 来设置它们以处理 Shadow DOM。你可以在github.com/webcomponents/webcomponentsjs上找到有关此 polyfill 的更多信息。
使用 SystemJS 的文本插件,我们可以导入只包含我们组件本地样式的样式表,然后将它们传递给styles属性。通过在 CSS 文件的导入后添加一个!text后缀,我们告诉 SystemJS 将我们的 CSS 文件作为原始文本加载。请注意,styles属性期望一个数组,这就是为什么我们将导入的样式包装在一个数组字面量中。
如果你查看ActivitySlider组件的样式表,你可以立即看到我们不再在类名前加上组件名称:
.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;
}
通常,这样的短类名可能会在我们的项目中导致名称冲突,但既然样式将仅限于我们组件的 Shadow DOM 中,我们就不需要担心名称冲突了。
作为输入参数,我们定义了将用于不仅确定滑动组件中的可用范围,还要在滑动组件的背景上渲染活动的活动列表。
一旦用户做出选择,我们的组件将使用selectionChange事件发射器来通知外部世界关于变化。
在构造函数中,我们留出组件 DOM 元素,以便进行一些测量,以便稍后绘制:
this.sliderElement = elementRef.nativeElement;
通过将ElementRef实例注入构造函数,我们可以轻松访问我们组件的原生 DOM 元素。
时间的投影
我们的滑动组件需要能够将时间戳投影到 SVG 的坐标系中。此外,当用户点击时间线选择范围时,我们需要能够将坐标投影回时间戳。为此,我们需要在我们的组件中创建两个投影函数,这些函数将使用一些辅助函数和状态来计算值,从坐标到时间和反过来:

我们计算中重要变量和函数的可视化
虽然我们将使用百分比来定位滑动组件上的 SVG 元素,但两侧的填充需要以像素为单位指定。totalWidth函数将返回该区域的像素总宽度;这是我们将在其中绘制活动指示器的位置。timeFirst、timeLast和timeSpan变量也将被计算使用,并以毫秒为单位指定。
让我们在activity-slider.js文件中添加一些代码来处理我们在滑动组件上的活动投影:
// Getting the total available width of the slider
totalWidth() {
return this.sliderElement.clientWidth - this.padding * 2;
}
// Projects a time stamp into percentage for positioning
projectTime(time) {
let position = this.padding +
(time - this.timeFirst) / this.timeSpan * this.totalWidth();
return position / this.sliderElement.clientWidth * 100;
}
// Projects a pixel value back to a time value. This is required
// for calculating time stamps from user selection.
projectLength(length) {
return this.timeFirst + (length - this.padding) / this.totalWidth() * this.timeSpan;
}
由于我们已经将根元素的引用作为sliderElement成员变量放置一旁,我们可以使用其clientWidth属性来获取组件的全宽并减去内边距。这将给我们想要绘制活动指示器的区域的全宽,以像素为单位。
在projectTime函数中,我们首先将时间戳通过一个简单的三段式规则转换为位置。因为我们可以访问第一个活动的时间戳以及总时间跨度,所以这将是一个非常简单的任务。一旦我们这样做,我们可以通过将其除以组件的总宽度然后乘以 100,将我们的位置值(以像素为单位)转换为百分比。
要将像素值投影回时间戳,我们可以做projectTime的相反操作,只不过这里我们不是处理百分比,而是假设projectLength函数的长度参数是以像素为单位。
我们在我们的投影代码中使用了一些成员变量——timeFirst、timeLast和timeSpan——但我们是如何设置这些成员变量的呢?因为我们有一个activities组件输入,它预期是一个相关活动的列表,我们可以观察输入以检测变化,并根据输入设置值。为了观察组件输入的变化,我们可以使用ngOnChanges生命周期钩子:
ngOnChanges(changes) {
// If the activities input changes we need to re-calculate and
// re-draw
if (changes.activities && changes.activities.currentValue) {
const activities = changes.activities.currentValue;
// For later calculations we set aside the times of the
// first and the last activity
if (activities.length === 1) {
// If we only have one activity we use the same time for
// first and last
this.timeFirst = this.timeLast = activities[0].time;
} else if (activities.length > 1) {
// Take first and last time
this.timeFirst = activities[activities.length - 1].time;
this.timeLast = activities[0].time;
} else {
// No activities yet, so we use the current time for both
// first and last
this.timeFirst = this.timeLast = new Date().getTime();
}
// The time span is the time from the first activity to the
// last activity. We need to limit to lower 1 for not messing
// up later calculations.
this.timeSpan = Math.max(1, this.timeLast - this.timeFirst);
}
}
首先,我们需要检查更改是否包括对activities输入的更改,并且当前输入值是有效的。在检查输入值之后,我们可以确定我们的成员变量,即timeFirst、timeLast和timeSpan。我们将timeSpan变量限制至少为1,否则我们的投影计算将会出错。
上述代码将确保当activities输入发生变化时,我们总是会重新计算我们的成员变量,并且我们会使用最新的数据渲染活动。
渲染活动指示器
我们已经实现了组件的基本功能,并为将时间信息引入组件的坐标系奠定了基础。现在是时候使用我们的投影函数,并使用 SVG 在滑块上绘制我们的活动指示器了。
首先,让我们看看我们将在activity-slider目录下的activity-slider.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指令重复表示活动指示器的矩形。
正如我们在前一个主题中构建ActivityService类时所知,活动总是包含一个带有活动时间戳的time字段。在我们的组件中,我们已创建了一个将时间转换为相对于组件宽度的百分比的投影函数。我们只需在rect元素的x属性绑定中使用projectTime函数,就可以将活动指示器定位在正确的位置。
通过仅使用 SVG 模板和我们的背景函数来投影时间,我们创建了一个小巧的图表,该图表在时间轴上显示活动指示器。
你可以想象,如果我们有很多活动,我们的滑块实际上看起来会很拥挤,很难感觉到这些活动可能发生的时间。我们需要有一种网格,可以帮助我们将图表与时间轴关联起来。
如我们在滑块组件的模拟中已展示的,我们现在将在滑块背景上引入一些刻度,将滑块分成几个部分。我们还将为每个刻度标注一个日历时间。这将使用户在查看滑块上的活动指示器时对时间有一个大致的感觉。
让我们看看ActivitySlider类中的代码更改,这将使我们能够渲染我们的刻度:
ngOnChanges(changes) {
// If the activities input changes we need to re-calculate and
// re-draw
if (changes.activities && changes.activities.currentValue) {
...
// Re-calculate the ticks that we display on top of the slider
this.computeTicks();
...
}
// This function computes 5 ticks with their time and position on
// the slider
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成员变量中。借助这些时间戳,我们可以在视图中轻松渲染刻度。
小贴士
我们使用 ES6 的Array.from函数创建一个具有所需长度的新数组,并使用函数式数组扩展函数map从该数组生成刻度模型对象。使用Array.from是一个创建给定长度的初始数组的不错技巧,这可以用来建立函数式风格。
让我们看看我们组件的模板以及我们如何使用我们的时间戳数组在滑块组件上渲染刻度。我们将修改activity-slider.html中的组件模板:
...
<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>
...
为了渲染我们的刻度,我们使用了一个 SVG 组元素来放置我们的NgFor指令,该指令重复我们在ticks成员变量中存储的刻度时间戳。
对于每个刻度,我们需要放置一个标签以及一条跨越滑块背景的线。我们可以使用 SVG 文本元素来渲染带有时间戳的标签,并将其放置在滑块上方。在我们的text元素的x属性绑定中,我们使用了projectTime投影函数来接收从时间戳中得到的投影百分比值。我们的text元素的y坐标固定在一个位置,这样标签就会正好位于滑块上方。
SVG 线由四个坐标组成:x1、x2、y1和y2。它们共同定义了两个坐标点,一条线将从其中一个点绘制到另一个点。
现在,我们越来越接近我们在本主题开头所指定的最终滑块。最后缺失的拼图碎片是使我们的滑块交互式,以便用户可以选择一系列活动。
使其生动起来
到目前为止,我们已经涵盖了滑块背景的渲染以及活动指示器的渲染。我们还生成了刻度,并用网格线和标签显示它们,以显示每个刻度的日历时间。
嗯,这并不真正是一个滑块,对吧?当然,我们还需要处理用户输入并使滑块交互式,以便用户可以选择他们想要显示活动的时间范围。
要实现这一点,请将以下更改添加到组件类中:
ngOnChanges(changes) {
// If the activities input changes we need to re-calculate and
// re-draw
if (changes.activities && changes.activities.currentValue) {
...
// Setting the selection to the full range
this.selection = {
start: this.timeFirst,
end: this.timeLast
};
// Selection changed so we need to emit event
this.selectionChange.next(this.selection);
}
}
当我们在ngOnChanges生命周期钩子中检测到activities输入属性的变化时,我们在滑块组件中初始化用户选择模型。它包含一个start和end属性,两者都包含表示活动滑块上选择范围的时间戳。
一旦我们设置了初始选择,我们需要使用selectionChange输出属性来触发一个事件。这样,我们可以让父组件知道滑块内的选择已更改。
为了显示选择范围,我们在模板中使用一个覆盖矩形,该矩形将放置在滑块背景上方。如果你再次查看滑块的模拟图像,你会注意到这个覆盖层被涂成灰色:
<rect *ngIf="selection"
[attr.x]="projectTime(selection.start) + '%'"
[attr.width]="projectTime(selection.end) - projectTime(selection.start) + '%'"
y="30" height="40" class="selection-overlay"></rect>
这个矩形将放置在我们的滑块背景上方,并使用我们的投影函数来计算x和width属性。由于我们需要等待变化检测在ngOnChanges生命周期钩子中初始化我们的选择,我们将通过使用NgIf指令来检查一个有效的选择对象。
现在,我们需要开始在ActivitySlider组件中处理用户输入。存储状态和渲染选择的机制已经就位,因此我们可以实现所需的主监听器来处理用户输入:
...
// If the component receives a mousedown event, we need to start a
// new selection
@HostListener('mousedown', ['$event'])
onMouseDown(event) {
// Starting a new selection by setting selection start and end
// to the projected time of the clicked position.
this.selection.start = this.selection.end =
this.projectLength(event.offsetX);
// Selection changed so we need to emit event an
this.selectionChange.next(this.selection);
// Setting a flag so we know that the user is currently moving
// the selection
this.modifySelection = true;
}
// We also need to track mouse moves within our slider component
@HostListener('mousemove', ['$event'])
onMouseMove(event) {
// We should only modify the selection if the component is in
// the correct mode
if (this.modifySelection) {
// Update the selection end with the projected time from the
// mouse coordinates
this.selection.end = Math.max(this.selection.start,
this.projectLength(event.offsetX));
// Selection changed so we need to emit event an
this.selectionChange.next(this.selection);
// To prevent side effects, we should stop propagation and
// prevent browser default
event.stopPropagation();
event.preventDefault();
}
}
// If the user is releasing the mouse button, we should stop the
// modify selection mode
@HostListener('mouseup')
onMouseUp() {
this.modifySelection = false;
}
// If the user is leaving the component with the mouse, we should
// stop the modify selection mode
@HostListener('mouseleave')
onMouseLeave() {
this.modifySelection = false;
}
...
在前面的代码片段中,我们在滑块宿主元素上处理了总共四个事件:
-
onMouseDown:我们使用相同的值设置选择模型的start和end属性。由于我们使用时间戳作为这些属性,我们首先将鼠标位置投影到时间空间中。鼠标位置以像素为单位相对于滑块组件的起点。由于我们知道滑块的宽度和显示的总时间长度,我们可以轻松地将这转换为时间戳。我们使用projectLength方法来完成此目的。通过传递第二个参数到@HostListener装饰器,我们指定我们希望将 DOM 事件传递给我们的onMouseDown方法。我们还在组件中设置了一个状态标志modifySelection,以指示正在进行选择。 -
onMouseMove:如果组件处于选择模式(modifySelection标志为true),您可以调整selection对象的结束属性。在这里,我们还确保使用Math.max和限制选择的结束不小于开始,从而排除了创建负选择的可能性。 -
onMouseUp:当用户释放鼠标按钮时,组件退出选择模式。这可以通过将modifySelection标志设置为false来完成。 -
onMouseLeave:这与onMouseUp事件相同;区别在于这里组件将仅退出选择模式。
使用@HostListener装饰器,我们能够处理所有必要的用户输入,以完成我们的组件,并添加了交互元素。
概述
在这个主题中,我们学习了如何使用 SVG 在 Angular 中创建图形和交互式组件。通过在 SVG 元素上创建属性绑定,并使用NgFor和NgIf指令控制图形元素的实例化,我们构建了一个自定义滑块组件,它为我们提供了活动的好概述。同时,我们还学习了如何使用@HostListener装饰器处理用户输入,以使我们的组件具有交互性:

完成活动滑块组件的截图
总结一下,我们学习了以下概念:
-
使用
ViewEncapsulation.Native封装组件视图并导入本地样式 -
将时间戳的基本投影到屏幕坐标上,以便与 SVG 元素一起使用
-
使用
@HostListener装饰器处理用户输入并创建自定义选择机制
构建活动时间线
到目前为止,我们已经构建了一个记录活动和服务滑块组件以选择时间范围并提供活动指标概述的服务。由于我们需要在滑块组件内执行许多绘图任务,SVG 对于这种情况非常合适。为了完成我们的Activities组件树,我们仍然需要渲染使用ActivitySlider组件选择的各项活动。
让我们继续工作在我们的活动组件树。我们将创建一个新的组件,该组件将负责在活动时间轴内渲染单个活动。让我们从 Activity 组件的模板开始,我们将在新的 activities/activity/activity.html 文件中创建它:
<img [attr.src]="activity.user.pictureDataUri"
[attr.alt]="activity.user.name"
class="activity__user-image">
<div [class.activity__info--align-right]="isAlignedRight()"
class="activity__info">
<h3 class="activity__title">{{activity.title}}</h3>
<p class="activity__author">
by {{activity.user.name}} {{activity.time | fromNow}}
</p>
<p>{{activity.message}}</p>
</div>
每个活动将包括一个用户图像以及一个包含活动标题、消息和作者详细信息的信息框。
我们的活动将使用一个输入来确定其对齐方式。这使我们能够从组件外部对活动进行对齐。isAlignedRight 方法帮助我们为活动信息框设置一个额外的 CSS 类,activity__info--align-right。
我们还需要为我们的 Activity 组件创建一个组件类,我们将在新的 activities/activity/activity.js 文件下创建它:
import {FromNowPipe} from '../../pipes/from-now';
@Component({
selector: 'ngc-activity',
…
// We are using the FromNow pipe to display relative times
// within our template
pipes: [FromNowPipe]
})
export class Activity {
@Input() activity;
// Input that should be a string 'left' or 'right' and will
// determine the activity alignment using CSS
@Input() alignment;
@Input() @HostBinding('class.activity--start-mark') startMark;
@Input() @HostBinding('class.activity--end-mark') endMark;
// Function with that will tell us if the activity should be
// aligned to the right. It's used for setting a modifier class
// on the info element.
isAlignedRight() {
return this.alignment === 'right';
}
}
我们的 Activity 组件期望四个输入:
-
activity:这个属性接受需要用组件渲染的活动数据模型。这是我们使用ActivityService创建的活动。 -
alignment:这个输入属性应该设置为包含单词left或right的字符串。我们使用这个来确定我们是否需要在模板中添加一个额外的 CSS 类,以便将活动信息框对齐到右侧。 -
startMark:这个输入属性同时充当输入和主机绑定。如果这个输入设置为true,活动将获得一个额外的 CSS 类,activity--start-mark,这将导致在时间轴顶部出现一个小标记,以指示时间轴的终止。 -
endMark:与startMark相同,这个输入使用主机绑定来设置一个额外的 CSS 类,activity--end-mark,这将导致在时间轴底部出现一个小标记,以指示时间轴的终止。
isAlignedRight 方法在模板中使用,以确定我们是否需要添加一个额外的 CSS 类到信息框,以便将其对齐到右侧。
我们使用在 第四章 中创建的 FromNow 管道格式化了活动的日期戳,即 请勿评论!。为了在模板中使用管道,我们需要导入它并将其添加到组件注解的 pipes 属性中。
我们现在几乎拥有了显示我们活动的所有组件。但仍然缺少一些东西,那就是将 ActivitySlider 与我们的 Activity 组件结合起来的粘合剂,并使我们的组件子树可导航。为此,我们将创建一个新的组件,称为 Activities。让我们创建一个 activities/activities.js 文件来编写我们的组件类:
@Component({
selector: 'ngc-activities',
...
directives: [ActivitySlider, Activity]
})
export class Activities {
@Input() activitySubject;
constructor(@Inject(ActivityService) activityService) {
this.activityService = activityService;
}
ngOnChanges(changes) {
if (changes.activitySubject) {
// If we have a subscription to the activities service
// already we need to unsubscribe first
if (this.activitiesChangeSubscription) {
this.activitiesChangeSubscription.unsubscribe();
}
// When the project data is updated we need to filter for
// activities again
this.activitiesChangeSubscription =
this.activityService.change.subscribe((activities) => {
// Filter for all activities that have the project ID as subject
this.activities = activities
.filter((activity) => activity.subject === this.activitySubject.document.data._id);
this.onSelectionChange();
});
}
}
首先,我们需要知道我们想在组件内显示哪些活动。为此,我们需要提供一个组件输入,即 activitySubject。一旦完成,我们就可以从父组件传递一个活动主题,并使用它来过滤我们感兴趣的活动。
由于我们已使用活动主语来记录活动,因此我们可以使用相同的主题来显示活动。在 ngOnChanges 生命周期钩子中,我们在 ActivityService 实例上设置了一个订阅来响应新创建的活动。因为活动服务会通过更新后的活动列表来通知我们,所以我们可以简单地使用 Array.prototype.filter 函数来过滤出相关项。我们已经利用了 activitySubject 输入来获取主题的 ID。
接下来,我们需要创建一个方法来对我们的活动应用日期范围过滤器。onSelectionChange 方法将从我们的 activities 模板中调用,我们在其中创建了一个与 ActivitySlider 组件的绑定:
// If the selection within the activity slider changes, we need
// to filter out activities again
onSelectionChange(selection = this.selection) {
this.selection = selection;
// Store filtered activities that fall into the date range
// selection specified by the slider
this.selectedActivities = this.selection ? this.activities.filter(
(activity) => activity.time >= this.selection.start
&& activity.time <= this.selection.end
);
}
当用户在滑块中更新时间范围时,我们将用从 ActivityService 获取的新过滤版本的活动覆盖 selectedActivities 成员变量。过滤器将通过将活动时间与滑块组件的选择范围进行比较来缩小活动范围。
现在我们将设置一些辅助函数,以便在模板中使用:
// Get an alignment string based on the index. Activities with
// even index get aligned left while odds get aligned right.
getAlignment(index) {
return index % 2 === 0 ? 'left' : 'right';
}
// Function to determine if an activity index is first
isFirst(index) {
return index === 0;
}
// Function to determine if an activity index is last
isLast(index) {
return index === this.selectedActivities.length - 1;
}
三个方法,即 getAlignment、isFirst 和 isLast,在模板中用作 Activity 组件的输入。如果你再次查看 ActivityComponent 的代码,你会看到我们需要提供一些输入来设置一些 CSS 类以设置外观。我们在这里创建的三个方法将用于此目的:
// If the component gets destroyed, we need to unsubscribe from
// the activities change observer
ngOnDestroy() {
this.activitiesChangeSubscription.unsubscribe();
}
}
最后,我们添加了一个 OnDestroy 生命周期钩子,该钩子将取消订阅活动的更改可观察对象。
此组件的模板相当简单。我们唯一需要做的是渲染 ActivitySlider 组件,以及遍历所选活动并连接每个迭代的 Activity 组件:
<ngc-activity-slider [activities]="activities"
(selectionChange)="onSelectionChange($event)">
</ngc-activity-slider>
<div class="activities__l-container">
<ngc-activity
*ngFor="let activity of selectedActivities, let index = index"
[activity]="activity"
[alignment]="getAlignment(index)"
[startMark]="isLast(index)"
[endMark]="isFirst(index)">
</ngc-activity>
</div>
这里没有太多需要解释的。我们已经将 activities 和我们的 onSelectionChange 方法绑定到滑块组件上,并遍历所有选定的活动来渲染我们的 Activity 组件。我们创建了一个局部视图变量 index,我们将使用它作为 Activity 组件的外观输入。
这就是我们的活动页面!我们创建了三个组件,它们组合在一起并显示活动流,该流提供了一个滑块来过滤按日期的活动:

完成活动的截图
摘要
在本章中,我们使用 SVG 创建了一个交互式滑块组件。在这个过程中,我们了解了一些 SVG 基础知识和 SVG 在 DOM 中的强大功能。使用 Angular,我们能够使 SVG 可组合,这是它固有的特性。我们了解了命名空间,Angular 如何处理它们,以及我们如何告诉 Angular 我们希望显式使用命名空间。
除了在我们的滑块组件中使用 SVG,我们还学习了如何使用 Shadow DOM 来创建原生视图封装。因此,我们能够为我们的组件使用本地样式。当我们使用本地样式时,不再需要担心 CSS 命名冲突、特异性和全局 CSS 的副作用。
本章的全部代码可以在您从 Packt Publishing 网站下载的书籍资源 ZIP 文件中找到。
在下一章中,我们将增强到目前为止我们在各个章节中构建的内容。我们将创建一些组件来丰富我们应用程序内的用户体验。
第七章。用户体验组件
用户体验是当今应用程序开发者的核心关注点。我们不再生活在一个用户对仅仅能工作的应用程序感到满意的世界。期望值要高得多。现在,一个应用程序需要高度可用,并且应该提供高效的工作流程;用户还期望它在执行任务时能带来愉悦。
在本章中,我们将探讨构建一些组件,这些组件将提高我们任务管理系统的整体可用性。这些功能将丰富当前的功能,并提供更高效的工作流程。
我们将开发以下三个技术特性,并将它们嵌入到我们当前的应用程序中,适用于任何地方:
-
标签管理:我们将启用在生成内容中使用标签,例如评论、活动和其他任何可以使用标签的地方。标签将帮助用户在内容和导航快捷方式之间建立链接。
-
拖放:我们将构建通用的组件,使拖放功能变得简单易用。通过启用拖放功能,我们将允许用户以更高的效率完成某些任务。
-
无限滚动:我们将构建一个组件,在滚动时揭示列表的内容。这个功能不会直接提高工作流程性能,但它将帮助我们提高整体应用程序性能。它还将通过仅显示相关信息来缩小用户的上下文。
本章我们将涵盖以下主题:
-
创建一个标签管理系统以输入和显示标签
-
创建一个有状态的管道,使用服务渲染标签
-
使用
sanitize-html模块对可能不安全的内容进行清理 -
创建一个组件,在用户输入时自动完成标签
-
探索 HTML5 拖放 API 的基础知识
-
为可拖动元素和放置目标创建指令
-
使用
dataTransfer对象和自定义属性来启用选择性的放置目标 -
使用星号模板语法创建一个自定义的
ForOf重复器,以实现无限滚动 -
使用
DoCheck生命周期钩子实现自定义更改检测,并使用IterableDiffer应用 DOM 变更 -
使用
ViewContainer执行动态视图实例化
标签管理
经典的标签形式允许你将分类法与系统内的元素相关联,并帮助你组织项目。它允许你有一个可以快速管理的多对多关联,你可以在以后用它来过滤相关信息。
在我们的任务管理系统中,我们将使用一个略有不同的标签版本。我们的目标是提供一种在应用程序内实现语义快捷方式的方法。借助标签,用户应该能够在不同部分的数据之间交叉引用信息,提供所引用实体的摘要以及实体的导航快捷方式。
例如,我们可以在用户评论中包含一个project标签。用户可以通过简单地输入项目 ID 来输入标签。当显示评论时,我们看到项目的标题和项目中的打开任务数量。但是,当我们点击标签时,我们会直接到达任务所在的项目详情页面。
在本节中,我们将开发所需元素,以提供使用project标签的方法,这将使用户能够在评论中交叉引用其他项目。我们还将使用我们在上一章中创建的活动中的标签管理。
标签数据实体
让我们从表示我们如何在系统中表示标签的标签实体开始。我们将在tags/tag.js文件下创建一个新的Tag类:
// Class that represents a tag
export class Tag {
constructor(textTag, title, link, type) {
// The textTag property is the text representation of the tag
this.textTag = textTag;
this.title = title;
this.link = link;
this.type = type;
}
}
这个类代表标签;每次我们存储标签信息时,我们都会使用这个实体作为数据载体。让我们看看单个字段并详细说明它们的使用:
-
textTag:这是标签的文本表示。我们所有的标签都需要使用这种文本表示来唯一标识。我们可以如下定义标签的文本表示:-
文本标签始终以井号符号(
#)开头 -
文本标签只包含单词字符或减号符号(
-) -
所有由其他属性(
title、link和type)定义的标签内部结构都可以从textTag属性中推断出来。因此,它可以被视为一个 ID。
-
-
title:这是标签的一个相对较长的文本表示。它应尽可能包含有关主题的详细信息。在项目标签的情况下,这可能意味着项目标题、打开的标签数量、负责人和其他重要信息。由于这是在解析标签时将渲染的字段,因此保持内容相对紧凑将是有益的。 -
link:一个有效的 URL,当渲染标签时将使用它。这个 URL 将使链接可点击,并启用快捷导航。在我们将要创建的项目标签的情况下,这将是一个 URL 片段标识符,它将链接到指定的项目页面。 -
type:用于区分不同的标签,并为我们提供一种在更高粒度级别组织标签的方法。
到目前为止,一切顺利。我们现在有一个可以轻松构建的数据载体,用于传输有关标签的信息。
生成标签
我们下一步是创建一个工厂,为我们生成标签。我们希望传递给工厂的只是一个主题,这可以是基本上任何东西。然后,工厂将确定主题的类型并执行必要的逻辑来从它生成标签。这听起来可能一开始有点抽象,但让我们看看我们将在tags/generate-tag.js模块中创建的generateTag函数的代码:
import {Tag} from './tag';
import {limitWithEllipsis} from '../utilities/string-utilities';
export const TAG_TYPE_PROJECT = 'project';
// The generateTag function is responsible for generating new tag
// objects depending on the passed subject
export function generateTag(subject) {
if (subject.type === TAG_TYPE_PROJECT) {
// If we're dealing with a project here, we generate the
// according tag object
const openTaskCount = subject.tasks.filter((task) => !task.done).length;
return new Tag(
`#${subject._id}`,
`${limitWithEllipsis(subject.title, 20)} (${openTaskCount} open tasks)`,
`#/projects/${subject._id}/tasks`,
TAG_TYPE_PROJECT
);
}
}
让我们检查generateTag函数以及我们在这里试图实现的目标。
首先,我们通过检查subject对象的类型属性来确定主题类型。在项目数据对象的情况下,我们知道类型将被设置为"project"。以下三个要点简要说明了我们所做的工作:
-
由于我们确信我们正在处理一个项目,所以我们生成了一个新的标签。在未来,我们还将处理其他主题类型,因此这个检查将是必需的。
-
我们希望在标签标题中使用一个指标来表示所有未完成的任务。因此,我们在项目中快速筛选了未完成的任务,并将筛选数组的长度存储在
openTaskCount常量中。 -
现在,我们可以使用项目 ID 作为
textTag实例化一个新的Tag对象。对于title字段,我们使用了一个辅助函数limitWithElipsis,该函数截断超过 20 个字符的项目标题。我们还把未完成的任务计数追加到标签标题中。对于Tag实例的link字段,我们指定一个将导航到项目详情视图的 URL。最后,我们使用TAG_TYPE_PROJECT常量来定义标签的type字段。
创建标签服务
好的,我们已经设置好了所有需要的辅助结构;现在我们可以继续创建标签服务。标签服务将承担以下职责:
-
生成和缓存标签:如果我们只想渲染标签,我们不会在我们的系统中随意创建标签。标签服务的机制更像是一个生成缓存。最初,标签服务收集生成系统内所有可能标签所需的所有信息。它还会对变化做出反应,并在需要时更新标签列表。这样,我们不仅可以节省一些处理需求,而且我们还将有一个现成的列表来搜索现有标签。如果我们想向用户展示可用的标签以便他们从中选择,这将特别有用。
-
渲染标签:标签服务还负责将标签转换为 HTML。它使用
Tag实例的title和link字段来生成它们的 HTML 表示。 -
解析文本内容:标签服务的解析功能负责在字符串中找到标签的文本表示。然后它使用渲染函数将这些标签渲染成 HTML。
让我们在tags/tags-service.js下的新文件中为我们的标签服务创建一个模块。
首先,我们需要创建两个实用函数,这些函数将帮助我们处理标签以及包含标签文本表示的字符串。
replaceAll函数是一个简单的替代方案,用于在没有使用正则表达式的情况下替换字符串中的多个文本出现:
// Utility function to replace all text occurrences in a string
function replaceAll(target, search, replacement) {
return target.split(search).join(replacement);
}
findTags 函数将从文本字符串中提取任何可能的标签。它是通过应用一个正则表达式来完成的,该正则表达式将找到与话题开头讨论的格式匹配的标签。这个格式假设我们的标签总是以一个井号符号开始,后面跟着任何单词字符或破折号符号。这个函数返回所有可能的文本标签列表:
// Function to find any tags within a string and return an array
// of discovered tags
function findTags(str) {
const result = [];
const regex = /#[\w\/-]+/g;
let match;
while (match = regex.exec(str)) {
result.push(match[0]);
}
return result;
}
对于我们的标签服务,我们现在将定义一个新的类,该类将使用 @Injectable 进行注解,这样我们就可以将其用作组件中的提供者:
@Injectable()
export class TagsService {
...
}
让我们看看我们的 TagsService 类的构造函数:
constructor(@Inject(ProjectService) projectService) {
// If the available tags within the system changes, we will
// emit this event
this.change = new ReplaySubject(1);
// In order to generate project tags, we're making use of the
// ProjectService
this.projectService = projectService;
this.projectService.change.subscribe((projects) => {
// On project changes we store the new project list and re-
// initialize our tags
this.projects = projects;
this.initializeTags();
});
}
为了生成和缓存项目标签,我们显然需要 ProjectService,它为我们提供所有项目的列表。我们不是一次性从 ProjectService 中获取列表数据,而是在观察列表的变化。这带来了优势,我们不仅会得到初始的项目列表,还会意识到项目列表中的任何更改。
我们使用 change 字段订阅了 ProjectService。这暴露了 ReplaySubject,它发出项目列表。在将当前项目列表存储为成员字段后,我们需要调用 initializeTags 方法:
// This method is used internally to initialize all available tags
initializeTags() {
// We're creating tags from all projects using the generateTag
// function
this.tags = this.projects.map(generateTag);
// Since we've updated the list of available tags we need to
// emit a change event
this.change.next(this.tags);
}
由于我们目前只支持项目标签,因此在生成标签时,我们唯一需要考虑的是我们存储在服务中的项目。我们可以简单地使用我们的 generateTag 函数将存储在 projects 成员字段中的项目列表进行映射。Array.prototype.map 函数将返回一个新的数组,该数组已经是为项目生成的任务列表。
标签渲染
好的,我们现在有一个使用反应式方法从可用项目中生成标签的服务。这已经解决了我们服务的第一个问题。让我们看看它的其他职责,即解析文本内容以查找标签和渲染 HTML。
由于我们已经以一种干净的方式抽象了标签的数据模型,标签渲染并不是什么大问题。我们需要编写一个方法来渲染标签,如果参数不是一个有效的 Tag 实例,它将作为一个透传函数。这样,我们可以将未识别的标签文本表示作为字符串传递,它将只返回字符串。
由于标签有指向位置的 URL,我们将使用锚点 HTML 元素来表示我们的标签。这些元素也有类,可以帮助我们以不同于常规内容的方式对标签进行样式化。让我们在标签服务中创建另一个方法,该方法可以用来将标签对象渲染成 HTML:
renderTag(tag) {
if (tag instanceof Tag) {
return `<a class="tags__tag tags__tag--${tag.type}" href="${tag.link}">${tag.title}</a>`;
} else {
return tag;
}
}
以下方法可以用来通过其文本表示形式查找标签。这个函数将尝试在我们的生成缓存中找到标签,如果失败,将返回 textTag 参数。这也是一种透传机制,它简化了当我们解析整个文本以查找标签时的处理:
// This method will lookup a tag via its text representation or
// return the input argument if not found
parseTag(textTag) {
return this.tags.find(
(tag) => tag.textTag === textTag
) || textTag;
}
最后但同样重要的是,让我们实现服务的主方法。parse 函数会扫描整个文本以查找标签,并将它们替换为它们的 HTML 表示形式:
// This method takes some text input and replaces any found and
// valid text representations of tags with the generated HTML
// representation of those tags
parse(value) {
// First we find all possible tags within the text
const tags = findTags(value);
// For each found text tag, we're parsing and rendering them
// while replacing the text tag with the HTML representation
// if applicable
tags.forEach(
(tag) => value = replaceAll(value, tag,
this.renderTag(this.parseTag(tag))));
);
// After all tags have been rendered, we're using a sanitizer
// to ensure some basic security
return value;
}
首先,我们需要使用findTags实用函数;这将返回一个列表,其中包含它会在传递给parse函数的字符串内容中找到的所有文本标签。使用这个文本标签列表,我们可以然后遍历列表,并使用renderTag方法连续替换内容中的所有文本标签,以生成相应的 HTML。
集成任务服务
我们的任务服务的所有问题现在都已得到解决,并且它已经为可用的项目存储了标签。我们现在可以继续将我们的服务集成到应用程序中。
由于我们的标签服务将文本中的简单哈希标签转换为带有链接的 HTML,因此管道将是一个完美的助手,以在我们的组件中集成该功能。
让我们在pipes文件夹中创建一个tags.js文件,并创建一个新的管道类,即Tags:
import {Pipe, Inject} from '@angular/core';
import {TagsService} from '../tags/tags-service';
@Pipe({
name: 'tags',
// Since our pipe is depending on services, we're dealing with a
// stateful pipe and therefore set the pure flag to false
pure: false
})
export class TagsPipe {
constructor(@Inject(TagsService) tagsService) {
this.tagsService = tagsService;
}
// The transform method will be called when the pipe is used within a template
transform(value) {
if (typeof value !== 'string') {
return value;
}
// The pipe is using the TagsService to parse the entire text
return this.tagsService.parse(value);
}
}
我们已经创建了一些管道。然而,这个管道有点不同,因为它不是一个纯管道。如果管道的转换函数总是为给定的输入返回相同的输出,则认为管道是纯的。这意味着转换函数不应依赖于任何其他可能影响转换结果的外部来源,唯一的依赖是输入值。然而,我们的Tags管道并非如此。它依赖于TagsService来转换输入,并且可以在任何时间将新标签存储在标签服务中。连续的转换可以成功渲染刚刚不存在的标签。
通过告诉 Angular 我们的管道不是纯的,我们可以禁用它在纯管道上执行的优化。这也意味着 Angular 需要在每次变更检测时重新验证管道的输出。这可能会导致性能问题;因此,应谨慎使用纯标志。
好了,就渲染标签而言,我们已经准备好了。让我们将我们的标签功能集成到Editor组件中,这样我们就可以在评论系统中使用它们。
让我们从编辑位于ui/editor/editor.js下的Editor模块开始:
...
import {TagsPipe} from '../../pipes/tags';
@Component({
selector: 'ngc-editor',
...
pipes: [TagsPipe]
})
export class Editor {
...
@Input() enableTags;
...
}
首先,我们导入了TagsPipe类,并将其引用到@Component注解的pipes配置中。
我们还向enableTags组件添加了一个新的输入,这将允许我们控制是否应该处理编辑器内容中的标签或忽略它们。
就组件文件的变化而言,到此为止。让我们通过编辑ui/editor/editor.html文件来对组件的模板应用一些更改:
...
<div *ngIf="enableTags" class="editor__output"
[innerHtml]="(content || '-') | tags"></div>
<div *ngIf="!enableTags" class="editor__output">
{{content || '-'}}
</div>
...
我们在模板中做的唯一改变是显示编辑器内容的位置。我们通过使用NgIf星号模板语法使用了两个模板元素。后者,如果标签被禁用,将渲染之前的内容。如果标签被启用,我们将使用属性绑定到编辑器输出 HTML 元素的innerHTML属性。这允许我们渲染 HTML 内容。在绑定中,我们使用了我们的Tags管道,该管道将使用TagService解析内容以查找标签。
标签服务的完成
在这一点上,让我们暂时偏离一下。我们已经创建了一个标签系统,并且我们刚刚通过使用Tags管道将其集成到我们的Editor组件中。如果用户现在在任意评论中编写项目标签,它们将通过TagsService进行渲染。这太棒了!用户现在可以在评论中建立与其他项目的交叉链接,这些链接将自动渲染为显示项目标题和开放任务的链接。用户需要做的只是将项目标签的文本表示添加到评论中。在书籍的默认数据集中,这可能是#project-1字符串。
以下两张图片展示了评论系统的示例。第一张图片是编辑模式下编辑器的示例,在评论系统中输入了一个文本标签:

输入文本标签的示例
第二张图片是评论系统中通过我们的编辑器集成启用的渲染标签的示例:

通过编辑器集成渲染的标签示例
在标签输入方面,我们还没有完成。我们不能期望我们的用户知道系统中的所有可用标签,然后在评论中手动输入它们。让我们看看在下一节中我们如何改进这一点:
在本节中,我们探讨了以下概念:
-
我们构建了一个生成、缓存和渲染标签的标签服务。
-
我们使用
pure标志构建了一个有状态的管道。 -
我们使用了
[innerHTML]属性绑定将 HTML 内容渲染到元素中。
支持标签输入
在这里,我们将构建一个组件及其支持结构,以便为用户提供一个顺畅的标签输入体验。到目前为止,他们可以编写项目标签,但需要他们知道项目 ID,这使得我们的标签管理变得毫无用处。我们希望做的是,当用户准备编写标签时,为他们提供一些选择。理想情况下,当他们开始通过输入哈希符号(#)编写标签时,我们立即显示可用的标签。
起初听起来很简单的事情实际上实现起来相当棘手。我们的标签输入需要处理以下挑战:
-
处理输入事件以监控标签创建。我们需要知道用户何时开始编写标签,以及当输入的标签名称被无效的标签字符更新或取消时,我们需要知道何时更新。
-
计算用户输入光标的位置。是的,我知道这听起来很简单,但实际上并不简单。计算用户输入光标的视口偏移位置需要使用浏览器的 Selection API,这是一个相当低级的 API,需要一些抽象。
为了应对这些挑战,我们将引入一个实用类,我们可以将用户输入委托给它。它将帮助我们了解我们感兴趣的细节,并处理低级 API。
创建标签输入管理器
在tags/tag-input-manager.js的新文件中创建一个模块。代码的第一部分是一个函数,它将帮助我们确定当用户开始输入标签时用户输入光标的位置:
// This function can be used to find the screen coordinates of the
// input cursor position
function getRangeBoundlingClientRect() {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (!range.collapsed) {
return range.getBoundingClientRect();
}
const dummy = document.createElement('span');
range.insertNode(dummy);
const pos = dummy.getBoundingClientRect();
dummy.parentNode.removeChild(dummy);
return pos;
}
这里就不详细说明了。这段代码的基本功能是尝试找到描述光标位置相对于视口的top、right、bottom和left偏移量的DOMRect对象。问题是 Selection API 不允许我们直接获取光标的位置;它只允许我们获取当前选择的位置。如果光标放置不正确,我们需要在光标位置插入一个占位元素,并返回占位元素的DOMRect对象。当然,在返回DOMRect对象之前,我们需要再次移除占位元素。
现在,让我们在lib/tags/tag-input-manager.js下创建一个新的类TagInputManager,它将处理标签创建的用户输入处理:
export class TagInputManager {
constructor() {
this.reset();
}
...
在构造函数中,我们需要调用一个内部reset方法。这个reset方法将重置TagInputManager将公开的两个成员字段。position成员将存储最新光标的位置,即用户开始编写标签的地方。textTag成员将存储当前标签,该标签由TagInputManager识别:
reset() {
this.textTag = '';
this.position = null;
}
现在让我们创建一个方法来确定用户是否正在输入标签的过程中。如果textTag成员以哈希符号开头,我们可以假设有一个标签正在输入中:
hasTextTag() {
return this.textTag[0] === '#';
}
我们还需要一个方法,允许我们更新当前输入的文本标签以及更新的光标位置:
updateTextTag(textTag, position = this.position) {
this.textTag = textTag;
this.position = position;
}
在onKeyDown方法中,我们期望接收委托的keydown事件。我们关注的是退格键,它应该也会移除当前输入的标签的最后一个字符。
onKeyDown(event) {
// If we receive a backspace (key code is 8), we need to
// remove the last character from the text tag
if (event.which === 8 && this.hasTextTag()) {
this.updateTextTag(this.textTag.slice(0, -1));
}
}
在onKeyPress方法中,我们期望接收委托的按键事件。这是这个辅助类的主要逻辑所在。在这里,我们处理两种不同的情况:
-
如果按下的键是哈希符号,我们将重新开始一个新的标签。
-
如果按下的键不是有效的单词字符或哈希符号,我们将将其重置为其初始状态,这将取消标签输入。否则,这意味着我们正在处理一个有效的标签字符,我们将将其添加到当前文本标签字符串中。
以下是相应的代码:
onKeyPress(event) {
const char = String.fromCharCode(event.which);
if (char === '#') {
// If the current character from user input is a hash symbol
// we can initiate a new text tag and set the current
// position
this.updateTextTag('#', getRangeBoundlingClientRect());
} else if ((/[\w-]/i).test(this.textTag[0])) {
// If the current character is not a valid tag character we
// reset our state and assume the tag entry was canceled
this.reset();
} else if (this.hasTextTag()) {
// If we have any other valid tag character input, we're
// updating our text tag
this.updateTextTag(this.textTag + char);
}
}
好的,现在我们有了处理标签输入所需的所有支持。然而,我们仍然需要一个方法来向用户显示TagsService中的可用标签。为此,我们将创建一个新的TagsSelect组件。
创建标签选择组件
为了帮助用户找到正确的标签,我们将提供一个包含可用标签的下拉菜单。为此,我们需要使用我们的TagInputManager类来识别用户输入中的标签以及使用用户输入过滤可用标签。让我们简要看看该组件的要求:
-
在工具提示/调用框中显示从
TagsService收集的可用的标签 -
它应该支持显示标签的限制
-
它应该支持一个输入来过滤可用的标签
-
组件应该接受一个输入参数来定位调用框
-
用户在列表标签中点击标签时,组件应该发出一个事件
-
如果过滤器无效或没有元素匹配过滤器,组件应该隐藏自己:
![创建标签选择组件]()
完成带有用户输入过滤的标签选择组件
让我们从组件类开始,看看我们如何满足这些要求。首先,在tags/tags-select下创建一个名为tags-select.js的新文件:
...
@Component({
selector: 'ngc-tags-select',
...
})
export class TagsSelect {
...
}
在我们的@Component注解中,我们没有特殊之处要处理。让我们从实现组件的内部开始。首先,我们将在组件中定义以下输入:
@Input() filter;
使用filter输入,我们可以将过滤标签传递给TagsSelect组件。这意味着我们将使用filter输入通过title和text标签过滤可用的标签。
limit输入可以设置为任何数字。此输入用于限制在组件内显示的过滤标签数量:
@Input() limit;
position输入应设置为包含top和left属性的合法DOMRect对象。它们将用于定位我们的组件:
@Input() position;
tagSelected输出属性用于在用户在标签列表中点击标签时发出事件:
@Output() tagSelected = new EventEmitter();
以下访问器属性绑定到宿主元素的显示样式属性。它将控制组件是显示还是隐藏。我们仅在过滤器有效且过滤后的标签至少包含一个标签时显示组件:
@HostBinding('style.display')
get isVisible() {
if (this.filter[0] === '#' && this.filteredTags.length > 0) {
return 'block';
} else {
return 'none';
}
}
以下两个访问器属性使用宿主绑定来设置宿主元素的top和left样式,这些样式基于组件的position输入:
@HostBinding('style.top')
get topPosition() {
return this.position ? `${this.position.top}px` : 0;
}
@HostBinding('style.left')
get leftPosition() {
return this.position ? `${this.position.left}px` : 0;
}
让我们将TagsService注入到我们的组件中,这样我们就可以访问可用的标签列表:
constructor(@Inject(TagsService) tagsService) {
this.tagsService = tagsService;
// This member is storing the filtered tag list
this.filteredTags = [];
this.filter = '';
}
我们需要使用OnInit生命周期钩子来设置对TagService更改观察器的订阅。这样,我们将能够访问初始标签列表以及列表中的任何更改。在收到新的标签列表后,我们需要重新应用过滤:
ngOnInit() {
// The TagsService is providing us with all available tags
// within the application
this.tagsSubscription = this.tagsService.change.subscribe(
(tags) => {
// If the available tags change we store the new list and
// execute filtering again
this.tags = tags;
this.filterTags();
}
);
}
以下是在点击标签时从模板中调用的方法。我们将仅使用tagSelected输出重新发射该标签:
onTagClick(tag) {
this.tagSelected.next(tag);
}
filterTags方法负责根据过滤和限制输入属性以及从TagsService提供的可用标签来过滤和限制我们的标签列表。因此,它将在filteredTags成员字段中存储过滤和限制后的列表:
filterTags() {
this.filteredTags = this.tags
.filter((tag) => {
return tag.textTag.indexOf(this.filter.slice(1)) !== -1 ||
tag.title.indexOf(this.filter.slice(1)) !== -1;
})
.slice(0, this.limit);
}
如果输入属性过滤或限制发生变化,我们需要重新应用我们的过滤方法。通过实现ngOnChanges生命周期钩子,我们可以轻松地管理这一需求:
ngOnChanges(changes) {
// If the filter or the limit input changes, we're filtering the
// available tags again
if (this.tags && (changes.filter || changes.limit)) {
this.filterTags();
}
}
最后,如果TagsSelect组件被销毁,我们应该从TagsService的变化可观察对象中取消订阅:
ngOnDestroy() {
this.tagsSubscription.unsubscribe();
}
我们的组件模板相当简单。让我们看看存储在tags/tags-select/tags-select.html下的视图模板:
<ul class="tags-select__list">
<li *ngFor="let tag of filteredTags"
(click)="onTagClick(tag)"
class="tags-select__item">{{tag.title}}</li>
</ul>
我们使用了NgFor指令来遍历filteredTags成员中的所有标签。如果点击了一个标签,我们需要执行onTagClicked方法并传递当前迭代的标签。在列表中,我们只显示有助于用户识别他们想要使用的标签的标签标题:
现在我们已经构建了我们需要的所有部分,以实现用户平滑地输入标签。让我们再次修补我们的Editor组件以包含我们的更改:
在编辑器组件中集成标签输入
作为第一步,我们应该修改我们的Editor组件以利用TagInputManager类。我们需要将内容可编辑元素内的用户输入委派给标签输入管理器,以便它可以检测任何标签输入。然后,我们将使用TagInputManager的信息来控制TagsSelector组件:
首先,让我们看看需要在位于ui/editor/editor.js下的Component类内部进行的必要更改:
...
import {TagsSelect} from '../../tags/tags-select/tags-select';
import {TagInputManager} from '../../tags/tag-input-manager';
@Component({
selector: 'ngc-editor',
...
directives: [TagsSelect]
})
export class Editor {
...
// We're using a TagInputManager to help us dealing with tag
// creation
this.tagInputManager = new TagInputManager();
}
...
// This method is called when the editable element receives a
// keydown event
onKeyDown(event) {
// We're delegating the keydown event to the TagInputManager
this.tagInputManager.onKeyDown(event);
}
// This method is called when the editable element receives a
// keypress event
onKeyPress(event) {
// We're delegating the keypress event to the TagInputManager
this.tagInputManager.onKeyPress(event);
}
// This method is called if the child TagSelect component is
// emitting an event for a selected tag
onTagSelected(tag) {
// We replace the partial text tag within the editor with the
// text representation of the tag that was selected in the
// TagSelect component.
this.setEditableContent(
this.getEditableContent().replace(
this.tagInputManager.textTag, tag.textTag
)
);
this.tagInputManager.reset();
}
...
}
在我们的@Component注解中,我们将TagsSelect组件添加到指令属性中,这样我们就可以在模板中使用该组件:
为了帮助我们完成标签输入的所有底层处理,我们使用了TagInputManager并在组件构造函数中创建了一个新的实例:
我们现在创建了两个方法来处理来自我们可编辑内容元素的keypress和keydown事件。这些方法将事件委派给TagInputManager,它将处理所有提取文本标签和光标位置的加工:
最后,我们添加了一个方法,当在TagsSelect组件中点击标签时将被调用。在这里,我们简单地替换了当前输入的文本标签,用被点击的标签的文本表示形式替换。这提供了一种简单的自动完成实现。在我们将点击的标签的文本表示形式添加到内容可编辑元素后,我们将重置TagInputManager以清除其状态:
现在唯一剩下的是编辑Editor组件的模板,以便包含TagsSelect组件:
在ui/editor/editor.html文件中,我们需要进行以下更改:
...
<ngc-tags-select *ngIf="enableTags"
[filter]="tagInputManager.textTag"
[position]="tagInputManager.position"
[limit]="5"
(tagSelected)="onTagSelected($event)">
</ngc-tags-select>
NgIf指令帮助我们避免在编辑器中未启用标签时创建组件:
我们从我们的TagInputManager实例中的数据设置了TagsSelect组件的filter和position输入。
在TagsSelect组件的tagSelected事件中,我们调用了我们刚才创建的Editor组件上的onTagSelected方法。
这就是我们对Editor组件模板需要做的所有事情。
完成我们的标签系统
恭喜!您已经成功实现了三个可用性组件中的第一个。
在TagInputManager类的帮助下,我们将用户输入的低级处理和用户光标位置的处理工作外包出去。然后,我们创建了一个组件来向用户显示可用的标签,并提供了他们通过点击来选择标签的方法。在我们的Editor组件中,我们使用了TagInputManager类和TagsSelect组件,以实现编辑评论和其他我们启用了标签功能的地方的标签的顺畅输入。
在本节中,我们涵盖了以下概念:
-
我们在指定的管理类中处理了复杂的用户输入,以从我们的组件中卸载逻辑。
-
我们使用了宿主绑定来设置位置样式属性。
-
我们实现了完全响应式的组件,这些组件依赖于可观察的,并在变化检测期间不创建副作用。
拖放
我们已经学会了高效地使用我们的计算机鼠标和键盘。使用快捷键、不同的点击动作和上下文鼠标菜单在执行任务时为我们提供了很好的支持。然而,鉴于当前移动和触摸设备的炒作,最近有一个模式再次引起了更多关注。拖放动作是表达移动或复制项目等动作的一种非常直观和逻辑的方式。在用户界面中执行的一项任务特别受益于拖放,例如在列表中排列项目。如果我们需要通过动作菜单来排列项目,这会变得非常混乱。使用上下按钮逐步移动项目效果很好,但需要花费很多时间。如果您可以将项目拖动到您希望它们重新排列的位置,您就可以非常快速地对项目列表进行排序。
在这个主题中,我们将构建所需的元素以实现选择性的拖放。我们将使用拖放功能来使用户能够重新排列他们的任务列表。通过开发可重用的指令来提供此功能,我们可以在稍后应用程序的任何其他位置启用此功能。
为了实现我们的指令,我们将利用 HTML5 拖放 API,该 API 在撰写本书时得到了所有主流浏览器的支持。
由于我们希望在多个组件上重用我们的拖放行为,我们将使用指令来实现。在本节中,我们将创建两个指令:
-
可拖动指令:这个指令应该附加到组件上,这些组件应该被启用以进行拖动
-
可拖动目标区域指令:这个指令应该附加到将作为目标区域的组件
我们还将实现一个功能,允许我们选择哪些元素可以拖动到哪些位置。为此,我们将在我们的可拖动指令中使用类型属性,同时在我们的目标区域中使用接受类型属性。
实现可拖动指令
draggable 指令将被附加到可以拖动到其他元素上的元素。让我们在 draggable/draggable.js 下创建一个新的指令类:
...
@Directive({
selector: '[draggable]',
host: {
class: 'draggable',
// Additionally to the class we also need to set the HTML
// attribute draggable to enable draggable browser behavior
draggable: 'true'
}
})
export class Draggable {
...
}
我们现在使用 @Directive 注解而不是 @Component 注解,让 Angular 知道以下类是一个指令类。通过将 HTML 属性 draggable 设置为 true,我们告诉浏览器我们正在考虑这个元素为可拖动元素。
小贴士
与组件相比,使用指令的一个重大区别是它们不包含视图,只包含行为。因此,也可以在同一个元素上使用多个指令,这是组件所不可能的。
让我们看看我们新创建的组件类的输入:
@Input() draggableData;
draggableData 输入用于指定表示可拖动元素的的数据。一旦拖动操作完成,这些数据将被序列化为 JSON 并传输到我们的目标区域。
通过指定可拖动类型,当元素拖动到目标区域上时,我们可以更加选择性地操作。在目标区域内,我们可以有一个对应的部分来控制哪些类型是被接受的。
@Input() draggableType;
除了我们的输入之外,我们还想使用主机绑定来设置一个特殊类,如果元素当前正在被拖动:
@HostBinding('class.draggable--dragging') dragging;
这个绑定将设置一个 draggable--dragging 类,这将应用一些特殊样式,使得识别出被拖动的元素变得容易。
现在我们需要在我们的指令中处理两个事件来实现可拖动元素的行为。以下 DOM 事件由拖放 DOM API 触发:
-
dragstart:这个事件在元素被抓住并在屏幕上移动时发出 -
dragend:如果之前启动的元素拖动因为成功放置或释放到有效的目标区域外而结束,这个 DOM 事件将被触发。
让我们看看 dragstart 事件的 HostListener 的实现:
// We're listening for the dragstart event and initialize the
// dataTransfer object
@HostListener('dragstart', ['$event'])
onDragStart(event) {
event.dataTransfer.effectAllowed = 'move';
// Serialize our data to JSON and set it on our dataTransfer
// object
event.dataTransfer.setData(
'application/json',
JSON.stringify(this.draggableData));
// By adding the draggableType as a data type key within our
// The dataTransfer object, we enable drop zones to observe the type
// before receiving the actual drop.
event.dataTransfer.setData(
`draggable-type:${this.draggableType}`, '');
this.dragging = true;
}
现在我们来讨论在实现我们的主机监听器时将执行的不同操作:
-
我们需要在我们的宿主监听器中访问 DOM 事件对象。如果我们要在模板中创建这个绑定,我们可能需要写一些类似这样的代码:
(dragstart)="onDragStart($event)"。在事件绑定中,我们可以使用合成变量$event,它是触发事件绑定的事件的引用。如果我们使用@HostListener注解在我们的宿主元素上创建事件绑定,我们需要使用装饰器的第二个参数来构造绑定的参数列表。 -
在我们的事件监听器的第一个动作是设置
dataTransfer对象上的期望effectAllowed属性。目前,我们只支持move效果,因为我们的主要关注点是使用拖放重新排序任务列表中的任务。拖放 API 非常特定于系统,但通常,如果用户在开始拖动时按住修饰键(如Ctrl或Shift),会有不同的拖放效果。在我们的draggable指令中,我们可以强制所有拖动动作都使用move效果。 -
在下一个代码片段中,我们将设置通过拖动应该传输的数据。理解拖放 API 的核心目的是很重要的。它不仅提供了一种在您的 DOM 中仅对元素实现拖放的方法,而且还支持将文件和其他对象拖放到浏览器中。正因为如此,API 经历了一些限制,其中之一是使得除了简单的字符串值之外的数据传输变得不可能。为了使我们能够传输复杂对象,我们将使用
JSON.stringify序列化draggableData输入的数据。 -
由于 API 中的一些安全限制造成的另一个限制是,数据只能在成功放置后读取。这意味着如果用户只是悬停在元素上,我们无法检查数据。然而,当悬停在放置区域时,我们需要了解一些关于数据的事实。当进入放置区域时,我们需要知道可拖动元素的类型,这样我们就可以让放置区域发出是否接受该类型的信号。我们为此问题使用了一个小的解决方案。拖放 API 在我们将数据拖动到放置目标上时隐藏数据。然而,它告诉我们数据的类型。了解这个事实后,我们可以使用
setData函数来编码我们的可拖动类型。仅访问数据键被认为是安全的,因此可以在所有放置区域事件中完成。 -
最后,我们将拖动标志设置为
true,这将导致类绑定重新验证并向元素添加draggable--dragging类。
在处理完dragstart事件后,我们只需要处理dragend事件来完成我们的Draggable指令。在绑定到dragend事件的onDragEnd方法中,我们唯一做的事情是将拖动成员设置为false。这将导致从宿主元素中移除draggable--dragging类:
@HostListener('dragend')
onDragEnd() {
this.dragging = false;
}
这就是我们的Draggable指令的行为。现在我们需要创建它的对应指令来提供拖放区域的行为。
实现拖放目标指令
拖放区域将作为容器,其中可拖动元素可以被放置。为此,我们将在draggable/draggable-drop-zone.js下创建一个新的指令,称为DraggableDropZone:
@Directive({
selector: '[draggableDropZone]'
})
export class DraggableDropZone {
...
}
这个@Directive注解没有什么特别之处。我们使用了一个属性选择器,因此它可以通过在 HTML 元素上使用draggableDropZone属性来附加。使用以下输入,我们可以指定在这个拖放区域中接受哪些类型的可拖动元素。这将帮助用户确定他们是否能够在接近拖放区域时放下可拖动元素:
@Input() dropAcceptType;
在成功将元素拖放到拖放区域后,我们需要发出一个事件,以便使用我们拖放功能的组件能够相应地做出反应。为此,让我们创建一个名为dropDraggable的输出属性:
@Output() dropDraggable = new EventEmitter();
over成员字段将存储一个已接受元素正在拖动过拖放区域的状态:
@HostBinding('class.draggable--over') over;
以下方法将用于检查我们的拖放区域是否应该接受任何给定的拖放事件,通过检查我们的dropAcceptType成员。如果您还记得我们在创建Draggable指令时需要解决的安全问题,您将理解为什么这个判断相当简单:
typeIsAccepted(event) {
const draggableType =
Array.from(event.dataTransfer.types).find(
(key) => key.indexOf('draggable-type') === 0
);
return draggableType &&
draggableType.split(':')[1] === this.dropAcceptType;
}
我们只能读取某些事件中dataTransfer对象的数据类型,而数据本身在成功发生drop事件之前是隐藏的。为了绕过这个安全限制,我们将可拖动类型信息编码到数据键本身中。由于我们可以安全地列出所有数据类型,因此提取编码的可拖动类型信息并不太难。我们将搜索一个以"draggable-type"开头的数据类型键,然后通过列字符进行分割。列字符之后的值就是我们的类型信息,然后我们将它与dropAcceptType指令输入属性进行比较。
我们将使用两个事件来确定一个可拖动元素是否被移动到我们的拖放区域:
-
dragenter:如果另一个元素被拖动到它上面,则由元素触发 -
dragleave:如果之前进入的元素再次离开,则由元素触发
前面的事件有一个问题,就是它们实际上会冒泡,如果拖动的元素被移动到我们的拖放区域内的子元素中,我们将会收到一个dragleave事件。由于冒泡,我们还会从子元素那里收到dragenter和dragleave事件。在我们的情况下,这并不是我们想要的,我们需要构建一些功能来改进这种行为。我们将利用一个计数成员字段dragEnterCount,它将计数到所有的dragenter事件,并递减到dragleave事件。这样,我们现在可以说,只有在dragleave事件中,当计数器变为零时,我们实际上才会离开我们的拖放区域。让我们看看以下说明问题的图示:

可视化我们计算中重要的变量和函数
让我们在draggable/draggable-drop-zone.js文件中实现这个逻辑,以构建我们拖放区域的适当进入和离开行为:
constructor() {
// We need this counter to know if a draggable is still over our
// drop zone
this.dragEnterCount = 0;
}
// The dragenter event is captured when a draggable is dragged
// into our drop zone
@HostListener('dragenter', ['$event'])
onDragEnter(event) {
// Only handle event if the draggable is accepted by our drop
// zone
if (this.typeIsAccepted(event)) {
this.over = true;
// We use this counter to determine if we loose focus because
// of child element or because of final leave
this.dragEnterCount++;
}
}
// The dragleave event is captured when the draggable leaves our
// drop zone
@HostListener('dragleave', ['$event'])
onDragLeave(event) {
// Using dragEnterCount, we determine if the dragleave event is
// because of child elements or because the draggable was moved
// outside the drop zone
if (this.typeIsAccepted(event) && --this.dragEnterCount === 0) {
this.over = false;
}
}
在这两个事件中,我们首先检查事件是否携带一个我们接受的dataTransfer对象。在用我们的typeIsAccepter方法验证类型之后,我们处理计数器,并在必要时设置over成员字段。
我们还需要处理另一个对拖放功能很重要的事件,即dragover事件。在dragover事件中,我们可以设置当前拖动操作的接受dropEffect。这将告诉我们的浏览器,从我们的可拖动元素发起的拖动操作适合这个拖放区域。同样重要的是,我们需要防止默认的浏览器行为,以便我们的自定义拖放行为不受干扰。让我们添加另一个函数来处理这些关注点:
@HostListener('dragover', ['$event'])
onDragOver(event) {
// Only handle event if the draggable is accepted by our drop
// zone
if (this.typeIsAccepted(event)) {
// Prevent any default drag action of the browser and set the
// dropEffect of the dataTransfer object
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
}
最后,我们需要处理拖放区域中最重要的事件,即当用户将可拖动元素拖放到我们的拖放区域时触发的drop事件:
// This event will be captured if a draggable element is dropped
// onto our drop zone
@HostListener('drop', ['$event'])
onDrop(event) {
// Only handle event if the draggable is accepted by our drop
// zone
if (this.typeIsAccepted(event)) {
// First obtain the data object that comes with the drop event
const data = JSON.parse(
event.dataTransfer.getData('application/json')
);
// After successful drop, we can reset our state and emit an
// event with the data
this.over = false;
this.dragEnterCount = 0;
this.dropDraggable.next(data);
}
}
在检查掉落元素是否为接受的类型之后,我们现在可以继续从事件中读取dataTransfer对象数据。这些数据之前由Draggable指令设置,需要使用JSON.parse进行反序列化。
由于拖放成功,我们可以重置我们的dragEnterCount成员,并将over标志设置为false。
最后,我们将使用我们的dropDraggable输出属性发出可拖动元素的反序列化数据。
这就是我们需要的所有内容,以拥有一个高度可重用的拖放行为,我们现在可以将其附加到我们应用程序中任何需要的地方。
在任务列表组件中集成拖放
我们现在可以在我们的TaskList组件中使用Draggable和DraggableDropZone指令,这样我们就可以通过拖放来启用任务的重新排序。
我们将这样做是通过将指令附加到TaskList组件模板中的任务元素上,我们将在这里渲染它们。是的,没错!我们希望使我们的Task组件既可以拖动也可以作为拖放区。这样,我们就可以将任务拖放到其他任务中,这为我们提供了重新排序的基础。我们将执行的操作是在拖放中重新排序列表,以便被拖放的任务将被挤压到它被放置的任务的正前方位置。
首先,让我们将指令应用到TaskList组件模板中的<ngc-task>元素上,即task-list/task-list.html:
<div class="task-list__l-container">
...
<ngc-task *ngFor="let task of filteredTasks"
line:[task]="task
(taskUpdated)="onTaskUpdated(task, $event)"
(taskDeleted)="onTaskDeleted(task)"
draggable
draggableType="task"
[draggableData]="task"
draggableDropZone
dropAcceptType="task"
(dropDraggable)="onTaskDrop($event, task)">
</ngc-task>
...
</div>
好的,使用前面的属性,我们已经使我们的任务不仅可拖动,还可以作为拖放区。通过将draggableType和dropAcceptType指定为字符串"task",我们告诉我们的拖放行为这些任务元素可以被拖放到其他任务元素中。我们的DraggableDropZone指令被设置为在有效可拖动元素被放下时发出dropDraggable事件。为了处理被放置的任务,我们可以简单地使用此事件并在我们的TaskList组件中创建一个绑定到方法的事件。
让我们看看在Component类中,位于task-list/task-list.js下的哪些内容需要更改,以便使这个功能正常工作:
...
import {Draggable} from '../draggable/draggable';
import {DraggableDropZone} from '../draggable/draggable-drop-zone';
@Component({
selector: 'ngc-task-list',
...
directives: [..., Draggable, DraggableDropZone]
})
export class TaskList {
...
onTaskDrop(source, target) {
if (source.position === target.position) {
return;
}
let tasks = this.tasks.slice();
const sourceIndex = tasks.findIndex(
(task) => task.position === source.position
);
const targetIndex = tasks.findIndex(
(task) => task.position === target.position
);
tasks.splice(targetIndex,
0,
tasks.splice(sourceIndex, 1)[0]);
tasks = tasks.map((task, index) => {
return Object.assign({}, task, {
position: index
});
});
this.tasksUpdated.next(tasks);
}
...
}
让我们详细说明在模板中绑定到DropZone的dropDraggable事件上的onTaskDrop方法中的行为:
-
如果你再次检查模板,你会看到我们使用以下表达式绑定到
onTaskDrop方法:(dropDraggable)="onTaskDrop($event, task)"。由于拖放区发出的事件包含使用可拖动输入属性draggableData绑定的反序列化数据,我们可以安全地假设我们将收到一个被拖放到拖放区的任务的副本。作为绑定的第二个参数,我们添加了本地视图变量task,它实际上是作为拖放区的任务。因此,我们可以这样说,我们的onTaskDrop方法的第一参数代表源,而第二个参数代表目标任务。 -
在我们的方法中的第一次检查,我们比较源位置和目标位置,如果它们匹配,我们可以假设任务是自己被放置的,我们不需要执行任何进一步的操作。
-
现在,我们可以在任务数组中获取源任务和目标任务的索引,并执行嵌套的
splice操作,以便我们可以从数组中的旧位置移除源任务,并将其添加到目标位置的正前方。 -
现在剩下的工作就是重新计算任务的坐标字段,以便它们反映重新排序的数组。我们可以通过使用
Array.prototype.map轻松完成此操作。 -
作为最后一步,我们需要通知我们的父组件我们已经更新了任务列表。我们可以简单地使用
taskUpdated事件来完成此操作。我们在添加或删除任务时使用了相同的事件。
这有多棒?我们已经成功地在任务列表上实现了拖放,提供了一个非常实用的功能来重新排列任务。
拖放回顾
通过使用低级拖放 API,使用事件和dataTransfer对象,我们已经实现了两个指令,现在可以在我们需要的任何地方执行平滑的拖放功能。
几乎不需要任何努力,我们就已经在任务列表上实现了拖放行为,为用户提供了一个重新排列列表中任务的不错功能。我们除了连接指令外,还需要实现一个方法,根据DraggableDropZone事件的信息来重新排列任务。
在本节中,我们处理了以下概念:
-
我们学习了 HTML5 拖放 API 的基础知识。
-
我们使用
dataTransfer对象在拖放事件中安全地传输数据。 -
使用指令构建可重用的行为模式。
-
通过提供我们自己的自定义选择机制,使用自定义数据类型来编码可拖动类型信息,丰富了标准的拖放 API。
无限与更远!
显示一个平均大小的简单列表并不具有太多挑战性。一旦列表开始增长,挑战就开始出现。我们很容易用一个非常长的列表让用户感到不知所措。长列表也可能对我们的应用程序产生性能影响,尤其是在显示动态内容时。
解决显示长列表时面临的挑战的一种方法是通过提供分页。然而,分页并不总是翻译得很好。虽然在使用鼠标的桌面设备上进行分页看起来非常直观,但在具有触摸支持的移动设备上则变得繁琐。
在本章中,我们将探讨一种不同的方法,可以帮助我们减轻长列表的性能影响,同时在移动设备上提供流畅的体验。我们使用了一种有时被称为无限滚动的模式。目标是只显示足够多的项目以填满屏幕,并在用户向下滚动时按需加载更多项目。
为了实现这种行为,我们可以编写一个包装组件,该组件将提供一个无限滚动面板,并使用内容插入来包含我们的列表。然而,我们将使用不同的方法来实现我们的无限滚动行为,并构建一个自定义模板指令,例如NgFor。
星号语法和模板
到目前为止,我们已经大量使用了NgFor和NgIf指令,使用星号(*)符号来表示我们正在处理一个创建模板的指令。然而,我们还没有查看星号模板语法的结构。想象一下,它将为我们的模板创建某种语法糖。
查看以下使用星号模板语法的NgFor指令示例:
<div *ngFor="let i of [1, 2, 3]">{{i}}</div>
Angular 的模板解析器将以特殊方式处理以星号开头的所有属性。前面的例子是以下更简单、更简洁的编写风格:
<template ngFor #i [ngForOf]="[1, 2, 3]">
<div>{{i}}</div>
</template>
前面的两个例子绝对相同。模板指令,如 NgFor 或 NgIf,利用 HTML 模板元素,我们已在 第一章 中简要讨论过,组件化用户界面。如果你考虑它们的本质,Angular 通用指令 NgFor、NgIf 和 NgSwitch 使用 HTML 模板元素的原因实际上是非常明显的。所有三个指令都需要动态地插入和删除我们的 DOM 中的大区域。例如,NgIf 会根据条件插入或删除它附加到的元素。通过利用模板元素,这可以通过浏览器的原生功能得到支持。
注意
如果你比较这里讨论的例子,很明显,第一种编写风格处理起来要简单得多。每次你想使用 NgFor 或 NgIf 时都要求你编写一个单独的模板元素,那将相当痛苦。这就是存在星号语法的原因,如果你喜欢的话,这就是它的存在理由。我们不需要直接编写模板元素,而可以在属性上使用星号,Angular 会将 HTML 部分转换为我们所需的模板元素。
NgFor 指令使用 TemplateRef 依赖项,这可以被注入到指令的构造函数中,以实例化模板或多个实例,根据需要。在去糖化过程中,[ngForOf] 属性绑定是通过在 NgFor 表达式中的 NgFor 指令名称后添加单词 Of 生成的。绑定是由 NgFor 指令创建的,它接受一个输入,ngForOf。
考虑以下例子:
<div *test="let variable withSugar true">{{variable}}</div>
Angular 会将此简化为以下代码:
<template test #variable [testWithSugar]="true">
<div>{{variable}}</div>
</template>
这只是 Angular 去糖化星号模板语法的做法。这是一个将指令附加到模板元素以及一个输入绑定到模板元素的快捷方式。
仍然有一件事可能看起来有些令人困惑,那就是模板中的变量属性。让我们通过使用 NgFor 指令并别名指令公开的局部变量(如当前索引)来查看另一个例子:
<div *ngFor="let n of [1, 2, 3]; let i = index">{{i}}: {{n}}</div>
这个例子将被简化为以下模板:
<template ngFor #n #i="index" [ngForOf]="[1, 2, 3]">
<div>{{i}}: {{n}}</div>
</template>
因此,我们现在可以从去糖化中得知,将创建额外的别名或映射作为模板元素中的变量映射。在 NgFor 指令类的代码中公开的索引作为局部视图变量映射到模板实例化内容中的局部视图变量。
那么,在我们的实例化模板中,局部视图变量 n 发生了什么?为什么我们能够访问 n,当只有一个没有任何值来告诉我们它映射到哪里的变量属性时?
我们已经了解到,当我们对常规元素使用哈希符号属性时,我们创建了一个局部视图引用。我们可以直接在视图中使用这个引用作为标识符,或者通过使用 @ViewChild 进行查询。然而,当 Angular 的视图编译器发现模板上看起来像局部视图引用的内容时,其行为略有不同。
我们目前看不到的是,Angular 实际上为模板元素上的变量属性隐含了一个默认值。它将为名为 $implicit 的局部视图变量创建一个映射。你可以将 $implicit 视为一个默认值,可以在指令中作为局部视图变量暴露,并在处理模板元素时提供一些便利。
同样,前面的示例也可以写成如下形式:
<template ngFor #n="$implicit" #i="index" [ngForOf]="[1, 2, 3]">
<div>{{i}}: {{n}}</div>
</template>
在这里,NgFor 指令正在暴露一个局部视图变量 $implicit,它是对在 ngForOf 输入中接收到的数组迭代期间与实例关联的当前值的引用。使用不带值的普通变量属性,Angular 将默认将其映射到 $implicit。因为我们不想每次都自己编写这个映射,所以我们可以只指定一个空的变量属性,Angular 会为我们处理。
创建无限滚动指令
由于我们现在对模板元素和 Angular 如何处理星号语法有了一些了解,我们实际上可以创建自己的 NgFor 复制,它还处理无限滚动的行为。
让我们在 infinite-scroll/infinite-scroll.js 下为我们的指令创建一个新文件:
...
@Directive({
selector: '[ngcInfiniteScroll]'
})
export class InfiniteScroll {
...
// This input will be set by the for of template syntax
@Input('ngcInfiniteScrollOf')
set infiniteScrollOfSetter(value) {
this.infiniteScrollOf = value;
...
}
...
applyChanges(changes) {
...
this.bulkInsert(insertTuples).forEach((tuple) =>
tuple.view.context.$implicit = tuple.record.item);
}
...
}
我们首先声明一个对属性选择器 ngcInfiniteScroll 敏感的常规指令。前面的代码摘录仅显示了我们在前一个主题中讨论的模板元素处理的相关代码。有一些代码部分我们将在本主题的后面部分进行介绍。你可以看到我们使用了一个输入属性 ngcInfiniteScrollOf,它用于传递无限滚动中使用的项目列表。对于插入的模板实例,我们将局部视图变量 $implicit 设置为我们正在迭代的列表中的实际项目。
我们将在稍后讨论如何获取所有相关代码,但首先让我们看看我们如何在模板中使用这个指令:
<div *ngcInfiniteScroll="#item of items">{{item}}</div>
根据前一个主题中描述的机制,前面的代码将简化为以下模板元素:
<template ngcInfiniteScroll #item [ngcInfiniteScrollOf]="items">
<div>{{item}}</div>
</template>
因此,我们现在可以告诉的是,项目数组将被放置为属性绑定到我们的模板元素上。相同的元素还包含 InfiniteScroll 指令。
在讨论了指令的用法以及如何将所需输入传递到指令之后,让我们看看实现无限滚动行为的详细实现。
我们的需求列表需要处理很多问题。让我们来看看一个高级需求列表:
-
它需要根据模板元素动态创建新的子视图,并删除不再需要的子视图。
-
它需要检测输入属性
ngcInfiniteScrollOf的变化,该属性绑定到模板内的数组。仅使用简单的身份检查是不够的,因为我们希望创建前一个数组与新数组的比较,并且仅基于差异执行视图更改。为此,我们需要实现DoCheck生命周期回调。 -
它需要存储应最初显示的项目数,并通过检测滚动事件,显示的项目数应增加,以便更多项目可见。同时,滚动应触发变更检测,以便我们可以在视图中创建模板的新实例。
让我们从指令的构造函数开始:
constructor(@Inject(ViewContainerRef) viewContainerRef,
@Inject(TemplateRef) templateRef,
@Inject(IterableDiffers) iterableDiffers,
@Inject(ChangeDetectorRef) cdr) {
// Using Object.assign we can easily add all constructor
// arguments to our instance
Object.assign(this,
{viewContainerRef, templateRef, iterableDiffers, cdr});
// How many items will be shown initially
this.shownItemCount = 3;
// How many items should be displayed additionally, when we
// scroll to the bottom
this.increment = 3;
}
为了执行所有满足指令概述要求所需的操作,我们需要使用相当多的注入依赖项:
-
ViewContainerRef依赖项帮助我们根据模板元素创建新的嵌入式视图,以及分离或完全删除现有视图。 -
TemplateRef依赖项是对模板元素的引用,我们可以将其与ViewContainerRef依赖项结合使用,以创建新实例。 -
IterableDiffers依赖项用于创建我们输入属性的差异,这是我们无限滚动重复器中关心的项目数组。它支持我们找到创建、删除和删除的项目。 -
ChangeDetectorRef依赖项用于在我们实际需要时手动触发变更检测。
作为第一步,我们使用 Object.assign 将所有函数参数存储在指令的实例中。然后,我们设置两个成员变量,将存储与应显示的项目数量以及滚动时应增加的显示项目数量相关的信息。
构造函数的内容到此为止。我们还需要在指令中的视图初始化后执行一些操作。我们将使用 ngOnInit 生命周期钩子来完成此目的:
ngOnInit() {
this.scrollableElement = findScrollableParent(
this.viewContainerRef.element.nativeElement.parentElement);
this.scrollableElement.addEventListener('scroll', this._onScrollListener);
}
让我们更详细地看看这两行代码:
-
我们的无限滚动工作方式是检测可滚动父元素是否已经滚动到底部。如果是这种情况,我们需要从列表中渲染更多项目。为了检查我们的父元素是否已经滚动到底部,我们需要对其有一个引用。由于滚动事件不会冒泡,我们需要非常精确地监控它们的位置。这就是为什么我们使用一个实用函数来扫描 DOM 树以找到下一个可滚动的父元素。
findScrollableParent函数寻找第一个具有滚动条或窗口对象的父元素。如果您想查看该函数的内部结构,可以检查本章的源代码。 -
现在我们已经为找到的可滚动父元素添加了一个事件处理器,并将我们的内部
onScroll方法注册为回调。
在我们的模板指令中检测变更
现在我们来看一下ngcInfiniteScrollOf属性设置器的完整代码,我们之前已经简要地看过:
@Input('ngcInfiniteScrollOf')
set infiniteScrollOfSetter(value) {
this.infiniteScrollOf = value;
// Create a new iterable differ for the iterable `value`, if the
// differ is not already present
if (value && !this.differ) {
this.differ = this.iterableDiffers.find(value).create(this.cdr);
}
}
当 Angular 每次ngcInfiniteScrollOf输入属性变化时,我们的属性设置器将被调用。由于这个属性通过模板语法的解糖绑定到我们在模板中引用的列表,我们可以假设这个值始终是一个数组或类似的可迭代结构。
除了将输入属性的新值存储到我们的指令实例上,我们还懒加载一个名为differ的成员字段。通过在IterableDiffers对象上调用find方法,我们可以获得一个与您正在处理的可迭代类型匹配的工厂(在我们的情况下,这将是一个普通的数组)。在获得的工厂上,我们可以调用create方法来创建一个新的差异。create方法期望传递一个ChangeDetectorRef对象。幸运的是,我们通过构造函数中的注入已经准备好了。
差异将帮助我们稍后检测数组现有值和更新值之间的变更。然后我们可以以非常高效的方式执行添加、移除和移动操作。
如果我们在IterableDiffer上调用diff方法,它将返回一个新的IterableDiffer对象,该对象包含相对于先前IterableDiffer对象的所有变更。在一个差异中,我们可以调用以下方法之一来遍历相关的CollectionChangeRecord:
-
forEachItem: 这通过提供一个回调函数来遍历差异中的每个CollectionChangeRecord。回调函数的第一个参数将是一个变更记录。 -
forEachPreviousItem: 这只遍历在先前的差异中已经存在于差异中的每个CollectionChangeRecord。 -
forEachAddedItem: 这只遍历从先前的差异到当前差异中添加的每个变更记录。 -
forEachMovedItem: 这只遍历被移动的每个变更记录。 -
forEachRemovedItem: 这只遍历被移除的变更记录
CollectionChangeRecord对象包含以下三个主要属性:
-
item: 对列表中我们正在使用differ观察变更的项目的一个引用 -
previousIndex: 在differ可迭代之前列表中项目的索引 -
currentIndex: 在differ可迭代之后列表中项目的索引
我们也可以仅从previousIndex和currentIndex的星座中判断出项目发生了什么。以下方法存在于一个IterableDiffer对象上:
-
新增的项目:如果
previousIndex为 null 且currentIndex设置为有效数字,则可以识别出来 -
已移动的项目:如果
previousIndex和currentIndex都设置为有效的数字,则可以识别出来 -
已删除的项目:如果
previousIndex设置为有效的数字但currentIndex设置为null,则可以识别出来
现在,让我们看看onScroll方法,它将由可滚动容器元素的滚动事件回调调用。在这个方法中,我们需要处理当用户向下滚动时应执行的行为逻辑:
onScroll() {
// If the scrollable parent is scrolled to the bottom, we will
// increase the count of displayed items
if (this.scrollableElement && isScrolledBottom(this. scrollableElement)) {
this.shownItemCount = Math.min(this.infiniteScrollOf.length, this.shownItemCount + this.increment);
// After incrementing the number of items displayed, we need
// to tell the change detection to revalidate
this.cdr.markForCheck();
}
}
在onScroll方法中,我们首先检查可滚动父元素的滚动条是否已经滚动到底部。如果是这样,我们可以假设我们应该从我们的列表中显示更多项目。
我们将showItemCount成员增加了默认的increment值,我们将其设置为3,在修改了显示项目的数量后,我们使用了变更检测器来标记我们的子树结构以进行检查。
由于我们希望在输入设置器中懒初始化的检测器中检测更改并手动执行任何操作,我们需要在我们的指令上实现DoCheck生命周期回调。通过实现这一点,我们将禁用 Angular 的默认变更检测并实现我们自己的处理更改的方式:
ngDoCheck() {
if (this.differ) {
// We are creating a new slice based on the displayed item
// count and then create a changes object containing the
// differences using the IterableDiffer
const updatedList = this.infiniteScrollOf
.slice(0, this.shownItemCount);
const changes = this.differ.diff(updatedList);
if (changes) {
// If we have any changes, we call our `applyChanges` method
this.applyChanges(changes);
}
}
}
首先,我们使用检测器从当前的infiniteScrollOf数组到前一个数组获取一个更改记录集。实际上,检测器总是会存储前一个值,所以我们只需要传递当前值给它。更改记录将帮助我们为添加、删除和移动的项目执行不同的操作。同样重要的是要注意,我们在这里没有使用整个列表来创建差异,而是使用列表的一部分,其中我们的showItemCount成员发挥作用。这将只使我们在无限滚动行为中关心的列表可用。
添加和删除嵌入式视图
如果检测到任何变化,我们可以调用applyChanges方法,该方法处理如何使用更改的项目执行视图更新的细节:
applyChanges(changes) {
// First we create a record list that contains all moved and
// removed change records
const recordViewTuples = [];
changes.forEachRemovedItem((removedRecord) =>
recordViewTuples.push({record: removedRecord}));
changes.forEachMovedItem((movedRecord) =>
recordViewTuples.push({record: movedRecord}));
// We can now bulk remove all moved and removed views and as a
// result we get all moved records only
const insertTuples = this.bulkRemove(recordViewTuples);
// In addition to all moved records we also add a record for all
// newly added records
changes.forEachAddedItem((addedRecord) =>
insertTuples.push({record: addedRecord}));
// Now we have stored all moved and added records within `
// insertTuples` which we use to do a bulk insert. As a result
// we get the list of the newly created views. On those views
// we're then creating a view local variable `$implicit` that
// will bind the list items to the variable name used within the
// for of template syntax.
this.bulkInsert(insertTuples).forEach((tuple) =>
tuple.view.context.$implicit = tuple.record.item);
}
让我们看看applyChanges方法的内部结构。它需要从观察到的输入数组infiniteScrollOf中的记录更改调用。在常量recordViewTuples中,我们存储了所有已移动或完全删除的更改记录。现在你可以通过传递recordViewTuples数组来调用bulkRemove方法。bulkRemove方法将根据是否有移动来断开视图,或者完全删除视图。返回的值是一个列表,它将只包含移动的元组。我们将这些存储在一个名为insertTuples的常量中。因为它们已经从视图容器中分离出来,所以我们需要在视图容器中的不同位置重新附加它们。
现在,我们可以继续将根据最新差异添加的所有记录添加到insertTuples数组中。insertTuples数组现在包含所有移动的以及添加的记录。
使用此列表,我们调用bulkInsert方法,该方法将重新插入移动的视图并为添加的记录创建新的嵌入视图。结果,我们得到一个包含所有插入记录(移动的和添加的)的列表,其中每个记录还包含一个指向插入视图的视图属性。
在我们的applyChanges方法的最后一步应该现在响起警钟。我们遍历了新插入的视图列表,并在视图上下文中设置了局部视图变量$implicit。这样,我们可以设置所需的变量,该变量用于在模板元素上创建默认变量映射,如前一个主题中讨论的那样。
为了理解我们如何从模板元素实例化新视图,移动视图,以及删除现有视图,我们需要了解视图容器。ViewContainerRef依赖项通过构造函数中的注入提供给我们的指令或组件。它存储了一个视图列表,并提供了一些添加新视图和删除现有视图的方法。Angular 中的每个组件都包含一个视图容器。然后我们可以访问视图容器上的方法来以编程方式修改视图。
在ViewContainerRef中有四种主要方法是我们感兴趣的:
| 方法 | 描述 |
|---|
| createEmbeddedView | 此方法将使用模板引用创建一个新的嵌入视图,并在视图容器中给定索引处插入新创建的视图。嵌入视图是从模板元素实例化的视图。以下是其参数:
-
templateRef:第一个参数应该是模板引用,它应该被实例化为嵌入视图。 -
context:这是一个可选的上下文对象,它将为实例化的模板视图可用。上下文中的所有属性都可以在视图模板中作为局部视图变量使用。 -
index:可选的索引参数可以用来在视图容器中指定位置放置实例化的视图。
此方法返回创建的嵌入视图。 |
| detach | detach方法将从视图容器中移除给定索引的嵌入视图,而不销毁视图,以便稍后可以使用insert方法重新附加。以下是其参数:
index:这是嵌入视图的索引,应该被分离
此方法返回分离的嵌入视图。 |
| remove | remove方法将完全从视图容器中删除嵌入视图并销毁视图。已销毁的视图不能简单地使用insert方法重新附加。以下是其参数:
index:这是嵌入视图的索引,应该被移除
此方法返回被删除的嵌入视图。 |
| insert | 此方法将现有视图插入到视图容器中。以下是其参数:
-
viewRef:应插入到视图容器中的嵌入式视图。 -
index:可选的索引参数,可用于在视图容器中指定位置放置嵌入式视图。
此方法返回插入的嵌入式视图。|
让我们快速查看 bulkRemove 和 bulkInsert 方法,看看我们如何使用视图容器在更改时修改包含的视图:
bulkRemove(tuples) {
...
// Reducing the change records so we can return only moved
// records
return tuples.reduceRight((movedTuples, tuple) => {
// If an index is present on the change record, it means that
// its of type "moved"
if (tuple.record.currentIndex != null) {
// For moved records we only detach the view from the view
// container and push it into the reduced record list
tuple.view = this.viewContainerRef.detach(tuple.record.previousIndex);
movedTuples.push(tuple);
} else {
// If we're dealing with a record of type "removed", we
// completely remove the view
this.viewContainerRef.remove(tuple.record.previousIndex);
}
return movedTuples;
}, []);
}
我们使用 ViewContainerRef 来断开视图,以防记录包含有效的 currentIndex 字段。如果是这种情况,我们知道我们正在处理一个将要移动的视图。我们使用 detach 方法将视图从视图容器中的位置排除,但这不会销毁视图。在此需要注意的是,我们在将视图添加到 movedTuples 列表之前,将 detach 方法返回的视图存储到元组中。这样,我们就能在以后将其识别为移动项,并可以使用视图容器上的 insert 方法重新附加它。
在没有有效 currentIndex 的情况下,我们正在处理从列表中删除的元素。在这种情况下,我们需要使用 remove 方法完全销毁视图并将其从视图容器中删除。
现在,我们将使用任何移动或插入的视图调用 bulkInsert 方法。让我们也简要看看这个方法的代码,看看我们如何在其中处理视图更新:
bulkInsert(tuples) {
...
tuples.forEach((tuple) => {
if (tuple.view) {
// We're inserting back the detached view at the new positionwithin the view container
this.viewContainerRef.insert(tuple.view,
tuple.record.currentIndex);
} else {
// We're dealing with a newly created view so we create a new embedded view on the view container and store it in the change record
tuple.view =
this.viewContainerRef.createEmbeddedView(
this.templateRef,
{},
tuple.record.currentIndex);
}
});
return tuples;
}
如果元组包含 view 属性,我们知道我们之前已经从不同的位置将其断开。我们正在使用视图容器的插入方法,使用 CollectionChangeRecord 中的信息在新的位置重新附加它。
如果没有 view 属性,我们正在处理一个新添加的记录。在这种情况下,我们只需使用 createEmbeddedView 方法创建一个新的模板实例。对于上下文参数,我们需要传递一个新的空对象。然而,我们已经在 applyChanges 方法中更新了上下文对象。在那里,我们为每个创建的视图添加了 $implicit 本地视图变量。
这就是我们 InfiniteScroll 指令所需的所有内容,我们现在可以将其添加到我们计划使用此功能的模板中。让我们在 task-list/task-list.js 文件中的 TaskList 组件的指令列表中添加这个指令:
...
import {InfiniteScroll} from '../infinite-scroll/infinite-scroll';
@Component({
selector: 'ngc-task-list',
...
directives: [..., InfiniteScroll]
})
export class TaskList {
...
}
现在,我们可以在 task-list/task-list.html 中简单地编辑任务列表模板,并用我们的 InfiniteScroll 指令替换 NgFor 指令:
<ngc-task *ngcInfiniteScroll="let task of filteredTasks"
[[task]="task"
(taskUpdated)="onTaskUpdated(task, $event)"
(taskDeleted)="onTaskDeleted(task)"
draggable
draggableType="task"
[draggableData]="task"
draggableDropZone
dropAcceptType="task"
(dropDraggable)="onTaskDrop($event, task)"></ngc-task>
这就是我们使用无限滚动功能所需的所有内容。这是高度可重用的,我们可以将其放置在我们希望使用它的任何位置,而不是常规的 NgFor 重复器。
完成我们的无限滚动指令
在这个主题中,我们通过实现一个类似于 NgFor 的模板指令来创建无限滚动行为。我们将任务列表中的 NgFor 指令替换为 InfiniteScroll 指令。现在我们不会一开始就显示所有任务,而是在用户开始滚动时,新任务就会出现。在依赖于从服务器部分加载的列表的场景中,我们的指令甚至可以扩展,以便在需要时从服务器请求更多项目。
在这里我们涵盖了以下子主题:
-
星号语法和将模板元素去糖化
-
本地视图变量,
$implicit -
实现用于提供自定义更改检测的
OnChange生命周期钩子 -
使用
IterableDiffer分析数组输入属性中的更改差异,并处理CollectionChangeRecord对象以对更改做出反应 -
使用
ViewContainerRef以编程方式更新组件的视图 -
在模板指令中使用
TemplateRef作为模板元素内的引用
摘要
在本章中,我们构建了三个组件来增强我们应用程序的可用性。现在用户可以使用标签轻松地对注释进行标记,并使用可导航的项目提供主题摘要。他们可以使用拖放来重新排序任务,并从任务列表的无限滚动行为中受益。
可用性是当今应用程序的关键资产,通过提供高度封装和可重用的组件来解决可用性问题,我们可以在构建这些应用程序时使生活变得更加容易。在处理可用性时以组件为思考方式是非常好的,这不仅简化了开发,还建立了一致性。一致性本身在使应用程序可用方面发挥着重要作用。
在下一章中,我们将创建一些巧妙的功能组件来管理任务管理系统中的时间。这还将包括一些新的用户输入组件,以启用简单的工时输入字段。
第八章. 时间会证明
我们的任务管理系统正在成形。然而,到目前为止,我们并未关注到管理项目的一个关键方面。时间在所有项目中都扮演着重要角色,这也是最复杂的管理事项之一。
在本章中,我们将向我们的任务管理系统添加一些功能,帮助用户更有效地管理时间。通过重用我们之前创建的一些组件,我们将能够提供一致的用户体验来管理时间。
在更高层次上,我们将开发以下功能以在我们的应用程序中实现时间管理:
-
任务详情:到目前为止,我们没有包括任务详情页面,因为所有关于任务的信息都可以在我们的项目页面上的任务列表中显示。虽然我们的时间管理将大大增加任务的复杂性,但我们将创建一个新的项目任务详情视图,它也将通过路由访问。
-
努力管理:我们将包括一些新的任务数据来管理任务上的努力。努力总是由估计的时间持续和实际花费的时间表示。我们将使努力的这两个属性都是可选的,以便它们可以独立存在。我们将创建新的组件,使用户能够轻松地提供时间持续时间输入。
-
里程碑管理:我们将包括一种管理项目里程碑的方法,并将它们映射到项目任务上。这将有助于我们后来对项目状态有一个全面的了解,并使用户能够将任务分组为更小的作业块。
本章将涵盖以下主题:
-
创建一个项目任务详情组件来编辑任务详情并启用新的路由
-
修改我们的标签管理系统以包含任务标签
-
创建新的管道来处理格式化时间持续时间
-
创建任务信息组件以在现有的任务组件上显示任务概述信息
-
创建一个时间持续时间输入组件,使用户能够轻松输入时间持续时间
-
创建一个 SVG 组件来显示任务进度
-
创建一个自动完成组件来管理任务上的里程碑
任务详情
到目前为止,我们的任务列表已经足够显示任务的所有详细信息。然而,随着我们在本章中为任务添加更多详细信息,是时候提供一个详情视图,让用户可以编辑任务了。
我们已经在本书的第五章,组件化路由中,使用路由器为项目导航奠定了基础。在项目中添加一个我们将使用的新可路由组件将变得轻而易举。
让我们在project/project-task-details/project-task-details.js路径下为我们的项目任务详情视图创建一个新的组件类:
…
@Component({
selector: 'ngc-project-task-details',
…
})
export class ProjectTaskDetails {
…
}
由于此组件在没有父Project组件的情况下将不存在,我们可以安全地依赖它来获取我们使用的数据。此组件在纯 UI 组合情况下不使用,因此不需要创建像我们在第五章“Component-Based Routing”中为其他组件创建的可路由包装组件。我们可以直接依赖路由参数,并从父Project组件中获取相关数据。
首先,我们使用依赖注入来获取父项目组件的引用:
constructor(@Inject(forwardRef(() => Project)) project) {
this.project = project;
}
类似于我们的路由包装组件,我们利用父组件注入来获取父Project组件的引用。
现在,我们将再次使用路由器的OnActivate生命周期钩子来从活动路由段中获取任务编号:
routerOnActivate(currentRouteSegment) {
const taskNr = currentRouteSegment.getParam('nr');
this.projectChangeSubscription = this.project.document.change.subscribe((data) => {
this.task = data.tasks.find((task) => task.nr === +taskNr);
this.projectMilestones = data.milestones || [];
});
}
最后,我们将创建一个对LiveDocument项目的响应式订阅,这将提取我们关心的任务并将其存储到组件的task成员中。这样,我们确保当项目在当前任务详情视图之外更新时,我们的组件将始终接收到最新的任务数据。
如果我们的组件被销毁,我们需要确保我们取消订阅由LiveDocument项目提供的 RxJS Observable。让我们为此实现ngOnDestroy生命周期钩子:
ngOnDestroy() {
this.projectChangeSubscription.unsubscribe();
}
好的,现在让我们看看我们组件的模板,看看我们将如何处理任务数据以提供一个编辑详情的接口。我们将在新的component文件夹中创建一个project-task-details.html文件:
<h3 class="task-details__title">
Task Details of task #{{task?.nr}}
</h3>
<div class="task-details__content">
<div class="task-details__label">Title</div>
<ngc-editor [content]="task?.title"
[showControls]="true"
(editSaved)="onTitleSaved($event)"></ngc-editor>
<div class="task-details__label">Description</div>
<ngc-editor [content]="task?.description"
[showControls]="true"
[enableTags]="true"
(editSaved)="onDescriptionSaved($event)">
</ngc-editor>
</div>
重新使用我们在本书第四章“No Comments, Please!”中创建的Editor组件,我们可以依靠简单的 UI 组合来使我们的任务标题和描述可编辑。
由于我们将任务数据存储到我们的组件上的task成员变量中,我们可以引用title和description字段来创建一个绑定到我们的编辑组件的content输入属性。
虽然title应仅包含纯文本,但我们可以支持我们在第七章“Components for User Experience”中创建的标签功能,在任务的description字段上。为此,我们只需将描述Editor组件的enableTags输入属性设置为true。
Editor组件有一个editSaved输出属性,当用户保存其编辑时将发出更新后的内容。现在,我们只需要确保我们创建一个绑定到我们的组件,以持久化这些更改。让我们在我们的Component类上创建onTitleSaved和onDescriptionSaved方法来处理这些事件:
onTitleSaved(title) {
this.task.title = title;
this.project.document.persist();
}
onDescriptionSaved(description) {
this.task.description = description;
this.project.document.persist();
}
任务成员只是对Project组件中的LiveDocument项目所给任务的参考。这简化了我们持久化任务上更改的数据的方式。在更新任务上的给定属性后,我们只需在LiveDocument项目上调用persist方法来将我们的更改存储在数据存储中。
到目前为止,一切顺利。我们创建了一个任务详情组件,使用我们的Editor UI 组件可以轻松编辑任务的标题和描述。我们唯一剩下要启用我们的组件的事情是在Project组件上创建一个子路由。让我们打开lib/project/project.js中的Project组件类,进行必要的修改:
…
import {ProjectTaskDetails} from './project-task-details/project-task-details';
…
@Component({
selector: 'ngc-project',
…
})
@Routes([
new Route({ path: 'task/:nr', component: ProjectTaskDetails}),
…
])
export class Project {
…
}
我们在Project组件上添加了一个新的子路由,该路由负责实例化我们的ProjectTaskDetails组件。通过在路由配置中包含一个:nr参数,我们可以将相关的任务编号传递给ProjectTaskDetails组件。
我们新创建的子路由现在可以在路由器中访问,我们可以使用/projects/project-1/task/1示例 URL 访问任务详情视图。
为了使我们的TaskDetails路由可导航,我们需要在我们的Task组件中添加一个导航链接,以便用户可以在项目任务列表中导航到它。
对于这个相对简单的任务,我们唯一需要做的事情是使用RouterLink指令在Task模板lib/task-list/task/task.html中创建一个新的链接:
…
<div class="task__l-box-b">
…
<a [routerLink]="['../task', task?.nr]"
class="button button--small">Details</a>
</div>
…
我们在这里使用相对路由 URL,因为我们已经在/project/tasks路由上。由于我们的task/:nr路由是项目路由的一部分,我们需要回退一级以访问task路由:

新创建的任务详情视图,具有可编辑的标题和描述
启用任务标签
到目前为止,我们在第七章中创建的标签管理系统,用户体验组件,仅支持项目标签。由于我们现在创建了一个任务详情视图,因此也直接在我们的标签系统中支持任务标签会很好。我们的标签系统非常灵活,我们可以以非常少的努力实现新的标签。在更高层次上,我们需要进行以下更改以在我们的系统中启用任务标签:
-
编辑
generate-tag.js模块以支持从任务和项目数据生成任务标签 -
编辑
TagsService以使用generate-tag.js模块和缓存初始化任务标签
让我们先修改lib/tags/generate-tag.js文件以启用任务标签生成:
…
export const TAG_TYPE_TASK = 'task';
export function generateTag(subject) {
if (subject.type === TAG_TYPE_PROJECT) {
…
} else if (subject.type === TAG_TYPE_TASK) {
// If we're dealing with a task, we generate the according tag
// object
return new Tag(
`#${subject.project._id}-task-${subject.task.nr}`,
`${limitWithEllipsis(subject.task.title, 20)} (${subject.task.done ? 'done' : 'open'})`,
`#/projects/${subject.project._id}/task/${subject.task.nr}`,
TAG_TYPE_TASK
);
}
}
由于我们需要同时引用项目数据和此项目的单个任务,我们期望subject参数看起来像以下对象:
{task: …, project: …, type: TAG_TYPE_TASK}
从这个subject对象中,我们可以创建一个新的Tag对象。对于textTag字段,我们使用一个包含项目 ID 以及任务编号的结构。这样,我们可以使用简单的文本表示来唯一标识任务。
对于link字段,我们从项目以及任务编号构建一个 URL。这个字符串将解析为激活我们在上一节中配置的TaskDetails路由所需的 URL。
我们现在已准备好generateTag函数来创建任务标签。现在,我们系统中启用任务标签的唯一剩余操作是对TagsService类的修改。让我们打开lib/tags/tags-service.js文件并应用我们的更改:
…
import {generateTag, TAG_TYPE_TASK} from './generate-tag';
…
@Injectable()
export class TagsService {
…
// This method is used internally to initialize all available
// tags
initializeTags() {
…
// Let's also create task tags
this.projects.forEach((project) => {
this.tags = this.tags.concat(project.tasks.map((task) => {
return {
type: TAG_TYPE_TASK,
project,
task
};
}).map(generateTag));
});
…
}
…
}
在我们的TagsService类的initializeTags方法中,我们现在为项目中所有可用的任务添加任务Tag对象。首先,我们通过generateTag函数将每个项目任务映射到所需的subject对象。然后,我们可以简单地使用generateTag函数直接映射结果数组。结果是生成任务Tag对象的数组,然后我们将它们连接到TagsService类的tags列表中。
这并不太复杂,对吧?这个相对简单的更改为我们用户带来了巨大的改进。现在,他们可以在我们系统中任何我们启用了标签的地方引用单个任务:

显示新添加任务标签的编辑器组件
管理努力
在本节中,我们将创建一些组件,帮助我们跟踪努力。主要地,我们将使用这些组件来管理任务上的努力,但这可以应用于我们应用中的任何需要跟踪时间的部分。
在我们的语境中,努力总是由两个组成部分组成:
-
预计持续时间:这是对任务最初估计的持续时间
-
有效持续时间:这是在特定任务上花费的时间长度
对于时间长度,我们假设一些时间单位和规则,这将简化时间的处理并符合某些工作标准。这里的目的是不提供锐利的时管理,而是提供足够准确以带来价值的东西。为此,我们定义以下工作时间单位:
-
分钟:一分钟是标准的 60 秒
-
小时:一小时总是代表 60 分钟
-
天:一天代表一个标准的工作日,八小时
-
周:一周相当于五个工作日(5 * 8 小时)
时间持续时间输入
现在,我们可以开始编写一个复杂的用户界面组件,用户可以在不同的输入元素中输入单独的时间单位。然而,我相信用无 UI 方法处理时间持续时间输入会更方便。因此,我们不必构建复杂的用户界面,而可以简单地约定一个文本简写形式来编写持续时间,并让用户输入一些内容,例如1.5d或5h 30m,以提供输入。按照我们之前建立的约定,我们可以构建一个简单的解析器来处理这种输入。
这种方法有几个优点。除此之外,这也是输入时间持续的最有效方法之一,而且对我们来说也很容易实现。我们可以简单地重用我们的Editor组件来收集用户的文本输入。然后,我们使用一个转换过程来解析输入的时间持续时间。
让我们启动一个新的模块,帮助我们处理这些转换。我们在lib/utilities/time-utilities.js文件中创建一个新的模块。
首先,我们需要一个常量来定义我们需要的所有转换单位:
export const UNITS = [{
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
}];
这是我们目前需要处理的全部单位。您可以看到在解释时计算的毫秒数。我们也可以将毫秒数写成常量,但这为我们提供了如何得到这些值的透明度,并且我们可以添加一些注释。
让我们看看我们的解析函数,我们可以用它将文本输入解析为时间持续时间:
export function parseDuration(formattedDuration) {
const pattern = /[\d\.]+\s*[wdhm]/g;
let timeSpan = 0;
let result;
while (result = pattern.exec(formattedDuration)) {
const chunk = result[0].replace(/\s/g, '');
let amount = Number(chunk.slice(0, -1));
let unitShortName = chunk.slice(-1);
timeSpan += amount * UNITS.find(
(unit) => unit.short === unitShortName
).milliseconds;
}
return +timeSpan || null;
}
让我们简要分析一下前面的代码,以解释我们在这里做了什么:
-
首先,我们定义一个正则表达式,帮助我们分解持续时间文本表示。这个模式将提取文本输入中的重要部分,用于计算文本表示背后的持续时间。这些部分总是由一个数字后面跟着
w、d、h或m组成。因此,文本10w 3d 2h 30m将被分割成10w、3d、2h和30m这些部分。 -
我们将
timeSpan变量初始化为0,这样我们就可以将发现的块中的所有毫秒数加在一起,然后返回这个总和。 -
对于之前提取的每个部分,我们现在将数字组件提取到一个名为
amount的变量中,将单位(w、d、h或m)提取到一个名为unitShortName的变量中。 -
现在,我们可以查找
UNITS常量中的数据,为我们将要处理的块的单位,将单位的毫秒数乘以我们从块中提取的量,然后将这个结果加到我们的timeSpan变量中。
好吧,这是我们构建的一个相当整洁的函数。它接受一个格式化的时间持续时间字符串,并将其转换为毫秒。这已经是我们需要处理文本表示的时间持续期的半部分了。第二部分是parseDuration函数的相反,将毫秒持续时间转换为格式化的持续时间字符串:
export function formatDuration(timeSpan) {
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,我们可以将给定的数量和单位简称添加到我们的格式化字符串中。
-
由于在数量的小数点后可能留下一些分数,我们需要将这些分数编码到更小的单位中,所以我们从
timeSpan中减去我们数量的向下取整版本,然后再返回到reduce函数。 -
这个过程会为每个单位重复,其中每个单位只有在数量大于或等于 1 时才会提供格式化输出。
这就是我们需要的所有内容,可以将格式化时间长度和以毫秒表示的时间长度相互转换。
在我们创建实际的时间长度输入组件之前,我们还将做一件事。我们将创建一个简单的管道,它基本上只是包装我们的formatTime函数。为此,我们将创建一个新的lib/pipes/format-duration.js文件:
import {Pipe, Inject} from '@angular/core';
import {formatDuration} from '../utilities/time-utilities';
@Pipe({
name: 'formatDuration'
})
export class FormatDurationPipe {
transform(value) {
if (value == null || typeof value !== 'number') {
return value;
}
return formatDuration(value);
}
}
使用我们的time-utilities模块中的formatTime函数,我们现在可以直接在我们的模板中以毫秒为单位格式化持续时间。
管理努力的组件
好的,现在我们已经有了足够的时间数学知识。现在让我们使用我们创建的元素来构建一些组件,这些组件将帮助我们收集用户输入。
在本节中,我们将创建两个组件来管理努力:
-
持续时间:持续时间组件是一个简单的 UI 组件,它允许用户使用我们在前几节中处理过的格式化时间字符串输入时间长度。它使用Editor组件来启用用户输入,并使用FormatTimePipe管道以及parseDuration实用函数。 -
努力:努力组件只是两个持续时间组件的组合,这两个组件分别表示给定任务上的估计努力和实际花费的努力。遵循严格的组合规则,这个组件对我们来说很重要,这样我们就不需要重复自己,而是组合一个更大的组件。
让我们从Duration组件类开始,并创建一个新的lib/ui/duration/duration.js文件:
…
import {FormatDurationPipe} from '../../pipes/format-duration';
import {Editor} from '../../ui/editor/editor';
import {parseDuration} from '../../utilities/time-utilities';
@Component({
selector: 'ngc-duration',
…
directives: [Editor],
pipes: [FormatDurationPipe]
})
export class Duration {
@Input() duration;
@Output() durationChange = new EventEmitter();
onEditSaved(formattedDuration) {
this.durationChange.next(formattedDuration ?
parseDuration(formattedDuration) : null);
}
}
这个组件实际上并没有什么特别之处,因为我们已经创建了大部分逻辑,我们只是将一个高级组件组合在一起。
作为duration输入,我们期望一个以毫秒为单位的时间长度,而durationChange输出属性将在用户提供输入时发出事件。
onEditSaved方法用于将我们的组件与编辑器组件绑定。每当用户在编辑器组件上保存其编辑时,我们将获取此输入,使用parseDuration函数将格式化的时长转换为毫秒,并使用durationChange输出属性重新发出转换后的值。
让我们看看我们的组件模板,在lib/ui/duration/duration.html文件中:
<ngc-editor [content]="duration | formatDuration"
[showControls]="true"
(editSaved)="onEditSaved($event)"></ngc-editor>
对我们的模板如此简单感到惊讶吗?好吧,这正是我们在建立了良好的基础组件之后,应该通过更高组件实现的目标。良好的组织结构极大地简化了我们的代码。我们在这里唯一处理的是我们那熟悉的编辑器组件。
我们将我们的时长组件的duration输入属性绑定到编辑器组件的内容输入属性。由于我们希望传递格式化的时长而不是毫秒数,我们在绑定表达式中使用FormatDurationPipe管道进行转换。
如果编辑器组件通知我们已保存的编辑,我们将在我们的时长组件上调用onEditSaved方法,该方法将解析输入的时长并重新发出结果值。
由于我们最初定义所有努力都包括估计时长和有效时长,我们现在想创建另一个组件,该组件结合这两个时长。
让我们在lib/efforts/efforts.html路径上创建一个新的Efforts组件,从一个新的模板开始:
<div class="efforts__label">Estimated:</div>
<ngc-duration [duration]="estimated"
(durationChange)="onEstimatedChange($event)">
</ngc-duration>
<div class="efforts__label">Effective:</div>
<ngc-duration [duration]="effective"
(durationChange)="onEffectiveChange($event)">
</ngc-duration>
<button class="button button--small"
(click)="addEffectiveHours(1)">+1h</button>
<button class="button button--small"
(click)="addEffectiveHours(4)">+4h</button>
<button class="button button--small"
(click)="addEffectiveHours(8)">+1d</button>
首先,我们添加两个标记为Duration的组件,其中第一个用于收集估计时间的输入,而后者用于有效时间。
此外,我们还提供了三个小按钮,通过简单的点击来增加有效时长。这样,用户可以快速增加一或四小时(半个工作日)或完整的工作日(我们定义为八小时)。
看一下Component类,不应该有任何惊喜。让我们打开lib/efforts/efforts.js组件类文件:
…
import {Duration} from '../ui/duration/duration';
import {UNITS} from '../utilities/time-utilities';
@Component({
selector: 'ngc-efforts',
…
directives: [Duration]
})
export class Efforts {
@Input() estimated;
@Input() effective;
@Output() effortsChange = new EventEmitter();
onEstimatedChange(estimated) {
this.effortsChange.next({
estimated,
effective: this.effective
});
}
onEffectiveChange(effective) {
this.effortsChange.next({
effective,
estimated: this.estimated
});
}
addEffectiveHours(hours) {
this.effortsChange.next({
effective: (this.effective || 0) +
hours * UNITS.find((unit) => unit.short === 'h'),
estimated: this.estimated
});
}
}
该组件提供了两个单独的输入,用于估计和有效时间时长(以毫秒为单位)。如果您再次查看组件模板,这些输入属性直接绑定到时长组件的输入属性。
onEstimatedChange和onEffectiveChange方法用于创建到时长组件的durationChange输出属性的绑定。我们在这里所做的一切就是发出一个包含有效时间和估计时间(以毫秒为单位)的聚合数据对象,使用effortsChange输出属性。
在addEffectiveHours方法中,我们简单地发出一个effortsChange事件,并通过计算出的毫秒数更新有效属性。我们使用来自time-utilities模块的UNITS常量来获取小时的毫秒数。
为了提供用户输入来管理任务上的努力,我们需要这些所有信息。为了完成这个主题,我们将把新创建的Efforts组件添加到ProjectTaskDetail组件中,以便管理任务上的努力。
让我们首先查看位于lib/project/project-task-detail/project-task-detail.js的Component类中的代码更改:
…
import {Efforts} from '../../efforts/efforts';
@Component({
selector: 'ngc-project-task-details',
…
directives: [Editor, Efforts]
})
export class ProjectTaskDetails {
…
onEffortsChange(efforts) {
if (!efforts.estimated && !efforts.effective) {
this.task.efforts = null;
} else {
this.task.efforts = efforts;
}
this.project.document.persist();
}
…
}
除了将Efforts组件添加到我们的ProjectTaskDetail组件的directives列表中,我们还添加了一个新的onEffortsChange方法来处理Efforts组件提供的输出。
如果既未设置估计和实际努力,或设置为0,我们将任务努力设置为null。否则,我们使用Efforts组件的输出数据并将其分配为我们新的任务努力。
在更改任务努力后,我们以与标题和描述更新相同的方式持久化项目的LiveDocument。
让我们检查位于lib/project/project-task-detail/project-task-detail.html的组件模板中的更改:
…
<div class="task-details__content">
…
<div class="task-details__label">Efforts</div>
<ngc-efforts [estimated]="task?.efforts?.estimated"
[effective]="task?.efforts?.effective"
(effortsChange)="onEffortsChange($event)">
</ngc-efforts>
</div>
我们将Efforts组件的估计和实际输入属性绑定到ProjectTaskDetail组件的任务数据中。对于effortsChange输出属性,我们使用一个表达式来调用我们刚刚创建的onEffortsChange方法:

我们的新Efforts组件由两个持续时间输入组件组成
视觉上的努力时间线
尽管我们迄今为止创建的用于管理努力的组件提供了编辑和显示努力和时间持续的好方法,但我们仍然可以通过一些视觉指示来改进这一点。
在本节中,我们将使用 SVG 创建一个视觉上的努力时间线。此时间线应显示以下信息:
-
总估计持续时间作为一个灰色背景条
-
总实际持续时间作为一个绿色条,它覆盖在总估计持续时间条上
-
一个显示任何加班(如果实际持续时间大于估计持续时间)的黄色条
下面的两个图示说明了我们的努力时间线组件的不同视觉状态:

当估计持续时间大于实际持续时间时的视觉状态

当实际持续时间超过估计持续时间时的视觉状态(加班显示为黑色条)
让我们在lib/efforts/efforts-timeline/efforts-timeline.js路径上创建一个新的EffortsTimeline组件类,以具体化我们的组件:
…
@Component({
selector: 'ngc-efforts-timeline',
…
})
export class EffortsTimeline {
@Input() estimated;
@Input() effective;
@Input() height;
ngOnChanges(changes) {
this.done = 0;
this.overtime = 0;
if (!this.estimated && this.effective ||
(this.estimated && this.estimated === this.effective)) {
// If there's only effective time or if the estimated time
// is equal to the effective time we are 100% done
this.done = 100;
} else if (this.estimated < this.effective) {
// If we have more effective time than estimated we need to
// calculate overtime and done in percentage
this.done = this.estimated / this.effective * 100;
this.overtime = 100 - this.done;
} else {
// The regular case where we have less effective time than
// estimated
this.done = this.effective / this.estimated * 100;
}
}
}
我们的组件有三个输入属性:
-
estimated:这是估计时间持续时间的毫秒数 -
effective:这是实际时间持续时间的毫秒数 -
height:这是努力时间线期望的高度,以像素为单位
在OnChanges生命周期钩子中,我们设置了两个基于估计和实际时间的组件成员字段:
-
done:这包含显示没有超过估算持续时间的有效持续时间的绿色条宽度百分比 -
overtime:这包含显示任何加班的黄色条宽度百分比,任何超过估算持续时间的持续时间
让我们看看EffortsTimeline组件的模板,看看我们如何现在使用done和overtime成员字段来绘制我们的时间线。
我们将创建一个新的lib/efforts/efforts-timeline/efforts-timeline.html文件:
<svg width="100%" [attr.height]="height">
<rect [attr.height]="height"
x="0" y="0" width="100%"
class="efforts-timeline__remaining"></rect>
<rect *ngIf="done" x="0" y="0"
[attr.width]="done + '%'" [attr.height]="height"
class="efforts-timeline__done"></rect>
<rect *ngIf="overtime" [attr.x]="done + '%'" y="0"
[attr.width]="overtime + '%'" [attr.height]="height"
class="efforts-timeline__overtime"></rect>
</svg>
我们的模板是基于 SVG 的,它包含我们想要显示的每个条的三个矩形。如果有剩余的努力,将始终显示背景条形图。
在剩余的条形图上方,我们使用从我们的组件类计算出的宽度有条件地显示完成和加班条形图。
现在,我们可以继续在我们的Efforts组件中包含EffortsTimeline类。这样,当我们的用户编辑估算或实际持续时间时,他们将获得视觉反馈,这为他们提供了一个概览。
让我们看看Efforts组件的模板,看看我们如何集成时间线:
…
<ngc-efforts-timeline height="10"
[estimated]="estimated"
[effective]="effective">
</ngc-efforts-timeline>
由于我们在Efforts组件中已经有了估算和实际持续时间,我们可以简单地创建一个绑定到EffortsTimeline组件输入属性:

显示我们新创建的努力时间线组件的Efforts组件(六小时的加班用黄色条可视化)
努力管理的总结
在本节中,我们将创建允许用户轻松管理努力并为我们任务添加简单但强大的时间跟踪的组件。我们已经做了以下事情来实现这一点:
-
我们实现了一些实用函数来处理时间数学,以便将毫秒时间段转换为格式化时间段,反之亦然
-
我们创建了一个管道,使用我们的实用函数格式化以毫秒为单位的时间段
-
我们创建了一个
DurationUI 组件,它包装了一个Editor组件,并使用我们的时间实用工具提供了一个无 UI 类型的输入元素来输入持续时间 -
我们创建了一个
Efforts组件,它作为两个Duration组件的组合,用于估算和实际时间,并提供额外的按钮来快速添加实际花费的时间 -
我们将
Efforts组件集成到ProjectTaskDetail组件中,以便在任务上管理努力 -
我们使用 SVG 创建了一个可视的
EffortsTimeline组件,它显示任务的总体进度
设置里程碑
跟踪时间很重要。我不知道你对时间的看法如何,但我在组织时间方面真的很差。尽管很多人问我如何做到这么多事情,但我相信我实际上在管理如何完成这些事情方面真的很差。如果我能成为一个更好的组织者,我可以用更少的精力完成事情。
总有一件事能帮助我组织自己,那就是将事情分解成更小的工作包。使用我们任务管理应用程序来组织自己的用户可以通过在项目中创建任务来实现这一点。虽然项目是整体目标,但我们可以创建更小的任务来实现这个目标。然而,有时我们只专注于任务时,往往会失去对整体目标的关注。
里程碑是项目和任务之间完美的粘合剂。它们确保我们将任务捆绑成更大的包。这将极大地帮助我们组织任务,并且我们可以查看项目的里程碑来了解项目的整体健康状况。然而,当我们以里程碑的上下文工作时,我们仍然可以专注于任务。
在本节中,我们将创建必要的组件,以便将基本里程碑功能添加到我们的应用程序中。
为了在我们的应用程序中实现里程碑功能,我们将坚持以下设计决策:
-
里程碑应存储在项目级别,并且任务可以包含对项目里程碑的可选引用。
-
为了保持简单,与里程碑的唯一交互点应该在任务级别。因此,里程碑的创建将在任务级别完成,尽管创建的里程碑将存储在项目级别。
-
目前里程碑仅包含一个名称。我们可以在系统中构建更多关于里程碑的内容,例如截止日期、依赖关系和其他美好的事物。然而,我们将坚持最基本的原则,即里程碑名称。
创建自动完成组件
为了保持里程碑管理的简单性,我们将创建一个新的用户界面组件来处理我们列出的设计问题。我们的新自动完成组件不仅会显示可供选择的可能值,而且还会允许我们创建新项目。然后我们可以简单地使用这个组件在我们的ProjectTaskDetail组件上,以便管理里程碑。
让我们来看看我们将在lib/ui/auto-complete/auto-complete.js文件中创建的新自动完成组件的Component类:
…
import {Editor} from '../editor/editor';
@Component({
selector: 'ngc-auto-complete',
…
directives: [Editor]
})
export class AutoComplete {
@Input() items;
@Input() selectedItem;
@Output() selectedItemChange = new EventEmitter();
@Output() itemCreated = new EventEmitter();
…
}
再次强调,我们的Editor组件可以被重用来创建这个高级组件。我们很幸运地创建了一个如此好的组件,因为它在这个项目中节省了我们大量的时间。
让我们更详细地看看AutoComplete组件的输入和输出属性:
-
items:这是我们期望的字符串数组。这将是在编辑器中输入时用户可以选择的项目列表。 -
selectedItem:这是我们将选中的项目作为一个输入属性来使这个组件成为纯组件的时候,并且我们可以依赖父组件来设置这个属性。 -
selectedItemChange:这个输出属性会在选中的项目发生变化时触发事件。由于我们在这里创建了一个纯组件,我们需要以某种方式传播在自动完成列表中选中的项目的相关事件。 -
itemCreated:如果向自动完成列表添加了新项,此输出属性将发出事件。更新项目列表和更改组件items输入属性仍然是父组件的责任。
让我们在组件中添加更多代码。我们使用Editor组件作为主要输入源。当我们的用户在编辑器中键入时,我们使用编辑器的文本输入来过滤可用的项。让我们为此创建一个filterItems:
filterItems(filter) {
this.filter = filter || '';
this.filteredItems = this.items
.filter(
(item) => item
.toLowerCase()
.indexOf(this.filter.toLowerCase().trim()) !== -1)
.slice(0, 10);
this.exactMatch = this.items.includes(this.filter);
}
filterItems方法有一个单一参数,即我们想要用于在列表中搜索相关项的文本。
让我们更详细地看看该方法的内容:
-
为了在模板中使用,我们将保存上一次调用此方法时使用的过滤查询。
-
在
filteredItems成员变量中,我们将通过搜索过滤字符串的文本出现来存储项目列表的过滤版本。 -
作为最后一步,我们还存储了搜索查询是否导致我们的列表中某个项的精确匹配的信息
现在,我们需要确保如果items或selectedItem输入属性发生变化,我们也再次执行我们的过滤方法。为此,我们简单地实现了ngOnChanges生命周期钩子:
ngOnChanges(changes) {
if (this.items && this.selectedItem) {
this.filterItems(this.selectedItem);
}
}
现在我们来看看我们如何处理Editor组件提供的事件:
onEditModeChange(editMode) {
if (editMode) {
this.showCallout = true;
this.previousSelectedItem = this.selectedItem;
} else {
this.showCallout = false;
}
}
如果编辑器切换到编辑模式,我们希望保存之前选中的项。如果用户决定取消他的编辑并切换回之前的项,我们将需要这样做。当然,这也是我们需要向用户显示自动完成列表的地方。
另一方面,如果将编辑模式切换回阅读模式,我们希望再次隐藏自动完成列表:
onEditableInput(content) {
this.filterItems(content);
}
editableInput事件在每次编辑器输入更改时由我们的编辑器触发。该事件为我们提供了用户输入的文本内容。如果发生此类事件,我们需要再次使用更新的过滤查询执行我们的过滤函数:
onEditSaved(content) {
if (content === '') {
this.selectedItemChange.next(null);
} else if (content !== this.selectedItem &&
!this.items.includes(content)) {
this.itemCreated.next(content);
}
}
当我们的编辑器触发editSaved事件时,我们需要决定是否应该执行以下操作之一:
-
如果保存的内容是空字符串,则使用
selectedItemChange输出属性发出事件,向父组件信号已删除选定的项。 -
如果提供了有效内容并且我们的列表中不包含具有该名称的项,则使用
itemCreated输出属性发出事件,以信号项的创建:onEditCanceled() { this.selectedItemChange.next(this.previousSelectedItem); }
在Editor组件的editCanceled事件上,我们希望切换回之前选中的项。为此,我们可以简单地使用selectedItemChange输出属性和我们在编辑器切换到编辑模式后留出的previousSelectedItem成员来发出一个事件。
这些是我们将用于连接我们的编辑器并将自动完成功能附加到其上的所有绑定函数。
在我们查看自动完成组件的模板之前,我们将创建两个更简单的其他方法:
selectItem(item) {
this.selectedItemChange.next(item);
}
createItem(item) {
this.itemCreated.next(item);
}
我们将使用这两个用于模板中自动完成提示的点击操作。让我们看一下模板,以便你可以看到我们刚刚创建的所有代码的实际效果:
<ngc-editor [content]="selectedItem"
[showControls]="true"
(editModeChange)="onEditModeChange($event)"
(editableInput)="onEditableInput($event)"
(editSaved)="onEditSaved($event)"
(editCanceled)="onEditCanceled($event)"></ngc-editor>
首先,放置Editor组件,并将我们创建的Component类中的处理方法所需的所有绑定附加到它上。
现在,我们将创建一个自动完成列表,它将作为用户在编辑器输入区域旁边的提示显示:
<ul *ngIf="showCallout" class="auto-complete__callout">
<li *ngFor="let item of filteredItems"
(click)="selectItem(item)"
class="auto-complete__item"
[class.auto-complete__item--selected]="item === selectedItem">{{item}}</li>
<li *ngIf="filter && !exactMatch"
(click)="createItem(filter)"
class="auto-complete__item auto-complete__item--create">Create "{{filter}}"</li>
</ul>
我们依赖于Component类的onEditModeChange方法设置的showCallout成员变量,以表示是否应该显示自动完成列表。
然后,我们使用NgFor指令遍历所有过滤后的项目,并渲染每个项目的文本内容。如果点击了某个项目,我们将调用我们的selectItem方法,并将相关项目作为参数值。
作为最后一个列表元素,在重复的列表项之后,我们条件性地显示一个额外的列表元素,以创建一个不存在的里程碑。我们仅在存在有效的过滤器且过滤器与现有里程碑没有精确匹配时显示此按钮:

我们的里程碑组件与编辑器组件配合得很好,使用干净的组合方式。
现在我们已经完成了自动完成组件,为了管理项目里程碑,我们唯一需要做的就是将其用于ProjectTaskDetails组件中。
让我们打开位于lib/project/project-task-details/project-task-details.js中的Component类,并应用必要的修改:
…
import {AutoComplete} from '../../ui/auto-complete/auto-complete';
@Component({
selector: 'ngc-project-task-details',
…
directives: […, AutoComplete]
})
export class ProjectTaskDetails {
constructor(@Inject(forwardRef(() => Project)) project, {
…
this.projectChangeSubscription = this.project.document.change.subscribe((data) => {
…
this.projectMilestones = data.milestones || [];
});
}
…
onMilestoneSelected(milestone) {
this.task.milestone = milestone;
this.project.document.persist();
}
onMilestoneCreated(milestone) {
this.project.document.data.milestones = this.project.document.data.milestones || [];
this.project.document.data.milestones.push(milestone);
this.task.milestone = milestone;
this.project.document.persist();
}
…
}
在项目更改的订阅中,我们现在还提取任何现有的项目里程碑,并将它们存储在projectMilestones成员变量中。这使得在模板中引用它们更容易。
onMilestoneSelected方法将被绑定到AutoComplete组件的selectItemChange输出属性上。我们使用AutoComplete组件发出的值来设置我们的任务里程碑,并使用其persist方法持久化LiveDocument项目。
onMilestoneCreated方法将被绑定到AutoComplete组件的itemCreated输出属性上。在这种情况下,我们将创建的里程碑添加到项目的里程碑列表中,并将当前任务分配给创建的里程碑。更新LiveDocument数据后,我们使用persist方法保存所有更改。
让我们查看lib/project/project-task-details/project-task-details.html,以查看模板中必要的更改:
…
<div class="task-details__content">
…
<ngc-auto-complete [items]="projectMilestones"
[selectedItem]="task?.milestone"
(selectedItemChange)="onMilestoneSelected($event)"
(itemCreated)="onMilestoneCreated($event)">
</ngc-auto-complete>
</div>
除了你已经知道的输出属性绑定之外,我们还为AutoComplete组件的items和selectedItem输入属性创建了两个输入绑定。
这就是全部了。我们创建了一个新的 UI 组件,提供了自动完成功能,并使用该组件在我们的任务中实现了里程碑管理。
使用具有适当封装的组件实现新功能突然变得如此简单,这不是很好吗?面向组件的开发的好处在于,你为新的功能开发时间随着你已创建的可重用组件的数量而减少。
摘要
在本章中,我们实现了一些帮助用户跟踪时间的组件。现在,他们可以在任务上记录努力,并在项目上管理里程碑。我们创建了一个新的任务详情视图,可以通过任务列表上的导航链接访问。
再次,我们体验到了使用组件进行组合的力量,通过重用现有组件,我们能够轻松实现提供更复杂功能的高级组件。
在下一章中,我们将探讨如何使用图表库 Chartist 并创建一些包装组件,使我们能够构建可重用的图表。我们将为我们的任务管理系统构建一个仪表板,在那里我们将看到我们的图表组件的实际应用。
第九章。太空船仪表盘
当我还是个孩子的时候,我喜欢扮演太空船飞行员。我把一些旧的纸箱堆叠起来,并装饰内部使其看起来像太空船驾驶舱。我用记号笔在纸箱的内侧画了一个太空船仪表盘,我记得我在那里玩了好几个小时。
舰桥和太空船仪表盘的设计特别之处在于,它们需要在非常有限的空间内提供对整个太空船的概述和控制。我认为这同样适用于应用程序仪表盘。仪表盘应该为用户提供对正在发生的事情的整体概述和感知。
在本章中,我们将为我们的任务管理应用程序创建这样一个仪表盘。我们将利用开源图表库 Chartist 创建外观美观的响应式图表,并提供对开放任务和项目状况的概述:

我们将在本章的进程中预览将要构建的任务图表
在更高层次上,在本章中我们将创建以下组件:
-
项目摘要:这是提供对整体项目状况快速洞察的项目摘要。通过聚合所有包含任务的努力,我们可以提供一个很好的整体努力状态,为此我们在上一章中创建了组件。
-
项目活动图表:没有标签或刻度,这个条形图将只给出过去 24 小时内项目活动的快速感知。
-
项目任务图表:此图表提供了项目任务进度的概述。我们将使用折线图显示一定时间内的开放任务数量。利用我们在本书第二章中创建的 Toggle 组件,我们将为用户提供一种简单的方法来切换图表上显示的时间范围。
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 对于视觉样式来说很棒,但在渲染图表的过程中有许多元素不能仅仅通过样式来控制。毕竟,这就是我们为什么使用 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 小时的时间范围内以柱状图的形式可视化活动。这将帮助我们的用户快速识别整体和峰值项目活动。
-
项目摘要:这是我们展示每个项目摘要的地方,其中概述了最重要的信息。我们的项目摘要组件还将包括一个活动图表组件,用于可视化项目活动。
-
项目仪表板:这个组件只是前两个组件的组合。这是我们仪表板中的主要组件。它代表我们的仪表板页面,并直接暴露给路由器。
创建项目仪表板组件
首先,我们将创建我们的主仪表板组件。ProjectsDashboard 组件只有两个职责:
-
获取用于创建仪表板的项目数据
-
通过包含我们的仪表板子组件来组合主仪表板布局
让我们直接进入并创建一个新的组件类,在路径 lib/projects-dashboard/projects-dashboard.js 上:
import {Component, ViewEncapsulation, Inject} from '@angular/core';
import template from './projects-dashboard.html!text';
import {ProjectService} from '../project/project-service/project-service';
@Component({
selector: 'ngc-projects-dashboard',
host: {class: 'projects-dashboard'},
template,
encapsulation: ViewEncapsulation.None
})
export class ProjectsDashboard {
constructor(@Inject(ProjectService) projectService) {
this.projects = projectService.change;
}
}
在我们的 dashboard 组件中,我们将直接使用 ProjectService 的变化可观察对象。这与我们通常处理可观察对象的方式不同。通常,我们会在组件中订阅可观察对象,并在数据流通过时更新我们的组件。然而,在我们的项目仪表板中,我们直接在我们的组件上存储 ProjectService 的变化可观察对象。
现在,我们可以使用 Angular 的一个异步核心管道来直接在我们的视图中订阅可观察对象。
直接在视图中暴露可观察对象并使用 async 管道来订阅可观察对象带来主要优势。
我们不需要在我们的组件中处理订阅和取消订阅,因为 async 管道将直接在视图中为我们完成这些操作。
当在可观察对象中发出新值时,async 管道将导致底层绑定被更新。此外,如果视图因任何原因被销毁,async 管道将自动取消订阅可观察对象。
小贴士
通过链式使用 RxJS 操作符,我们可以在不执行任何订阅的情况下将可观察流转换为所需的形状。然后,使用 async 管道,我们可以让视图来订阅和取消订阅转换后的可观察流。这鼓励我们编写纯净和无状态的组件,并且当正确使用时,这是一种很好的实践。
让我们看看在 Component 类所在的同一目录下的 projects-dashboard.html 文件中创建的组件视图:
<div class="projects-dashboard__l-header">
<h2 class="projects-dashboard__title">Dashboard</h2>
</div>
<div class="projects-dashboard__l-main">
<h3 class="projects-dashboard__sub-title">Projects</h3>
<ul class="projects-dashboard__list">
<li *ngFor="let project of projects | async">
<div>{{project.title}}</div>
<div>{{project.description}}</div>
</li>
</ul>
</div>
你可以从模板中看到,我们使用 async 管道来订阅 Component 类的 projects 可观察对象。async 管道最初将返回 null,但在任何可观察对象的变化时,这将返回订阅的解析值。这意味着我们不需要担心订阅我们的项目列表可观察对象。我们可以简单地利用 async 管道来订阅并在视图中直接解析。
目前,我们只显示了项目标题和描述,但在下一节中,我们将创建一个新的项目摘要组件,该组件将处理一些更复杂的渲染。
项目摘要组件
在本节中,我们将创建一个 project-summary 组件,该组件将为项目提供一些概述信息。除了标题和描述外,这还将包括对项目任务总努力的概述。
让我们首先构建组件并做好必要的准备,以便我们可以显示项目底层任务的总努力。
我们将从 lib/projects-dashboard/project-summary/project-summary.js 路径上的 Component 类开始:
...
import{FormatEffortsPipe} from '../../pipes/format-efforts';
import{EffortsTimeline} from '../../efforts/efforts-timeline/efforts-timeline';
import template from './project-summary.html!text';
@Component({
selector: 'ngc-project-summary',
host: { class: 'project-summary' },
template,
directives: [EffortsTimeline],
pipes: [FormatEffortsPipe],
encapsulation: ViewEncapsulation.None
})
export class ProjectSummary {
@Input() project;
ngOnChanges(changes) {
if (this.project) {
this.totalEfforts = this.project.tasks.reduce(
(totalEfforts, task) => {
if (task.efforts) {
totalEfforts.estimated += task.efforts.estimated || 0;
totalEfforts.effective += task.efforts.effective || 0;
}
returntotalEfforts;
}, {
estimated: 0,
effective: 0
});
}
}
}
如你可能已经猜到的,我们重用了在前一章中创建的 EffortsTimeline 组件。由于我们的项目摘要也将包括努力时间线,基于与总努力相同的语义,因此不需要为这个创建新的组件。
然而,我们需要做的是将所有任务努力累积到一个整体努力中。使用 Array.prototype.reduce 函数,我们可以相对容易地累积所有任务努力。
reduce 调用的结果对象需要符合预期的 efforts 对象的格式。作为初始值,我们将提供一个具有零估算时间和有效时间的 efforts 对象。然后,reduce 回调将添加项目中的任何任务努力值。
让我们来看看模板,看看我们将如何使用这些总努力数据来显示我们的 EffortsTimeline 组件:
<div class="project-summary__title">{{project?.title}}</div>
<div class="project-summary__description">
{{project?.description}}
</div>
<div class="project-summary__label">Total Efforts</div>
<ngc-efforts-timeline [estimated]="totalEfforts?.estimated"
[effective]="totalEfforts?.effective"
height="10"></ngc-efforts-timeline>
<p>{{totalEfforts | formatEfforts}}</p>
在显示项目的标题和描述后,我们包含了EffortsTimeline组件,并将其绑定到我们刚刚构建的totalEfforts成员。现在,这个时间线将显示在任务上记录的总聚合工作量。
除了时间线之外,我们还渲染了格式化的努力文本,就像我们在上一章的Efforts组件中已经渲染的那样。为此,我们使用了FormatEffortsPipe管道。
现在,我们还需要做什么呢?将我们的ProjectSummary组件集成到ProjectsDashboard组件中。
让我们看看projects-dashboard.html组件模板中的模板修改:
...
<li *ngFor="let project of projects | async">
<ngc-project-summary
[project]="project"
[routerLink]="['/projects', project._id]">
</ngc-project-summary>
</li>
...
您可以看到,我们将由NgFor指令与async管道一起创建的project局部视图变量绑定到ProjectSummary组件的project输入属性。
我们还使用了RouterLink指令来在用户点击其中一个摘要磁贴时导航到ProjectDetails视图。
ProjectsDashboard组件类中的修改微乎其微:
...
import {ROUTER_DIRECTIVES} from '@angular/router';
import{ProjectSummary} from './project-summary/project-summary';
...
@Component({
selector: 'ngc-projects-dashboard',
directives: [ProjectSummary, ROUTER_DIRECTIVES],
...
})
export class ProjectsDashboard {
...
}
我们对Component类所做的唯一修改是将ProjectSummary组件和ROUTER_DIRECTIVES常量添加到组件的指令列表中。ROUTER_DIRECTIVES常量包括RouterOutlet和RouterLink指令,我们在模板中使用后者。

显示两个项目摘要组件和聚合总工作量的项目仪表板
好的,到目前为止一切顺利。我们创建了两个新的组件,并重用了我们的EffortsTimeline组件来创建任务工作量的聚合视图。在下一节中,我们将用漂亮的 Chartist 图表丰富我们的ProjectSummary组件。
创建你的第一个图表
在本节中,我们将使用 Chartist 创建我们的第一个图表,以在过去 24 小时内提供项目活动概览。这个柱状图将只提供一些关于项目活动的视觉线索,我们的目标不是让它提供详细信息。因此,我们将配置它隐藏任何标签、刻度和网格线。唯一可见的部分应该是柱状图的柱子。
在开始创建活动图表之前,我们需要看看我们需要如何转换和准备我们的数据以供图表使用。
让我们看看我们系统中已有的数据。就活动而言,它们都在time字段上有一个时间戳。然而,对于我们的图表,我们希望显示其他内容。我们正在寻找的是显示过去 24 小时内每个小时的图表,每个柱子代表该时间段的活动数量。
以下插图显示了我们的源数据,它基本上是一系列活动事件的时序流。在下面的箭头中,我们看到我们需要用于我们图表的数据:

一个显示活动作为时间流的插图,其中点代表活动。下方的箭头显示了过去 24 小时的按小时光栅化计数。
让我们实现一个执行图中概述的转换的函数。我们将把这个函数添加到我们的time-utilities模块上的lib/utilities/time-utilities.js路径:
function rasterize(timeData, timeFrame, quantity, now, fill = 0) {
// Floor to a given time frame
now = Math.floor(now / timeFrame) * timeFrame;
returntimeData.reduce((out, timeData) => {
// Calculating the designated index in the rasterized output
const index = Math.ceil((now - timeData.time) / timeFrame);
// If the index is larger or equal to the designed rasterized
// array length, we can skip the value
if (index < quantity) {
out[index] = (out[index] || 0) + timeData.weight;
}
return out;
}, 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: 这是我们指定如何初始化我们的光栅化输出数组的方式。在我们的活动计数的情况下,我们希望将其设置为0。
我们刚刚创建的函数对于创建活动图表是必要的。这个转换帮助我们为图表的输入数据准备项目活动。
是时候创建我们的第一个图表组件了!让我们从在lib/projects-dashboard/project-summary/activity-chart/activity-chart.html路径上创建的新模板开始:
<div #chartContainer></div>
由于我们将所有渲染都交给 Chartist,这实际上已经是我们需要的一切。Chartist 需要一个元素作为容器来创建图表。我们设置一个chartContainer本地视图引用,以便我们可以从我们的组件中引用它,然后将其传递给 Chartist。
让我们继续创建图表,通过在模板相同的目录下创建activity-chart.js中的Component类来完善活动图表组件:
...
import Chartist from 'chartist';
import {rasterize, UNITS} from '../../../utilities/time-utilities';
@Component({
selector: 'ngc-activity-chart',
...
})
export class ActivityChart {
@Input() activities;
@ViewChild('chartContainer') chartContainer;
ngOnChanges() {
this.createOrUpdateChart();
}
ngAfterViewInit() {
this.createOrUpdateChart();
}
...
}
注意
Chartist 适用于几乎所有包管理器,并且它还以UMD模块格式(通用模块格式)捆绑提供,实际上这是一个包装器,用于启用AMD(异步模块定义)、CommonJS 模块格式和全局导出。
使用 JSPM,我们可以在命令行上执行以下命令来简单地安装 Chartist:
jspm install chartist
安装 Chartist 后,我们可以直接使用 ES6 模块语法导入它。
我们还导入了我们创建的光栅化函数,以便我们可以稍后使用它将我们的活动转换为图表预期的输入格式。
由于我们依赖于视图子元素作为容器元素来创建我们的图表,我们需要等待AfterViewInit生命周期钩子来构建图表。同时,如果输入的activities发生变化,我们还需要重新渲染图表。使用OnChanges生命周期钩子,我们可以对输入变化做出反应并更新我们的图表。
现在,让我们看看createOrUpdateChart函数,它确实如其名称所暗示的那样:
createOrUpdateChart() {
if (!this.activities || !this.chartContainer) {
return;
}
consttimeData = this.activities.map((activity) => {
return {
time: activity.time,
weight: 1
};
});
const series = [
rasterize(
timeData,
UNITS.find((unit) => unit.short === 'h').milliseconds,
24,
+new Date())
];
if (this.chart) {
this.chart.update({ series });
} else {
this.chart = new Chartist.Bar(this.chartContainer.nativeElement, {
series
}, {
width: '100%',
height: 60,
axisY: {
onlyInteger: true,
showGrid: false,
showLabel: false,
offset: 0
},
axisX: {
showGrid: false,
showLabel: false,
offset: 0
},
chartPadding: 0
});
}
}
让我们更详细地查看代码,并一步一步地走一遍:
-
由于我们既从
AfterViewInit又从OnChanges生命周期中被调用,我们需要确保在继续之前chartContainer和activities输入都已就绪。 -
现在,是我们将接收到的活动数据转换为所需的栅格化形式的时候了,这是我们想要创建的图表所需的。我们使用
Array.prototype.map将我们的活动转换为timeData对象,这些对象是rasterize函数所需的。我们还传递必要的参数,以便函数将栅格化到 24 帧,每帧包含一小时。 -
如果
chart成员已经设置为之前创建的图表,我们可以使用 Chartist 图表对象的update函数来仅用新数据更新。 -
如果还没有图表对象,我们需要创建一个新的图表。作为
Chartist.Bar构造函数的第一个参数,我们将传递容器视图子元素的 DOM 元素引用。Chartist 将在该容器元素中创建我们的图表。第二个参数是我们的数据,我们将用刚刚生成的系列填充它。在图表选项中,我们将设置一切以实现一个非常简单的图表,没有任何详细信息。
这太棒了!我们使用 Chartist 创建了第一个图表组件!现在,我们可以回到我们的ProjectSummary组件,并在其中集成活动图表以提供活动概述:
...
import {ActivityService} from '../../activities/activity-service/activity-service';
import {ActivityChart} from './activity-chart/activity-chart';
@Component({
selector: 'ngc-project-summary',
...
directives: [EffortsTimeline, ActivityChart],
...
})
export class ProjectSummary {
...
constructor(@Inject(ActivityService) activityService) {
this.activityService = activityService;
}
ngOnChanges() {
if (this.project) {
...
this.activities = this.activityService.change
.map((activities) => activities.filter((activity) => activity.subject === this.project._id));
}
}
}
这里的第一个变化是包含ActivityService,这样我们就可以提取所需的计划活动并将它们传递给ActivityChart组件。我们还需要导入ActivityChart组件,并在组件中将其声明为指令。
由于我们的组件依赖于作为输入提供的项目,该项目可能会发生变化,因此我们需要在组件的OnChanges生命周期钩子中实现提取活动的逻辑。
在我们将活动流传递之前,我们需要过滤通过流来的活动,以便我们只得到与当前项目相关的活动。我们还将使用async管道来订阅活动,这样就不需要在组件中使用subscribe形式。activities属性将直接设置为过滤后的Observable。
让我们看看ProjectSummary组件视图中的变化,以便启用我们的图表:
...
<div class="project-summary__label">Activity last 24 hours</div>
<ngc-activity-chart [activities]="activities | async">
</ngc-activity-chart>
我们在现有模板的底部添加我们的ActivityChart组件。我们还创建了必要的绑定,将我们的活动传递到组件中。使用async管道,我们可以解析可观察的流并将过滤后的活动列表传递到chart组件中。
最后,我们的ProjectSummary组件看起来很棒,并立即通过显示聚合的努力时间线和漂亮的活动图表来提供项目洞察。在下一节中,我们将更深入地探讨 Chartist 的图表功能,并且我们还将使用 Angular 提供一些交互性。
可视化打开的任务
在本节中,我们将使用 Chartist 创建一个图表组件,该组件将显示项目随时间推移的打开任务进度。为此,我们将使用具有特定插值的折线图,该插值提供量化步骤而不是直接连接点的线条。
我们还提供了一些交互性,用户将能够使用切换按钮切换显示的时间范围。这允许我们重用我们在本书第二章“准备,出发,行动!”中创建的Toggle组件。
让我们先看看我们系统中有的数据以及我们如何将其转换成 Chartist 所需的数据。
我们可以依靠我们的任务的两个数据属性来将它们绘制到时间线上。created属性设置为任务创建时的戳记。如果任务被标记为完成,则done属性设置为那个时间的时间戳。
由于我们只对任何给定时间的打开任务数量感兴趣,我们可以安全地假设一个模型,其中我们将所有任务放在单个时间线上,并且我们只关心作为事件的created和done时间戳。让我们看看以下插图以更好地理解问题:

一个插图展示了我们如何使用创建和完成事件在单个时间线上表示所有任务的时间线。创建事件计为+1,而完成事件计为-1。
下箭头表示时间线上所有created和done事件的任务。现在我们可以使用这些信息作为输入到我们的rasterize函数中,以获取我们图表所需的数据。由于用作光栅化的输入的timeData对象也支持weight属性,我们可以使用它来表示created (+1)或done (-1)事件。
我们需要对我们的 rasterize 函数进行轻微的修改。到目前为止,rasterize 函数只按帧将事件一起计数。然而,对于打开的任务计数,我们查看时间上的累积。如果任务计数发生变化,我们需要保持该值直到它再次变化。在前面主题中活动转换时,我们没有使用这种相同的逻辑。在那里,我们只计算帧内的事件,但没有累积。
让我们看一下以下插图,以了解与我们在处理活动时应用的光栅化相比的差异:

一个说明我们如何随时间累积开放任务计数的插图
我们可以随时间一起计算timeData对象(事件)的每个weight属性。只有当累积值发生变化时,我们才会将当前的累积值写入光栅化输出数组。
让我们打开我们的time-utilities模块,并将更改应用到rasterize函数:
export function rasterize(timeData, timeFrame, quantity,
now = +new Date(), fill = 0,
accumulate = false) {
// Floor to a given time frame
now = Math.floor(now / timeFrame) * timeFrame;
// Accumulation value used for accumulation mode to keep track
// of current value
let accumulatedValue = 0;
// In accumulation mode we need to be sure that the time data
// is ordered
if (accumulate) {
timeData = timeData.slice().sort(
(a, b) => a.time < b.time ? -1 : a.time > b.time ? 1 : 0);
}
return timeData.reduce((rasterized, timeData) => {
// Increase the accumulated value, in case we need it
accumulatedValue += timeData.weight;
// Calculating the designated index in the rasterized output
// array
const index = Math.ceil((now - timeData.time) / timeFrame);
// If the index is larger or equal to the designed rasterized
// array length, we can skip the value
if (index < quantity) {
rasterized[index] = accumulate ?
accumulatedValue :
(rasterized[index] || 0) + timeData.weight;
}
return rasterized;
}, Array.from({length: quantity}).fill(fill)).reverse();
}
让我们回顾一下我们对rasterize函数所做的更改,以允许累积框架:
-
首先,我们在函数中添加了一个名为
accumulate的新参数。我们使用 ES6 默认参数来设置当函数被调用而没有传入值时参数为false。 -
我们现在定义了一个新的
accumulatedValue变量,并将其初始化为0。这个变量将用于跟踪随时间所有weight值的总和。 -
下一段代码非常重要。如果我们想随时间累积所有
weight值的总和,我们需要确保这些值按顺序到来。为了确保这一点,我们按timeData列表的time属性对它进行排序。 -
在
reduce回调中,我们将当前timeData对象的weight值增加到accumulatedValue变量上。 -
如果
timeData对象落入一个光栅化框架中,我们不会像之前那样增加这个框架的计数。在累积模式下,我们将框架计数设置为accumulatedValue中的当前值。这将导致所有变化的累积值都反映在光栅化输出数组中。
这是我们处理数据以渲染我们的开放任务图表所需的所有准备工作。让我们继续前进,创建我们新的chart组件的Component类。
创建开放任务图表
在以下组件中,我们将利用之前主题中重构的rasterize函数。使用新的累积函数,我们现在可以跟踪随时间变化的开放任务计数。
让我们从新文件lib/projects-dashboard/tasks-chart/tasks-chart.js中的Component类开始,以实现我们的Component类:
...
import Chartist from 'chartist';
import Moment from 'moment';
import {rasterize} from '../../utilities/time-utilities';
@Component({
selector: 'ngc-tasks-chart',
...
})
export class TasksChart {
@Input() projects;
@ViewChild('chartContainer') chartContainer;
ngOnChanges() {
this.createOrUpdateChart();
}
ngAfterViewInit() {
this.createOrUpdateChart();
}
...
}
到目前为止,这看起来就像我们的第一个chart组件,我们在其中可视化了项目活动。我们也导入了 Chartist,因为我们将在不久后创建的createOrUpdateChart函数中使用它来渲染我们的图表。我们将创建的图表将包含更多详细的信息。我们将渲染轴标签和一些刻度。为了格式化基本上包含时间戳的标签,我们再次使用 Moment.js 库。
我们还使用projects输入数据,并通过修改后的rasterize实用函数对其进行转换,以便为我们的折线图准备所有数据。
让我们继续完善组件的createOrUpdateChart方法:
createOrUpdateChart() {
if (!this.projects || !this.chartContainer) {
return;
}
// Create a series array that contains one data series for each
// project
const series = this.projects.map((project) => {
// First we need to reduces all tasks into one timeData list
const timeData = project.tasks.reduce((timeData, task) => {
// The created time of the task generates a timeData with
// weight 1
timeData.push({
time: task.created,
weight: 1
});
// If this task is done, we're also generating a timeData
// object with weight -1
if (task.done) {
timeData.push({
time: task.done,
weight: -1
});
}
return timeData;
}, []);
// Using the rasterize function in accumulation mode, we can
// create the required data array that represents our series
// data
return rasterize(timeData, 600000, 144, +new Date(),
null, true);
});
const now = +new Date();
// Creating labels for all the timeframes we're displaying
const labels = Array.from({
length: 144
}).map((e, index) => now - index * 600000).reverse();
if (this.chart) {
// If we already have a valid chart object, we can simply
// update the series data and labels
this.chart.update({
series,
labels
});
} else {
// Creating a new line chart using the chartContainer element
// as container
this.chart = new Chartist.Line(this.chartContainer.nativeElement, {
series,
labels
}, {
width: '100%',
height: 300,
// Using step interpolation, we can cause the chart to
// render in steps instead of directly connected points
lineSmooth: Chartist.Interpolation.step({
// The fill holes setting on the interpolation will cause
// null values to be skipped and makes our line to
// connect to the next valid value
fillHoles: true
}),
axisY: {
onlyInteger: true,
low: 0,
offset: 70,
// We're using the label interpolation function for
// formatting our open tasks count
labelInterpolationFnc: (value) => `${value} tasks`
},
axisX: {
// We're only displaying two x-axis labels and grid lines
labelInterpolationFnc: (value, index, array) => index % (144 / 4) === 0 ? Moment(value).calendar() : null
}
});
}
}
好吧,这里有很多代码。让我们一步一步地走一遍,以便更好地理解正在发生的事情:
-
首先,我们需要通过映射项目列表来创建我们的转换后的系列数据。系列数组应该包括每个项目的数据数组。每个数据数组将包含随时间变化的开放项目任务。
-
由于
rasterize函数期望一个timeData对象的列表,我们首先需要将项目任务列表转换成这种格式。通过减少任务列表,我们创建了一个包含单个timeData对象的列表。reduce函数回调将为每个任务生成一个具有weight值为 1 的timeData对象。此外,它将为标记为具有weight值-1 的每个任务生成一个timeData对象。这将产生所需的timeData数组,我们可以使用它来累积和光栅化。 -
在准备完
timeData列表后,我们可以调用rasterize函数来创建一定时间段内的开放任务列表。我们使用 10 分钟的时间段(600000 毫秒)并使用 144 帧进行光栅化。这总共是 24 小时。 -
除了系列数据外,我们还需要为我们的图表提供标签。我们创建了一个新的数组,并用 144 个时间戳初始化这个数组,所有这些时间戳都设置为显示在图表上的 144 个光栅化帧的开始。
-
现在,我们已经准备好了系列数据和标签,接下来要做的就是渲染我们的图表。
-
使用
lineSmooth配置,我们可以为我们的折线图指定一种特殊的插值。步进插值不会直接连接我们折线图中的每个点,而是会以离散的步骤从一个点到另一个点移动。这正是我们渲染开放任务随时间变化所需的方法。 -
在步骤插值中将
fillHoles选项设置为true非常重要。使用此设置,我们实际上可以告诉 Chartist 它应该关闭数据(实际上为 null 值)中的任何间隙,并将线条连接到下一个有效值。如果没有此设置,我们会在数据数组中的任务计数变化之间在图表上看到间隙。 -
在我们的代码中,最后一项重要的事情是在x轴配置上设置的
labelInterpolationFnc选项。此函数不仅可以用来格式化标签或插值可能伴随的任何表达式,而且还允许我们返回 null。从这个函数返回 null 将导致 Chartist 跳过给定的标签和相应的网格线。如果我们想通过值或标签的索引跳过某些标签,这将非常有用。在我们的代码中,我们确保只渲染所有 144 个生成的标签中的四个。
让我们来看看我们组件在tasks-chart.html文件中的相对简单的模板,这个文件与我们的Component类文件位于同一文件夹中:
<div #chartContainer class="tasks-chart__container"></div>
与ActivityChart组件类似,我们只创建了一个简单的图表容器元素,这个元素我们已经在Component类中引用。
这基本上就是我们创建使用 Chartist 的开放任务图表所需做的所有事情。然而,这里还有一些改进的空间:

使用我们的任务图表组件和 Chartist 的步进插值可视化开放任务
创建图表图例
目前,我们无法确切知道哪条线代表哪个项目。我们能看到每个项目有一条彩色线,但我们无法将这些颜色关联起来。我们需要的是一个简单的图例,帮助我们的用户将折线图的颜色与项目关联起来。
让我们看看实现图例所需的代码更改。在我们的TasksChart组件的Component类中,我们需要执行以下修改:
...
export class TasksChart {
...
ngOnChanges() {
if (this.projects) {
// On changes of the projects input, we need to update the
// legend
this.legend = this.projects.map((project, index) => {
return {
name: project.title,
class: `tasks-chart__series--series-${index + 1}`
};
});
}
this.createOrUpdateChart();
}
...
}
在OnChanges生命周期钩子中,我们将项目输入映射到一个包含name和class属性的对象列表中,这将支持我们在图例中渲染简单的图例。模板字符串tasks-chart__series--series-${index + 1}将生成渲染图例中正确颜色的必要类名。
使用这个图例信息,我们现在可以继续实施必要的模板更改,以在我们的chart组件中渲染图例:
<ul class="tasks-chart__series-list">
<li *ngFor="let series of legend"
class="tasks-chart__series {{series.class}}">
{{series.name}}
</li>
</ul>
<div #chartContainer class="tasks-chart__container"></div>
嗯,这很简单,对吧?然而,结果证明了一切。我们仅用几分钟就为图表创建了一个漂亮的图例:

带有我们添加的图例的开放任务图表
使任务图表交互式
目前,我们硬编码了开放任务图表的时间范围为 144 帧,每帧 10 分钟,总共显示给用户 24 小时。然而,也许我们的用户想要改变这个视图。
在这个主题中,我们将使用我们的Toggle组件创建一个简单的输入控制,允许我们的用户更改图表的时间范围设置。
我们将提供以下视图作为选项:
-
日:这个视图将渲染 144 帧,每帧 10 分钟,总共 24 小时
-
周:这个视图将渲染 168 帧,每帧一小时,总共七天
-
年:这个视图将渲染 360 帧,每帧代表一整天
让我们从修改TasksChart组件的Component类代码开始,实现我们的时间范围切换功能:
...
import {Toggle} from '../../ui/toggle/toggle';
@Component({
...
directives: [Toggle]
})
export class TasksChart {
...
constructor() {
// Define the available time frames within the chart provided
// to the user for selection
this.timeFrames = [{
name: 'day',
timeFrame: 600000,
amount: 144
}, {
name: 'week',
timeFrame: 3600000,
amount: 168
}, {
name: 'year',
timeFrame: 86400000,
amount: 360
}];
// From the available time frames, we're generating a list of
// names for later use within the Toggle component
this.timeFrameNames
= this.timeFrames.map((timeFrame) => timeFrame.name);
// The currently selected timeframe is set to the first
// available one
this.selectedTimeFrame = this.timeFrames[0];
}
...
createOrUpdateChart() {
...
const series = this.projects.map((project) => {
...
return rasterize(timeData,
this.selectedTimeFrame.timeFrame,
this.selectedTimeFrame.amount,
+new Date(), null, true);
});
const now = +new Date();
const labels = Array.from({
length: this.selectedTimeFrame.amount
}).map((e, index) => now - index * this.selectedTimeFrame.timeFrame).reverse();
...
}
...
// Called from the Toggle component if a new timeframe was
// selected
onSelectedTimeFrameChange(timeFrameName) {
// Set the selected time frame to the available timeframe with
// the name selected in the Toggle component
this.selectedTimeFrame =
this.timeFrames.find((timeFrame) =>
timeFrame.name === timeFrameName);
this.createOrUpdateChart();
}
}
让我们简要地回顾一下这些更改:
-
首先,我们在
Component类中添加了一个构造函数,在其中初始化了三个新的成员。timeFrames成员被设置为时间范围描述对象数组。它们包含name、timeFrame和amount属性,这些属性随后用于计算。timeFrameNames成员包含时间范围名称列表,该列表直接从timeFrames列表派生。最后,我们有一个selectedTimeFrame成员,它简单地指向第一个可用的时间范围对象。 -
在
createOrUpdateChart函数中,我们不再依赖于硬编码的任务计数光栅化值,而是引用selectedTimeFrame对象中的数据。通过更改此对象引用并再次调用createOrUpdateChart函数,我们现在可以动态地切换底层数据的视图。 -
最后,我们添加了一个新的
onSelectedTimeFrameChange方法,它作为对Toggle组件的绑定,并且将在用户选择不同的时间范围时被调用。
让我们看看必要的模板更改,以启用时间范围的切换:
<ngc-toggle
[buttonList]="timeFrameNames"
[selectedButton]="selectedTimeFrame.name"
(selectedButtonChange)="onSelectedTimeFrameChange($event)">
</ngc-toggle>
...
<div #chartContainer class="tasks-chart__container"></div>
从绑定到Toggle组件,你可以看出我们依赖于组件上的timeFrameNames成员来表示所有可选的时间范围。我们还使用selectedTimeFrame.name属性绑定到Toggle组件的selectedButton输入属性。当Toggle组件中选定的按钮发生变化时,我们调用onSelectedTimeFrameChange函数,在那里时间范围被切换,图表被更新。
这是我们需要的一切,以启用在图表上切换时间范围。现在用户可以选择按年、周和日查看。
我们的TasksChart组件现在已准备好集成到我们的仪表板中。我们可以通过修改ProjectsDashboard组件的模板来实现这一点:
...
<div class="projects-dashboard__l-main">
<h3 class="projects-dashboard__sub-title">Tasks Overview</h3>
<div class="projects-dashboard__tasks">
<ngc-tasks-chart [projects]="projects | async">
</ngc-tasks-chart>
</div>
...
</div>
这基本上是我们需要做的所有事情,在此更改之后,我们的仪表板包含了一个显示随时间推移的开放任务计数的漂亮图表。
在TasksChart项目输入属性的绑定中,我们再次使用async管道直接在视图中解析项目观察流。
摘要
在本章中,我们学习了 Chartist 及其如何与 Angular 结合使用来创建外观美观且功能齐全的图表。我们可以利用两个世界的力量,创建可重用的封装良好的图表组件。
就像在大多数实际案例中一样,我们总是有很多数据,但在特定情况下我们只需要其中一部分。我们学习了如何将现有数据转换成适合视觉表示的形式。
在下一章中,我们将探讨如何在应用程序中构建插件系统。这将允许我们开发打包成插件的便携式功能。我们的插件系统将动态加载新插件,我们将使用它来开发一个简单的敏捷估算插件。
第十章. 使事物可插件化
我非常喜爱插件架构。除了它们对应用程序和范围管理产生的巨大积极影响外,它们在开发过程中也非常有趣。我建议任何向我询问的人都将插件架构集成到他们的库或应用程序中。一个好的插件架构允许你编写简洁的应用程序核心,并通过插件提供额外的功能。
以一种允许你构建插件架构的方式设计整个应用程序,这对系统的可扩展性有很大影响。这是因为你使应用程序易于扩展,但关闭了修改。
在编写我的开源项目时,我也发现插件架构有助于你管理项目的范围。有时,请求的功能非常好且非常有用,但它仍然会使库核心膨胀。与其用这样的功能使整个应用程序或库膨胀,你不如简单地编写一个插件来完成这项工作。
在本章中,我们将创建自己的插件架构,这将帮助我们扩展应用程序的功能,而不会使其核心膨胀。我们首先将在应用程序的核心中构建插件 API,然后使用该 API 实现一个小巧的敏捷插件,帮助我们使用故事点来估算任务。
在本章中,我们将涵盖以下主题:
-
基于 Angular 生态系统的插件架构设计
-
实现基于装饰器的插件 API
-
使用
ComponentResolver和ViewContainerRef将插件组件实例化到我们应用程序中预定义的槽位 -
使用 SystemJS 实现插件加载机制
-
在我们的插件架构中使用响应式方法以实现即插即用风格的插件
-
使用新的插件 API 实现一个敏捷插件来记录故事点
插件架构
在更高层次上,插件架构应至少满足以下要求:
-
可扩展性:插件背后的主要思想是允许使用隔离的代码包扩展核心功能。一个出色的插件架构允许你无缝地扩展核心,而不会引起明显的性能损失。
-
可移植性:插件应足够隔离,以便在运行时将其插入到系统中。不应有必要重建系统以启用插件。理想情况下,插件甚至可以在运行时任何时候加载。它们可以被停用和激活,并且不应导致系统运行到不一致的状态。
-
可组合性:插件系统应允许并行使用多个插件,并允许通过组合多个插件来扩展系统。理想情况下,系统还应包括依赖管理、插件版本管理和插件间通信。
实现插件架构的方法有很多。尽管这些方法可能差异很大,但几乎总是有一个机制在位,提供统一的扩展点。没有这个机制,统一扩展系统将会很困难。
我过去曾与一些插件架构合作过,除了使用现有的插件机制外,我还享受自己设计一些插件。以下列表应该能提供一个关于在设计插件系统时可以采用的一些方法的思路:
-
领域特定语言(DSL):使用领域特定语言是实现可插拔架构的一种方式。在你实现了应用程序的核心之后,你可以开发一个 API,甚至是一种脚本语言,允许你使用这种 DSL 进一步开发功能。许多视频游戏引擎和 CG 应用程序都依赖于这种方法。尽管这种方法非常灵活,但它也可能迅速导致性能问题,并容易引入复杂性。通常,实现这种架构的先决条件是将非常低级别的核心操作(如添加 UI 元素、配置流程等)暴露到 DSL 中,这并不提供清晰的边界和扩展点,但非常灵活。基于 DSL 的插件系统的一些例子包括 Adobe 的大部分 CG 应用程序、3D Studio Max 和 Maya,以及游戏引擎,如 Epic Games 的 Unreal Engine 或 Bohemia Interactive Studio 的 Real Virtuality Engine。
-
核心是插件系统:另一种方法是构建一个复杂的插件架构,使其满足前面列表中概述的所有要求(可扩展性、可移植性和可组合性),甚至还有一些更复杂的要求。应用程序的核心是一个庞大的插件系统。然后,你开始将一切作为插件来实现。甚至应用程序的核心关注点也将作为插件实现。这种方法的一个完美例子是 Eclipse IDE 及其 Equinox 核心。这种方法的缺点是,随着应用程序的增长,你可能会遇到性能问题。由于一切都是插件,优化相当困难,插件兼容性可能会使应用程序变得不稳定。
-
基于事件的扩展点:另外,提供系统扩展性的一个很好的方法是通过向系统开放外部输入的管道。想象一下,对于你应用程序中的每一个重要步骤,你都会通知外部世界这个步骤,并在应用程序继续处理之前允许拦截。以这种方式,插件将仅仅是一个适配器,它监听应用程序的这些管道事件,并根据需要修改行为。插件本身也可以发出事件,然后可以被其他插件再次处理。这种架构非常灵活,因为它允许你在不引入太多复杂性的情况下更改核心功能的行为。即使在完成核心功能后没有考虑插件系统,这种方法也相当容易实现。我在我的开源项目 Chartist 中一直遵循这种方法,到目前为止,我取得了非常好的效果。
-
插件接口:一个应用程序可以公开一组接口,这些接口定义了某些扩展点。这种方法在 Java 框架中得到了广泛的应用,被称为服务提供者接口(SPI)。提供者实现一定的合同,允许核心系统依赖于接口而不是实现。然后,这些提供者可以循环回到系统中,在那里它们被提供给框架和其他提供者。尽管这可能是在统一性方面提供扩展性的最安全方式,但它也是最僵化的。插件永远不会被允许执行合同中未指定的任何其他操作。
你可以看到,所有四种方法差异很大。从最顶层的,它以复杂性和稳定性为代价提供了极端的灵活性,到最底层的,它非常健壮但也非常僵化。
实现插件系统时选择的方法在很大程度上取决于你应用程序的需求。如果你不打算构建一个包含各种风味的应用程序,其中应该存在针对完全不同关注点的多个版本,那么前面列表中的方法可能更可能是你应该遵循的方法。
可插入的 UI 组件
在本章中我们将要构建的系统借鉴了 Angular 框架中已经存在的许多机制。为了使用插件实现扩展性,我们依赖于以下核心概念:
-
我们使用指令来指示 UI 中的扩展点,我们称之为插件槽。这些插件槽指令将负责动态实例化插件组件,并将它们插入到应用程序 UI 的指定位置。
-
插件通过我们称之为插件放置的概念来公开组件。插件放置声明了插件中的哪些组件应该放置到应用程序中的哪个插件槽位中。我们还使用插件放置来决定来自不同插件的组件应按何种顺序插入到插件槽位中。为此,我们将使用一个名为优先级的属性。
-
我们使用 Angular 的依赖注入来提供已实例化的插件信息到插件组件中。由于插件组件将被放置在一个已经存在注入器的地方,因此它们将能够注入周围的组件和依赖项,以便连接到应用程序。
在我们开始实现它之前,让我们看一下以下插图,以了解我们插件系统的架构:

我们将在本章中使用一些基本的 UML 和基数注释来实现插件架构
让我们快速查看这个图中的不同实体,并简要解释它们的作用:
-
PluginConfig:这是一个 ES7 装饰器,在实现插件时是关键元素。通过使用此装饰器注释插件类,我们可以存储有关插件元信息,这些信息将在以后由我们的插件系统使用。元数据包括插件名称、描述和位置信息。 -
PluginData:这是一个聚合类,由插件系统用于将关于已实例化插件的详细信息与位置信息(插件组件应实例化的位置)耦合。一旦创建插件组件,该实体就会在依赖注入中公开。任何插件组件都可以使用这个实体来收集有关实例化的信息或获取对插件实例的访问权限。 -
PluginService:这是用于将我们的插件系统粘合在一起的服务。它主要用于加载插件、删除插件,或由PluginSlot指令用于收集与插件槽位创建相关的插件组件。 -
PluginSlot:这个指令用于标记我们应用程序中的 UI 扩展点。无论我们希望在哪个位置使插件能够钩入我们的应用程序用户界面,我们都会放置这个指令。插件槽位需要命名,插件使用位置信息通过名称引用槽位。这样,一个插件可以为我们的应用程序中的不同槽位提供不同的组件。 -
PluginComponent:这些是随插件实现捆绑的常规 Angular 组件。一个插件可以通过使用PluginPlacement对象在插件上配置多个组件。 -
PluginPlacement:在插件配置中使用,当一个插件可以有多个放置配置时。每个放置实体包括对一个组件的引用、组件应实例化的槽位名称以及一个优先级数字,这有助于插件系统在多个组件在同一个槽位中实例化时正确地排序插件组件。 -
Plugin:这是实现插件时的实际插件类。该类包含使用PluginConfig装饰器注解的插件配置。插件类在应用程序中仅实例化一次,并且通过 Angular 的依赖注入与插件组件共享。因此,这个类也是插件组件之间共享数据的好地方。
现在,我们对将要构建的内容有一个更高层次的概述。我们的插件系统非常基础,但它将支持诸如热加载插件(即插即用风格)和其他优秀功能。在下一个主题中,我们将开始实现插件 API 的核心组件。
实现插件 API
让我们从我们插件 API 中较简单的实体开始。我们在lib/plugin/plugin.js文件中创建一个新的文件来创建PluginConfig装饰器和PluginPlacement类,这些类存储了插件组件应放置的信息。我们还在这个文件中创建了PluginData类,该类用于将插件运行时信息注入到插件组件中:
export function PluginConfig(config) {
return (type) => {
type._pluginConfig = config;
};
}
PluginConfig装饰器包含接受配置参数的非常简单的逻辑,然后该参数将被存储在注解的类(构造函数)上的_pluginConfig属性中。如果你需要复习装饰器的工作原理,现在可能是阅读第一章中关于装饰器的主题,即组件化用户界面的好时机:
export class PluginPlacement {
constructor(options) {
this.slot = options.slot;
this.priority = options.priority;
this.component = options.component;
}
}
PluginPlacement类代表配置对象,用于将插件组件暴露到应用程序 UI 中的不同插件槽位:
export class PluginData {
constructor(plugin, placement) {
this.plugin = plugin;
this.placement = placement;
}
}
PluginData类代表在插件实例化期间创建的插件运行时信息以及一个PluginPlacement对象。这个类将由PluginService使用,以将有关插件组件的信息传达给应用程序中的插件槽位。
这三个类是实现插件时的主要交互点。
让我们看看一个简单的插件示例,以了解我们如何使用PluginConfig装饰器和PluginPlacement类来配置一个插件:
@PluginConfig({
name: 'my-example-plugin',
description: 'A simple example plugin',
placements: [
new PluginPlacement({
slot: 'plugin-slot-1',
priority: 1,
component: PluginComponent1
}),
new PluginPlacement({
slot: 'plugin-slot-2',
priority: 1,
component: PluginComponent2
})
]
})
export default class ExamplePlugin {}
使用PluginConfig装饰器,实现一个新的插件变得非常简单。我们在设计时决定名称、描述以及我们希望在应用程序中放置插件组件的位置。
我们的插件系统使用命名的 PluginSlot 指令来指示我们应用程序组件树中的扩展点。在 PluginPlacement 对象中,我们引用插件中内置的 Angular 组件,并通过引用插件槽名称来指示它们应在哪个槽中放置。放置的优先级将告诉插件槽在创建时如何对插件组件进行排序。当不同插件的组件在同一个插件槽中创建时,这一点变得很重要。
好的,让我们直接深入到我们的插件架构的核心,通过实现插件服务。我们将创建一个新的 lib/plugin/plugin-service.js 文件并创建一个新的 PluginService 类:
import {Injectable} from '@angular/core';
import {ReplaySubject} from 'rxjs/Rx';
@Injectable()
export class PluginService {
...
}
由于我们将创建一个可注入的服务,我们将使用 @Injectable 注解来注释我们的 PluginService 类。我们使用 RxJS 的 ReplaySubject 类型来在激活插件的任何更改上发出事件。
让我们看看我们服务的构造函数:
constructor() {
this.plugins = [];
// Change observable if the list of active plugin changes
this.change = new ReplaySubject(1);
this.loadPlugins();
}
首先,我们初始化一个新的空 plugins 数组。这将是我们活动插件的列表,它包含运行时插件数据,例如插件加载的 URL、插件类型(类的构造函数)、指向存储在插件上的配置的快捷方式(由 PluginConfig 装饰器创建)以及最后,插件类的实例本身。
我们还添加了一个 change 成员,我们使用新的 RxJS ReplaySubject 进行初始化。我们将使用此主题在它改变时发出活动插件的列表。这允许我们以响应式的方式构建插件系统,并启用即插即用风格的插件。
作为构造函数中的最后一个操作,我们调用服务的 loadPlugins 方法。这将执行带有已注册插件的初始加载:
loadPlugins() {
System.import('/plugins.js').then((pluginsModule) => {
pluginsModule.default.forEach(
(pluginUrl) => this.loadPlugin(pluginUrl)
);
});
}
loadPlugins 方法异步地从我们应用程序的根路径使用 SystemJS 加载名为 plugins.js 的文件。期望 plugins.js 文件默认导出一个数组,该数组包含预配置的插件路径,这些插件应在应用程序启动时加载。这允许我们配置我们已知并希望默认存在的插件。使用单独的异步加载文件进行此配置使我们能够更好地从主应用程序中分离出来。我们可以运行相同的应用程序代码,但使用不同的 plugins.js 文件,并通过控制默认应存在的插件来控制。
然后,loadPlugins 方法通过调用 loadPlugin 方法使用 plugins.js 文件中存在的 URL 加载每个插件:
loadPlugin(url) {
return System.import(url).then((pluginModule) => {
const Plugin = pluginModule.default;
const pluginData = {
url,
type: Plugin,
// Reading the meta data previously stored by the @Plugin
// decorator
config: Plugin._pluginConfig,
// Creates the plugin instance
instance: new Plugin()
};
this.plugins = this.plugins.concat([pluginData]);
this.change.next(this.plugins);
});
}
loadPlugin 方法负责加载和实例化单个插件模块。它将插件模块的 URL 作为参数,并使用 System.import 动态加载插件模块。使用 System.import 来完成这项工作的好处是,我们可以加载已存在于捆绑应用程序中的模块,以及通过 HTTP 请求加载远程 URL。这使得我们的插件系统非常便携,我们甚至可以在运行时从不同的服务器、NPM 或甚至 GitHub 加载模块。当然,SystemJS 也支持不同的模块格式,如 ES6 模块或 CommonJS 模块,如果模块尚未转换,还支持不同的转换器。
在插件模块成功加载后,我们将有关加载的插件的所有信息捆绑到一个 pluginData 对象中。然后我们可以将此信息添加到我们的 plugins 数组中,并在我们的 ReplaySubject 上发出一个新事件,以通知感兴趣的各方关于更改的消息。
最后,我们需要一个方法来收集所有插件中的 PluginPlacement 数据,并按槽位名称进行过滤。当我们的插件槽位需要知道它们应该实例化哪些组件时,这一点很重要。插件可以将任意数量的组件暴露到任意数量的应用程序插件槽位中。当插件槽位需要知道哪些暴露的 Angular 组件与它们相关时,将使用此函数:
getPluginData(slot) {
return this.plugins.reduce((components, pluginData) => {
return components.concat(
pluginData.config.placements
.filter((placement) => placement.slot === slot)
.map((placement) => new PluginData(pluginData, placement))
);
}, []);
到目前为止,PluginService 类已经完成了,我们创建了插件系统的核心。在下一章中,我们将处理插件槽位,并看看我们如何可以动态实例化插件组件。
实例化插件组件
现在,是时候看看我们插件架构的第二大主要部分了,那就是负责在正确位置实例化插件组件的 PluginSlot 指令。
在我们实现指令之前,让我们看看如何在 Angular 中动态实例化组件。我们已经在 第七章 用户体验组件 中介绍了可以包含组件的实例化视图。在无限滚动指令中,我们使用了 ViewContainerRef 来实例化模板元素。然而,这里有一个不同的用例。我们希望将单个组件实例化到现有的视图中。
ViewContainerRef 对象也为我们提供了这个问题的解决方案。让我们看看如何使用 ViewContainerRef 对象来实例化组件的一个非常基础的例子。在下面的例子中,我们使用了四个新的概念:
-
使用
@ViewChild并将read选项设置为{read: ViewContainerRef}来查询视图容器而不是元素 -
使用
ComponentResolver实例来获取我们想要动态实例化的组件的工厂 -
使用
ReflectiveInjector创建一个新的子注入器,用于我们的实例化组件 -
使用
ViewContainerRef.createComponent实例化一个组件并将其附加到视图容器的底层视图。
以下代码示例展示了我们如何使用 ViewContainerRef 实例动态创建一个组件。
import {Component, Inject, ViewChild, ViewContainerRef, ComponentResolver} from '@angular/core';
@Component({
selector: 'hello-world',
template: 'Hello World'
})
export class HelloWorld {}
@Component({
selector: 'app'
template: '<h1 #headingRef>App</h1>'
})
export class App {
@ViewChild('headingRef', {read: ViewContainerRef}) viewContainer;
constructor(@Inject(ComponentResolver) resolver) {
this.resolver = resolver;
}
ngAfterViewInit() {
this.resolver
.resolveComponent(HelloWorld)
.then((componentFactory) => {
this.viewContainer.createComponent(componentFactory);
});
}
}
注入到 App 组件的构造函数中,我们稍后可以使用 ComponentResolver 解决 HelloWorld 组件。我们使用 @ViewChild 装饰器在 App 组件中查询标题元素。通常,这将给我们一个与视图元素关联的 ElementRef 对象。然而,由于我们需要与元素关联的视图容器,我们可以使用 {read: ViewContainerRef} 选项来获取 ViewContainerRef 对象。
在 AfterViewInit 生命周期钩子中,我们首先在 ComponentResolver 实例上调用 resolveComponent 方法。此调用返回一个承诺,该承诺解决为 ComponentFactory 类型的对象。Angular 在内部使用组件工厂来创建组件。
在承诺解决后,我们现在可以使用我们标题元素的视图容器上的 createComponent 方法来创建我们的 HelloWorld 组件。
让我们更详细地看看 ViewContainerRef 对象的 createComponent 方法:
| 方法 | 描述 |
|---|
| ViewContainerRef.createComponent | 此方法将创建一个基于在 componentFactory 参数中提供的组件工厂的组件。编译后的组件将随后附加到由 index 参数提供的特定位置上的视图容器。以下参数:
-
componentFactory:这是组件工厂,将用于创建新的组件。 -
Index:这是一个可选参数,用于指定创建的组件应在视图容器中插入的位置。如果没有指定此参数,组件将插入到视图容器的最后一个位置。 -
Injector:这是一个可选参数,允许您为创建的组件指定自定义注入器。这允许您为创建的组件提供额外的依赖项。 -
projectableNodes:这是一个可选参数,用于指定内容投影的节点。
此方法返回一个在实例化组件编译完成后解决的承诺。Promise 解决为一个 ComponentRef 对象,该对象也可以用于稍后再次销毁组件。|
提示
默认情况下,使用 ViewContainerRef.createComponent 方法创建的组件将从父组件继承注入器,这使得此过程具有上下文感知性。然而,createComponent 方法的 injector 参数在您想要向组件提供不在任何父注入器上存在的额外依赖项时特别有用。
让我们回到我们的PluginSlot指令,它负责相关插件组件的实例化。
首先,在我们深入代码之前,让我们思考一下我们的PluginSlot指令的高级需求:
-
插件槽应该包含一个名称输入属性,这样这个名称就可以被想要为插槽提供组件的插件所引用。
-
指令需要响应
PluginService的变化,并重新评估需要放置哪些插件组件。 -
在插件槽的初始化过程中,我们需要获取与这个特定插槽相关的
PluginData对象列表。我们应该咨询PluginService的getPluginData方法来获取这个列表。 -
使用获取的相关
PluginData对象列表,我们将能够使用我们的指令的ViewContainerRef对象实例化与放置信息关联的组件。
让我们在lib/plugin/plugin-slot.js路径上创建我们的PluginSlot指令:
import {Directive, Input, Inject, provide, ViewContainerRef, ComponentResolver, ReflectiveInjector} from '@angular/core';
import {PluginData} from './plugin';
import {PluginService} from './plugin-service';
@Directive({
selector: 'ngc-plugin-slot'
})
export class PluginSlot {
@Input() name;
...
}
在我们的指令中,name输入对于我们的插件机制非常重要。通过向指令提供名称,我们可以在我们的 UI 中定义命名的扩展点,并在稍后使用这个名称在插件配置的PluginPlacement数据中:
constructor(@Inject(ViewContainerRef) viewContainerRef,
@Inject(ComponentResolver) componentResolver,
@Inject(PluginService) pluginService) {
this.viewContainerRef = viewContainerRef;
this.componentResolver = componentResolver;
this.pluginService = pluginService;
this.componentRefs = [];
// Subscribing to changes on the plugin service and re-
// initialize slot if needed
this.pluginChangeSubscription =
this.pluginService
.change.subscribe(() => this.initialize());
}
在构造函数中,我们首先注入ViewContainerRef对象,这是一个指向指令视图容器的引用。由于我们想直接使用指令的视图容器,这里不需要使用@ViewChild。如果我们想获取当前指令的视图容器,我们可以简单地使用注入。当我们使用ViewContainerRef.createComponent方法实例化组件时,我们将使用这个引用。
为了解析组件及其工厂,我们注入ComponentResolver实例。
PluginService被注入有两个原因。首先,我们希望订阅活动插件列表上的任何变化,其次,我们使用它来获取这个插槽的相关PluginData对象。
我们使用componentRefs成员来跟踪已经实例化的插件组件。这将帮助我们稍后当插件被停用时销毁它们。
最后,我们为PluginService创建一个新的订阅,并将订阅存储到pluginChangeSubscription成员字段中。在激活的插件列表发生任何变化时,我们在我们的组件上执行initialize方法:
initialize() {
if (this.componentRefs.length > 0) {
this.componentRefs.forEach(
(componentRef) => componentRef.destroy());
this.componentRefs = [];
}
const pluginData =
this.pluginService.getPluginData(this.name);
pluginData.sort(
(a, b) => a.placement.priority < b.placement.priority ?
1 : a.placement.priority > b.placement.priority ? -1 : 0);
return Promise.all(
pluginData.map((pluginData) =>
this.instantiatePluginComponent(pluginData))
);
}
让我们详细看看initialize方法的四个部分:
-
首先,我们检查这个插件槽是否已经在
componentRefs成员中包含实例化的插件组件。如果是这种情况,我们使用ComponentRef对象的 detach 方法移除所有现有实例。之后,我们将componentRefs成员初始化为一个空数组。 -
我们使用
PluginService的getPluginData方法来获取与这个特定槽位相关的PluginData对象列表。我们将此槽位的名称传递给该方法,这样PluginService就会提前为我们提供一个感兴趣的插件组件列表,这些组件希望放置在我们的槽位中。 -
由于可能有多个插件排队等待在我们的槽位中放置,我们正在使用
PluginPlacement对象的优先级属性来对PluginData对象列表进行排序。这将确保具有更高优先级的插件组件将排在具有较低优先级的组件之前。这是一个很好的额外功能,当我们要处理许多争夺空间的插件时,这个功能将非常有用。 -
在我们的
initialize方法的最后一段代码中,我们为列表中的每个PluginData对象调用instantiatePluginComponent方法。
现在,让我们创建instantiatePluginComponent方法,该方法在initialize方法的最后一步被调用:
instantiatePluginComponent(pluginData) {
return this.componentResolver
.resolveComponent(pluginData.placement.component)
.then((componentFactory) => {
// Get the injector of the plugin slot parent component
const contextInjector = this.viewContainerRef.parentInjector;
// Preparing additional PluginData provider for the created
// plugin component
const providers = [
provide(PluginData, {
useValue: pluginData
})
];
// We're creating a new child injector and provide the
// PluginData provider
const childInjector = ReflectiveInjector
.resolveAndCreate(providers, contextInjector);
// Now we can create a new component using the plugin slot view
// container and the resolved component factory
const componentRef = this.viewContainerRef
.createComponent(componentFactory,
this.viewContainerRef.length,
childInjector);
this.componentRefs.push(componentRef);
});
}
此方法负责创建单个插件组件。现在,我们可以使用我们在本主题中关于ViewContainerRef.createComponent方法和ComponentResolver对象所获得的知识来动态创建组件。
除了从放置此插件槽位的组件继承的提供者之外,我们还想将PluginData提供给已实例化的插件组件的注入器。使用 Angular 的provide函数,我们可以指定pluginData以解决对PluginData类型的任何注入。
ReflectiveInjector类为我们提供了一些静态方法,用于创建注入器。我们可以使用我们的视图容器上的parentInjector成员来获取插件槽位上下文中的注入器。然后,我们使用ReflectiveInjector类上的静态resolveAndCreate方法来创建一个新的子注入器。
在resolveAndCreate方法的第一个参数中,我们可以提供一个提供者列表。这些提供者将被解决并可供我们的新子注入器使用。resolveAndCreate方法的第二个参数接受新创建的子注入器的父注入器。
最后,我们使用ViewContainerRef对象的createComponent方法来实例化插件组件。作为createComponent方法调用的第二个参数,我们需要传递视图容器中的位置。在这里,我们利用我们的视图容器的length属性将其放置在最后。在第三个参数中,我们用我们的自定义子注入器覆盖组件的默认注入器。成功后,我们将创建的ComponentRef对象添加到我们的已实例化组件列表中。
完成我们的插件架构
恭喜你,你已经使用 Angular 构建了自己的插件架构!我们创建了一个插件 API,可以使用PluginConfig装饰器来创建新的插件。PluginService管理整个插件加载,并使用自定义注入器将PluginData对象提供给应用中的插槽。PluginSlot指令可以在任务管理应用中使用,以在用户界面中标记扩展点。利用 Angular 中依赖注入的继承特性,插件组件将能够访问它们环境中所需的一切。
在下一节中,我们将使用我们刚刚创建的插件架构来创建我们的第一个插件。
构建敏捷插件
在上一节中,我们创建了一个简单但有效的插件架构,现在我们将使用这个插件 API 在任务管理应用中构建我们的第一个插件。
在我们深入插件细节之前,我们首先应该就我们的应用的可扩展性达成一致。我们的插件系统基于PluginSlot指令,这些指令应该放置在我们的组件树中的某个位置,以便插件可以暴露组件到这些插槽。目前,我们决定在我们的应用中设置两个可扩展的位置:
-
TaskInfo:在项目中显示的任务列表中,我们目前渲染Task组件。除了任务的标题外,Task组件还显示其他信息,如任务编号、创建日期、里程碑以及适用的情况下的努力信息。这些附加信息是通过TaskInfos子组件在Task组件上渲染的。这是一个为插件提供可扩展性的好位置,以便它们可以添加额外的任务信息,这些信息将在任务列表概览中显示。 -
TaskDetail:另一个提供可扩展性的绝佳位置是ProjectTaskDetails组件。这是我们编辑任务详情的地方,这使得它成为插件扩展的绝佳组件。
除了将PluginSlot指令添加到TaskInfos组件的指令列表中,我们还修改了位于lib/task-list/task/task-infos/task-infos.html的模板:
...
<ngc-task-info title="Efforts"
[info]="task.efforts | formatEfforts">
</ngc-task-info>
<ngc-plugin-slot name="task-info"></ngc-plugin-slot>
在包含PluginSlot指令并将名称输入属性设置为task-info之后,我们为插件提供了一个扩展点,它们可以在其中提供额外的组件。
让我们将相同的更改应用到lib/project/project-task-details/project-task-details.html中的ProjectTaskDetails组件模板:
...
<div class="task-details__content">
...
<ngc-plugin-slot name="task-detail"></ngc-plugin-slot>
</div>
在任务详情内容元素的末尾之前,我们包含另一个名为task-detail的插件插槽。通过为这个插槽提供组件,插件可以钩入任务的编辑视图。
好的,所以我们的扩展点已经设置好了,插件可以在任务级别提供额外的组件。你可以看到,使用PluginSlot指令准备这些位置真的是小菜一碟。
现在,我们可以查看我们的敏捷插件的实现,它将利用我们刚刚暴露的扩展点。
我们将要创建的敏捷插件将提供在任务上记录故事点的功能。故事点在敏捷项目管理中常用。它们应该提供对复杂性的感知,并且相对于所谓的参考故事而言。如果你想了解更多关于敏捷项目管理以及如何使用故事点进行估算的信息,我强烈推荐迈克·科恩的书籍,《敏捷估算与规划》。
让我们从我们的插件类和必要的配置开始。我们创建插件在常规 lib 文件夹之外,只是为了表明插件的便携性。
我们在 plugins/agile/agile.js 路径上创建一个新的 AgilePlugin 类:
import {PluginConfig, PluginPlacement} from '../../lib/plugin/plugin';
@PluginConfig({
name: 'agile',
description: 'Agile development plugin to manage story points on tasks',
placements: []
})
export default class AgilePlugin {
constructor() {
this.storyPoints = [0.5, 1, 2, 3, 5, 8, 13, 21];
}
}
插件类构成了我们插件的核心入口点。我们使用 PluginConfig 装饰器,这是我们作为插件 API 的一部分创建的。除了名称和描述之外,我们还需要配置任何放置,其中我们将插件组件映射到应用程序插件槽位。然而,由于我们还没有任何插件组件要暴露,我们的列表目前仍然是空的。
还需要注意的是,插件模块始终需要默认导出插件类。这正是我们在 PluginService 类中实现插件加载机制的方式。
回顾 PluginService 的 loadPlugin 方法中的这两行,可以看出我们依赖于插件模块的默认导出:
return System.import(url).then((pluginModule) => {
const Plugin = pluginModule.default;
...
当插件模块成功加载时,我们通过引用模块上的 default 属性来获取默认导出。
到目前为止,我们已经创建了我们的插件入口模块。这充当了一个插件配置容器,并且与 Angular 没有任何关系。使用放置配置,一旦我们创建了插件,我们就可以暴露我们的插件 Angular 组件。
敏捷任务信息组件
让我们继续到我们想要暴露的第一个敏捷插件组件。首先,我们创建一个组件,它将被暴露到名为 task-info 的槽位中。在任务列表下的任务标题下方,我们的敏捷信息组件应该显示存储的故事点。
我们在 plugins/agile/agile-task-info/agile-task-info.js 路径上创建一个新的 Component 类:
...
import {Task} from '../../../lib/task-list/task/task';
@Component({
selector: 'ngc-agile-task-info',
encapsulation: ViewEncapsulation.None,
template,
host: {
class: 'task-infos__info'
}
})
export class AgileTaskInfo {
constructor(@Inject(Task) taskComponent) {
this.task = taskComponent.task;
}
}
你可以看到,我们在这里实现了一个常规组件。这个组件没有任何特别之处。
我们导入 Task 组件以获取类型信息,并将其注入到我们的构造函数中。由于插件槽位于 TaskInfos 组件内部,而实际上 TaskInfos 组件始终是 Task 组件的子组件,这是一个安全的注入。
在构造函数中,我们首先获取注入的 Task 组件,并将任务数据提取到本地的 task 成员变量中。
我们还借用 TaskInfos 组件的 task-infos__info 类,以便获得与其他已存在于任务上的任务信息相同的样式。
让我们看看位于同一路径的agile-task-info.html文件中的AgileTaskInfo组件的模板:
<div *ngIf="task.storyPoints || task.storyPoints === 0">
<strong>Story Points: </strong>{{task.storyPoints}}
</div>
按照我们在TaskInfo组件中使用的相同标记,如果存在,我们显示storyPoints任务。
好的,现在我们可以使用PluginPlacement对象在插件配置中公开插件组件。让我们对我们的agile.js模块文件进行必要的修改:
...
import {AgileTaskInfo} from './agile-task-info/agile-task-info';
@PluginConfig({
name: 'agile',
description: 'Agile development plugin to manage story points on tasks',
placements: [
new PluginPlacement({slot: 'task-info', priority: 1,
component: AgileTaskInfo})
]
})
export default class AgilePlugin {
...
}
现在,我们在插件配置中包含一个新的PluginPlacement对象,它将我们的AgileTaskInfo组件映射到名为task-info的应用程序插件插槽中:

显示由我们的敏捷插件提供的额外信息的任务信息
这对于插件工作来说已经足够了。然而,由于我们的任务上没有填充任何storyPoints数据,这个插件目前实际上不会显示任何内容。
灵活任务详情组件
让我们创建另一个插件组件,它可以用来输入故事点。为此,我们将在plugins/agile/agile-task-detail/agile-task-detail.js路径上创建一个新的AgileTaskDetail组件:
...
import {Project} from '../../../lib/project/project';
import {ProjectTaskDetails} from '../../../lib/project/project-task-details/project-task-details';
import {Editor} from '../../../lib/ui/editor/editor';
@Component({
selector: 'ngc-agile-task-detail',
encapsulation: ViewEncapsulation.None,
template,
host: {class: 'agile-task-detail'},
directives: [Editor]
})
export class AgileTaskDetail {
constructor(@Inject(Project) project,
@Inject(ProjectTaskDetails) projectTaskDetails) {
this.project = project;
this.projectTaskDetails = projectTaskDetails;
this.plugin = placementData.plugin.instance;
}
onStoryPointsSaved(storyPoints) {
this.projectTaskDetails.task.storyPoints = +storyPoints || 0;
this.project.document.persist();
}
}
这个组件也没有什么特别之处。我们的目标插槽是task-detail插件插槽,它位于ProjectTaskDetails组件内部。因此,将ProjectTaskDetails和Project组件注入到我们的插件组件中是安全的。ProjectTaskDetails组件用于获取上下文中的任务数据。我们使用存储在Project组件上的LiveDocument来持久化我们对项目任务数据的任何更改。
我们重用Editor组件来获取用户输入,并在onStoryPointsSaved回调中存储输入数据。这是我们从前面的区域所了解的相同机制,在那里我们使用了Editor组件。当故事点被编辑时,我们首先更新存储在ProjectTaskDetails组件中的任务数据模型。之后,我们可以使用LiveDocument的persist方法来保存更改。
让我们看看位于plugins/agile/agile-task-detail/agile-task-detail.html文件中的我们的AgileTaskDetail组件的模板:
<div class="task-details__label">Story Points</div>
<ngc-editor [content]="projectTaskDetails.task?.storyPoints"
[showControls]="true"
(editSaved)="onStoryPointsSaved($event)"></ngc-editor>
我们从编辑器的content输入属性直接绑定到任务数据的storyPoints属性。
当编辑被保存时,我们使用更新后的值调用onStoryPointsSaved回调:

显示由我们的敏捷插件公开的新敏捷故事点的任务详情
在我们使用新的PluginPlacement对象在插件配置中公开我们新创建的插件组件之前,我们将进一步增强组件。如果我们在组件上提供两个按钮,允许用户将故事点增加到或减少到下一个常见故事点值,这将很好。因为我们已经在Agile插件类上存储了常见故事点的列表,让我们看看我们如何利用这一点:
...
import {PluginData} from '../../../lib/plugin/plugin';
@Component({
selector: 'ngc-agile-task-detail',
...
})
export class AgileTaskDetail {
constructor(..., @Inject(PluginData) pluginData) {
...
this.plugin = pluginData.plugin.instance;
}
...
increaseStoryPoints() {
const current = this.projectTaskDetails.task.storyPoints || 0;
const storyPoints = this.plugin.storyPoints.slice().sort((a, b) => a > b ? 1 : a < b ? -1 : 0);
this.projectTaskDetails.task.storyPoints =
storyPoints.find((storyPoints) => storyPoints > current) || current;
this.project.document.persist();
}
decreaseStoryPoints() {
const current = this.projectTaskDetails.task.storyPoints || 0;
const storyPoints = this.plugin.storyPoints.slice().sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
this.projectTaskDetails.task.storyPoints =
storyPoints.find((storyPoints) => storyPoints < current) || current;
this.project.document.persist();
}
}
当我们之前注入了由组件级别注入器提供的Project和ProjectTaskDetails组件时,我们现在利用了在PluginSlot指令实例化期间添加的提供者。在这里,我们提供了PluginData,我们现在可以使用它来获取对插件组件的引用。
下一个更高或更低的用户故事点值是通过increaseStoryPoints和decreaseStoryPoints找到的。这是通过搜索存储在我们AgilePlugin类上的常见故事点列表来完成的。使用注入的PluginData上的插件类实例,我们可以轻松访问此列表。在存储修改后的故事点后,我们然后使用项目组件的LiveDocument实例来持久化调整后的故事点。
在我们的AgileTaskDetail组件模板中,我们简单地添加了两个按钮,允许用户增加或减少基于我们新创建方法的用户故事点:
...
<button (click)="decreaseStoryPoints()"
class="button button--small">-</button>
<button (click)="increaseStoryPoints()"
class="button button--small">+</button>
好的,现在让我们使用一个新的PluginPlacement对象将AgileTaskDetail组件添加到插件配置中,该对象引用task-detail插件槽位:
...
import {AgileTaskDetail} from './agile-task-detail/agile-task-detail';
@PluginConfig({
...
placements: [
new PluginPlacement({slot: 'task-info', priority: 1,
component: AgileTaskInfo}),
new PluginPlacement({slot: 'task-detail', priority: 1,
component: AgileTaskDetail})
]
})
export default class AgilePlugin {
...
}
这不是很好吗?您创建了一个完全可移植的插件,该插件可以管理任务上的敏捷故事点。

带有故事点和额外增加/减少按钮的任务详情视图
剩下的唯一事情是将插件添加到PluginService指令最初应加载的插件列表中。为此,我们将在应用程序的根目录下创建一个plugins.js文件,并添加以下内容:
export default [
'/plugins/agile/agile.js'
];
现在,如果我们启动我们的应用程序,插件将由PluginService加载,并且PluginSlot指令将在适当的位置实例化敏捷插件组件。
回顾我们的第一个插件
干得好!您成功实现了您的第一个插件!在本节中,我们使用插件架构的 API 创建了一个用于管理敏捷故事点的插件。我们使用了PluginPlacement类将我们的插件组件映射到应用程序 UI 的不同槽位中。我们还利用了提供给插件槽位中每个实例化组件的PluginData对象,以便访问插件实例。
在插件内部实现此类功能的优势应该是显而易见的。我们没有建立额外的依赖关系就为我们的应用程序添加了一个新功能。我们的 Agile 功能是完全可移植的。第三方开发者可以编写独立的插件,并且它们可以被我们的系统加载。这是一个很大的优势,它帮助我们保持核心精简的同时提供出色的可扩展性。
管理插件
我们已经构建了插件架构的核心以及在这个系统中运行的第一个插件。我们可以使用应用根目录下的 plugins.js 文件来注册插件。系统实际上已经完全可用。然而,提供一个在运行时管理我们插件的方法会更好。
在本节中,我们将构建一个新的可路由组件,该组件将列出系统中的所有活动插件。完成此操作后,我们还将添加一些元素,允许用户在运行时卸载活动插件以及加载新插件。由于我们的插件系统的响应式特性,浏览器无需刷新即可使新加载的插件变为活动状态。插件加载的瞬间,它将立即对相关的插件槽位可用。
让我们从 lib/manage-plugins/manage-plugins.js 路径上的一个新的 ManagePlugins 组件类开始:
...
import {PluginService} from '../plugin/plugin-service';
@Component({
selector: 'ngc-manage-plugins',
...
})
export class ManagePlugins {
constructor(@Inject(PluginService) pluginService) {
this.plugins = pluginService.change;
}
}
我们的 ManagePlugins 组件相当简单。我们在组件构造函数中注入 PluginService,并将成员字段 plugins 指向 PluginService 的变化可观察对象。由于我们总会得到这个可观察对象发出的最新插件列表,我们可以在视图中简单地使用 async 管道来订阅这个可观察对象。
让我们看看我们新组件的模板 lib/manage-plugins/manage-plugins.html:
<div class="manage-plugins__l-header">
<h2 class="manage-plugins__title">Manage Plugins</h2>
</div>
<div class="manage-plugins__l-main">
<h3 class="manage-plugins__sub-title">Active Plugins</h3>
<div class="manage-plugins__section">
<table class="manage-plugins__table">
<thead>
<tr>
<th>Name</th>
<th>Url</th>
<th>Description</th>
<th>Placements</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let plugin of plugins | async">
<td>{{plugin.config.name}}</td>
<td>{{plugin.url}}</td>
<td>{{plugin.config.description}}</td>
<td>
<div *ngFor="let placement of plugin.config.placements"
class="manage-plugins__placement">
{{placement.slot}}
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
我们使用 HTML 表格来显示活动插件的列表。在表格体行中,我们使用 NgFor 指令遍历活动插件的列表,我们使用 async 管道来订阅这些插件。
在插件对象中,我们已经有了所有值得显示的内容。通过遍历存储在插件数据 config 属性上的 PluginPlacement 对象,我们甚至可以显示我们的插件提供组件的槽位名称。
现在,要启用我们的新组件,我们唯一要做的就是使其可路由,并将其添加到我们应用程序的导航中。让我们在 lib/app.js 模块中进行必要的修改:
...
import {ManagePlugins} from './manage-plugins/manage-plugins';
@Component({
selector: 'ngc-app',
...
})
@Routes([
...
new Route({path: 'plugins', component: ManagePlugins})
])
export class App {
...
}
我们添加了一个新的路由,所以让我们将它添加到 lib/app.html 中的导航中:
<div class="app">
<div class="app__l-side">
<ngc-navigation [openTasksCount]="openTaskCount">
...
<ngc-navigation-section title="Admin">
<ngc-navigation-item title="Manage Plugins"
[link]="['/plugins']">
</ngc-navigation-item>
</ngc-navigation-section>
</ngc-navigation>
</div>
<div class="app__l-main">
<router-outlet></router-outlet>
</div>
</div>
在新的 Admin 导航部分,我们添加了一个新的 navigation-item,该链接指向新创建的 "plugins" 路由:

我们的新 ManagePlugins 组件显示活动插件及其暴露的位置的表格
在运行时加载新插件
我们已经准备好提供一个页面来查看所有活动插件。然而,我们说能够管理这个列表会很好。用户应该能够移除活动插件以及手动加载额外的插件。
让我们在ManagePlugins组件中添加这些功能。在我们能够这样做之前,我们将在PluginService类上需要一个额外的方法,这是负责加载插件的部分。到目前为止,我们没有考虑移除活动插件的功能。让我们在lib/plugin/plugin-service.js中打开PluginService以添加此功能:
...
@Injectable()
export class PluginService {
...
removePlugin(name) {
const plugin = this.plugins.find(
(plugin) => plugin.name === name);
if (plugin) {
const plugins = this.plugins.slice();
plugins.splice(plugins.indexOf(plugin), 1);
this.plugins = plugins;
this.change.next(this.plugins);
}
}
}
好吧,这很简单!我们提供了一个新的removePlugin方法,它接受一个插件名称作为参数。然后我们在plugins数组中查找插件,如果找到了具有此名称的插件,我们就从列表中删除它。此外,在我们删除插件后,我们发出一个带有更新列表的change事件。由于应用程序中所有插件槽都订阅了这个更改可观察对象,它们将自动更新和重新初始化相关的插件组件。
现在我们需要对ManagePlugins组件类应用必要的更改,以便不仅能够移除插件,还能加载额外的插件:
...
@Component({
selector: 'ngc-manage-plugins',
...
})
export class ManagePlugins {
constructor(@Inject(PluginService) pluginService) {
...
this.pluginService = pluginService;
}
removePlugin(name) {
this.pluginService.removePlugin(name);
}
loadPlugin(loadUrlInput) {
this.pluginService.loadPlugin(loadUrlInput.value);
loadUrlInput.value = '';
}
}
现在,我们也存储了PluginService在我们的组件上。在removePlugin和loadPlugin函数中,我们将委托给PluginService以采取必要的行动。
loadPlugin方法将接收一个指向输入字段的ElementRef对象,用户在此输入字段中输入从其中加载新插件的 URL。我们可以将输入字段的值传递给PluginService的loadPlugin方法,它负责处理其余部分。一旦我们提交了这个调用,我们也将输入字段的值设置为空字符串。
让我们打开lib/manage-plugins/manage-plugins.html中的模板,以在我们的组件视图中应用所需的更改:
...
<div class="manage-plugins__l-main">
<h3 class="manage-plugins__sub-title">Active Plugins</h3>
<div class="manage-plugins__section">
...
<td>
<button (click)="removePlugin(plugin.name)"
class="button button--small">remove</button>
</td>
...
</div>
<h3 class="manage-plugins__sub-title">Load Plugin</h3>
<div class="manage-plugins__section">
<div class="manage-plugins__load-elements">
<input #loadUrlRef type="text"
placeholder="Enter plugin URL"
class="manage-plugins__load-url">
<button (click)="loadPlugin(loadUrlRef)"
class="button">Load</button>
</div>
</div>
</div>
我们在表格中为每个列出的插件添加了一个额外的按钮,该按钮包含一个绑定表达式,它调用带有当前迭代的插件名称的removePlugin方法。
我们还在插件列表之后添加了一个新部分来加载新插件。在这个部分中,我们使用一个输入字段来输入插件 URL,以及一个按钮来执行加载。使用一个loadUrlRef本地视图引用,我们可以将输入 DOM 元素的引用传递给组件上的loadPlugin方法:

一个完成的ManagePlugins组件,具有在运行时移除和加载插件模块的能力
现在,我们已经准备好管理我们的插件了。最初从根plugins.js文件中的 URL 加载的插件现在可以通过插件列表中的删除按钮来卸载。可以通过输入插件 URL 来加载和激活新插件,这个 URL 可以是本地 URL、捆绑并映射的模块,甚至是不同服务器上的远程 URL。
摘要
在本章中,我们探讨了如何实现插件架构的不同方法。然后,我们为插件架构设计了自己的方案,该方案利用了一些 Angular 机制,并基于我们称之为槽位的 UI 扩展点概念。
我们实现了一个插件 API,通过利用 ES7 装饰器使新插件的配置变得轻而易举,从而提供了极佳的开发者体验。我们使用服务来加载和卸载基于 SystemJS 模块加载器的插件,实现了插件系统的核心。这使得我们能够利用 SystemJS 提供的先进加载可能性。插件可以实时转换,可以位于本地 URL、远程 URL,甚至可以捆绑到主应用程序中。
我们实现了我们的第一个插件,该插件提供了一些组件来管理任务上的敏捷故事点。该插件是在我们的常规项目lib文件夹之外创建的,这应该强调了我们的插件系统的可移植性。
最后,我们创建了一个新的可路由组件来管理运行时的插件。由于我们的插件系统具有反应性,插件可以在应用程序运行时加载和卸载,而不会产生任何不希望出现的副作用。
当您在玩本章的源代码时,我强烈建议您尝试我们的插件架构的加载机制。我们几乎不费吹灰之力就实现了灵活性,这真是太棒了。您可以通过提供插件主模块的 URL 来卸载敏捷插件并重新加载它。您甚至可以尝试将整个插件文件夹放置在远程服务器上,并从那里加载插件。只需确保您考虑了必要的跨源资源共享(CORS)头信息。
本章的全部代码可以在书籍资源的 ZIP 文件中找到,您可以从 Packt Publishing 下载。您可以在书的前言中的下载示例代码部分进行参考。
在本书的下一章和最后一章中,我们将探讨如何测试我们迄今为止创建的组件。所以,请关注这个迟到的主题!
第十一章。对事物进行测试
编写测试对于代码的可维护性至关重要。众所周知,拥有一个覆盖大多数功能的好测试范围与功能本身同样重要。
当想到测试时,首先想到的可能就是代码质量保证。你测试你写的代码,这无疑是正确的。然而,编写测试还有很多其他重要的方面:
-
抵抗意外变化:你的测试定义了你的代码应该做什么。它们测试你的代码是否符合你的规范。这有几个好处,其中最明显的好处可能是对未来意外变化的抵抗。如果你将来修改代码,你不太可能破坏现有的代码,因为你的测试将验证现有功能是否仍然按指定的方式工作。
-
文档:你的测试定义了你的代码应该做什么。同时,它们显示了使用相关功能所需的 API 调用。这是任何开发者的完美文档。每当我想要了解一个库是如何真正工作的,我首先看的就是测试。
-
避免不必要的代码:编写测试迫使你将代码限制在满足你的规范要求,而不再需要更多。你应用程序中任何在自动化测试中未触及的代码都可以被认为是死代码。如果你坚持无情地重构方法,那么你将尽快移除这些未使用的代码。
到目前为止,我们完全没有在我们的书中考虑测试,鉴于其重要性,你可能想知道为什么我在最后一章才提出这个观点。在实际项目中,如果我们不是一开始就创建测试,我们肯定会更早地创建测试。然而,我希望你理解,在这本书中,我们将这个相当重要的主题推迟到了最后。我真的很喜欢测试,但因为我们主要关注 Angular 的组件架构,所以将这一章放在最后似乎更合理。
在本章中,我们将探讨如何对你的组件进行适当的单元测试。我们将专注于单元测试;自动化端到端测试超出了本书的范围。尽管如此,我们还将探讨如何测试组件上的用户交互,尽管不是在端到端测试的水平上。
在本章中,我们将深入探讨以下主题:
-
Jasmine 测试框架的介绍
-
为组件编写简单的 JavaScript 测试
-
创建一个
tests.html文件,它作为浏览器中的测试运行器 -
创建 Jasmine 间谍和观察组件输出属性
-
了解 Angular 测试工具,例如
inject、async、TestComponentBuilder、DebugElement等 -
模拟组件
-
模拟现有服务
-
为我们的
AutoCompleteUI 组件创建测试 -
为我们的插件系统创建测试
Jasmine 的介绍
Jasmine 是一个非常简单的测试框架,它提供了一个 API,允许你编写 行为驱动开发(BDD)风格的测试。BDD 是一种敏捷软件开发过程,它以书面格式定义规范。
在 BDD 中,我们定义敏捷用户故事由多个场景组成。这些场景与故事的可接受标准密切相关,甚至可以替代它们。它们在更高层次上定义需求,并且主要是叙事性的。每个场景然后由三个部分组成:
-
Given: 这部分用于描述场景的初始状态。测试代码是我们执行测试场景所需的所有设置的执行部分。
-
When: 这部分反映了我们对测试系统所做的更改。通常,这部分包括一些 API 调用和反映系统用户行为的操作。
-
Then: 这部分指定在给定状态和
when部分应用更改后系统应该看起来像什么。在我们的代码中,这部分通常是测试函数的末尾,我们使用断言库来验证系统的状态。 -
Jasmine 提供了一个 API,使得编写按照 BDD 风格结构的测试变得非常简单。让我们看看如何使用 Jasmine 为购物车系统编写一个测试的非常简单的例子:
describe('Buying items in the shop', () => { it('should increase the basket count', () => { // Given const shop = new Shop(); // When shop.buy('Toothpaste'); 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 回调函数中,我们可以使用 it 函数注册尽可能多的场景。
在 Jasmine it 函数的回调函数内部,我们可以开始编写我们的测试。我们在测试内部使用 BDD 风格来组织代码。
你不一定需要在浏览器中运行 Jasmine,但如果你这样做,你将获得所有测试及其状态的简洁总结报告:

Jasmine 提供了所有测试规范的漂亮视觉报告,还允许你重跑单个测试,并提供更多选项
Jasmine 有三个与我们相关的部分:
-
Jasmine 核心: 这部分包含测试定义 API、断言库以及测试框架的所有其他核心部分
-
Jasmine HTML: 这是指定将所有测试结果写入浏览器文档的 HTML 报告器,并提供重跑单个测试的选项
-
Jasmine 引擎: 这是一个用于浏览器中启动 Jasmine 框架并执行与 HTML 报告器相关的任何设置的文件
在我们的项目中,我们将直接从 CDN 使用 Jasmine 和前面的部分,因此我们不需要安装任何东西就可以开始。我们创建一个新的 tests.html 文件,它将作为我们的测试运行器。结合 live-server,我们总可以在浏览器中打开这个页面。这样我们就可以在开发过程中立即获得测试的反馈。
小贴士
Jasmine 还与测试运行器(如 Karma)很好地配合使用,以运行您的测试。Karma 是一个流行的测试运行器,它允许您使用 Karma CLI 并行运行测试,或者将其集成到您的构建管道中。这也允许您在不同的浏览器中运行测试。在本章中,我们将使用 Jasmine HTML 和 Jasmine 启动直接在浏览器中运行我们的测试。这样,我们可以跳过如果使用 Karma 作为测试运行器时需要进行的相当复杂的设置。
让我们看看我们在应用程序根目录中创建的 tests.html 文件的代码,它紧挨着已经存在的 index.html 文件:
...
<script src="img/es6-shim.min.js"></script>
<script src="img/angular2-polyfills.js"></script>
<script src="img/system.js"></script>
<script src="img/config.js"></script>
<script src="img/strong>"></script>
<script src="img/strong>"></script>
<script src="img/strong>"></script>
除了加载我们 Angular 应用程序的常规组件(ES6 shim、Angular polyfills 和 SystemJS)之外,我们现在还加载了 Jasmine 的三个主要组件。
默认情况下,Jasmine 在窗口的 load 事件上执行所有注册的测试。然而,由于我们将使用 SystemJS 加载我们的测试,我们需要将 Jasmine 的启动推迟到 SystemJS 完全加载我们的测试:
<script>
window._jasmineOnLoad = window.onload;
window.onload = null;
return System.import('./all.spec')
.then(window._jasmineOnLoad)
.catch(console.error.bind(console));
...
</script>
我们首先将 Jasmine 启动在 window.onload 上注册的函数放在一边。我们将该函数存储在一个临时 _jasmineOnLoad 全局变量中。
现在,我们使用 SystemJS 导入我们的测试入口模块,它将被存储在 all.spec.js 文件中。SystemJS 返回一个 Promise,如果测试模块已成功加载和执行,则该 Promise 将被解决。我们可以使用返回的 Promise 的 then 函数来执行存储在 window._jasmineOnLoad 中的 Jasmine 启动函数。这样,我们确保在所有测试注册后启动 Jasmine。
编写我们的第一个测试
现在我们已经完成了 Jasmine 的设置,我们可以开始编写我们的第一个测试了。在本节中,我们将为本书第八章 Time Will Tell 中创建的 AutoComplete 组件编写一个测试。这个组件是在 第八章 中创建的。
由于 Angular 组件只是类,我们可以通过实例化 Component 类并测试其方法来测试很多功能。应该首先考虑可以以这种方式执行的测试。这些测试可以在不启动 Angular 组件的情况下运行。
AutoComplete 组件根据可用的项和筛选标准筛选显示的结果。在下面的测试中,我们将验证组件上的 filter 方法是否按预期工作。
小贴士
在这本书中,我们遵循将测试文件存储为需要测试的文件名后追加.spec.js文件的做法。我们还将这些测试文件存储在主题的同一文件夹中。这使得保持上下文变得更加容易。
我们将在AutoComplete组件的文件夹lib/ui/auto-complete中创建一个新的auto-complete.spec.js文件:
import {describe, expect, it,} from '@angular/core/testing';
import {AutoComplete} from './auto-complete';
describe('AutoComplete', () => {
it('should filter items correctly', () => {
// Given
const autoComplete = new AutoComplete();
autoComplete.items = ['One', 'two', 'three'];
// When
autoComplete.filterItems('o');
// Then
expect(autoComplete.filteredItems).toEqual(['One', 'two']);
});
});
由于我们在执行测试之前加载了 Jasmine,我们可以依赖 Jasmine 暴露的全球describe、it和expect函数。然而,Angular 为我们提供了一些很好的 Jasmine 函数包装器,我们可以从位于@angular/core/testing模块中导入这些包装器。
如您所见,我们实际上不需要激活AutoComplete组件来测试其一些功能。通过简单地测试组件类,我们就可以执行一些可执行规范。
我们遵循 BDD 方法来构建我们的测试,在Given部分,我们实例化一个新的AutoComplete组件类,然后使用一些测试项目初始化项目列表。即使项目字段实际上是一个组件输入,我们也可以简单地忽略这个事实来测试过滤功能。
在我们的测试的When部分,我们实际上调用组件类的filterItems方法,并测试它是否根据规范过滤项目。
在Then部分,我们使用 Jasmine 的expect函数来断言When部分之后的预期状态。由于组件应该过滤所有与过滤标准部分匹配和大小写不敏感的项目,filteredItems中的预期值应该是一个包含One和two项目的数组。
我们使用toEqual断言函数来执行深度相等检查。如果我们使用toBe匹配器,我们将比较两个数组的引用,这将导致负匹配。
这就是我们的第一次测试。接下来要做的事情就是创建我们的主要测试模块,该模块将在tests.html文件中加载。
我们在应用程序根目录下的all.spec.js文件中创建了所有测试的主要入口点。该文件将包括我们在应用程序中创建的所有规范文件:
import './lib/ui/auto-complete/auto-complete.spec';
目前,我们只需要做这些来使我们的测试运行。我们只需导入我们刚刚创建的测试文件。现在,tests.html将使用 SystemJS 加载我们的all.spec.js文件,然后在这里,我们加载auto-complete-spec.js文件。
现在,我们可以在应用程序的根目录下启动live-server,并在浏览器中导航到http://127.0.1:8080/tests.html。由于live-server会在变化时重新加载我们的浏览器,因此我们可以在浏览器中不断获取测试状态更新的同时开始添加新的测试。
间谍组件输出
测试中的一个常见做法是在测试执行期间使用间谍函数调用,然后评估这些调用,检查是否所有函数都正确地被调用。
Jasmine 为我们提供了一些很好的辅助工具来使用间谍函数调用。我们可以使用 Jasmine 的spyOn函数来用间谍函数替换原始函数。间谍函数将记录任何调用,我们可以在之后评估它被调用了多少次以及使用了什么参数。
让我们看看如何使用spyOn函数的一个简单例子:
class Calculator {
multiply(a, b) {
return a * b;
}
pythagorean(a, b) {
return Math.sqrt(this.multiply(a, a) + this.multiply(b, b));
}
}
我们将测试一个简单的Calculator类,它有两个方法。multiply方法简单地乘以两个数字并返回结果。pythagorean方法计算直角三角形的斜边,该三角形有两个边长a和b。
你可能还记得从你早期的学校生活中学到的毕达哥拉斯定理的公式:
a² + b² = c²
我们将使用这个公式通过获取a*a + b*b的结果的平方根来从a和b生成c。对于乘法,我们将使用我们的multiply方法而不是直接使用算术运算符。
现在,我们想要测试我们的计算器pythagorean方法,因为它使用multiply方法来乘以a和b,我们可以监视这个方法以深入验证我们的测试结果:
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 的间谍函数替换我们类实例上的原始multiply函数。默认情况下,间谍函数只会记录函数调用,并且不会将调用进一步委托给原始函数。我们可以使用.and.callThrough()函数来指定我们希望 Jasmine 调用原始函数。这样,我们的间谍函数将充当代理并记录任何调用。
在我们测试的Then部分,我们可以检查间谍函数。使用toHaveBeenCalled匹配器,我们可以检查间谍函数是否被调用过。
使用间谍函数的calls属性,我们可以更详细地检查并验证调用次数以及各个调用接收到的参数。
利用我们关于 Jasmine 间谍的知识,我们现在可以将这些应用到我们的组件测试中。正如我们所知,所有组件的输出属性都包含一个EventEmitter,我们实际上可以监视它们以检查我们的组件是否发送输出。
在组件内部,我们调用EventEmitter上的next方法以将输出发送到父组件绑定。由于这是一个异步操作,我们还想在不涉及父组件的情况下测试我们的组件,因此我们可以简单地监视输出属性的next方法。
在我们AutoComplete组件的下一个两个测试中,我们想验证在Editor子组件中保存编辑时的功能。让我们快速回顾一下这个行为:
-
在保存编辑后,我们在
AutoComplete组件上获得onEditSaved方法,该方法被调用。 -
如果保存的值是一个空字符串,
AutoComplete组件应该发出一个带有null值的selectedItemChange事件。 -
如果保存的值不是空字符串,并且该值不在
AutoComplete组件的项中,则应该发出一个itemCreated事件
让我们为之前预期的行为创建测试,并将其添加到已存在的lib/ui/auto-complete/auto-complete.spec.js测试文件中:
...
it('should emit selectedItemChange event with null on empty content being saved', () => {
// Given
const autoComplete = new AutoComplete();
autoComplete.items = ['one', 'two', 'three'];
autoComplete.selectedItem = 'three';
spyOn(autoComplete.selectedItemChange, 'next');
spyOn(autoComplete.itemCreated, 'next');
// When
autoComplete.onEditSaved('');
// Then
expect(autoComplete.selectedItemChange.next).toHaveBeenCalledWith(null);
expect(autoComplete.itemCreated.next).not.toHaveBeenCalled();
});
我们在这里创建了两个 Jasmine 间谍。第一个间谍监视selectedItemChange输出属性,而第二个间谍监视itemCreated输出属性。
模拟后,编辑器以空字符串保存。我们可以在测试的Then部分开始验证我们的间谍。
selectedItemChange事件的next函数,即EventEmitter,应该被一个null值调用,而itemCreated的next函数则根本不应该被调用。我们可以使用返回的期望对象上的not属性来反转匹配器。
让我们添加第二个测试,以测试当编辑器保存的值在AutoComplete组件中尚不存在时的行为:
it('should emit an itemCreated event on content being saved which does not match an existing item', () => {
// Given
const autoComplete = new AutoComplete();
autoComplete.items = ['one', 'two', 'three'];
autoComplete.selectedItem = 'three';
spyOn(autoComplete.selectedItemChange, 'next');
spyOn(autoComplete.itemCreated, 'next');
// When
autoComplete.onEditSaved('four');
// Then
expect(autoComplete.selectedItemChange.next).not.toHaveBeenCalled();
expect(autoComplete.itemCreated.next).toHaveBeenCalledWith('four');
});
这次,我们模拟了一个带有值的已保存编辑,这个值不是空字符串,并且不在自动完成项中存在。
在我们的代码的Then部分,我们评估间谍并期望itemCreated.next函数被一个four字符串调用。
使用 Jasmine 间谍,我们成功测试了我们的组件输出,而不需要引导 Angular。我们仅在组件类上执行了这些测试,并在所有输出属性上创建了EventEmitter的间谍。
组件测试的实用工具
到目前为止,我们使用纯 JavaScript 测试了我们的组件。组件只是常规类的事实使得这一点成为可能。然而,这只能用于非常简单的用例。一旦我们想要测试涉及模板编译、组件上的用户交互、变更检测或依赖注入的组件,我们就需要从 Angular 那里得到一点帮助来执行我们的测试。
Angular 附带了一系列测试工具,帮助我们完成这项工作。事实上,Angular 构建的平台无关性允许我们用调试视图适配器替换常规视图适配器。这使得我们能够以允许我们详细检查组件的方式渲染组件。
要在渲染组件时启用 Angular 的调试功能,我们首先需要修改我们的测试主入口点。
让我们打开all.spec.js以进行必要的修改:
import {setBaseTestProviders} from '@angular/core/testing';
import {TEST_BROWSER_DYNAMIC_PLATFORM_PROVIDERS, TEST_BROWSER_DYNAMIC_APPLICATION_PROVIDERS} from '@angular/platform-browser-dynamic/testing';
setBaseTestProviders(TEST_BROWSER_PLATFORM_PROVIDERS, TEST_BROWSER_APPLICATION_PROVIDERS);
import './lib/ui/auto-complete/auto-complete.spec';
import './lib/plugin/plugin.spec';
使用@angular/core/testing模块的setBaseTestProviders函数,我们可以实际初始化一个测试平台注入器,该注入器将在 Angular 测试的上下文中使用。这个函数接受两个参数,第一个是一个平台提供者数组,第二个是一个应用提供者数组。
从 @angular/platform-browser-dynamic/testing 模块,我们可以导入两个常量,它们包含平台和应用级依赖项的已准备列表。以下是这些常量中的一些提供者:
-
平台级提供者:这些主要是由平台初始化提供者组成,用于调试
-
应用级提供者:这些包括以下内容:
-
DebugDomRootRenderer: 这覆盖了浏览器中的默认DomRenderer并使使用DebugElement和探测进行元素调试成为可能 -
MockDirectiveResolver: 这覆盖了默认的DirectiveResolver并允许在测试目的下覆盖指令元数据 -
MockViewResolver: 这覆盖了默认的ViewResolver并允许覆盖组件视图特定的元数据
-
使用 setBaseTestProviders 函数和导入的调试提供者常量,我们现在可以初始化我们的测试环境。在调用此函数并传递我们的提供者之后,Angular 已设置好以进行测试。
测试中的注入
通过两个辅助函数简化了在测试中注入 Angular 依赖项。inject 和 async 函数通过 @angular/core/testing 包提供,并帮助我们在我们的测试中注入依赖项。
让我们看看这个简单的例子,其中我们使用 inject 包装器函数注入文档元素。这个测试对我们应用程序来说并不相关,但它说明了我们现在如何在测试中利用注入:
import {describe, expect, it, inject} from '@angular/core/testing';
import {DOCUMENT} from '@angular/platform-browser';
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);
}))
);
});
通过将inject与async(包装)结合,我们现在在我们的测试中有一个异步操作。async辅助函数将使我们的测试等待直到所有异步操作都完成。我们不需要依赖于回调,并且我们有保证,即使内部异步操作也会在我们的测试完成之前完成。
小贴士
Zone.js 旨在与浏览器中的所有异步操作一起工作。它修补了所有核心 DOM API 并确保每个操作都通过一个区域。Angular 也依赖于 Zone.js 来启动变更检测。
测试组件构建器
Angular 附带另一个非常重要的测试实用工具,用于测试组件和指令。到目前为止,我们只测试了组件的组件类。然而,一旦我们需要测试应用中的组件及其行为,这就涉及到更多的事情:
-
测试组件视图:有时需要测试组件的渲染视图。在我们的视图中,所有绑定、使用模板指令的动态实例化和内容插入,我们需要有一种方法来测试所有这些行为。
-
测试变更检测:一旦我们在组件类中更新了我们的模型,我们希望测试通过变更检测执行的所有更新。这涉及到我们组件的整个变更检测行为。
-
用户交互:我们的组件模板可能包含一组事件绑定,这些绑定在用户交互时触发某些行为。我们还需要一种方法来测试某些用户交互后的状态。
-
覆盖和模拟:在测试场景中,有时需要模拟组件中的某些区域,以便为测试创建适当的隔离。在单元测试中,我们应该只关注我们想要测试的特定行为。
TestComponentBuilder,通过@angular/compiler/testing包提供,帮助我们解决上述问题。它是我们测试组件的主要工具。
TestComponentBuilder提供给测试应用程序注入器,我们在all.spec.js模块中使用setBaseTestProviders函数初始化了它。这样做的原因是,构建器本身也依赖于大量的平台和应用依赖来创建组件。由于我们所有的依赖都来自测试注入器,并且大多数都已被覆盖以启用检查,这完全合理。
让我们看看一个非常简单的例子,说明我们如何使用TestComponentBuilder来测试一个虚拟组件的视图渲染:
@Component({
selector: 'dummy-component',
template: 'dummy'
})
class DummyComponent {}
describe('Creating a component with TestComponentBuilder', () => {
it('should render its view correctly', async(inject([TestComponentBuilder], (tbc) => {
tbc.createAsync(DummyComponent).then((fixture) => {
// When
fixture.detectChanges();
// Then
expect(fixture.nativeElement.textContent).toBe('dummy');
});
}))
);
});
由于TestComponentBuilder在测试注入器中公开,我们需要使用依赖注入来获取其实例。我们为此使用inject辅助函数。由于创建组件是一个异步操作,我们还需要使用async辅助函数使我们的测试等待完成。
在我们的测试函数中,我们调用TestComponentBuilder的createAsync方法,并传递我们想要创建的DummyComponent的引用。此方法返回一个Promise,一旦组件成功编译,它就会解析。
在返回的Promise的then回调中,我们将收到一个特殊的ComponentFixture类型的固定对象。然后我们可以调用此固定对象的detectChanges方法,这将执行创建的组件的变更检测。在此初始变更检测之后,我们虚拟组件的视图已更新。现在我们可以使用固定对象的nativeElement属性来访问创建的组件的根 DOM 元素。
让我们更详细地看看ComponentFixture类型及其可用的字段:
| 成员 | 描述 |
|---|---|
detectChanges() |
此方法在固定上下文中创建的根组件上执行变更检测。使用TestComponentBuilder创建组件后,模板绑定不会自动评估。这是我们的责任来触发变更检测。即使我们更改了组件的状态,我们也需要再次触发变更检测。 |
destroy() |
此方法销毁底层组件并执行所需的任何清理。这可以用来测试OnDestroy组件的生命周期。 |
componentInstance |
此属性指向组件类实例,如果我们想与组件交互,这是我们的主要交互点。 |
nativeElement |
这是创建的组件根部的原生 DOM 元素的引用。此属性可用于直接检查我们组件的渲染 DOM。 |
elementRef |
这是创建的组件根元素的ElementRef包装器。 |
debugElement |
这个属性指向由DebugDomRootRenderer在组件视图渲染管道中创建的DebugElement实例。调试元素为我们提供了一些检查渲染元素树和测试用户交互的便利工具。我们将在另一部分中更详细地探讨这一点。 |
我们现在已经查看了一个非常简单的虚拟组件以及如何使用TestComponentBuilder结合inject和async辅助函数来测试它。
这很好,但它并没有真正反映我们在需要测试真实组件时所面临的复杂性。真实组件比我们的虚拟组件有更多的依赖。我们依赖于子指令,可能还需要注入服务来获取数据。
当然,TestComponentBuilder也为我们提供了测试更复杂组件并保持单元测试中必要隔离所需的工具。
让我们先来看一个例子,我们想要测试一个ParentComponent组件,它使用ChildComponent组件来渲染一个数字列表。因为我们只想测试ParentComponent,所以我们不关心ChildComponent如何渲染这个列表。我们希望在测试期间为ChildComponent提供一个模拟组件,以此来移除子组件的行为,这样我们就可以轻松地验证数据是否被子组件接收:
@Component({
selector: 'child',
template:'<ul><li *ngFor="let n of numbers">Item: {{n}}</li></ul>'
})
class ChildComponent {
@Input() numbers;
}
@Component({
selector: 'parent',
template: '<child [numbers]="numbers"></child>',
directives: [ChildComponent]
})
class ParentComponent {
numbers = [1, 2, 3];
}
这是我们的起点。我们有两个组件,我们只对测试父组件感兴趣。然而,父组件需要子组件,并且它意味着一种非常特定的渲染方式来渲染父组件传递的数字。我们只想测试我们的数字是否成功传递给了子组件。我们不希望在测试中涉及子组件的渲染逻辑。这非常重要,因为仅仅更改子组件可能会破坏我们的父组件测试,这是我们想要避免的。
我们现在想要实现的是在测试的上下文中创建子组件的模拟:
@Component({
selector: 'child',
template: '{{numbers.toString()}}'
})
class MockChildComponent {
@Input() numbers;
}
在我们的MockChildComponent类中,使用与真实组件相同的选择器属性是很重要的。否则,模拟将不会工作。在模板中,我们使用一个非常简单的数字输入输出,这使得检查变得容易。
同样重要的是,我们需要提供与原始组件相同的输入属性。否则,我们无法正确地模仿真实组件。
现在,我们可以继续进行我们的测试。使用TestComponentBuilder的另一个方法,我们能够用我们的模拟组件覆盖真实的ChildComponent:
describe('ParentComponent', () => {
it('should pass data to child correctly', async(inject([TestComponentBuilder], (tbc) => {
tbc
.overrideDirective(ParentComponent, ChildComponent, MockChildComponent)
.createAsync(ParentComponent).then((fixture) => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('1,2,3');
});
}))
);
});
在TestBuilderComponent上使用overrideDirective方法,我们可以在创建它之前修改父组件的directives元数据。这样,我们能够用我们的MockChildComponent类替换真实的子组件。
因此,我们在测试的上下文中将ParentComponent与ChildComponent解耦。我们需要这种级别的分离,以便正确地隔离我们的单元测试。由于我们的模拟子组件只是渲染传递数组的字符串表示,我们可以轻松地测试我们的固定内容的文本。
小贴士
单元测试的定义是测试单个单元,并隔离该单元与任何依赖项。如果我们想坚持这种范式,我们需要为每个依赖组件创建一个模拟。这很容易让我们陷入一种需要仅为了测试而维护更多复杂性的情况。关键在于找到正确的平衡。你应该模拟对我们主题有重大影响的依赖项,而忽略对我们想要测试的功能影响较小的依赖项。
让我们看看一个不同的用例,其中我们有一个注入服务以获取数据的组件。由于我们只想测试我们的组件,而不是它所依赖的服务,我们 somehow 需要偷偷将模拟服务而不是真实服务注入到我们的组件中。TestComponentBuilder还提供了一个方法来修改指令的providers元数据,这对于这种情况非常有用。
首先,我们声明我们的基础组件及其依赖的服务。在这个例子中,NumbersComponent类注入了NumbersService类,并从中获取一个包含数字的数组:
@Injectable()
class NumbersService {
numbers = [1, 2, 3, 4, 5, 6];
}
@Component({
selector: 'numbers-component',
template: '{{numbers.toString()}}',
providers: [NumbersService]
})
class NumbersComponent {
constructor(@Inject(NumbersService) numbersService) {
this.numbers = numbersService.numbers;
}
}
现在,我们需要创建一个提供测试所需数据并使我们的组件从原始服务中隔离的模拟服务:
@Injectable()
class MockNumbersService extends NumbersService {
numbers = [1, 2, 3];
}
在这个简化的例子中,我们只是提供了一组不同的数字。然而,在实际的模拟案例中,我们可以排除很多不必要的步骤,这些步骤可能会产生副作用。使用模拟服务还可以确保我们的测试,该测试专注于NumbersComponent类,不会因为NumbersService类的变化而中断。
通过扩展真实服务,我们可以利用原始服务的一些行为,同时在模拟中覆盖某些功能。不过,你需要小心这种做法,因为我们通过这样做依赖于原始服务。如果你想要创建一个完全隔离的测试,你可能需要覆盖所有方法和属性。或者,你可以创建一个完全独立的模拟服务,它提供与测试中使用的相同的方法和属性。
小贴士
当使用 TypeScript 时,你应该为此目的使用接口,其中你的实际服务和模拟服务都实现了相同的接口。
现在,让我们看看测试用例以及我们如何使用TestComponentBuilder来提供我们的模拟服务而不是真实服务:
describe('NumbersComponent', () => {
it('should render numbers correctly', async(inject([TestComponentBuilder], (tbc) => {
tbc
.overrideProviders(NumbersComponent, [
provide(NumbersService, {
useClass: MockNumbersService
})
])
.createAsync(NumbersComponent).then((fixture) => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('1,2,3');
});
}))
);
});
使用TestComponentBuilder上的overrideProviders方法,我们可以向正在测试的组件提供额外的提供者。这允许我们覆盖组件上已经存在的现有提供者。使用@angular/core模块的provide函数,我们可以创建一个提供者,它在请求NumberService时提供,但也解析为MockNumberService。
TestComponentBuilder允许我们以非常简单、隔离和灵活的方式执行测试。它在编写组件的单元测试时扮演着重要角色。如果您想了解更多关于TestComponentBuilder上可用的方法,您可以访问官方文档网站angular.io/docs/ts/latest/api/core/testing/TestComponentBuilder-class.html。
现在,是时候使用我们关于TestComponentBuilder服务的知识来开始测试我们的应用程序组件的实际操作了!
测试组件的实际操作
在上一个主题中,我们学习了TestComponentBuilder服务以及如何使用它来在我们的测试环境中创建组件。我们还学习了inject和async辅助函数以及如何模拟组件和服务。
现在,让我们利用这些知识来处理AutoComplete组件的测试。让我们向lib/ui/auto-complete路径上的auto-complete.spec.js文件添加另一个测试。
由于AutoComplete组件依赖于相对复杂的Editor组件,在我们开始编写测试之前模拟我们的Editor组件可能是个好主意:
@Component({
selector: 'ngc-editor',
template: '{{content}}'
})
export class MockEditor {
@Input() content;
}
这可能看起来有点牵强,但实际上这正是我们当前对AutoComplete组件测试所需的所有内容。Editor组件应该只接受一个内容输入,这是两个组件之间的主要交互。在我们的MockEditor组件的模板中,我们只渲染内容输入属性。这样,我们可以轻松验证使用AutoComplete组件的结果。
让我们使用这个模拟编辑器来编写我们的下一个测试:
it('should initialize editor with selected item', async(inject([TestComponentBuilder], (tcb) => {
tcb
.overrideDirective(AutoComplete, Editor, MockEditor)
.createAsync(AutoComplete).then((fixture) => {
// Given
fixture.componentInstance.items = ['one', 'two', 'three'];
fixture.componentInstance.selectedItem = 'two';
// When
fixture.detectChanges();
// Then
expect(fixture.nativeElement.textContent.trim())
.toBe('two');
});
})));
在我们的测试中,我们想测试AutoComplete组件是否以正确的内容初始化Editor(分别,我们的MockEditor组件)。我们测试AutoComplete组件的selectedItem是否成功反映到编辑器中。
我们使用TestComponentBuilder,它异步创建组件。使用async辅助函数,我们告诉 Jasmine 等待所有异步操作完成以进行此测试。
使用TestComponentBuilder提供的ComponentFixture,我们可以开始与创建的组件进行交互。使用组件固件的componentInstance成员,我们可以设置AutoComplete组件所需的所有输入属性。
由于我们在测试中负责手动触发变更检测,我们使用 fixture 上的 detectChanges 方法根据新状态更新组件视图。这将启动组件的变更检测生命周期并执行必要的视图更新。
在视图更新后,我们的 AutoComplete 组件和底层的 MockEditor 组件都更新了,我们可以运行断言来验证更新的 DOM,通过获取我们 fixture 上的 nativeElement 属性的文本内容。
对于这个特定的测试,我们对此方法感到满意。然而,在其他涉及更多 DOM 元素的场景中,直接断言根组件的 textContent 属性可能就不够了。这可能会包括很多我们对于断言不感兴趣的信息。我们应该始终尝试将断言缩小到尽可能少的细节。
由于我们可以在我们的 fixture 上访问原生的 DOM 元素,我们可以简单地使用 DOM API 来选择子元素,以缩小我们的断言:
expect(fixture.nativeElement.querySelector('ngc-editor').textContent.trim()).toBe('two');
这将成功选择我们的模拟编辑器的 DOM 元素,我们只能检查编辑器内的文本内容。
虽然这可能是一个可行的方案,但 Angular 为我们提供了一个更好的方法来实现这个目标。
由 ComponentFixture 提供,我们可以访问由 DebugDomRootRenderer 在测试上下文中创建的 DebugElement 树。DebugElement 允许我们对 Angular 渲染我们的组件时创建的元素树进行高级检查。它还包含一个高级查询 API,允许我们在树中搜索特定元素。
让我们重写我们的测试,以使用 DebugElement 提供的高级功能:
...
import {By} from '@angular/platform-browser';
...
it('should initialize editor with selected item', async(inject([TestComponentBuilder], (tcb) => {
tcb
.overrideDirective(AutoComplete, Editor, MockEditor)
.createAsync(AutoComplete).then((fixture) => {
...
expect(fixture.debugElement.query(By.directive(MockEditor)).nativeElement.textContent.trim()).toBe('two');
});
})));
每个 DebugElement 对象上可用的 query 和 queryAll 方法允许我们像使用 querySelector 和 querySelectorAll 查询 DOM 树一样查询 Angular 视图树。这里的区别在于,我们可以使用谓词辅助器来查询匹配的元素。使用 By 辅助类,我们可以创建这些谓词,然后按顺序用于查询 DebugElement 树。
目前,使用 By 辅助器有三种不同的谓词可用:
| 成员 | 描述 |
|---|---|
By.all() |
这是对应的谓词,它将导致查询当前 DebugElement 对象的所有子 DebugElement 对象 |
By.css(selector) |
这是对应的谓词,它将导致使用指定的 CSS 选择器查询 DebugElement |
By.directive(type) |
这是对应的谓词,它将导致查询包含指定指令的 DebugElement |
回到我们的测试中,我们现在可以使用固定调试元素上的查询方法来查询我们的编辑器。由于我们已经用MockEditor组件替换了真实的Editor组件,我们需要查询后者。我们使用By.directive(MockEditor)谓词,这将成功查询代表我们的MockEditor组件宿主元素的DebugElement对象。
DebugElement对象的query方法将始终返回一个新创建的DebugElement对象,该对象是第一个找到的元素。如果没有找到查询的元素,它将返回null。
DebugElement的queryAll方法将返回一个包含所有匹配谓词的DebugElement对象的数组。如果没有匹配的元素,此方法将返回一个空数组。
测试组件交互
虽然 UI 交互测试可能是端到端测试的一部分,但我们将在这个主题中探讨如何测试组件的基本用户交互。
在这个主题中,我们将测试当用户点击调用窗口中显示所有可用项的项时,自动完成组件的行为。
让我们将这个测试添加到已经存在的auto-complete.spec.js模块中:
it('should emit selectedItemChange on click in callout', async(inject([TestComponentBuilder], (tcb) => {
tcb
.overrideDirective(AutoComplete, Editor, MockEditor)
.createAsync(AutoComplete).then((fixture) => {
spyOn(fixture.componentInstance.selectedItemChange, 'next');
fixture.componentInstance.items = ['one', 'two', 'three'];
fixture.componentInstance.selectedItem = 'one';
fixture.componentInstance.onEditModeChange(true);
fixture.componentInstance.onEditableInput('');
fixture.detectChanges();
fixture.debugElement
.queryAll(By.css('.auto-complete__item'))
.find((item) => item.nativeElement.textContent.trim() === 'two')
.triggerEventHandler('click');
expect(fixture.componentInstance.selectedItemChange.next).toHaveBeenCalledWith('two');
});
})));
首先,我们想在测试中为selectedItemChange EventEmitter next函数设置一个 Jasmine 间谍。这样,我们可以在之后检查我们的AutoComplete组件在用户从调用中选择一个项时是否成功发出了事件。
在测试代码的Given部分,我们还调用AutoComplete组件实例上的onEditModeChanged和onEditableInput方法。通过这些调用,我们模拟了使用的编辑器,目前编辑器中没有内容。这将导致所需的过滤,将所有可用项在调用中呈现以供选择。
在我们的代码的When部分,我们首先需要在固定件上触发变更检测。这将导致包含所有可用自动完成项的调用在AutoComplete组件中被渲染。
现在,我们可以模拟点击我们的自动完成项之一来触发这个测试中的动作。
首先,我们将选择所有匹配我们自动完成项调用中 CSS 类的DebugElement对象。这将为我们提供一个包含所有元素的数组,我们可以使用Array.prototype.find方法根据包含的文本选择一个特定的项。
在查询得到的DebugElement上,我们现在调用triggerEventHandler方法来模拟一个点击事件。这实际上不会触发一个真实的点击事件,而是会直接执行视图绑定中附加的处理程序。
在模拟点击具有文本内容为two的自动完成项之后,我们现在可以检查我们的selectedItemChange.next函数上的间谍。根据我们组件中的行为,这应该已经用所选项的值调用了。
使用DebugElement测试组件的用户交互变得非常简单。我们还通过使用triggerEventHandler方法提供的快捷方式,将我们的测试与底层 DOM 事件解耦。
小贴士
triggerEventHandler方法作用于 Angular 的虚拟元素树,而不是实际的 DOM 树。因此,我们也可以使用此方法来触发附加到组件输出属性的事件处理器。
测试我们的插件系统
在前面的章节中,我们为AutoComplete组件创建了测试,这是一个相对简单的 UI 组件。然而,我们学习了进行更复杂组件或组件系统测试所需的所有技术。
现在,我们将探讨测试在第十章中创建的插件系统,即使事物可插拔。
在着手这个主题之前,回顾一下插件系统架构概述可能是个好主意。与测试一样,理解正在测试的系统中的确切发生情况至关重要。
让我们在lib/plugin路径下创建一个新的plugin.spec.js文件。
在我们为这个主题实现第一个测试函数之前,我们需要创建一些虚拟组件和插件来测试我们的系统。让我们在测试模块的顶部创建这些组件:
@Component({
selector: 'dummy-plugin-component-1',
template: 'dummy1'
})
export class DummyPluginComponent1 {}
@Component({
selector: 'dummy-plugin-component-2',
template: 'dummy2'
})
export class DummyPluginComponent2 {}
@Component({
selector: 'dummy-application',
template: 'dummy-slot:<ngc-plugin-slot name="dummy-slot"></ngc-plugin-slot>',
directives: [PluginSlot]
})
export class DummyApplication {}
这里没有什么特别的。我们声明了两个具有静态模板的虚拟组件,这些模板将帮助我们进行插件测试。此外,我们还创建了一个虚拟应用程序组件,它将成为我们的主要测试组件。在接下来的测试中,我们将使用虚拟组件来测试我们的PluginSlot指令,而不是直接测试组件。
接下来,我们需要模拟我们的PluginService注入服务,该服务旨在从 URL 异步加载插件。在我们的模拟中,我们想要覆盖这个功能。我们不想从 URL 加载插件,而是想加载一些预定义的测试插件:
@Injectable()
export class MockPluginService extends PluginService {
constructor() {
super();
this.change = {
subscribe() {}
};
}
loadPlugins() {}
}
我们覆盖了loadPlugins方法,以避免在服务构建过程中加载任何插件。我们还覆盖了change属性上存在的 RxJS 主题,以防止我们的插件系统有任何反应行为,因为这只会干扰我们的测试。
让我们直接进入我们的第一个测试,我们想要测试一个非常基本的插件,该插件通过PluginSlot指令正确实例化。首先,我们使用describe和it函数设置我们的测试结构:
describe('PluginSlot', () => {
beforeEachProviders(() => [
provide(PluginService, {
useClass: MockPluginService
})
]);
it('should create dummy component into designated slot',async(inject([TestComponentBuilder, PluginService], (tcb, pluginService) => {
tcb.createAsync(DummyApplication).then((fixture) => {
...
});
}));
与我们已知的内容唯一的区别是,我们使用了来自@angular/core/testing模块的新beforeEachProviders函数。此函数允许我们设置一些默认提供者,这些提供者在我们的测试中使用。由于我们所有的插件系统测试都将依赖于PluginService的存在,我们使用此函数来设置模拟提供者,解析为我们的MockPluginService类。
除了使用beforeEachProviders,我们还可以在TestComponentBuilder中使用overrideProviders方法来提供额外的可注入项。然而,这将限制使用范围仅限于我们的组件内部。如果我们想从我们的测试函数中与该服务交互,我们需要使用beforeEachProviders辅助函数。
使用inject辅助函数,我们注入TestComponentBuilder和PluginService,我们使用beforeEachProviders辅助函数提供这些。
现在我们来实现createAsync执行后的Promise回调中缺失的测试主体。
作为第一步,我们定义一个新的虚拟插件,它使用上一章中的PluginConfig装饰器。我们在插件元数据中创建一个PluginPlacement,它将DummyPluginComponent1映射到名为dummy-slot的槽位。如果你再次查看我们在这个测试中使用的DummyApplication组件,你可以看到它包含一个名为dummy-slot的PluginSlot指令:
@PluginConfig({
name: 'dummy-plugin',
description: 'Dummy Plugin',
placements: [
new PluginPlacement({slot: 'dummy-slot', priority: 1, component: DummyPluginComponent1})
]
})
class DummyPlugin {}
此插件现在应该会导致DummyPluginComponent1组件在我们的DummyApplication类的插件槽中渲染。
作为下一步,我们将DummyPlugin类添加到我们的MockPluginService模拟服务的插件列表中:
pluginService.plugins = [{
type: DummyPlugin,
config: DummyPlugin._pluginConfig,
instance: new DummyPlugin()
}];
我们添加到MockPluginService插件数组中的对象仅仅模拟了通常在PluginService中加载的插件。
接下来,我们保留一个指向放置在我们DummyApplication组件中的PluginSlot指令的引用。为此,我们可以使用我们的固定DebugElement根上的query方法。我们使用一个谓词,它允许我们通过我们的PluginSlot组件的指令类型进行查询:
const pluginSlot = fixture.debugElement
.query(By.directive(PluginSlot))
.injector
.get(PluginSlot);
我们需要插件槽指令实例的引用,以便在测试断言之前初始化槽位。这是一个重要的步骤,因为我们不能依赖于MockPluginService类中的可观察主题来初始化我们的PluginSlot指令。我们明确禁用了插件系统的响应式功能,以便进行适当的测试。因此,在执行任何断言之前,我们需要手动初始化我们的插件槽。
在使用指令谓词(搜索包含PluginSlot指令的元素)执行查询后,我们将收到我们的插件槽元素上的DebugElement。为了获取指令实例,我们使用每个DebugElement对象上存在的元素注入器。
PluginSlot组件实例上的initialize方法将创建所有相关的插件组件。幸运的是,这也会返回一个Promise给我们,一旦所有组件在我们的ApplicationDummy组件视图中创建完成,这个Promise就会被解析:
pluginSlot.initialize().then(() => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('dummy-slot:dummy1');
});
在PluginSlot实例的initialize方法返回的Promise回调中,我们最终可以对DummyApplication组件根元素的文本内容进行断言。
由于 DummyPluginComponent1 类有一个简单的静态模板,其中包含文本 dummy1,我们应该在我们的应用程序视图中看到完整的文本内容为 dummy-slot:dummy1。
这就是我们的第一个插件测试的全部内容。现在,我们将看看第二个测试,我们将使用它来验证我们的插件系统的另一个功能。我们的插件系统也应该能够将同一插件的两个组件渲染到两个不同的插件槽位中。然而,在我们的 DummyApplication 组件的模板中,我们目前只有一个名为 dummy-slot 的插件槽位。
为了仅针对特定测试修改 DummyApplication 组件的模板,我们可以使用 TestBuilderComponent 上的 overrideTemplate 方法:
it('should create two dummy components of same plugin into different slots',async(inject([TestComponentBuilder, PluginService], (tcb, pluginService) => {
const template = 'dummy-slot1:<ngc-plugin-slot name="dummy-slot1"></ngc-plugin-slot>dummy-slot2:<ngc-plugin-slot name="dummy-slot2"></ngc-plugin-slot>';
tcb.overrideTemplate(DummyApplication, template)
.createAsync(DummyApplication).then((fixture) => {
...
});
}))
);
在我们的测试函数中,我们为 DummyApplication 组件创建一个新的模板。我们在模板中添加了两个插件槽位,其名称属性设置为 dummy-slot1 和 dummy-slot2。
现在,我们可以使用 TestComponentBuilder 上的 overrideTemplate 方法在创建之前覆盖 DummyApplication 组件的模板。这为我们提供了必要的灵活性,以便为不同的测试重用模拟和虚拟组件。
让我们看看 createAsync 承诺回调中包含的代码:
@PluginConfig({
name: 'dummy-plugin',
description: 'Dummy Plugin',
placements: [
new PluginPlacement({slot: 'dummy-slot', priority: 1, component: DummyPluginComponent1}),
new PluginPlacement({slot: 'dummy-slot', priority: 2, component: DummyPluginComponent2})
]
})
class DummyPlugin {}
首先,我们创建一个新的 DummyPlugin 插件类,并使用 PluginConfig 装饰器对其进行配置。在放置元数据中,我们配置映射,以便将两个组件映射到不同的插件槽位。第一个组件被映射到名为 DummySlot1 的插件槽位,而第二个组件将被映射到名为 DummySlot2 的槽位。我们已经覆盖了我们的 DummyApplication 模板,以包含这两个插件槽位。
我们现在将我们的 DummyPlugin 类添加到 MockPluginService 类中,并模拟插件被加载:
pluginService.plugins = [{
type: DummyPlugin,
config: DummyPlugin._pluginConfig,
instance: new DummyPlugin()
}];
以下代码使用 queryAll 方法在固定装置中查询 DebugElements。我们使用一个谓词来查询包含 PluginSlot 指令的所有元素。通过 Array.prototype.map 的附加调用,我们转换数组,以便直接获取发现的 PluginSlot 组件的组件实例:
const pluginSlots = fixture.debugElement
.queryAll(By.directive(PluginSlot))
.map((debugElement) => debugElement.injector.get(PluginSlot));
现在,是我们完成测试的时候了。使用 Promise.all 函数,我们可以将一系列承诺流线化为单个承诺,该承诺将在所有底层承诺解决后解决。然后,我们可以通过在每个 PluginSlot 组件上执行初始化方法来映射我们的 pluginSlots 数组。这将返回一个承诺数组给我们,这些承诺将在插件槽位中的所有组件创建后解决:
Promise.all(
pluginSlots.map((pluginSlot) => pluginSlot.initialize())
).then(() => {
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toBe('dummy-slot1:dummy1dummy-slot2:dumm
y2');
});
在使用 Promise.all 函数的合并承诺的 then 回调中,我们最终可以执行我们的断言。使用覆盖后的 DummyApplication 组件模板和两个分离的插件槽位中的两个插件组件的输出,我们应该得到文本内容为 dummy-slot1:dummy1dummy-slot2:dummy2。
这是本章我们要查看的最后一个测试。然而,这本书附带代码中还有更多的测试。只需查看代码仓库,自己动手尝试那些测试即可。
摘要
在本章中,我们学习了如何为我们的组件编写简洁的单元测试。我们遵循了 BDD 风格的测试编写方法,同时也涵盖了 JavaScript 测试框架 Jasmine 的基础知识。
我们了解了 Angular 中可用的调试工具以及如何设置测试的注入器环境。使用TestComponentBuilder,我们能够以非常灵活但精确的方式执行测试。我们还了解了在调试环境中运行的TestComponentBuilder所创建的多个DebugElement的视图树。这使我们能够进行巧妙的检查,并对渲染的视图应用实际查询,以断言预期的结果。
我们使用了inject和async辅助函数来注入依赖,并同时运行异步测试。我们构建了模拟和虚拟组件,以便将我们的测试与应用程序的其他部分隔离开来。
附录 A. 任务管理应用程序源代码
本书构建的任务管理应用程序的源代码可在 Packt 下载服务器上获取。每个章节最终代码的链接列在本附录中。本附录还提供了如何使用下载的代码以及运行代码所需的步骤说明。此外,它还帮助您解决在使用任务管理应用程序时可能遇到的一些常见问题,并提供了一些解决问题的提示。
下载
以下列表提供了本书每个章节的下载链接。下载链接引用可下载的存档文件,需要在您的本地硬盘上解压:
完整的源代码也可在www.packtpub.com找到。
先决条件
任务管理应用程序是使用 Node.js 技术构建的;因此,在您能够运行任何代码之前,您需要在您的机器上安装 Node.js。
您可以从网站nodejs.org下载并安装 Node.js。为了构建 Sass 源文件并启动具有实时重载的服务器,需要两个全局 node 模块:
npm install -g gulp live-server
使用方法
在下载单个章节的源代码后,您需要安装 NPM 以及 JSPM 依赖项。通过在您的控制台运行以下两行代码,您将确保所有依赖项都已安装。请确保您从下载并解压的代码文件夹内运行这些命令:
npm install
jspm install
在您安装了所需的依赖项之后,您可以继续启动应用程序:
npm start
NPM start 脚本将调用 gulp 以编译任何 Sass 文件以及启动具有实时重载的静态服务器。请阅读先决条件主题了解如何安装这些全局 Node.js 模块。
故障排除
我们已经仔细考虑了任务管理源代码将在其中执行的各种环境。然而,在处理源代码时,您可能会遇到一些问题。本主题将为您提供在处理任务管理应用程序时常见问题的解决方案。
清理 IndexDB 以重置数据
任务管理应用程序使用一个持久保存在你浏览器中的数据存储。如果你在多个章节中广泛使用该应用程序,那么你的数据很可能导致一些应用程序不稳定。
如果你想要清理应用程序中使用的本地数据库,你可以在浏览器调试控制台中运行以下代码行。重要的是,你需要在使用任务管理应用程序打开的情况下,在浏览器的调试器中运行代码片段。如果你的浏览器指向不同的源,应用程序数据库将无法删除。删除数据库后,应用程序可以重新加载,并且它会使用初始样本数据重新创建数据库:
indexedDB.deleteDatabase('_pouch_angular-2-components');
在 Firefox 中启用 web 组件
第六章, 保持活动, 依赖于浏览器中 Shadow DOM 的存在。Chrome 从 35 版本开始原生支持 Shadow DOM。在 Firefox 中,你可以通过访问about:config页面并开启dom.webcomponents.enabled标志来启用 Shadow DOM。
IE、Edge 和 Safari 完全不支持这个标准;然而,我们可以通过包含一个名为webcomponents.js的 polyfill 来教它们如何处理 Shadow DOM。你可以在github.com/webcomponents/webcomponentsjs找到更多关于这个 polyfill 的信息。



浙公网安备 33010602011771号