Angular 切换指南第三版(二)
原文:
zh.annas-archive.org/md5/77474cce19d591591d4c31d9b073c017译者:飞龙
第六章:Angular 中的依赖注入
在本章中,我们将解释如何利用框架的依赖注入(DI)机制及其所有各种功能。
我们将探讨以下主题:
-
配置和创建提供者
-
注入由声明提供者实例化的依赖项
-
创建和配置注入器的底层 API
-
在我们的 UI 组件之间重用服务中定义的业务逻辑
为什么我需要依赖注入?
假设我们有一个依赖于Engine和Transmission类的Car类。我们如何实现这个系统?让我们看看:
class Engine {...}
class Transmission {...}
class Car {
engine;
transmission;
constructor() {
this.engine = new Engine();
this.transmission = new Transmission();
}
}
在前面的示例中,我们在Car类的构造函数中创建了Car类的依赖项。尽管看起来很简单,但它远非灵活。每次我们创建Car类的实例时,在其构造函数中,都会创建相同Engine和Transmission类的实例。这可能会因为以下原因而成为问题:
-
由于我们无法独立于其
engine和transmission依赖项对其进行测试,Car类变得难以测试。 -
我们将
Car类与其依赖项的实例化逻辑耦合在一起。
Angular 中的依赖注入
我们还可以通过利用依赖注入模式来解决这个问题。我们已经从 AngularJS 中熟悉了它;让我们演示如何在 Angular 的上下文中使用 DI 重构前面的代码:
class Engine {...}
class Transmission {...}
@Injectable()
class Car {
engine;
transmission;
constructor(engine: Engine, transmission: Transmission) {
this.engine = engine;
this.transmission = transmission;
}
}
在前面的代码片段中,我们只是在Car类的定义上方添加了@Injectable类装饰器,并为构造函数的参数提供了类型注解。
使用依赖注入的优点
还有一个步骤尚未完成,我们将在下一节中探讨。在此之前,让我们看看这种方法的优点:
-
我们可以轻松地为测试环境或为实例化不同的
Car模型传递Car类的不同依赖项版本。 -
我们与依赖项实例化的逻辑没有耦合。
Car类只负责实现其自身的领域特定逻辑,而不是与额外的功能耦合,例如其依赖项的管理。我们的代码也变得更加声明性,更容易阅读。
既然我们已经意识到 DI 的一些好处,让我们看看为了让这段代码工作所缺少的部分。
声明提供者
在我们的 Angular 应用程序中,通过框架的 DI 机制实例化单个依赖项所使用的原始数据类型称为注入器。注入器包含一组提供者,它们封装了与令牌关联的已注册依赖项的实例化逻辑。我们可以将令牌视为注入器内注册的不同提供者的标识符。
在 Angular 中,我们可以使用@NgModule声明单个依赖的提供者。内部,Angular 将根据我们在模块中声明的提供者创建一个注入器。
让我们看看以下代码片段,它位于ch6/injector-basics/basics/app.ts:
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import {
NgModule,
Component,
Inject,
InjectionToken,
Injectable
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
const BUFFER_SIZE = new InjectionToken<number>('buffer-size');
class Buffer {
constructor(@Inject(BUFFER_SIZE) private size: Number) {
console.log(this.size);
}
}
@Injectable()
class Socket {
constructor(private buffer: Buffer) {}
}
@Component({
selector: 'app',
template: ''
})
class AppComponent {
constructor(private socket: Socket) {
console.log(socket);
}
}
@NgModule({
providers: [{ provide: BUFFER_SIZE, useValue: 42 }, Buffer, Socket],
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [BrowserModule]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
一旦你为本书设置了代码(有关说明,请参阅第五章*,开始使用 Angular 组件和指令*)并运行npm start,你就可以在http://localhost:5555/dist/dev/ch6/injector-basics/basics/地址上看到执行结果。当你打开浏览器的控制台时,你会看到以下这些行:
42
Socket {buffer: Buffer}
在图 1中,我们可以看到AppComponent依赖于Socket类,而Socket类依赖于Buffer类,Buffer类又依赖于BUFFER_SIZE类:
图 1
我们将BUFFER_SIZE类常量的值设置为new InjectionToken<number>('buffer-size')。我们可以把BUFFER_SIZE的值看作是一个在应用程序中不能重复的唯一标识符。"InjectionToken"是 ES2015 中的Symbol类的替代品,因为在编写本书的时候,TypeScript 不支持它。"InjectionToken"提供了一个Symbol没有的额外功能:更好的类型检查;Angular 和 TypeScript 可以使用我们传递给InjectionToken的类型参数(在前面的例子中是number)来执行更复杂的类型检查算法。
我们定义了两个类:Buffer和Socket。Buffer类有一个构造函数,它只接受一个名为size的单个依赖项,其类型为number。为了在依赖项解析过程中添加额外的元数据(即提示 Angular 应该注入与BUFFER_SIZE令牌关联的值),我们使用@Inject参数装饰器。这个装饰器接受我们想要注入的依赖项的令牌。通常,这个令牌是依赖项的类型(即类的引用),但在某些情况下,它可以是不同类型的值。例如,在我们的案例中,我们使用了InjectionToken类的实例。
使用@Injectable装饰器
现在,让我们来看看Socket类。我们用@Injectable装饰器来装饰它。这个装饰器应该被任何接受依赖并通过 Angular 的 DI 机制注入依赖的类使用。
@Injectable装饰器向 Angular 暗示,一个给定的类接受应该通过框架的依赖注入机制注入的参数。这意味着如果我们省略@Injectable装饰器,Angular 的 DI 机制将不知道在实例化类之前需要解决类的依赖。
在 Angular 5 版本之前,@Injectable 装饰器与 TypeScript 编译器生成带有类型信息的元数据语义不同。尽管这是一个重要的细节,但它对我们使用框架的依赖注入机制或特定的 @Injectable 装饰器方式没有任何影响。
作为一条经验法则,当给定的类接受需要通过 Angular 的依赖注入机制注入的依赖项时,始终使用 @Injectable 装饰器。
引入前向引用
Angular 引入了前向引用的概念。这是由于以下原因所必需的:
-
ES2015 类不会被提升
-
允许在声明依赖提供者之后解决声明的依赖项
在本节中,我们将解释前向引用解决的问题以及我们如何利用它们。
现在,假设我们以相反的顺序定义了 Buffer 和 Socket 类:
// ch6/injector-basics/forward-ref/app.ts
@Injectable()
class Socket {
constructor(private buffer: Buffer) {...}
}
// undefined
console.log(Buffer);
class Buffer {
constructor(@Inject(BUFFER_SIZE) private size: Number) {...}
}
// [Function: Buffer]
console.log(Buffer);
在这里,我们具有与上一个示例中完全相同的依赖项;然而,在这种情况下,Socket 类的定义先于 Buffer 类的定义。请注意,在 Buffer 类声明之前,Buffer 标识符的值将是 undefined。这意味着在生成的 JavaScript 代码的解释过程中,Buffer 标识符的值将是 undefined:也就是说,作为依赖项的标记,框架将获得一个无效的值。
运行前面的代码片段将导致以下形式的运行时错误:
Error: Cannot resolve all parameters for Socket(?). Make sure they all have valid type or annotations.
解决这个问题的最佳方式是通过交换定义以正确的顺序。我们还可以利用 Angular 提供的解决方案:前向引用:
...
import {forwardRef} from '@angular/core';
...
@Injectable()
class Socket {
constructor(@Inject(forwardRef(() => Buffer))
private buffer: Buffer) {}
}
class Buffer {...}
之前的代码片段展示了我们如何利用前向引用。我们只需要使用 @Inject 参数装饰器,并将 forwardRef 函数的调用结果作为参数。forwardRef 函数是一个高阶函数,它接受一个参数:另一个函数,该函数负责返回需要注入的依赖项(或更精确地说,与其提供者关联的依赖项)的标记。这样,框架提供了一种延迟解决依赖项(标记)类型的过程的方法。
依赖项的标记将在 Socket 需要实例化的第一次被解决,这与默认行为不同,在默认行为中,标记在给定类的声明时就需要。
配置提供者
现在,让我们看看一个与之前使用的示例类似,但具有不同语法配置的注入器示例:
@NgModule({
// ...
providers: [
{ provide: BUFFER_SIZE, useValue: 42 },
{ provide: Buffer, useClass: Buffer },
{ provide: Socket, useClass: Socket }
]
// ...
})
class AppModule {}
在这种情况下,在提供者内部,我们明确声明我们希望使用Buffer类来构建与Buffer标识符引用相等的令牌的依赖项。我们为与Socket令牌关联的依赖项做完全相同的事情;然而,这次我们提供了Socket类。这就是 Angular 在我们省略显式提供者声明并仅传递类引用时将如何操作。
明确声明用于创建同一类实例的类可能看起来毫无价值,并且鉴于我们迄今为止看到的示例,这将是完全正确的。然而,在某些情况下,我们可能希望为与给定令牌关联的依赖项的实例化提供不同的类。
例如,假设我们有一个在名为UserService的服务中使用的Http服务:
class Http {...}
@Injectable()
class UserService {
constructor(private http: Http) {}
}
@NgModule({
// ...
providers: [
UserService,
Http
]
})
class AppModule {}
现在,让我们追踪UserService服务实例化的过程,以防我们想在应用程序的某个地方注入它。
内部,Angular 将根据传递给@NgModule的提供者创建一个注入器:这就是 Angular 将用于实例化UserService服务的注入器。最初,提供者会发现UserService服务接受一个带有Http令牌的依赖项,因此提供者会尝试找到与该令牌关联的提供者。由于在同一注入器中存在这样的提供者,它将创建一个Http服务的实例并将其传递给UserService。
到目前为止一切顺利;然而,如果我们想测试UserService服务,我们实际上并不需要通过网络进行 HTTP 调用。在单元测试的情况下,我们可以提供一个模拟实现,它只会伪造这些 HTTP 调用。为了向UserService服务注入不同类的实例,我们可以将提供者的配置更改如下:
class DummyHttp {...}
// ...
@NgModule({
// ...
providers: [
UserService,
{ provide: Http, useClass: DummyHttp }
]
})
class TestingModule {}
在这种情况下,Angular 将再次根据传递给@NgModule的提供者创建一个注入器。这次的不同之处在于,我们用DummyHttp服务关联了Http令牌。现在当注入器实例化UserService时,它会在它维护的提供者列表中寻找与Http令牌关联的提供者,并发现它需要使用DummyHttp服务来创建所需的依赖项。当 Angular 发现我们已声明一个useClass提供者时,它将使用new DummyHttp()创建DummyHttp服务的实例。
此代码位于ch6/configuring-providers/dummy-http/app.ts。
使用现有提供者
另一种进行的方式是使用提供者配置对象的useExisting属性:
@NgModule({
// ...
providers: [
DummyHttp,
{ provide: Http, useExisting: DummyHttp },
UserService
]
})
class TestingModule {}
在前面的片段中,我们为三个令牌注册了提供者:DummyHttp、UserService和Http。我们声明我们想要将Http令牌绑定到现有的令牌DummyHttp。这意味着当请求Http服务时,注入器将找到用作useExisting属性值的令牌的提供者,并实例化它或获取与之关联的值(如果它已经被实例化)。我们可以将useExisting视为创建给定令牌的别名:
// ch6/configuring-providers/existing/app.ts
// ...
const dummyHttp = new DummyHttp();
@Component(...)
class AppComponent {
constructor(private service: UserService) {
console.log(service.http === dummyHttp);
}
}
@NgModule({
providers: [
{ provide: DummyHttp, useValue: dummyHttp },
{ provide: Http, useExisting: DummyHttp },
UserService
],
// ...
})
class AppModule {}
上述片段将创建Http令牌到DummyHttp令牌的别名。这意味着一旦请求Http令牌,调用将被转发到与DummyHttp令牌关联的提供者,它将被解析为dummyHttp的值。
useValue提供者返回设置到提供者声明中useValue属性的值。
定义用于实例化服务的工厂
现在,假设我们想要创建一个复杂对象,例如,一个代表传输层安全性(TLS)连接的对象。此类对象的一些属性包括套接字、一组加密协议和证书。在这个问题的背景下,我们迄今为止所查看的 Angular 的 DI 机制的功能可能看起来有点有限。
例如,我们可能需要配置TLSConnection类的一些属性,而不将其实例化过程与所有配置细节耦合(选择合适的加密算法、打开我们将通过它建立安全连接的 TCP 套接字等)。
在这种情况下,我们可以利用提供者配置对象的useFactory属性:
@NgModule({
// ...
providers: [
{
provide: TLSConnection,
useFactory: function(
socket: Socket, certificate: Certificate, crypto: Crypto) {
let connection = new TLSConnection();
connection.certificate = certificate;
connection.socket = socket;
connection.crypto = crypto;
socket.open();
return connection;
},
deps: [Socket, Certificate, Crypto]
},
{ provide: BUFFER_SIZE, useValue: 42 },
Buffer,
Socket,
Certificate,
Crypto
]
})
class AppModule {}
最初的片段看起来可能有点复杂,但让我们一步一步地来看。我们可以从我们已经熟悉的部分开始:
@NgModule({
// ...
providers: [
// ...
{ provide: BUFFER_SIZE, useValue: 42 },
Buffer,
Socket,
Certificate,
Crypto
]
})
class AppModule {}
初始时,我们注册了多个提供者:Buffer、Socket、Certificate和Crypto。就像在之前的例子中一样,我们也注册了BUFFER_SIZE令牌并将其关联到值42。这意味着我们已经在我们的应用程序中类的构造函数中注入了Buffer、Socket、Certificate和Crypto类型的依赖项。
我们可以通过以下方式创建和配置TLSConnection对象的一个实例:
let connection = new TLSConnection();
connection.certificate = certificate;
connection.socket = socket;
connection.crypto = crypto;
socket.open();
return connection;
现在,为了允许 Angular 使用前面的片段来实例化TLSConnection,我们可以使用提供者配置对象的useFactory属性。这样,我们可以指定一个函数,在其中我们可以手动创建与提供者令牌关联的对象的实例。
我们可以使用useFactory属性与deps属性一起指定要传递给工厂的依赖项:
{
provide: TLSConnection,
useFactory: function (socket: Socket, certificate: Certificate, crypto: Crypto) {
// ...
},
deps: [Socket, Certificate, Crypto]
}
在前面的代码片段中,我们定义了用于 TLSConnection 实例化的工厂函数。作为依赖项,我们声明了 Socket、Certificate 和 Crypto。这些依赖项由 Angular 的 DI 机制解析并注入到工厂函数中。您可以查看整个实现并在 ch6/configuring-providers/factory/app.ts 中尝试它。
值得注意的是,内部,Angular 将 useClass 提供者转换为 useFactory。Angular 在 deps 数组中列出类的依赖项,并使用 new 操作符调用该类,将工厂接收到的依赖项作为参数传递。
声明可选依赖项
Angular 引入了 @Optional 装饰器,它允许我们处理没有与它们关联已注册提供者的依赖项。假设一个提供者的依赖项在任何负责其实例化的目标注入器中都不可用。如果我们使用 @Optional 装饰器,在依赖提供者的实例化过程中,缺失的依赖项的值将被传递为 null。
现在,让我们看一下以下示例:
abstract class SortingAlgorithm {
abstract sort(collection: BaseCollection): Collection;
}
class BaseCollection {
getDefaultSort(): SortingAlgorithm {
// get some generic sorting algorithm...
return null;
}
}
class Collection extends BaseCollection {
public sort: SortingAlgorithm;
constructor(sort: SortingAlgorithm) {
super();
this.sort = sort || this.getDefaultSort();
}
}
@Component({
selector: 'app',
template: "Open your browser's console"
})
class AppComponent {
constructor(private collection: Collection) {
console.log(collection);
}
}
@NgModule({
providers: [Collection],
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [BrowserModule]
})
class AppModule { }
在这种情况下,我们定义了一个名为 SortingAlgorithm 的抽象类和一个名为 Collection 的类,它作为依赖项接受一个扩展 SortingAlgorithm 的具体类的实例。在 Collection 构造函数内部,我们将 sort 实例属性设置为传递的依赖项或默认排序算法实现。
我们在声明的 @NgModule 提供者中没有为 SortingAlgorithm 标记定义任何提供者。因此,如果我们想在 AppComponent 中注入 Collection 类的实例,我们将得到一个运行时错误。这意味着,如果我们想使用框架的 DI 机制获取 Collection 类的实例,我们必须为 SortingAlgorithm 标记注册一个提供者,尽管我们可能希望回退到由 getDefaultSort 方法返回的默认排序算法。
Angular 通过 @Optional 装饰器提供了这个问题的解决方案。以下是我们可以如何使用它来解决这个问题:
// ch6/decorators/optional/app.ts
@Injectable()
class Collection extends BaseCollection {
private sort: SortingAlgorithm;
constructor(@Optional() sort: SortingAlgorithm) {
super();
this.sort = sort || this.getDefaultSort();
}
}
在前面的代码片段中,我们将 sort 依赖项声明为可选的,这意味着如果 Angular 没有找到任何为其标记提供提供者的,它将传递 null 值。
理解多提供者
多提供者(Multiproviders)是 Angular 依赖注入(DI)机制中引入的另一个新概念。它们允许我们将多个提供者与同一个令牌关联起来。如果我们正在开发一个带有一些默认服务实现的第三方库,但希望用户能够用自定义实现来扩展它,这将非常有用。例如,在 Angular 的表单模块中,多提供者专门用于对单个控件声明多个验证。我们将在 第七章,使用 Angular 路由和表单,和 第八章,解释管道和与 RESTful 服务通信中解释这个模块。
多提供者的另一个适用用例示例是 Angular 在其 Web Workers 实现中用于事件管理。用户为事件管理插件创建多提供者。每个提供者返回不同的策略,支持不同的事件集(触摸事件、键盘事件等)。一旦发生特定事件,Angular 可以选择处理该事件的适当插件。
让我们看看一个示例,它说明了多提供者(multiproviders)的典型用法:
// ch6/configuring-providers/multi-providers/app.ts
const VALIDATOR = new InjectionToken('validator');
interface EmployeeValidator {
(person: Employee): string;
}
class Employee {...}
@NgModule({
providers: [
{
provide: VALIDATOR,
multi: true,
useValue: (person: Employee) => {
if (!person.name) {
return 'The name is required';
}
}
},
{
provide: VALIDATOR,
multi: true,
useValue: (person: Employee) => {
if (!person.name || person.name.length < 1) {
return 'The name should be more than 1 symbol long';
}
}
},
Employee
],
declarations: [AppComponent],
bootstrap: [AppComponent],
imports: [BrowserModule]
})
class AppModule { }
在前面的代码片段中,我们声明了一个名为 VALIDATOR 的常量,并将其值设置为 InjectionToken 的新实例。我们还创建了一个 @NgModule,在其中注册了三个提供者:其中两个提供者提供基于不同标准的函数,这些函数用于验证 Employee 类的实例。这些函数的类型是 EmployeeValidator。
为了声明我们希望注入器将所有注册的验证器传递给 Employee 类的构造函数,我们需要使用以下 constructor 定义:
class Employee {
name: string;
constructor(@Inject(VALIDATOR) private validators: EmployeeValidator[]) {}
validate() {
return this.validators
.map(v => v(this))
.filter(value => !!value);
}
}
在示例中,我们声明了一个 Employee 类,它接受单个依赖项:一个 EmployeeValidator 数组。在 validate 方法中,我们对当前类实例应用单个验证器,并过滤结果以仅获取返回错误消息的验证器。
注意,validators 构造函数参数的类型是 EmployeeValidator[]。由于我们不能使用 对象数组 类型作为提供者的令牌,因为它在 JavaScript 中不是一个有效的值,也不能用作令牌,因此我们需要使用 @Inject 参数装饰器。
之后,我们可以像往常一样注入 Employee 类型的实例:
@;Component({
selector: 'app',
template: '...'
})
class AppComponent {
constructor(private employee: Employee) {
console.log(employee);
}
}
子注入器和可见性
在本节中,我们将探讨如何构建注入器的层次结构。在 AngularJS 中,这个概念没有替代方案。每个注入器可以有零个或一个父注入器,每个父注入器可以有一个或多个子注入器。与 AngularJS 中所有注册的提供者都存储在扁平结构中不同,在 Angular 中它们存储在树形结构中。扁平结构更为有限;例如,它不支持令牌的命名空间;我们不能为相同的令牌声明不同的提供者。到目前为止,我们已经看到了一个没有子注入器或父注入器的注入器示例。现在,让我们构建一个注入器的层次结构。
为了更好地理解注入器的这种分层结构,让我们看一下下面的图示:
图 2
在这里,我们看到一个树,每个节点都是一个注入器,这些注入器都保留对其父注入器的引用。House 注入器有三个子注入器:Bathroom、Kitchen 和 Garage。
Garage 有两个子注入器:Car 和 Storage。我们可以将这些注入器视为内部注册了提供者的容器。
假设我们想要获取与 Tire 令牌关联的提供者的值。如果我们使用 Car 注入器,这意味着 Angular 的依赖注入机制将尝试在 Car 以及其所有父注入器 Garage 和 House 中找到与该令牌关联的提供者,直到达到根注入器。
实例化注入器
内部,Angular 构建了这个注入器的层次结构,但所有操作都是隐式的。为了我们自己实现这一点,我们将不得不使用较低级别的 API,这对于我们的日常开发过程来说是不寻常的。
首先,让我们创建一个注入器的实例,以便使用它来实例化注册的令牌:
// ch6/manual-injector/instantiate.ts
import { Injector } from '@angular/core';
// ...
const injector = Injector.create([
{ provide: BUFFER_SIZE, useValue: 42 },
{
provide: Buffer,
deps: [BUFFER_SIZE],
useFactory: function (size: number) {
return new Buffer(size);
}
},
{
provide: Socket,
deps: [Buffer],
useFactory: function (buffer: Buffer) {
return new Socket(buffer);
}
}
]);
在这里,我们首先从 @angular/core 中导入 Injector。这个抽象类有一个名为 create 的静态方法,用于注入器的实例化。在 create 方法内部,我们传递一个提供者数组作为参数。我们可以看到从 配置提供者 部分已经熟悉的语法。
我们声明一个提供者用于 BUFFER_SIZE,使用值 42;我们声明一个 Buffer 的工厂,并列出其所有依赖项(在这种情况下,只有 BUFFER_SIZE);最后,我们还声明了一个 Socket 的工厂提供者。create 方法将创建一个 StaticInjector 的实例,我们可以使用它来获取单个 tokens 的实例。提醒一下,注入器是包含单个提供者的抽象,并且知道如何实例化与它们关联的依赖项。
在前面的例子中,一个重要的细节是,在StaticInjector中,我们只能使用有限类型的提供者,例如,我们不能使用useClass提供者。这是因为 Angular 使用StaticInjector与提供者的标准化版本一起使用,而useClass的标准化版本是useFactory。内部,Angular 会收集传递给@NgModule的提供者,将它们转换为它们的标准化版本,并实例化StaticInjector。
构建注入器层次结构
为了更好地理解段落,让我们看看这个简单的例子:
// ch6/manual-injector/simple-example.ts
class Http { }
class UserService {
constructor(public http: Http) { }
}
const parentInjector = Injector.create([{
provide: Http,
deps: [],
useFactory() {
return new Http();
}
}]);
const childInjector = Injector.create([{
provide: UserService,
deps: [Http],
useFactory(http) {
return new UserService(http);
}
}], parentInjector);
console.log(childInjector.get(UserService));
console.log(childInjector.get(Http) === parentInjector.get(Http));
省略了导入,因为它们对于解释代码不是必需的。我们有两个服务,Http和UserService,其中UserService依赖于Http服务。
初始时,我们使用Injector类的create静态方法创建一个注入器。我们向这个注入器传递一个带有Http令牌的工厂提供者。稍后,再次使用create,我们通过传递包含UserService提供者的数组来实例化子注入器。请注意,作为第二个参数,我们传递了parentInjector常量,因此我们得到了与前面图中Garage和House之间相同的关系:parentInjector是childInjector的父级。
现在,使用childInjector.get(UserService),我们能够获取与UserService令牌关联的值。同样,使用childInjector.get(Http)和parentInjector.get(Http),我们获取与Http令牌关联的相同值。这意味着childInjector会向其父级请求请求令牌关联的值。
然而,如果我们尝试使用parentInjector.get(UserService),由于它的提供者在childInjector中注册,我们将无法获取与令牌关联的值。
使用组件和指令进行依赖注入
在第五章“使用 Angular 组件和指令入门”中,当我们开发我们的第一个 Angular 指令时,我们看到了如何利用 DI 机制将服务注入到我们的 UI 相关构建块(即指令和组件)中。
让我们快速回顾一下我们之前做了什么,但从一个 DI 的角度来看:
// ch5/tooltip/app.ts
// ...
@Directive(...)
export class Tooltip {
@Input() saTooltip: string;
constructor(private el: ElementRef, private overlay: Overlay) {
this.overlay.attach(el.nativeElement);
}
// ...
}
@Component({
// ...
providers: [Overlay]
})
class App {}
大部分早期实现中的代码都被省略了,因为它与我们当前的关注点没有直接关系。
注意,Tooltip的构造函数接受两个依赖项:
-
ElementRef类的实例 -
Overlay类的实例
依赖项的类型是与它们的提供者关联的令牌,以及从提供者获得的相应值将通过 Angular 的 DI 机制注入。
Tooltip 类的依赖声明与我们在前面的章节中所做的一样:我们只是将它们作为类的构造函数的参数列出。然而,请注意,在这种情况下,我们没有为 ElementRef 标记进行任何显式的提供者声明,我们只有一个为 Overlay 标记的提供者,它在 App 组件的元数据中声明。在这种情况下,Angular 内部创建并配置了所谓的 元素注入器。
介绍元素注入器
在底层,Angular 将为所有指令和组件创建注入器,并向它们添加一组默认的提供者。这些被称为 元素注入器,并且是框架自己负责处理的事情。与组件关联的注入器被称为 宿主注入器。每个元素注入器中的一个提供者与 ElementRef 标记相关联;它将返回指令的主元素引用。那么 Overlay 类的提供者怎么办?让我们看看顶级组件的实现:
@Component({
// ...
providers: [Overlay]
})
class App {}
我们通过在 @Component 装饰器内部声明 providers 属性来配置 App 组件的元素注入器。在这个时候,注册的提供者将对与相应元素注入器关联的指令或组件以及组件的整个组件子树可见,除非它们在层次结构中的某个地方被覆盖。
声明元素注入器的提供者
将所有提供者的声明放在同一个地方可能相当不方便。例如,想象一下我们正在开发一个大型应用程序,该应用程序有数百个组件依赖于数千个服务。在这种情况下,在根组件中配置所有提供者不是一个实际的解决方案。当两个或多个提供者与同一个标记相关联时,将发生名称冲突。配置将非常庞大,并且很难追踪不同的依赖项需要注入的位置。
正如我们所提到的,Angular 的 @Directive(和 @Component)装饰器允许我们使用 providers 属性为给定指令对应的元素注入器声明一组提供者。以下是我们可以采取的方法:
@Directive({
selector: '[saTooltip]',
providers: [{ provide: Overlay, useClass: OverlayMock }]
})
export class Tooltip {
@Input() saTooltip: string;
constructor(private el: ElementRef, private overlay: Overlay) {
this.overlay.attach(el.nativeElement);
}
// ...
}
// ...
platformBrowserDynamic().bootstrapModule(AppModule);
之前的示例在 Tooltip 指令的声明中覆盖了 Overlay 标记的提供者。这样,Angular 将在提示框实例化期间注入 OverlayMock 实例而不是 Overlay。
探索组件中的依赖注入
由于组件通常是带有模板的指令,因此到目前为止我们所看到的所有关于 DI 机制如何与指令一起工作的内容也适用于组件。然而,由于组件提供的额外功能,我们允许对它们的提供者有更多的控制。
正如我们所说的,与每个组件关联的注入器将被标记为宿主注入器。有一个名为@Host的参数装饰器,它允许我们从任何注入器中检索给定的依赖项,直到它达到最近的宿主注入器。这意味着,在指令中使用@Host装饰器,我们可以声明我们想要从当前注入器或任何父注入器中检索给定的依赖项,直到我们达到最近父组件的注入器。
此外,Angular 的 API 允许我们通过viewProviders属性,它是@Component装饰器的配置对象的一部分,在组件树中更具体地指定提供者的可见性。
视图提供者与提供者
让我们看看一个名为MarkdownPanel的组件的例子。这个组件将被以下方式使用:
<markdown-panel>
<panel-title># Title</pane-title>
<panel-content>
# Content of the panel
* First point
* Second point
</panel-content>
</markdown-panel>
面板每个部分的内文将是从 Markdown 转换为 HTML。我们可以将此功能委托给一个名为Markdown的服务:
import * as markdown from 'markdown';
class Markdown {
toHTML(md) {
return markdown.toHTML(md);
}
}
Markdown服务将markdown模块包装起来,以便通过 DI 机制进行注入。
现在让我们实现MarkdownPanel。
在以下代码片段中,我们可以找到组件实现的所有重要细节:
// ch6/directives/app.ts
@Component({
selector: 'markdown-panel',
viewProviders: [Markdown],
styles: [...],
template: `
<div class="panel">
<div class="panel-title">
<ng-content select="panel-title"></ng-content>
</div>
<div class="panel-content">
<ng-content select="panel-content"></ng-content>
</div>
</div>`
})
class MarkdownPanel {
constructor(private el: ElementRef, private md: Markdown) {}
ngAfterContentInit() {
let el = this.el.nativeElement;
let title = el.querySelector('panel-title');
let content = el.querySelector('panel-content');
title.innerHTML = this.md.toHTML(title.innerHTML);
content.innerHTML = this.md.toHTML(content.innerHTML);
}
}
在@Component装饰器中,我们使用markdown-panel选择器并设置viewProviders属性。在这种情况下,只有一个视图提供者:为Markdown服务提供的那个。通过设置此属性,我们声明其中声明的所有提供者都将从组件本身及其所有视图子项中可访问。
现在,假设我们有一个名为MarkdownButton的组件,并且我们想以下这种方式将其添加到我们的模板中:
<markdown-panel>
<panel-title>### Small title</panel-title>
<panel-content>
Some code
</panel-content>
<markdown-button>*Click to toggle*</markdown-button>
</markdown-panel>
以下Markdown服务将无法通过位于panel-content元素下方的MarkdownButton访问;然而,如果我们使用组件模板中的按钮,它将是可访问的:
@Component({
selector: 'markdown-panel',
viewProviders: [Markdown],
styles: [...],
template: `
<div class="panel">
<markdown-button>*Click to toggle*</markdown-button>
<div class="panel-title">
<ng-content select="panel-title"></ng-content>
</div>
<div class="panel-content">
<ng-content select="panel-content"></ng-content>
</div>
</div>`
})
// ...
如果我们需要提供者在所有内容和视图子项中可见,我们只需将viewProviders属性的名称更改为providers即可。
你可以在ch6/directives/app.ts目录下的示例目录中找到这个例子。
注意,对于任何组件或指令,我们可以使用传递给@Component或@Directive装饰器的对象字面量的providers属性来覆盖在@NgModule中声明的现有提供者。如果我们只想为给定组件的视图子项覆盖特定的提供者,我们可以使用viewProviders。
使用@SkipSelf装饰器
有时候在层次结构中,我们在不同的注入器中为相同的令牌定义了提供者。例如,假设我们有前面的示例,但具有以下注入器配置:
@Component({
selector: 'markdown-panel',
viewProviders: [{ provide: Markdown, useValue: null }],
// ...
})
class MarkdownPanel {
constructor(private el: ElementRef, private md: Markdown) { }
// ...
}
@Component({
selector: 'app',
providers: [Markdown],
// ...
})
class App {
constructor() { }
}
在前面的例子中,如果我们尝试在MarkdownPanel的构造函数中注入Markdown服务,我们会得到null,因为这是与组件元数据中viewProviders声明中的Markdown令牌关联的值。
然而,请注意,在App组件中,我们还有另一个提供者声明,它将被用于实例化App组件的ElementInjector。我们如何使用在App组件元数据中声明的Markdown提供者而不是在MarkdownPanel元数据中声明的提供者?我们只需要在MarkdownPanel的构造函数中添加@SkipSelf()装饰器。这将提示 Angular 跳过当前注入器,并在层次结构中向上查找与所需令牌关联的提供者:
@Component(...)
class MarkdownPanel {
constructor(private el: ElementRef, @SkipSelf() private md: Markdown) { }
}
Angular 还提供了@Self装饰器,它向框架提示从当前注入器获取给定令牌的提供者。在这种情况下,如果 Angular 在当前注入器中找不到提供者,它将抛出一个错误。
摘要
在本章中,我们介绍了 Angular 的依赖注入(DI)机制。我们通过在框架的上下文中介绍它,简要讨论了在我们的项目中使用 DI 的优点。我们旅程的第二步是如何使用@NgModule配置注入器;我们还解释了注入器的层次结构和注册提供者的可见性。为了强制更好的关注点分离,我们提到了如何在我们的指令和组件中注入携带我们应用程序业务逻辑的服务。
在下一章中,我们将介绍框架的新路由机制。我们将解释如何配置基于组件的路由器并将多个视图添加到我们的应用程序中。我们还将涵盖另一个重要主题,即新的表单模块。通过构建一个简单的应用程序,我们将演示如何创建和管理表单。
第七章:使用 Angular 路由和表单进行工作
到目前为止,我们已经熟悉了框架的核心。我们知道如何定义组件和指令来开发我们应用程序的视图。我们还知道如何将业务逻辑封装到服务中,并使用 Angular 的 DI 机制将一切连接起来。
在本章中,我们将解释一些将帮助我们构建真实 Angular 应用程序的概念。它们如下:
-
框架的基于组件的路由
-
使用 Angular 的表单模块
-
开发自定义表单验证器
-
开发模板驱动表单
让我们开始吧!
开发 "Coders repository" 应用程序
在解释列出的概念的过程中,我们将开发一个包含开发者库的示例应用程序。在我们开始编码之前,让我们讨论应用程序的结构。
"Coders repository" 将允许其用户通过填写包含他们详细信息的表单或提供开发者的 GitHub 处理程序并从 GitHub 导入他们的资料来添加开发者。
为了本章的目的,我们将把开发者的信息存储在内存中,这意味着在页面刷新后,我们将丢失会话期间存储的所有数据。
应用程序将具有以下视图:
-
所有开发者的列表
-
用于添加或导入新开发者的视图
一个显示给定开发者详细信息的视图。这个视图有两个子视图:
-
基本详情:显示开发者的姓名以及如果有的话他们的 GitHub 头像
-
高级资料:显示开发者所知的所有详细信息
应用程序主页的最终结果将如下所示:
图 1
在本章中,我们将只构建列出的几个视图。应用程序的其余部分将在 第八章 中解释,解释管道和与 RESTful 服务通信。
每个开发者都将是一个以下类的实例:
// ch7/multi-page-template-driven/developer.ts
export class Developer {
public id: number;
public githubHandle: string;
public avatarUrl: string;
public realName: string;
public email: string;
public technology: string;
public popular: boolean;
}
所有开发者都将位于 DeveloperCollection 类中:
// ch7/multi-page-template-driven/developer_collection.ts
class DeveloperCollection {
private developers: Developer[] = [];
getUserByGitHubHandle(username: string) {
return this.developers
.filter(u => u.githubHandle === username)
.pop();
}
getUserById(id: number) {
return this.developers
.filter(u => u.id === id)
.pop();
}
addDeveloper(dev: Developer) {
this.developers.push(dev);
}
getAll() {
return this.developers;
}
}
这里提到的类封装了简单的业务逻辑,没有 Angular 特定的内容,所以我们不会深入细节。
现在,让我们继续通过探索新的路由来继续实现。
探索 Angular 路由
正如我们所知,为了启动任何 Angular 应用程序,我们需要开发一个根 NgModule 和一个启动组件。 "Coders repository" 应用程序并没有什么不同;在这个特定情况下,唯一的增加是我们将有多页需要通过 Angular 路由连接在一起。
让我们从路由配置所需的导入开始,并在下面定义根组件:
// ch7/step-0/app.ts
import {
APP_BASE_HREF,
LocationStrategy,
HashLocationStrategy
} from '@angular/common';
import {RouterModule} from '@angular/router';
在前面的代码片段中,我们直接从 @angular/router 导入 RouterModule;正如我们所见,路由器被外部化到框架的核心之外。此模块声明了所有与路由相关的指令,以及所有路由相关的提供者,这意味着如果我们导入它,我们将能够访问所有这些。
LocationStrategy 类是一个抽象类,它定义了 HashLocationStrategy(用于基于哈希的路由)和 PathLocationStrategy(通过利用历史 API 用于基于 HTML5 的路由)之间的共同逻辑。
HashLocationStrategy 不支持服务器端渲染,因为页面的哈希值不会发送到服务器。由于哈希是应用程序的视图标识符,服务器将不会知道需要渲染的页面。幸运的是,除了 IE9 之外的所有现代浏览器都支持 HTML5 历史 API。你可以在本书的最后一章中找到更多关于服务器端渲染的信息。
现在,让我们定义一个启动组件并配置应用程序的根模块:
// ch7/step-0/app.ts
@Component({
selector: 'app',
template: `...`,
providers: [DeveloperCollection]
})
class App {}
const routeModule = RouterModule.forRoot([...]);
@NgModule({
declarations: [App],
bootstrap: [App],
imports: [BrowserModule],
providers: [{
provide: LocationStrategy,
useClass: HashLocationStrategy
}]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的代码片段中,我们可以注意到一个我们已熟悉的语法,来自第五章 入门 Angular 组件和指令和第六章 Angular 中的依赖注入。我们定义了一个具有 app 选择器的组件,template,我们将在稍后查看它,以及提供者和指令的集合。
App 组件声明了一个与 DeveloperCollection 标记关联的单个提供者。这是包含所有由应用程序存储的开发者的类。稍后,我们将调用 RouterModule 的 forRoot 方法;此方法允许我们通过声明应用程序的根路由来配置路由器。
一旦我们导入了模块,它作为 forRoot 方法调用的结果返回,我们就已经可以访问一组指令。这些指令可以帮助我们将链接到路由器配置中定义的其他路由(routerLink 指令)以及声明与不同路由关联的组件应该渲染的位置(router-outlet)。我们将在本节稍后解释如何使用它们。
现在,让我们看看我们的 AppModule 类的配置:
@NgModule({
declarations: [App],
bootstrap: [App],
imports: [BrowserModule, routeModule],
providers: [{
provide: LocationStrategy,
useClass: HashLocationStrategy
}]
})
class AppModule {}
我们添加了一个单独的声明——用于启动应用的 App 组件。注意,在这里,我们不仅导入了 BrowserModule,还导入了 RouterModule 的 forRoot 方法的返回结果。在 providers 数组中,我们配置了 LocationStrategy 的提供者。Angular 使用的默认 LocationStrategy 实现是 PathLocationStrategy(即基于 HTML5 的一个);然而,在这种情况下,我们将使用基于哈希的。
当我们不得不在两种位置策略之间进行选择时,我们应该记住,默认的位置策略(PathLocationStrategy)由 Angular 的服务器端渲染模块支持,并且应用程序的 URL 对最终用户来说看起来更自然(没有使用#)。另一方面,如果我们使用PathLocationStrategy,我们可能需要配置我们的应用程序服务器以与 HTML5 历史 API 一起工作,这对于HashLocationStrategy是不必要的。
使用 PathLocationStrategy
PathLocationStrategy类使用APP_BASE_HREF,默认情况下其值为"/"。这意味着,如果我们的应用程序的基本路径名不同,我们必须明确设置它,以便正确地实现路由功能。例如,在我们的情况下,配置应如下所示:
import {APP_BASE_HREF} from '@angular/common';
//...
@NgModule({
...
providers: [{
provide: APP_BASE_HREF,
useValue: '/dist/dev/ch7/multi-page-template-driven/'
},
{ provide: LocationStrategy, useClass: HashLocationStrategy }
]
})
class AppModule {}
在这里,APP_BASE_HREF代表应用程序的基本路径。例如,在我们的情况下,“Coders repository”将位于/dist/dev/ch7/multi-page-template-driven/目录下(或者,如果我们包括方案和主机,http://localhost:5555/dist/dev/ch7/multi-page-template-driven/)。
我们需要提供APP_BASE_HREF的值,以便向 Angular 提示路径的哪一部分是应用程序路由(即对路由器有意义的)。例如,对于http://localhost:5555/dist/dev/ch7/multi-page-template-driven/home URL,如果APP_BASE_HREF等于/dist/dev/ch7/multi-page-template-driven/,Angular 将知道它需要提供与home路径关联的组件,因为 URL 的其余部分与应用程序中声明的路由无关。
配置路由
作为下一步,让我们更新路由的声明。打开ch7/step-0/app.ts并更新RouteModule的forRoot方法的调用:
// ch7/step-1/app.ts
const routingModule = RouterModule.forRoot([
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'home',
component: Home
},
{
path: 'dev-add',
component: AddDeveloper
},
{
path: 'add-dev',
redirectTo: 'dev-add'
}
]);
如前述代码片段所示,forRoot方法接受一个路由声明数组作为参数。我们定义了两个重定向和两个与组件关联的路由。
每个非懒加载的路由都必须定义以下属性:
-
component:与给定路由关联的组件 -
path:用于路由的路径——它将在浏览器的地址栏中可见
另一方面,重定向的定义应包含以下属性:
-
path:用于重定向的路径 -
redirectTo:用户将被重定向到的路径 -
pathMatch:这定义了匹配策略
在上一个示例中,我们声明当用户导航到/add-dev时,我们希望他们被重定向到/dev-add。正如我们提到的,pathMatch定义了路径匹配策略。默认情况下,它具有"prefix"值,这意味着路由器将尝试将当前路由的开始部分与重定向中声明的path属性匹配。相比之下,当我们将pathMatch属性设置为"full"值时,只有当整个路径匹配时,路由器才会重定向到redirectTo路径。在第一个重定向中显式设置pathMatch为"full"是很重要的,否则,在以前缀匹配的情况下,每个路由都将匹配到""路径。
现在,为了使一切正常工作,我们需要定义AddDeveloper和Home组件,这些组件在路由器的配置中被引用。首先,我们将提供一个基本的实现,我们将在本章的进程中逐步扩展它。在ch7/step-0中,让我们创建一个名为home.ts的文件,并输入以下内容:
import {Component} from '@angular/core';
@Component({
selector: 'home',
template: `Home`
})
export class Home {}
现在,打开名为add_developer.ts的文件,并在其中输入以下内容:
import {Component} from '@angular/core';
@Component({
selector: 'dev-add',
template: `Add developer`
})
export class AddDeveloper {}
不要忘记在app.ts中导入Home和AddDeveloper组件。
使用 routerLink 和 router-outlet
我们有路由和所有与之相关的组件的声明。唯一剩下的事情就是定义根App组件的模板,以便将一切连接起来。
将以下内容添加到ch7/step-0/app.ts中@Component装饰器的template属性内:
@Component({
//...
template: `
<nav class="navbar navbar-default">
<ul class="nav navbar-nav">
<li><a [routerLink]="['home']">Home</a></li>
<li><a [routerLink]="['dev-add']">Add developer</a></li>
</ul>
</nav>
<router-outlet></router-outlet>
`,
//...
})
在模板中,有两个 Angular 特定的指令:
-
routerLink:这允许我们向特定路由添加链接 -
router-outlet:这定义了当前选中路由相关组件应该渲染的容器
让我们看看routerLink指令。作为值,它接受一个路由路径和参数的数组。在我们的情况下,我们只提供了一个路由路径。请注意,routerLink使用的路由名称是由forRoot内部的路由声明中的path属性声明的。在本书的后面部分,我们将看到如何链接到嵌套路由并传递路由参数。
这个指令允许我们独立于我们配置的LocationStrategy声明链接。例如,假设我们正在使用HashLocationStrategy;这意味着我们需要在我们的模板中为所有路由添加前缀#。如果我们切换到PathLocationStrategy,我们需要删除所有的哈希前缀。这仅仅是routerLink在路径引用之上创建的整洁抽象的部分好处。
从上一个模板中引入的下一个新指令是router-outlet。它具有与 AngularJS 中的ng-view指令相似的责任。基本上,它们都扮演着相同的角色:指出目标组件应该渲染的位置。这意味着根据定义,当用户导航到/时,Home组件将在router-outlet指出的位置渲染,同样,当用户导航到dev-add时,AddDeveloper组件也会在相同的位置渲染。
现在,我们已经启动了这两个路由!打开http://localhost:5555/dist/dev/ch7/step-0/,你应该会看到一个类似于以下截图的页面:
图 2
如果不这样做,只需查看包含结果的ch7/step-1。
使用 loadChildren 进行懒加载
AngularJS 模块允许我们将应用中逻辑相关的构建单元组合在一起。然而,默认情况下,它们需要在应用的初始引导阶段可用,并且不允许延迟加载。这意味着在初始页面加载时需要下载整个应用代码库,这在大型单页应用的情况下可能会导致不可接受的性能影响。
在一个完美的场景中,我们希望只加载用户当前查看的页面的代码,或者根据与用户行为相关的启发式方法预取捆绑模块,但这超出了本书的范围。例如,从我们的示例的第一个步骤打开应用,http://localhost:5555/dist/dev/ch7/step-1/。一旦用户到达/,我们只需要Home组件可用,一旦他们导航到dev-add,我们希望加载AddDeveloper组件。
让我们在 Chrome DevTools 中检查实际发生的情况:
图 3
我们可以注意到,在初始页面加载期间,Angular 下载了与所有路由关联的组件,甚至包括不需要的AddDeveloper组件。这是因为,在app.ts中,我们明确地要求了Home和AddDeveloper组件,并在路由的声明中使用它们。
在这个特定情况下,加载这两个组件可能看起来不是什么大问题,因为在这个阶段,它们非常精简,没有任何依赖。然而,在实际应用中,它们将导入其他指令、组件、管道、服务,甚至第三方库。一旦任何组件被要求,它的整个依赖图都将被下载,即使此时该组件并不需要。
Angular 的路由器提供了一个解决方案来解决这个问题:
// ch7/step-1-async/app.ts
const routingModule = RouterModule.forRoot([
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'home',
loadChildren: './home#HomeModule'
},
{
path: 'dev-add',
loadChildren: './add_developer#AddDeveloperModule'
}
]);
懒加载路由的声明是一个具有以下属性的对象:
-
loadChildren: 一个指向懒加载模块路径的字符串 -
path: 路由的路径
一旦用户导航到与任何懒加载路由定义匹配的路由,模块加载器(默认为 SystemJS)将从 loadChildren 提供的位置下载模块。当加载器返回的承诺解析为目标模块的值时,模块将被缓存,其引导组件将被渲染。下次用户导航到相同的路由时,将使用缓存的模块,因此路由模块不会下载相同的组件两次。
注意 loadChildren 属性值中的 # 符号。如果我们通过 # 符号拆分字符串,其第一部分将是模块的 URL,其第二部分将是代表路由器将用于路由的 Angular 模块的导出名称。如果我们不提供模块名称,Angular 将使用默认导出。
之前的示例使用 loadChildren,默认情况下,使用 SystemJS 加载模块。您可以使用更高级的配置和自定义模块加载器。有关更多信息,请参阅 Angular 文档angular.io。
懒加载路由的预取
正如我们已经提到的,在理想情况下,我们希望下载用户在特定时间需要的最小资源集。例如,如果用户访问主页,我们只想下载对应于主页模块(即 HomeModule)的包。
之后,当用户导航到 dev-add 时,路由器将需要下载 AddDeveloperModule。尽管如此,由于导航到尚未访问的页面时发生的减速,用户将只消耗他们使用的网络带宽,用户体验将远非完美。
为了处理这个问题,我们可以添加一个路由预加载策略:
import {RouterModule, PreloadAllModules, ... } from '@angular/router';
...
export const appRoutes = RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules
});
在前面的代码片段中,我们声明我们想要使用 Angular 提供的默认 preloadingStrategy 抽象类。因此,当用户打开 home 并且 HomeModule 成功下载后,路由器将自动开始预取所有其他路由。所以,下次用户导航到不同的页面时,它很可能已经在内存中可用。这将几乎不花费任何成本地提高用户体验。
通过提供 PreloadingStrategy 抽象类(位于 @angular/router 包中)的自定义实现,我们可以为懒加载模块的预取引入自定义机制。
RouterModule.forRoot 与 RouterModule.forChild
我们可以使用 RouterModule 调用两种方法来注册路由。
如果我们声明应用程序的顶级路由,我们需要使用 RouterModule.forRoot。此方法将注册顶级路由,并返回应该由应用程序的根模块导入的路由模块。
如果我们想在懒加载模块中定义路由并导入 forRoot 方法调用的模块,我们将得到一个运行时错误。这是因为 forRoot 方法将返回一个包含提供者的模块,这些提供者应该只由顶级模块导入一次。为了在懒加载模块中注册嵌套路由,我们需要使用 forChild 方法。
我们将在第八章 解释管道和与 RESTful 服务通信中进一步探讨如何定义嵌套路由。
作为一项经验法则,我们可以记住 RouterModule.forRoot 是用于注册顶级路由的,而 RouterModule.forChild 应仅用于在懒加载模块中注册嵌套路由。
使用 Angular 的表单模块
现在,让我们继续应用程序的实现。对于下一步,我们将处理 AddDeveloper 和 Home 组件。你可以通过扩展当前在 ch7/step-0 中的内容来继续你的实现,或者如果你还没有达到步骤 1,你可以继续在 ch7/step-1 中的文件上工作。
Angular 提供了两种带有验证的开发表单的方式:
-
模板驱动方法:这提供了一个声明式 API,其中我们将验证声明到组件的模板中
-
模型驱动方法(也称为响应式表单):这提供了一个命令式、响应式的 API
让我们先从模板驱动方法开始,并在下一章中探索模型驱动方法。
开发模板驱动表单
表单对于每个 创建、检索、更新和删除(CRUD)应用程序都是必不可少的。在我们的案例中,我们想要构建一个表单来输入我们想要存储的开发者的详细信息。
到本节结束时,我们将有一个表单,允许我们输入指定开发者的真实姓名,添加他们偏好的技术,输入他们的电子邮件,并声明他们是否在社区中受欢迎或尚未。最终结果如下所示:
图 4
将以下导入添加到 app.ts 文件中:
import {FormsModule} from '@angular/forms';
我们接下来需要做的是在 AppModule 类中导入 FormsModule。FormsModule 类包含一组用于管理 Angular 表单的预定义指令,例如 form 和 ngModel 指令。FormsModule 类还声明了一个数组,其中包含一组预定义的与表单相关的提供者,我们可以在应用程序中使用这些提供者。
在导入 FormsModule 类之后,我们的 app.ts 文件将看起来像这样:
// ch7/step-2/app.ts
@NgModule({
imports: [BrowserModule, FormsModule, routingModule],
declarations: [App, Home, AddDeveloper, ControlErrors],
providers: [{
provide: LocationStrategy,
useClass: HashLocationStrategy
}],
bootstrap: [App]
})
class AppModule {}
现在,更新 AddDeveloper 的实现如下:
// ch7/step-2/add_developer.ts
@Component({
selector: 'dev-add',
templateUrl: './add_developer.html',
styles: [...]
})
export class AddDeveloper {
developer = new Developer();
errorMessage: string;
successMessage: string;
submitted = false;
// ...
constructor(private developers: DeveloperCollection) {}
addDeveloper() {}
}
developer属性包含与我们通过表单添加的当前开发者的相关信息。最后两个属性,errorMessage和successMessage,将用于在开发者成功添加到开发者集合或发生错误时显示当前表单的错误或成功消息。
深入了解模板驱动的表单的标记
作为下一步,让我们为AddDeveloper组件创建模板(step-1/add_developer.html)。将以下内容添加到文件中:
<span *ngIf="errorMessage"
class="alert alert-danger">{{errorMessage}}</span>
<span *ngIf="successMessage"
class="alert alert-success">{{successMessage}}</span>
这两个元素旨在在我们添加新开发者时显示错误和成功消息。当errorMessage或successMessage具有非空值(即,不同于空字符串、false、undefined、0、NaN或null)时,它们将是可见的。
现在,让我们来开发实际的形式:
<form #f="ngForm" class="form col-md-4" [hidden]="submitted"
(ngSubmit)="addDeveloper()">
<div class="form-group">
<label class="control-label" for="realNameInput">Real name</label>
<div>
<input id="realNameInput" class="form-control"
type="text" name="realName"
[(ngModel)]="developer.realName" required>
</div>
</div>
<!-- MORE CODE TO BE ADDED -->
<button class="btn btn-default" type="submit">Add</button>
</form>
我们使用 HTML form标签声明一个新的表单。一旦 Angular 在模板中找到这样的标签,并且父组件中包含了一个表单指令,它将自动增强其功能,以便用作 Angular 表单。一旦表单被 Angular 处理,我们就可以应用表单验证和数据绑定。之后,使用#f="ngForm",我们在模板中定义一个局部变量,这允许我们使用f标识符引用表单。表单元素最后剩下的就是提交事件处理器。我们使用我们已熟悉的语法,(ngSubmit)="expr";在这种情况下,表达式的值是调用组件控制器中定义的addDeveloper方法。
现在,让我们看看具有control-group类名的div元素。
注意,这并不是一个 Angular 特定的类;这是一个由 Bootstrap 定义的 CSS 类,我们使用它来为表单提供更好的外观和感觉。
在div元素内部,我们可以找到一个没有 Angular 特定标记的label元素和一个允许我们设置当前开发者的真实姓名的输入元素。我们将控件设置为文本类型,并声明其标识符和名称等于realNameInput。required属性由 HTML5 规范定义,用于验证。在元素上使用它,我们声明此元素必须有值。尽管required属性不是 Angular 特定的,但 Angular 将通过包括 Angular 特定的验证行为来扩展其语义。这种行为包括在控件状态改变时设置特定的 CSS 类,并管理其状态,这些状态框架内部保持。
当表单控件值发生变化时,通过运行验证来增强其行为,并在控件的生命周期中应用特定的类。您可能已经从 AngularJS 中熟悉了这一点,其中表单控件被装饰了ng-pristine、ng-invalid和ng-valid等类。
以下表格总结了框架在其生命周期中添加到表单控件的 CSS 类:
| 类 | 描述 |
|---|---|
ng-untouched | 控件尚未被访问 |
ng-touched | 控件已被访问 |
ng-pristine | 控件的值尚未更改 |
ng-dirty | 控件的值已更改 |
ng-valid | 控件上附加的所有验证器都检测到值是有效的 |
ng-invalid | 控件上附加的任何验证器都检测到值是无效的 |
根据此表,我们可以定义我们希望所有具有无效值的输入控件在以下方式下具有红色边框:
input.ng-dirty.ng-invalid {
border: 1px solid red;
}
在 Angular 的上下文中,上述 CSS 的确切语义是,我们使用红色边框表示所有值已更改且根据附加的验证器无效的输入元素。
现在,让我们探索如何将验证行为附加到我们的控件上。
使用内置验证器
我们已经看到,我们可以使用required属性更改任何控件的验证行为。Angular 提供了两个更多内置验证器,如下所示:
-
minlength:这允许我们指定给定控件应具有的最小值长度 -
maxlength:这允许我们指定给定控件应具有的最大值长度
这些验证器是用 Angular 指令定义的,可以按以下方式使用:
<input id="realNameInput" class="form-control"
type="text" minlength="2" maxlength="30">
这样,我们指定输入的值应在2到30个字符之间。
定义自定义验证器
在Developer类中定义的另一个数据属性是email。让我们为它添加一个输入字段。在上一个表单的添加按钮上方,添加以下标记:
<div class="form-group">
<label class="control-label" for="emailInput">Email</label>
<div>
<input type="text" id="emailInput" class="form-control" name="emailInput"
[(ngModel)]="developer.email">
</div>
</div>
我们可以将[(ngModel)]属性视为 AngularJS 中ng-model指令的替代。我们将在使用 Angular 进行双向数据绑定部分中详细解释它。
虽然 Angular 提供了一套预定义的验证器,但它们不足以满足我们数据可能存在的所有各种格式。有时,我们需要为特定于应用程序的数据定义自定义验证逻辑。例如,在这种情况下,我们想要定义一个电子邮件验证器。一个典型的正则表达式,在一般情况下有效(但并不涵盖定义电子邮件地址格式的整个规范),如下所示:/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/。
在ch7/step-1/email_validator.ts中,定义一个函数,该函数接受 Angular 控件实例作为参数,如果控件值是空的或与前面提到的正则表达式匹配,则返回null,否则返回{ 'invalidEmail': true }:
function validateEmail(emailControl) {
if (!emailControl.value ||
/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(emailControl.value)) {
return null;
} else {
return { 'invalidEmail': true };
}
}
现在,从@angular/common和@angular/core模块中导入NG_VALIDATORS和Directive,并将此验证函数包裹在以下指令中:
@Directive({
selector: '[email-input]',
providers: [{
provide: NG_VALIDATORS,
multi: true,
useValue: validateEmail
}]
})
class EmailValidator {}
在前面的代码中,我们为 NG_VALIDATORS 令牌定义了一个多提供者。一旦我们注入与该令牌关联的值,我们将得到一个包含所有附加到给定控件(作为参考,请参阅第六章[3ad730a8-f797-4fc1-b908-5a20eeffac23.xhtml],Angular 中的依赖注入)的验证器的数组。
使我们的自定义验证生效只剩两个步骤。首先,将 email-input 属性添加到电子邮件控件:
<input type="text" id="emailInput" class="form-control" email-input
[(ngModel)]="developer.email">
接下来,将指令添加到 AppModule 类的声明中:
// ...
import {EmailValidator} from './email_validator';
// ...
@NgModule({
// ...
declarations: [..., EmailValidator],
// ...
})
class AppModule {}
我们正在使用一个外部模板来定义 AddDeveloper 控件。关于一个给定的模板是否应该外部化或内联,没有最终的答案。最佳实践表明,我们应该内联短模板,并将长模板外部化。然而,没有具体的定义来说明哪些模板被认为是短的,哪些被认为是长的。模板是否应该内联使用或放入外部文件,取决于开发者的个人偏好或组织内的通用惯例。
使用 Angular 选择输入
作为下一步,我们应该允许应用程序的用户输入输入开发者最擅长的技术。我们可以定义一个技术列表,并在表单中以选择输入的形式显示它们。
在 AddDeveloper 类中,添加 technologies 属性:
class AddDeveloper {
...
technologies: string[] = [
'JavaScript',
'C',
'C#',
'Clojure'
];
...
}
现在,在模板中,在添加按钮上方,添加以下标记:
<div class="form-group">
<label class="control-label"
for="technologyInput">Technology</label>
<div>
<select class="form-control" name="technology" required
[(ngModel)]="developer.technology">
<option *ngFor="let technology of technologies" [value]="technology">
{{technology}}
</option>
</select>
</div>
</div>
就像我们之前声明的输入元素一样,Angular 会根据选择输入的状态添加相同的类。为了在选择元素值无效时显示红色边框,我们需要修改 CSS 规则:
@Component({
...
styles: [
`input.ng-touched.ng-invalid,
select.ng-touched.ng-invalid {
border: 1px solid red;
}`
],
...
})
class AddDeveloper {...}
注意,将所有样式内联到我们的组件声明中可能是一种不良做法,因为这样它们就不会是可重用的。我们可以做的是将我们组件之间的所有通用样式提取到单独的文件中。@Component 装饰器有一个名为 styleUrls 的属性,其类型为 string[],我们可以添加一个引用,指向给定组件使用的提取样式。这样,如果需要,我们只可以内联组件特定的样式。
在此之后,我们使用 name="technology" 声明控件的名称等于 "technology"。使用 required 属性,我们声明应用程序的用户必须指定当前开发者擅长的技术。现在让我们跳过 [(ngModel)] 属性,看看我们如何定义 select 元素的选项。
在 select 元素内部,我们使用以下方式定义不同的选项:
<option *ngFor="let technology of technologies" [value]="technology">
{{technology}}
</option>
这是一个我们已经很熟悉的语法。我们只是遍历 AddDeveloper 类中定义的所有技术,并为每个技术显示一个具有技术名称值的 option 元素。
使用 NgForm 指令
我们已经提到,表单指令通过添加一些额外的 Angular 特定逻辑来增强 HTML5 表单的行为。现在,让我们退一步,看看围绕输入元素的表单:
<form #f="ngForm" (ngSubmit)="addDeveloper()"
class="form col-md-4" [hidden]="submitted">
...
</form>
在这个代码片段中,我们定义了一个新的标识符 f,它引用了表单。我们可以将表单视为控制的一个组合;我们可以通过表单的 controls 属性访问单个控制。在此基础上,表单还具有 touched、untouched、pristine、dirty、invalid 和 valid 属性,这些属性取决于表单内定义的各个控制。例如,如果表单内的控制都没有被 touched,那么表单本身将显示为 untouched 状态。然而,如果表单中的任何控制至少被 touched 一次,表单将显示其状态为 touched。同样,只有当表单的所有控制都有效时,表单才是有效的。
为了说明 form 元素的用法,让我们定义一个具有 control-errors 选择器的组件,该组件显示给定控制的当前错误。我们可以按以下方式使用它:
<label class="control-label" for="realNameInput">Real name</label>
<div>
<input id="realNameInput" class="form-control" type="text"
[(ngModel)]="developer.realName"
required maxlength="50">
<control-errors control="realName"
[errors]="{
'required': 'Real name is required',
'maxlength': 'The maximum length of the real name is 50 characters'
}"
/>
</div>
注意,我们还向 realName 控制添加了 maxlength 验证器。
control-errors 元素具有以下属性:
-
control:这声明了我们想要显示错误的控制的名称 -
errors:这创建了一个控制错误与错误消息之间的映射
现在,创建一个名为 control_errors.ts 的新文件,并将以下导入添加到其中:
import {Component, Host, Input} from '@angular/core';
import {NgForm} from '@angular/forms';
在这些导入中,NgForm 代表 Angular 表单,而 Host 是与 DI 机制相关的参数装饰器,我们已经在 第六章 中介绍过,即 Angular 中的依赖注入。
这里是组件定义的一部分:
@Component({
template: '<div>{{currentError}}</div>',
selector: 'control-errors',
})
class ControlErrors {
@Input() errors: Object;
@Input() control: string;
constructor(@Host() private formDir: NgForm) {}
get currentError() {...}
}
ControlErrors 组件定义了两个输入:control,控制的名称(name 属性的值)和 errors,错误标识符与错误消息之间的映射;它们分别可以通过 control-errors 元素的 control 和 errors 属性指定。
例如,假设我们有以下输入:
<input type="text" name="foobar" required>
我们可以使用以下标记来声明其关联的 control-errors 组件:
<control-errors control="foobar"
[errors]="{
'required': 'The value of foobar is required'
}"></control-errors>
在 currentError 访问器中,在前面 ControlErrors 类的声明中,我们需要做以下两件事:
-
找到具有
control属性声明的组件引用 -
返回与任何使当前控制无效的错误相关的错误消息
这里是一个实现此行为的代码片段:
@Component(...)
class ControlErrors {
...
get currentError() {
let control = this.formDir.controls[this.control];
let errorMessages = [];
if (control && control.touched) {
errorMessages = Object.keys(this.errors)
.map(k => control.hasError(k) ? this.errors[k] : null)
.filter(error => !!error);
}
return errorMessages.pop();
}
}
在currentError实现的第 一行,我们使用注入表单的controls属性获取目标控件。controls属性是{[key: string]: AbstractControl}类型,其中key是我们使用name属性声明的控件的名称。一旦我们有了目标控件实例的引用,我们可以检查其状态是否为touched(即是否已被聚焦),如果是,我们可以遍历ControlErrors实例的errors属性中的所有错误。map函数将返回一个包含错误消息或null值的数组。剩下要做的就是过滤掉所有的null值,只获取错误消息。一旦我们获取了每个错误的错误消息,我们将通过从errorMessages数组中弹出最后一个来返回它。
最终结果应该如下所示:
图 5
如果你在实现ControlErrors组件的过程中遇到任何问题,你可以查看其实现,位置在ch7/step-2/control_errors.ts。
每个控件的hasError方法接受一个错误消息标识符作为参数,该标识符由相应的验证器定义。例如,在我们的例子中,我们定义了自定义的电子邮件验证器,当输入控件具有无效值时,我们返回{ 'invalidEmail': true }对象字面量。如果我们将ControlErrors组件应用于电子邮件控件,其声明应该如下所示:
<control-errors control="email"
[errors]="{
'invalidEmail': 'Invalid email address'
}"></control-errors>
Angular 的双向数据绑定
关于 Angular 最著名的谣言之一是双向数据绑定功能被移除,因为强制单向数据流。这并不完全正确;Angular 的表单模块实现了一个带有[(ngModel)]选择器的指令(我们也将这个指令称为NgModel,因为它控制器的名字),这使我们能够轻松实现双向数据绑定:从视图到模型,以及从模型到视图。
让我们看看以下简单的组件:
// ch7/simple-two-way-data-binding/app.ts
import {Component, NgModule} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {FormsModule} from '@angular/forms';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
@Component({
selector: 'app',
template: `
<input type="text" [(ngModel)]="name">
<div>{{name}}</div>
`
})
class App {
name: string;
}
@NgModule({
imports: [BrowserModule, FormsModule],
declarations: [App],
bootstrap: [App]
})
class AppModule {}
platformBrowserDynamic().bootstrapModule(AppModule);
在前面的例子中,我们从@angular/common包中导入FormsModule。稍后,在模板中,我们将[(ngModel)]属性设置为name。
首先,[(ngModel)]语法可能看起来有点不寻常。从第五章,《Angular 组件和指令入门》,我们知道(eventName)语法用于绑定由给定组件触发的事件(或输出)。另一方面,我们使用[propertyName]="foobar"语法通过将propertyName名称与foobar表达式的评估结果设置到属性(或,在 Angular 组件的术语中,输入)的值来实现单向数据绑定。[(ngModel)]语法结合了两者,以实现双向数据绑定。这就是为什么我们可以将其视为一种语法糖,而不是一个新概念。与 AngularJS 相比,这种语法的主要优势之一是,我们只需查看模板就可以知道哪些绑定是单向的,哪些是双向的。
[(foo)]语法的另一个名称是“盒子里的香蕉”或“香蕉括号”语法。这个名称的灵感来源于这篇论文:Erik Meijer、Maarten Fokkinga 和 Ross Paterson 的《Bananas, Lenses, Envelopes and Barbed Wire:Functional Programming》(eprints.eemcs.utwente.nl/7281/01/db-utwente-40501F46.pdf)。就像(click)有它的规范语法on-click,[propertyName]有自己的bind-propertyName,[(ngModel)]的替代语法是bindon-ngModel。
如果你打开http://localhost:5555/dist/dev/ch7/simple-two-way-data-binding/,你会看到以下结果:
图 6
一旦输入框的值发生变化,其下方的标签将自动更新。
我们已经在之前的代码片段中使用了[(ngModel)]指令。例如,我们使用以下方式将开发者的电子邮件绑定:
<input id="emailInput" class="form-control" type="text"
[(ngModel)]="developer.email" email-input>
这样,一旦我们更改文本输入的值,定义在AddDeveloper组件实例中的开发者对象的电子邮件属性值就会更新。
存储表单数据
让我们再次浏览一下AddDeveloper组件控制器的界面:
export class AddDeveloper {
submitted: false;
successMessage: string;
developer = new Developer();
//...
constructor(private developers: DeveloperCollection) {}
addDeveloper(form) {...}
}
此界面有一个Developer类型的字段,我们使用NgModel指令将其表单控件绑定到其属性。该类还有一个名为addDeveloper的方法,该方法在表单提交时被调用。我们通过以下方式绑定到ngSubmit事件来声明它:
<!-- ch7/multi-page-template-driven/add_developer.html -->
<form #f="form" (ngSubmit)="addDeveloper()"
class="form col-md-4" [hidden]="submitted">
...
<button class="btn btn-default"
type="submit" [disabled]="!f.form.valid">Add</button>
</form>
在前面的代码片段中,我们应该注意两个额外的事项。我们通过#f="ngForm"获取表单的引用,并将按钮的禁用属性绑定到!f.form.valid表达式。我们已经在上一节中描述了NgForm控制;一旦表单中的所有控件都具有有效的值,其valid属性将具有true值。
现在,假设我们已经为表单中的所有输入控件输入了有效的值。这意味着其submit按钮将被启用。一旦我们按下Enter或点击submit按钮,addDeveloper方法将被调用。以下是这个方法的示例实现:
class AddDeveloper {
//...
addDeveloper() {
// We can't remove developers so setting the id this way is safe
this.developer.id = this.developers.getAll().length + 1;
this.developers.addDeveloper(this.developer);
this.successMessage = `Developer ${this.developer.realName} was successfully added`;
this.submitted = true;
}
初始时,我们将当前开发者的id属性设置为DeveloperCollection中开发者的总数加一。稍后,我们将开发者添加到集合中并设置successMessage属性的值。紧接着,我们将submitted属性设置为true,这将导致隐藏表单。
列出所有存储的数据
既然我们可以向开发者集合中添加新条目,那么让我们在“Coders repository”的前页上展示所有开发者的列表。
打开ch7/step-1/home.ts文件(或根据上一节中的进度,可能是 step-2),并输入以下内容:
import {Component} from '@angular/core';
import {DeveloperCollection} from './developer_collection';
@Component({
selector: 'home',
templateUrl: './home.html'
})
export class Home {
constructor(private developers: DeveloperCollection) {}
getDevelopers() {
return this.developers.getAll();
}
}
这里对我们来说没有什么新东西。我们通过提供一个外部模板并实现getDevelopers方法来扩展Home组件的功能,该方法将调用委托给在构造函数中注入的DeveloperCollection实例。
模板本身使用我们已经熟悉的指令:
<table class="table" *ngIf="getDevelopers().length > 0">
<thead>
<th>Email</th>
<th>Real name</th>
<th>Technology</th>
<th>Popular</th>
</thead>
<tr *ngFor="let dev of getDevelopers()">
<td>{{dev.email}}</td>
<td>{{dev.realName}}</td>
<td>{{dev.technology}}</td>
<td [ngSwitch]="dev.popular">
<span *ngSwitchCase="true">Yes</span>
<span *ngSwitchCase="false">Not yet</span>
</td>
</tr>
</table>
<div *ngIf="getDevelopers().length == 0">
There are no any developers yet
</div>
我们将所有开发者作为 HTML 表格中的行列出。对于每个开发者,我们检查其popular标志的状态。如果其值为true,则在“热门”列中显示带有文本Yes的 span,否则将文本设置为No。
当你在“添加开发者”页面中输入一些开发者并导航到主页时,你应该看到以下截图类似的结果:
图 7
你可以在ch7/multi-page-template-driven中找到应用程序的完整实现。
摘要
到目前为止,我们已经解释了 Angular 中路由的基础。我们查看如何定义不同的路由并实现与之关联的组件,这些组件在路由更改时显示。为了链接到不同的路由,我们引入了routerLink,我们还使用了router-outlet指令来指出与单个路由关联的组件应该在哪里渲染。
我们还查看了一下 Angular 表单的内置和自定义验证功能。在此之后,我们解释了NgModel指令,它为我们提供了双向数据绑定。
在下一章中,我们将介绍如何开发模型驱动的表单和子以及参数化路由,使用Http模块,以及使用自定义管道转换数据。
第八章:解释管道和与 RESTful 服务通信
在前一章中,我们介绍了框架的一些非常强大的功能。然而,我们可以进一步深入到 Angular 表单模块和路由的功能。在本章中,我们将解释我们如何执行以下操作:
-
开发模型驱动表单
-
定义参数化路由
-
定义子路由
-
使用 HTTP 模块与 RESTful API 通信
-
使用自定义管道转换数据
我们将在扩展“Coders repository”应用程序功能的过程中探讨所有这些概念。在前一章的开始,我们提到我们将允许从 GitHub 导入开发者。然而,在我们实现这个功能之前,让我们扩展表单的功能。
在 Angular 中开发模型驱动表单
这些将是完成“Coders repository”的最后几步。您可以在ch7/step-1/(或ch7/step-2,取决于您之前的工作)提供的代码基础上构建,以扩展应用程序的功能,我们将介绍新的概念。完整的示例位于ch8/multi-page-model-driven。
这就是我们将在本节结束时实现的结果:
图 1
在前面的屏幕截图中,有两个表单:
-
该表单包含以下控件:
-
用于 GitHub 处理的文本输入
-
一个复选框,指出我们是否想从 GitHub 导入开发者或手动输入他们
-
-
用于手动输入新用户的表单
第二个表单看起来与我们之前章节中留下的完全一样。然而,这次,其定义看起来略有不同:
<form class="form col-md-4" [formGroup]="addDevForm" [hidden]="submitted">
<!-- TODO -->
</form>
注意,这次我们没有submit处理程序或#f="ngForm"属性。相反,我们将[formGroup]输入绑定到组件控制器内部的addDevForm属性。使用这个输入,我们可以绑定到称为FormGroup的东西。正如其名称所示,FormGroup类由一组与它们关联的验证规则组合在一起的控件列表组成。
我们需要在用于导入开发者的表单中使用类似的声明。然而,这次,我们将提供不同的[formGroup]属性值,因为我们将在组件控制器中定义不同的表单组。将以下片段放置在我们之前引入的表单之上:
<form class="form col-md-4" [formGroup]="importDevForm" [hidden]="submitted">
<!-- TODO -->
</form>
现在,让我们在组件控制器中声明importDevForm和addDevForm属性:
import {FormGroup} from '@angular/forms';
@Component(...)
export class AddDeveloper {
importDevForm: FormGroup;
addDevForm: FormGroup;
...
constructor(private developers: DeveloperCollection,
fb: FormBuilder) {...}
addDeveloper() {...}
}
初始时,我们从@angular/forms模块导入FormGroup类,稍后,在控制器中声明所需的属性。注意,我们在AddDeveloper的constructor中有一个额外的参数,名为fb,其类型为FormBuilder。
FormBuilder 类型提供了一个可编程 API,用于定义 FormGroup,我们可以将验证行为附加到组中的每个控件。让我们使用 FormBuilder 实例初始化 importDevForm 和 addDevForm 属性:
...
constructor(private developers: DeveloperCollection,
fb: FormBuilder) {
this.importDevForm = fb.group({
githubHandle: ['', Validators.required],
fetchFromGitHub: [false]
});
this.addDevForm = fb.group({
realName: ['', Validators.required],
email: ['', validateEmail],
technology: ['', Validators.required],
popular: [false]
});
}
...
FormBuilder 实例有一个名为 group 的方法,它允许我们定义控件并设置它们的属性,例如它们的默认值和验证器。
根据前面的代码片段,importDevForm 有两个字段:githubHandle 和 fetchFromGitHub。我们声明 githubHandle 控件的默认值为空字符串,其值是必需的,并将 fetchFromGitHub 控件的默认值设置为 false。
在第二个表单 addDevForm 中,我们声明了四个控件。对于 realName 控件,我们将默认值设置为空字符串,并使用 Validators.requred 来引入验证行为(这正是我们对 githubHandle 控件所做的那样)。作为电子邮件输入的验证器,我们将使用 validateEmail 函数,并将控件初始值设置为空字符串。validateEmail 函数是我们之前章节中定义的:
function validateEmail(emailControl) {
if (!emailControl.value ||
/^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$/.test(emailControl.value)) {
return null;
} else {
return { 'invalidEmail': true };
}
}
在这里定义的最后两个控制是 technology 控制器,它有一个必需的值验证器,其初始值为空字符串,以及 popular 控制器,其初始值设置为 false。
使用控制验证器的组合
我们查看了一下如何将单个验证器应用于表单控件。使用模型驱动方法,我们以与上一章中使用的模板驱动表单和添加 required 属性相同的方式应用了 Validators.required 验证器。然而,在某些应用程序中,领域可能需要更复杂的验证逻辑。例如,如果我们想将 required 和 validateEmail 验证器都应用于电子邮件控件,我们应该做以下操作:
this.addDevForm = fb.group({
...
email: ['', Validators.compose([
Validators.required,
validateEmail
])
],
...
});
Validators 对象的 compose 方法接受一个验证器数组作为参数,并返回一个新的验证器。新验证器的行为将是作为参数传递的各个验证器中定义的逻辑的组合,并且它们将按照在数组中引入的顺序应用。
传递给 FormBuilder 的 group 方法的对象字面量中的属性名应该与我们在模板中为输入的 formControlName 属性设置的值相匹配。这是 importDevForm 的完整模板:
<form class="form col-md-4" [formGroup]="importDevForm" [hidden]="submitted">
<div class="form-group">
<label class="control-label" for="githubHandleInput">GitHub handle</label>
<div>
<input id="githubHandleInput" class="form-control"
type="text" formControlName="githubHandle">
<control-errors control="githubHandle"
[errors]="{
'required': 'The GitHub handle is required'
}"></control-errors>
</div>
</div>
<div class="form-group">
<label class="control-label" for="fetchFromGitHubCheckbox">
Fetch from GitHub
</label>
<input class="checkbox-inline" id="fetchFromGitHubCheckbox"
type="checkbox" formControlName="fetchFromGitHub">
</div>
</form>
在前面的模板中,我们可以看到,一旦 submitted 标志具有 true 值,表单将隐藏给用户。在第一个输入元素旁边,我们将 formControlName 属性的值设置为 githubHandle。formControlName 属性将模板中现有的表单输入与在 FormGroup 类中声明的输入关联起来,对应于 HTML 输入所在的表单元素。这意味着我们传递给 FormBuilder 的 group 方法的对象字面量内部的控件定义所关联的键必须与模板中设置 formControlName 的相应控件名称匹配。
现在我们想要实现以下行为:
-
当“从 GitHub 获取”复选框被勾选时,禁用输入新开发者的表单,并启用从 GitHub 导入开发者的表单
-
当当前活动(或启用)的表单无效时,禁用
submit按钮
我们将探讨如何使用 Angular 的响应式表单 API(也称为模型驱动表单)来实现此功能。
在 AddDeveloper 类中,添加以下方法定义:
...
export class AddDeveloper {
//...
ngOnInit() {
this.toggleControls(this.importDevForm.controls['fetchFromGitHub'].value);
this.subscription = this.importDevForm.controls['fetchFromGitHub']
.valueChanges.subscribe(this.toggleControls.bind(this));
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
private toggleControls(importEnabled: boolean) {
const addDevControls = this.addDevForm.controls;
if (importEnabled) {
this.importDevForm.controls['githubHandle'].enable();
Object.keys(addDevControls).forEach((c: string) =>
addDevControls[c].disable());
} else {
this.importDevForm.controls['githubHandle'].disable();
Object.keys(addDevControls).forEach((c: string) =>
addDevControls[c].enable());
}
}
}
...
注意,在 ngOnInit 中,我们使用当前 fetchFromGitHub 复选框的值调用 toggleControls 方法。我们可以通过获取 importDevForm 中 controls 的 fetchFromGitHub 属性来获取表示复选框的 AbstractControl 的引用。
之后,我们通过传递一个回调给其 subscribe 方法来订阅复选框的 valueChange 事件。每次复选框的值发生变化时,我们传递给 subscribe 的回调将被调用。
之后,在 ngOnDestroy 中,我们取消订阅 valueChange 订阅,以防止我们的代码出现内存泄漏。
最后,最有趣的事情发生在 toggleControls 方法中。我们将一个标志传递给此方法,该标志指示我们是否希望 importDevForm 被启用。如果我们希望表单被启用,我们只需要调用 githubHandle 控件的 enable 方法,并禁用 addDevForm 中的所有 controls。我们可以通过遍历控制名称(即 addDevForm 的 controls 属性的键)来禁用 addDevForm 中的所有 controls,获取每个单独名称的相应控件实例,并调用其 disable 方法。如果 importEnabled 标志的值为 false,我们将执行完全相反的操作,通过调用 addDevForm 中的 controls 的 enable 方法和 importDevForm 中控件的 disable 方法。
探索 Angular 的 HTTP 模块
现在,在我们开发了两个表单——导入现有开发者和添加新开发者之后,是时候在组件的控制器中实现它们背后的逻辑了。
为了这个目的,我们需要与 GitHub API 进行通信。虽然我们可以直接从组件的控制器中这样做,但通过这种方式解决问题,我们会将视图与 GitHub 的 RESTful API 耦合起来。为了强制更好的关注点分离,我们可以将用于与 GitHub 通信的逻辑提取到一个单独的服务中,称为GitHubGateway。打开名为github_gateway.ts的文件,并输入以下内容:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
export interface GitHubUser {
id: number;
login: string;
email: string;
followers: number;
name: string;
avatar_url: string;
}
@Injectable()
export class GitHubGateway {
constructor(private http: HttpClient) { }
getUser(username: string): Observable<GitHubUser> {
return this.http.get<GitHubUser>(`https://api.github.com/users/${username}`);
}
}
初始时,我们从@angular/common/http模块导入HttpClient类。所有与 HTTP 相关的功能都被外部化到 Angular 的核心之外。
在此之后,我们声明GitHubUser接口。我们使用它来描述 GitHub 返回的预期响应类型。在这种情况下,我们手动创建接口声明;然而,通常这样的接口是通过 API 模式(例如 OpenAPI)生成的,这些模式在前端和后端之间共享。
在接受GitHubGateway依赖后,需要通过框架的 DI 机制注入,我们将使用@Injectable装饰器装饰类。
我们使用的 GitHub API 的唯一功能是用于获取用户的功能,因此我们定义了一个名为getUser的单个方法。它接受开发者的 GitHub 处理程序作为参数。
注意,如果你每天向 GitHub 的 API 发送超过 60 个请求,你可能会遇到这个错误:GitHub API 速率限制超出。这是由于没有 GitHub API 令牌的请求速率限制。有关更多信息,请访问github.com/blog/1509-personal-api-tokens。
在getUser方法内部,我们使用在constructor中接收到的HttpClient服务实例。请注意,客户端的get方法有一个类型参数,并返回Observable。类型参数的目的是指示响应的类型,该响应被包裹在Observable中。作为预期类型,我们设置了GitHubUser接口。使用可观察对象而不是承诺来为HttpClient提供一些好处;例如,考虑以下好处:
-
可观察对象按设计是可取消的
-
可观察对象可以轻松重试
-
我们可以映射和过滤从给定请求接收到的响应
HttpClient服务的完整 API 可以在angular.io/api/common/http/HttpClient找到。
使用 Angular 的 HTTP 模块
现在,让我们实现从 GitHub 导入现有开发者的逻辑。首先,我们需要在我们的AppModule类中导入HttpClientModule:
import {HttpClientModule} from '@angular/common/http';
...
@NgModule({
imports: [..., HttpClientModule],
declarations: [...],
providers: [...],
bootstrap: [...]
})
class AppModule {}
...
然后,打开ch7/step-2/add_developer.ts文件,并输入以下导入:
import {GitHubGateway} from './github_gateway';
将GitHubGateway添加到AddDeveloper组件提供者的列表中:
@Component({
...
providers: [GitHubGateway]
})
class AddDeveloper {...}
作为下一步,我们必须在类的constructor中包含以下参数:
constructor(private githubAPI: GitHubGateway,
private developers: DeveloperCollection,
fb: FormBuilder) {
//...
}
这样,AddDeveloper类实例将有一个名为githubAPI的私有属性。
剩下的工作就是实现addDeveloper方法,并允许用户使用GitHubGateway实例导入现有的开发者。
一旦用户点击添加按钮,我们需要检查是否需要导入现有的 GitHub 用户或添加新的开发者。为此,我们可以使用fetchFromGitHub控制器的值:
if (this.importDevForm.controls['fetchFromGitHub'].value) {
// Import developer
} else {
// Add new developer
}
如果它有一个真值,那么我们可以调用githubAPI属性的getUser方法,并将githubHandle控制器的值作为参数传递:
this.githubAPI.getUser(model.githubHandle)
在GitHubGateway实例的getUser方法中,我们将调用委托给HttpClient服务的get方法,该方法返回Observable。为了获取Observable将要推送的结果,我们需要将其subscribe方法传递一个回调函数:
this.githubAPI.getUser(model.githubHandle)
.subscribe((res: GitHubUser) => {
// "res" contains the response of the GitHub's API.
});
在前面的代码片段中,我们首先执行一个 HTTP GET请求。之后,我们得到相应的Observable实例,在一般情况下,将发出一系列值;在这种情况下,只有一个——响应的主体,解析为 JSON。如果请求失败,那么我们将得到一个错误。
注意,为了减少 Angular 的包大小,谷歌团队只在框架中包含了 RxJS 的核心。为了使用map和catch方法,你需要在add_developer.ts中添加以下导入:import 'rxjs/add/operator/map'; 和 import 'rxjs/add/operator/catch';。
请记住,RxJS 版本 5.5 引入了所谓的可订阅操作符,这允许我们使用命名导入导入操作符,与前面的示例相比,它使用具有副作用导入。这是一个向前的重大步骤,提高了类型安全性。更多关于可订阅操作符的信息可以在这里找到:
github.com/ReactiveX/rxjs/blob/master/doc/lettable-operators.md
现在,让我们实现传递给subscribe的回调函数的主体:
let dev = new Developer();
dev.githubHandle = res.login;
dev.email = res.email;
dev.popular = res.followers >= 1000;
dev.realName = res.name;
dev.id = res.id;
dev.avatarUrl = res.avatar_url;
this.developers.addDeveloper(dev);
this.successMessage = `Developer ${dev.githubHandle} successfully imported from GitHub`;
在前面的示例中,我们设置了一个新的Developer实例的属性。在这里,我们建立了 GitHub API 返回的对象与我们在应用程序中代表开发者的映射。我们认为如果一个开发者拥有超过1000个关注者,那么他就是受欢迎的。
addDeveloper方法的整个实现可以在ch8/multi-page-model-driven/add_developer.ts中找到。
为了处理失败的请求,我们可以使用 Observable 实例的catch方法:this.githubAPI.getUser(model.githubHandle)
.catch((error, source, caught) => {
console.log(error);
return error;
});
定义参数化视图
作为下一步,一旦用户点击了应用程序主页上任何开发者的名字,他们应该被重定向到一个包含所选开发者详细资料的视图。最终结果将如下所示:
图 2
为了做到这一点,我们需要将开发者的标识符传递给显示开发者详细资料的组件。打开app.ts,并添加以下导入:
import {DeveloperDetails} from './developer_details';
我们尚未开发DeveloperDetails组件,因此,如果您运行应用程序,您将得到一个错误。我们将在下一段定义该组件,但在那之前,让我们修改app.ts的路线定义:
const routingModule = RouterModule.forRoot([
...
{
component: DeveloperDetails,
path: 'dev-details/:id',
children: devDetailsRoutes
}
]);
在这里,我们添加一个具有dev-details/:id路径的单个路由,并将其与DeveloperDetails组件关联。
注意,在path属性中,我们声明该路由有一个名为id的单个参数,并将children属性设置为devDetailsRoutes。devDetailsRoutes子路由应位于DeveloperDetails组件中的router-outlet内。
现在,让我们将当前开发者的id作为参数传递给routerLink指令。打开您的工作目录中的home.html,并将显示开发者realName属性的表格单元格替换为以下内容:
<td>
<a [routerLink]="['/dev-details', dev.id, 'dev-basic-info']">
{{dev.realName}}
</a>
</td>
routerLink指令的值是一个包含以下三个元素的数组:
-
'/dev-details':显示根路由的字符串 -
dev.id:我们想要查看详细信息的开发者的 ID -
'dev-basic-info':显示嵌套路由中应渲染哪个组件的路由路径
定义嵌套路由
现在,让我们转到DeveloperDetails的定义。在您的工作目录中,创建一个名为developer_details.ts的文件,并输入以下内容:
import {Component} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {Developer} from './developer';
import {DeveloperCollection} from './developer_collection';
import {DeveloperBasicInfo} from './developer_basic_info';
import {DeveloperAdvancedInfo} from './developer_advanced_info';
import 'rxjs/add/operator/take';
@Component({
selector: 'dev-details',
template: `...`,
})
export class DeveloperDetails {
public dev: Developer;
constructor(private route: ActivatedRoute,
private developers: DeveloperCollection) {}
ngOnInit() {
this.route.params.take(1)
.subscribe((params: any) => {
this.dev = this.developers.getUserById(parseInt(params['id']));
});
}
}
export const devDetailsRoutes = [...];
为了简化起见,为了避免在本书的示例中引入复杂的目录/文件结构,我们在单个文件中包含了一些组件和路线声明。请记住,根据最佳实践,单个声明应放置在单独的文件中。有关更多信息,请访问angular.io/styleguide。
在前面的片段中,我们定义了一个名为DeveloperDetails的组件,并在控制器中调用。注意,在控制器的constructor函数中,通过 Angular 的依赖注入机制,我们注入了一个与ActivatedRoute令牌关联的参数。注入的参数为我们提供了访问当前路由可见参数的能力。在ngOnInit中,我们采用命令式方法,订阅params属性值的变化,获取第一组参数,并将dev属性赋值为调用this.developers.getUserById的结果,其中所选开发者的标识符作为参数。
注意,利用 RxJS 提供的更高阶函数采取更声明性和响应式的方法会更好,我们可以通过类似以下代码的方式获取所选开发者:
...
get dev$() {
return this.route.params.map((params: any) =>
this.developers.getUserById(parseInt(params['id']));
}
...
之后,我们可以使用 Angular 的异步管道绑定到调用结果,我们将在本章后面解释该管道。
由于我们从routeParams.params['id']获取的参数是一个字符串,我们需要将其解析为数字,以便获取与给定路由关联的开发者。
现在,让我们定义子路由,这些路由将在DeveloperDetails的模板中渲染:
export const devDetailsRoutes = [
{ path: '', redirectTo: 'dev-basic-info', pathMatch: 'full' },
{ component: DeveloperBasicInfo, path: 'dev-basic-info' },
{ component: DeveloperAdvancedInfo, path: 'dev-details-advanced' }
];
在前面的声明中,对我们来说没有什么新的。路由定义遵循我们已熟悉的完全相同的规则。
现在,让我们为组件的模板添加与单个嵌套路由关联的链接:
@Component({
selector: 'dev-details',
template: `
<section class="col-md-4">
<ul class="nav nav-tabs">
<li><a [routerLink]="['./dev-basic-info']">Basic profile</a></li>
<li><a [routerLink]="['./dev-details-advanced']">Advanced details</a></li>
</ul>
<router-outlet></router-outlet>
</section>
`
})
export class DeveloperDetails {...}
在模板中,我们声明了两个相对于当前路径的链接。第一个链接指向dev-basic-info,这是在devDetailsRoutes中定义的第一个路由的路径,第二个链接指向dev-details-advanced。
由于与两个路由关联的组件的实现相当相似,让我们只看看DeveloperBasicInfo的实现。第二个组件的实现可以在ch8/multi-page-model-driven/developer_advanced_info.ts中找到:
import {Component, Inject, OnInit, forwardRef, Host} from '@angular/core';
import {DeveloperDetails} from './developer_details';
import {Developer} from './developer';
@Component({
selector: 'dev-details-basic',
styles: [`
.avatar {
border-radius: 150px;
}`
],
template: `
<h2>{{dev.githubHandle | uppercase}}</h2>
<img *ngIf="dev.avatarUrl == null" class="avatar"
src="img/gravatar-60-grey.jpg" width="150">
<img *ngIf="dev.avatarUrl != null" class="avatar" [src]="dev.avatarUrl" width="150">
`
})
export class DeveloperBasicInfo implements OnInit {
dev: Developer;
constructor(@Inject(forwardRef(() => DeveloperDetails))
@Host() private parent: DeveloperDetails) {
}
ngOnInit() {
this.dev = this.parent.dev;
}
}
在前面的代码片段中,我们使用@Inject参数装饰器注入父组件。在@Inject内部,我们使用forwardRef,因为我们有developer_basic_info和developer_details包之间的循环依赖(在developer_basic_info中,我们导入developer_details,在developer_details中,我们导入developer_basic_info)。
我们需要一个对父组件实例的引用,以便获取与所选路由对应的当前开发者的实例。我们在ngOnInit生命周期钩子中获取这个引用。
使用管道转换数据
现在是时候看看 Angular 为我们开发应用程序提供的最后一个构建块——管道了,我们还没有详细讨论过。
就像 AngularJS 中的过滤器一样,管道旨在封装所有的数据转换逻辑。让我们看看我们刚刚开发的应用程序的主页模板:
...
<td [ngSwitch]="dev.popular">
<span *ngSwitchCase="true">Yes</span>
<span *ngSwitchCase="false">Not yet</span>
</td>
...
在前面的代码片段中,根据popular属性的值,我们使用NgSwitch和NgSwitchCase指令显示不同的数据。虽然这可行,但它是多余的。
开发无状态管道
让我们开发一个管道,它将popular属性的值进行转换,并用它来代替NgSwitch和NgSwitchCase。该管道将接受三个参数:一个需要转换的值,一个当值为真时应该显示的字符串,以及一个在值为假时应该显示的另一个字符串。
使用 Angular 自定义管道,我们可以将模板简化为以下形式:
<td>{{dev.popular | boolean: 'Yes': 'No'}}</td>
我们甚至可以使用表情符号,如下所示:
<td>{{dev.popular | boolean: '': ''}}</td>
我们将管道应用于值的方式与在 AngularJS 中做的一样。传递给管道的参数应该由冒号(:)符号分隔。
为了开发 Angular 管道,我们需要以下导入:
import {Pipe, PipeTransform} from '@angular/core';
Pipe 装饰器可以用于向实现数据转换逻辑的类添加元数据。PipeTransform 接口是一个只有一个方法的接口,称为 transform:
import {Pipe, PipeTransform} from '@angular/core';
@Pipe({ name: 'boolean' })
export class BooleanPipe implements PipeTransform {
transform(flag: boolean, trueValue: any, falseValue: any): string {
return flag ? trueValue : falseValue;
}
}
前面的代码片段是 BooleanPipe 的完整实现。我们传递给 @Pipe 装饰器的 name 类型决定了我们在模板中如何引用它。
在能够使用 BooleanPipe 之前,我们需要做的最后一件事是将它添加到我们的 AppModule 类的声明列表中:
@NgModule({
...
declarations: [..., BooleanPipe, ...],
...
})
class AppModule {}
使用 Angular 的内置管道
Angular 提供以下内置管道集:
CurrencyPipe:这个管道用于格式化货币数据。作为参数,它接受货币类型的缩写(即"EUR"、"USD"等)。它可以按以下方式使用:
{{ currencyValue | currency: 'USD' }} <!-- USD42 -->
DatePipe:这个管道用于日期的转换。它可以按以下方式使用:
{{ dateValue | date: 'shortTime' }} <!-- 12:00 AM -->
DecimalPipe:这个管道用于十进制数字的转换。它接受的参数格式如下:"{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}"。它可以按以下方式使用:
{{ 42.1618 | number: '3.1-2' }} <!-- 042.16 -->
JsonPipe:这个管道将 JavaScript 对象转换为 JSON 字符串。它可以按以下方式使用:
{{ { foo: 42 } | json }} <!-- { "foo": 42 } -->
LowerCasePipe:这个管道将字符串转换为小写。它可以按以下方式使用:
{{ FOO | lowercase }} <!-- foo -->
UpperCasePipe:这个管道将字符串转换为大写。它可以按以下方式使用:
{{ 'foo' | uppercase }} <!-- FOO -->
PercentPipe:这个管道将数字转换为百分比。它可以按以下方式使用:
{{ 42 | percent: '2.1-2' }} <!-- 4,200.0% -->
SlicePipe:这个管道返回数组的切片。管道接受切片的起始和结束索引。它可以按以下方式使用:
{{ [1, 2, 3] | slice: 1: 2 }} <!-- 2 -->
AsyncPipe:这是一个具有状态的管道,它接受一个Observable对象或一个承诺;我们将在本章末尾探讨它。
开发具有状态的管道
所提到的所有管道中有一个共同点——每次我们将它们应用于相同的值并传递相同的参数集时,它们都返回完全相同的结果。这样的管道,具有引用透明性属性,被称为 纯管道。
@Pipe 装饰器接受一个 { name: string, pure?: boolean } 类型的对象字面量,其中 pure 属性的默认值是 true。这意味着当我们定义任何给定的管道时,我们可以声明它是具有状态的还是无状态的。纯属性很重要,因为如果管道不产生副作用并且在应用相同的参数集时返回相同的结果,则可以优化变更检测。
现在,让我们构建一个具有状态的管道。我们的管道将向一个 JSON API 发送 HTTP get 请求。为此,我们将使用 @angular/common/http 模块。
注意,在管道中包含业务逻辑不被认为是最佳实践。这种类型的逻辑应该提取到服务中。这里的例子仅用于学习目的。
在这种情况下,管道需要根据请求的状态(即它是否挂起或完成)保持一个状态。我们将以下这种方式使用管道:
{{ "http://example.com/user.json" | fetchJson | json }}
这样,我们在 URL 上应用了fetchJson管道。一旦我们有了响应体,我们就可以在它上面应用json管道。这个例子还展示了我们如何使用 Angular 链式管道。
与无状态管道类似,对于有状态管道的开发,我们必须用@Pipe装饰实现管道逻辑的类,并实现PipeTransform接口。这次,由于 HTTP 请求功能,我们还需要从@angular/common/http模块导入HttpClient类:
import {Pipe, PipeTransform} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import 'rxjs/add/operator/toPromise';
每次将fetchJson管道应用于具有不同值的参数时,我们都需要发起一个新的 HTTPget请求。这意味着作为管道的状态,我们需要至少保留远程服务的响应值和最后一个 URL 的值:
@Pipe({
name: 'fetchJson',
pure: false
})
export class FetchJsonPipe implements PipeTransform {
private data: any;
private prevUrl: string = null;
constructor(private http: HttpClient) {}
transform(url: string): any {...}
}
我们需要实现的唯一逻辑是transform方法:
...
transform(url: string): any {
if (this.prevUrl !== url) {
this.http.get(url).toPromise(Promise)
.then(result => this.data = result);
this.prevUrl = url;
}
return this.data || {};
}
...
在其中,我们最初比较作为参数传递的 URL 与我们已有的一个(默认情况下,其值将为null)。如果它们不同,我们将使用传递给constructor函数的本地HttpClient类的实例发起一个新的 HTTPget请求。一旦请求完成,我们将data属性设置为结果。
现在,假设管道已经开始了一个get请求,并且在它完成之前,变更检测机制再次调用了管道。在这种情况下,我们将比较prevUrl属性和url参数。如果它们相同,我们不会执行新的请求,并将立即返回data属性的值。如果prevUrl的值与url不同,我们将发起一个新的请求。
使用有状态管道
现在,让我们使用我们开发的管道。我们将实现的程序提供了一个文本输入和一个带有“获取头像”标签的按钮。一旦用户在文本输入中输入值并点击按钮,GitHub 用户的头像将出现在文本输入下方,如下面的截图所示:
图 3
现在,让我们开发一个示例组件,它将允许我们输入 GitHub 用户的 handle:
// ch8/statful_pipe/app.ts
@Component({
selector: 'app',
template: `
<input type="text" #input>
<button (click)="setUsername(input.value)">Get Avatar</button>
`
})
class App {
username: string;
setUsername(user: string) {
this.username = user;
}
}
剩下的唯一事情就是显示用户的 GitHub 头像。我们可以通过以下img声明轻松地通过更改组件的模板来实现这一点:
<img width="160"
[src]="(('https://api.github.com/users/' + username) | fetchJson).avatar_url">
初始时,我们将 GitHub handle 附加到用于从 API 获取用户的基 URL。稍后,我们将对它应用fetchJson过滤器,并从返回的结果中获取avatar_url属性。
使用 Angular 的 AsyncPipe
Angular 的 AsyncPipe transform 方法接受一个 Observable 对象或一个 promise 作为参数。一旦参数推送一个值(即,promise 已解决或 Observable 的 subscribe 回调被调用),AsyncPipe 将将其作为结果返回。让我们看看以下示例:
// ch8/async-pipe/app.ts
@Component({
selector: 'greeting',
template: 'Hello {{ greetingPromise | async }}'
})
class Greeting {
greetingPromise = new Promise<string>(resolve => this.resolve = resolve);
resolve: Function;
constructor() {
setTimeout(_ => {
this.resolve('Foobar!');
}, 3000);
}
}
在这里,我们定义了一个具有两个属性的 Angular 组件,即 Promise<string> 类型的 greetingPromise 和 Function 类型的 resolve。我们使用一个新的 Promise<string> 实例初始化了 greetingPromise 属性,并将 resolve 属性的值设置为 promise 的 resolve 回调函数。
在类的 constructor 中,我们开始一个持续 3000 毫秒的定时器,并在其回调函数中解决 promise。一旦 promise 被解决,{{ greetingPromise | async }} 表达式的值将被评估为 Foobar! 字符串。用户最终在屏幕上看到的文本是“Hello Foobar!”。
当我们将 async 管道与推送一系列值的 Observable 结合使用时,async 管道非常强大。当它所使用的视图被销毁时,它会自动取消订阅 Observable。
使用 AsyncPipe 与 observables
我们已经从前面的章节中熟悉了可观察对象的概念。我们可以这样说,一个 Observable 对象允许我们订阅一系列值的发射,例如:
let observer = Observable.create(observer => {
setInterval(() => {
observer.next(new Date().getTime());
}, 1000);
});
observer.subscribe(date => console.log(date));
一旦我们订阅了 Observable,它将每秒发射一个值,这些值将在控制台中打印出来。让我们将这个片段与组件定义结合起来,实现一个简单的计时器:
// ch8/async_pipe/app.ts
@Component({ selector: 'timer' })
class Timer {
username: string;
timer: Observable<number>;
constructor() {
let counter = 0;
this.timer = new Observable<number>(observer => {
setInterval(() => {
observer.next(new Date().getTime());
}, 1000);
});
}
}
为了能够使用 timer 组件,我们只需添加一个模板声明。我们可以在模板中使用 async 管道直接订阅 Observable:
{{ timer | async | date: "medium" }}
这样,我们每秒都会接收到 Observable 推出的新值,date 管道将其转换为可读形式。
摘要
在本章中,我们通过开发一个基于模型的(响应式)表单,结合 HTTP 模块,深入研究了 Angular 的表单模块。我们查看了一些新组件路由的高级功能,并了解了如何使用和开发自定义的有状态和无状态管道。
下一章将专门介绍如何利用模块 Universal 提供的服务端渲染功能,使我们的 Angular 应用程序更易于搜索引擎优化(SEO)。我们还将探讨 Angular CLI 和其他使开发者体验更好的工具。最后,我们将解释在 Angular 的背景下,什么是预编译(ahead-of-time compilation),以及为什么我们应该在我们的应用程序中利用它。