TypeScript2-Angular-开发第二版-全-

TypeScript2 Angular 开发第二版(全)

原文:zh.annas-archive.org/md5/81C516831B5BF457C3508E2F3CF1895F

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

决定学习 Angular 可能会让人感到非常不知所措。这是因为编写 Angular 应用程序的事实方式是使用一种名为 TypeScript 的超集语言,这是一种相当新的语言。讽刺的是,TypeScript 通过提供严格类型(如 Java 等严格类型语言中所见)简化了编写 Angular 应用程序的方式,从而改善了我们编写的应用程序的预测行为。本书旨在通过解释 TypeScript 的核心概念,帮助初学者/中级 Angular 开发人员了解 TypeScript 或严格类型的基本概念。

本书涵盖内容

第一章《从松散类型到严格类型》讨论了 TypeScript 推出之前开发人员面临的问题,以及 TypeScript 解决了哪些问题。我们将通过讨论松散类型及其挑战,展示一些先前如何解决这些挑战的示例,以及为什么 TypeScript 是更好的选择。

第二章《开始使用 Typescript》概述了 TypeScript 的核心概念,并提供了如何设置一个纯 JavaScript 加 TypeScript 项目的实际示例。第一章中的所有松散类型示例将被重写为 TypeScript,以展示 TypeScript 的效率。

第三章《Typescript 本地类型和特性》深入探讨了内置的 TypeScript 严格类型,这些类型与现有的 JavaScript 松散类型相匹配。每种类型都将通过工作示例进行广泛讨论,展示应该如何使用以及应该如何工作。

第四章《使用 Angular 和 TypeScript 快速上手》讨论了 TypeScript 如何应用于 Angular。为此,需要借助 CLI 工具使 Angular 快速上手。在本章中,我们将讨论使 Angular 和 TypeScript 协同工作所需的条件。我们还将介绍在“Hello World”示例中可能找到的基本 Angular 概念。

第五章,使用 TypeScript 创建高级自定义组件,讨论了 Web 组件的概念以及 Angular 如何借助 TypeScript 构建 Web 组件。我们将看到如何使用类创建组件,如何使用 TypeScript 接口实现生命周期钩子,并使用 TypeScript 装饰器定义组件元数据。

第六章,使用 TypeScript 进行组件组合,讨论了 Angular 是基于组件的。它解释了组件是如何作为构建块组合在一起,以使一个完全功能的应用程序。我们将讨论使用示例和组件交互(数据传输和事件)对组件进行模块化组合。在这样做的过程中,我们将看到 TypeScript 如何用于让我们检查所有这些移动部分。

第七章,使用类型服务分离关注点,讨论了允许逻辑存在于组件中是不好的做法。在这种情况下,Angular 允许您通过服务提供 API 方法,这些组件可以使用。我们将讨论 TypeScript 如何帮助我们在这些 API 方法和组件之间创建合同(使用类型)。

第八章,使用 TypeScript 改进表单和事件处理,解释了 Angular 表单模块如何使我们能够使用 TypeScript 编写可预测的类型表单,这是从我们的应用程序用户收集数据的完美手段。我们还将看到如何使用类型化的 DOM 事件(例如,点击、鼠标悬停和按键)来响应用户交互。

第九章,使用 TypeScript 编写模块、指令和管道,讨论了 Angular 的次要构建模块以及它们如何最好地与 TypeScript 一起使用。您将学习如何在 Angular 中使用类型和装饰器构建自定义指令和管道。

第十章,SPA 的客户端路由,解释了单页应用程序(SPA),它是通过使用 JavaScript 而不是服务器来处理路由来构建的。我们将讨论如何使用 Angular 和 TypeScript,可以使用路由器模块仅使用单个服务器路由构建多个视图应用程序。

第十一章,使用真实托管数据,深入探讨了使用 Angular 的 HTTP 模块消耗 API 数据。您将学习如何直接从我们的 Angular 应用程序发出 HTTP 请求。从此请求中获取的数据可以由组件呈现。

第十二章,测试和调试,涵盖了对 Angular 构建块进行单元测试的推荐实践。这些包括组件、服务、路由等。

本书适合谁

本书中涵盖的示例可以在 Windows、Linux 或 macOS PC 上实现。您需要安装 Node 和 npm 来使用 TypeScript,以及一个体面的网络浏览器。

这本书适合谁

本书旨在通过解释 TypeScript 的核心概念,帮助初学者/中级 Angular 开发人员了解 TypeScript 或严格类型的知识很少或根本没有的人。对于已经使用过 Angular 1.x 或其他框架并试图转移到 Angular 2.x 的开发人员来说,这也是一本完美的书籍。

约定

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是一些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"我们可以通过使用include指令来包含其他上下文。"

代码块设置如下:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

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

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

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

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
/etc/asterisk/cdr_mysql.conf

新术语重要单词以粗体显示。屏幕上显示的单词,比如菜单或对话框中的单词,会以这种方式出现在文本中:"点击“下一步”按钮会将您移动到下一个屏幕。"

警告或重要说明会以这样的框出现。

提示和技巧会以这种方式出现。

第一章:从松散类型到严格类型

JavaScript 是松散类型的。值得重复一下,JavaScript 是松散类型的。注意句子是被动的——我们不能绝对地责怪某人对 JavaScript 的松散类型本质,就像我们不能对 JavaScript 的其他著名故障负责一样。

对松散类型和松散类型语言的详细讨论将有助于理解我们计划用本书解决的问题。

当编程语言是松散类型时,意味着通过变量、函数或适用于语言的任何成员传递的数据没有定义的类型。可以声明变量x,但它持有的数据类型从未确定。松散类型的语言与强类型的语言相反,后者要求每个声明的成员必须严格定义它可以持有的数据类型。

这些类型被分类为:

  • 字符串

  • 数字(整数、浮点数等)

  • 数据结构(数组、列表、对象、映射等)

  • 布尔值(true 和 false)

JavaScript、PHP、Perl、Ruby 等都是松散类型的语言。Java、C、C#是强类型语言的例子。

在松散类型的语言中,一个成员最初可以被定义为字符串。在后续过程中,这个成员可能最终存储一个数字、一个布尔值,甚至一个数据结构。这种不稳定性导致了松散类型语言的含义。

术语定义

在继续之前,定义一下您可能在理解松散和严格类型的过程中遇到或将要遇到的常见行话会很有帮助:

  • 成员:这些是描述数据如何存储和操作的语言特性。变量、函数、属性、类、接口等都是语言可能具有的成员的示例。

  • 声明与定义与赋值:当一个变量被初始化而没有值时,它被称为声明。当它被声明并具有类型时,它被称为定义。当变量有一个值,无论是否有类型,它被赋值

  • 类型:这些用于根据它们被解析和操作的方式对数据进行分类。例如,数字、字符串、布尔值、数组等。

  • :分配给给定成员的数据称为成员的值。

松散类型的含义

让我们从一个例子开始,展示松散类型语言的行为方式:

// Code 1.1

// Declare a variable and assign a value
var x = "Hello";

// Down the line
// you might have forgotten 
// about the original value of x
//
//
// Re-assign the value
x = 1;

// Log value
console.log(x); // 1

变量x最初被声明并赋予一个字符串值Hello。然后x被重新赋值为一个数值1。一切都没问题;代码被解释执行,当我们将值记录到控制台时,它记录了x的最新值,即1

这不仅仅是一个字符串-数字的问题;同样的情况也适用于每一种类型,包括复杂的数据结构:

// Code 1.2

var isCompleted;

// Assign null
isCompleted = null;
console.log('When null:', isCompleted);

// Re-assign a boolean
isCompleted = false;
console.log('When boolean:', isCompleted);

// Re-assign a string
isCompleted = 'Not Yet!';
console.log('When string:', isCompleted);

// Re-assign a number
isCompleted = 0;
console.log('When number:', isCompleted);

// Re-assign an array
isCompleted = [false, true, 0];
console.log('When array:', isCompleted);

// Re-assign an object
isCompleted = {status: true, done: "no"};
console.log('When object:', isCompleted);

/**
* CONSOLE:
*
* When null: null
* When boolean: false
* When string: Not Yet!
* When number: 0
* When array: [ false, true, 0 ]
* When object: { status: true, done: 'no' }
*/

这里需要注意的重要事情不是的变化。而是类型的变化。类型的改变不会影响执行。一切都运行正常,我们在控制台中得到了预期的结果。

函数参数和返回类型也不例外。您可以有一个接受字符串参数的函数签名,但是当您或任何其他开发人员在调用函数时传递数字时,JavaScript 将保持沉默:

function greetUser( username ) {
 return `Hi, ${username}`
}

console.log('Greet a user string: ', greetUser('Codebeast'))
console.log('Greet a boolean: ', greetUser(true))
console.log('Greet a number: ', greetUser(1))

/**
 * CONSOLE:
 *
 * Greet a user string: Hi, Codebeast
 * Greet a boolean: Hi, true
 * Greet a number: Hi, 1
 */

如果您来自强类型背景,并且没有使用松散类型语言的经验,那么前面的例子一定会感到奇怪。这是因为在强类型语言中,很难改变特定成员(变量、函数等)的类型。

那么,需要注意的含义是什么?显而易见的含义是,松散类型的成员是不一致的。因此,它们的值类型可以改变,这是您作为开发人员需要注意的事情。这样做会面临一些挑战;让我们来谈谈它们。

问题

松散类型很棘手。乍一看,它们似乎很好,很灵活,可以随意更改类型,而不像其他强类型语言那样会出现解释器发出错误的情况。就像任何其他形式的自由一样,这种自由也是有代价的。

主要问题是不一致性。很容易忘记成员的原始类型。这可能导致您处理一个字符串,就好像它仍然是一个字符串,而其值现在是布尔值。让我们看一个例子:

function greetUser( username ) {
 // Reverse the username
 var reversed = username.split('').reverse().join('');
 return `Hi, ${reversed}`
}

console.log('Greet a correct user: ', greetUser('Codebeast'))

 * CONSOLE:
 *
 * Greet a correct user: Hi, tsaebedoC
 */

在前面的例子中,我们有一个根据用户用户名向他们打招呼的函数。在打招呼之前,它首先颠倒用户名。我们可以通过传递用户名字符串来调用该函数。

当我们传递一个布尔值或其他没有split方法的类型时会发生什么?让我们来看看:

// Code 1.4

function greetUser( username ) {
 var reversed = username.split('').reverse().join('');
 return `Hi, ${reversed}`
}

console.log('Greet a correct user: ', greetUser('Codebeast'))

// Pass in a value that doesn't support
// the split method
console.log('Greet a boolean: ',greetUser(true))

 * CONSOLE:
 *
 * Greet a correct user: Hi, tsaebedoC
 * /$Path/Examples/chapter1/1.4.js:2
 * var reversed = username.split('').reverse().join('');
 ^
 * TypeError: username.split is not a function
 */

第一条日志输出,打印出一个字符串的问候语,效果很好。但第二次尝试失败了,因为我们传入了一个布尔值。就像 JavaScript 中的一切都是对象一样,布尔值没有split方法。下面的图片显示了前面示例的清晰输出:

是的,您可能会认为您是这段代码的作者;为什么在设计函数接收字符串时会传入布尔值?请记住,我们一生中编写的大部分代码都不是由我们维护的,而是由我们的同事维护的。

当另一个开发人员接手greetUser并决定将该函数作为 API 使用而不深入挖掘代码源或文档时,他/她很可能不会传入正确的值类型。这是因为他/她是盲目的。没有任何东西告诉他/她什么是正确的,什么是错误的。甚至函数的名称也不足以让她传入一个字符串。

JavaScript 发展了。这种演变不仅在内部体验到,而且在其庞大的社区中也有所体现。社区提出了解决 JavaScript 松散类型特性挑战的最佳实践。

缓解松散类型问题

JavaScript 没有任何明显的本地解决方案来解决松散类型带来的问题。相反,我们可以使用 JavaScript 的条件来进行各种形式的手动检查,以查看所讨论的值是否仍然是预期类型。

我们将看一些示例,手动检查以保持值类型的完整性。

在 JavaScript 中,一切都是对象这句流行的说法并不完全正确(blog.simpleblend.net/is-everything-in-javascript-an-object/)。有对象原始值。字符串、数字、布尔值、null、undefined 都是原始值,但在计算过程中只被视为对象。这就是为什么你可以在字符串上调用.trim()之类的方法。对象、数组、日期和正则表达式是有效的对象。说对象是对象,这确实让人费解,但这就是 JavaScript。

typeof 运算符

typeof运算符用于检查给定操作数的类型。您可以使用该运算符来控制松散类型的危害。让我们看一些例子:

// Code 1.5
function greetUser( username ) {
 if(typeof username !== 'string') {
 throw new Error('Invalid type passed');
 };
 var reversed = username.split('').reverse().join('');
 return `Hi, ${reversed}`
}

console.log('Greet a correct user: ', greetUser('Codebeast'))
console.log('Greet a boolean: ',greetUser(true))

我们不应该等待系统在传入无效类型时告诉我们错误,而是尽早捕获错误并抛出自定义和更友好的错误,就像下面的截图所示:

typeof 运算符返回一个表示值类型的字符串。typeof 运算符并不完美,只有在你确定它的工作方式时才应该使用。参见下面的问题:

function greetUser( user ) {
 if ( typeof user !== 'object' ) {
 throw new Error('Type is not an object');
 }
 return `Hi, ${user.name}`;
}

console.log('Greet a correct user: ', greetUser( {name: 'Codebeast', age: 24 } ))
// Greet a correct user: Hi, Codebeast

console.log('Greet a boolean: ', greetUser( [1, 2, 3] ))
// Greet a boolean: Hi, undefined

当第二次调用函数时,你可能期望会抛出错误。但是程序没有通过检查,并在意识到它是未定义之前执行了user.name。为什么它通过了这个检查?记住数组是一个对象。因此,我们需要更具体的东西来捕获检查。日期和正则表达式也可能通过了检查,尽管这可能不是本意。

toString 方法

toString 方法是所有对象和包装对象(原始对象)原型继承的。当你在它们上调用这个方法时,它会返回一个类型的字符串标记。看下面的例子:

Object.prototype.toString.call([]);  // [object Array]  Object.prototype.toString.call({});  // [object Object]  Object.prototype.toString.call('');  // [object String]  Object.prototype.toString.call(new  Date());  // [object Date]
// etc

现在你可以使用这个来检查类型,正如 Todd Motto 所示(toddmotto.com/understanding-javascript-types-and-reliable-type-checking/#true-object-types):

var getType = function (elem) {
 return Object.prototype.toString.call(elem).slice(8, -1);
};
var isObject = function (elem) {
 return getType(elem) === 'Object';
};

// You can use the function
// to check types
if (isObject(person)) {
 person.getName();
}

前面的例子所做的是检查toString方法返回的字符串的一部分,以确定其类型。

最后说明

我们之前看到的例子对于简单的类型检查来说有些过度。如果 JavaScript 具有严格的类型特性,我们就不必经历这种压力。事实上,这一章可能根本就不存在。

想象一下 JavaScript 可以做到这一点:

function greet( username: string ) {
 return `Hi, ${username}`;
}

我们不必经历所有那些类型检查的痛苦,因为编译器(以及编辑器)在遇到类型不一致时会抛出错误。

这就是 TypeScript 发挥作用的地方。幸运的是,有了 TypeScript,我们可以编写类似于前面的代码,并将其转译为 JavaScript。

总结

在本书中,我们将讨论 TypeScript,不仅用于构建 JavaScript 应用程序,还用于构建 Angular 应用程序。Angular 是一个 JavaScript 框架;因此,除非通过 TypeScript 进行缓解,它将具有讨论的限制特性。

现在你知道手头的问题了,那就做好准备,让我们深入研究 Angular,并探讨 TypeScript 提供的可能解决方案。

目前为止,一切都很顺利!我们已经能够讨论以下关注点,以帮助我们继续前进:

  • 理解松散类型

  • 松散类型和严格类型之间的区别

  • 松散类型编程语言的挑战,包括 JavaScript

  • 减轻松散类型的影响

第二章:使用 TypeScript 入门

在上一章中,我们讨论了由于 JavaScript 语言的松散类型特性可能遇到的挑战。我们还看到了各种减轻这些挑战的尝试,但没有一种感觉自然。我们还介绍了 TypeScript 作为一种有助于的工具;本章将讨论 TypeScript 如何帮助我们。

TypeScript 的构建块和核心概念是关乎内心的事情,我们需要将它们视为这样。因此,通过实际示例,我们将讨论这些构建块,它们如何一起工作,以及如何将它们集成到您的工作流程中作为 JavaScript 开发人员。但首先,我们需要学习如何设置 TypeScript。

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

  • 创建 TypeScript 环境

  • 使用 TypeScript 构建工作示例

  • 类型注解

  • ES6 和 TypeScript

设置 TypeScript

TypeScript 的设置取决于将要使用的上下文。这是因为只要为环境正确配置,就可以将其集成到任何 JavaScript 工具、库和框架中。现在,我们将专注于最简单和最基本的设置。

要开始使用 TypeScript,需要基本了解 Node 及其包管理器 npm。还需要从 Node 网站安装两者(nodejs.org/en/)。

安装了 Node 和 npm 后,可以使用命令行工具通过npm全局安装 TypeScript:

npm install -g typescript

如果在安装时出现权限警告,可以使用sudo命令:

sudo npm install -g typescript

如果安装顺利,将看到以下输出:

要确认 TypeScript 安装是否成功,可以检查已安装的版本。如果显示版本,则安装成功:

tsc -v

因此,您的计算机上的 TypeScript 实例将如下所示:

Hello World

TypeScript 文件的扩展名为.ts。该扩展名支持 JavaScript 和 TypeScript。这意味着可以在.ts文件中编写 JavaScript 代码而不需要 TypeScript。让我们看一个例子。

首先,创建一个带有以下最小引导标记的index.html文件:

<!-- Code 2.1.html -->
<html>
 <head>
 <title>Example 2.1: Hello World</title>
 <!-- Include Bootstrap and custom style -->
 <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css">
 <link rel="stylesheet" href="2.1.css">
 </head>
 <body>
 <div class="container">
 <div class="col-md-4 col-md-offset-4 main">
 <h3 class="messenger"></h3>
 </div>
 <div class="col-md-4 col-md-offset-4 main">
 <input type="text" class="form-control">
 <button class="button">Greet</button>
 </div>
 </div>
 <!-- Include JavaScript file -->
 <script src="2.1.js"></script>
 </body>
</html>

请注意,在结束标记之前添加的 JavaScript 文件不是一个.ts文件;相反,它是一个带有.js扩展名的熟悉的 JavaScript 文件。这并不意味着我们的逻辑将用 JavaScript 编写;事实上,它是一个名为2.1.ts的 TypeScript 文件:

// Code 2.1.ts
(function() {
 var button = document.querySelector('.button');
 var input = document.querySelector('.form-control');
 var messenger = document.querySelector('.messenger');

 button.addEventListener('click', handleButtonClick);

 function handleButtonClick() {
 if(input.value.length === 0) {
 alert('Please enter your name');
 return;
 }
 // Update messanger 
 messenger.innerHTML = 'Hello, ' + input.value;
 }
})();

有什么奇怪的地方吗?不,我不这么认为。我们仍然在谈论纯 JavaScript,只是它存在于一个 TypeScript 文件中。这展示了 TypeScript 如何支持纯 JavaScript。

请记住,我们在index.html文件中导入的是2.1.js,而不是2.1.ts。因此,现在是时候生成浏览器可以理解的输出了。这就是我们通过npm安装的 TypeScript 编译器派上用场的地方。要编译,进入您的工作目录并在命令行中运行以下命令:

tsc 2.1.ts

忽略关于值属性的警告。我们很快就会解决这个问题。

这将生成一个编译后的2.1.js文件。正如您可能已经猜到的那样,查看这两者并没有语法差异:

然后,您可以使用 Web 服务器提供生成的资产来提供您的网页。有很多选项可以帮助您完成这一点,但serve非常受欢迎和稳定(github.com/zeit/serve)。要安装serve,运行以下命令:

npm install -g serve

现在,您可以直接使用以下内容托管您的index文件:

serve --port 5000

使用npm脚本,您可以同时运行这两个命令。首先,初始化package.json

npm init -y

现在,将以下脚本添加到 JSON 中:

"scripts": {"start": "tsc 2.1.ts -w & serve --port 5000"},

我们传入了-w选项,因此 TypeScript 可以在.ts文件中检测到更改时重新编译。

这就是我们的示例的样子:

TypeScript 中的类型注释

值得再次提到的是,在我们刚刚看到的Hello World示例中,没有任何不同之处。让我们使用一些特定于 TypeScript 的功能,其中之一就是类型。类型是 TypeScript 存在的原因,除了类型之外的每个功能都只是语法糖。

我们不会详细讨论类型,因为第三章,Typescript 原生类型和访问器,涵盖了这一点。我们可以讨论的是类型注释,这是 TypeScript 用来对成员应用严格类型的机制。注释是通过在成员初始化后跟着一个冒号(:)和类型(例如,string)来实现的,如下所示:

var firstName: string;

让我们看一些带注释的示例:

var name: string = 'John';
console.log(name); // John

var age: number = 18;
console.log(age); // 18

var siblings: string[] = ['Lisa', 'Anna', 'Wili'];
console.log(siblings); // ['Lisa', 'Anna', 'Wili']

// OR

var siblings: Array<string> = ['Lisa', 'Anna', 'Wili'];
console.log(siblings); // ['Lisa', 'Anna', 'Wili']

// any type supports all other types
// and useful for objects when we are lazy
// to make types with interfaces/class for them

var attributes: any = {legs: 2, hands: 2, happy: true}

不仅基本类型,对象、数组和函数也可以被类型化。我们很快就会看到。

我们可以重写之前的Hello World例子,以便用类型注释来注释变量和函数。

再看一下这张图片:

在 TypeScript 部分(右侧),value似乎没有被编辑器识别为 DOM 的属性,因此出现了错误行。但等等,这还是你一直在写的老 JavaScript。这里有什么问题吗?

TypeScript 自带了 DOM 的定义类型。这意味着当我们尝试访问在相应的 DOM 接口中未定义的属性时,它会抛出错误(接口的更多内容稍后再说)。DOM 查询方法querySelector以及其他查询方法返回的是Element类型(如果没有注释的话会被推断出来)。Element类型是基本的,包含有关 DOM 的通用信息,这意味着从Element派生的属性和方法将不会被看到。

这不仅在 TypeScript 中有意义,在其他面向对象的语言中也是如此:

class Base {
 name: string = 'John'
}

class Derived extends Base {
 gender: string = 'male'
}

(new Base()).name // John
(new Base()).gender // throws an error

回到我们的例子,让我们看看如何使用注释和转换来解决这个问题:

// Code 2.2.ts
(function() {
 // 1\. Button type is Element
 var button: Element = document.querySelector('.button');
 // 2\. Input type is HTMLInputElement and we cast accordingly
 var input: HTMLInputElement = <HTMLInputElement>document.querySelector('.form-control');
 // 3\. Messanger is HTMLElement and we cast accordingly
 var messenger: HTMLElement = document.querySelector('.messenger') as HTMLElement;

 // 4\. The handler now takes a function and returns another function (callback)
 button.addEventListener('click', handleButtonClick('Hello,', 'Please enter your name'));

 function handleButtonClick(prefix, noNameErrMsg) {
 // Logic here
 // Should return a function 
 }
})()

没有行为上的改变,只是提高了生产力。让我们讨论一下发生了什么:

  1. 按钮元素是Element类型。这里没有什么特别的,因为 TypeScript 已经内部推断出来了。

  2. 输入元素是HTMLInputElement类型。因为 TypeScript 将返回值推断为Element,所以我们必须将其转换为正确的类型,即HTMLInputElement。这是通过在返回值前加上<>并传递我们想要转换的接口来完成的。

  3. 信使元素是HTMLElement类型。我们仍然需要使用相同的原因进行转换,就像在步骤 2中看到的那样,但使用了不同的支持语法(as)。HTMLElementElement的子类型,包括更具体的 DOM 属性/方法(如innerText)。

  4. 我们不是直接传递回调函数,而是将其包装在一个函数中,这样我们就可以接收参数。

让我们看一下传递给addEventListener的方法:

// Code 2.2.ts
function handleButtonClick(prefix, noNameErrMsg) {
 return function() {
 if(input.value.length === 0) {
 if(typeof noNameErrMsg !== 'string') {
 alert('Something went wrong, and no valid error msg was provided')
 return;
 }
 alert(noNameErrMsg);
 return;
 }

 if(typeof prefix !== 'string') {
 alert('Improper types for prefix or error msg')
 }

 messenger.innerHTML = prefix + input.value;

 }

我们添加了很多验证逻辑,只是为了确保我们从参数中得到了正确的类型。我们可以通过使用 TypeScript 注释来简化这个过程:

// Code 2.3.ts
function handleButtonClick(prefix: string, noNameErrMsg: string) {
 return function(e: MouseEvent) {
 if(input.value.length === 0) {
 alert(noNameErrMsg);
 return;
 }

 messenger.innerHTML = prefix + input.value;

 }
}

这样好多了,对吧?类型检查已经处理了不必要的检查。事实上,在传递到浏览器之前,如果你的编辑器(例如 VS Code)支持 TypeScript,当使用无效类型调用方法时,你会得到语法错误。

类型注解帮助我们编写更简洁、更易理解和无 bug 的应用程序。TypeScript 使注解灵活;因此,你不必严格为逻辑中的每个成员提供类型。你可以自由地注解你认为必要的内容,从什么都不注解到全部注解;只需记住,你的注解越严格,你在浏览器中需要做的调试就越少。

ES6 及更高版本

除了类型注解,TypeScript 还支持 EcamaScript 6(ES6/ES2015)以及其他有用的功能,如枚举、装饰器、可访问级别(private、public 和 protected)、接口、泛型等等

我们将在下一章深入了解一些功能。在那之前,让我们先尝试另一个例子,其中包括一些 ES6 和 TypeScript 特定的功能。我们将构建一个计数器应用程序。这只是一个让你对这些功能感到兴奋的尝试,你将看到 TypeScript 如何带来你一直希望存在于 JavaScript 中的功能。

让我们从一个基本的 HTML 模板开始:

<!-- Code 2.4.html -->
<div class="container">
 <div class="col-md-6 col-md-offset-3 main">
 <div class="row">
 <div class="col-md-4">
 <button id="decBtn">Decrement--</button>
 </div>
 <div class="col-md-4 text-center" id="counter">0</div>
 <div class="col-md-4">
 <button id="incBtn">Inccrement++</button>
 </div>
 </div>
 </div>
</div>

用户故事

用户预期从按钮点击中增加或减少计数器,基本上,一个初始化为0的计数器,一个增加按钮以增加1,一个减少按钮以减少1

我们可以将 DOM 操作和事件逻辑组织成类,而不是在代码中到处散落。毕竟,这就是类存在的原因:

// Code 2.4.ts
class DOM {
 private _incBtn: HTMLElement;
 private _decBtn: HTMLElement;
 private _counter: HTMLElement;

 constructor() {
 this._incBtn = this._getDOMElement('#incBtn');
 this._decBtn = this._getDOMElement('#decBtn');
 this._counter = this._getDOMElement('#counter');
 }

 public _getDOMElement (selector: string) : HTMLElement {
 return document.querySelector(selector) as HTMLElement;
 }

 get incBtn(): HTMLElement {
 return this._incBtn;
 }

 get decBtn(): HTMLElement {
 return this._decBtn;
 }

 get counter(): number {
 return parseInt(this._counter.innerText);
 }

 set counter(value: number) {
 this._counter.innerText = value.toString();
 }
}

这就是 JavaScript 看起来像一个结构化语言。让我们花点时间解释一下正在发生的事情:

  • 首先,我们创建一个类并声明一些私有属性来保存 HTML DOM 元素的临时状态。像private这样的可见性特性只在 TypeScript 中特有,但类在 ES6 中已经存在了。

  • 构造函数使用了_getDOMElement私有实用方法来查询 DOM 并初始化私有属性的值。

  • incBtndecBtn的 getter 用于将这些私有属性的值公开。这是面向对象编程中的常见模式。Getter 被归类为访问器,并在 ES6 中可用。

  • 计数器访问器用于通过将它们转换为整数和字符串来设置和检索计数器文本的值。

您第一次尝试运行此应用程序应该会抛出错误,如下图所示:

这是因为 TypeScript 默认编译为 ES3,但在 ES3 中不支持 getter 和 setter(访问器)。要消除此错误,您可以告诉 TypeScript 编译器您更喜欢 ES5 而不是 ES3:

"start": "tsc 2.4.ts -w -t es5 & serve --port 5000"

-t标志,--target的别名,告诉 TypeScript 要编译到哪个版本。

DOMEvent类要简单得多--只有一个方法在调用时注册所有类型的事件:

// Code 2.4.ts
class DOMEvents {
 private register(htmlElement: HTMLElement, type:string, callback: (e: Event) => void): void {
 htmlElement.addEventListener(type, callback)
 }
}

该方法接受以下内容:

  • 要监听事件的元素

  • 事件类型(例如clickmouseoverdblclick)作为字符串

  • 一个回调方法,返回void,但被传递给事件负载

然后该方法使用addEventListener注册事件。

最后,我们需要一个示例的入口点。这也将是一个类的形式,该类将依赖于DOMDOMEvent类的实例:

// Code 2.4.ts
class App {
 constructor(public dom:DOM, public domEvents: DOMEvents) {
 this.setupEvents()
 }
 private setupEvents() {
 const buttons = [this.dom.incBtn, this.dom.decBtn];
 buttons.forEach(button => {
 this.domEvents.register(button, 'click', this.handleClicks.bind(this))
 })
 }
 private handleClicks(e: MouseEvent): void {
 const {id} = <HTMLElement>e.target;
 if(id === 'incBtn') {
 this.incrementCounter();
 } else {
 this.decrementCounter();
 }
 }

 private incrementCounter() {
 this.dom.counter++
 }

 private decrementCounter () {
 this.dom.counter--
 }
}

让我们讨论前面代码片段的工作原理:

  • 构造函数在类初始化时被调用,尝试使用setupEvents方法设置事件。

  • setupEvents方法遍历 DOM 上的按钮列表,并在每个按钮上调用DOMEvents register方法

  • register方法作为HTMLElement传递给按钮,click作为事件类型,handleClicks作为事件处理程序。处理程序与正确的上下文this绑定。这在 JavaScript 中总是令人困惑;Yehuda Katz 已经以简单的方式解释了它的工作原理,网址为yehudakatz.com/2011/08/11/understanding-javascript-function-invocation-and-this/

  • 回调方法根据被点击的按钮的 ID 调用incrementCounterdecrementCounter。这些方法分别从计数器中加 1 或减 1。

您可以通过创建App的实例来初始化应用程序:

// Code 2.4.ts
(new App(new DOM, new DOMEvents))

该图显示了我们新建的时髦计数器应用程序:

最后说明

重要的是再次指出我们在这些示例中使用的很酷的功能:

  • 访问器

  • 可见性

  • 箭头函数(回调):

var fooFunc = (arg1) => {
 return arg1
}
  • const关键字用于变量声明,而不是var

  • 解构:

const {id} = <HTMLElement>e.target;

摘要

其中一些功能在 JavaScript 环境中是原生可用的;TypeScript 在此基础上进行了扩展,为开发人员提供更好的体验。这就是为什么它被称为 JavaScript 的超集。

在下一章中,我们将回顾和描述这些功能,并举更多例子让你熟悉工作流程。

第三章:Typescript 本机类型和特性

您已经看到了使用 TypeScript 的不同示例。希望现在您知道 TypeScript 作为开发人员可以为您提供什么。在开始使用它构建 Angular 2 应用程序之前,还有一些 TypeScript 核心概念需要学习。本章将涵盖以下 TypeScript 概念:

  • 基本类型,如字符串、数字、布尔、数组、void 等

  • 函数类型

  • 接口

  • 装饰器

基本类型

让我们重新讨论基本类型。我们将讨论的大多数类型对您来说都很熟悉,但是通过复习会更好地欣赏 TypeScript 提供了什么。另一方面,一些类型在 JavaScript 中不可用,但是在 TypeScript 中是特定的。

字符串

字符串在 JavaScript 和 TypeScript 中都可用。它们用于表示文本数据。这些数据在程序中显示为字符串文字。这些文字在大多数编程语言中很容易识别,因为用双引号("")括起来。在 JavaScript(和 TypeScript)中,这些文字用双引号("")和单引号('')表示:

let text: string = "Hi, I am a string. Now you know!";

在上面的片段中,text变量存储了这个字符串:"Hi, I am a string. Now you know!"。因为 TypeScript 支持 JavaScript 的最新特性,你可以使用新的 ES6 模板文字:

const outro: string = 'Now you know!';

let text: string = `Hi, I am not just a simple string.
 I am actually a paragraph. ${outro}`;

数字

数字在 JavaScript 和 TypeScript 中都可用。数字表示 JavaScript 中的浮点数。您可以直接用键盘输入它们,不需要像字符串那样进行任何装饰:

let whole: number = 6;
let decimal: number = 2.5; let hex: number = 0xf00d; let binary: number = 0b1010; let octal: number = 0o744;

布尔

布尔类型在 JavaScript 和 TypeScript 中都可用。布尔类型是您在编程语言中遇到的最简单的类型。它们用是或否回答问题,这在 JavaScript 中表示为truefalse

let isHappy: boolean = true;
let done: boolean = false;

数组

数组在 JavaScript 和 TypeScript 中都可用。JavaScript 中的数据结构基本上是用对象和数组表示的。对象是键值对,而数组具有可索引的结构。没有array类型,而是为数组中包含的项目提供类型。

您有两种选择。您可以使用[]符号对,如下所示:

let textArray: string[];

textArray = ["java", "kotlin", "typescript", "the rest..."]

或者,您可以使用内置的通用类型:

let numberArray: Array<number> = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

Void

Void 仅在 TypeScript 中可用。void类型适用于函数的返回类型(我们很快会讨论这个)。Void 表示函数不会返回任何东西:

let sum: number = 20

// No return type function
function addToGlobalSum(numToAdd): void { 
 number + numToAdd }

addToGlobalSum(30) 
console.log(number) // 50

Any

Any 仅在 TypeScript 中可用。any类型是最灵活的类型。当需要时,它允许您更接近 JavaScript 的松散性质。这种需求可能来自未经类型化的第三方库,如果您不知道属性或方法可能返回的值类型。

这种类型可以存储所有已知的 JavaScript 类型:

// Stores a string
let name: any = 'John Doe' 

// Stores a number
let age: any = 24

// Stores a boolean
let employed: any = true

// ...even data structures
let person: any[] =['John Doe', 24, true] 

元组

元组仅在 TypeScript 中可用。它们允许数组中有不同的类型。元组意味着在创建类型时必须定义数组中的固定元素数量。例如,如果我们需要一个包含stringnumberboolean的数组,它将如下所示:

let flexibleArray: [string, number, boolean];

flexibleArray = ['John Doe', 24, true] 

当您尝试访问最初未创建的索引时,新索引将以适当的推断类型添加:

let anotherFlexArray: [string, number];

anotherFlexArray = ['John Doe', 24];

Assign true to index 2
anotherFlexArray[2] = true;

// anotherFlexArray becomes ['John Doe', 24, true]

枚举

枚举类型仅在 TypeScript 中可用。在某些情况下,您可能只想存储一组数字,无论是连续的还是不连续的。枚举为您提供了一个数值数据结构控制,而无需引入数组或对象的复杂性。

以下示例显示了一个enum类型,其中包含从02的数字:

enum Status {Started, InProgress, Completed}

let status:Status = Status.InProgress // 1

枚举是基于0的;因此,Started0InProgress1Completed2。此外,枚举是灵活的;因此,您可以为起始点提供一个数字,而不是0

enum Status {Started = 1, InProgress, Completed}

let status:Status = Status.InProgress // 2

使用枚举可以编写更具表现力的代码。让我们看看如何在前面示例中使用百分比值来表示状态:

enum Status {Started = 33, InProgress = 66, Completed = 100}

let status:Status = Status.InProgress + '% done' // 66% done

如果您知道实际值,那么很容易找到值的名称:

enum Status {Started = 33, InProgress = 66, Completed = 100}

let status:string = Status[66] // InProgress

函数和函数类型

JavaScript 函数是松散类型的,也是语言中最常见的错误来源之一。基本函数看起来像这样:

function stringToArray(char) {
 return char.split(' ')
}

我们有多大把握char不是一个数字?嗯,我们可能无法控制使用stringToArray的开发人员会传入什么。这就是为什么我们需要使用 TypeScript 严格控制值类型的原因。

函数在声明的两个不同部分使用类型:

  1. 函数参数

  2. 函数返回值

函数参数

您可以告诉 TypeScript 函数应该期望什么类型的值,并且它将严格遵守。以下示例显示了一个接收类型化字符串和数字作为参数的函数:

// Typed parameters
function stringIndex(char: string, index: number) {
 const arr = char.split(' ')
 return arr[number];
}

charindex参数分别具有stringnumber类型。甚至在事情到达浏览器之前,TypeScript 会在您尝试一些愚蠢的事情时提醒您:

function stringIndex(char: string, index: number) {
 const arr = char.split(' ')
 return arr[number];
}

stringIndex(true, 'silly') // Types don't match

当然,函数表达式也不会被忽视:

const stringIndex = function (char: string, index: number) {
 const arr = char.split(' ')
 return arr[number];
}

此外,箭头函数也是可以的:

const stringIndex = (char: string, index: number) => char.split(' ')[number];

函数返回值

执行函数时期望的值也可以是严格类型的:

function stringIndex(char: string, index: number): string {
 const arr = char.split(' ')
 return arr[number];
}

从前面的代码片段中可以看出,返回类型位于包含参数的括号之后,也位于函数体的左大括号之前。预期该函数将返回一个字符串。除了字符串之外的任何内容都会报错。

可选参数

当函数的参数是严格类型时,当函数需要灵活时,它会感到僵硬。在我们的先前示例中,为什么我们应该传入index,如果我们打算在索引丢失的情况下返回整个字符串?

当在调用函数时省略索引参数时,TypeScript 将抛出错误。为了解决这个问题,我们可以将index参数声明为可选的:

function stringIndex(char: string, index?: number): string {
 // Just return string as is
 // if index is not passed in
 if(!index) return char;
 // else, return the index 
 // that was passed in
 const arr = char.split(' ')
 return arr[number];
}

参数名称后面的问号告诉 TypeScript,当调用时参数丢失是可以的。要小心处理函数体中未提供参数的情况,如前面的示例所示。

接口

接口是我们的代码遵循的合同。这是数据结构必须遵循的协议。这有助于每个实现接口的数据/逻辑免受不当或不匹配类型的影响。它还验证了传入的值的类型和可用性。

在 TypeScript 中,接口用于以下目的:

  1. 为 JavaScript 对象创建类型。

  2. 为类设置遵循的合同。

我们将讨论接口在我们刚才列出的情景中的应用。

JavaScript 对象类型的接口

我们同意以下是一个有效的 JavaScript 对象:

// Option bag
let options = {show: true, container: '#main'};

这是有效的 JavaScript 代码,但是松散类型的。一直以来,我们一直在讨论字符串、数字、布尔值,甚至数组。我们还没有考虑对象。

正如您可能已经想象的那样,以下代码片段演示了先前示例的类型化版本:

// Typed object
let options: {show: boolean, container: string};

// Assing values
options = {show: true, container: '#main'};

这是正确的,但实际上,TypeScript 可以使用接口使其更易于维护和理解。以下是我们在 TypeScript 中编写接口的方式:

interface OptionBag {
 show: boolean,
 container: string
}

然后,您可以将options变量设置为OptionBag类型:

// Typed object
let options: OptionBag = {show: true, container: '#main'};

可选属性

不过关于接口的一件事是,接口定义的属性/方法在创建使用该接口类型的值时必须提供。基本上,我是说我们必须严格遵守与接口建立的契约。

因此,以下是不正确的,会抛出错误:

interface OptionBag {
 show: boolean,
 container: string
}

let options: OptionBag = {show: true}; // Error

我们可以将container设置为可选的;我们使用问号字面量,就像之前的例子中看到的那样:

interface OptionBag {
 show: boolean,
 container?: string
}

let options: OptionBag = {show: true}; // No Error

不过要小心,要考虑当未提供可选参数时。以下是一个这样做的例子:

// Get element
function getContainerElement(options: OptionBag):HTMLElement {
 let containerElement: HTMLElement
 if(!options.container) {
 // container was not passed in
 containerElement = document.querySelector('body');
 } else {
 // container was passed in
 containerElement = document.querySelector(options.container);
 }

 return containerElement
}

只读属性

另一个典型的情况是当你有属性,你打算只赋值一次,就像我们用 ES6 的const声明关键字一样。你可以将这些值标记为readonly

interface StaticSettings {
 readonly width: number,
 readonly height: number
}

// There are no problems here
let settings: StaticSettings = {width: 1500, height: 750}

// ...but this will throw an error
settings.width = 1000
// or
settings.height = 500

接口作为契约

您可以确保一个类遵循特定的契约,使用接口。我使用契约这个术语,意思是接口中定义的所有属性和方法必须在类中实现。

假设我们有以下Note接口:

interface Note {
 wordCount: number
}

要使用类来实现接口,我们在类名后面加上implements关键字,然后是我们要实现的接口:

class NoteTaker implements Note {
 // Implement wordCount from
 // Note interface
 wordCount: number;
 constructor(count: number) {
 this.wordCount = count
 }
}

接口不仅定义属性的签名,还接受函数类型作为方法:

interface Note {
 wordCount: number;
 updateCount(count: number): void
}

这可以通过类来实现:

class NoteTaker implements Note {
 // Implement wordCount from
 // Note interface
 wordCount: number;
 constructor(count: number) {
 this.wordCount = count
 }

 updateCount(count: number): void {
 wordCount += count
 }
}

如果NoteTaker类中既没有wordCount属性也没有updateCount方法,TypeScript 会抛出错误。

装饰器

在 Angular 2+中引入的最常见特性是装饰器。装饰器乍一看令人困惑,因为它们的使用前面有一个不寻常的@符号:

上面的截图是来自一个 Angular 应用的代码片段。它显示了一个组件装饰器装饰了一个名为AppComponent的类。

起初,这可能看起来令人不知所措,因为在 JavaScript 的历史上,我从未见过@字面量以这种方式使用。如果我们知道它只是一个可以访问所装饰内容的函数就好了!类、属性、方法和访问器都可以被装饰。让我们讨论如何装饰方法和类

装饰方法

假设我们想要让类上的一个方法只读。因此,在创建方法之后,它不能被任何原因覆盖。例如,方法看起来是这样的:

class Report {
 errPayload;

 // To become readonly
 error() {
 console.log(`The following error occured ${errPayload}`)
 }
}

如果我们不想在应用程序的生命周期中覆盖error,我们可以编写一个装饰器将描述符的writable属性设置为false

function readonly(target, key, descriptor) {
 descriptor.writable = false;
 return descriptor
}

通用签名是方法装饰器接受与Object.defineProperty相同的参数。在这种情况下,目标将是类,键将是方法名,这是类的属性,描述符将是config对象。

现在我们可以用刚刚创建的readonly装饰器装饰error方法:

class Report {
 errPayload;

 // Decorated method 
 @readonly
 error() {
 console.log(`The following error occured ${errPayload}`)
 }
}

任何试图改变error属性的尝试都将失败:

const report = new Report()

// This would never work
// because 'error' is read only
report.error = function() {
 console.log('I won't even be called')
}

装饰类

另一个常常被装饰的成员是类。事实上,在 Angular 中,几乎所有的类(组件、服务、模块、过滤器和指令)都被装饰。这就是为什么理解装饰器的存在是如此重要的原因。

装饰器可用于扩展类的功能,如下例所示:

// decorator function
function config(target) {
 target.options = {
 id: '#main',
 show: true
 }
}

// class
@config
class App {}

// options added
console.log(App.options) // {id: '#main', show: true}

装饰器工厂

前面的例子是固定的,因为options对象将始终具有相同的值。如果我们需要接收动态值怎么办?当然,这是一个有效的问题,因为id属性可能并不总是#main。因此,我们需要更灵活一些。

装饰器工厂是返回装饰器的函数,使您能够通过其工厂传递参数给装饰器:

// decorator factory function
function config(options) {
 // decorator function
 return function(target) {
 target.options = options
 }
}

// class decorator
// with arguments
@config({id: '#main', show: true})
class App {}

// options added
console.log(App.options) // {id: '#main', show: true}

总结

在前三章中,我们花了时间讨论 TypeScript 的基础知识,目的是在接下来的章节中(其中充满了大量的 Angular 内容)中,TypeScript 将不再是你需要担心的东西。

可以假设基本类型、函数类型、装饰器和接口已经添加到您对 TypeScript 的现有知识中。

在本书的接下来的章节中,我们将深入学习 Angular。如果你已经走到了这一步,那么你已经度过了本书中枯燥的部分,因为从现在开始,我们将用 Angular 2+构建许多有趣的示例。

第四章:使用 Angular 和 TypeScript 快速上手

前几章旨在解释 TypeScript 的基本和最常见的特性。在开发 Angular 项目时,这些特性将被广泛使用。在构建 Angular 项目时,TypeScript 是完全可选的,但相信我,只使用 JavaScript 并不是你想要经历 TypeScript 简化开发过程后的选择。

本章介绍了本书中令人兴奋的部分--使用 TypeScript 构建 Angular 应用程序。本章将涵盖以下主题:

  • 使用 TypeScript 设置 Angular

  • 理解组件基础知识

  • 学习关于 Angular 的模板语法

  • 一些数据绑定魔法

所有这些令人兴奋的主题都将有很好的示例支持,这样你就可以亲自看到这些东西是如何工作的。让我们开始吧。

使用 Angular 和 TypeScript 设置

Angular 并不是一个难以入门的框架。不幸的是,从初学者的角度来看,生态系统可能会用大量术语压倒你。这些术语大多代表了使 Angular 工作的工具,而不是 Angular 本身。Webpack、linters、TypeScript、typings、构建过程等等,都是一些令人困惑的术语,可能会在你开始 Angular 之旅的时候让你望而却步。

因此,Angular 团队构建了一个全能工具,帮助你更少地关注周围的工具,而更多地关注构建你的项目。它被称为 Angular CLI,只需几个 CLI 命令,你就可以构建你的应用程序。如今花在管理 JavaScript 工具上的时间令人担忧,作为一个初学者(甚至是专业人士),你不想陷入那样的混乱中。

要安装 CLI,你需要用 npm 运行以下命令:

npm install -g @angular/cli

当安装完成时,你应该在控制台中看到以下 npm 日志:

你可以通过运行helpversion命令来检查安装是否成功。

# Help command
ng help

# Version command
ng version

帮助命令将显示通过 CLI 工具可用的命令列表,而版本命令将显示当前安装的版本。如果安装不成功,这些命令都不会打印上述信息。

当你运行help命令时,以下是打印的日志详情:

运行版本命令会显示以下截图:

创建一个新的 Angular 项目

安装了 CLI 后,您现在可以在项目中开始使用它。当然,首先要做的是创建一个。CLI 的new命令只在项目中使用一次,用于生成项目需要的起始文件和配置:

ng new hello-angular

该命令不仅为您创建项目;它还安装了 npm 依赖项,因此您无需在开始之前运行安装命令:

直接导航到文件夹的根目录并运行serve命令:

ng serve

运行命令后,您将获得以下输出,显示您的应用程序成功运行的位置以及您可以访问它的位置。它还显示了捆绑文件,包括样式和脚本。请注意,这里没有 TypeScript 文件;一切都已转换为 JavaScript,以便浏览器理解:

您应该在localhost:4200看到您闪亮的应用程序正在运行:

项目结构

Angular 生成了许多辅助文件,以便测试、构建过程、包管理等。您可以成功构建一个项目,而不必关心这些文件的作用。因此,我们只会展示一些对我们开始很重要的文件:

我们现在应该关注src目录。这就是我们的项目文件(组件、服务、模板等)将存放的地方。

生成文件

您可以手动添加更多的 TypeScript 文件和模板,但使用 CLI 工具更有效。这是因为 CLI 工具不仅创建文件,还生成了起始片段来表示您尝试创建的文件类型。例如,让我们创建一个引用组件:

ng generate component quote
# OR
ng g component quote

这就是组件命令的样子,其中包含一些生成的代码和文件:

该图包括以下内容:

  1. 生成过程的 CLI 输出。

  2. 生成的组件、模板、CSS 和测试文件。

  3. TypeScript 组件。

CLI 可以用来生成其他 Angular/TypeScript 构建模块,而不仅仅是组件。我们现在不会尝试它;我们将在后续章节中讨论时再这样做。以下表格是在项目的 Github 自述文件中看到的生成命令:

脚手架 用法
组件 ng g component my-new-component
指令 ng g directive my-new-directive
管道 ng g pipe my-new-pipe
服务 ng g service my-new-service
ng g class my-new-class
守卫 ng g guard my-new-guard
接口 ng g interface my-new-interface
枚举 ng g enum my-new-enum
模块 ng g module my-module

基本概念

我们将在本书中深入探讨不同的主题,但大致解释正在发生的事情是个好主意,以便有上下文。

组件

您的好奇心可能会导致您打开app.component.tsquote.component.ts。如果它们看起来令人不知所措,不要担心;我们将在本书中广泛讨论组件(特别是在接下来的两章中)。

组件是任何 Angular 项目的核心。它们是核心构建模块,其他所有功能都只是为了支持组件。提到的文件包含用 TypeScript 编写的 Angular 组件。这就是app.component.ts的样子:

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

组件是带有模板的装饰类。装饰的类型很重要,在这种情况下是Component装饰器。从前一章中记得装饰器只是扩展它们装饰的功能的函数。这就是前面例子中发生的事情。

首先,我们从 Angular 的核心模块@angular/core中导入这个装饰器。然后我们将装饰器放在我们的AppComponent类的正上方。装饰器以一个 JavaScript 对象作为其参数来描述组件。该对象包含以下内容:

  • selector:这是组件在应用程序的任何部分中被调用时将被识别为的内容。因为这个组件是您的应用程序的入口点,它将直接在 body 中使用,包括其选择器:
<!--./src/index.html-->
...
<body>  
 <app-root></app-root>  </body>
...
  • templateUrl:组件将模板呈现到视图中。我们需要一种方法来告诉组件要呈现哪个模板。这可以通过templatetemplateUrl属性实现。template属性接受 HTML 内容的字符串,而templateUrl接受模板 HTML 文件的 URL。

  • styleUrls:这是应用于定义模板的样式 URL 的数组。

实际组件的类(并且正在被装饰)成为与该组件相关的属性和方法的主页。所有这些一起作为一个整体,以创建一个可重用的功能,称为组件。

引用组件看起来非常相似:

import { Component, OnInit } from '@angular/core';  @Component({  
 selector: 'app-quote',  
 templateUrl: './quote.component.html',  
 styleUrls: ['./quote.component.css']  })  export class QuoteComponent implements OnInit {   
 constructor() { }   
 ngOnInit() {  }  }  

唯一明显的区别是它实现了OnInit接口,该接口具有一个ngOnInit方法,该方法类必须实现。这个方法被称为生命周期钩子,我们很快会讨论它。

模板

模板只是常规的 HTML 文件,但通过插值和指令进行了增强。以下是app.component.html的当前内容,这是AppComponent的模板:

<div style="text-align:center">  
 <h1>  Welcome to {{title}}!!  </h1>  
 <img width="300" src="...">  </div>  <h2>Here are some links to help you start: </h2>  <ul>  
 <li>  <h2><a target="_blank" href="https://angular.io/tutorial">Tour of Heroes</a></h2>  </li>  
 <li>  <h2><a target="_blank" href="https://github.com/angular/angular-cli/wiki">CLI Documentation</a></h2>  </li>  
 <li>  <h2><a target="_blank" href="http://angularjs.blogspot.ca/">Angular blog</a></h2>  </li>  </ul>  

正如您所看到的,这只是普通的 HTML。不过有一件事可能看起来不太熟悉:

<h1>  Welcome to {{title}}!!  </h1>  

用双大括号括起来的title文本可能会让您感到困惑。这被称为插值。title值是根据组件类上的属性值在运行时解析的。不要忘记我们有一个值为app的 title 属性:

title = 'app';

除了像这样绑定值之外,您还可以在模板上执行许多令人惊奇的任务。它们包括以下内容:

  • 属性和事件绑定

  • 双向绑定

  • 迭代和条件

  • 样式和类绑定

  • 简单表达式

  • 管道和指令

与其向您提供与模板和模板语法相关的所有无聊的东西,我们应该讨论它们以及它们与其他即将到来的主题的关系。这样,您可以在示例中看到它们的实际应用,这应该更有趣。

组件样式

组件大量地展示了可重用性。实际上,这是您询问使用组件架构的好处时得到的第一个答案。这就是为什么模板和样式被限定在组件范围内,而不是用沉重的 HTML 和 CSS 来污染应用程序的环境的原因。

组件装饰器参数中的styleUrls属性接受一个指向要应用于组件的样式的 URL 数组。大多数情况下,您只需要一个文件;因此数组将只包含一个 URL 项,在我们的情况下是app.component.css。它目前是空的,但我们可以对其进行实验:

* {  
 background: red; }

*选择器应该选择文档中的所有内容。因此,我们说,选择每个元素并将背景设置为红色。您可能会对结果感到惊讶:

注意实际的 body 标签没有样式,这可能并不直观,因为您使用了全局选择器。组件样式被限定在组件内部;因此样式不能泄漏到包含父级。这就是为什么 body 保持为白色,而AppComponent模板中的内容为红色的原因。

模块

组件用于构建产品中的小型可重用功能。它们与服务、指令、管道等概念一起工作,以实现功能特性。在某些情况下,您可能希望将这些功能从一个项目移动到另一个项目,甚至在一个庞大的项目的不同部分之间移动。因此,您需要一种将它们收集在一起作为功能的方法。这正是模块所做的。

模块是用NgModule装饰器装饰的类。装饰器接受一个对象,就像组件装饰器一样。这个对象描述了你需要关联到这个模块的所有功能成员。可能的成员(但不是所有成员)如下:

  • 声明: 这些包括组件、指令和管道

  • 提供者: 这些包括可注入的服务

  • 导入: 这些包括其他导入的模块

  • 引导: 这是启动应用程序的入口组件

我们已经有一个模块,即AppModule

import { BrowserModule } from '@angular/platform-browser';  import { NgModule } from '@angular/core';  import { AppComponent } from './app.component';  import { QuoteComponent } from './quote/quote.component';  @NgModule({  
 declarations: [  
 AppComponent,  
 QuoteComponent  
 ],  
 imports: [  
 BrowserModule  
 ],  
 providers: [],  
 bootstrap: [ 
 AppComponent 
 ]  })  export class AppModule { }  

让我们花点时间描述一下这个模块中的项目:

  • 声明: AppComponentQuoteComponent是组件。因此,它们属于这个类别。在生成引言组件后,Angular CLI 做的一件了不起的事情是自动将其添加到声明中。如果没有这样做,即使在应用程序的某个地方使用组件选择器,引言组件的内容仍然不会显示,并且您将在控制台中收到错误。

  • 导入: BrowserModule是一个模块。它是一个包含常见浏览器任务的模块,特别是用于模板的指令,如*ngFor等。

  • 提供者: 由于我们还没有任何服务,可以省略提供者,或者将数组留空。

  • 引导: 应用程序模块是我们的入口模块。因此,它应该定义入口组件,即AppComponent。这就是bootstrap属性的作用。

单元测试

虽然我们不会在本书的最后一章之前涵盖测试,但养成测试的习惯是值得的。这就是为什么我们要在这里探索测试组件的简单性。

基本上,Angular 提供了一个测试组件的抽象层,借助TestBed。在你能看到你的组件是否按计划运行之前,你不需要运行整个应用程序。一个简单的测试已经与我们的应用组件的 CLI 脚手架捆绑在一起。它可以在文件旁边找到(这是一个常见且良好的做法),如app.component.spec.ts

让我们查看这个文件的内容:

import { TestBed, async } from '@angular/core/testing';  import { AppComponent } from './app.component';  describe('AppComponent', () => {

});

首先,我们从@angular/core/testing导入测试工具和要测试的组件,即AppComponent。还创建了一个describe块,其中包含了给定功能(AppComponent)的测试套件集,但是为空的。

在开始编写测试套件之前,我们需要为组件配置一个临时测试模块。这是在beforeEach块中完成的:

//...
describe('AppComponent', () => {  
 beforeEach(async(() => {  
 TestBed.configureTestingModule({  
 declarations: [  AppComponent  ],  
 }).compileComponents();  
 }));
 // ...
});

在实际应用中,我们可以创建AppModule,其中AppComponent作为声明。在这里,我们只需要一个简单的模块,其中包含AppComponent,这要归功于TestBedconfigureTestingModule模块使这成为可能。

接下来,我们可以开始编写对我们想要检查的任何场景的测试套件。首先,让我们检查AppComponent是否存在:

describe('AppComponent', () => {  
 it('should create the app', async(() => {  
 const fixture = TestBed.createComponent(AppComponent);  
 const app = fixture.debugElement.componentInstance;  
 expect(app).toBeTruthy();  
 }));
});

在使用createComponent()创建组件本身之后,我们首先尝试使用componentInstance创建组件的实例。

当我们使用expect断言来查看组件是否存在时,实际的检查是完成的,使用toBeTruthy()

我们还可以检查组件属性的内容:

it(`should have as title 'app'`, async(() => {  
 const fixture = TestBed.createComponent(AppComponent);  
 const app = fixture.debugElement.componentInstance;  
 expect(app.title).toEqual('app');  
}));

通过app作为组件的一个实例,您可以访问此实例上的属性和方法。我们刚刚测试了app.title的初始值是否等于app

最后的测试套件实际上检查了值的 DOM:

it('should render title in a h1 tag', async(() => {  
 const fixture = TestBed.createComponent(AppComponent);  
 fixture.detectChanges();  
 const compiled = fixture.debugElement.nativeElement; expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!!');  }));

请注意,在这个测试套件中调用了detectChanges。这会启动模板上的绑定(如果有的话)。然后,我们不是创建一个实例,而是抓住编译后的元素,查询它的h1标签,并检查标签的文本内容是否包含Welcome to app

要运行这些测试,请执行以下命令:

ng test

这应该启动 Karma,一个隔离的测试环境。您的测试将运行,并且以下内容将被打印到 CLI:

您可能想知道为什么最后一行说4个测试而不是3个;请记住,我们生成的引用组件也有一个单独的测试套件。

摘要

在本章中,您学会了如何创建 Angular 项目以及新项目必需的文件。现在您知道如何创建 Angular 项目,并且构建组件等基本构建块,了解了模块的存在原因,如何将简单样式应用到组件,以及 Angular 中的单元测试是什么样子的。

在下一章中,我们将深入探讨更多组件的创建,并看一些示例在实际中的运用。

第五章:使用 TypeScript 创建高级自定义组件

在上一章中,我们讨论了组件的创建和使用基础知识。这些知识不足以构建健壮的应用程序。我们需要更深入地了解 Angular 令人兴奋的组件,并看看 TypeScript 如何使与组件一起工作变得更容易。

我们将在展示一些实际示例的同时,讨论以下主题:

  • 生命周期钩子: 这些是 Angular 中的类方法,您可以连接到它们。通过实现 TypeScript 接口来实现。

  • ElementRef: 这涉及使用 ElementRef API 在 Angular 中安全地操作和查询 DOM。

  • 视图封装: 您将学习如何将作用域样式应用于 Angular 组件,以及如何更改默认行为。

生命周期钩子

您在类中创建的大多数方法必须由您在某个地方调用,这是编程中的预期模式。这在 Angular 定义的生命周期钩子中并非如此。这些钩子是您为 Angular 在组件/指令的当前状态下内部调用它们而创建的方法。它们在组件或指令的类中创建。

以下钩子在 Angular 组件中可用:

  • ngOnChanges: 记住属性如何绑定到组件。这些属性是响应式的,意味着当它们改变时,视图也会更新。当任何绑定到视图的属性发生变化时,将调用此生命周期方法。因此,您可以在更改反映之前操纵发生的事情。

  • ngOnInit: 这是最常见的生命周期。在使用默认属性绑定初始化组件后调用。因此,在第一个ngOnChanges之后调用。

  • ngDoCheck: 通常,响应性(变更检测)由您处理,但在极端情况下,如果不是这样,您需要自己处理。使用ngDoCheck来检测并对 Angular 无法或不会自行检测的变化做出反应。

  • ngAfterContentInit: 组件内容初始化后调用。

  • ngAfterContentChecked: 在对组件内容进行每次检查后调用。

  • ngAfterViewInit: 在基于组件模板初始化视图后调用。

  • ngAfterViewChecked: 在检查组件视图和组件的子视图后调用。

  • ngOnDestroy: 在组件被销毁之前调用。这是一个清理的好地方。

有些生命周期钩子可能并不立即有意义。你不必担心它们,因为只有在极端情况下才会需要很多这样的钩子。

举个例子可以帮助澄清它们的工作原理。让我们探讨最常见的钩子,即ngOnInit

使用 CLI 命令创建一个新的 Angular 项目。打开应用组件的 TypeScript 文件,并更新导入以包括OnInit

// Code: 5.1
//./src/app/app.component.ts

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

OnInit是一个接口,任何打算实现ngOnInit的类都应该继承它。这在技术上并不是必需的(参见angular.io/guide/lifecycle-hooks#interfaces-are-optional-technically)。

现在,你可以让AppComponent类实现这个接口:

// Code: 5.1 //./src/app/app.component.ts

@Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.css']  })  export class AppComponent implements OnInit {  title: string = 'Items in Bag';  items: Array<string> = [];  loading: boolean = false;  
 ngOnInit () {  this.loading = true;  
 setTimeout(() => {  this.items = [  'Pen',  'Note',  'Mug',  'Charger',  'Passport',  'Keys'  ]  this.loading = false;  }, 3000)  }  }

我们试图模拟一种异步行为,其中的值在将来被解析。这种操作最好在应用程序初始化时完成,这就是为什么我们在ngOnInit方法中处理这个操作。一旦组件准备就绪,Angular 就会调用这个钩子,它将在三秒后设置项目数组。

我们甚至可以在值到来之前就将其绑定到视图上。当值可用时,Angular 将始终更新视图:

<!-- Code: 5.1 -->
<!-- ./src/app/app.component.html --> 
<div style="text-align:center">  
 <h1>  {{title}}!!  </h1>  
 <h4 *ngIf="loading">Please wait...</h4>  
</div>  
<ul>  
 <li *ngFor="let item of items">{{item}}</li>  
</ul>

在 Angular 模板中迭代列表时,我们使用*ngFor 结构指令,如前面的例子所示。*ngIf结构指令类似于*ngFor,但用于根据组件上的布尔属性显示 DOM 元素。

像往常一样,用ng serve运行应用程序,你将首先看到以下内容:

三秒后,“请稍候...”文本将消失,你将看到你的项目列表:

DOM 操作

在 Angular 1.x 中,触及 DOM 似乎是神秘的;不是说你不能,但不知何故它会反过来伤害你。这很讽刺,因为作为网页设计师/开发者,我们所做的就是绘制 DOM,而这是不可能的,如果不对其进行操作。

使用 Angular 2+,这变得非常容易。Angular 抽象了 DOM,并为你提供了浅拷贝来操作。然后它负责在不伤害任何人的情况下将其放回。使用 TypeScript 会更有趣,因为你的编辑器可以为你提示大多数 DOM 属性方法。

ElementRef

实现 DOM 操作的 API 是ElementRef。让我们基于www.w3schools.com/howto/howto_js_tabs.asp上的基本演示构建一个使用这个 API 的选项卡组件。

通过使用 CLI 生成命令生成一个新组件:

ng g component tab

将模板作为子级添加到我们的应用组件中,就在*ngFor指令之后:

<ul>   <li *ngFor="let item of items">{{item}}</li>  
</ul>  

<!--Add tab component to app-->
<app-tab></app-tab>

然后,用以下内容替换组件的模板:

<!--./src/app/tab/tab.component.css-->
<div class="tab">  
 <button class="tablink" (click)="openTab($event, 'London')">London</button> <button class="tablink" (click)="openTab($event, 'Paris')">Paris</button> <button class="tablink" (click)="openTab($event, 'Tokyo')">Tokyo</button> </div>  <div id="London" class="tabcontent">  <h3>London</h3>  <p>London is the capital city of England.</p> </div> <div id="Paris" class="tabcontent">   <h3>Paris</h3>   <p>Paris is the capital of France.</p>  </div> <div id="Tokyo" class="tabcontent">   <h3>Tokyo</h3>   <p>Tokyo is the capital of Japan.</p> </div>

你应该在浏览器上看到结果,如下面的截图所示:

让我们添加一些样式来创建一个选项卡的外观:

// based on styles from the base sample

/* ./src/app/tab/tab.component.css */
div.tab {
  overflow: hidden;
  border: 1px solid #ccc;
  background-color: #f1f1f1;
  }  div.tab button {
  background-color: inherit;
  float: left;
  border: none;
  outline: none;
  cursor: pointer;
  padding: 14px 16px;
  transition: 0.3s;
  } div.tab button:hover {
  background-color: #ddd;
  }   div.tab button.active {
  background-color: #ccc;
  }   .tabcontent {   padding: 6px 12px;
  border: 1px solid #ccc;
 border-top: none; }

有了样式,你应该看到下面截图中显示的结果:

现在是开始操作 DOM 的时候了。我们首先需要通过 CSS 默认隐藏所有选项卡内容;然后可以在 TypeScript 中激活它们:

.tabcontent {  
 display: none;   }

钩入内容初始化

为了确保能够访问 DOM,我们需要钩入ngAfterContentInit生命周期方法。在这个方法中,我们可以使用ElementRef来查询 DOM 并操作它:

import { Component, ElementRef, OnInit, AfterContentInit } from '@angular/core';  @Component({
  selector: 'app-tab',
  templateUrl: './tab.component.html',
  styleUrls: ['./tab.component.css']
  })  export class TabComponent implements OnInit, AfterContentInit {  tabContents: Array<HTMLElement>;
 tabLinks: Array<HTMLElement>;  
 constructor(
  private el: ElementRef
  ) { }

  ngOnInit() {}

  ngAfterContentInit() {
 // Grab the DOM
  this.tabContents = this.el.nativeElement.querySelectorAll('.tabcontent');
  this.tabLinks = this.el.nativeElement.querySelectorAll('.tablink');
   }   }  

该类实现了AfterContentInitOnInit,展示了如何实现多个接口。然后,我们将按钮声明为HTMLElement链接的数组。选项卡内容也是如此。

就在构造函数中,我们创建一个名为elElementRef实例,我们可以用它来与 DOM 交互。ngAfterContentInit函数在 DOM 内容准备就绪后被调用,这使它成为处理启动时 DOM 操作的理想候选者。因此,我们在那里获取对 DOM 的引用。

我们需要在加载时显示第一个选项卡并使第一个选项卡链接处于活动状态。让我们扩展ngAfterContentInit来实现这一点:

export class TabComponent implements OnInit, AfterContentInit {
  tabContents: Array<HTMLElement>;
  tabLinks: Array<HTMLElement>;
  constructor(
  private el: ElementRef
  ) { }
  ngOnInit() {}
  ngAfterContentInit() {
  this.tabContents = this.el.nativeElement.querySelectorAll('.tabcontent');
  this.tabLinks = this.el.nativeElement.querySelectorAll('.tablink');

 // Activate first tab

 this.tabContents[0].style.display = "block";
 this.tabLinks[0].className = " active";
 }  }  

这将显示第一个选项卡,如下面的截图所示:

处理 DOM 事件

最后要做的事情是为点击事件添加事件侦听器并开始切换选项卡。在前面的模板中,我们为每个按钮附加了点击事件:

<button class="tablink" (click)="open($event, 'London')">London</button> <button class="tablink" (click)="open($event, 'Paris')">Paris</button> <button class="tablink" (click)="open($event, 'Tokyo')">Tokyo</button>

openTab方法是事件处理程序。让我们实现它:

export class TabComponent implements OnInit, AfterContentInit {
  tabContents: Array<HTMLElement>;
  tabLinks: Array<HTMLElement>;
  constructor(
  private el: ElementRef
  ) { }

 // ...

 open(evt, cityName) {
  for (let i = 0; i < this.tabContents.length; i++) {
  this.tabContents[i].style.display = "none";
  }
  for (let i = 0; i < this.tabLinks.length; i++) {
  this.tabLinks[i].className = this.tabLinks[i].className.replace(" active", "");
  }
  this.el.nativeElement.querySelector(`#${cityName}`).style.display = "block"; 
 evt.currentTarget.className += " active"; 
 } }  

当调用该方法时,我们遍历所有选项卡并隐藏它们。我们还遍历按钮并通过用空字符串替换活动类来禁用它们。然后,我们可以显示我们想要打开的选项卡并激活被点击的按钮。

现在当你点击选项卡按钮时,每个选项卡内容都会显示出来:

有不同的方法来解决这个问题,其中一些方法更加高级。我们刚刚展示的例子故意执行 DOM 查询,以向您展示在 Angular 中进行 DOM 操作是多么可能和简单。

视图封装

组件可以配置为以不同的方式应用样式。这个概念被称为封装,这就是我们现在要讨论的内容。

使用 CLI 创建另一个项目,并使用以下命令添加一个额外的组件:

ng g component child

然后,通过应用组件将这个新组件添加到视图中:

// Code 5.2
<!-- ./src/app/app.component.html -->

<div style="text-align:center">   <h1>  This is parent component  </h1>   <app-child></app-child>  </div>  

子组件的模板就是这么简单:

// Code 5.2
<!-- ./src/app/child/child.component.html -->

<h3>This is child component  </h3>  

这只是我们需要了解视图封装策略的最低设置。让我们来探索一下。

模拟

这是默认策略。通过 HTML 全局应用的任何样式(而不是父组件)以及应用到组件的所有样式都将被反映。在我们的例子中,如果我们针对h3并在style.cssapp.component.csschild.component.css中应用样式,只有style.csschild.component.css会被反映。

以下 CSS 是子组件的:

h3 {  color: palevioletred  }

运行上述代码后,子组件视图上的结果如下:

在全局样式和组件本身上应用相同样式到相同元素的情况下,组件样式会覆盖全局样式。例如,假设style.css文件如下:

h3 {
 color: palevioletred }

现在考虑child.component.css文件如下:

h3 {
 color: blueviolet }

h3的颜色将是blueviolet,如下截图所示:

您可以在组件装饰器中设置这个,尽管这并不是必需的,因为Emulated是默认值:

import { Component, OnInit, ViewEncapsulation } from '@angular/core'; @Component({
 selector: 'app-child',
</span>  templateUrl: './child.component.html',
  styleUrls: ['./child.component.css'],
 // Encapsulation: Emulated
 encapsulation: ViewEncapsulation.Emulated })  export class ChildComponent implements OnInit {   constructor() { }
   ngOnInit() { } } 

本地

这种策略类似于模拟,但它禁止全局样式进入组件。将全局样式中的样式保持不变,将封装设置为本地:

@Component({
  selector: 'app-child',
  templateUrl: './child.component.html',
  styleUrls: ['./child.component.css'],
 // Encapsulation: Native
 encapsulation: ViewEncapsulation.Native })

即使全局样式将h3的颜色设置为pinkvioletred,文本颜色仍然是黑色,因为它无法渗透模板:

这是最自由的策略。无论样式设置在哪里--子组件还是父组件--样式都会泄漏到其他组件中:

@Component({
  selector: 'app-child',
  templateUrl: './child.component.html',
  styleUrls: ['./child.component.css'],
 // Encapsulation: Native
 encapsulation: ViewEncapsulation.None })

通过这个设置,您可以通过子组件的样式来为父标签中的h1标签设置样式:

// child component style
h1 {
 color: blueviolet }

这在视图中反映出来,如下图所示:

摘要

希望讨论的高级主题并不复杂或难以理解。你学会了如何实现生命周期钩子,控制组件范围样式的行为,并在渲染后操作 DOM 内容。

如果你只从这一章中学到了一件事,那就是如何使用 TypeScript 实现生命周期接口,并使用 TypeScript 装饰器配置组件。在下一章中,你将学习组件通信以及组件如何通过属性、事件、视图子元素和内容子元素相互交互。

第六章:使用 TypeScript 进行组件组合

使用 TypeScript 编写的组件在保持简短和简单时效果最佳。然而,一个简短和简单的组件很难构建一个完整的应用程序。如何组合执行特定任务的组件并将它们组合在一起以制作可用的应用程序?这就是本章的内容。我们将讨论以下主题:

  • 组件层次结构

  • 不同级别组件之间的通信

我们还将看到一些实际示例,说明组件是如何组合的,以及这些组合的组件如何相互通信。

组件的可组合性

可组合性是组件最突出的特点和卖点。事实上,这就是使组件成为组件的原因。不仅在网络上,而且每当一个实体被称为组件时,它都有与其他组件组合的倾向。

虽然一些组件可以独立运行,但大多数隐式或显式地依赖于其他独立组件来完成特定任务。TypeScript 和模板极大地简化了 Angular 中的组合,使其能够以一种无缝和易于维护的方式将应用程序的各个部分组合在一起。

组合是分层发生的;因此,大多数组件关系要么是父子关系,要么是子父关系。还要记住,如果存在这样的父子关系,那么根据架构,一些组件可能是其他组件的兄弟。

分层组合

一个组合的组件与另一个组件有父子关系,可以是父组件或子组件。存在嵌套链的倾向;因此,没有什么能阻止子组件有一个祖父组件或父组件有一个孙子组件。

以下截图更好地说明了这一点:

在这里,入口 App 组件有两个子组件:CommentListCommentFormCommentList 也有一个子组件,CommentItem。可以说 CommentItemApp 的孙子。也可以说 CommentListCommentForm 是兄弟。

粗箭头显示了数据如何从父组件流向子组件,而虚线箭头显示了数据如何作为事件从子组件推送到父组件。这种数据向下流动和向上移动的说明引导我们进入下一个讨论主题:组件通信。

组件通信

根据我们之前看到的图表,让我们看一些实际示例。开始的推荐位置是从父级到子级的数据流。

父子流程

立即开始并使用 Angular CLI 创建一个新的 Angular 项目。完成后,使用以下内容更新AppComponent

import { Component } from '@angular/core';    @Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.css']  })  export class AppComponent {  title = 'app';  comments = [  {  author: 'Jay Kay',  content: 'TypeScript makes Angular awesome'  },  {  author: 'William',  content: 'Yeah, right!'  },  {  author: 'Raphael',  content: 'Got stuck passing data around'  }  ]  }  

关键区别在于我添加了一个评论数组。这些评论是我们打算传递给子组件的。

让我们使用 Angular CLI 生成命令创建CommentListComponent

ng g component comment-list

创建的组件旨在从父组件AppComponent接收评论列表。当它接收到这个组件时,它可以对它们进行迭代并在屏幕上打印它们:

import { Component, OnInit, Input } from '@angular/core';    @Component({  selector: 'app-comment-list',  templateUrl: './comment-list.component.html',  styleUrls: ['./comment-list.component.css']  })  export class CommentListComponent implements OnInit {  // Received via Imputs @Input() comments;   constructor() { }   
 ngOnInit() {}    }   

Input TypeScript 装饰器用于指定一个类属性将由父组件设置。因此,我们不需要在CommentListComponent.comments上设置任何值,但是我们需要等待直到通过AppComponent传递一个值给它。请记住AppComponent.comments也存在,因此我们可以使用属性绑定将AppComponent.comments传递给CommentListComponent.commentsapp.component.html中:

<div>  <h2>Comments</h2>  <app-comment-list [comments]="comments"></app-comment-list>  </div>  

comments数组是传递给[comments]属性的值。这个属性是我们在CommentListComponent组件中创建和装饰的。

现在您在父组件(AppComponent)上有一个评论数组;您已经通过属性绑定将此组件传递给子组件(CommentListComponent),并且正在使用Input装饰器接收评论列表。您需要做的下一件事是在comment-list.component.html上显示接收到的评论:

<div class="comment-box" *ngFor="let comment of comments">  <h3>{{comment.author}}</h3>  <p>{{comment.content}}</p>  </div>  

*ngFor指令用于遍历评论,获取每条评论,并在我们的视图上显示评论。

这就是输出的样子:

您可以再深入一层,创建一个评论项组件,它只需要一个评论并显示它。创建另一个组件:

ng g component comment-item

添加一个装饰的评论属性,它将从评论列表中接收评论项:

import { Component, OnInit, Input } from '@angular/core';    @Component({  selector: 'app-comment-item',  templateUrl: './comment-item.component.html',  styleUrls: ['./comment-item.component.css']  })  export class CommentItemComponent implements OnInit {  // Decorated comment 
 @Input() comment;   constructor() { }    ngOnInit() {}    }   

通过评论列表父组件将评论传递下去:

<app-comment-item 
 *ngFor="let comment of comments" [comment]="comment">  </app-comment-item>  

comment模板变量不必存在于组件类中。它是从迭代器中获取的。

然后,您可以简单地在comment-item.component.html模板上渲染评论项:

<h3>{{comment.author}}</h3>  <p>{{comment.content}}</p>  

添加另一个子组件说明了嵌套。App | 评论列表 | 评论项是流程。App评论列表的父级,也是评论项的祖父级。评论列表评论项的父级。

转到浏览器,看到,虽然实际上没有任何变化,但我们的代码结构更好了:

拦截属性更改

有时,您可能希望对从父组件流入子组件的数据进行一些调整。您可以使用 getter 和 setter 拦截数据并在将其设置到视图之前对其进行操作。让我们通过将作者名称大写化来演示这一点:

import { Component, OnInit, Input } from '@angular/core';    @Component({  selector: 'app-comment-item',  templateUrl: './comment-item.component.html',  styleUrls: ['./comment-item.component.css']  })  export class CommentItemComponent implements OnInit {   
 private _comment;  constructor() { }    ngOnInit() {}    @Input()  set comment(comment) {  this._comment = Object.assign(comment, {
 author: comment.author.toUpperCase()
 });  }    get comment() {  return this._comment  }    }   

装饰器不再设置在值属性上,而是设置在 setter 属性上。该属性接收来自评论列表(父组件)的评论。然后,它用作者姓名的大写版本覆盖作者属性。getter 只是返回评论,所以您可以从视图中访问它。

在浏览器中的效果如下:

子-父流程

在这个流程中,数据不是向下传递,而是需要沿着链条向上流动。大多数情况下,数据是根据用户在子组件上触发的事件而向上流动的,我们试图通知父组件有关该事件。因此,Angular 允许您在父组件上监听子事件并对事件做出反应。这些事件可以以数据作为有效载荷进行描述。

让我们首先通过评论列表组件在每个评论项上注册双击事件:

<app-comment-item 
 *ngFor="let comment of comments" 
 [comment]="comment" 
 (dblclick)="showComment(comment)">  </app-comment-item>  

然后,您需要在组件类上添加showComment处理程序来处理此事件:

import { 
 Component, 
 OnInit, 
 Input, 
 EventEmitter, 
 Output } from '@angular/core';    @Component({  selector: 'app-comment-list',  templateUrl: './comment-list.component.html',  styleUrls: ['./comment-list.component.css']  })  export class CommentListComponent implements OnInit {    @Input() comments;  @Output() onShowComment = new EventEmitter();    constructor() { }   ngOnInit() {}    showComment(comment) {  this.onShowComment.emit(comment);  }    }   

处理程序使用onShowComment,它被装饰为Output装饰器的输出属性,以发出EventEmitter类型的事件。这个发出的事件是父组件需要监听的。注意评论是如何传递给emit方法的;这显示了我们如何可以从子组件向父组件传递数据。

接下来,我们监听父组件(App)以便发生这个事件:

<div>  <h2>Comments</h2>  <app-comment-list 
 [comments]="comments" 
 (onShowComment)="onShowComment($event)">
 </app-comment-list>  </div>  

请注意,事件绑定注释()用于事件,在这种情况下是onShowComment。绑定指的是EventEmitter,而其值指的是尚未创建的处理程序方法。处理程序方法被调用,我们将来自子组件的值数据作为$event传递。

以下是处理程序的实现:

import { Component } from '@angular/core';    @Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.css']  })  export class AppComponent {  title = 'app';  comments = [  {  author: 'Jay Kay',  content: 'TypeScript makes Angular awesome'  },  // ...  ]    onShowComment(comment) {  alert(comment.content);  }  }   

该方法只是像下面的截图中所示警报评论:

通过父组件访问子组件的属性和方法

除了数据流向和事件向上推送之外,还有其他的通信策略。我们可以使用模板变量从父组件访问子成员。让我们创建一个计数器组件作为示例:

ng g component counter

现在添加一个计数器变量并将其初始化为零:

//counter.component.html
<h5>  {{counter}}  </h5> //counter.component.ts import { Component, OnInit } from '@angular/core';    @Component({  selector: 'app-counter',  templateUrl: './counter.component.html',  styleUrls: ['./counter.component.css']  })  export class CounterComponent implements OnInit {   
 counter: number = 0;    increment() {  this.counter++  }    decrement() {  this.counter--  }    }   

此外,还有两种方法只增加或减少计数器。请注意,没有任何东西调用这些方法;没有按钮附带事件来增加或减少。我们想要做的是从父组件访问这些方法。

为此,在模板中添加组件并使用模板变量:

<div>  <h2>Comments</h2>  <app-comment-list [comments]="comments" (onShowComment)="onShowComment($event)"></app-comment-list>  ...

  <h2>Counter</h2>  <app-counter #counter></app-counter>    </div>  

#counter是一个在模板中任何地方都可以访问的变量。因此,您可以将其用作访问计数器组件的方法和属性的对象:

<div>
  <h2>Comments</h2>
  <app-comment-list [comments]="comments" (onShowComment)="onShowComment($event)"></app-comment-list>

 ... <h2>Counter</h2>
  <app-counter #counter></app-counter>
  <button (click)="counter.increment()">++</button>
  <button (click)="counter.decrement()">--</button>
</div>

这显示了一个带有按钮的按钮计数器,我们可以点击按钮来增加或减少计数器:

使用 ViewChild 访问子成员

如果模板变量感觉不自然,您可以使用ViewChild来实现相同的行为。这允许您将子组件作为类上的变量而不是模板上的变量来访问:

//app.component.ts
import { Component, ViewChild } from '@angular/core';  import { CounterComponent } from './counter/counter.component'    @Component({  selector: 'app-root',  templateUrl: './app.component.html',  styleUrls: ['./app.component.css']  })  export class AppComponent {
  @ViewChild(CounterComponent)  counterComponent: CounterComponent   comments = [  {</span&gt;  author: 'Jay Kay',  content: 'TypeScript makes Angular awesome'  },  // ...  ]    onShowComment(comment) {  alert(comment.content);  }    } 

我们导入计数器组件,并使用ViewChild将其注册为此组件的子组件。然后,我们创建一个CounterComponent类型的counterComponent变量。然后我们可以在模板中使用这个变量:

<app-counter></app-counter>  <button (click)="counterComponent.increment()">++</button>  <button (click)="counterComponent.decrement()">--</button>  

总结

现在,您可以通过编写小型、可维护的组件,并使用组合使它们相互交互,从而将组件作为构建块来使用。在本章中,您学习了组件体系结构中层次继承的含义,数据如何在层次树中上下流动,以及组件如何相互交互。

在下一章中,我们将探讨一种更加集中的交互策略,即使用服务。这将帮助我们创建组件将共享的逻辑,从而保持我们的代码库非常干净(不重复自己)。

第七章:使用类型化服务分离关注点

本章在前一章的基础上构建,展示了更多关于应用程序构建模块内部通信的技术。在本章中,您将学习以下主题:

  • 服务和依赖注入(DI)概念

  • 使用服务进行组件通信

  • 使用服务编写数据逻辑

要更好地理解服务,您需要至少了解依赖注入的基本概念。

依赖注入

在 TypeScript 中编写 Angular 要求您的构建模块(组件、指令、服务等)都是以类的形式编写的。它们只是构建模块,这意味着它们在成为功能模块之前需要相互交织,从而形成一个完整的应用程序。

这种交织的过程可能会非常令人望而生畏。因此,让我们首先了解问题。例如,考虑以下 TypeScript 类:

export class Developer {
 private skills: Array<Skill>;
 private bio: Person;
 constructor() {
 this.bio = new Person('Sarah', 'Doe', 24, 'female');
 this.skills = [
 new Skill('css'), 
 new Skill('TypeScript'), 
 new Skill('Webpack')
 ];
 }
}

PersonSkill类的实现就像下面这样简单:

// Person Class
export class Person {
 private fName: string;
 private lName: string;
 private age: number;
 private gender: string;
 constructor(
 fName: string, 
 lName: string, 
 age: number, 
 gender: string, 
 ) {
 this.fName = fName;
 this.lName = lName;
 this.age = age;
 this.gender = gender;
 }
}

// Skill Class
export class Skill {
 private type: string;
 constructor(
 type: string
 ) {
 this.type = type;
 }
}

前面的示例是非常实用和有效的代码,直到您开始使用这个类创建更多类型的开发人员。由于所有实现细节都与一个类绑定,因此实际上无法创建另一种类型的开发人员;因此,这个过程并不灵活。在可以用于创建更多类型的开发人员之前,我们需要使这个类更加通用。

让我们尝试改进Developer类,使其从构造函数中接收创建类所需的所有值,而不是在类中设置它:

export class Developer {
 private skills: Array<Skills>;
 private bio: Person;
 constructor(
 fName: string, 
 lName: string, 
 age: number, 
 gender: string, 
 skills: Array<string>
 ) {
 this.bio = new Person(fName, lName, age, gender);
 this.skills = skills.map(skill => new Skill(skill));
 }
}

这么少的代码就有了这么多的改进!我们现在使用构造函数使代码更加灵活。通过这个更新,您可以使用Developer类来创建所需数量的开发人员类型。

尽管这个解决方案看起来像是能拯救一天,但系统中仍然存在紧密耦合的问题。当PersonSkill类中的构造函数发生变化时会发生什么?这意味着您将不得不回来更新Developer类中对此构造函数的调用。以下是Skill中这种变化的一个例子:

// Skill Class
export class Skill {
 private type: string;
 private yearsOfExperience: number;
 constructor(
 type: string,
 yearsOfExperience: number
 ) {
 this.type = type;
 this.yearsOfExperience = yearsOfExperience
 }
}

我们为yearsOfExperience类添加了另一个字段,它是一个数字类型,表示开发人员练习所声称技能的时间有多长。为了使Developer中实际工作,我们还必须更新Developer类:

export class Developer {
 public skills: Array<Skill>;
 private bio: Person;
 constructor(
 fName: string, 
 lName: string, 
 age: number, 
 gender: string, 
 skils: Array<any>
 ) {
 this.bio = new Person(fName, lName, age, gender);
 this.slills = skills.map(skill => 
 new Skill(skill.type, skill.yearsOfExperience));
 }
}

每当依赖项发生变化时更新这个类是我们努力避免的。一个常见的做法是将依赖项的构造函数提升到类本身的构造函数中:

export class Developer {
 public skills: <Skill>;
 private person: Person;
 constructor(
 skill: Skill,
 person: Person
 ) {}
}

这样,Developer就不太了解SkillPerson的实现细节。因此,如果它们在内部发生变化,Developer不会在意;它仍然保持原样。

事实上,TypeScript 提供了一个高效的简写:

export class Developer {
 constructor(
 public skills: <Skill>,
 private person: Person
 ) {}
}

这个简写将隐式声明属性,并通过构造函数将它们分配为依赖项。

这还不是全部;提升这些依赖项还引入了另一个挑战。我们如何在应用程序中管理所有依赖项,而不失去它们应该在哪里的轨迹?这就是依赖注入的作用。这不是 Angular 的事情,而是在 Angular 中实现的一种流行模式。

让我们开始在 Angular 应用程序中看 DI 的实际应用。

组件中的数据

为了更好地理解服务和 DI 的重要性,让我们创建一个简单的应用程序,其中包含一个显示用户评论列表的组件。创建应用程序后,您可以运行以下命令来生成所需的组件:

ng g component comment-list

使用以下片段更新组件的代码:

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

@Component({
 selector: 'app-comment-list',
 templateUrl: './comment-list.component.html',
 styleUrls: ['./comment-list.component.css']
})
export class CommentListComponent implements OnInit {

 comments: Array<any>
 constructor() { }

 ngOnInit() {
 this.comments = [
 {
 author: 'solomon',
 content: `TypeScript + Angular is amazing`
 },
 {
 author: 'lorna',
 content: `TypeScript is really awesome`
 },
 {
 author: 'codebeast',
 content: `I'm new to TypeScript`
 },
 ];
 }

}

该组件有一个comments数组,在组件通过ngOnInit生命周期初始化后,将使用硬编码的数据填充。现在我们需要遍历数组列表并在 DOM 上打印:

<div class="list-group">
 <a href="#" class="list-group-item" *ngFor="let comment of comments">
 <h4 class="list-group-item-heading">{{comment.author}}</h4>
 <p class="list-group-item-text">{{comment.content}}</p>
 </a>
</div>

您需要在入口(应用)组件中包含该组件才能显示出来:

<div class="container">
 <h2 class="text-center">TS Comments</h2>
 <div class="col-md-6 col-md-offset-3">
 <app-comment-list></app-comment-list>
 </div>
</div>

您的应用程序应该如下所示(记得包含 Bootstrap,就像在第二章中看到的那样,使用 TypeScript 入门):

这个例子是有效的,但魔鬼在细节中。当另一个组件需要评论列表或列表的一部分时,我们最终会重新创建评论。这就是在组件中拥有数据的问题所在。

数据类服务

为了重用和可维护性,我们需要将组件中的逻辑关注点抽象出来,让组件只作为一个呈现层。这是 TypeScript 在 Angular 中发挥作用的用例之一。

您首先需要使用以下命令创建一个服务:

ng g service comment

这将创建您的服务类./src/app/comment.service.ts,其中包含一个框架内容。使用以下内容更新内容:

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

@Injectable()
export class CommentService {
 private comments: Array<any> = [
 {
 author: 'solomon',
 content: `TypeScript + Angular is amazing`
 },
 {
 author: 'lorna',
 content: `TypeScript is really awesome`
 },
 {
 author: 'codebeast',
 content: `I'm new to TypeScript`
 }
 ];
 constructor() {}

 getComments() {
 return this.comments;
 }
}

现在这个类会执行我们的组件应该对数据执行的操作,并且使用getComments方法获取数据,该方法简单地返回一个评论数组。CommentService类也被装饰了;这不是必需的,除非类有待解决的依赖关系。尽管如此,良好的实践要求我们始终使用Injectable进行装饰,以知道一个类是一个服务。

回到我们的列表组件,我们只需导入类,从构造函数中解析依赖项以创建服务类的实例,然后用getComments的返回值填充属性:

import { Component, OnInit } from '@angular/core';
import { CommentService } from '../comment.service';

@Component({
 selector: 'app-comment-list',
 templateUrl: './comment-list.component.html',
 styleUrls: ['./comment-list.component.css']
})
export class CommentListComponent implements OnInit {
 private comments: Array<any>;
 constructor(
 private commentService: CommentService
 ) { }

 ngOnInit() {
 this.comments = this.commentService.getComments();
 }

}

让我们尝试在浏览器中运行应用程序,看看当前的更改是否仍然按预期工作:

该死,不行!它刚刚爆炸了。出了什么问题?错误消息显示没有为 CommentService 提供程序!

请记住,当我们使用ngCLI 命令脚手架组件时,CLI 不仅会创建一个组件,还会将其添加到ngModule装饰器的声明数组中:

// ./src/app/app.module.ts
declarations: [
 AppComponent,
 // New scaffolded component here
 CommentListComponent
 ],

模块需要知道哪些组件和服务属于它们的成员。这就是为什么组件会自动添加给你的原因。但是对于服务来说情况并不相同,因为当你通过 CLI 工具创建服务类时,CLI 不会自动更新模块(它会在脚手架期间警告你)。我们需要通过providers数组手动添加服务:

import { CommentService } from './comment.service';
//...

@NgModule({
 //...
 providers: [
 CommentService
 ],
})
export class AppModule { }

现在再次运行应用程序,看看我们的服务现在如何驱动应用程序,控制台中不再有错误:

如果需要操作数据,则必须在服务中进行,而不是在组件中进行。假设您想通过双击列表中的每个项目来删除评论,那么在组件中接收事件是可以的,但实际的删除操作应该由服务处理。

首先为列表项添加事件监听器:

<a href="#" class="list-group-item" (dblclick)="removeComment(comment)" *ngFor="let comment of comments">
 <h4 class="list-group-item-heading">{{comment.author}}</h4>
 <p class="list-group-item-text">{{comment.content}}</p>
 </a>

dblclick事件是通过双击项目触发的。当这种情况发生时,我们调用removeComment方法,同时传递我们想要从项目中删除的评论。

这是组件中removeComment的样子:

removeComment(comment) {
 this.comments = this.commentService.removeComment(comment);
}

正如你所看到的,它除了调用服务上的一个方法之外,什么也不做,该方法也被称为removeComment。这个方法实际上负责从评论数组中删除项目:

// Comment service
removeComment(removableComment) {
 // find the index of the comment
 const index = this.comments.findIndex(
 comment => comment.author === removableComment.author
 );
 // remove the comment from the array
 this.comments.splice(index, 1);
 // return the new array
 return this.comments;
 }

组件与服务的交互

这是服务的一个非常方便的用例。在第六章中,使用 TypeScript 进行组件组合,我们讨论了组件如何相互交互,并展示了不同的方法。其中一种方法被遗漏了--使用服务作为不同组件的事件中心/通信平台。

再假设,当列表中的项目被点击时,我们使用评论列表组件的兄弟组件来显示所选评论的详细视图。首先,我们需要创建这个组件:

ng g component comment-detail

然后,您可以更新app.component.html文件以显示添加的组件:

<div class="container">
 <h2 class="text-center">TS Comments</h2>
 <div class="col-md-4 col-md-offset-2">
 <app-comment-list></app-comment-list>
 </div>
 <div class="col-md-4">
 <!-- Comment detail component -->
 <app-comment-detail></app-comment-detail>
 </div>
</div>

现在,我们需要定义我们的组件做什么,因为它现在是空的。但在此之前,让我们更新评论服务,使其也作为列表组件和兄弟详细组件之间的中心:

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

@Injectable()
export class CommentService {
 private commentSelectedSource = new Subject<any>();
 public commentSelected$ = this.commentSelectedSource.asObservable();

 private comments: Array<any> = [
 // ...
 ];

 // ...

 showComment(comment) {
 this.commentSelectedSource.next(comment);
 }
}

现在,服务使用 Rx 主题来创建一个流和一个监听器,通过它传递和获取所选评论。commentSelectedSource对象负责在点击评论时向流中添加评论。commetSelected$对象是一个我们可以订阅并在点击此评论时执行操作的可观察对象。

现在,立即返回到您的组件,并添加一个点击事件来选择评论项:

<div class="list-group">
 <a href="#" class="list-group-item" 
 (dblclick)="removeComment(comment)" 
 *ngFor="let comment of comments"
 (click)="showComment(comment)"
 >
 <h4 class="list-group-item-heading">{{comment.author}}</h4>
 <p class="list-group-item-text">{{comment.content}}</p>
 </a>
</div>

点击事件触发组件上的showComment方法,然后调用服务上的showComment

showComment(comment) {
 this.commentService.showComment(comment);
}

我们仍然需要更新评论详细组件,以便订阅我们在类中创建的可观察对象:

import { Component, OnInit } from '@angular/core';
import { CommentService } from '../comment.service';

@Component({
 selector: 'app-comment-detail',
 templateUrl: './comment-detail.component.html',
 styleUrls: ['./comment-detail.component.css']
})
export class CommentDetailComponent implements OnInit {

 comment: any = {
 author: '',
 content: ''
 };
 constructor(
 private commentService: CommentService
 ) { }

 ngOnInit() {
 this.commentService.commentSelected$.subscribe(comment => {
 this.comment = comment;
 })
 }

}

通过ngOnInit生命周期钩子,我们能够在组件准备就绪后创建对可观察对象的订阅。有一个评论属性将绑定到视图,这个属性通过订阅在每次点击评论项时更新。以下是显示所选评论的组件的模板:

<div class="panel panel-default" *ngIf="comment.author">
 <div class="panel-heading">{{comment.author}}</div>
 <div class="panel-body">
 {{comment.content}}
 </div>
</div>

您可以重新启动应用程序并尝试选择评论。您应该看到以下行为:

服务作为实用工具

除了管理状态和组件交互之外,服务还以处理实用操作而闻名。假设我们想要在评论应用中开始收集新评论。我们对表单还不太了解,所以我们可以使用浏览器的提示框。我们期望用户通过提示框中的同一文本框传递用户名和内容,如下所示:

<username>: <comment content>

因此,我们需要一个实用方法来从文本框中提取这些部分,形成一个具有作者和内容属性的评论对象。让我们从评论列表组件中收集信息开始:

showPrompt() {
 const commentString = window.prompt('Please enter your username and content: ', 'username: content');
 const parsedComment = this.commentService.parseComment(commentString);
 this.commentService.addComment(parsedComment);
 }

showPrompt()方法用于收集用户输入,并将输入传递给服务中的parseComment方法。这个方法是一个实用方法的例子,我们很快会实现它。我们还将实现addComment方法,该方法将使用解析后的评论来更新评论列表。接下来,在视图中添加一个按钮,并添加一个点击事件监听器来触发showPrompt

<button class="btn btn-primary" 
 (click)="showPrompt()"
>Add Comment</button>

将这两种方法添加到评论服务中:

parseComment(commentString) {
 const commentArr = commentString.split(':');
 const comment = {
 author: commentArr[0].trim(),
 content: commentArr[1].trim()
 }
 return comment;
 }

 addComment(comment) {
 this.comments.unshift(comment);
 }

parseComment方法接受一个字符串,拆分字符串,并获取评论的作者和内容。然后返回评论。addComment方法接受一个评论并将其添加到现有评论列表中。

现在,您可以开始添加新评论,如下面的截图所示:

摘要

本章介绍了数据抽象中许多有趣的概念,同时利用了依赖注入的强大功能。您学会了组件如何使用服务作为中心相互交互,数据和逻辑如何从组件中抽象到服务中,以及如何在服务中处理可重用的实用代码以保持应用程序的清晰。在下一章中,您将学习 Angular 中表单和 DOM 事件的实际方法。

第八章:使用 TypeScript 进行更好的表单和事件处理

让我们谈谈表单。自本书开始以来,我们一直在避免在示例中使用表单输入。这是因为我想把整个章节都专门用于表单。我们将涵盖尽可能多的内容,以构建收集用户信息的业务应用程序。以下是您可以从本章中期待的内容:

  • 类型化表单输入和输出

  • 表单控件

  • 验证

  • 表单提交和处理

  • 事件处理

  • 控件状态

为表单创建类型

我们希望尽可能地利用 TypeScript,因为它简化了我们的开发过程,并使我们的应用行为更可预测。因此,我们将创建一个简单的数据类作为表单值的类型。

首先,创建一个新的 Angular 项目来跟随示例。然后,使用以下命令创建一个新的类:

ng g class flight

该类在app文件夹中生成;用以下数据类替换其内容:

export class Flight {
 constructor(
 public fullName: string,
 public from: string,
 public to: string,
 public type: string,
 public adults: number,
 public departure: Date,
 public children?: number,
 public infants?: number,
 public arrival?: Date,
 ) {}
}

这个类代表了我们的表单(尚未创建)将拥有的所有值。以问号(?)结尾的属性是可选的,这意味着当相应的值未提供时,TypeScript 不会抛出错误。

在着手创建表单之前,让我们从一张干净的纸开始。用以下内容替换app.component.html文件:

<div class="container">
 <h3 class="text-center">Book a Flight</h3>
 <div class="col-md-offset-3 col-md-6">
 <!-- TODO: Form here -->
 </div>
</div>

运行应用并让其保持运行状态。您应该在本地主机的端口4200看到以下内容(记得包括 Bootstrap):

表单模块

现在我们有了一个我们希望表单遵循的约定,让我们现在生成表单的组件:

ng  g component flight-form

该命令还将该组件作为声明添加到我们的App模块中:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { FlightFormComponent } from './flight-form/flight-form.component';

@NgModule({
 declarations: [
 AppComponent,
 // Component added after
 // being generated
 FlightFormComponent
 ],
 imports: [
 BrowserModule
 ],
 providers: [],
 bootstrap: [AppComponent]
})
export class AppModule { }

Angular 表单的特殊之处和易用性在于提供了开箱即用的功能,比如NgForm指令。这些功能不在核心浏览器模块中,而在表单模块中。因此,我们需要导入它们:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

// Import the form module
import { FormsModule } from '@angular/forms';

import { AppComponent } from './app.component';
import { FlightFormComponent } from './flight-form/flight-form.component';

@NgModule({
 declarations: [
 AppComponent,
 FlightFormComponent
 ],
 imports: [
 BrowserModule,
 // Add the form module 
 // to imports array
 FormsModule
 ],
 providers: [],
 bootstrap: [AppComponent]
})
export class AppModule { }

只需导入并将FormModule添加到imports数组中即可。

双向绑定

现在是在浏览器中使用表单组件显示一些表单控件的完美时机。在数据层(模型)和视图之间保持状态同步可能非常具有挑战性,但是使用 Angular 只需要使用FormModule中暴露的一个指令:

<!-- ./app/flight-form/flight-form.component.html -->
<form>
 <div class="form-group">
 <label for="fullName">Full Name</label>
 <input 
 type="text" 
 class="form-control" 
 [(ngModel)]="flightModel.fullName"
 name="fullName"
 >
 </div>
</form>

Angular 依赖于内部的name属性来进行绑定。因此,name属性是必需的。

注意[(ngModel)]="flightModel.fullName";它试图将组件类上的属性绑定到表单。这个模型将是我们之前创建的Flight类型的类:

// ./app/flight-form/flight-form.component.ts

import { Component, OnInit } from '@angular/core';
import { Flight } from '../flight';

@Component({
 selector: 'app-flight-form',
 templateUrl: './flight-form.component.html',
 styleUrls: ['./flight-form.component.css']
})
export class FlightFormComponent implements OnInit {
 flightModel: Flight;
 constructor() {
 this.flightModel = new Flight('', '', '', '', 0, '', 0, 0, '');
 }

 ngOnInit() {}
}

flightModel属性被添加到组件中作为Flight类型,并用一些默认值进行初始化。

将组件包含在应用 HTML 中,以便在浏览器中显示:

<div class="container">
 <h3 class="text-center">Book a Flight</h3>
 <div class="col-md-offset-3 col-md-6">
 <app-flight-form></app-flight-form>
 </div>
</div>

这是你在浏览器中应该看到的:

看到双向绑定的实际效果,使用插值来显示flightModel.fullName的值。然后,输入一个值并查看实时更新:

<form>
 <div class="form-group">
 <label for="fullName">Full Name</label>
 <input 
 type="text" 
 class="form-control" 
 [(ngModel)]="flightModel.fullName"
 name="fullName"
 >
 {{flightModel.fullName}}
 </div>
</form>

这是它的样子:

更多表单字段

让我们动手添加剩下的表单字段。毕竟,我们不能只提供我们的名字就预订航班。

fromto字段将是选择框,其中包含我们可以飞往和飞出的城市列表。这个城市列表将直接存储在我们的组件类中,然后我们可以在模板中对其进行迭代,并将其呈现为选择框:

export class FlightFormComponent implements OnInit {
 flightModel: Flight;
 // Array of cities
 cities:Array<string> = [
 'Lagos',
 'Mumbai',
 'New York',
 'London',
 'Nairobi'
 ];
 constructor() {
 this.flightModel = new Flight('', '', '', '', 0, '', 0, 0, '');
 }
}

数组以字符串形式存储了世界各地的一些城市。现在让我们使用ngFor指令来迭代这些城市,并在表单中使用选择框显示它们:

<div class="row">
 <div class="col-md-6">
 <label for="from">From</label>
 <select type="text" id="from" class="form-control" [(ngModel)]="flightModel.from" name="from">
 <option *ngFor="let city of cities" value="{{city}}">{{city}}</option>
 </select>
 </div>
 <div class="col-md-6">
 <label for="to">To</label>
 <select type="text" id="to" class="form-control" [(ngModel)]="flightModel.to" name="to">
 <option *ngFor="let city of cities" value="{{city}}">{{city}}</option>
 </select>
 </div>
 </div>

整洁!您可以打开浏览器,就在那里看到它:

当点击选择下拉菜单时,会显示一个预期的城市列表:

接下来,让我们添加行程类型字段(单选按钮)、出发日期字段(日期控件)和到达日期字段(日期控件):

<div class="row" style="margin-top: 15px">
 <div class="col-md-5">
 <label for="" style="display: block">Trip Type</label>
 <label class="radio-inline">
 <input type="radio" name="type" [(ngModel)]="flightModel.type" value="One Way"> One way
 </label>
 <label class="radio-inline">
 <input type="radio" name="type" [(ngModel)]="flightModel.type" value="Return"> Return
 </label>
 </div>
 <div class="col-md-4">
 <label for="departure">Departure</label>
 <input type="date" id="departure" class="form-control" [(ngModel)]="flightModel.departure" name="departure">
 </div>
 <div class="col-md-3">
 <label for="arrival">Arrival</label>
 <input type="date" id="arrival" class="form-control" [(ngModel)]="flightModel.arrival" name="arrival">
 </div>
 </div>

数据如何绑定到控件与我们之前创建的文本和选择字段非常相似。主要区别在于控件的类型(单选按钮和日期):

最后,添加乘客数量(成人、儿童和婴儿):

<div class="row" style="margin-top: 15px">
 <div class="col-md-4">
 <label for="adults">Adults</label>
 <input type="number" id="adults" class="form-control" [(ngModel)]="flightModel.adults" name="adults">
 </div>
 <div class="col-md-4">
 <label for="children">Children</label>
 <input type="number" id="children" class="form-control" [(ngModel)]="flightModel.children" name="children">
 </div>
 <div class="col-md-4">
 <label for="infants">Infants</label>
 <input type="number" id="infants" class="form-control" [(ngModel)]="flightModel.infants" name="infants">
 </div>
 </div>

乘客部分都是数字类型,因为我们只需要选择每个类别上船的乘客数量:

验证表单和表单字段

Angular 通过使用其内置指令和状态属性大大简化了表单验证。您可以使用状态属性来检查表单字段是否已被触摸。如果它被触摸但违反了验证规则,您可以使用ngIf指令来显示相关错误。

让我们看一个验证全名字段的例子:

<div class="form-group">
 <label for="fullName">Full Name</label>
 <input 
 type="text" 
 id="fullName" 
 class="form-control" 
 [(ngModel)]="flightModel.fullName" 
 name="fullName"

 #name="ngModel"
 required
 minlength="6">
 </div>

我们刚刚为表单的全名字段添加了三个额外的重要属性:#namerequiredminlength#name属性与name属性完全不同,前者是一个模板变量,通过ngModel值保存有关此给定字段的信息,而后者是通常的表单输入名称属性。

在 Angular 中,验证规则被传递为属性,这就是为什么requiredminlength在那里的原因。

是的,字段已经验证,但用户没有得到任何反馈,不知道出了什么问题。让我们添加一些错误消息,以便在表单字段违反时显示:

<div *ngIf="name.invalid && (name.dirty || name.touched)" class="text-danger">
 <div *ngIf="name.errors.required">
 Name is required.
 </div>
 <div *ngIf="name.errors.minlength">
 Name must be at least 6 characters long.
 </div>
 </div>

ngIf指令有条件地显示这些div元素:

  • 如果表单字段已被触摸但没有值,则会显示“名称是必需的”错误

  • 当字段被触摸但内容长度小于6时,也会显示“名称必须至少为 6 个字符长”。

以下两个屏幕截图显示了浏览器中的这些错误输出:

当输入一个值但值的文本计数不到 6 时,会显示不同的错误:

提交表单

在提交表单之前,我们需要考虑一些因素:

  • 表单是否有效?

  • 在提交之前是否有表单处理程序?

为了确保表单有效,我们可以禁用提交按钮:

<form #flightForm="ngForm">
 <div class="form-group" style="margin-top: 15px">
 <button class="btn btn-primary btn-block" [disabled]="!flightForm.form.valid">
 Submit
 </button>
 </div>
</form>

首先,我们向表单添加一个模板变量称为flightForm,然后使用该变量来检查表单是否有效。如果表单无效,我们将禁用按钮的点击:

要处理提交,向表单添加一个ngSubmit事件。当点击按钮时,将调用此事件:

<form #flightForm="ngForm" (ngSubmit)="handleSubmit()">
 ...
</form>

现在,您可以添加一个类方法handleSubmit来处理表单提交。对于这个例子来说,简单的控制台日志可能就足够了:

export class FlightFormComponent implements OnInit {
 flightModel: Flight;
 cities:Array<string> = [
 ...
 ];
 constructor() {
 this.flightModel = new Flight('', '', '', '', 0, '', 0, 0, '');
 }

 // Handle for submission
 handleSubmit() {
 console.log(this.flightModel);
 }
}

处理事件

表单不是我们从用户那里接收值的唯一方式。简单的 DOM 交互、鼠标点击和键盘交互可能引发事件,这些事件可能导致用户的请求。当然,我们必须以某种方式处理他们的请求。有许多事件我们无法在本书中讨论。我们可以看一下基本的键盘和鼠标事件。

鼠标事件

为了演示两种常见的鼠标事件,单击和双击,创建一个新的 Angular 项目,然后添加以下自动生成的 app.component.html

<div class="container">
 <div class="row">
 <h3 class="text-center">
 {{counter}}
 </h3>
 <div class="buttons">
 <div class="btn btn-primary">
 Increment
 </div>
 <div class="btn btn-danger">
 Decrement
 </div>
 </div>
 </div>
</div>

counter 属性通过插值和增量和减量按钮绑定到视图。该属性在应用程序组件上可用,并初始化为零:

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

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

以下基本上是它的外观:

单击按钮完全没有任何作用。让我们为增量按钮添加一个点击事件,这样每次单击时它都会将 1 添加到计数器属性中:

export class AppComponent {
 counter = 0;
 increment() {
 this.counter++
 }
}

我们需要将此事件处理程序绑定到模板中的按钮,以便在单击按钮时实际增加计数器:

<div class="btn btn-primary" (click)="increment()">
 Increment
</div>

事件通过属性绑定到模板,但将属性包装在括号中。属性值成为组件类上将充当事件处理程序的方法。

我们需要为减量添加相同的功能。假设减量是您希望确保用户打算执行的操作,您可以附加双击事件:

<div class="btn btn-danger" (dblclick)="decrement()">
 Decrement
</div>

如您所见,我们使用 dblclick 事件而不是 click,然后将减量事件处理程序绑定到它。处理程序只是增量处理程序的反向,同时检查我们是否已经达到零:

decrement() {
 this.counter <= 0 ? (this.counter = 0) : this.counter--;
}

以下显示了新事件的执行情况:

键盘事件

您可以通过监听各种键盘事件来跟踪键盘交互。keypress 事件告诉您按钮被点击;如果您附加了监听器,监听器将被触发。您可以以与附加鼠标事件相同的方式附加键盘事件:

<div class="container" (keypress)="showKey($event)" tabindex="1">
 ...
 <div class="key-bg" *ngIf="keyPressed">
 <h1>{{key}}</h1>
 </div>
<div>

具有 key-bg 类的元素在按下键时显示;它显示我们按下的确切键,该键保存在 key 属性中。keyPressed 属性是一个布尔值,当按下键时我们将其设置为 true

事件触发 showKey 监听器;让我们实现它:

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

@Component({
 selector: 'app-root',
 templateUrl: './app.component.html',
 styleUrls: ['./app.component.css']
})
export class AppComponent {
 keyPressed = false;
 key = '';
 // ....
 showKey($event) {
 this.keyPressed = true;
 this.key = $event.key.toUpperCase();
 setTimeout(() => {
 this.keyPressed = false;
 }, 500)
 }
}

showKey 处理程序执行以下操作:

  • 它使用按下的键的值设置了 key 属性

  • 按下的键被表示为小写字符串,因此我们使用 toUpperCase 方法将其转换为大写

  • keyPressed 属性设置为 true,因此显示按下的键,然后在 500 毫秒后设置为 false,因此显示的键被隐藏

当您按下键时(并且 container div 获得焦点),以下屏幕截图显示了发生了什么:

总结

你现在对通过表单或事件收集用户输入有了很多知识。我们还涵盖了表单的重要特性,如输入类型、验证、双向绑定、提交等。我们看到的事件示例涵盖了鼠标和键盘事件以及如何处理它们。所有这些有趣的经历都为你构建业务应用程序做好了准备。

第九章:使用 TypeScript 编写模块、指令和管道

模块化对于构建大型软件系统至关重要,Angular 项目也不例外。当我们的应用开始增长时,在一个入口模块中管理其不同成员变得非常困难和混乱。当你有很多服务、指令和管道时,情况变得更具挑战性。说到指令和管道,我们将花一些时间在本章讨论它们的用例和示例,同时在模块中更好地管理我们的应用程序。

指令

DOM 操作并不总是最好在组件中处理。组件应该尽可能精简;这样,事情就会变得简单,你的代码可以轻松地移动和重用。那么,我们应该在哪里处理 DOM 操作呢?答案是指令。就像你应该将数据操作任务交给服务一样,最佳实践建议你将繁重的 DOM 操作交给指令。

Angular 中有三种指令类型:

  • 组件

  • 属性指令

  • 结构指令

是的,组件!组件是合格的指令。它们是具有直接访问被操作的模板的指令。我们在本书中已经看到了足够多的组件;让我们专注于属性和结构指令。

属性指令

这类指令以为 DOM 添加行为特性而闻名,但不会删除或添加任何 DOM 内容。诸如改变外观、显示或隐藏元素、操作元素属性等等。

为了更好地理解属性指令,让我们构建一些应用于组件模板的 UI 指令。这些指令将在应用时改变 DOM 的行为。

在一个新项目中使用以下命令创建一个新的指令:

ng generate directive ui-button

这将在应用程序文件夹中创建一个空指令,内容如下:

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

@Directive({
 selector: '[appUiButton]'
})
export class UiButtonDirective {
 constructor() {}
}

Directive装饰器首先从@angular/core模块中导入。该装饰器用于任何预期充当指令的类。就像组件上的装饰器一样,指令装饰器接受一个具有选择器属性的对象。当这个选择器应用到 DOM 时,指令的行为就会展现出来。

在这个例子中,我们试图实现的行为是用一个属性来为一个完全未经样式处理的按钮添加样式。假设我们在我们的应用组件中有以下按钮:

<div class="container">
 <button>Click!!</button>
</div>

这只是屏幕上的一个简单无聊的按钮:

要使用我们刚刚创建的属性指令,将其作为无值属性添加到按钮中:

<button appUiButton>Click!!</button>

接下来,找到一种方法来从directive类中访问按钮元素。我们需要这种访问权限来能够直接从类中应用样式到按钮上。感谢ElementRef类,通过构造函数注入到指令中,它给了我们访问原生元素的权限,这就是按钮元素可以被访问的地方:

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

@Directive({
 selector: '[appUiButton]'
})
export class UiButtonDirective {
 constructor(el: ElementRef) {

 }
}

它被注入并解析为el属性。我们可以从该属性访问按钮元素:

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

@Directive({
 selector: '[appUiButton]'
})
export class UiButtonDirective {
 constructor(el: ElementRef) {
 el.nativeElement.style.backgroundColor = '#ff00a6';
 }
}

nativeElement属性让你可以访问应用属性指令的元素。然后你可以像处理 DOM API 一样处理这个值,这就是为什么我们可以访问stylebackgroundColor属性:

你可以看到粉色背景已经有效应用。让我们通过指令为按钮添加更多样式,使其更有趣:

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

@Directive({
 selector: '[appUiButton]'
})
export class UiButtonDirective {
 constructor(el: ElementRef) {
 Object.assign(el.nativeElement.style, {
 backgroundColor: '#ff00a6',
 padding: '7px 15px',
 fontSize: '16px',
 color: '#fff',
 border: 'none',
 borderRadius: '4px'
 })
 }
}

我们不再使用多个点来设置值,而是使用Object.assign方法来减少我们需要编写的代码量。现在,我们在浏览器中有一个更漂亮的按钮,完全由指令进行样式设置:

在指令中处理事件

指令非常灵活,可以根据用户触发的事件应用不同的状态。例如,我们可以为按钮添加一个悬停行为,当鼠标光标移动到按钮上时,按钮会应用不同的颜色(比如黑色):

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

@Directive({
 selector: '[appUiButton]'
})
export class UiButtonDirective {
 constructor(private el: ElementRef) {
 Object.assign(el.nativeElement.style, {
 backgroundColor: '#ff00a6',
 ...
 })
 }

 @HostListener('mouseenter') onMouseEnter() {
 this.el.nativeElement.style.backgroundColor = '#000';
 }

 @HostListener('mouseleave') onMouseLeave() {
 this.el.nativeElement.style.backgroundColor = '#ff00a6';
 }
}

我们在这个文件中引入了一些成员:

  • 我们导入了HostListener,这是一个装饰器,可以扩展类中的方法。它将方法转换为附加到原生元素的事件监听器。装饰器接受事件类型的参数。

  • 我们在onMouseEnteronMouseLeave上定义了两种方法,然后用HostListener装饰这些方法。这些方法在悬停发生时改变按钮的背景颜色。

当我们将鼠标悬停在按钮上时,行为看起来像这样:

动态属性指令

如果我们,作为这个指令的作者,是最终的使用者呢?如果另一个开发人员将指令作为 API 进行重用呢?我们如何使它具有足够的灵活性来处理动态值?当你在编写指令时问自己这些问题时,就是使其动态化的时候了。

一直以来,我们一直在使用指令而没有任何值。实际上,我们可以使用属性值将输入传递到指令中:

<button appUiButton bgColor="red">Click!!</button>

我们添加了一个新属性bgColor,它不是一个指令,而是一个输入属性。该属性用于将动态值发送到指令,如下所示:

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

@Directive({
 selector: '[appUiButton]'
})
export class UiButtonDirective implements OnInit {
 @Input() bgColor: string;
 @Input() hoverBgColor: string;
 constructor(private el: ElementRef) {}

 ngOnInit() {
 Object.assign(this.el.nativeElement.style, {
 backgroundColor: this.bgColor || '#ff00a6',
 padding: '7px 15px',
 fontSize: '16px',
 color: '#fff',
 border: 'none',
 borderRadius: '4px'
 })
 }

 @HostListener('mouseenter') onMouseEnter() {
 console.log(this.bgColor);
 this.el.nativeElement.style.backgroundColor = this.hoverBgColor || '#000';
 }

 @HostListener('mouseleave') onMouseLeave() {
 this.el.nativeElement.style.backgroundColor = this.bgColor || '#ff00a6';
 }
}

以下是我们引入的更改:

  • 引入了两个Input装饰的属性--bgColorbgHoverColor--用作从模板到指令的动态值流。

  • 该指令的设置从构造函数移至ngOnInit方法。这是因为 Angular 的变更检测设置了输入装饰器,构造函数中不会发生这种情况,因此当我们尝试从构造函数中访问它们时,bgColorbgHoverColor是未定义的。

  • 在设置样式时,我们不是硬编码backgroundColor的值,而是使用通过bgColor接收到的值。我们还设置了一个备用值,以防开发人员忘记包含属性。

  • 鼠标进入和鼠标离开事件也会发生同样的事情。

现在,按钮的外观受到动态值的影响:

结构指令

结构指令与属性指令有很多共同之处,但它们在预期行为上有很大不同。与属性指令不同,结构指令预期创建或删除 DOM 元素。这与使用 CSS 显示属性来显示或隐藏元素不同。在这种情况下,元素仍然在 DOM 树中,但在隐藏时对最终用户不可见。

一个很好的例子是*ngIf。当使用*ngIf结构指令从 DOM 中移除元素时,该指令会从屏幕上消失,并从 DOM 树中删除。

为什么会有这样的差异?

您控制 DOM 元素的可见性的方式可能会对应用程序的性能产生重大影响。

举个例子,您可能有一个手风琴,用户预期点击以显示更多信息。用户在查看内容后可能决定隐藏手风琴的内容,并在以后的某个时间再次打开以供参考。很明显,手风琴的内容有可能随时显示和隐藏。

在这种情况下,最好使用一个不隐藏/移除手风琴内容,而只是隐藏它的属性指令。这样在需要时显示和隐藏会非常快速。使用*ngIf这样的结构指令会不断地创建和销毁 DOM 树的一部分,如果被控制的 DOM 内容很庞大,这样做会非常昂贵。

另一方面,当你有一些内容,你确信用户只会查看一次或最多两次时,最好使用*ngIf这样的结构指令。这样,你的 DOM 就不会被大量未使用的 HTML 内容所淹没。

星号的作用

星号在所有结构指令之前都非常重要。如果你从它们中移除星号,*ngIf*ngFor指令将拒绝工作,这意味着星号是必需的。因此,问题是:为什么星号必须在那里呢?

它们在 Angular 中是语法糖,意味着不必以这种方式编写。这才是它们实际上的样子:

<div template="ngIf true">
 <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Nesciunt non perspiciatis consequatur sapiente provident nemo similique. Minus quo veritatis ratione, quaerat dolores optio facilis dolor nemo, tenetur, obcaecati quibusdam, doloremque.</p>
</div>

这个模板属性转换成了 Angular 中的以下内容:

<ng-template [ngIf]="true">
 <div template="ngIf true">
 <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit....</p>
 </div> </ng-template>

看看ngIf现在已经成为了一个普通的 Angular 属性,但被注入到了模板中。当值为false时,模板会从 DOM 树中被移除(而不是隐藏)。以这种方式编写这样的指令只是一大堆代码,所以 Angular 添加了语法糖来简化我们编写ngIf指令的方式:

<div *ngIf="true">
 <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Nesciunt non perspiciatis consequatur sapiente provident nemo similique.</p>
</div>

创建结构指令

我们已经从之前的例子中看到了如何使用结构指令。我们如何创建它们呢?我们通过在终端中运行以下命令来创建它们:

ng generate directive when

是的,我们将指令命名为when。这个指令确实做了*ngIf做的事情,所以希望这样做能帮助你更好地理解你已经使用过的指令的内部工作原理。

使用以下内容更新指令:

import { 
 Directive, 
 Input, 
 TemplateRef, 
 ViewContainerRef } from '@angular/core';

@Directive({
 selector: '[appWhen]'
})
export class WhenDirective {
 constructor(
 private templateRef: TemplateRef<any>,
 private viewContainer: ViewContainerRef) { }
}

我们介绍了一些你还不熟悉的成员。TemplateRef是对我们之前看到的ng-template模板的引用,其中包含了我们正在控制的 DOM 内容。ViewContainerRef是对视图本身的引用。

在视图中使用appWhen指令时,它预期接受一个条件,比如ngIf。为了接收这样的条件,我们需要创建一个装饰过的Input setter 方法:

export class WhenDirective {
 private hasView = false;

 constructor(
 private templateRef: TemplateRef<any>,
 private viewContainer: ViewContainerRef) { }

 @Input() set appWhen(condition: boolean) {
 if (condition && !this.hasView) {
 this.viewContainer.createEmbeddedView(this.templateRef);
 this.hasView = true;
 } else if (!condition && this.hasView) {
 this.viewContainer.clear();
 this.hasView = false;
 }
 }
}

指令中的 setter 方法检查值是否解析为true,然后显示内容并创建视图(如果尚未创建)。当值解析为false时,情况将发生变化。

让我们通过单击我们在属性指令部分劳累的按钮来测试指令。单击按钮时,它会将属性切换为truefalse。此属性绑定到我们创建的指令的值。

使用以下内容更新应用程序组件类:

export class AppComponent {
 toggle = false;
 updateToggle() {
 this.toggle = !this.toggle;
 }
}

updateToggle方法绑定到按钮,以便在用户单击时翻转toggle的值。以下是应用程序组件 HTML 的样子:

<h3 
 style="text-align:center" 
 *appWhen="toggle"
 >Hi, cute directive</h3>

<button 
 appUiButton 
 bgColor="red" 
 (click)="updateToggle()"
>Click!!</button>

点击按钮后,它通过将文本添加或从屏幕中移除来显示或隐藏文本:

管道

我们还没有讨论的另一个有趣的模板功能是管道。管道允许您在模板中就地格式化模板内容。您可以在模板中编写管道来代替在组件中格式化内容。这是一个管道的完美示例:

<div class="container">
 <h2>{{0.5 | percent}}</h2>
</div>

在小数后添加| percent会将值更改为百分比表示,如下面的屏幕截图所示:

以下是使用一个案例管道的另一个示例:

<div class="container">
 <h2>{{0.5 | percent}}</h2>
 <h3>{{'this is uppercase' | uppercase}}</h3>
</div>

uppercase管道将文本字符串转换为大写。以下是前面代码示例的输出:

一些管道接受参数,这些参数有助于微调应用于某些内容的管道的行为。这样的管道的一个例子是货币管道,它接受一个参数来定义要使用哪种货币格式化内容:

<h2>{{50.989 | currency:'EUR':true}}</h2>

以下屏幕截图显示了一个格式良好的值:

管道采用由冒号(:)分隔的两个参数。第一个参数是我们设置为欧元的货币。第二个参数是一个布尔值,表示显示的货币符号的类型。因为值为true,所以显示欧元符号。当值为false时,输出如下:

不使用符号,而是用货币代码(EUR)在值之前。

创建管道

我们已经了解了管道的用途和使用场景。接下来,我们需要了解如何使用 TypeScript 类来创建自定义管道。首先,运行以下命令生成一个空管道:

ng generate pipe reverse

然后,使用以下内容更新生成的类文件:

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

@Pipe({
 name: 'reverse'
})
export class ReversePipe implements PipeTransform {

 transform(value: any, args?: any): any {
 return value.split('').reverse().join('');
 }

}

这个示例接受一个字符串并返回字符串的颠倒版本。ReversePipe类实现了PipeTransform接口,该接口定义了必须以特定签名创建的transform方法,如前所述。

该类使用Pipe装饰器进行装饰,该装饰器以配置对象作为参数。该对象必须定义一个name属性,该属性用作应用到模板时管道的标识符。在我们的情况下,管道的名称是reverse

现在可以将自定义管道应用到模板中:

<h3>{{'watch me flip' | reverse}}</h3> 

当您查看示例时,文本被颠倒,现在以 p 开头,以 w 结尾:

向管道传递参数

我们已经了解了如何创建管道,但我们也知道管道需要参数。我们如何将这些参数添加到我们的自定义管道中?

由于传递给 transform 方法的可选args参数,生成的管道可能已经给出了上一个示例的提示:

transform(value: any, args?: any): any {
 ...
}

假设我们想要定义字符串的颠倒是按字母还是按单词应用,向管道用户提供这种控制的最佳方式是通过参数。以下是更新后的示例:

export class ReversePipe implements PipeTransform {

 transform(value: any, args?: any): any {
 if(args){
 return value.split(' ').reverse().join(' ');
 } else {
 return value.split('').reverse().join('');
 }
 }

}

当提供的参数为true时,我们按单词而不是字母颠倒字符串。这是通过在存在空格的地方拆分字符串来实现的,而不是空字符串。当为false时,我们在空字符串处拆分,这样就可以根据字母颠倒字符串。

现在我们可以在传递参数的同时使用管道:

<h2>{{'watch me flip' | reverse:true}}</h2> 

这是生成的输出:

模块

我们在本文开头提到了模块以及它们如何帮助我们组织项目。考虑到这一点,看一下这个应用模块:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { UiButtonDirective } from './ui-button.directive';
import { WhenDirective } from './when.directive';

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

这是来自指令的一个模块:

examples:import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { ReversePipe } from './reverse.pipe';

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

如果您对细节如此关注,您可能已经注意到我们在指令中从未添加UiButtonDirectiveWhenDirective。在管道示例中也没有添加ReversePipe。这些添加是在运行generate命令时为所有成员自动完成的,除了服务。

对于您创建的所有成员,即组件、指令、管道和服务,您需要将它们包含在其所属的模块中。

模块(通常称为 NgModule)是一个用 NgModule 装饰器装饰的类。这个装饰器接受一个配置对象,告诉 Angular 应用中创建的成员以及它们所属的位置。

以下是不同的属性:

  • declarations:组件、指令和管道必须在 declarations 数组中定义,以便向应用程序公开它们。如果未这样做,将在控制台中记录错误,告诉您省略的成员未被识别。

  • imports:应用程序模块并不是唯一存在的模块。您可以拥有更小、更简单的模块,将相关任务成员组合在一起。在这种情况下,您仍然需要将较小的模块导入到应用程序模块中。这就是 imports 数组的作用。这些较小的模块通常被称为特性模块。特性模块也可以被导入到另一个特性模块中。

  • providers:如果您有抽象特定任务并需要通过依赖注入注入到应用程序中的服务,您需要在 providers 数组中指定这些服务。

  • bootstrapbootstrap 数组只在入口模块中声明,通常是应用程序模块。这个数组定义了应该首先启动哪个组件,或者哪个组件作为应用程序的入口点。该值始终为 AppComponent,因为这是入口点。

总结

您学到了许多概念,从指令和管道到模块。您学到了不同类型的指令(属性和结构性),以及如何创建每种指令。我们还讨论了在创建管道时如何传递参数。在下一章中,我们将讨论 Angular 应用程序中的路由以及 TypeScript 扮演的重要角色。

第十章:SPA 的客户端路由

单页应用程序SPA)是一个用来指代仅从一个服务器路由提供服务但具有多个客户端视图的应用程序的术语。单一服务器路由通常是默认的(/*)。一旦加载了单一服务器路由,客户端(JavaScript)就会接管页面,并开始使用浏览器的路由机制来控制路由。

能够从 JavaScript 控制路由使开发人员能够构建更好的用户体验。本章描述了如何在 Angular 中使用 TypeScript 编写的类、指令等来实现这一点。

就像每一章一样,我们将通过实际示例来做这个。

RouterModule

就像表单一样,Angular 在 CLI 脚手架中默认不生成路由。这是因为你可能在你正在工作的项目中不需要它。要使路由工作,你需要在需要使用它的模块中导入它:

import { RouterModule }   from '@angular/router';

该模块公开了一个静态的forRoot方法,该方法传入一个路由数组。这样做会为导入RouterModule的模块注册和配置这些路由。首先创建一个routes.ts文件在app文件夹中:

import { Routes } from '@angular/router';

export const routes: Routes = [
 {
 path: '',
 component: HomeComponent
 },
 {
 path: 'about',
 component: AboutComponent
 },
 {
 path: 'contact',
 component: ContactComponent
 }
];

Routes类的签名是一个数组,其中包含一个或多个对象。传入的对象应该有一个路径和一个组件属性。路径属性定义了位置,而组件属性定义了应该挂载在定义路径上的 Angular 组件。

然后你可以在AppModule中使用这些数组配置RouterModule。我们已经导入了RouterModule,所以让我们导入routes文件并在imports数组中配置路由:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
//Import RuterModule
import { RouterModule } from '@angular/router';

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

//Imprt routes
import { routes } from './routes';

@NgModule({
 declarations: [
 AppComponent
 ],
 imports: [
 BrowserModule,
 // RouterModule used to
 // configure routes
 RouterModule.forRoot(routes)
 ],
 providers: [],
 bootstrap: [AppComponent]
})
export class AppModule { }

这就是在 Angular 中配置路由所需的全部内容。路由的组件尚未创建,所以如果你尝试运行应用程序,你将在终端中看到相同的错误:

让我们使用 CLI 生成这些组件:

ng generate component home
ng generate component about
ng generate component contact

然后,更新路由配置以导入组件:

import { Routes } from '@angular/router';

import { ContactComponent } from './contact/contact.component';
import { AboutComponent } from './about/about.component';
import { HomeComponent } from './home/home.component';

export const routes: Routes = [
 {
 path: '',
 component: HomeComponent
 },
 {
 path: 'about',
 component: AboutComponent
 },
 {
 path: 'contact',
 component: ContactComponent
 }
];

再次运行应用程序,看看是否摆脱了错误:

路由指令

我知道你迫不及待地想在浏览器中看到示例,但是如果你尝试在端口4200上测试应用程序,你仍然会看到app组件的内容。这是因为我们还没有告诉 Angular 它应该在哪里挂载路由。

Angular 公开了两个重要的路由指令:

  • 路由出口:这定义了路由配置应该挂载的位置。这通常是单页应用程序的入口组件。

  • 路由链接:这用于定义 Angular 路由的导航。基本上,它为锚标签添加功能,以便更好地与 Angular 应用程序中定义的路由一起工作。

让我们替换应用组件模板的内容以利用路由指令:

<div>
 <nav class="navbar navbar-inverse">
 <div class="container-fluid">
 <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
 <ul class="nav navbar-nav">
 <li><a routerLink="/">Home</a></li>
 <li><a routerLink="/about">About</a></li>
 <li><a routerLink="/contact">Contact</a></li>
 </ul>
 </div>
 </div>
 </nav>
 <div class="container">
 <router-outlet></router-outlet>
 </div>
</div>

具有container类的 div 是每个组件在我们访问相应路由时将显示的位置。我们可以通过点击具有routerLink指令的锚标签来浏览每个路由。

打开浏览器,访问端口4200的本地主机。您应该默认看到主页:

尝试在导航栏中点击“关于”或“联系”链接。如果您按照所有步骤操作,您应该看到应用程序用“关于”或“联系”组件替换主页组件:

注意地址栏也会随着我们在配置中定义的路径位置更新:

带有路由的主细节视图

一个非常常见的 UI 模式是有一个项目列表,但关于项目的信息不多。当选择项目、点击或发生鼠标悬停时,会显示每个项目的详细信息。

每个项目通常被称为主项目,而与项目交互后显示的内容被称为子项目或详细信息。

让我们构建一个简单的博客,在主页上显示文章列表,当点击每篇文章时,会显示文章页面,您可以阅读所选文章。

数据源

对于一个基本的例子,我们不需要数据库或服务器。一个包含博客文章的简单 JSON 文件就足够了。在您的app文件夹中创建一个名为db.json的文件,结构如下:

[
 {
 "imageId": "jorge-vasconez-364878_me6ao9",
 "collector": "John Brian",
 "description": "Yikes invaluably thorough hello more some that neglectfully on badger crud inside mallard thus crud wildebeest pending much because therefore hippopotamus disbanded much."
 },
 {
 "imageId": "wynand-van-poortvliet-364366_gsvyby",
 "collector": "Nnaemeka Ogbonnaya",
 "description": "Inimically kookaburra furrowed impala jeering porcupine flaunting across following raccoon that woolly less gosh weirdly more fiendishly ahead magnificent calmly manta wow racy brought rabbit otter quiet wretched less brusquely wow inflexible abandoned jeepers."
 },
 {
 "imageId": "josef-reckziegel-361544_qwxzuw",
 "collector": "Ola Oluwa",
 "description": "A together cowered the spacious much darn sorely punctiliously hence much less belched goodness however poutingly wow darn fed thought stretched this affectingly more outside waved mad ostrich erect however cuckoo thought."
 },
....
]

结构显示了一个帖子数组。每篇文章都有imageID,作者作为收集者,以及作为帖子内容的描述。

默认情况下,TypeScript 在尝试将其导入到 TypeScript 文件中时不会理解 JSON 文件。为了解决这个问题,使用以下声明定义typings

// ./src/typings.d.ts
declare module "*.json" {
 const value: any;
 export default value;
}

博客服务

请记住,我们提到将应用程序的业务逻辑放在组件中是一个坏主意。尽可能地,不建议直接从组件与数据源进行交互。我们将创建一个服务类来代替我们执行相同的操作:

ng generate service blog

使用以下内容更新生成的空服务:

import { Injectable } from '@angular/core';
import * as rawData from './db.json';

@Injectable()
export class BlogService {
 data = <any>rawData;
 constructor() { }

 getPosts() {
 return this.data.map(post => {
 return {
 id: post.imageId,
 imageUrl: `https://res.cloudinary.com/christekh/image/upload/c_fit,q_auto,w_300/${post.imageId}`,
 author: post.collector
 }
 })
 }

 byId(id) {
 return this.data
 .filter(post => post.imageId === id)
 .map(post => {
 return {
 id: post.imageId,
 imageUrl: `https://res.cloudinary.com/christekh/image/upload/c_fit,q_auto,w_300/${post.imageId}`,
 author: post.collector,
 content: post.description
 }
 })[0]
 }

}

让我们谈谈服务中发生的事情:

  1. 首先,我们导入了创建的数据源。

  2. 接下来,我们创建了一个getPosts方法,该方法在转换每个帖子项后返回所有帖子。我们还使用图像 ID 生成图像 URL。这是通过将 ID 附加到 Cloudinary (cloudinary.com/)图像服务器 URL 来完成的。在使用它们之前,这些图像已上传到 Cloudinary。

  3. byId方法以 ID 作为参数,使用 filter 方法找到具有该 ID 的帖子,然后转换检索到的帖子。转换后,我们获取数组中的第一个且唯一的项目。

要公开此服务,您需要将其添加到app模块中的providers数组中:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { BlogService } from './blog.service';

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

创建路由

现在我们有了数据源和与该数据源交互的服务,是时候开始处理将使用这些数据的路由和组件了。在app文件夹中添加一个routes.ts文件,并进行以下配置:

import { Routes } from '@angular/router';

import { HomeComponent } from './home/home.component';
import { PostComponent } from './post/post.component';

export const routes: Routes = [
 {
 path: '',
 component: HomeComponent
 },
 {
 path: 'post/:id',
 component: PostComponent
 }
]

指向post的第二个路由具有一个:id占位符。这用于定义动态路由,这意味着传递的 ID 值可以用于控制挂载组件的行为。

创建之前导入的两个组件:

# Generate home component
ng generate component home

# Generate post component
ng generate component post

更新app模块以导入配置的路由,使用RouterModule

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';

import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { PostComponent } from './post/post.component';
import { BlogService } from './blog.service';
import { routes } from './routes';

@NgModule({
 declarations: [
 AppComponent,
 HomeComponent,
 PostComponent
 ],
 imports: [
 BrowserModule,
 RouterModule.forRoot(routes)
 ],
 providers: [
 BlogService
 ],
 bootstrap: [AppComponent]
})
export class AppModule { }

为了挂载路由器,用以下标记替换 app 组件模板的整个内容:

<div class="wrapper">
 <router-outlet></router-outlet>
</div>

在主页组件中列出帖子

我们在主页上挂载的主页组件预期显示帖子列表。因此,它需要与博客服务进行交互。将类更新为以下内容:

import { Component, OnInit } from '@angular/core';
import { BlogService } from './../blog.service';

@Component({
 selector: 'app-home',
 templateUrl: './home.component.html',
 styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {

 public posts;
 constructor(
 private blogService: BlogService
 ) { }

 ngOnInit() {
 this.posts = this.blogService.getPosts();
 }

}

该组件依赖于BlogService类,该类在构造函数中解析。然后使用blogService实例获取帖子列表并将其传递给posts属性。该属性将绑定到视图。

为了在浏览器中显示这些帖子,我们需要遍历每个帖子并在组件模板中显示它们:

<div class="cards">
 <div class="card" *ngFor="let post of posts">
 <div class="card-content">
 <img src="{{post.imageUrl}}" alt="{{post.author}}">
 <h4>{{post.author}}</h4>
 </div>
 </div>
</div>

当您运行应用程序时,它看起来像这样:

我们需要定义与文章卡片交互的行为。当点击卡片时,我们可以使用路由链接指令导航到帖子页面。但是,因为我们已经看到了,让我们使用第二个选项,即在 TypeScript 方法中定义行为。首先,添加一个事件监听器:

<div class="cards">
 <div class="card" *ngFor="let post of posts" (click)="showPost(post.id)">
 ...
 </div>
</div>

我们打算在点击卡片时调用 showPost 方法。这个方法接收被点击图片的 ID。以下是方法的实现:

import { Router } from '@angular/router';

...
export class HomeComponent implements OnInit {

 public posts;
 constructor(
 private blogService: BlogService,
 private router: Router
 ) { }

 ngOnInit() {
 this.posts = this.blogService.getPosts();
 }

 showPost(id) {
 this.router.navigate(['/post', id]);
 }

}

showPost 方法使用路由器的 navigate 方法来移动到新的路由位置。

使用帖子组件阅读文章

帖子组件只显示带有所有细节的单个帖子。为了显示这个单个帖子,它从 URL 接收参数并将参数传递给博客服务类中的 byId 方法:

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, ParamMap } from '@angular/router';
import { BlogService } from './../blog.service';

@Component({
 selector: 'app-post',
 templateUrl: './post.component.html',
 styleUrls: ['./post.component.css']
})
export class PostComponent implements OnInit {

 public post;
 constructor(
 private route: ActivatedRoute,
 private blogService: BlogService,
 ) { }

 ngOnInit() {
 this.route.params.subscribe(params => {
 this.post = this.blogService.byId(params.id)
 console.log(this.post)
 });
 }

}

ActivatedRoute 类公开了一个 params 属性,它是一个 Observable。您可以订阅这个 Observable 来获取传递给给定路由的参数。我们将 post 属性设置为 byId 方法返回的过滤值。

现在,您可以在模板中显示帖子:

<div class="detail">
 <img src="{{post.imageUrl}}" alt="">
 <h2>{{post.author}}</h2>

 <p>{{post.content}}</p>
</div>

打开应用程序,然后单击每张卡片。它应该带您到它们各自的详细页面:

摘要

在 Angular 中进行路由设置非常重要,可能是你日常项目的一部分。在这种情况下,这对你来说不会是一个全新的概念。这是因为本章已经教会了你一些路由基础知识,构建导航和客户端路由,通过开发一个简单的博客系统来构建主-子视图关系。在下一章中,您将运用所学的知识来构建一个实际使用真实和托管数据的应用程序。

第十一章:使用真实托管的数据

现代 Web 应用程序通常是数据驱动的。我们经常需要从各种资源中创建、读取、更新和删除数据,或者消费 API。Angular 让我们能够轻松地从外部资源中获取数据,以供我们的组件使用。

Angular 提供了一个简单的 HTTP API,为我们的应用程序提供了 HTTP 功能。它建立在现代浏览器暴露的原生 XMLHttpRequest 接口之上,我们可以执行任何这些 HTTP 操作:

  • Get:从资源中请求数据

  • Post:提交数据到资源

  • Put:修改资源中的数据

  • Delete:删除指定的资源

在本章中,我们将学习如何使用 Angular 来消费 API,并使我们的应用程序成为数据驱动的。

Observables

Observables,类似于 promises,帮助处理应用程序中的异步事件。Observables 和 promises 之间的关键区别在于:

  • Observables 可以处理多个值,而 promises 只能调用一次并返回一个值

  • Observables 是可取消的,而 promises 不是

为了使用 Observables,Angular 利用了JavaScript 的响应式扩展RxJs)Observables 库。Angular 在处理 HTTP 请求和响应时广泛使用 Observables;我们将在本章中更多地了解它们。

HTTP 模块

要开始在组件中使用 HTTP,你需要在应用程序模块中安装提供它的HttpModule。首先,导入这个模块:

import { HttpModule } from '@angular/http';

接下来,你需要在应用程序模块中的导入数组中包含这个模块,就在BrowserModule之后:

// app.module.ts
@NgModule({
imports: [
BrowserModule,
HttpModule,
],
})

构建一个简单的 todo 演示应用

让我们构建一个简单的todo应用程序,以更好地理解如何在 Angular 应用程序中处理数据。

Angular-CLI 将被用来快速搭建应用程序。应用程序的 API 将使用 Express.js 构建,我们的 Angular 应用程序将连接到这个 API 来 CRUD todo 数据。

项目设置

使用 CLI 创建一个新项目:

ng new [project name]

ng new命令创建一个新的 angular 应用程序

构建 API

从命令行通过 npm 安装 express、body-parser 和 cors 作为依赖项:

npm install express body-parser cors

如果你使用 npm 5,你不需要在package.json文件中指定-S--save标志来保存为依赖项。

接下来,我们将在 Angular 项目的根文件夹中创建一个server.js文件,其中将包含我们所有的 API 逻辑:

// server.js
const express = require('express');
const path = require('path');
const http = require('http');
const bodyParser = require('body-parser');
const cors = require('cors');
const app = express();
// Get API routes
const route = require('./routes/index');
// Parser for POST data
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// Use CORS
app.use(cors());
// Declare API routes
app.use('/api', route);
/**
* Get port from environment. Default is 3000
*/
const port = process.env.PORT || '3000';
/**
* Create HTTP server.
*/
const server = http.createServer(app);
/**
* Listen on port
*/
app.listen(port, function () {
console.log(`API running on port ${port}`)
} );

这个文件使用了 ES6 的新版本,所以你应该注意一些情况,你的代码编辑器可能不会立即识别它。

/api 路由指向 ./routes/index.js 文件,但我们还没有它。在下一步中,我们将创建它。仍然在 root 目录中,创建一个名为 routes 的文件夹,在其中创建一个名为 index.js 的文件:

// routes/index.js
const express = require('express');
// create a new router object
const router = express.Router();
/* GET api listing. */
router.get('/', (req, res) => {
res.send('api works');
});
module.exports = router;

要启动服务器,请输入以下命令:

node server.js

当服务器开始运行时,这是输出:

在这里我们可以看到服务器正在运行,并且监听在 3000 端口上。

打开浏览器并访问 localhost:3000/api/

如果您可以在前面的图像中看到响应,那么 API 是有效的。现在我们可以引入更复杂的逻辑,这样我们就有实际的数据可以使用了。

安装 diskdb

Diskdb 是一个轻量级的基于磁盘的 JSON 数据库,具有类似于 Node 的 MongoDB API。我们可以使用以下命令安装 diskdb:

npm install diskdb

在目录的根目录创建一个 todos.json 文件。这个文件将作为我们的数据库集合,其中包含我们的待办事项。您可以在这里了解更多关于 diskdb 的信息 www.npmjs.com/package/diskdb

更新 API 端点

让我们更新 routes/index.js 文件,使用新的逻辑来处理我们的 todos:

// routes/index.js
const express = require('express');
const router = express.Router();
// require diskdb
const db = require('diskdb');
db.connect(__dirname, ['todos']);
// store Todo
router.post('/todo', function(req, res, next) {
var todo = req.body;
if (!todo.action || !(todo.isDone + '')) {
res.status(400);
res.json({
error: 'bad data'
});
} else {
db.todos.save(todo);
res.json(todo);
}
});
// get Todos
router.get('/todos', function(req, res, next) {
const todos = db.todos.find();
res.json(todos);
});
// update Todo
router.put('/todo/:id', function(req, res, next) {
const todo = req.body;
db.todos.update({_id: req.params.id}, todo);
res.json({ msg: `${req.params.id} updated`});
});
// delete Todo
router.delete('/todo/:id', function(req, res, next) {
db.todos.remove({
_id: req.params.id
});
res.json({ msg: `${req.params.id} deleted` });
});
module.exports = router;

在前面的代码中,我们能够使用 getpostputdelete 端点更新我们的 API。

接下来,我们将用一些数据填充我们的数据库。更新 todos.json 文件:

[{
"action":"write more code",
"isDone":false,"
_id":"97a8ee67b6064e06aac803662d98a46c"
},{
"action":"help the old lady",
"isDone":false,"
_id":"3d14ad8d528549fc9819d8b54e4d2836"
},{
"action":"study",
"isDone":true,"
_id":"e77cb6d0efcb4b5aaa6f16f7adf41ed6"
}]

现在我们可以重新启动服务器并访问 localhost:3000/api/todos 来查看我们的 API 在运行中:

从数据库中获取的待办事项列表。

创建一个 Angular 组件

接下来,我们将创建一个 todo 组件。我们可以使用 Angular-CLI 轻松完成这个命令:

ng generate component todos

这将生成以下文件:todos.component.tstodos.component.htmltodos.component.ts。todos 组件也会自动导入到 app.module.ts 中。

// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { TodosComponent } from './todos/todos.component';
@NgModule({
declarations: [
AppComponent,
TodosComponent
],
imports: [
BrowserModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }

我们应该确保在 src/index.html 的头标签中添加 <base href="/">。这是为了告诉路由器如何组合导航 URL。当我们使用 Angular-CLI 生成 angular 项目时,index.html 文件会自动创建:

<!-- index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Data</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

创建应用程序路由

接下来,我们将创建一个 /todos 路由,并让我们的应用程序默认重定向到它。

首先,从 @angular/router 导入 RouterModule 并将其添加到 AppModule 的导入数组中:

import { RouterModule } from '@angular/router';
...
imports: [
...
RouterModule.forRoot(ROUTES)
],

ngModule 声明的上方创建一个 ROUTES 数组,并将以下路由定义添加到其中:

const ROUTES = [
{
path: '',
redirectTo: 'todos',
pathMatch: 'full'
},
{
path: 'todos',
component: TodosComponent
}
]

app.component.html文件中,让我们在想要渲染路由的地方添加一个 router-outlet:

<div style="text-align:center">
<h1>
Welcome to {{ title }}!
</h1>
<router-outlet></router-outlet>

创建一个 todos 服务

接下来,我们将创建一个服务,用于处理调用并将我们的组件连接到 express API。使用 Angular-CLI 生成服务:

ng generate service todos

服务已经创建但未注册——要在我们的应用中注册它,我们需要将它添加到主应用模块的 providers 部分。

Angular-CLI 不会自动注册服务。

将 TodosService 添加到 providers 数组中:

import {TodosService} from './todos.service';
...
providers: [TodosService],
...
})
export class AppModule { }

现在,在我们的服务中,我们将发起 HTTP 调用到 express 服务器来执行我们的 CRUD 操作。首先,我们将导入HTTPHeadersrxjs/add/operator/map

import { Injectable } from '@angular/core';
import { Http, Headers} from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
// constructor and methods to execute the crud operations will go in here
}

定义一个构造函数并注入 HTTP 服务:

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
constructor(private http: Http) {}
}
Next, we will define a method that will fetch all todos from the API. Updating todos.service.ts:
// todo.service.ts
...
export class TodosService {
isDone: false;
constructor(private http: Http) {}
// Get all todos
getTodos() {
return this.http
.get('http://localhost:3000/api/todos')
.map(res => res.json());
}
}

在上述代码中,我们利用HttpModule发起了一个简单的get请求到我们的 API 以检索 Todos 列表。然后将请求的响应以 JSON 格式返回。

接下来,我们将编写一个存储 todo 项目的方法,名为addTodos()。这个方法将用于发起存储 todos 的 post 请求。

// todo.service.ts
...
addTodos(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/todo', JSON.stringify(todo), { headers })
.map(res => res.json());
}
}

在上述代码中,我们设置了新的头部,并将Content-Type设置为告诉服务器它将接收什么类型的内容('application/json')。

我们利用http.post()方法发起了一个 post 请求。参数JSON.stringify(todo)表示我们要将新的 todo 以 JSON 编码的字符串形式发送。最后,我们可以以 JSON 格式返回 API 的响应。

接下来,我们将定义一个名为deleteTodo()的删除方法。这个方法将用于发起删除请求。这使我们能够从 todos 列表中删除 todos。再次更新todos.service.ts

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
constructor(private http: Http) {}
getTodos() {
return this.http
.get('http://localhost:3000/api/todos')
.map(res => res.json());
}
addTodos(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/todo', JSON.stringify(todo), { headers })
.map(res => res.json());
}
deleteTodo(id) {
return this.http
.delete(`http://localhost:3000/api/todo/${id}`)
.map(res => res.json());
}
}

在上述代码中,我们定义了deleteTodo()方法,它以要删除的帖子的id作为唯一参数。这个方法发起了一个删除请求到 API,从数据库中移除指定的 todo。API 的响应也以 JSON 格式返回。

最后,我们将定义一个名为updateStatus()的方法。这个方法将用于发起一个put请求来改变 todos 项目的状态。

import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class TodosService {
isDone: false;
constructor(private http: Http) {}
getTodos() {
return this.http
.get('http://localhost:3000/api/todos')
.map(res => res.json());
}
addTodos(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/todo', JSON.stringify(todo), { headers })
.map(res => res.json());
}
deleteTodo(id) {
return this.http
.delete(`http://localhost:3000/api/todo/${id}`)
.map(res => res.json());
}
updateStatus(todo) {
let headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.put('http://localhost:3000/api/todo/' + todo._id, JSON.stringify(todo), {
headers: headers
})
.map(res => res.json());
}
}

在上述代码中,我们创建了一个updateStatus()方法,它类似于addTodos()方法。不同之处在于updateStatus()方法发起了一个put请求。我们还将todo._id连接到被调用的 API 端点上。这使我们能够修改 todos 列表中单个项目的状态。

请记住,我们在我们的服务中使用了 HTTP API,因此,我们应该在app.module.ts中导入HttpModule并将其包含在导入数组中:

import {HttpModule} from '@angular/http';
...
imports: [
HttpModule,
BrowserModule,
RouterModule.forRoot(ROUTES)
],
...

将服务与我们的 todos 组件连接起来

首先,我们必须在 todos 组件中导入 todos 服务:

import {TodosService} from '../todos.service';

然后在组件的构造函数中添加TodosService类:

constructor(private todoService: TodosService) { }

现在,我们将使用 todo 服务来获取创建删除更新todos。

这就是我们的 todos 组件应该看起来的样子:

import { Component, OnInit } from '@angular/core';
import { TodosService } from '../todos.service';
@Component({
selector: 'app-todos',
templateUrl: './todos.component.html',
styleUrls: ['./todos.component.css']
})
export class TodosComponent implements OnInit {
//define data types
todos: any = [];
todo: any;
action: any;
name: any;
isDone: boolean;
constructor(private todoService: TodosService) {}
ngOnInit() {
this.todoService.getTodos().subscribe(todos => {
this.todos = todos;
});
}
addTodos(event) {
event.preventDefault();
let newTodo = {
name: this.name,
action: this.action,
isDone: false
};
this.todoService.addTodos(newTodo).subscribe(todo => {
this.todos.push(todo);
this.name = '';
this.action = '';
});
}
deleteTodo(id) {
let todos = this.todos;
this.todoService.deleteTodo(id).subscribe(data => {
const index = this.todos.findIndex(todo => todo._id == id);
todos.splice(index, 1)
});
}
updateStatus(todo) {
var _todo = {
_id: todo._id,
action: todo.action,
isDone: !todo.isDone
};
this.todoService.updateStatus(_todo).subscribe(data => {
const index = this.todos.findIndex(todo => todo._id == _todo._id)
this.todos[index] = _todo;
});
}
choice(todo) {
console.log(todo);
return todo.isDone;
}
}

我们刚刚启用了服务和组件之间的通信。component.ts文件现在可以使用服务和其中的方法。

现在我们已经连接了服务和组件,我们必须在todos.component.html中在浏览器中显示 todos 操作。

实现视图

为了显示 todos,我们将使用:

  • Angular 的*ngFor指令,它遍历 todos 数组,并为该数组中的每个 todo 呈现此模板的一个实例

  • Angular 的插值绑定语法,{{}}

更新todos.component.html

<div class="container">
<form (submit) = "addTodos($event)">
<input type="text"
class="form-control" placeholder="action"
[(ngModel)] ="action" name="action">
<button type="submit"><h4>Submit</h4></button>
</form>
<div *ngFor="let todo of todos">
<div class="container">
<p (click)="updateStatus(todo)" [ngStyle]="{ 'text-decoration': todo.isDone ? 'line-through' : ''}" >Action: {{todo.action}}</p>
{{todo.isDone}}
<button (click) ="deleteTodo(todo._id)" >Delete</button>
</div>
</div>
</div>

为了让我们的应用看起来更好,我们将使用 bootstrap。Bootstrap是一个强大的前端框架,用于创建网页和用户界面组件,如表单、模态框、手风琴、轮播和选项卡:

<!-- Index.html --&gt;
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<meta charset="utf-8">
<title>Data</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

更新todos.component.html

<form (submit) = "addTodos($event)">
<input type="text" class="form-control" placeholder="action" [(ngModel)] ="action" name="action">
<button class="btn btn-primary" type="submit"><h4>Submit</h4></button>
</form>
<div class="card pos" style="width: 20rem;" *ngFor="let todo of todos">
<div class="card-body">
<h4 class="card-title" (click)="updateStatus(todo)" [ngStyle]="{ 'text-decoration': todo.isDone ? 'line-through' : ''}">{{todo.action}}</h4>
<p class="card-text">{{todo.isDone}}</p>
<button (click) ="deleteTodo(todo._id)" class="btn btn-danger">Delete</button>
</div>
</div>
We'll also update app.component.css file to add some optional extra styling.
// app.component.css
.isDone{
text-decoration: line-through;
}
.pos{
margin-left: 40%;
margin-top: 10px;
}

打开命令行/终端并导航到项目文件夹。运行node server.js启动服务器。在project文件夹中打开另一个终端窗口,并运行ng serve来提供 Angular 应用。

打开浏览器并访问localhost:4200。这就是结果应该看起来像以下截图:

我们已成功创建了一个 todo 应用,通过服务向 node 服务器发出 HTTP 请求,然后通过组件将结果呈现到 DOM 中。您可以添加一个 todo,删除一个 todo,获取所有 todos,当您点击一个 todo 时,布尔值会改变,并且在特定的 todo 上出现删除线。当您重新加载浏览器时,您可以看到对 todo 列表所做的更改是持久的。

让我们简要回顾一下我们所做的一切:

  • 首先,我们使用 Angular-CLI 创建了一个 Angular 应用

  • 然后我们创建了一个服务器文件,在那里我们需要我们的依赖项,创建了一个 express 应用程序,设置了我们的 API 路由,声明了一个端口供我们的服务器监听,添加了用于 post 数据的解析器,等等

  • 然后我们定义了我们的数据源,这是一个与diskdb通信的 todos 的.json文件

  • 创建一个 Angular 组件

  • 创建一个具有getpostputdelete方法的服务,用于与 REST API 通信

让我们看另一个例子。我们将创建一个简单的应用程序,显示用户列表以及他们的电子邮件和电话号码。用户还将具有一个真假状态,指示他们是否可用或不可用。

使用 Angular 构建用户目录

我们即将构建的应用程序将具有一个 REST API,在本示例的过程中将创建该 API。在这个简单的例子中,我们将创建一个“用户”应用程序,这将是非常简单的。该应用程序基本上将是一个包含用户列表及其电子邮件地址和电话号码的表格。表中的每个用户都将具有一个active状态,其值为布尔值。我们将能够将特定用户的active状态从 false 更改为 true,反之亦然。该应用程序将使我们能够添加新用户,还可以从表中删除用户。与上一个例子一样,diskDB 将用作此示例的数据库。我们将有一个 Angular 服务,其中包含负责与 REST 端点通信的方法。这些方法将负责向 REST API 发出getpostputdelete请求。服务中的第一个方法将负责向 API 发出get请求。这将使我们能够从后端检索所有用户。接下来,我们将有另一个方法,该方法将向 API 发出post请求。这将使我们能够将新用户添加到现有用户数组中。

接下来的方法将负责向 API 发出“删除”请求,以便删除用户。最后,我们将有一个方法,该方法将向 API 发出put请求。这将是赋予我们编辑/修改用户状态的能力的方法。为了向 REST API 发出这些请求,我们将不得不使用 HttpModule。本节的目的是巩固您对 HTTP 的知识。作为 JavaScript 和实际上是 Angular 开发人员,您几乎总是需要与 API 和 Web 服务器进行交互。今天开发人员使用的许多数据都是以 API 的形式存在的,为了与这些 API 进行交互,我们需要不断地使用 HTTP 请求。事实上,HTTP 是 Web 数据通信的基础。

创建一个新的 Angular 应用程序

如前所示,要启动一个新的 Angular 应用程序,请运行以下命令:

ng new user

这创建了 Angular 2 用户应用程序。

安装以下依赖项:

  • 表达

  • Body-parser

  • Cors

npm install express body-parser cors --save

创建一个 Node 服务器

在项目目录的根目录创建一个名为server.js的文件。这将是我们的 node 服务器。

使用以下代码块填充server.js

// Require dependencies
const express = require('express');
const path = require('path');
const http = require('http');
const cors = require('cors');
const bodyParser = require('body-parser');
// Get our API routes
const route = require('./route');
const app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// Use CORS
app.use(cors());
// Set our api routes
app.use('/api', route);
/**
* Get port from environment.
*/
const port = process.env.PORT || '3000';
/**
* Create HTTP server.
*/
const server = http.createServer(app);
//Listen on provided port
app.listen(port);
console.log('server is listening');

这里正在发生的事情非常简单:

  • 我们需要并使用了依赖项

  • 我们定义并设置了 API 路由

  • 为我们的服务器设置一个端口

API 路由正在从./route中被引用,但这个路径还不存在。让我们快速创建它。

在项目目录的根目录,创建一个名为route.js的文件。这是 API 路由将被创建的地方。我们需要有一个数据库形式,从中我们可以获取、发布、删除和修改数据。

就像在之前的例子中一样,我们将使用 diskdb。路由将基本上与第一个例子中的模式相同。

安装 diskDB

在项目文件夹中运行以下命令以安装 diskdb:

npm install diskdb

在项目目录的根目录创建一个名为users.json的文件,作为我们的数据库集合,其中包含我们用户的详细信息。

使用以下内容填充users.json

[{"name": "Marcel", "email": "test1@gmail.com", "phone_number":"08012345", "isOnline":false}]

现在,更新route.js

route.js
const express = require('express');
const router = express.Router();
const db = require('diskdb');
db.connect(__dirname, ['users']);
//save
router.post('/users', function(req, res, next) {
var user = req.body;
if (!user.name && !(user.email + '') && !(user.phone_number + '') && !(user.isActive + '')) {
res.status(400);
res.json({
error: 'error'
});
} else {
console.log('ds');
db.users.save(todo);
res.json(todo);
}
});
//get
router.get('/users', function(req, res, next) {
var foundUsers = db.users.find();
console.log(foundUsers);
res.json(foundUsers);
foundUsers = db.users.find();
console.log(foundUsers);
});
//updateUsers
router.put('/user/:id', function(req, res, next) {
var updUser = req.body;
console.log(updUser, req.params.id)
db.users.update({_id: req.params.id}, updUser);
res.json({ msg: req.params.id + ' updated' });
});
//delete
router.delete('/user/:id', function(req, res, next) {
console.log(req.params);
db.users.remove({
_id: req.params.id
});
res.json({ msg: req.params.id + ' deleted' });
});
module.exports = router;

我们已经创建了一个 REST API,使用 diskDB 作为数据库的 API 路由。

使用以下命令启动服务器:

node server.js

服务器正在运行,并且正在监听分配的端口。现在,打开浏览器并转到http://localhost:3000/api/users

在这里,我们可以看到我们输入到users.json文件中的数据。这表明我们的路由正在工作,并且我们正在从数据库中获取数据。

创建一个新组件

运行以下命令创建一个新组件:

ng g component user

这创建了user.component.tsuser.component.htmluser.component.cssuser.component.spec.ts文件。User.component.spec.ts用于测试,因此我们在本章中不会使用它。新创建的组件会自动导入到app.module.ts中。我们必须告诉根组件有关用户组件。我们将通过将选择器从user.component.ts导入到根模板组件(app.component.html)来实现这一点:

<div style="text-align:center">
<app-user></app-user>
</div>

创建一个服务

下一步是创建一个与我们之前创建的 API 交互的服务:

ng generate service user

这创建了一个名为user.service.ts的用户服务。接下来,将UserService类导入app.module.ts并将其包含在 providers 数组中:

Import rxjs/add/operator/map in the imports section.
import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
Within the UserService class, define a constructor and pass in the angular 2 HTTP service.
import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';
import 'rxjs/add/operator/map';
@Injectable()
export class UserService {
constructor(private http: Http) {}
}

在服务类中,编写一个发出 get 请求以从 API 获取所有用户及其详细信息的方法:

getUser() {
return this.http
.get('http://localhost:3000/api/users')
.map(res => res.json());
}

编写一个发出 post 请求并创建新待办事项的方法:

addUser(newUser) {
var headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.post('http://localhost:3000/api/user', JSON.stringify(newUser), {
headers: headers
})
.map(res => res.json());
}

编写另一个发出 delete 请求的方法。这将使我们能够从用户集合中删除用户:

deleteUser(id) {
return this.http
.delete('http://localhost:3000/api/user/' + id)
.map(res => res.json());
}

最后,编写一个发出 put 请求的方法。这个方法将使我们能够修改用户的状态:

updateUser(user) {
var headers = new Headers();
headers.append('Content-Type', 'application/json');
return this.http
.put('http://localhost:3000/api/user/' + user._id, JSON.stringify(user), {
headers: headers
})
.map(res => res.json());
}

更新 app.module.ts 以导入 HttpModuleFormsModule 并将它们包含到导入数组中:

import { HttpModule } from '@angular/http';
import { FormsModule } from '@angular/forms';
.....
imports: [
.....
HttpModule,
FormsModule
]

接下来要做的是教会用户组件使用服务:

Import UserService in user.component.ts.
import {UserService} from '../user.service';
Next, include the service class in the user component constructor.
constructor(private userService: UserService) { }.
Just below the exported UserComponent class, add the following properties and define their data types:
users: any = [];
user: any;
name: any;
email: any;
phone_number: any;
isOnline: boolean;

现在,我们可以在用户组件中使用用户服务的方法。

更新 user.component.ts

ngOnInit 方法中,使用用户服务从 API 中获取所有用户:

ngOnInit() {
this.userService.getUser().subscribe(users => {
console.log(users);
this.users = users;
});
}

ngOnInit 方法下面,编写一个使用用户服务中的 post 方法添加新用户的方法:

addUser(event) {
event.preventDefault();
var newUser = {
name: this.name,
email: this.email,
phone_number: this.phone_number,
isOnline: false
};
this.userService.addUser(newUser).subscribe(user => {
this.users.push(user);
this.name = '';
this.email = '';
this.phone_number = '';
});
}

让我们使用用户服务中的 delete 方法来使我们能够删除用户:

deleteUser(id) {
var users = this.users;
this.userService.deleteUser(id).subscribe(data => {
console.log(id);
const index = this.users.findIndex(user => user._id == id);
users.splice(index, 1)
});
}

最后,我们将使用用户服务向 API 发出 put 请求:

updateUser(user) {
var _user = {
_id: user._id,
name: user.name,
email: user.email,
phone_number: user.phone_number,
isActive: !user.isActive
};
this.userService.updateUser(_user).subscribe(data => {
const index = this.users.findIndex(user => user._id == _user._id)
this.users[index] = _user;
});
}

我们与 API、服务和组件之间的所有通信都已经完成。我们必须更新 user.component.html,以便在浏览器中说明我们所做的一切。

我们将使用 bootstrap 进行样式设置。因此,我们必须在 index.html 中导入 bootstrap CDN:

<!doctype html>
<html lang="en">
<head>
//bootstrap CDN
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<meta charset="utf-8">
<title>User</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
</html>

更新 user.component.html

以下是用户组件的组件模板:

<form class="form-inline" (submit) = "addUser($event)">
<div class="form-row">
<div class="col">
<input type="text" class="form-control" [(ngModel)] ="name" name="name">
</div>
<div class="col">
<input type="text" class="form-control" [(ngModel)] ="email" name="email">
</div>
<div class="col">
<input type="text" class="form-control" [(ngModel)] ="phone_number" name="phone_number">
</div>
</div> <br>
<button class="btn btn-primary" type="submit" (click) = "addUser($event)"><h4>Add User</h4></button>
</form>
<table class="table table-striped" >
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone_Number</th>
<th>Active</th>
</tr>
</thead>
<tbody *ngFor="let user of users">
<tr>
<td>{{user.name}}</td>
<td>{{user.email}}</td>
<td>{{user.phone_number}}</td>
<td>{{user.isActive}}</td>
<td><input type="submit" class="btn btn-warning" value="Update Status" (click)="updateUser(user)" [ngStyle]="{ 'text-decoration-color:': user.isActive ? 'blue' : ''}"></td>
<td><button (click) ="deleteUser(user._id)" class="btn btn-danger">Delete</button></td>
</tr>
</tbody>
</table>

在上述代码中发生了很多事情,让我们深入了解代码块:

  • 我们有一个表单,其中包含三个输入和一个提交按钮,当点击时会触发 addUser() 方法

  • 还有一个删除按钮,当点击时会触发 delete 方法

  • 还有一个更新状态输入元素,当点击时会触发 updateUser() 方法

  • 我们创建了一个表格,其中将使用 Angular 的 *ngFor 指令和 Angular 的插值绑定语法 {{}} 来显示用户的详细信息

项目将添加一些额外的样式。转到 user.component.css 并添加以下内容:

form{
margin-top: 20px;
margin-left: 20%;
size: 50px;
}
table{
margin-top:20px;
height: 50%;
width: 50%;
margin-left: 20%;
}
button{
margin-left: 20px;
}

运行应用程序

打开两个命令行界面/终端。在两者中都导航到项目目录。在其中一个中运行 node server.js 启动服务器。在另一个中运行 ng serve 来提供 Angular 2 应用程序。

打开浏览器并转到 localhost:4200

在这个简单的用户应用程序中,我们可以执行所有的 CRUD 操作。我们可以创建新用户,获取用户,删除用户,并更新用户的状态。

默认情况下,新添加用户的活动状态为 false。可以通过点击更改状态按钮来更改。

摘要

在开发任何应用程序时,使用来自数据库或 API 的实际数据非常重要。HTTP 与可观察对象和 Rxjs 使得可以从 API 中使用所需的数据集,并执行所有 CRUD 操作。

在下一章中,我们将看一下编写单元测试和调试。

第十二章:测试和调试

测试对于构建可投入生产的应用程序至关重要。在单元测试中,我们独立于外部来源测试组件,以确保其按预期工作。Angular 2 自带了测试功能。在本章中,我们将查看对以下元素执行单元测试:

  • 组件

  • 服务

  • 管道

  • 指令

Angular 2 测试工具

在 Angular 2 中用于辅助测试的一些工具如下:

  • 茉莉花

  • Karma

  • Protractor

  • Angular 2 测试平台

让我们更深入地了解每一个。

茉莉花

Jasmine 是一个开源的测试框架。它使用行为驱动的符号,带来了改进的测试。

茉莉花的主要概念

在我们深入实际测试案例之前,这里有一些您应该了解的 Jasmine 概念:

  • 套件:这些是由describe块编写和描述的。它们以函数的形式出现。

  • 规范定义it(string,function)函数。此函数的主体包含实际的断言。

  • 期望:这些是评估为布尔值的断言。这用于查看输入是否等于预期值。

  • 匹配器:这些是常见断言的辅助工具,例如,toBe(expected),toEqual(expected)。

Karma

Karma 是由 Angular 团队创建的 JavaScript 测试运行器。Karma 可以成为项目的持续集成过程的一部分,也可以成为其开发的一部分。

Protractor

Protractor 是用于 Angular 应用的端到端测试框架。使用 Protractor,您可以设置期望并根据我们的假设进行测试。顾名思义,端到端测试不仅确保系统自身正常工作,还验证其与外部系统的功能。它们探索应用程序的最终用户体验。

Angular 测试平台

Angular 测试平台用于测试类与 Angular 和 DOM 的交互。Angular 测试平台使我们能够检查类的实例,而不依赖于 Angular 或注入的值。

在本章中,我们将专注于 Jasmine 和 Karma 进行测试。

当使用 Angular-CLI 创建新项目时,将创建包含使用 Protractor 的端到端测试的e2e文件夹,以及karma.conf.jsprotractor.conf.js文件,这些是 Karma 和 Protractor 测试的配置文件。

使用 Karma(与 Jasmine 一起)

使用 Karma,您可以在运行应用程序时测试您的代码,因为 Karma 为测试创建了一个浏览器环境。除了您的浏览器,您还可以在其他设备上测试您的代码,比如手机和平板电脑。

Jasmine 是用于测试 JavaScript 代码的行为驱动开发框架。Jasmine 无需依赖,不需要 DOM,并经常与 Karma 一起使用。我们现在将继续创建一个新项目并测试其元素。

创建一个新项目

我们将使用以下命令创建一个名为Angular-test的新项目:

ng new Angular-test

安装 Karma CLI

要安装 Karma CLI,请输入以下命令:

npm install -g karma-cli

我们的测试将在.spec.ts文件中执行。在./app/文件夹中创建一个新的测试文件(sampletest.spec.ts)并复制以下内容:

// ./app/sampletest.spec.ts
describe('Sample Test', () => {
 it('true is true', () => expect(true).toBe(true));
 });

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

 describe('AppComponent', () => {
 beforeEach(function() {
 this.app = new AppComponent();
 });

 it('should have hello property', function() {
 expect(this.app.hello).toBe('Hello, World!');
 });
 });

在上述代码中,我们首先编写一个示例测试来展示 Jamine 中的主要概念。我们创建一个测试套件,定义我们的测试套件,并编写期望。在示例测试中,我们进行了一个简单的检查,看看true是否与true相同。

我们还为AppComponent编写了一个简单的测试。我们期望该组件具有一个hello属性,其值等于Hello, World。让我们通过更新app.component.ts来确保测试通过:

private hello: string = 'Hello, World!';

通过创建带有.spec.ts扩展名的文件,我们满足了 Karma 的配置要求。

您也可以测试多个组件。例如,当您通过 Angular CLI 创建新组件时,它会自动为组件创建测试文件(.spec.ts),这些文件只是测试组件是否与其他组件正确配合工作。对于 Angular 来说,惯例是为每个.ts文件都有一个.spec.ts文件。当您使用ng test命令时,这些文件将通过 Karma 任务运行器使用 Jasmine JavaScript 测试框架运行。

配置 Karma

为了配置我们的 Karma,我们需要更新karma.conf.js文件。默认文件的内容如下:

// ./karma.conf.js.
module.exports = function (config) {
 config.set({
 basePath: '',
 frameworks: ['jasmine', 'angular-cli'],
 plugins: [
 require('karma-jasmine'),
 require('karma-chrome-launcher'),
 require('karma-remap-istanbul'),
 require('angular-cli/plugins/karma')
 ],
 files: [
 { pattern: './src/test.ts', watched: false }
 ],
 preprocessors: {
 './src/test.ts': ['angular-cli']
 },
 remapIstanbulReporter: {
 reports: {
 html: 'coverage',
 lcovonly: './coverage/coverage.lcov'
 }
 },
 angularCli: {
 config: './angular-cli.json',
 environment: 'dev'
 },
 reporters: ['progress', 'karma-remap-istanbul'],
 port: 9876,
 colors: true,
 logLevel: config.LOG_INFO,
 autoWatch: true,
 browsers: ['PhantomJS'],
 singleRun: false
 });
 };

在这里,我们展示了将使用 PhantomJS 浏览器;将使用 Jasmine 测试框架和 Webpack 进行文件捆绑。

测试组件

组件是 Angular 的核心。它们是整个框架构建的核心。我们将探讨组件是什么,为什么它很重要,以及如何测试它。

我们的测试策略围绕验证组件的属性和方法的正确性展开。

在为组件编写单元测试时,我们手动初始化组件并注入任何依赖项,而不是启动应用程序。

TestBed函数将用于测试组件,这是所有 Angular 测试接口的主要入口。它将使我们能够创建我们的组件以用于运行单元测试。

TestBed是为 Angular 应用程序和库编写单元测试的主要 API。

创建一个名为sample的新组件:

ng generate component sample

这将自动生成.ts.spec.ts文件。我们还将在生成的.spec.ts文件中添加一些测试,以了解测试的工作原理:

//sample.component.ts
import { Component, OnInit } from '@angular/core';

 @Component({
 selector: 'app-sample',
 templateUrl: './sample.component.html',
 styleUrls: ['./sample.component.css']
 })
 export class SampleComponent implements OnInit {
 title = 'Test Sample Component';
 constructor() { }
 ngOnInit() {
 }
 }

这是更新后的测试规范:

//sample.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { SampleComponent } from './sample.component';
describe('SampleComponent (inline template)', () => {
let component: SampleComponent;
let fixture: ComponentFixture<SampleComponent>;
// For Debugging HTML Elements
let debug: DebugElement;
let htmlElem: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ SampleComponent ], // Our Test sample component
});
// Get the ComponentFixture
fixture = TestBed.createComponent(SampleComponent);
component = fixture.componentInstance; // SampleComponent test instance
// CSS Element selector
debug = fixture.debugElement.query(By.css('h1'));
htmlElem = debug.nativeElement;
});
it('don't show any title on DOM until we call `detectChanges`', () => {
expect(htmlElem.textContent).toEqual('');
});
it('should display original title', () => {
fixture.detectChanges();
expect(htmlElem.textContent).toContain(component.title);
});
it('should display a different test title', () => {
component.title = Different Test Title';
fixture.detectChanges();
expect(htmlElem.textContent).toContain('Different Test Title');
});
});

TestBed中的createComponent方法创建组件的实例。这些测试告诉 Angular 何时通过fixture.detectChanges()(我们从createComponent中获得)执行变更检测。默认情况下,TestBed.createComponent不会触发变更检测。这就是为什么我们测试中的特定部分不会在 DOM 上显示更改。

利用@angular/core/testing中的ComponentFixtureAutoDetect可以全局应用自动检测:

TestBed.configureTestingModule({
 declarations: [ SampleComponent ],
 providers: [
 { provide: ComponentFixtureAutoDetect, useValue: true }
 ]
 })

测试服务

让我们创建一个示例服务。我们的服务只有一个方法,返回应用程序可用用户的数组:

//a simple service
export class UsersService {
get() {
return ['Ken', 'Mark', 'Chris'];
}
}

我们使用beforeEach方法实例化服务。即使我们只有一个规范,这也是一个好习惯。我们正在检查每个单独的用户和总数:

describe('Service: UsersService', () => {
let service;
beforeEach(() => TestBed.configureTestingModule({
providers: [ UsersService ]
}));
beforeEach(inject([UsersService], s => {
service = s;
}));
it('should return available users', () => {
let users = service.get();
expect(users).toContain('en');
expect(users).toContain('es');
expect(users).toContain('fr');
expect(users.length).toEqual(3);
});
});

使用 HTTP 进行测试

让我们首先创建一个users.serviceHttp.ts文件:

// users.serviceHttp.ts
export class UsersServiceHttp {
constructor(private http:Http) { }
get(){
return this.http.get('api/users.json')
.map(response => response.json());
}
}

在这种情况下,它使用http.get()从 JSON 文件中获取数据。然后我们使用Observable.map()使用json()将响应转换为最终结果。

这个测试与之前的测试之间的区别在于使用了异步测试:

//users.serviceHttp.spec.ts
describe('Service: UsersServiceHttp', () => {
let service;
//setup
beforeEach(() => TestBed.configureTestingModule({
imports: [ HttpModule ],
providers: [ UsersServiceHttp ]
}));
beforeEach(inject([UsersServiceHttp], s => {
service = s;
}));
//specs
it('should return available users', async(() => {
service.get().subscribe(x => {
expect(x).toContain('en');
expect(x).toContain('es');
expect(x).toContain('fr');
expect(x.length).toEqual(3);
});
}));
})

使用 MockBackend 进行测试

一个更明智的方法是用 MockBackend 替换 HTTP 调用。为此,我们可以使用beforeEach()方法。这将允许我们模拟我们的响应并避免访问真实的后端,从而提高我们的测试:

//users.serviceHttp.spec.ts
describe('MockBackend: UsersServiceHttp', () => {
let mockbackend, service;
//setup
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ HttpModule ],
providers: [
UsersServiceHttp,
{ provide: XHRBackend, useClass: MockBackend }
]
})
});
beforeEach(inject([UsersServiceHttp, XHRBackend], (_service, _mockbackend) => {
service = _service;
mockbackend = _mockbackend;
}));
//specs
it('should return mocked response (sync)', () => { 
 let response = ["ru", "es"]; 
 mockbackend.connections.subscribe(connection => { 
 connection.mockRespond(new Response(new ResponseOptions({ 
 body: JSON.stringify(response) 
 }))); 
 service.get().subscribe(users => { 
 expect(users).toContain('ru'); 
 expect(users).toContain('es'); 
 expect(users.length).toBe(2); 
 }); 
 }); 
}); 

我们制作了模拟响应。因此,当我们最终调用我们的服务时,它会得到预期的结果。

测试一个指令

在 Angular 中,指令装饰器用于装饰一个负责根据定义的方法和逻辑扩展 DOM 中组件的类。

以更改背景的指令为例:

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

@Directive({
 selector: '[appBackgroundChanger]'
})
export class BackgroundChangerDirective {

 @HostBinding('style.background-color') backgroundColor: string;

 @HostListener('mouseover') onHover() {
 this.backgroundColor = 'red';
 }

 @HostListener('mouseout') onLeave() {
 this.backgroundColor = 'inherit';
 }

}

我们将使用一个属性指令logClicks,它记录宿主元素上的点击次数。

让我们创建一个container组件。这将是我们的宿主,重现我们指令发出的事件:

@Component({
 selector: 'container',
 template: `<div log-clicks (changes)="changed($event)"></div>`,
 directives: [logClicks]
 })
 export class Container {
 @Output() changes = new EventEmitter();
 changed(value){
 this.changes.emit(value);
 }
 }

以下是测试规范:

describe('Directive: logClicks', () => {
let fixture;
let container;
let element;
//setup
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ Container, logClicks ]
});
fixture = TestBed.createComponent(Container);
container = fixture.componentInstance; // to access properties and methods
element = fixture.nativeElement; // to access DOM element
});
//specs
it('should increment counter', fakeAsync(() => {
let div = element.querySelector('div');
//set up subscriber
container.changes.subscribe(x => {
expect(x).toBe(1);
});
//trigger click on container
div.click();
//execute all pending asynchronous calls
tick();
}));
})

beforeEach方法用于将创建组件的逻辑与测试分开。建议使用 DOM API 来触发容器上的点击。

测试管道

Angular 中的管道是可以将输入数据转换为用户可读格式的函数。以下是我们src文件夹中一个名为capitalise的自定义管道的示例,使用了标准的String.toUpperCase()。这只是一个例子;Angular 已经有了自己的管道来进行大写转换:

//capitalise.pipe.ts
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({
name: 'capitalise'
})
export class CapitalisePipe implements PipeTransform {
transform(value: string): string {
if (typeof value !== 'string') {
throw new Error('Requires a String as input');
}
return value.toUpperCase();
}
}

capitalise.pipe.spec.ts文件将如下所示:

describe('Pipe: CapitalisePipe', () => {
let pipe;
//setup
beforeEach(() => TestBed.configureTestingModule({
providers: [ CapitalisePipe ]
}));
beforeEach(inject([CapitalisePipe], p => {
pipe = p;
}));
//specs
it('should work with empty string', () => {
expect(pipe.transform('')).toEqual('');
});
it('should capitalise', () => {
expect(pipe.transform('wow')).toEqual('WOW');
});
it('should throw with invalid values', () => {
//must use arrow function for expect to capture exception
expect(()=>pipe.transform(undefined)).toThrow();
expect(()=>pipe.transform()).toThrow();
expect(()=>pipe.transform()).toThrowError('Requires a String as input');
});
})

调试

Augury 是用于调试 Angular 应用程序的 Chrome 扩展,就像 Batarang 用于调试 Angular 1 应用程序一样。安装后,该扩展被视为具有测试 Angular 应用程序行为功能的开发工具插件。

Augury

Augury 检查和可视化一个或多个组件的不同属性的组件树。从 Augury Chrome 扩展页面安装 Augury 工具(chrome.google.com/webstore/detail/augury/elgalmkoelokbchhkhacckoklkejnhcd),然后单击“添加到 Chrome”按钮。安装完成后,需要按照以下步骤才能使用 Augury:

  • 使用Ctrl + Shift + I打开 Chrome 开发者工具窗口。

  • 单击 Augury 打开工具。它显示菜单选项,如组件树、路由器树和 NgModules。

一旦安装完成,您可以在浏览器的右上角看到 Augury 图标。

打开后,您将看到当前加载的组件列表,按其层次结构排序。您还可以看到它们在 DOM 中的位置。对组件所做的任何更改也将显示出来。

有了这个,开发人员可以更容易地了解他们的应用程序的性能以及问题和错误可能来自哪里:

Augury 功能

让我们详细看一些 Augury 功能。

组件树

这是可见的第一个视图,显示了属于应用程序的加载组件:

组件树显示了组件之间的分层关系。通过选择每个组件,还可以显示有关组件的更多信息:

路由器树

路由器树以分层顺序显示应用程序树中每个组件的路由信息:

源映射

值得注意的是,TypeScript 代码将显示源映射文件是否存在。在生产环境中,如果找不到源映射,将仅显示编译后的 JavaScript 代码,这可能也是经过缩小处理的,难以阅读。

单击“注入图形”将显示组件和服务的依赖关系:

值得注意的是,要使 Augury 调试工作,应用程序必须设置为开发模式。

总结

进行单元测试很重要,因为它们运行更快,我们将能够更快地获得反馈。测试的一个很大优势是它有助于防止回归(破坏现有代码的更改)。

调试帮助我们识别和从代码中删除错误。使用 Augury,开发人员可以通过组件树和可视化调试工具看到应用程序的可视化效果。这使得调试更容易。

posted @ 2024-05-18 12:03  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报