Angular 2 示例(二)
原文:
zh.annas-archive.org/md5/529E3E7FE7FFE986F90814E2C501C746译者:飞龙
第三章:更多 Angular 2-SPA,路由和深入的数据流
如果上一章是关于在 Angular 中构建我们的第一个有用的应用程序,那么这一章是关于为其添加大量的 Angular 功能。在学习曲线中,我们已经开始探索技术平台,现在我们可以使用 Angular 构建一些基本的应用程序。但这只是开始!在我们能够在一个相当大的应用程序中有效使用 Angular 之前,还有很多东西要学习。这一章让我们离实现这个目标更近了一步。
7 分钟锻炼应用程序仍然有一些不足之处/限制,我们可以在改善整体应用程序体验的同时解决这些问题。这一章就是关于添加这些增强和功能的。而且,像往常一样,这个应用程序构建过程为我们提供了足够的机会来增强我们对框架的理解,并学习关于它的新知识。
本章涵盖的主题包括:
-
探索 Angular 单页应用程序(SPA):我们探索 Angular 的单页应用程序功能,包括路由导航、链接生成和路由事件。
-
理解依赖注入:这是核心平台功能之一。在本章中,我们将学习 Angular 如何有效利用依赖注入,在整个应用程序中注入组件和服务。
-
Angular 纯(无状态)和不纯(有状态)管道:我们将更详细地探索 Angular 的主要数据转换构造,管道,同时构建一些新的管道。
-
跨组件通信:由于 Angular 完全涉及组件及其交互,我们将看看如何在父子和同级组件设置中进行跨组件通信。我们将学习 Angular 模板变量和事件如何促进这种通信。
-
创建和使用事件:我们将学习组件如何公开自己的事件,以及如何从模板 HTML 和其他组件绑定到这些事件。
作为一个旁注,我希望你经常使用7 分钟锻炼并关注你的身体健康。如果没有,请休息七分钟并进行锻炼。我坚持!
希望锻炼很有趣!现在让我们回到一些严肃的事情。让我们开始探索 Angular 单页应用程序(SPA)的功能。
注意
我们从第二章中离开的地方开始,构建我们的第一个应用程序-7 分钟锻炼。git 分支checkpoint2.4可以作为本章的基础。
该代码也可在 GitHub 上获取(github.com/chandermani/angular2byexample),供所有人下载。检查点在 GitHub 上作为分支实现。
如果您不使用 git,请从 GitHub 位置bit.ly/ng2be-checkpoint2-4下载checkpoint2.4的快照(ZIP 文件)。首次设置快照时,请参考trainer文件夹中的README.md文件。
探索单页应用程序的能力
7 分钟锻炼从加载应用程序开始,但以最后一次锻炼永久停留在屏幕上结束。这不是一个非常优雅的解决方案。为什么我们不在应用程序中添加开始和结束页面呢?这使应用程序更专业,并且可以让我们理解 AngularJS 的单页面命名法。
Angular SPA 基础设施
使用现代 Web 框架(如 Angular(Angular 1.x)和 Ember),我们现在习惯于不执行完整页面刷新的应用程序。但是,如果您是新手,值得一提的是这些单页应用程序(SPAs)是什么。
单页应用程序(SPAs)是基于浏览器的应用程序,不需要进行完整的页面刷新。在这种应用程序中,一旦加载了初始 HTML,任何未来的页面导航都是使用 AJAX 作为 HTML 片段检索并注入到已加载的视图中。谷歌邮件是 SPA 的一个很好的例子。SPAs 为用户提供了极佳的用户体验,因为用户可以获得类似桌面应用程序的感觉,而无需不断的后退和页面刷新,这通常与传统 Web 应用程序相关联。
与其前身一样,Angular 2 也为 SPA 实现提供了必要的构造。让我们了解它们并添加我们的应用程序页面。
Angular 路由
Angular 使用其路由基础设施支持 SPA 开发。该基础设施跟踪浏览器 URL,启用超链接生成,公开路由事件,并提供一组用于视图的指令/组件。
有四个主要的框架部分共同支持 Angular 路由基础设施:
-
路由器(Router):实际提供组件导航的主要基础设施
-
路由配置(Route):组件路由器依赖于路由配置来设置路由
-
RouterOutlet 组件:
RouterOutlet组件是路由特定视图加载的占位符容器(主机) -
RouterLink 指令:这会生成可以嵌入到锚标签中用于导航的超链接
以下图表突出显示了这些组件在路由设置中所扮演的角色:
我强烈鼓励每个人在为7 分钟锻炼设置路由时不断回顾这个图表。
路由器是这个完整设置的核心部分;因此,对路由器的快速概述将会很有帮助。
角度路由器
如果你曾经使用过带有 SPA 支持的任何 JavaScript 框架,这就是它的工作原理。框架监视浏览器 URL,并根据加载的 URL 提供视图。有专门的框架组件来完成这项工作。在 Angular 世界中,这种跟踪是由框架服务,即路由器来完成的。
注意
在 Angular 中,任何提供一些通用功能的类、对象或函数都被称为服务。Angular 没有提供任何特殊的构造来声明服务,就像它为组件、指令和管道所做的那样。任何可以被组件/指令/管道消耗的东西都可以被称为服务。路由器就是这样的一个服务。还有许多其他作为框架一部分的服务。
如果你来自 Angular 1 领域,这是一个令人愉快的惊喜-没有服务、工厂、提供者、值或常量!
提示
在构建组件时,尽量将尽可能多的功能委托给服务。组件应该只充当帮助同步组件模型和视图状态的中介者
角度路由器的作用是:
-
在路由更改时在组件之间启用导航
-
在组件视图之间传递路由数据
-
使当前路由的状态对活动/加载的组件可用
-
提供允许组件代码导航的 API
-
跟踪导航历史,允许我们使用浏览器按钮在组件视图之间前进和后退
-
提供生命周期事件和守卫条件,允许我们根据一些外部因素影响导航
注意
路由器还支持一些高级路由概念,如父子路由。这使我们能够在组件树的多个级别定义路由。父组件可以定义路由,子组件可以进一步添加更多的子路由到父路由定义中。这是我们在第四章中详细介绍的内容,构建个人教练。
路由器不能单独工作。正如前面的图表所示,它依赖于其他框架部分来实现期望的结果。让我们添加一些应用页面,并与每个拼图的每个部分一起工作。
路由设置
为了使组件路由器工作,我们首先需要将其引用,因为路由器不是核心框架的一部分。
打开package.json并按照这里的提示向路由器添加一个包引用:
"@angular/platform-browser-dynamic": "2.0.0",
**"@angular/router": "3.0.0",**
接下来,使用命令行安装包:
**npm install**
最后,在systemjs.config.js中引用该包。这样 SystemJS 就可以正确加载router模块了。将路由器包添加到ngPackageNames数组中以设置packages配置:
var ngPackageNames = [
...
**'router',**
...];
如果不存在,还要在index.html的head部分中添加base引用(已高亮显示):
<link rel="stylesheet" href="static/css/app.css" />
**<base href="/">**
路由器需要设置base href。href值指定用于 HTML 文档中所有相对 URL 的基本 URL,包括链接到 CSS、脚本、图像和任何其他资源。
路由器使用pushstate机制进行 URL 导航。这使我们能够使用诸如:
-
localhost:9000/start -
localhost:9000/workout -
localhost:9000/finish
这可能看起来不是什么大不了的事,但请记住,我们正在进行客户端导航,而不是我们习惯的全页重定向。正如开发者指南所述:
现代 HTML 5 浏览器支持
history.pushState,这是一种在不触发服务器页面请求的情况下更改浏览器位置和历史记录的技术。路由器可以组合一个与需要页面加载的 URL 无法区分的“自然”URL。
Pushstate API 和服务器端 URL 重写
路由器使用的 pushstate API 仅在我们点击视图中嵌入的链接(<a>标签)或使用路由器 API 时才起作用。路由器拦截任何导航事件,加载适当的组件视图,最后更新浏览器 URL。请求从不发送到服务器。
但是如果我们刷新浏览器会怎么样?
Angular 路由器无法拦截浏览器的刷新事件,因此会发生完整的页面刷新。在这种情况下,服务器需要响应仅存在于客户端的资源请求。典型的服务器响应是对于可能导致404(未找到)错误的任何任意请求发送应用主机文件(如index.html)。这就是我们所说的服务器URL 重写。
即使我们的服务器设置也进行了 URL 重写。查看gulpfile.js中的突出显示行:
connect.server({
...
**fallback: 'index.html'**
});
connect.server的最后一个配置参数设置了应用服务器的fallback URL 为index.html。这意味着对任何不存在的 URL 的请求,如/start、/workout、/finish或其他任何 URL,都会加载首页。
提示
每个服务器平台都有不同的机制来支持 URL 重写。我们建议您查看您使用的服务器堆栈的文档,以启用 Angular 应用程序的 URL 重写。
一旦我们为7 分钟锻炼添加了一些页面,我们就可以看到服务器端的重写。一旦新页面就位,尝试刷新应用程序并查看浏览器的网络日志;服务器每次都发送index.html内容,无论请求的 URL 是什么。
提示
回退路径和调试
为所有不存在的 URL 设置一个回退路径可能会在调试应用程序时产生不利影响。一旦回退机制就位,对于脚本/HTML/CSS 加载失败,就不会出现 404 错误。这可能会对任何缺失的引用产生意外结果,因为服务器总是返回index.html文件。每当您向应用程序添加新文件时,请注意浏览器网络日志和浏览器控制台中返回的内容是否有异常。
作为前面路由器设置的一部分,我们已经学会了如何包含路由器脚本,如何设置服务器端重定向以支持 HTML5 推送状态以及设置base href的需要。
在我们继续之前,我们需要为我们的应用程序添加一些其他页面并配置路由。
添加开始和完成页面
这里的计划是为7 分钟锻炼创建三个页面:
-
开始页面:这将成为应用程序的登陆页面
-
锻炼页面:我们目前拥有的内容
-
完成页面:我们在锻炼完成后导航到这里
锻炼组件及其视图(workout-runner.component.ts和workout-runner.html)已经存在。因此,让我们创建StartComponent和FinishComponent以及它们的视图。
从 git 分支checkpoint3.1复制以下文件。这些文件位于components文件夹下的start和finish文件夹中(从 GitHub 位置下载的链接是bit.ly/ng2be-3-1-components):
-
start.component.ts,start.html和start.module.ts:这包括StartComponent的实现和视图模板。一个标准的 HTML 视图,和一个基本的组件,使用routerLink指令生成超链接。 -
finish.component.ts,finish.html和finish.module.ts:这包括FinishComponent的实现和视图模板。它遵循与StartComponent相同的模式。
Start和Finish组件都已经使用自己的模块进行了定义。我们将遵循的约定是每个顶级视图一个模块。
三个组件都已准备就绪。是时候定义路由配置了!
路由配置
为了设置7 分钟锻炼的路由,我们将创建一个路由定义文件。在components/app文件夹中创建一个名为app.routes.ts的文件,定义应用程序的顶级路由。添加以下路由设置:
import { ModuleWithProviders } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import {WorkoutRunnerComponent} from '../workout-runner/workout-runner.component';
import {StartComponent} from '../start/start.component';
import {FinishComponent} from '../finish/finish.component';
export const routes: Routes= [
{ path: 'start', component: StartComponent },
{ path: 'workout', component: WorkoutRunnerComponent },
{ path: 'finish', component: FinishComponent },
{ path: '**', redirectTo:'/start'}
];
export const routing: ModuleWithProviders = RouterModule.forRoot(routes);
routes变量是Route对象的数组。每个Route定义了单个路由的配置,其中包含:
-
path:要匹配的目标路径 -
component:当路径被命中时要加载的组件
这样的路由定义可以解释为:“当用户导航到一个路径(在path中定义),加载component属性中定义的相应组件。”以第一个路由示例为例;导航到http://localhost:9000/start会加载StartComponent的组件视图。
您可能已经注意到最后一个Route定义看起来有点不同。path看起来很奇怪,而且也没有component属性。带有**的路径表示一个捕获所有路径或我们应用程序的通配符路由。任何不匹配前三个路由之一的导航都会匹配捕获所有路由,导致应用程序导航到起始页面(在redirectTo属性中定义)。
注意
一旦路由设置完成,我们可以尝试这个。输入任意随机路由,如http://localhost:9000/abcd,应用程序会自动重定向到http://localhost:9000/start。
最后调用RouterModule.forRoot用于将此路由设置导出为模块。我们在 AppModule 中使用这个设置(导出为routing)来完成路由设置。打开app.module.ts并导入路由设置以及我们根据Start和Finish页面创建的模块:
import {StartModule} from '../start/start.module';
import {FinishModule} from '../finish/finish.module';
import {routing} from './app.routes';
@NgModule({
imports: [..., StartModule, FinishModule, routing],
现在我们已经拥有了所有所需的组件和所有定义的路由,我们在路由更改时在哪里注入这些组件呢?我们只需要在宿主视图中为其定义一个占位符。
使用 router-outlet 渲染组件视图
如果我们检查当前的TrainerAppComponent模板,它有一个嵌入的WorkoutRunnerComponent:
<workout-runner></workout-runner>
这需要改变。删除前面的声明并替换为:
<router-outlet></router-outlet>
RouterOutlet是一个 Angular 组件指令,作为一个占位符,在路由更改时加载子组件。它与路由器集成,根据当前浏览器 URL 和路由定义加载适当的组件。
以下图表帮助我们轻松地可视化了router-outlet的设置发生了什么:
我们现在几乎完成了;是时候触发导航了。
路由导航
像标准浏览器导航一样,Angular 导航可以发生:
-
当用户直接在浏览器中输入 URL 时
-
单击锚标签上的链接
-
使用脚本/代码进行导航
如果尚未启动,请启动应用程序并加载http://localhost:9000或http://localhost:9000/start。应该加载开始页面。
单击页面上的开始按钮,训练视图应该加载到http://localhost:9000/workout。
注意
Angular 路由器还支持旧式的基于*哈希(#)*的路由。启用基于哈希的路由时,路由如下所示:
-
localhost:9000/#/start -
localhost:9000/#/workout -
localhost:9000/#/finish
默认的路由选项是基于pushState的。要将其更改为基于哈希的路由,顶级路由的路由配置在路由设置期间更改,如本例所示:export const routing: ModuleWithProviders = RouterModule.forRoot(routes, **{ useHash: true }** );
有趣的是,StartComponent视图定义中的锚链接没有href属性。相反,有一个RouterLink指令:
<a [routerLink]="['/workout']">
这看起来像是属性绑定语法,RouterLink 指令接受一个数组类型的输入参数。这是一个路由链接参数数组(或链接参数数组)。
routerLink 指令与路由器一起使用这个链接参数数组来解析正确的 URL 路径。在前面的情况下,数组中唯一的元素是路由的名称。
注意
注意在前面的路由路径中的 / 前缀。/ 用于指定绝对路径。Angular 路由器还支持相对路径,这在处理子路由时非常有用。我们将在接下来的几章中探讨子路由的概念。
刷新应用并检查 StartComponent 的渲染 HTML;前面的锚标签被渲染为:
<a href="/workout">
提示
避免硬编码路由链接
虽然你可以直接使用 <a href="/workout">,但最好使用 routerLink 来避免硬编码路由。
链接参数数组
传递给 routerLink 指令的链接参数数组遵循特定的模式:
['routePath', param1, param2, {prop1:val1, prop2:val2} ....]
第一个元素始终是路由路径,下一组参数用于替换路由模板中定义的占位符标记。
当前 7 分钟锻炼 的路由设置非常简单,不需要在链接生成中传递参数。但是对于需要动态参数的非平凡路由,可以使用这个功能。看看这个例子:
@RouteConfig([
**{ path: '/users/:id', component: UserDetail },**
{ path: '/users', component: UserList},
])
这是如何生成第一个路由的:
<a [routerLink]="['/users', 2] // generates /users/2
注意
Angular 路由器非常强大,支持几乎我们对现代路由库的所有期望。它支持子路由、异步路由、生命周期钩子和其他一些高级场景。我们将延迟讨论这些主题,直到后面的章节。本章只是让我们开始使用 Angular 路由,但还有更多内容要学习!
路由链接参数也可以是一个对象。这样的对象用于向路由提供可选参数。看看这个例子:
<a [routerLink]="['/users', {id:2}] // generates /users;id=2
注意生成的链接包含一个分号,用于将参数与路由和其他参数分开。
在实现中最后缺失的部分是在锻炼完成后路由到完成页面。
使用路由服务进行组件导航
从锻炼页面导航到完成页面不是手动触发的,而是在完成锻炼时触发。WorkoutRunnerComponent 需要触发这个转换。
为此,WorkoutRunnerComponent 需要获取组件路由并在其上调用 navigate 方法。
WorkoutRunnerComponent如何获得路由器实例?使用 Angular 的依赖注入框架。我们已经有一段时间没有涉及这个话题了。我们甚至不知道在这段时间里依赖注入框架一直在发挥作用。
让我们再等一会儿,首先集中精力解决导航问题。
为了让WorkoutRunnerComponent获得路由器服务实例,它只需要在构造函数中声明该服务。更新WorkoutRunnerComponent构造函数并添加导入:
import {Router} from '@angular/router';
constructor(private router: Router) {
当WorkoutRunnerComponent被实例化时,Angular 会神奇地将当前路由器注入到router私有变量中。它使用的魔法是依赖注入框架。
现在只需要用调用navigation路由替换语句console.log("Workout complete!");:
this.router.navigate( ['/finish'] );
navigate方法接受与RouterLink指令相同的链接参数数组。我们可以通过耐心等待锻炼完成来验证实现!
注意
如果您在运行代码时遇到问题,请查看 git 分支checkpoint3.1,了解我们迄今为止所做的工作的可工作版本。
或者如果您不使用 git,请从bit.ly/ng2be-checkpoint3-1下载checkpoint3.1的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。
如果您仍然想知道如何访问当前路由的路由参数,我们有ActivatedRoute服务。
使用ActivatedRoute服务访问路由参数
有时,当前视图需要访问活动路由状态。在组件实现过程中,诸如当前 URL 片段、当前路由参数和其他与路由相关的数据可能会派上用场。
ActivatedRoute服务是所有当前路由相关查询的一站式商店。它有许多属性,包括url和params,可以利用路由的当前状态。
让我们来看一个带参数的路由的例子,以及如何从组件中访问传递的参数。给定这个路由:
{ path: '/users/:id', component: UserDetail },
当用户导航到/user/5时,底层组件可以通过首先将ActivatedRoute注入到其构造函数中来访问:id参数值:
export class UsersComponent {
constructor( private route: ActivatedRoute ...
然后从ActivatedRoute服务的params属性中查询id属性。看看这个例子:
this.route.params.forEach((params: Params) => {
let id = +params['id']; // (+) converts string 'id' to a number
var currentUser=this.getUser(id)
});
ActivatedObject上的params属性实际上是一个observable。我们将在本章后面学习更多关于 observables 的知识,但现在足够理解 observables 是可以触发事件并且可以被订阅的对象。
我们使用route.params observable 上的forEach函数来获取路由的参数。回调对象(params:Params)包含与每个路由参数对应的属性。看看我们如何检索id属性并使用它。
我们现在已经介绍了基本的 Angular 路由基础设施,但在后面的章节中还有更多内容可以探索。现在是时候集中精力讨论一个长期以来的话题:依赖注入。
Angular 依赖注入
Angular 大量使用依赖注入来管理应用程序和框架的依赖关系。令人惊讶的是,我们可以忽略这个话题,直到我们开始讨论路由器,而不会影响我们对事物如何工作的理解。在此期间,Angular 依赖注入框架一直在支持我们的实现。一个好的依赖注入框架的特点是,消费者可以在不关心内部细节的情况下使用它,并且只需很少的仪式感。
如果你不确定什么是依赖注入,或者只是对它有一个模糊的概念,那么对 DI 的介绍肯定不会伤害任何人。
依赖注入 101
对于任何应用程序,其组件(不要与 Angular 组件混淆)并不是孤立工作的。它们之间存在依赖关系。一个组件可能使用其他组件来实现其所需的功能。依赖注入是一种管理这种依赖关系的模式。
DI 模式在许多编程语言中很受欢迎,因为它允许我们以松散耦合的方式管理依赖关系。有了这样一个框架,依赖对象由 DI 容器管理。这使得依赖关系可互换,并且整体代码更加解耦和可测试。
DI 背后的理念是一个对象不会创建\管理自己的依赖关系。相反,依赖关系是从外部提供的。这些依赖关系可以通过构造函数提供,这被称为构造函数注入(Angular 也这样做),或者直接设置对象属性,这被称为属性注入。
这里是一个依赖注入实例的基本示例。考虑一个名为Tracker的类,它需要一个Logger来进行日志记录操作:
class Tracker() {
logger:Logger;
constructor() {
this.logger = new Logger();
}
}
类Logger的依赖关系在Tracker内部是硬编码的。如果我们将这种依赖关系外部化呢?所以类变成了:
class Tracker {
logger:Logger;
constructor(logger:Logger) {
this.logger = logger;
}
}
这看似无害的改变产生了重大影响。通过添加提供外部依赖的能力,我们现在可以:
- 解耦组件并实现可扩展性。DI 模式允许我们在不触及类本身的情况下改变
Tracker类的日志行为。这里有一个例子:
var trackerWithDBLog=new Tracker(new DBLogger());
var trackerWithMemoryLog=new Tracker(new MemoryLogger());
我们刚刚看到的两个Tracker对象对于相同的Tracker类实现具有不同的日志功能。trackerWithDBLog记录到数据库,trackerWithMemoryLog记录到内存(假设DBLogger和MemoryLogger都是从Logger类派生的)。由于 Tracker 不依赖于Logger的特定实现(DBLogger或MemoryLogger),这意味着Logger和Tracker是松耦合的。将来,我们可以派生一个新的Logger类实现,并在不改变Tracker实现的情况下用它来记录。
- 模拟依赖关系:模拟依赖关系的能力使我们的组件更易于测试。通过为 Logger 提供一个模拟实现,如 MockLogger,或者使用一个可以轻松模拟
Logger接口的模拟框架,可以单独测试跟踪器实现(单元测试)。
现在我们可以理解 DI 是多么强大。一旦 DI 就位,解决依赖关系的责任就落在了调用/消费者代码身上。在前面的例子中,之前实例化Tracker的类现在需要创建一个Logger的派生类并在使用之前将其注入到Tracker中。
显然,这种在组件中交换内部依赖的灵活性是有代价的。调用代码的实现可能变得过于复杂,因为现在它必须管理子依赖关系。这一开始可能看起来很简单,但考虑到依赖组件本身可能有依赖关系,我们正在处理的是一个复杂的依赖树结构。
为了减少调用代码的依赖管理工作,需要使用 DI 容器/框架。这些容器负责构建/管理依赖关系并将其提供给我们的客户/消费者代码。
Angular DI 框架管理我们的 Angular 组件、指令、管道和服务的依赖关系。
在 Angular 中探索依赖注入
Angular 采用自己的 DI 框架来管理应用程序中的依赖关系。可见的依赖注入的第一个示例是将组件路由器注入到WorkoutRunnerComponent中:
constructor(private router: Router) {
当WorkoutRunnerComponent类被实例化时,DI 框架在内部定位/创建正确的路由器实例,并将其注入到调用者(在我们的例子中是WorkoutRunnerComponent)中。
虽然 Angular 在隐藏 DI 基础设施方面做得很好,但我们必须了解 Angular DI 的工作原理。否则,一切可能看起来相当神奇。
DI 是关于创建和管理依赖项的,负责这一点的框架组件被称为注入器。为了注入器能够管理依赖项,它需要理解以下内容:
-
什么是依赖项: 依赖项是什么?依赖项可以是一个类、一个对象、一个工厂函数或一个值。每个依赖项在注入之前都需要在 DI 框架中注册。
-
何时何地: DI 框架需要知道在哪里注入依赖项以及何时注入。
-
如何: DI 框架还需要知道在请求时创建依赖项的方法。
无论是框架构造还是我们创建的工件,任何注入的依赖项都需要回答这些问题。
例如,在WorkoutRunnerComponent中进行的Router实例注入。为了回答“什么”和“如何”部分,我们通过模块装饰器上的导入语句在应用模块(app.module.ts)中注册Router服务:
imports: [..., routing];
routing变量是一个模块,它导出了多个路由以及所有与 Angular 路由相关的服务(技术上它重新导出了RouterModule)。我们通过以下语句从app.routes.ts中导出这个变量:
export const routing: ModuleWithProviders = RouterModule.forRoot(routes);
“何时”和“何地”是根据需要依赖项的组件来决定的。WorkoutRunnerComponent的构造函数需要一个Router的依赖项。这通知注入器在路由导航的过程中创建WorkoutRunnerComponent时注入当前的Router实例。
注意
在内部,注入器根据从 TypeScript 转换为 ES5 代码时反映出的元数据来确定类的依赖关系(由 TypeScript 编译器完成)。只有在类上添加了@Component或@RouteConfig等装饰器时才会生成元数据。
如果我们将Router注入到另一个类中会发生什么?答案是是。Angular 注入器会创建和缓存依赖项以供将来重用,因此这些服务在本质上是单例的。
注意
虽然注入器中的依赖项是单例的,但在任何给定时间,整个 Angular 应用程序中可能有多个活动的注入器。你很快就会了解注入器层次结构。
使用路由器,还有另一层复杂性。由于 Angular 支持子路由概念,每个路由都有自己的路由器实例。等到下一章涵盖子路由时,你就能理解其中的复杂性!
让我们创建一个 Angular 服务来跟踪训练历史。这个过程将帮助你理解如何使用 Angular DI 连接依赖项。
跟踪训练历史
如果我们能够跟踪训练历史,这将是我们应用的一个很好的补充。我们上次锻炼是什么时候?我们完成了吗?我们花了多少时间?
跟踪训练历史需要我们跟踪训练进度。我们需要以某种方式跟踪训练何时开始和何时结束。然后需要将这些跟踪数据持久化存储在某个地方。
实现这种历史跟踪的一种方法是通过扩展我们的WorkoutRunnerComponent来实现所需的功能。但这会给WorkoutRunnerComponent增加不必要的复杂性,这不是它的主要工作。我们需要一个专门的历史跟踪服务来完成这项工作,一个可以跟踪历史数据并在整个应用程序中共享的服务。让我们开始构建WorkoutHistoryTracker服务。
构建 WorkoutHistoryTracker 服务
通过WorkoutHistoryTracker服务,我们计划跟踪训练的执行。该服务还公开了一个接口,允许WorkoutRunnerComponent启动和停止训练跟踪。
如果没有,请在src文件夹内创建一个名为services的文件夹,并添加一个名为workout-history-tracker.ts的文件,其中包含以下代码:
import {ExercisePlan} from '../components/workout-runner/model';
export class WorkoutHistoryTracker {
private maxHistoryItems: number = 20; //Tracking last 20 exercises
private currentWorkoutLog: WorkoutLogEntry = null;
private workoutHistory: Array<WorkoutLogEntry> = [];
private workoutTracked: boolean;
constructor() { }
get tracking(): boolean {
return this. workoutTracked;
}
}
export class WorkoutLogEntry {
constructor(
public startedOn: Date,
public completed: boolean = false,
public exercisesDone: number = 0,
public lastExercise?: string,
public endedOn?: Date) { }
}
定义了两个类:WorkoutHistoryTracker和WorkoutLogEntry。顾名思义,WorkoutLogEntry定义了一个训练执行的日志数据。maxHistoryItems允许我们配置要存储在workoutHistory数组中的最大项目数,该数组包含历史数据。get tracking()方法在 TypeScript 中定义了workoutTracked的 getter 属性。在训练执行期间,workoutTracked被设置为true。
让我们添加开始跟踪、停止跟踪和完成练习的功能:
startTracking() {
this.workoutTracked = true;
this.currentWorkoutLog = new WorkoutLogEntry(new Date());
if (this.workoutHistory.length >= this.maxHistoryItems) {
this.workoutHistory.shift();
}
this.workoutHistory.push(this.currentWorkoutLog);
}
exerciseComplete(exercise: ExercisePlan) {
this.currentWorkoutLog.lastExercise = exercise.exercise.title;
++this.currentWorkoutLog.exercisesDone;
}
endTracking(completed: boolean) {
this.currentWorkoutLog.completed = completed;
this.currentWorkoutLog.endedOn = new Date();
this.currentWorkoutLog = null;
this.workoutTracked = false;
};
startTracking函数创建一个WorkoutLogEntry并将其添加到workoutHistory数组中。通过将currentWorkoutLog设置为新创建的日志条目,我们可以在以后的训练执行过程中对其进行操作。endTracking函数和exerciseComplete函数只是改变currentWorkoutLog。exerciseComplete函数应该在完成训练中的每个练习时调用。
最后,添加一个返回完整历史数据的函数:
getHistory(): Array<WorkoutLogEntry> {
return this.workoutHistory;
}
这完成了WorkoutHistoryTracker的实现;现在是将其整合到训练执行中的时候了。
与WorkoutRunnerComponent整合
WorkoutRunnerComponent需要WorkoutHistoryTracker来跟踪训练历史记录;因此需要满足一个依赖关系。
为了使WorkoutHistoryTracker可发现,它需要在框架中注册。在这一点上,我们有很多选择。有很多种方法可以注册依赖项,也有很多地方可以注册!这种灵活性使得 DI 框架非常强大,尽管它也增加了混乱。
让我们首先尝试理解使用WorkoutHistoryTracker作为示例来注册依赖项的不同机制。
注册依赖项
注册依赖项的最简单方法是在根/全局级别注册它。这可以通过将依赖类型传递到模块装饰器中的provides属性(数组)来实现。
如本例所示,将WorkoutHistoryTracker添加到任何模块的providers数组中会全局注册该服务:
@NgModule({...**providers: [WorkoutHistoryTracker],**})
从技术上讲,当一个服务被添加到providers数组中时,它会被注册到应用程序的根注入器中,而不管它在哪个 Angular 模块中声明。因此,以后任何模块中的任何 Angular 构件都可以使用该服务(WorkoutHistoryTracker)。根本不需要任何模块导入。
注意
这种行为与组件/指令/管道的注册不同。这些构件必须从一个模块中导出,以便另一个模块使用它们。
当 Angular 注入器请求它们时,提供者会创建依赖项。这些提供者有创建这些依赖项的配方。虽然类似乎是可以注册的明显依赖项,但我们也可以注册:
-
一个特定的对象/值
-
一个工厂函数
直接使用类类型来注册依赖关系(如在bootstrap函数中所示)可能大多数情况下可以满足我们的需求,但有时我们需要在依赖注册中具有一些灵活性。提供者注册语法的扩展版本为我们提供了这种灵活性。
要了解这些变化,我们需要更详细地探讨提供者和依赖注册。
Angular 提供者
提供者创建由 DI 框架提供的依赖关系。
查看上一节中WorkoutHistoryTracker的依赖关系注册:
providers: [WorkoutHistoryTracker],
这种语法是以下版本的简写形式:
providers:({ provide: WorkoutHistoryTracker, useClass: WorkoutHistoryTracker })
第一个属性(provide)是一个令牌,充当注册依赖关系的键。这个键还允许我们在依赖注入期间定位依赖关系。
第二个属性(useClass)是一个提供者定义对象,定义了创建依赖值的方法。框架提供了许多创建这些依赖关系的方法,我们很快就会看到。
使用useClass,我们正在注册类provider。类provider通过实例化所请求的对象类型来创建依赖关系。
值提供者
类provider创建类对象并满足依赖,但有时我们希望注册一个特定的对象/原始对象到 DI 提供者中。值提供者解决了这种用例。
以使用此技术注册的WorkoutHistoryTracker为例:
{provide: WorkoutHistoryTracker, useValue: new WorkoutHistoryTracker()};
注册的是我们创建的WorkoutHistoryTracker对象的实例,而不是让 Angular DI 创建一个。如果下游也需要手工创建的依赖关系,那么考虑这样手工创建的依赖关系(手动创建的依赖关系)。再次以WorkoutHistoryTracker为例。如果WorkoutHistoryTracker有一些依赖关系,那么这些依赖关系也需要通过手动注入来满足:
{provide: WorkoutHistoryTracker, useValue: new WorkoutHistoryTracker(new LocalStorage())});
值提供者在特定情况下非常有用。例如,我们可以使用值提供者注册一个常见的应用程序配置:
{provide: AppConfig, {useValue: {name:'Test App', gridSetting: {...} ...}}
或者在单元测试时注册一个模拟依赖:
{provide:WorkoutHistoryTracker, {useValue: new MockWorkoutHistoryTracker()}
工厂提供者
有时候注入并不是一件简单的事情。注入取决于外部因素。这些因素决定了创建和返回的对象或类实例。工厂提供者完成了这项繁重的工作。
举个例子,我们想要为开发和生产版本设置不同的配置。我们可以很好地使用工厂实现来选择正确的配置:
{provide: AppConfig, useFactory: () => {
if(PRODUCTION) {
return {name:'My App', gridSetting: {...} ...}
}
else {
return {name:'Test App', gridSetting: {...} ...}
}
}
工厂函数也可以有自己的依赖项。在这种情况下,语法会有一些变化:
{provide: WorkoutHistoryTracker, useFactory: (environment:Environment) => {
if(Environment.isTest) {
return new MockWorkoutHistoryTracker();
}
else {
return new WorkoutHistoryTracker();
},
deps:[Environment]
}
依赖项作为参数传递给工厂函数,并在提供者定义对象属性deps上注册。
如果依赖项的构建复杂,并且在连接期间无法决定所有内容,可以使用UseFactory提供。
虽然我们有许多选项来声明依赖项,但消耗依赖项要简单得多。
注意
在继续之前,让我们在一个新的服务模块中注册WorkoutHistoryTracker服务。这个新模块(ServicesModule)将用于注册所有应用程序范围的服务。
将模块定义从 git 分支checkpoint3.2复制到本地的src/services文件夹中。您可以从此 GitHub 位置下载它:bit.ly/ng2be-3-2-services-module-ts。还要删除所有对LocalStorage服务的引用,因为我们计划在本章稍后添加它。最后,将该模块导入AppModule(app.module.ts)。
注入依赖项
消耗依赖项很容易!往往我们使用构造函数注入来消耗依赖项。
构造函数注入
在顶部添加import语句,并更新WorkoutRunnerComponent的构造函数,如下所示:
import {WorkoutHistoryTracker} from
'../../services/workout-history-tracker';
...
constructor(private router: Router,
**) {**
与路由器一样,当创建WorkoutRunnerComponent时,Angular 也会注入WorkoutHistoryTracker。简单!
在我们继续整合之前,让我们探索一下关于 Angular 的 DI 框架的其他事实。
使用注入器进行显式注入
我们甚至可以使用 Angular 的Injector服务进行显式注入。这是 Angular 用来支持 DI 的相同注入器。以下是如何使用Injector注入WorkoutHistoryTracker服务:
constructor(private router: Router, private injector:Injector) {
this.tracker=injector.get(WorkoutHistoryTracker);
我们首先注入Injector,然后显式要求Injector获取WorkoutHistoryTracker实例。
什么时候有人想要这样做呢?嗯,几乎从不。避免这种模式,因为它会将 DI 容器暴露给您的实现,并且还会增加一些噪音。
消耗依赖项很容易,但 DI 框架如何定位这些依赖项呢?
依赖项标记
还记得之前显示的依赖项注册的扩展版本吗?
{ provide: WorkoutHistoryTracker, useClass: WorkoutHistoryTracker }
provide属性值是一个标记。此标记用于标识要注入的依赖项。每当 Angular 看到这个语句时:
constructor(tracker: WorkoutHistoryTracker)
它根据类类型注入正确的依赖项。这是一个类令牌的示例。类类型用于依赖项搜索/映射。Angular 还支持一些其他令牌。
字符串令牌
我们可以使用字符串文字而不是类来标识依赖项。我们可以使用字符串令牌注册WorkoutHistoryTracker依赖项,如下所示:
{provide:"MyHistoryTracker", useClass: WorkoutHistoryTracker })
如果我们现在这样做:
constructor(private tracker: WorkoutHistoryTracker)
Angular 一点也不喜欢它,并且无法注入依赖项。由于之前看到的WorkoutHistoryTracker是使用字符串令牌注册的,因此在注入时也需要提供令牌。
要注入使用字符串令牌注册的依赖项,我们需要使用@Inject装饰器。这样做非常完美:
constructor(@Inject("MyHistoryTracker")
private tracker: WorkoutHistoryTracker)
提示
当不存在@Inject()时,注入器使用参数的类型名称(类令牌)。
在注册实例或对象时,字符串令牌非常有用。如果没有AppConfig这样的类,我们之前分享的应用程序配置注册示例可以使用字符串令牌进行重写:
{ provide: "AppConfiguration", useValue: {name:'Test App', gridSetting: {...} ...});
然后使用@Inject注入:
constructor(@Inject("AppConfiguration") config:any)
注意
虽然任何对象都可以充当令牌,但最常见的令牌类型是类和字符串令牌。在内部,提供程序将令牌参数转换为OpaqueToken类的实例。查看框架文档以了解有关OpaqueToken的更多信息:
angular.io/docs/ts/latest/api/core/index/OpaqueToken-class.html。
虽然WorkoutHistoryTracker注入到WorkoutRunnerComponent中已完成,但其集成仍然不完整。
与WorkoutRunnerComponent集成-继续
历史跟踪器实例(tracker)需要在锻炼开始时、锻炼完成时和锻炼结束时调用。
将此添加为start函数中的第一条语句:
this.tracker.startTracking();
在startExerciseTimeTracking函数中,在clearInterval调用后添加突出显示的代码:
clearInterval(this.exerciseTrackingInterval);
**if (this.currentExercise !== this.restExercise) {**
**this.tracker.exerciseComplete(this.workoutPlan**
**.exercises[this.currentExerciseIndex]);**
**}**
然后在锻炼中使用突出显示的代码来完成相同函数中的else条件:
**this.tracker.endTracking(true);**
this.router.navigate(['/finish']);
历史跟踪几乎完成,除了一个情况。如果用户手动从锻炼页面导航离开呢?
当发生这种情况时,我们总是可以依赖组件的生命周期钩子/事件来帮助我们。当触发NgOnDestroy事件时,可以停止锻炼跟踪。在组件从组件树中移除之前,执行任何清理工作的合适位置。让我们来做吧。
将此函数添加到workout-runner.component.ts中:
ngOnDestroy() {
this.tracker.endTracking(false);
}
虽然我们现在已经实现了锻炼历史跟踪,但我们没有检查历史的机制。迫切需要一个锻炼历史页面/组件。
添加锻炼历史页面
我们在锻炼执行过程中收集的锻炼历史数据现在可以在视图中呈现出来。让我们添加一个历史组件。该组件将位于/history位置,并且可以通过单击应用程序标题部分的链接来加载。
更新app.routes.ts中的路由定义以包括新路由和相关导入:
**import {WorkoutHistoryComponent}**
**from '../workout-history/workout-history.component';**
...
export const routes: Routes = [
...,
**{ path: 'history', component: WorkoutHistoryComponent }**
{ path: '**', redirectTo: '/start' }
])
历史链接需要添加到应用程序标题部分。让我们将标题部分重构为自己的组件。更新app.component.ts模板navbar div为:
<div class="navbar navbar-default navbar-fixed-top top-navbar">
<div class="container app-container">
**<header></header>**
</div>
</div>
这里有一个新的HeaderComponent。从 git 分支checkpoint3.2的app文件夹中复制标题组件(header.component.ts)的定义(GitHub 位置:bit.ly/ng2be-3-2-header-component-ts)。还将该组件添加到app.module.ts的声明数组中,就像对任何 Angular 组件一样:
import {HeaderComponent} from './header.component';
...
declarations: [TrainerAppComponent, HeaderComponent],
如果查看HeaderComponent,现在已经有了历史链接。让我们添加锻炼历史组件。
WorkoutHistoryComponent的实现可在 git 分支checkpoint3.2中找到;文件夹是workout-history(GitHub 位置:bit.ly/ng2be-3-2-workout-history)。将文件夹中的所有三个文件复制到本地相应的文件夹中。记得在本地设置中保持相同的文件夹层次结构。请注意,WorkoutHistoryComponent已在一个单独的模块(WorkoutHistoryModule)中定义,并且需要导入到AppModule(app.module.ts)中。在继续之前,将WorkoutHistoryModule导入到AppModule中。现在从WorkoutHistoryModule中删除对SharedModule的所有引用。
WorkoutHistoryComponent的视图代码可以说是微不足道的:一些 Angular 构造,包括ngFor和ngIf。组件实现也非常简单。在WorkoutHistoryComponent初始化时注入WorkoutHistoryTracker服务依赖项并设置历史数据:
ngOnInit() {
this.history = this.tracker.getHistory();
}
这一次,我们使用Location服务而不是Router来从历史组件中导航离开:
goBack() {
this.location.back();
}
位置服务用于与浏览器 URL 交互。根据 URL 策略,可以使用 URL 路径(例如/start,/workout)或 URL 哈希段(例如#/start,#/workout)来跟踪位置更改。路由器服务也在内部使用位置服务来触发导航。
提示
路由器与位置
虽然Location服务允许我们执行导航,但使用Router是执行路由导航的首选方式。我们在这里使用位置服务,因为需要导航到最后一个路由,而不必担心如何构建路由。
我们准备测试我们的锻炼历史实现。加载起始页面,然后单击历史链接。历史页面加载时为空白。开始锻炼并让一个锻炼完成。再次检查历史页面;应该列出一个锻炼:
看起来不错,除了这个列表中的一个痛点。如果历史数据按时间顺序排序,并且最新的数据在顶部,那将更好。如果我们也有过滤功能,那将更好。
使用管道对历史数据进行排序和过滤
在第二章,“构建我们的第一个应用程序-7 分钟锻炼”,我们探索了管道。我们甚至建立了自己的管道来将秒值格式化为 hh:mm:ss。由于管道的主要目的是转换数据,这可以与任何输入一起使用。对于数组,管道可以用于对数据进行排序和过滤。我们创建了两个管道,一个用于排序,一个用于过滤。
注意
Angular1 具有预构建的过滤器(在 Angular2 中是管道),orderBy和filter,用于这个目的。目前,将这些过滤器移植到 Angular2 的工作已经停滞。请参阅此 GitHub 问题:bit.ly/ng2-issue-2340。
让我们从orderBy管道开始。
orderBy 管道
我们实现的orderBy管道将根据对象的任何属性对对象数组进行排序。基于fieldName属性按升序排序项目的使用模式将是:
*ngFor="let item of items| orderBy:fieldName"
而对于按降序排序项目,使用模式是:
*ngFor="let item of items| orderBy:-fieldName"
注意在fieldName之前的额外连字符。
在src/components中创建一个名为shared的文件夹,并复制位于 git 分支checkpoint3.2(GitHub 位置:bit.ly/ng2be-3-2-shared)相应位置的所有三个文件。此文件夹中有两个管道和一个新的模块定义(SharedModule)。SharedModule定义了在整个应用程序中共享的组件/指令/管道。
打开order-by.pipe.ts并查看管道实现。虽然我们不会深入讨论管道的实现细节,但有些相关部分需要被强调。查看这个管道概述:
@Pipe({ name: 'orderBy' })
export class OrderByPipe {
transform(value: Array<any>, field:string): any {
...
}
}
前面的field变量接收需要排序的字段。查看下面的代码以了解如何传递field参数。
如果字段有-前缀,我们在对数组进行降序排序之前截断前缀。
注意
该管道还使用了扩展运算符,这可能对您来说是新的。在 MDN 上了解有关扩展运算符的更多信息:bit.ly/js-spread。
要在锻炼历史视图中使用这个管道,将SharedModule导入WorkoutHistoryModule。
更新模板 HTML:
<tr *ngFor="let historyItem of history|orderBy:'-startedOn'; let i = index">
历史数据现在将按startedOn降序排序。
注意
请注意管道参数周围的单引号('-startedOn')。我们将一个字面字符串传递给orderBy管道。相反,管道参数也可以绑定到组件属性。
这对于orderBy管道已经足够了。让我们实现过滤。
搜索管道
我们之前添加的SearchPipe只是进行基于相等性的基本过滤。没有什么特别的。
查看管道代码;管道接受两个参数,第一个是要搜索的字段,第二个是要搜索的值。我们使用数组的filter函数来过滤记录,进行严格的相等性检查。
让我们更新锻炼历史视图,并加入搜索管道。打开workout-history.html并取消注释带有单选按钮的 div。这些单选按钮根据是否完成来过滤锻炼。这是过滤选择 HTML 的样子:
<label><input type="radio" name="searchFilter" value=""
(change)="completed = null" checked="">All </label>
<label><input type="radio" name="searchFilter" value="true"
(change)="completed = $event.target.value=='true'">
Completed </label>
<label><input type="radio" name="searchFilter" value="false"
(change)="completed = $event.target.value=='true'">
Incomplete </label>
我们可以有三个过滤器:all、completed和incomplete的锻炼。单选按钮通过change事件表达式设置组件的属性completed。$event.target是被点击的单选按钮。我们不将completed=$event.target.value赋值,因为它的值是字符串类型。completed属性(在WorkoutHistoryComponent上)应该是boolean类型,以便与WorkoutLogEntry.completed属性进行相等比较。
search管道现在可以添加到ngFor指令表达式中。我们将链式使用search和orderBy管道。更新ngFor表达式为:
<tr *ngFor="let historyItem of history
**|search:'completed':completed**
|orderBy:'-startedOn';
let i = index">
search管道首先过滤历史数据,然后orderBy管道重新排序。要特别注意search管道的参数:第一个参数是一个字符串字面量,表示要搜索的字段('completed'),而第二个参数是从组件属性completed派生的。能够将管道参数绑定到组件属性允许我们有很大的灵活性。
继续验证历史页面的搜索功能。根据单选按钮的选择,历史记录被过滤,当然它们根据锻炼开始日期的逆序排列。
虽然使用管道与数组看起来很简单,但如果我们不理解管道何时被评估,可能会出现一些意外情况。
数组的管道陷阱
要理解应用于数组的管道的问题,请重现问题。
打开search.pipe.ts并删除@Pipe装饰器属性pure。还要更改以下语句:
if (searchTerm == null) return [...value];
到以下内容:
if (searchTerm == null) return [value];
在单选按钮列表的末尾(在workout-history.html中)添加一个按钮,将新的日志条目添加到history数组中:
<button (click)="addLog()">Add Log</button>
在WorkoutHistoryComponent中添加一个函数:
addLog() {
this.history.push(Object.assign({},
this.history[this.history.length-1]));
}
前面的函数复制了第一个历史项目,并将其添加回history数组。如果我们加载页面并点击按钮,新的日志条目将被添加到历史数组,但除非我们更改过滤器(点击其他单选按钮),否则它不会显示在视图中。有趣!
注意
在调用addLog之前,请确保至少已经有一个历史记录;否则addLog函数将失败。
到目前为止,我们构建的管道都是无状态(也称为纯粹)的。它们只是将输入数据转换为输出。无状态管道在管道输入发生变化(管道符号左侧的表达式)或任何管道参数更新时重新评估。
对于数组,这发生在数组赋值/引用更改时,而不是元素的添加或删除。切换过滤条件会起作用,因为它会导致搜索管道再次评估,因为搜索参数(completed状态)发生变化。这是需要注意的行为。
有什么解决方法?首先,我们可以使历史数组不可变,这意味着一旦创建就无法更改。要添加一个新元素,我们需要使用新值创建一个新数组,类似于:
this.history = [...this.history,
Object.assign({}, this.history[0])];
工作得很完美,但我们正在改变我们的实现方式以使其与管道一起工作。相反,我们可以更改管道并将其标记为有状态。
无状态和有状态管道的区别在于,有状态管道在每次框架进行变更检测运行时都会被 Angular 评估,这涉及检查完整的应用程序是否有变化。因此,对于有状态管道,检查不仅限于管道输入/参数的变化。
要使search管道无状态,只需使用pure:false更新Pipe装饰器:
@Pipe({
name: 'search',
**pure:false**
})
它仍然不起作用!search管道还有一个需要修复的小问题。**“全部”**单选按钮选择不完美。添加一个新的训练日志,它仍然不会显示,除非我们切换过滤器。
这里的解决方法是恢复search管道中的这一行:
if (searchTerm == null) return value;
对于以下内容:
if (searchTerm == null) return [...value];
我们将if条件更改为每次返回一个新数组(使用扩展运算符),即使searchTerm为null。如果我们返回相同的数组引用,Angular 不会检查数组大小的变化,因此不会更新 UI。
这完成了我们的历史页面实现。您现在可能想知道管道的最后几个修复与变更检测的工作方式有什么关系。或者您可能想知道什么是变更检测?让我们消除所有这些疑虑,并向每个人介绍 Angular 的变更检测系统。
注意
Angular 的变更检测将在第八章中得到广泛覆盖,一些实际场景。下一节的目标是介绍变更检测的概念以及 Angular 如何执行此过程。
Angular 变更检测概述
简而言之,变更检测就是跟踪应用程序执行期间对组件模型所做的更改。这有助于 Angular 的数据绑定基础设施确定需要更新哪些视图部分。每个数据绑定框架都需要解决这个问题,而这些框架用于跟踪更改的方法也不同。甚至从 Angular1 到 Angular2 也有所不同。
要理解 Angular 中的变更检测如何工作,有一些事情我们需要记住。
-
首先,Angular 应用程序只是一个从根到叶的组件层次结构。
-
其次,我们绑定到视图的组件属性没有任何特殊之处;因此,Angular 需要一种有效的机制来知道这些属性何时发生更改。它无法持续轮询这些属性的更改。
-
最后,为了检测属性值的变化,Angular 对先前值和当前值进行严格比较(
===)。对于引用类型,这意味着只比较引用。不进行深层比较。
注意
正因为这个原因,我们不得不将我们的搜索管道标记为有状态。
向现有数组添加元素不会改变数组引用,因此 Angular 无法检测到数组的任何更改。一旦管道被标记为有状态,无论数组是否发生更改,管道都会被评估。
由于 Angular 无法自动知道何时更新任何绑定属性,因此在触发变更检测运行时,它会检查每个绑定属性。从组件树的根开始,Angular 检查每个绑定属性以查找组件层次结构中的更改。如果检测到更改,该组件将被标记为需要刷新。值得重申的是,绑定属性的更改不会立即更新视图。相反,变更检测运行分为两个阶段。
-
在第一阶段,它执行组件树遍历并标记需要由于模型更新而刷新的组件
-
在第二阶段,实际视图与底层模型同步
注意
在变更检测运行期间,模型更改和视图更新永远不会交错进行。
我们现在只需要回答另外两个问题。何时触发变更检测运行?它运行多少次?
当触发以下事件之一时,将触发 Angular 变更检测运行:
-
用户输入/浏览器事件:我们点击按钮,输入一些文本,滚动内容。这些操作都可以更新视图(和底层模型)。
-
远程 XHR 请求:这是视图更新的另一个常见原因。从远程服务器获取数据以显示在网格上,以及获取用户数据以渲染视图都是这种情况的例子。
-
setTimeout 和 setInterval:事实证明,我们可以使用
setTimeout和setInterval来异步执行一些代码,并在特定间隔内执行。这样的代码也可以更新模型。例如,setInterval计时器可以定期检查股票报价并更新 UI 上的股价。
最重要的是,每个组件模型只检查一次,以自顶向下的方式进行,从根组件到树叶。
注意
当 Angular 配置为运行在生产模式时,最后一句是正确的。在开发模式下,组件树会被遍历两次以进行更改。Angular 期望在第一次遍历树后模型是稳定的。如果不是这种情况,Angular 会在开发模式下抛出错误,并在生产模式下忽略更改。
我们可以通过在bootstrap函数调用之前调用enableProdMode函数来启用生产模式。import {enableProdMode} from '@angular/core' enableProdMode(); platformBrowserDynamic().bootstrapModule(AppModule);
让我们探索一些 Angular DI 框架的其他方面,从层次注入器开始,这是 Angular 的一个令人困惑但非常强大的特性。
层次注入器
在 Angular 中,注入器是一个负责存储依赖项并在需要时分发它们的依赖容器。之前在模块上展示的提供者注册示例实际上是在全局注入器中注册依赖项。
注册组件级别的依赖关系
到目前为止,我们所做的所有依赖注册都是在模块内完成的。Angular 更进一步,还允许在组件级别注册依赖关系。在@Component装饰器上有一个类似的 providers 属性,允许我们在组件级别注册依赖。
我们本来可以在WorkoutRunnerComponent上注册WorkoutHistoryTracker的依赖关系。类似这样的东西:
@Component({
selector: 'workout-runner',
providers: [WorkoutRunnerComponent]
})
但我们是否应该这样做,这是我们将在本节中讨论的事情。
在讨论分层注射器的情况下,重要的是要理解 Angular 为每个组件创建一个注射器(过于简化)。在组件级别进行的依赖项注册可在组件及其后代上使用。
我们还学到了依赖项是单例的。一旦创建,注射器每次都会返回相同的依赖项。这一特性在锻炼历史实现中非常明显。
WorkoutHistoryTracker已在ServicesModule中注册,然后注入到两个组件WorkoutRunnerComponent和WorkoutHistoryComponent中。两个组件都获得相同的WorkoutHistoryTracker实例。下一个图表突出了这个注册和注入:
要确认,只需在WorkoutHistoryTracker构造函数中添加一个console.log语句:
console.log("WorkoutHistoryTracker instance created.")
刷新应用程序并通过点击标题链接打开历史页面。无论我们运行锻炼多少次或打开历史页面,消息日志都会生成一次。
现在我们看到了一个新的交互/数据流模式!仔细想想;一个服务被用来在两个组件之间共享状态。WorkoutRunnerComponent生成数据,WorkoutHistoryComponent消耗数据。而且这一切都没有任何相互依赖。我们正在利用依赖项是单例的事实。这种数据共享/交互/数据流模式可以用来在任意数量的组件之间共享状态。事实上,这是我们武器库中非常强大的武器。下次需要在不相关的组件之间共享状态时,考虑使用服务。
但这与分层注射器有什么关系呢?好吧,让我们不拐弯抹角了;让我们直截了当地说。
虽然使用注射器注册的依赖项是单例的,但注射器本身不是!在任何给定的时间点,应用程序中都有多个活动的注射器。实际上,注射器是按照组件树的相同层次结构创建的。Angular 为组件树中的每个组件创建一个Injector实例(过于简化;请参阅下一个信息框)。
注意
Angular 并不是为每个组件都创建一个注射器。如 Angular 开发人员指南中所解释的:
每个组件都不需要自己的注射器,为了没有好处而大量创建注射器将是非常低效的。
但事实是每个组件都有一个注入器(即使它与另一个组件共享该注入器),并且可能有许多不同的注入器实例在组件树的不同级别运行。
假设每个组件都有自己的注入器是很有用的。
当进行锻炼时,组件和注入器树看起来像这样:
插入文本框表示组件名称。根注入器是作为应用程序引导过程的一部分创建的注入器。
这种注入器层次结构的重要性是什么?要理解其影响,我们需要了解当组件请求依赖项时会发生什么。
Angular DI 依赖项遍历
每当请求依赖项时,Angular 首先尝试从组件自己的注入器满足依赖项。如果无法找到所请求的依赖项,则会查询父组件注入器以获取依赖项,如果再次失败,则查询其父级,依此类推,直到找到依赖项或达到根注入器。要点:任何依赖搜索都是基于层次结构的。
早些时候,当我们注册WorkoutHistoryTracker时,它是与根注入器一起注册的。WorkoutRunnerComponent和WorkoutHistoryComponent对WorkoutHistoryTracker的依赖请求是由根注入器满足的,而不是它们自己的组件注入器。
这种分层注入器结构带来了很大的灵活性。我们可以在不同的组件级别配置不同的提供者,并在子组件中覆盖父级提供者配置。这仅适用于在组件上注册的依赖项。如果依赖项添加在模块上,它将在根注入器上注册。
让我们尝试在使用它的组件中覆盖全局WorkoutHistoryTracker服务,以了解这种覆盖会发生什么。这将会很有趣,我们会学到很多!
打开workout-runner.component.ts,并在@Component装饰器中添加一个providers属性:
providers: [WorkoutHistoryTracker]
在workout-history.component.ts中也这样做。现在,如果我们刷新应用程序,开始锻炼,然后加载历史页面,网格是空的。无论我们尝试运行锻炼的次数,历史网格始终为空。
原因是非常明显的。在每个WorkoutRunnerComponent和WorkoutHistoryComponent上设置WorkoutHistoryTracker提供程序后,依赖关系由各自的组件注入器自行满足。当请求时,两个组件注入器都会创建自己的WorkoutHistoryTracker实例,因此历史跟踪被破坏。查看以下图表以了解在两种情况下请求是如何被满足的:
一个快速的问题:如果我们在根组件TrainerAppComponent中注册依赖项,而不是在应用程序引导期间进行注册,会发生什么?类似于:
@Component({
selector: 'trainer-app',
**providers:[WorkoutHistoryTracker]**
}
export class TrainerAppComponent {
有趣的是,即使使用这种设置,事情也能完美地运行。这是非常明显的;TrainerAppComponent是RouterOutlet的父组件,它在内部加载WorkoutRunnerComponent和WorkoutHistoryComponent。因此,在这样的设置中,依赖关系由TrainerAppComponent的注入器满足。
注意
如果中间组件声明自己是宿主组件,那么在组件层次结构上进行的依赖查找可以被操纵。我们将在后面的章节中了解更多关于这个的内容。
分层注入器允许我们在组件级别注册依赖项,避免了全局注册所有依赖项的需要。
这个功能在构建 Angular 库组件时非常方便。这样的组件可以注册它们自己的依赖项,而不需要库的消费者注册特定于库的依赖项。
提示
记住:如果你在加载正确的服务/依赖项时遇到问题,请确保检查组件层次结构,看看是否在任何级别上进行了覆盖。
我们现在了解了组件中的依赖解析是如何工作的。但是如果一个服务有一个依赖项会发生什么呢?又是另一个未知的领域需要探索。
提示
在继续之前,删除我们在这两个组件中进行的provider注册。
使用@Injectable 进行依赖注入
WorkoutHistoryTracker有一个基本缺陷;历史记录没有被持久化。刷新应用程序,历史记录就丢失了。我们需要添加持久化逻辑来存储历史数据。为了避免任何复杂的设置,我们使用浏览器本地存储来存储历史数据。
在services文件夹中添加一个local-storage.ts文件。并添加以下类定义:
export class LocalStorage {
getItem<T>(key: string): T {
if (localStorage[key]) {
return <T>JSON.parse(localStorage[key]);
}
return null;
}
setItem(key: string, item: any) {
localStorage[key] = JSON.stringify(item);
}
}
浏览器的localStorage对象上的一个简单包装器。
继续在服务模块(services.module.ts)中注册 LocalStorage 服务。
像任何其他依赖项一样,在 WorkoutHistoryTracker 构造函数中注入它(workout-history-tracker.ts 文件)并进行必要的导入:
import {LocalStorage} from './local-storage';
...
constructor(private storage: LocalStorage) {
这是标准的 DI 内容,只是它没有按预期工作。如果我们现在刷新应用程序,Angular 会抛出一个错误:
Cannot resolve all parameters for WorkoutHistoryTracker(?). Make sure they all have valid type or annotations.
奇怪!这么棒的 DI 居然失败了,而且没有任何好的理由!其实不然;Angular 并没有进行任何魔法。它需要知道类的依赖关系,唯一的方法就是检查类的定义和构造函数参数。
在 WorkoutHistoryTracker 上添加一个名为 @Injectable() 的装饰器(记得加上括号),并添加模块导入语句:
import {Injectable} from '@angular/core';
刷新页面,DI 就能完美地工作了。是什么让它工作的?
通过添加 @Injectable 装饰器,我们强制 TypeScript 转译器为 WorkoutHistoryTracker 类生成元数据。这包括有关构造函数参数的详细信息。Angular DI 使用这些生成的元数据来确定服务的依赖类型,并在将来创建服务时满足这些依赖。
那些使用 WorkoutHistoryTracker 的组件呢?我们没有在那里使用 @Injectable,但是 DI 仍然起作用。我们不需要。任何装饰器都可以使用,而且所有组件已经应用了 @Component 装饰器。
提示
记住装饰器需要添加到调用类(或客户类)上。
LocalStorage 服务和 WorkoutHistoryTracker 之间的实际集成是一个平凡的过程。
更新 WorkoutHistoryTracker 的构造函数如下:
constructor(private storage: LocalStorage) {
this.workoutHistory = (storage.getItem<Array<WorkoutLogEntry>>(this.storageKey) || [])
.map((item: WorkoutLogEntry) => {
item.startedOn = new Date(item.startedOn.toString());
item.endedOn = item.endedOn == null ? null
: new Date(item.endedOn.toString());
return item;
});
}
并添加一个 storageKey 的声明:
private storageKey: string = "workouts";
构造函数从本地存储中加载训练日志。map 函数调用是必要的,因为 localStorage 中存储的所有内容都是字符串。因此,在反序列化时,我们需要将字符串转换回日期值。
在 startTracking、exerciseComplete 和 endTracking 函数中最后添加这个声明:
this.storage.setItem(this.storageKey, this.workoutHistory);
我们每次历史数据发生变化时都会将训练记录保存到本地存储中。
就是这样!我们已经在 localStorage 上构建了训练历史记录跟踪。验证一下!
在我们继续处理音频支持这个大问题之前,还有一些小的修复需要进行,以提供更好的用户体验。第一个与 历史 链接有关。
使用路由器服务跟踪路由更改。
Header组件中的History链接对所有路由可见。如果在锻炼页面上隐藏该链接会更好。我们不希望因为意外点击History链接而丢失正在进行中的锻炼。此外,在进行锻炼时,没有人对锻炼历史感兴趣。
修复很容易。我们只需要确定当前路由是否是锻炼路由,并隐藏链接。Router服务将帮助我们完成这项工作。
打开header.component.ts并为路由添加必要的导入;更新Header类的定义为:
import {Router, Event } from '@angular/router';
...
export class HeaderComponent {
showHistoryLink: boolean = true;
private subscription: any;
constructor(private router: Router) {
this.router.events.subscribe((data: Event) => {
this.showHistoryLink=!this.router.url.startsWith('/workout');
});
}
showHistoryLink属性确定是否向用户显示历史链接。在构造函数中,我们注入了Router服务,并使用subscribe函数在events属性上注册了一个回调。
events属性是一个可观察对象。我们将在本章后面学习更多关于可观察对象的知识,但现在理解可观察对象是指能够触发事件并且可以被订阅的对象就足够了。subscribe函数注册一个回调函数,每当路由改变时就会被调用。
回调函数的实现只是根据当前路由名称切换showHistoryLink状态。我们从router对象的url属性中获取名称。
在视图中使用showHistoryLink,只需更新头部模板行的锚标签为:
<li *ngIf="showHistoryLink"><a [routerLink]="['History']" ...>...</a></li>
就是这样!History链接不会出现在锻炼页面上。
另一个修复/增强与锻炼页面上的视频面板有关。
修复视频播放体验
当前视频面板的实现最多可以称为业余。默认播放器的大小很小。当我们播放视频时,锻炼不会暂停。在锻炼转换时,视频播放会中断。此外,整体视频加载体验在每次锻炼例行程序开始时都会有明显的延迟。这清楚地表明了视频播放需要一些修复。
这就是我们要做的来修复视频面板的方法:
-
为锻炼视频使用图像缩略图,而不是加载视频播放器本身
-
当用户点击缩略图时,加载一个可以播放所选视频的更大的视频播放器的弹出窗口/对话框
-
在视频播放时暂停锻炼
让我们开始工作吧!
使用视频缩略图
用这段代码替换video-player.html中的ngFor模板 html:
<div *ngFor="let video of videos" class="row video-image">
<div class="col-sm-12">
<div id="play-video-overlay">
<span class="glyphicon glyphicon-play-circle video absolute-center">
</span>
</div>
<img height="220" [src]="'//i.ytimg.com/vi/'+video+'/hqdefault.jpg'" />
</div>
</div>
我们已经放弃了 iframe,而是加载了视频的缩略图图片(检查img标签)。这里显示的所有其他内容都是为了给图片设置样式。
注意
我们已经参考了 Stack Overflow 上的帖子bit.ly/so-yt-thumbnail来确定我们视频的缩略图图片 URL。
开始一个新的训练;图片应该显示出来,但是播放功能是坏的。我们需要添加视频播放对话框。
使用 angular2-modal 对话框库
Angular 框架没有预打包的 UI 库/控件。我们需要向外寻找社区解决方案来满足任何 UI 控件的需求。
我们将要使用的库是 angular2-modal,在 GitHub 上可以找到bit.ly/angular2-modal。让我们安装和配置这个库。
从命令行(在trainer文件夹内),运行以下命令来安装这个库:
**npm i angular2-modal@2.0.0-beta.13 --save**
为了在我们的应用中集成 angular2-modal,我们需要在systemjs.config.js中添加 angular2-modal 的包引用。从 git 分支checkpoint3.2(GitHub 位置:bit.ly/ng2be-3-2-system-config-js)中复制更新后的systemjs.config.js到trainer文件夹,并覆盖本地配置文件。更新后的配置允许 SystemJS 在遇到库import语句时知道如何加载模态对话框库。
接下来的几步突出了在使用 angular2-modal 之前需要执行的配置仪式:
- 在第一步中,我们要配置 angular2-modal 的根元素。打开
app.component.ts并添加下面的代码:
import {Component, **ViewContainerRef} from '@angular/core';**
...
import { Overlay } from 'angular2-modal';
...
export class TrainerAppComponent {
**constructor(overlay: Overlay,**
**viewContainer: ViewContainerRef) {**
**overlay.defaultViewContainer = viewContainer;**
}
}
这一步是必不可少的,因为模态对话框需要一个容器组件来托管自己。通过传入TrainerAppComponent的ViewContainerRef,我们允许对话框在应用根内加载。
- 下一步是将库中的两个模块添加到
AppModule中。更新app.module.ts并添加以下代码:
import { ModalModule } from 'angular2-modal';
import { BootstrapModalModule }
from 'angular2-modal/plugins/bootstrap';
...
imports: [..., ModalModule.forRoot(), BootstrapModalModule]
现在这个库已经准备好使用了。
虽然 angular2-modal 有许多预定义的标准对话框模板,比如警报、提示和确认,但这些对话框在外观和感觉方面提供了很少的定制。为了更好地控制对话框 UI,我们需要创建一个自定义对话框,幸运的是这个库支持。
使用 angular2-modal 创建自定义对话框
在 angular2-modal 中创建自定义对话框只是一些带有一些特殊库构造的 Angular 组件。
从 git 分支checkpoint3.2的workout-runner/video-player文件夹中复制video-dialog.component.ts文件(GitHub 位置:bit.ly/ng2be-3-2-video-dialog-component-ts)到本地设置中。该文件包含了自定义对话框的实现。
接下来,更新workout-runner.module.ts,并在模块装饰器中添加一个新的entryComponents属性:
**import {VideoDialogComponent} from './video-player/video-dialog.component';** ...
declarations: [..., VideoDialogComponent],
**entryComponents:[VideoDialogComponent]**
需要将VideoDialogComponent添加到entryComponents中,因为它在组件树中没有明确使用。
VideoDialogComponent是一个标准的 Angular 组件,具有一些模态对话框的特定实现,我们稍后会描述。
VideoDialogContext类已经被创建,用于将点击的 YouTube 视频的videoId传递给对话框实例。该类继承自BSModalContext,这是对话框库用于修改模态对话框行为和 UI 的配置类。
为了更好地了解VideoDialogContext的使用方式,让我们从锻炼运行器中调用前面的对话框。
更新video-player.html中的ngFor div,并添加一个click事件处理程序:
<div *ngFor="let video of videos" (click)="playVideo(video)"
class="row video-image">
前面的处理程序调用playVideo方法,传入点击的视频。playVideo函数反过来打开相应的视频对话框。将playVideo的实现添加到video-player.component.ts中,如下所示:
**import {Modal} from 'angular2-modal';**
**import { overlayConfigFactory } from 'angular2-modal'**
**import {VideoDialogComponent, VideoDialogContext}
from './video-dialog.component';**
...
export class VideoPlayerComponent {
@Input() videos: Array<string>;
**constructor(private modal: Modal) { }**
**playVideo(videoId: string) {**
**this.modal.open(VideoDialogComponent,**
**overlayConfigFactory(new VideoDialogContext(videoId)));**
**};**
}
playVideo函数调用Modal类的open函数,传入要打开的对话框组件以及VideoDialogContext类的新实例,其中包含 YouTube 视频的videoId。在继续之前,也要从文件中删除ngOnChange函数。
video-dialog.component.ts中的对话框实现实现了ModalComponent<VideoDialogContext>接口,这是模态库所需的。看看如何将上下文(VideoDialogContext)传递给构造函数,以及如何从上下文中提取和分配videoId属性。然后只需要将videoId属性绑定到模板视图(查看模板 HTML)并渲染 YouTube 播放器。
我们已经准备就绪。加载应用程序并开始锻炼。然后点击任何锻炼视频图片。视频对话框应该加载,现在我们可以观看视频了!
在我们完成对话框实现之前,有一个小问题需要解决。当对话框打开时,锻炼应该暂停:目前并没有发生。我们将在下一节中使用 Angular 事件支持来解决这个问题。
注意
如果您在运行代码时遇到问题,请查看 git 分支checkpoint3.2,以获取我们迄今为止所做的工作的可工作版本。
或者,如果您不使用 git,请从bit.ly/ng2be-checkpoint3-2下载checkpoint3.2的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。
在用 Angular 构建新的东西之前,我们计划在7 分钟锻炼中添加最后一个功能:音频支持。它还教会我们一些新的跨组件通信模式。
使用 Angular 事件进行跨组件通信
在上一章中,我们提到了事件,当学习 Angular 的绑定基础设施时。现在是时候更深入地了解事件了。让我们为7 分钟锻炼添加音频支持。
使用音频跟踪运动进展
对于7 分钟锻炼应用程序,添加声音支持至关重要。人们无法一直盯着屏幕做运动。音频提示有助于用户有效地进行锻炼,因为他/她可以只需跟随音频指示。
以下是我们将如何使用音频提示支持运动跟踪:
-
滴答声跟踪运动进展
-
半程指示器发出声音,表明练习已经进行了一半
-
当练习即将结束时,会播放一个练习完成的音频片段
-
在休息阶段播放音频片段,通知用户下一个练习
每种情况都会有一个音频片段。
现代浏览器对音频有很好的支持。HTML5 的<audio>标签提供了一种将音频片段嵌入到 html 内容中的机制。我们也将使用<audio>标签来播放我们的片段。
由于计划使用 HTML 的<audio>元素,我们需要创建一个包装指令,允许我们从 Angular 控制音频元素。请记住,指令是没有视图的 HTML 扩展。
注意
Git 的checkpoint3.3文件夹trainer/static/audio包含了所有用于播放的音频文件;首先复制它们。如果您不使用 Git,可以在bit.ly/ng2be-checkpoint3-3下载并解压内容并复制音频文件。
构建 Angular 指令来包装 HTML 音频
到目前为止,您可能还没有意识到,但我们有意避免直接访问 DOM 以实现任何组件。目前还没有这样的需求。Angular 的数据绑定基础设施,包括属性、属性和事件绑定,已经帮助我们在不触及 DOM 的情况下操作 HTML。
对于音频元素,访问模式也应该符合 Angular 的风格。让我们创建一个包装对音频元素访问的指令。
在workout-runner文件夹内创建一个名为workout-audio的文件夹,并在其中添加一个名为my-audio.directive.ts的新文件。然后将此处概述的MyAudioDirective指令的实现添加到该文件中:
import {Directive, ElementRef} from '@angular/core';
@Directive({
selector: 'audio',
exportAs: 'MyAudio'
})
export class MyAudioDirective {
private audioPlayer: HTMLAudioElement;
constructor(element: ElementRef) {
this.audioPlayer = element.nativeElement;
}
}
MyAudioDirective类被装饰为@Directive。@Directive装饰器类似于@Component装饰器,只是我们不能有附加的视图。因此,不允许有template或templateUrl!
前面的selector属性允许框架确定应用指令的位置。使用audio作为选择器使我们的指令在 html 中的每个<audio>标签中加载。
注意
在标准情况下,指令选择器是基于属性的,这有助于我们确定指令的应用位置。我们偏离了这个规范,使用了MyAudioDirective指令的元素选择器。
我们希望该指令加载到每个音频元素中,而逐个音频声明并添加指令特定属性变得繁琐。因此使用了元素选择器。
当我们在视图模板中使用该指令时,使用exportAs就变得清晰了。
在构造函数中注入的ElementRef对象是该指令加载的 Angular 元素。当 Angular 编译和执行 html 模板时,为每个组件和指令创建ElementRef实例。在构造函数中请求时,DI 框架会定位相应的ElementRef并注入它。我们使用ElementRef来在代码中获取实际的音频元素(HTMLAudioElement的实例)。audioPlayer属性保存了这个引用。
该指令现在需要公开一个 API 来操作音频播放器。将这些函数添加到MyAudioDirective指令中:
stop() {
this.audioPlayer.pause();
}
start() {
this.audioPlayer.play();
}
get currentTime(): number {
return this.audioPlayer.currentTime;
}
get duration(): number {
return this.audioPlayer.duration;
}
get playbackComplete() {
return this.duration == this.currentTime;
}
MyAudioDirective API 有两个函数(start和stop)和三个 getter(currentTime,duration,以及一个名为playbackComplete的布尔属性)。这些函数和属性的实现只是包装了音频元素的函数。
注意
从 MDN 文档中了解这些音频功能:bit.ly/html-media-element。
要了解我们如何使用音频指令,让我们创建一个新的组件来管理音频播放。
注意
在继续之前,请记得在WorkoutRunnerModule下注册MyAudioDirective。
为音频支持创建 WorkoutAudioComponent
如果我们回过头来看一下所需的音频提示,有四个不同的音频提示,因此我们将创建一个带有五个嵌入式<audio>标签的组件(两个音频标签一起用于接下来的音频)。
打开workout-audio文件夹,并为组件模板创建一个名为workout-audio.html的文件。添加以下 HTML 片段:
<audio #ticks="MyAudio" loop src="/static/audio/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="/static/audio/nextup.mp3"></audio>
<audio #nextUpExercise="MyAudio" [src]= "'/static/audio/'
+ nextupSound">
</audio>
<audio #halfway="MyAudio" src="/static/audio/15seconds.wav"></audio>
<audio #aboutToComplete="MyAudio" src="/static/audio/321.wav"></audio>
五个<audio>标签,每个标签对应一个:
-
滴答声音:此音频产生滴答声音,并在锻炼开始时立即开始。使用模板变量
ticks引用。 -
接下来的音频和锻炼音频:有两个一起工作的音频标签。第一个带有模板变量
nextUp产生“接下来”声音。而实际的锻炼音频(nextUpExercise)。 -
中途音频:中途音频在锻炼进行到一半时播放。
-
即将完成的音频:播放此音频片段以表示完成一项锻炼(
aboutToComplete)。
你有没有注意到视图中使用了#符号?有一些变量赋值以#为前缀。在 Angular 世界中,这些变量被称为模板引用变量,有时也称为模板变量。
平台开发人员指南这样描述它们:
模板引用变量是模板内的 DOM 元素或指令的引用。
注意
不要将它们与我们之前在ngFor指令中使用的模板输入变量混淆:“*ngFor="let video of videos"`”
模板输入变量(在本例中为video)允许我们从视图中访问模型对象。分配给video的值取决于ngFor指令循环的上下文。
看一下最后一节,我们在那里将MyAudioDirective指令的exportAs元数据设置为MyAudio。我们在前面的视图中分配模板引用变量时重复了相同的字符串:
#ticks="MyAudio"
exportAs的作用是定义可以在视图中用来将该指令分配给变量的名称。记住,单个元素/组件可以应用多个指令。exportAs允许我们选择应该分配给模板变量的指令。
一旦声明了模板变量,就可以从视图的其他部分访问它们。我们很快就会讨论这个问题。但在我们的情况下,我们将使用模板变量来引用父组件代码中的多个MyAudioDirective。让我们了解一下它是如何工作的。
将workout-audio.compnent.ts文件添加到workout-audio文件夹,并按以下大纲进行编写:
import {Component, ViewChild} from '@angular/core';
import {MyAudioDirective} from './my-audio.directive'
import {WorkoutPlan, ExercisePlan, ExerciseProgressEvent,
ExerciseChangedEvent} from '../model';
@Component({
selector: 'workout-audio',
templateUrl: '/src/components/workout-runner/workout-audio/
workout-audio.html'
})
export class WorkoutAudioComponent {
@ViewChild('ticks') private ticks: MyAudioDirective;
@ViewChild('nextUp') private nextUp: MyAudioDirective;
@ViewChild('nextUpExercise')private nextUpExercise: MyAudioDirective;
@ViewChild('halfway') private halfway: MyAudioDirective;
@ViewChild('aboutToComplete') private aboutToComplete:
MyAudioDirective;
private nextupSound: string;
}
这里有趣的地方是对五个属性使用的@ViewChild装饰器。@ViewChild装饰器允许我们将子组件/指令/元素引用注入到其父组件中。传递的参数是模板变量名称,它帮助 DI 匹配要注入的元素/指令。当 Angular 实例化WorkoutAudioComponent时,它会根据@ViewChild装饰器注入相应的音频组件。在我们详细了解@ViewChild之前,让我们完成基本的类实现。
注意
在MyAudioDirective指令上没有设置exportAs时,@ViewChild注入会注入相关的ElementRef实例,而不是MyAudioDirective实例。
剩下的任务就是在正确的时间播放正确的音频组件。将这些函数添加到WorkoutAudioComponent中:
stop() {
this.ticks.stop();
this.nextUp.stop();
this.halfway.stop();
this.aboutToComplete.stop();
this.nextUpExercise.stop();
}
resume() {
this.ticks.start();
if (this.nextUp.currentTime > 0 && !this.nextUp.playbackComplete)
this.nextUp.start();
else if (this.nextUpExercise.currentTime > 0 &&
!this.nextUpExercise.playbackComplete)
this.nextUpExercise.start();
else if (this.halfway.currentTime > 0 &&
!this.halfway.playbackComplete) this.halfway.start();
else if (this.aboutToComplete.currentTime > 0 &&
!this.aboutToComplete.playbackComplete)
this.aboutToComplete.start();
}
onExerciseProgress(progress: ExerciseProgressEvent) {
if (progress.runningFor == Math.floor(progress.exercise.duration / 2)
&& progress.exercise.exercise.name != "rest") {
this.halfway.start();
}
else if (progress.timeRemaining == 3) {
this.aboutToComplete.start();
}
}
onExerciseChanged(state: ExerciseChangedEvent) {
if (state.current.exercise.name == "rest") {
this.nextupSound = state.next.exercise.nameSound;
setTimeout(() => this.nextUp.start(), 2000);
setTimeout(() => this.nextUpExercise.start(), 3000);
}
}
在编写这些函数时遇到了困难吗?它们可以在 Git 分支checkpoint3.3中找到。
接下来,继续将WorkoutAudioComponent添加到WorkoutRunnerModule的declarations数组中。
在上述代码中使用了两个新的模型类。将它们的声明添加到model.ts中,如下所示:
export class ExerciseProgressEvent {
constructor(
public exercise: ExercisePlan,
public runningFor: number,
public timeRemaining: number,
public workoutTimeRemaining: number) { }
}
export class ExerciseChangedEvent {
constructor(
public current: ExercisePlan,
public next: ExercisePlan) { }
}
这些是用于跟踪进度事件的模型类。WorkoutAudioComponent的实现会使用这些数据。随着我们的进展,数据是如何产生的就会变得清晰起来。
start和resume函数在开始、暂停或完成训练时停止和恢复音频。在resume函数中的额外复杂性是为了处理当训练在下一个动作时被暂停,当即将完成时被暂停,或者在播放音频时半途而废的情况。我们只想从上次离开的地方继续。
onExerciseProgress 函数应该被调用以报告锻炼进度。它用于根据锻炼的状态播放中途音频和即将完成的音频。传递给它的参数是一个包含锻炼进度数据的对象。
当锻炼改变时,应该调用 onExerciseChanged 函数。输入参数包含当前和下一个锻炼,帮助 WorkoutAudioComponent 决定何时播放下一个锻炼的音频。
请注意,这两个函数是由组件的消费者(在本例中是 WorkoutRunnerComponent)调用的。我们不在内部调用它们。
在本节中,我们涉及了两个新概念:模板引用变量和将子元素/指令注入到父元素中。在继续实现之前,值得更详细地探讨这两个概念。我们将首先学习更多关于模板引用变量的知识。
理解模板引用变量
模板引用变量是在视图模板上创建的,并且大多数情况下是从视图中使用的。正如您已经学到的,这些变量可以通过使用 # 前缀来声明来识别。
模板变量的最大好处之一是它们在视图模板级别促进了跨组件通信。一旦声明,这些变量可以被同级元素/组件及其子元素引用。查看以下片段:
<input #emailId type="email">Email to {{emailId.value}}
<button (click)= "MailUser(emaild.value)">Send</button>
这个片段声明了一个模板变量 emailId,然后在插值和按钮的 click 表达式中引用它。
Angular 模板引擎将 input 的 DOM 对象(HTMLInputElement 的一个实例)分配给 emailId 变量。由于该变量在同级元素中可用,我们在按钮的 click 表达式中使用它。
模板变量也适用于组件。我们可以轻松地这样做:
<trainer-app>
<workout-runner #runner></workout-runner>
<button (click)= "runner.start()">Start Workout</button>
</trainer-app>
在这种情况下,runner 引用了 WorkoutRunnerComponent 对象,并且按钮用于开始锻炼。
注意
ref- 前缀是 # 的规范替代。#runner 变量也可以声明为 ref-runner。
模板变量赋值
到目前为止,我们所见到的模板变量赋值有一些有趣的地方。回顾我们使用的例子:
<audio #ticks="MyAudio" loop src="/static/audio/tick10s.mp3"></audio>
<input #emailId type="email">Email to {{emailId.value}}
<workout-runner #runner></workout-runner>
变量分配的内容取决于变量声明的位置。这由 Angular 中的规则所控制,如下所述:
-
如果指令存在于元素上,例如在前面显示的第一个示例中的
MyAudioDirective,则该指令设置该值。MyAudioDirective指令将ticks变量设置为MyAudioDirective的实例。 -
如果没有指令存在,要么分配底层 HTML DOM 元素,要么分配组件对象(如
email和workout-runner示例中所示)。
我们将利用这种技术来实现训练音频组件与训练运行器组件的集成。这个介绍给了我们所需要的先发优势。
我们承诺要涵盖的另一个新概念是使用ViewChild和ViewChildren装饰器进行子元素/指令注入。
使用@ViewChild装饰器
@ViewChild装饰器通知 Angular DI 框架在组件树中搜索子组件/指令/元素并将它们注入到父组件中。在上面的代码中,音频元素指令(MyAudioDirective类)被注入到WorkoutAudioComponent代码中。
为了建立上下文,让我们重新检查WorkoutAudioComponent中的一个视图片段:
<audio #ticks="MyAudio" loop src="/static/audio/tick10s.mp3"></audio>
Angular 将指令(MyAudioDirective)注入到WorkoutAudioComponent属性ticks中。映射是基于传递给@ViewChild装饰器的选择器进行的。
ViewChild上的选择器参数可以是字符串值,在这种情况下,Angular 会搜索匹配的模板变量,就像以前一样。
或者它可以是一个类型。这是有效的:
@ViewChild(MyAudioDirective) private ticks: MyAudioDirective;
但在我们的情况下,这并不起作用。在WorkoutAudioComponent视图中加载了多个MyAudioDirective指令,每个<audio>标签对应一个。在这种情况下,只会注入第一个匹配项。并不是很有用。如果视图中只有一个<audio>标签,传递类型选择器将起作用。
提示
使用@ViewChild装饰的属性在调用组件的ngAfterViewInit事件钩子之前一定会被设置。这意味着如果在构造函数内部访问这些属性,它们将为null。
与@ViewChild类似,Angular 有一个装饰器来定位多个子组件/指令:@ViewChildren。
@ViewChildren装饰器
@ViewChildren的工作方式与@ViewChild类似,只是当视图具有多个相同类型的子组件/指令时使用。使用@ViewChildren,我们可以获取WorkoutAudioComponent中所有MyAudioDirective指令的实例,如下所示:
@ViewChildren(MyAudioDirective) allAudios: QueryList<MyAudioDirective>;
仔细看,allAudios不是一个数组,而是一个自定义对象,QueryList<Type>。 QueryList是 Angular 能够定位的组件/指令的不可变集合。这个列表最好的地方是,Angular 将保持此列表与视图状态同步。当动态地从视图中添加/删除指令/组件时,此列表也会更新。使用ng-for生成的组件/指令是这种动态行为的一个主要例子。考虑前面的@ViewChildren用法和这个视图模板:
<audio *ngFor="let clip of clips" src="/static/audio/ "
+{{clip}}></audio>
由 Angular 创建的MyAudioDirective指令的数量取决于clips的数量。当使用@ViewChildren时,Angular 会将正确数量的MyAudioDirective实例注入到allAudio属性中,并在从clips数组中添加或删除项目时保持同步。
虽然使用@ViewChildren允许我们获得所有MyAudioDirective指令,但它不能用于控制播放。你看,我们需要获得单独的MyAudioDirective实例,因为音频播放的时间不同。因此,我们将坚持使用@ViewChild实现。
一旦我们获得了附加到每个音频元素的MyAudioDirective指令,只需在正确的时间播放音频轨道。
集成 WorkoutAudioComponent
虽然我们已经将音频播放功能组件化为WorkoutAudioComponent,但它始终与WorkoutRunnerComponent实现紧密耦合。WorkoutAudioComponent从WorkoutRunnerComponent获取其操作智能。因此,这两个组件需要互动。WorkoutRunnerComponent需要提供WorkoutAudioComponent的状态更改数据,包括训练开始时,练习进度,训练停止,暂停和恢复。
实现此集成的一种方法是使用当前公开的WorkoutAudioComponentAPI(停止,恢复和其他功能)从WorkoutRunnerComponent中。
可以通过将WorkoutAudioComponent注入到WorkoutRunnerComponent中来完成一些工作,就像我们之前将MyAudioDirective注入到WorkoutAudioComponent中一样。看看这段代码:
@ViewChild(WorkoutAudioComponent) workoutAudioPlayer:
WorkoutAudioComponent;
然后,WorkoutAudioComponent函数可以从代码中的不同位置调用WorkoutRunnerComponent。例如,这就是pause会如何改变的方式:
pause() {
clearInterval(this.exerciseTrackingInterval);
this.workoutPaused = true;
**this.workoutAudioPlay.stop();**
}
要播放接下来的音频,我们需要更改startExerciseTimeTracking函数的部分内容:
this.startExercise(next);
**this.workoutAudioPlayer.onExerciseChanged(
new ExerciseChangedEvent(next, this.getNextExercise()));**
这是一个完全可行的选择,其中WorkoutAudioComponent成为由WorkoutRunnerComponent控制的哑组件。这种解决方案的唯一问题是,它给WorkoutRunnerComponent的实现增加了一些噪音。WorkoutRunnerComponent现在还需要管理音频播放。
然而,还有一种选择。WorkoutRunnerComponent可以在锻炼执行的不同时间触发事件,比如锻炼开始、练习开始、锻炼暂停等等。WorkoutRunnerComponent暴露事件的另一个优势是,它允许我们在未来将其他组件与WorkoutRunnerComponent集成,使用相同的事件。
暴露 WorkoutRunnerComponent 事件
Angular 允许组件和指令使用EventEmitter类来暴露自定义事件。在变量声明部分的末尾,将这些事件声明添加到WorkoutRunnerComponent中:
workoutPaused: boolean;
**@Output() exercisePaused: EventEmitter<number>** **= new EventEmitter<number>();**
**@Output() exerciseResumed: EventEmitter<number>** **= new EventEmitter<number>();**
**@Output() exerciseProgress:EventEmitter<ExerciseProgressEvent>** **= new EventEmitter<ExerciseProgressEvent>();**
**@Output() exerciseChanged: EventEmitter<ExerciseChangedEvent>** **= new EventEmitter<ExerciseChangedEvent>();**
**@Output() workoutStarted: EventEmitter<WorkoutPlan>**
**= new EventEmitter<WorkoutPlan>();**
**@Output() workoutComplete: EventEmitter<WorkoutPlan>**
**= new EventEmitter<WorkoutPlan>();**
事件的名称不言自明,在我们的WorkoutRunnerComponent实现中,我们需要在适当的时机触发这些事件。
记得将ExerciseProgressEvent添加到模型模块中,并在顶部已经声明的模块中导入。并且将Output和EventEmitter导入到@angular/core中。
让我们试着理解@Output装饰器和EventEmitter类的作用。
@Output 装饰器
在第二章中,我们涵盖了相当多的 Angular 事件能力。具体来说,我们学会了如何使用bracketed ()语法在组件、指令或 DOM 元素上消耗任何事件。那么如何触发我们自己的事件呢?
在 Angular 中,我们可以创建和触发自己的事件,这些事件表示组件/指令中发生了值得注意的事情。使用@Output装饰器和EventEmitter类,我们可以定义和触发自定义事件。
现在是一个很好的时机,通过重新访问第二章中的Angular 事件绑定基础设施部分的Eventing 子部分来复习一下我们学到的关于事件的知识。
记住,正是通过事件,组件才能与外部世界进行通信。当我们声明:
@Output() exercisePaused: EventEmitter<number> =
new EventEmitter<number>();
这表示 WorkoutRunnerComponent 公开了一个名为 exercisePaused 的事件(在锻炼暂停时触发)。
要订阅此事件,我们这样做:
<workout-runner (exercisePaused)="onExercisePaused($event)">
</workout-runner>
这看起来与我们在锻炼运行器模板中订阅 DOM 事件的方式非常相似:
<div id="pause-overlay" (click)="pauseResumeToggle()"
(window:keyup)="onKeyPressed($event)">
@Output 装饰器指示 Angular 使此事件可用于模板绑定。你可以创建一个没有 @Output 装饰器的事件,但这样的事件不能在 html 中引用。
注意
@Output 装饰器也可以带一个参数,表示事件的名称。如果没有提供,装饰器将使用属性名称:@Output("workoutPaused") exercisePaused: EventEmitter<number> = new EventEmitter<number>();
这声明了一个名为 workoutPaused 的事件,而不是 exercisePaused。
像任何装饰器一样,@Output 装饰器也只是为了提供元数据,以便 Angular 框架使用。真正的重活是由 EventEmitter 类完成的。
使用 EventEmitter 进行事件处理
Angular 采用响应式编程(也称为Rx风格编程)来支持异步操作和事件。如果你第一次听到这个术语,或者对响应式编程不太了解,你并不孤单。
响应式编程就是针对异步数据流进行编程。这样的流就是基于它们发生的时间顺序排列的一系列持续事件。我们可以把流想象成一个生成数据(以某种方式)并将其推送给一个或多个订阅者的管道。由于这些事件被订阅者异步捕获,它们被称为异步数据流。
数据可以是任何东西,从浏览器/DOM 元素事件,到用户输入,再到使用 AJAX 加载的远程数据。使用 Rx 风格,我们统一消耗这些数据。
在 Rx 世界中,有观察者和可观察对象,这是从非常流行的观察者设计模式派生出来的概念。可观察对象是发出数据的流。观察者则订阅这些事件。
Angular 中的 EventEmitter 类主要负责提供事件支持。它既充当观察者又充当可观察对象。我们可以在其上触发事件,也可以用它来监听事件。
EventEmitter 上有两个函数对我们很有兴趣:
-
emit:顾名思义,使用这个函数来触发事件。它接受一个事件数据作为参数。emit是可观察的一面。 -
subscribe:使用这个函数来订阅EventEmitter引发的事件。subscribe是观察者端。
让我们进行一些事件发布和订阅,以了解前面的函数是如何工作的。
从 WorkoutRunnerComponent 中引发事件
看一下EventEmitter的声明。这些已经声明了type参数。type参数表示发出的数据类型。
让我们按照时间顺序从顶部开始,逐步向下在workout-runner.component.ts中添加事件实现。
在start函数的末尾添加这个语句:
this.workoutStarted.emit(this.workoutPlan);
我们使用EventEmitter的emit函数来引发一个名为workoutStarted的事件,并将当前的训练计划作为参数。
对于pause,添加以下行来引发exercisePaused事件:
this.exercisePaused.emit(this.currentExerciseIndex);
对于resume,添加以下行:
this.exerciseResumed.emit(this.currentExerciseIndex);
每次在引发exercisePaused和exerciseResumed事件时,我们都将当前的练习索引作为参数传递给emit。
在startExerciseTimeTracking函数内,在调用startExercise后添加高亮显示的代码:
this.startExercise(next);
**this.exerciseChanged.emit(new ExerciseChangedEvent(
next, this.getNextExercise()));**
传递的参数包含即将开始的练习(next)和队列中的下一个练习(this.getNextExercise())。
在同一个函数中,添加高亮显示的代码:
this.tracker.endTracking(true);
**this.workoutComplete.emit(this.workoutPlan);**
this.router.navigate(['finish']);
当训练完成时引发事件。
最后在同一个函数中,我们引发一个表达训练进度的事件。添加这个语句:
--this.workoutTimeRemaining;
**this.exerciseProgress.emit(new ExerciseProgressEvent(**
**this.currentExercise,**
**this.exerciseRunningDuration,**
**this.currentExercise.duration -**
**this.exerciseRunningDuration,**
**this.workoutTimeRemaining**
**));**
这样就完成了我们的事件实现。
正如你可能已经猜到的,WorkoutAudioComponent现在需要消费这些事件。这里的挑战是如何组织这些组件,以便它们可以以最小的相互依赖来进行通信。
组件通信模式
就目前的实现而言,我们有一个基本的WorkoutAudioComponent实现,并通过暴露训练生命周期事件来增强了WorkoutRunnerComponent。现在这两者只需要相互交流。
首先显而易见的选择是将WorkoutAudioComponent声明添加到WorkoutRunnerComponent视图中。因此,WorkoutAudioComponent成为WorkoutRunnerComponent的子组件。然而,在这样的设置中,它们之间的通信变得有点笨拙。记住,事件是组件与外部世界通信的机制。
如果父组件需要与其子组件通信,可以通过以下方式实现:
- 属性绑定:父组件可以在子组件上设置属性绑定,将数据推送到子组件。例如:
<workout-audio [stopped]="workoutPaused"></workout-audio>
在这种情况下,属性绑定效果很好。当锻炼暂停时,音频也会停止。但并非所有情况都可以使用属性绑定来处理。播放下一个练习音频或中途音频需要更多的控制。
- 在子组件上调用函数:如果父组件可以获取子组件,那么父组件也可以在子组件上调用函数。我们已经看到了如何在
WorkoutAudioComponent的实现中使用@ViewChild和@ViewChildren装饰器来实现这一点。
还有一个不太好的选择,即父组件实例可以被注入到子组件中。在这种情况下,子组件可以调用父组件函数或设置内部事件处理程序以处理父事件。
我们将尝试这种方法,然后放弃实现一个更好的方法!我们计划实现的不太理想的解决方案可以带来很多学习。
将父组件注入到子组件中
在最后一个闭合 div 之前将WorkoutAudioComponent添加到WorkoutRunnerComponent视图中:
<workout-audio></workout-audio>
接下来我们需要将WorkoutRunnerComponent注入到WorkoutAudioComponent中。
打开workout-audio.component.ts并添加以下声明和构造函数:
private subscriptions: Array<any>;
constructor( @Inject(forwardRef(() => WorkoutRunnerComponent))
private runner: WorkoutRunnerComponent) {
this.subscriptions = [
this.runner.exercisePaused.subscribe((exercise: ExercisePlan) =>
this.stop()),
this.runner.workoutComplete.subscribe((exercise: ExercisePlan) =>
this.stop()),
this.runner.exerciseResumed.subscribe((exercise: ExercisePlan) =>
this.resume()),
this.runner.exerciseProgress.subscribe((
progress: ExerciseProgressEvent) =>
this.onExerciseProgress(progress)),
this.runner.exerciseChanged.subscribe((
state: ExerciseChangedEvent) =>
this.onExerciseChanged(state))]
}
记得添加这些导入:
import {Component, ViewChild, Inject, forwardRef} from '@angular/core';
import {WorkoutRunnerComponent} from '../workout-runner.component'
我们在构造注入中使用了一些技巧。如果我们直接尝试将WorkoutRunnerComponent注入到WorkoutAudioComponent中,Angular 会抱怨无法找到所有依赖项。仔细阅读代码并深思熟虑;这里隐藏着一个微妙的依赖循环问题。WorkoutRunnerComponent已经依赖于WorkoutAudioComponent,因为我们在WorkoutRunnerComponent视图中引用了WorkoutAudioComponent。现在通过在WorkoutAudioComponent中注入WorkoutRunnerComponent,我们创建了一个依赖循环。
循环依赖对于任何 DI 框架来说都是具有挑战性的。在创建具有循环依赖的组件时,框架必须以某种方式解决这个循环。在前面的例子中,我们通过使用@Inject装饰器并传入使用forwardRef()全局框架函数创建的标记来解决循环依赖问题。
在构造函数中,我们使用EventEmitter订阅函数将处理程序附加到事件上。传递给subscribe的箭头函数在特定事件发生时被调用。我们将所有订阅收集到一个数组subscription中。当我们需要取消订阅时,这个数组非常有用,以避免内存泄漏。
EventEmmiter的订阅(subscribe函数)有三个参数:
subscribe(generatorOrNext?: any, error?: any, complete?: any) : any
-
第一个参数是一个回调函数,每当事件被触发时调用
-
第二个参数是一个错误回调函数,当可观察对象(生成事件的部分)出现错误时调用
-
最终的参数是一个回调函数,当可观察对象完成发布事件时调用
我们已经做了足够的工作来使音频集成工作。运行应用程序并开始锻炼。除了滴答声音之外,所有其他音频片段都会在正确的时间播放。您可能需要等一段时间才能听到其他音频片段。问题是什么?
事实证明,我们从未在锻炼开始时播放滴答声音。我们可以通过在ticks音频元素上设置autoplay属性或使用“组件生命周期事件”来触发滴答声音来修复它。让我们采取第二种方法。
使用组件生命周期事件
我们在WorkoutAudioComponent中进行了MyAudioDirective的注入:
@ViewChild('ticks') private ticks: MyAudioDirective;
在组件的视图被初始化之前,这将不可用:
我们可以通过在构造函数中访问ticks变量来验证它;它将为 null。Angular 仍然没有发挥其魔力,我们需要等待WorkoutAudioComponent的子级初始化。
组件的生命周期钩子可以帮助我们。一旦组件的视图被初始化,就会调用AfterViewInit事件钩子,因此从中访问组件的子指令/元素是一个安全的地方。让我们快速做一下。
通过添加接口实现和必要的导入来更新WorkoutAudioComponent,如下所示:
import {..., AfterViewInit} from '@angular/core';
...
export class WorkoutAudioComponent implements AfterViewInit {
ngAfterViewInit() {
this.ticks.start();
}
继续测试应用程序。应用程序已经具备了完整的音频反馈。不错!
虽然表面上一切看起来都很好,但现在应用程序中存在内存泄漏。如果在锻炼过程中我们从锻炼页面导航到开始或结束页面,然后再返回到锻炼页面,多个音频片段会在随机时间播放。
似乎WorkoutRunnerComponent在路由导航时没有被销毁,因此,包括WorkoutAudioComponent在内的子组件都没有被销毁。结果是什么?每次我们导航到锻炼页面时都会创建一个新的WorkoutRunnerComponent,但在导航离开时却从内存中永远不会被移除。
这个内存泄漏的主要原因是我们在WorkoutAudioComponent中添加的事件处理程序。当音频组件卸载时,我们需要取消订阅这些事件,否则WorkoutRunnerComponent的引用将不会被解除引用。
另一个组件生命周期事件在这里拯救我们:OnDestroy!将这个实现添加到WorkoutAudioComponent类中:
ngOnDestroy() {
this.subscriptions.forEach((s) => s.unsubscribe());
}
还记得像我们为AfterViewInit那样为OnDestroy事件接口添加引用吗?
希望我们在事件订阅期间创建的subscription数组现在有意义了。一次性取消订阅!
这个音频集成现在完成了。虽然这种方法并不是集成这两个组件的一个非常糟糕的方式,但我们可以做得更好。子组件引用父组件似乎是不可取的。
如果WorkoutRunnerComponent和WorkoutAudioComponent组织为兄弟组件呢?
注意
在继续之前,删除我们从将父组件注入到子组件部分开始添加到workout-audio.component.ts的代码。
使用事件和模板变量进行兄弟组件交互
如果WorkoutAudioComponent和WorkoutRunnerComponent变成兄弟组件,我们可以充分利用 Angular 的事件和模板引用变量。感到困惑?好吧,首先,组件应该布局如下:
<workout-runner></workout-runner>
<workout-audio></workout-audio>
有没有什么灵感?从这个模板开始,你能猜到最终的模板 HTML 会是什么样子吗?在继续之前先想一想。
还在挣扎吗?一旦我们将它们变成兄弟组件,Angular 模板引擎的威力就显现出来了。以下模板代码足以集成WorkoutRunnerComponent和WorkoutAudioComponent:
<workout-runner (exercisePaused)="wa.stop()"
(exerciseResumed)="wa.resume()" (exerciseProgress)=
"wa.onExerciseProgress($event)" (exerciseChanged)=
"wa.onExerciseChanged($event)" (workoutComplete)="wa.stop()"
(workoutStarted)="wa.resume()">
</workout-runner>
<workout-audio #wa></workout-audio>
WorkoutAudioComponent的模板变量wa在WorkoutRunnerComponent的模板上被操作。相当优雅!在这种方法中,我们仍然需要解决最大的难题:前面的代码应该放在哪里?记住,WorkoutRunnerComponent是作为路由加载的一部分加载的。在代码中我们从来没有像这样的语句:
<workout-runner></workout-runner>
我们需要重新组织组件树,并引入一个容器组件,可以承载WorkoutRunnerComponent和WorkoutAudioComponent。然后路由器加载此容器组件,而不是WorkoutRunnerComponent。让我们开始吧。
在workout-runner文件夹内创建一个名为workout-container的文件夹,并添加两个新文件,workout-container.component.ts和workout-container.html。
将带有前面描述的事件的 HTML 代码复制到模板文件中,并在workout-container.component.ts中添加以下声明:
import {Component, Input} from '@angular/core';
@Component({
selector: 'workout-container',
templateUrl: '/src/components/workout-runner/workout-container.html'
})
export class WorkoutContainerComponent { }
锻炼容器组件已准备就绪。将其添加到workout-runner.module.ts中的declarations部分,并导出它,而不是WorkoutRunnerComponent。
接下来,我们只需要重新设置路由。打开app.routes.ts。更改锻炼页面的路由并添加必要的导入:
import {WorkoutContainerComponent} from '../workout-runner/
workout-container/workout-container.component';
..
**{ path: '/workout', component: WorkoutContainerComponent },**
我们有一个清晰、简洁且令人愉悦的工作音频集成!
现在是时候结束本章了,但在解决早期部分引入的视频播放器对话框故障之前还不要结束。当视频播放器对话框打开时,锻炼不会停止/暂停。
我们不打算在这里详细说明修复方法,并敦促读者在不查看checkpoint3.3代码的情况下尝试一下。
这是一个明显的提示。使用事件基础设施!
另一个:从VideoPlayerComponent中触发事件,每个事件对应播放开始和结束。
最后的提示:对话框服务(Modal)上的open函数返回一个 promise,在对话框关闭时解析。
注意
如果您在运行代码时遇到问题,请查看 git 分支checkpoint3.3,以获取到目前为止我们所做的工作的可用版本。
或者,如果您不使用 git,请从bit.ly/ng2be-checkpoint3-3下载checkpoint3.2的快照(ZIP 文件)。在首次设置快照时,请参考trainer文件夹中的README.md文件。
总结
一点一点地,我们为7 分钟锻炼添加了许多增强功能,这对于任何专业应用程序都是必不可少的。仍然有新功能和改进的空间,但核心应用程序运行良好。
我们开始本章时探讨了 Angular 的单页面应用(SPA)功能。在这里,我们学习了基本的 Angular 路由,设置路由,使用路由配置,使用RouterLink指令生成链接,以及使用 Angular 的Router和Location服务进行导航。
从应用程序的角度来看,我们为7 分钟锻炼添加了开始、完成和锻炼页面。
然后,我们构建了一个锻炼历史跟踪器服务,用于跟踪历史锻炼执行情况。在这个过程中,我们深入学习了 Angular 的依赖注入(DI)。我们学习了依赖项的注册方式,依赖项令牌是什么,以及依赖项是单例的特性。
最后,我们提到了一个重要的主题:跨组件通信,主要使用 Angular 事件。我们详细介绍了如何使用@Output装饰器和EventEmitter创建自定义事件。
@ViewChild和@ViewChildren装饰器在本章中帮助我们理解了父组件如何获取子组件以供使用。Angular DI 也允许将父组件注入到子组件中。
我们通过构建一个WorkoutAudioComponent来结束了本章,并突出了如何使用 Angular 事件和模板变量进行兄弟组件通信。
接下来呢?我们将构建一个新的应用程序,个人教练。这个应用程序将允许我们构建自己的定制锻炼。一旦我们能够创建自己的锻炼,我们将把7 分钟锻炼应用程序改造成一个通用的锻炼运行器应用程序,可以运行我们使用个人教练构建的锻炼。
在下一章中,我们将展示 AngularJS 表单的功能,同时构建一个允许我们创建、更新和查看自定义锻炼/练习的 UI。
第四章:个人教练
构建个人教练 7 分钟锻炼应用程序为我们提供了学习 Angular 的绝佳机会。通过这个应用程序,我们已经涵盖了许多 Angular 构造。然而,仍有一些领域,如 Angular 表单支持和客户端服务器通信,尚未被探索。这部分是因为从功能角度来看,“7 分钟锻炼”与最终用户的接触点有限。交互仅限于开始、停止和暂停锻炼。此外,该应用程序既不消耗数据,也不生成任何数据(除了锻炼历史记录)。
在本章中,我们计划深入研究前面提到的两个领域之一,即 Angular 表单支持。为了跟上健康和健身主题(无意冒犯),我们计划构建一个“个人教练”应用程序。这个新应用程序将是“7 分钟锻炼”的延伸,使我们能够构建自己定制的锻炼计划,而不仅限于我们已经拥有的“7 分钟锻炼”计划。
本章致力于理解 Angular 表单以及在构建“个人教练”应用程序时如何使用它们。
本章我们将涵盖的主题如下:
-
定义个人教练需求:由于本章我们正在构建一个新应用程序,因此我们首先要定义应用程序的需求。
-
定义个人教练模型:任何应用程序设计都始于定义其模型。我们为“个人教练”定义模型,这与之前构建的“7 分钟锻炼”应用程序类似。
-
定义个人教练布局和导航:我们为新应用程序定义布局、导航模式和视图。我们还设置了一个与 Angular 路由和主视图集成的导航系统。
-
添加支持页面:在我们专注于表单功能并构建一个锻炼组件之前,我们将为锻炼和锻炼列表构建一些支持组件。
-
定义锻炼构建器组件结构:我们布置了将用于管理锻炼的锻炼构建器组件。
-
构建表单:我们大量使用 HTML 表单和输入元素来创建自定义锻炼。在这个过程中,我们将更多地了解 Angular 表单。我们涵盖的概念包括:
-
表单类型:使用 Angular 可以构建两种类型的表单,即模板驱动型和模型驱动型。本章我们将使用这两种类型的表单。
-
ngModel:这为模板驱动的表单提供了双向数据绑定,并允许我们跟踪更改和验证表单输入。
-
模型驱动表单控件:这些包括表单构建器、表单控件、表单组和表单数组。这些用于以编程方式构建表单。
-
数据格式化:这些是允许我们为用户样式化反馈的 CSS 类。
-
输入验证:我们将了解 Angular 表单的验证能力。
个人教练应用 - 问题范围
7 分钟锻炼应用程序很不错,但如果我们能够创建一个允许我们构建更多这样的锻炼计划,以适应我们的健身水平和强度要求的应用程序呢?有了这种灵活性,我们可以构建任何类型的锻炼,无论是 7 分钟、8 分钟、15 分钟还是其他变化。机会是无限的。
有了这个前提,让我们踏上建立我们自己的个人教练应用程序的旅程,这将帮助我们根据我们特定的需求创建和管理训练/锻炼计划。让我们从定义应用程序的要求开始。
注意
新的个人教练应用现在将包括现有的7 分钟锻炼应用程序。支持锻炼创建的组件将被称为“锻炼构建器”。7 分钟锻炼应用程序本身也将被称为“锻炼运行器”。在接下来的章节中,我们将修复锻炼运行器,使其能够运行使用锻炼构建器创建的任何锻炼。
个人教练要求
基于管理锻炼和练习的概念,这些是我们的个人教练应用程序应该满足的一些要求:
-
有能力列出所有可用的锻炼。
-
有能力创建和编辑锻炼。在创建和编辑锻炼时,它应该具有:
-
有能力添加包括名称、标题、描述和休息时间在内的锻炼属性
-
有能力为锻炼添加/移除多个练习
-
有能力对锻炼进行排序
-
有能力保存锻炼数据
-
有能力列出所有可用的练习。
-
有能力创建和编辑练习。在创建和编辑练习时,它应该具有:
-
有能力添加练习属性,如名称、标题、描述和步骤
-
有能力为练习添加图片
-
为练习添加相关视频的能力
-
有能力为练习添加音频提示
所有要求似乎都是不言自明的;因此,让我们从应用程序的设计开始。按照惯例,我们首先需要考虑可以支持这些要求的模型。
个人教练模型
这里没有什么意外!个人教练模型本身是在我们创建7 分钟锻炼应用程序时定义的。锻炼和练习的两个核心概念对个人教练也适用。
现有的锻炼模型的唯一问题是它位于锻炼运行者目录中。这意味着为了使用它,我们必须从该目录导入它。将模型移动到service文件夹中更有意义,这样可以清楚地表明它可以跨功能使用。
让我们了解如何在整个应用程序中共享模型。
分享锻炼模型
我们将分享锻炼模型作为服务。如前一章所述,服务没有特定的定义。基本上,它是一个保存功能的类,可能在我们的应用程序中的多个位置有用。由于它将在 Workout Runner 和 Workout Builder 中使用,我们的锻炼模型符合该定义。将我们的模型转换为服务并不需要太多仪式感 - 所以让我们开始做吧。
首先,从 GitHub 存储库中的checkpoint4.1下载新个人教练应用程序的基本版本。
注意
代码可在 GitHub 上下载github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.1。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.1 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.1.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。
此代码包含完整的7 分钟锻炼(锻炼运行者)应用程序。我们添加了一些内容来支持新的个人教练应用程序。一些相关的更新包括:
-
添加新的
WorkoutBuilder功能。此功能包含与个人教练相关的实现。 -
更新应用程序的布局和样式。
-
在
trainer/src/components文件夹下的workout-builder文件夹中添加一些组件和带有个人教练占位内容的 HTML 模板。 -
定义一个新的路由到
WorkoutBuilder功能。我们将在接下来的部分中介绍如何设置这个路由。
让我们回到定义模型。
模型作为一个服务
在上一章中,我们专门学习了关于 Angular 服务的一个完整部分,我们发现服务对于在控制器和其他 Angular 构造之间共享数据非常有用。实际上,我们没有任何数据,只有描述数据形状的蓝图。因此,我们计划使用服务来公开模型结构。打开app文件夹下的services文件夹中的model.ts文件。
注意
model.ts文件已经移动到services文件夹中,因为该服务在Workout Builder和Workout Runner应用程序之间共享。注意:在trainer/src/components文件夹下的workout-runner文件夹中,我们已经更新了workout-runner.component.ts,workout-audio.component.ts和workout-audio0.component.ts中的导入语句,以反映这一变化。
在第二章中,构建我们的第一个应用 - 7 分钟锻炼,我们回顾了模型文件中的类定义:Exercise,ExercisePlan和WorkoutPlan。正如我们之前提到的,这三个类构成了我们的基本模型。我们现在将开始在我们的新应用中使用这个基本模型。
这就是关于模型设计的全部内容。接下来,我们要做的是定义新应用的结构。
个人教练布局
个人教练的骨架结构如下:
这有以下组件:
-
顶部导航:这包含应用品牌标题和历史链接。
-
子导航:这里有导航元素,根据活动组件的不同而变化。
-
左侧导航:这包含依赖于活动组件的元素。
-
内容区域:这是我们组件的主视图显示的地方。这里发生了大部分的动作。我们将在这里创建/编辑练习和锻炼,并显示练习和锻炼的列表。
查看源代码文件;在trainer/src/components下有一个新的文件夹workout-builder。它有我们之前描述的每个组件的文件,带有一些占位内容。我们将在本章节中逐步构建这些组件。
但是,我们首先需要在应用程序中连接这些组件。这要求我们定义 Workout Builder 应用程序的导航模式,并相应地定义应用程序路线。
带路由的私人教练
我们计划在应用程序中使用的导航模式是列表-详细信息模式。我们将为应用程序中可用的练习和锻炼创建列表页面。单击任何列表项将带我们到该项的详细视图,我们可以在那里执行所有 CRUD 操作(创建/读取/更新/删除)。以下路线符合此模式:
| 路线 | 描述 |
|---|---|
/builder | 这只是重定向到builder/workouts。 |
/builder/workouts | 这列出了所有可用的锻炼。这是Workout Builder的登陆页面。 |
/builder/workout/new | 这将创建一个新的锻炼。 |
/builder/workout/:id | 这将编辑具有特定 ID 的现有锻炼。 |
/builder/exercises | 这列出了所有可用的练习。 |
/builder/exercise/new | 这将创建一个新的练习。 |
/builder/exercise/:id | 这将编辑具有特定 ID 的现有练习。 |
开始
此时,如果您查看src/components/app文件夹中的app.routes.ts中的路由配置,您将找到一个新的路由定义 - builder :
export const routes: Routes = [
...
{ path: 'builder', component: WorkoutBuilderComponent },
...
];
如果您运行应用程序,您将看到启动屏幕显示另一个链接:创建一个锻炼:
在幕后,我们为此链接添加了另一个路由器链接到start.html:
<a [routerLink]="['/builder']">
<span>Create a Workout</span>
<span class="glyphicon glyphicon-plus"></span>
</a>
如果您点击此链接,您将进入以下视图:
再次在幕后,我们在trainer/src/components/workout-builder文件夹中添加了一个WorkoutBuilderComponent,并在workout-builder.component.html中添加了以下相关模板:
<div class="row">
<div class="col-sm-3"></div>
<div class="col-sm-6">
<h1 class="text-center">Workout Builder</h1>
</div>
<div class="col-sm-3"></div>
</div>
并且这个视图在app.component.ts的视图模板中使用路由器出口显示在屏幕上的标题下:
<div class="container body-content app-container">
<router-outlet></router-outlet>
</div>`
我们已经将此组件包装在一个名为workout-builder.module.ts的新模块中:
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { WorkoutBuilderComponent } from "./workout-builder.component";
@NgModule({
imports: [CommonModule],
declarations: [
WorkoutBuilderComponent,
],
exports: [WorkoutBuilderComponent],
})
export class WorkoutBuilderModule { }
这里唯一可能与我们创建的其他模块不同的地方是,我们导入的是CommonModule而不是BrowserModule。这样可以避免第二次导入整个BrowserModule,这样在实现此模块的延迟加载时会生成错误。
最后,我们已经在app.module.ts中为这个模块添加了一个导入:
...
@NgModule({
imports: [
...
**WorkoutBuilderModule],**
...
所以这里没有什么令人惊讶的。这些是我们在前几章介绍的基本组件构建和路由模式。遵循这些模式,我们现在应该开始考虑为我们的新功能添加先前概述的额外导航。然而,在我们开始做这件事之前,有一些事情我们需要考虑。
首先,如果我们开始将路由添加到app.routes.ts文件中,那么存储在那里的路由数量将增加。Workout Builder的这些新路由也将与Workout Runner的路由混合在一起。虽然我们现在添加的路由数量似乎微不足道,但随着时间的推移,这可能会成为一个维护问题。
其次,我们需要考虑到我们的应用程序现在包括两个功能 - Workout Runner和Workout Builder。我们应该考虑如何在应用程序中分离这些功能,以便它们可以独立开发。
换句话说,我们希望在构建的功能之间实现松耦合。使用这种模式允许我们在不影响其他功能的情况下替换应用程序中的功能。例如,将来我们可能希望将Workout Runner转换为移动应用程序,但保持Workout Builder作为基于 Web 的应用程序不变。
回到第一章,我们强调了将组件彼此分离的能力是 Angular 实现的组件设计模式的关键优势之一。幸运的是,Angular 的路由器使我们能够将我们的路由分离成逻辑组织良好的路由配置,这与我们应用程序中的功能密切匹配。
为了实现这种分离,Angular 允许我们使用子路由,在这里我们可以隔离每个功能的路由。在本章中,我们将使用子路由来分离Workout Builder的路由。
向 Workout Builder 引入子路由
Angular 支持我们隔离新的Workout Builder路由的目标,通过为我们提供在应用程序中创建路由组件层次结构的能力。目前,我们只有一个路由组件,它位于应用程序的根组件中。但是 Angular 允许我们在根组件下添加所谓的子路由组件。这意味着一个功能可以不知道另一个功能正在使用的路由,并且每个功能都可以自由地根据该功能内部的变化来调整其路由。
回到我们的应用程序,我们可以使用 Angular 中的子路由来匹配我们应用程序的两个功能的路由与将使用它们的代码。因此,在我们的应用程序中,我们可以将路由结构化为以下Workout Builder的路由层次结构(在这一点上,我们将Workout Runner保持不变,以显示之前和之后的比较):
介绍将子路由引入到 Workout Builder 中
通过这种方法,我们可以通过功能创建路由的逻辑分离,并使其更易于管理和维护。
所以让我们开始通过向我们的应用程序添加子路由来开始。
注意
从这一点开始,在本节中,我们将继续添加我们在本章早期下载的代码。如果您想查看本节的完整代码,可以从 GitHub 存储库的检查点 4.2 中下载。如果您想与我们一起构建本节的代码,请确保在trainer/static/css文件夹中添加app.css中的更改,因为我们不会在这里讨论它们。还要确保从存储库的trainer/src/components/workout-builder文件夹中添加 exercise(s)和 workout(s)的文件。在这个阶段,这些只是存根文件,我们将在本章后面实现它们。但是,您需要这些存根文件来实现Workout Builder模块的导航。该代码可供所有人在 GitHub 上下载:github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.2。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.2 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.2.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。
添加子路由组件
在workout-builder目录中,添加一个名为workout-builder.routes.ts的新的 TypeScript 文件,其中包含以下导入:
import { ModuleWithProviders } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { WorkoutBuilderComponent} from "./workout-builder.component";
import { ExerciseComponent} from './exercise/exercise.component';
import { ExercisesComponent} from './exercises/exercises.component';
import { WorkoutComponent} from './workout/workout.component';
import { WorkoutsComponent} from './workouts/workouts.component';
正如你所看到的,我们正在导入我们刚刚提到的组件;它们将成为我们的Workout Builder(exercise,exercises,workout 和 workouts)的一部分。除了这些导入之外,我们还从 Angular 核心模块导入ModuleWithProviders,从 Angular 路由器模块导入Routes和RouterModule。这些导入将使我们能够添加和导出子路由。
然后将以下路由配置添加到文件中:
export const workoutBuilderRoutes: Routes = [
{
path: 'builder',
component: WorkoutBuilderComponent,
children: [
{path:'', pathMatch: 'full', redirectTo: 'workouts'},
{path:'workouts', component: WorkoutsComponent },
{path:'workout/new', component: WorkoutComponent },
{path:'workout/:id', component: WorkoutComponent },
{path:'exercises', component: ExercisesComponent},
{path:'exercise/new', component: ExerciseComponent },
{path:'exercise/:id', component: ExerciseComponent }
]
}
];
第一个配置,path: 'builder',设置了子路由的基本 URL,以便每个子路由都会在其前面添加它。下一个配置将WorkoutBuilder组件标识为该文件中子组件的功能区根组件。这意味着它将是使用router-outlet显示每个子组件的组件。最后一个配置是一个或多个子组件的列表,定义了子组件的路由。
这里需要注意的一点是,我们已经将Workouts设置为子路由的默认配置:
{path:'', pathMatch: 'full', redirectTo: 'workouts'},
这个配置表示,如果有人导航到builder,他们将被重定向到builder/workouts路线。pathMatch: 'full'设置意味着只有在 workout/builder 后的路径为空字符串时才会进行匹配。这可以防止在路由为其他内容时发生重定向,比如workout/builder/exercises或者我们在这个文件中配置的其他路由。
最后,添加以下export语句:
export const workoutBuilderRouting: ModuleWithProviders = RouterModule.forChild(workoutBuilderRoutes);
这个导出将我们的子路由注册到路由器中,与 app.routes.ts 中的类似,只有一个区别:我们使用的是RouterModule.forChild而不是RouterModule.forRoot。这种差异的原因似乎很明显:我们正在创建子路由,而不是应用程序根目录中的路由,这是我们表示的方式。然而,在底层,这有着重大的区别。这是因为我们的应用程序中不能有多个路由器服务。forRoot创建路由器服务,但forChild不会。
更新 WorkoutBuilder 组件
接下来,我们需要更新WorkoutBuilder组件以支持我们的新子路由。为此,将 Workout Builder 的@Component装饰器更改为:
-
移除
selector -
用
template引用替换对templateUrl的引用 -
在模板中添加一个
<sub-nav>自定义元素 -
在模板中添加一个
<router-outlet>标签 -
装饰器现在应该如下所示:
@Component({
template: `<div class="navbar navbar-default
navbar-fixed-top second-top-nav">
<sub-nav></sub-nav>
</div>
<div class="container body-content app-container">
<router-outlet></router-outlet>
</div>`
})
我们移除了选择器,因为WorkoutBuilderComponent不会嵌入在应用程序根目录app.component.ts中。相反,它将通过路由从app.routes.ts中到达。虽然它将处理来自app.routes.ts的入站路由请求,但它将进一步将它们路由到 Workout Builder 功能中包含的其他组件。
这些组件将使用我们刚刚添加到WorkoutBuilder模板中的<router-outlet>标签显示它们的视图。鉴于Workout BuilderComponent的模板将是简单的,我们还将内联模板替换为templateUrl。
注意
通常,对于组件的视图,我们建议使用指向单独的 HTML 模板文件的templateUrl。当您预期视图将涉及多于几行 HTML 时,这一点尤为重要。在这种情况下,更容易使用单独的 HTML 文件来处理视图。单独的 HTML 文件允许您使用具有颜色编码和标记完成等功能的 HTML 编辑器。相比之下,内联模板只是 TypeScript 文件中的字符串,编辑器不会给您带来这些好处。
我们还将添加一个<sub-nav>元素,用于创建Workout Builder功能内部的次级顶级菜单。我们将在本章稍后讨论这一点。
更新 Workout Builder 模块
现在让我们更新WorkoutBuilderModule。这将涉及一些重大变化,因为我们将把这个模块转变为一个功能模块。因此,这个模块将导入我们用于构建锻炼的所有组件。我们不会在这里涵盖所有这些导入,但一定要从 GitHub 存储库的checkpoint 4.2中的trainer/src/components/workout-builder文件夹中的workout-builder.ts中添加它们。
值得一提的是以下导入:
import { workoutBuilderRouting } from './workout-builder.routes';
它导入了我们刚刚设置的子路由。
现在让我们将@NgModule装饰器更新为以下内容:
@NgModule({
imports: [
CommonModule,
**workoutBuilderRouting**
],
declarations: [
WorkoutBuilderComponent,
**WorkoutComponent,**
**WorkoutsComponent,**
**ExerciseComponent,**
**ExercisesComponent,**
**SubNavComponent,**
**LeftNavExercisesComponent,**
**LeftNavMainComponent**
],
exports: [WorkoutBuilderComponent],
})
更新 app.routes
最后一步:返回到app.routes.ts,并从该文件中删除WorkoutBuilderComponent及其路由的导入。
把所有东西放在一起
从上一章,我们已经知道如何为我们的应用程序设置根路由。但现在,我们所拥有的不是根路由,而是包含子路由的区域或功能路由。我们已经能够实现我们之前讨论的关注点分离,这样与Workout Builder相关的所有路由现在都单独包含在它们自己的路由配置中。这意味着我们可以在WorkoutBuilderRoutes组件中管理Workout Builder的所有路由,而不会影响应用程序的其他部分。
我们可以看到路由器如何将app.routes.ts中的路由与workout-builder.routes.ts中的默认路由组合在一起,如果我们现在从起始页面导航到 Workout Builder。
如果我们在浏览器中查看 URL,它是/builder/workouts。您会记得起始页面上的路由链接是['/builder']。那么路由是如何将我们带到这个位置的呢?
它是这样做的:当链接被点击时,Angular 路由器首先查找app.routes.ts中的builder路径,因为该文件包含了我们应用程序中根路由的配置。路由器没有在该文件的路由中找到该路径,因为我们已经从该文件的路由中删除了它。
然而,WorkoutBuilderComponent已经被导入到我们的AppModule中,该组件反过来从workout-builder-routes.ts导入了workoutBuilderRouting。后者文件包含了我们刚刚配置的子路由。路由器发现builder是该文件中的父路由,因此它使用了该路由。它还发现了默认设置,即在builder路径以空字符串结尾时重定向到子路径workouts,在这种情况下就是这样。
路由解析的过程如下所示:
如果您看屏幕,您会发现它显示的是Workouts的视图(而不是之前的Workout Builder)。这意味着路由器已成功地将请求路由到了WorkoutsComponent,这是我们在workout-builder.routes.ts中设置的子路由配置的默认路由的组件。
关于子路由的最后一个想法。当您查看我们的子路由组件workout-builder.component.ts时,您会发现它没有引用其parent组件app.component.ts(正如我们之前提到的,<selector>标签已被移除,因此Workout Builder组件没有被嵌入到根组件中)。这意味着我们已成功地封装了Workout Builder(以及它导入的所有组件),这将使我们能够将其全部移动到应用程序的其他位置,甚至是到一个新的应用程序中。
现在是时候将我们的训练构建器的路由转换为使用延迟加载,并构建其导航菜单了。如果您想查看下一节的完成代码,可以从检查点 4.3的伴随代码库中下载。再次强调,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。
注意
该代码也可供所有人在 GitHub 上下载:github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.3(文件夹 - trainer)。如果您不使用 Git,请从以下 GitHub 位置下载检查点 4.3 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.3.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。
路由的延迟加载
当我们推出我们的应用程序时,我们预计我们的用户每天都会访问训练运行器(我们知道这对你来说也是如此!)。但我们预计他们只会偶尔使用训练构建器来构建他们的练习和训练计划。因此,如果我们的用户只是在训练运行器中做练习时,我们最好能避免加载训练构建器的开销。相反,我们希望只在用户想要添加或更新他们的练习和训练计划时按需加载训练构建器。这种方法称为延迟加载。
注意
在幕后,Angular 使用 SystemJS 来实现这种延迟加载。它允许我们在加载模块时采用异步方法。这意味着我们可以只加载启动应用程序所需的内容,然后根据需要加载其他模块。
在我们的个人教练应用程序中,我们希望改变应用程序,使其只在需要时加载训练构建器。Angular 路由器允许我们使用延迟加载来实现这一点。
但在开始实现懒加载之前,让我们先看看我们当前的应用程序以及它如何加载我们的模块。在Sources选项卡中打开开发者工具,启动应用程序;当应用程序的起始页面出现在您的浏览器中时,您会看到应用程序中的所有文件都已加载,包括Workout Runner和Workout Builder文件:
因此,即使我们可能只想使用Workout Runner,我们也必须加载Workout Builder。从某种意义上讲,如果你把我们的应用程序看作是一个单页应用程序(SPA),这是有道理的。为了避免与服务器的往返,SPA 通常会在用户首次启动应用程序时加载所有将需要使用应用程序的资源。但在我们的情况下,重要的是当应用程序首次加载时,我们并不需要 Workout Builder。相反,我们希望只在用户决定要添加或更改锻炼或练习时才加载这些资源。
因此,让我们开始实现这一点。
首先,修改app.routes.ts,添加以下单独的路由配置,用于我们的workoutBuilderRoutes:
const workoutBuilderRoutes: Routes = [
{
path: 'builder',
loadChildren: 'dist/components/workout-builder/workout-builder.module#Workout-BuilderModule'
}
];
请注意,loadChildren属性是:
component: file path + # + component name
此配置提供了加载和实例化组件所需的信息。特别注意文件路径;它指向我们的代码在dist文件夹中的位置,当它部署为 JavaScript 文件时,而不是该文件的 TypeScript 版本所在的文件夹。
接下来,更新Routes配置以添加以下内容:
export const routes: Routes = [
{ path: 'start', component: StartComponent },
{ path: 'workout', component: WorkoutContainerCompnent },
{ path: 'finish', component: FinishComponent },
{ path: 'history', component: WorkoutHistoryComponent },
**...workoutBuilderRoutes,**
{ path: '**', redirectTo: '/start' }
];
您会注意到我们已经添加了对WorkoutBuilderRoutes的引用,我们刚刚配置并用三个点添加了前缀。通过这三个点,我们使用 ES2015 扩展运算符来插入一个路由数组 - 具体来说是WorkoutBuilder功能的路由。这些路由将包含在WorkoutBuilderRoutes中,并将与我们应用程序根目录中的路由分开维护。最后,从该文件中删除对WorkoutBuilderComponent的导入。
接下来回到workout-builder.routes.ts,将path属性更改为空字符串:
export const workoutBuilderRoutes: Routes = [
{
**path: '',**
. . .
}
];
我们进行此更改是因为我们现在正在将路径('builder')设置为app.routes.ts中添加的WorkoutBuilderRoutes的新配置。
最后返回app-module.ts,并在该文件的@NgModule配置中删除WorkoutBuilderModule的导入。这意味着我们不会在应用程序启动时加载锻炼构建器功能,而是只有在用户访问锻炼构建器路由时才加载它。
让我们返回并再次运行应用程序,保持 Chrome 开发者工具中的源选项卡打开。当应用程序开始并加载起始页面时,只有与锻炼运行器相关的文件出现,而与锻炼构建器相关的文件不会出现,如下所示:
然后,如果我们清除网络选项卡并单击创建锻炼链接,我们将只看到与锻炼构建器加载相关的文件:
正如我们所看到的,现在加载的所有文件都与锻炼构建器相关。这意味着我们已经实现了新功能的封装,并且通过异步路由,我们能够使用延迟加载仅在需要时加载所有其组件。
子级和异步路由使我们能够轻松实现允许我们“既能拥有蛋糕,又能吃掉蛋糕”的应用程序。一方面,我们可以构建具有强大客户端导航的单页面应用程序,另一方面,我们还可以将功能封装在单独的子路由组件中,并仅在需要时加载它们。
Angular 路由器的这种强大和灵活性使我们能够通过密切映射应用程序的行为和响应性来满足用户的期望。在这种情况下,我们利用了这些能力来实现我们的目标:立即加载锻炼运行器,以便我们的用户可以立即开始锻炼,但避免加载锻炼构建器的开销,而只在用户想要构建锻炼时提供它。
现在我们已经在锻炼构建器中放置了路由配置,我们将把注意力转向创建子级和左侧导航;这将使我们能够使用这个路由。接下来的部分将涵盖实现这种导航。
集成子级和侧边导航
将子级和侧边导航集成到应用程序中的基本思想是提供基于活动视图而变化的上下文感知子视图。例如,当我们在列表页面而不是编辑项目时,我们可能希望在导航中显示不同的元素。电子商务网站是一个很好的例子。想象一下亚马逊的搜索结果页面和产品详细页面。随着上下文从产品列表变为特定产品,加载的导航元素也会改变。
子级导航
我们将首先向Workout Builder添加子级导航。我们已经将SubNavComponent导入到Workout Builder中。但目前它只显示占位内容:
现在我们将用三个路由链接替换该内容:主页,新锻炼和新练习。
打开sub-nav.component.html文件,并将其中的 HTML 更改为以下内容:
<div>
<a [routerLink]="['/builder/workouts']" class="btn btn-primary">
<span class="glyphicon glyphicon-home"></span> Home
</a>
<a [routerLink]="['/builder/workout/new']" class="btn btn-primary">
<span class="glyphicon glyphicon-plus"></span> New Workout
</a>
<a [routerLink]="['/builder/exercise/new']" class="btn btn-primary">
<span class="glyphicon glyphicon-plus"></span> New Exercise
</a>
</div>
现在重新运行应用程序,您将看到三个导航链接。如果我们点击新练习链接按钮,我们将被路由到ExerciseComponent,并且其视图将出现在Workout Builder视图中的路由出口中:
新锻炼链接按钮将以类似的方式工作;当点击时,它将带用户到WorkoutComponent并在路由出口显示其视图。点击主页链接按钮将把用户返回到WorkoutsComponent和视图。
侧边导航
Workout Builder内的侧边导航将根据我们导航到的子组件而变化。例如,当我们首次导航到Workout Builder时,我们会进入锻炼屏幕,因为WorkoutsComponent的路由是Workout Builder的默认路由。该组件将需要侧边导航;它将允许我们选择查看锻炼列表或练习列表。
Angular 的基于组件的特性为我们提供了一种实现这些上下文敏感菜单的简单方法。我们可以为每个菜单定义新的组件,然后将它们导入到需要它们的组件中。在这种情况下,我们有三个组件将需要侧边菜单:锻炼,练习和锻炼。前两个组件实际上可以使用相同的菜单,所以我们实际上只需要两个侧边菜单组件:LeftNavMainComponent,它将类似于前面的菜单,并将被Exercises和Workouts组件使用,以及LeftNavExercisesComponent,它将包含现有练习列表,并将被Workouts组件使用。
我们已经为两个菜单组件准备了文件,包括模板文件,并将它们导入到WorkoutBuilderModule中。现在我们将把它们整合到需要它们的组件中。
首先,修改workouts.component.html模板以添加菜单的选择器:
div class="container-fluid">
<div id="content-container" class="row">
**<left-nav-main></left-nav-main>**
<h1 class="text-center">Workouts</h1>
</div>
</div>
然后,将left-nav-main.component.html中的占位文本替换为导航链接到WorkoutsComponent和ExercisesComponent:
<div class="col-sm-2 left-nav-bar">
<div class="list-group">
<a [routerLink]="['/builder/workouts']" class="list-group-item list-group-item-info">Workouts</a>
<a [routerLink]="['/builder/exercises']" class="list-group-item list-group-item-info">Exercises</a>
</div>
</div>
运行应用程序,您应该会看到以下内容:
按照完全相同的步骤完成Exercises组件的侧边菜单。
注意
我们不会在这里展示这两个菜单的代码,但您可以在 GitHub 存储库的checkpoint 4.3中的trainer/src/components文件夹下的workout-builder/exercises文件夹中找到它们。
对于锻炼屏幕的菜单,步骤是相同的,只是您应该将left-nav-exercises.component.html更改为以下内容:
<div class="col-sm-2 left-nav-bar">
<h3>Exercises</h3>
</div>
我们将使用这个模板作为构建出现在屏幕左侧的练习列表的起点,并可以选择包含在锻炼中的练习。
实现锻炼和练习列表
甚至在我们开始实现锻炼和练习列表页面之前,我们需要一个练习和锻炼数据的数据存储。当前的计划是使用内存数据存储并使用 Angular 服务来公开它。在第五章中,支持服务器数据持久性,我们将把这些数据移到服务器存储以实现长期持久性。目前,内存存储就足够了。让我们添加存储实现。
WorkoutService 作为锻炼和练习存储库
这里的计划是创建一个负责在两个应用程序中公开练习和锻炼数据的WorkoutService实例。服务的主要职责包括:
-
与 Exercise 相关的 CRUD 操作:获取所有练习,根据名称获取特定练习,创建练习,更新练习和删除练习
-
与 Workout 相关的 CRUD 操作:这些类似于与 Exercise 相关的操作,但是针对 Workout 实体
注意
该代码可在 GitHub 上下载,网址为github.com/chandermani/angular2byexample。要下载的分支如下:GitHub 分支:checkpoint4.4(文件夹 - trainer)。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.4 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.4.zip。首次设置快照时,请参考trainer文件夹中的README.md文件。再次,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。因为本节中的一些文件相当长,所以我们不会在这里显示代码,有时我们会建议您将文件简单复制到您的解决方案中。
在trainer/src/services文件夹中找到workout-service.ts。该文件中的代码应该如下所示,除了两个方法setupInitialExercises和setupInitialWorkouts的实现,由于它们的长度,我们已经省略了:
import {Injectable} from '@angular/core';
import {ExercisePlan} from './model';
import {WorkoutPlan} from './model';
import {Exercise} from "./model";
@Injectable()
export class WorkoutService {
workouts: Array<WorkoutPlan> = [];
exercises: Array<Exercise> = [];
constructor() {
this.setupInitialExercises();
this.setupInitialWorkouts();
}
getExercises(){
return this.exercises;
}
getWorkouts(){
return this.workouts;
}
setupInitialExercises(){
// implementation of in-memory store.
}
setupInitialWorkouts(){
// implementation of in-memory store.
}
}}
正如我们之前提到的,实现 Angular 服务是直截了当的。在这里,我们声明了一个名为WorkoutService的类,并用@Injectable进行装饰,以支持在整个应用程序中注入它。在类定义中,我们首先创建了两个数组:一个用于Workouts,一个用于Exercises。这些数组分别是WorkoutPlan和Exercise类型的,因此我们需要从model.ts中导入WorkoutPlan和Exericse以获取它们的类型定义。
构造函数调用了两个方法来设置Workouts和Services List。目前,我们只是使用一个内存存储来填充这些列表的数据。
这两个方法,getExercises和getWorkouts,顾名思义,分别返回练习和锻炼的列表。由于我们计划使用内存存储来存储锻炼和练习数据,Workouts和Exercises数组存储了这些数据。随着我们的进行,我们将向服务添加更多的函数。
还有一件事情我们需要做,就是使服务可以在整个应用程序中被注入。
打开同一文件夹中的services.module.ts,然后导入WorkoutService并将其添加为提供者:
---- other imports ----
**import { WorkoutService } from "./workout-service";**
@NgModule({
imports: [],
declarations: [],
providers: [
LocalStorage,
WorkoutHistoryTracker,
**WorkoutService],**
})
这将WorkoutService注册为 Angular 的依赖注入框架的提供者。
是时候添加锻炼和练习列表的组件了!
锻炼和练习列表组件
首先,打开trainer/src/components/workout-builder/workouts文件夹中的workouts.component.ts文件,并按照以下方式更新导入:
import { Component, OnInit} from '@angular/core';
import { Router } from '@angular/router';
import { WorkoutPlan } from "../../../services/model";
import { WorkoutService } from "../../../services/workout-service";
这段新代码从 Angular 核心中导入了OnInit,以及Router,WorkoutService和WorkoutPlan类型。
接下来,用以下代码替换类定义:
export class WorkoutsComponent implements OnInit {
workoutList:Array<WorkoutPlan> = [];
constructor(
public router:Router,
public workoutService:WorkoutService) {}
ngOnInit() {
this.workoutList = this.workoutService.getWorkouts();
}
onSelect(workout: WorkoutPlan) {
this.router.navigate( ['./builder/workout', workout.name] );
}
}
这段代码添加了一个构造函数,我们在其中注入了WorkoutService和Router。然后ngOnInit方法调用WorkoutService的getWorkouts方法,并用从该方法调用返回的WorkoutPlans列表填充了一个workoutList数组。我们将使用这个workoutList数组来填充在Workouts组件的视图中显示的锻炼计划列表。
您会注意到我们将调用WorkoutService的代码放入了一个ngOnInit方法中。我们希望避免将这段代码放入构造函数中。最终,我们将用外部数据存储的调用替换这个服务使用的内存存储,我们不希望我们组件的实例化受到这个调用的影响。将这些方法调用添加到构造函数中也会使组件的测试变得复杂。
为了避免这种意外的副作用,我们将代码放在ngOnInit方法中。这个方法实现了 Angular 的生命周期钩子之一,OnInit,Angular 在创建服务的实例后调用这个方法。这样我们就依赖于 Angular 以一种可预测的方式调用这个方法,不会影响组件的实例化。
接下来,我们将对Exercises组件进行几乎相同的更改。与Workouts组件一样,这段代码将锻炼服务注入到我们的组件中。这次,我们使用锻炼服务来检索练习。
注意
因为它与我们刚刚为Workouts组件展示的内容非常相似,所以我们不会在这里展示代码。只需从workout-builder/exercises文件夹的checkpoint 4.4中添加它。
锻炼和锻炼列表视图
现在我们需要实现到目前为空的列表视图!
注意
在本节中,我们将使用checkpoint 4.4中找到的代码更新checkpoint 4.3中的代码。因此,如果您正在与我们一起编码,只需按照本节中列出的步骤进行。如果您想查看完成的代码,只需将checkpoint 4.4中的文件复制到您的解决方案中。
锻炼列表视图
要使视图工作,打开workouts.component.html并添加以下标记:
<div class="container-fluid">
<div id="content-container" class="row">
<left-nav-main></left-nav-main>
<h1 class="text-center">Workouts</h1>
**<div class="workouts-container">
<div *ngFor="let workout of workoutList|orderBy:'title'"
class="workout tile" (click)="onSelect(workout)">
<div class="title">{{workout.title}}</div>
<div class="stats">
<span class="duration" >
<span class="glyphicon glyphicon-time"></span> -
{{workout.totalWorkoutDuration()|secondsToTime}}</span>
<span class="length pull-right" >
<span class="glyphicon glyphicon-th-list">
</span> - {{workout.exercises.length}}</span>
</div>
</div>
</div>
</div>** </div>
我们使用 Angular 核心指令之一ngFor来循环遍历锻炼列表并在页面上以列表形式显示它们。我们在ngFor前面加上*号来将其标识为 Angular 指令。使用let语句,我们将workout分配为一个本地变量,用于遍历工作列表并识别要为每个锻炼显示的值(例如,workout.title)。然后我们使用我们的自定义管道之一orderBy,按标题的字母顺序显示锻炼列表。我们还使用另一个自定义管道secondsToTime来格式化显示总锻炼持续时间的时间。
注意
如果您正在与我们一起编码,您需要将secondsToTime管道移动到共享文件夹并将其包含在SharedModule中。然后将SharedModule作为额外导入添加到WorkoutBuilderModule中。这个更改已经在 GitHub 存储库的checkpoint 4.4中进行了。
最后,我们将点击事件绑定到我们组件中添加的onSelect方法:
onSelect(workout: WorkoutPlan) {
this.router.navigate( ['./builder/workout', workout.name] );
}
这设置了导航到锻炼详细页面。当我们双击锻炼列表中的项目时,将发生此导航。所选锻炼名称作为路由/URL 的一部分传递到锻炼详细页面。
继续刷新构建器页面(/builder/workouts);列出了一个锻炼,7 分钟锻炼。点击该锻炼的瓷砖。您将被带到锻炼屏幕,锻炼名称7MinWorkout将出现在 URL 的末尾:
锻炼列表视图
对于Exercises列表视图,我们将采用与Workouts列表视图相同的方法。只是在这种情况下,我们实际上将实现两个视图:一个用于Exercises组件(当用户导航到该组件时显示在主内容区域),另一个用于LeftNavExercisesComponent练习上下文菜单(当用户导航到Workouts组件创建/编辑锻炼时显示)。
对于Exercises组件,我们将采用几乎与在Workouts组件中显示锻炼列表时相同的方法。所以我们不会在这里展示那些代码。只需添加来自checkpoint 4.4的exercise.conponent.ts和exercise.component.html文件。
当你完成复制文件后,点击左侧导航中的练习链接,加载你已经在WorkoutService中配置好的 12 个练习。
与Workouts列表一样,这设置了导航到练习详情页面。在练习列表中双击一个项目会带我们到练习详情页面。所选练习的名称作为路由/URL 的一部分传递到练习详情页面。
在最终的列表视图中,我们将添加一个练习列表,它将显示在Workout Builder屏幕的左侧上下文菜单中。当我们创建或编辑一个锻炼时,这个视图会在左侧导航中加载。使用 Angular 的基于组件的方法,我们将更新leftNavExercisesComponent及其相关视图,以提供这个功能。同样,我们不会在这里展示那些代码。只需添加来自checkpoint 4.4的left-nav-exercises.component.ts和left-nav-exercises.component.html文件,它们位于trainer/src/components/navigation文件夹中。
一旦你完成了复制这些文件,点击Workout Builder子导航菜单中的新锻炼按钮,你将会看到一个练习列表,在左侧导航菜单中显示了我们已经在WorkoutService中配置好的练习。
是时候添加加载、保存和更新练习/锻炼数据的功能了!
构建锻炼
个人教练的核心功能围绕着锻炼和练习的建立。一切都是为了支持这两个功能。在这一部分,我们将专注于使用 Angular 构建和编辑锻炼。
WorkoutPlan模型已经定义,所以我们知道构成锻炼的元素。 Workout Builder页面促进用户输入,并让我们构建/持久化锻炼数据。
完成后,“Workout Builder”页面将如下所示:
页面有一个左侧导航,列出了可以添加到锻炼中的所有练习。单击右侧的箭头图标将练习添加到锻炼的末尾。
中心区域用于构建锻炼。它由按顺序排列的练习瓷砖和一个表单组成,允许用户提供关于锻炼的其他细节,如名称、标题、描述和休息时间。
此页面有两种模式:
-
创建/新建:此模式用于创建新的锻炼。URL 为
#/ builder/workout/new。 -
编辑:此模式用于编辑现有的锻炼。URL 为
#/ builder/workout/:id,其中:id映射到锻炼的名称。
了解了页面元素和布局,现在是构建每个元素的时候了。我们将从左侧导航开始。
完成左侧导航
在上一节的最后,我们更新了Workout组件的左侧导航视图,以显示锻炼列表。我们的意图是让用户点击练习旁边的箭头将其添加到锻炼中。当时,我们推迟了在LeftNavExercisesComponent中实现addExercise方法,该方法绑定到该点击事件。现在我们将继续执行。
我们在这里有几个选择。 LeftNavExercisesComponent是WorkoutComponent的子组件,因此我们可以实现子/父组件间的通信来完成这一点。在上一章中,我们在7 分钟锻炼时使用了这种技术。
但是,将练习添加到锻炼中是构建锻炼的一个较大过程的一部分,使用子/父组件间通信将使AddExercise方法的实现与我们将要添加的其他功能有所不同。
因此,对于共享数据,更有意义的是采用另一种方法,这是我们可以在整个构建锻炼的过程中一致使用的方法。这种方法涉及使用服务。随着我们开始添加用于创建实际锻炼的其他功能,例如保存/更新逻辑,并实现其他相关组件,采用服务路线的好处将变得越来越明显。
因此,我们引入了一个新的服务:WorkoutBuilderService。WorkoutBuilderService服务的最终目标是在构建锻炼时协调WorkoutService(用于检索和保存锻炼)和组件(例如LeftNavExercisesComponent和我们稍后将添加的其他组件),从而将WorkoutComponent中的代码量减少到最低限度。
添加 WorkoutBuilderService
WorkoutBuilderService跟踪正在构建的锻炼的状态。它:
-
跟踪当前锻炼
-
创建新的锻炼
-
加载现有的锻炼
-
保存锻炼
从checkpoint 4.5中的trainer/src/components文件夹下的workout-builder/builder-services文件夹中复制workout-builder-service.ts。
注意
该代码也可供所有人在 GitHub 上下载github.com/chandermani/angular2byexample。检查点在 GitHub 中作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.5(文件夹-trainer)。如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.5 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.5.zip。首次设置快照时,请参阅trainer文件夹中的README.md文件。再次,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。
虽然我们通常会在整个应用程序中提供服务,但WorkoutBuilderService只会在Workout Builder功能中使用。因此,我们将在WorkoutBuilderModule的提供程序数组中注册它,而不是在AppModule中注册它(在文件顶部添加导入后):
providers: [
**WorkoutBuilderService,**
. . .
]
在这里将其添加为提供者意味着只有在访问Workout Builder功能时才会加载它,并且无法在此模块之外访问。这意味着它可以独立于应用程序中的其他模块进行演变,并且可以在不影响应用程序其他部分的情况下进行修改。
让我们看一下服务的一些相关部分。
WorkoutBuilderService需要WorkoutPlan,Exercise和WorkoutService的类型定义,因此我们将其导入到组件中:
import { WorkoutPlan, Exercise } from '../../../services/model';
import { WorkoutService } from "../../../services/workout-service";
WorkoutBuilderService依赖于WorkoutService来提供持久性和查询功能。我们通过将WorkoutService注入到WorkoutBuilderService的构造函数中来解决这个依赖关系**:**
constructor(public workoutService:WorkoutService){}
WorkoutBuilderService还需要跟踪正在构建的锻炼。我们使用buildingWorkout属性来实现这一点。当我们在服务上调用startBuilding方法时,跟踪开始:
startBuilding(name: string){
if(name){
this.buildingWorkout = this.workoutService.getWorkout(name)
this.newWorkout = false;
}else{
this.buildingWorkout = new WorkoutPlan("", "", 30, []);
this.newWorkout = true;
}
return this.buildingWorkout;
}
此跟踪功能背后的基本思想是设置一个WorkoutPlan对象(buildingWorkout),以便让组件可以操纵锻炼的细节。startBuilding方法以锻炼名称作为参数。如果未提供名称,则意味着我们正在创建新的锻炼,因此将创建并分配一个新的WorkoutPlan对象;如果提供名称,则通过调用WorkoutService.getWorkout(name)加载锻炼详情。在任何情况下,buildingWorkout对象都包含正在进行的锻炼。
newWorkout对象表示锻炼是新的还是现有的。在调用此服务的save方法时,它用于区分保存和更新情况。
其余的方法,removeExercise,addExercise和moveExerciseTo,都是不言自明的,并影响锻炼(buildingWorkout)的一部分的锻炼列表。
WorkoutBuilderService正在调用WorkoutService上的一个新方法getWorkout,我们还没有添加。继续并从checkpoint 4.5中trainer/src下的services文件夹中的workout-service.ts文件中复制getWorkout的实现。我们不会深入讨论新服务代码,因为实现非常简单。
让我们回到左侧导航栏并实现剩余的功能。
使用 ExerciseNav 添加锻炼
要将练习添加到我们正在构建的训练中,我们只需要将WorkoutBuilderService导入并注入LeftNavExercisesComponent,然后调用它的addExercise方法,将所选的练习作为参数传递:
constructor(
public workoutService:WorkoutService,
**public workoutBuilderService:WorkoutBuilderService) {}**
. . .
addExercise(exercise:Exercise) {
**this.workoutBuilderService.addExercise(new ExercisePlan(exercise, 30));**
}
在内部,WorkoutBuilderService.addExercise会使用新的练习更新buildingWorkout模型数据。
前面的实现是共享独立组件之间数据的经典案例。共享服务以受控的方式向任何请求数据的组件公开数据。在共享数据时,最好的做法是使用方法来公开状态/数据,而不是直接公开数据对象。我们在组件和服务的实现中也可以看到这一点。LeftNavExercisesComponent并不直接更新训练数据;事实上,它并没有直接访问正在构建的训练。相反,它依赖于服务方法addExercise来改变当前训练的练习列表。
由于服务是共享的,需要注意一些潜在的问题。由于服务可以通过系统注入,我们无法阻止任何组件依赖任何服务并以不一致的方式调用其函数,导致不良结果或错误。例如,WorkoutBuilderService需要在调用addExercise之前通过调用startBuilding进行初始化。如果一个组件在初始化之前调用addExercise会发生什么?
实现训练组件
Workout组件负责管理训练。这包括创建、编辑和查看训练。由于引入了WorkoutBuilderService,这个组件的整体复杂性将会降低。除了与模板视图集成、公开和交互的主要责任外,我们将把大部分其他工作委托给WorkoutBuilderService。
Workout组件与两个路由/视图相关联,即/builder/workout/new和/builder/workout/:id。这些路由处理创建和编辑训练的情况。组件的第一个任务是加载或创建它需要操作的训练。
路由参数
但在我们开始构建WorkoutComponent及其相关视图之前,我们需要简要介绍一下将用户带到该组件屏幕的导航。该组件处理创建和编辑锻炼场景。组件的第一个任务是加载或创建它需要操作的锻炼。我们计划使用 Angular 的路由框架将必要的数据传递给组件,以便它知道它是在编辑现有的锻炼还是创建新的锻炼,并在现有的锻炼的情况下,应该编辑哪个组件。
这是如何完成的?WorkoutComponent与两个路由相关联,即/builder/workout/new和/builder/workout/:id。这两个路由的区别在于这些路由的结尾处; 在一个情况下,是/new,在另一个情况下是/:id。这些被称为路由参数。第二个路由中的:id是路由参数的一个标记。路由器将把标记转换为锻炼组件的 ID。正如我们之前看到的,这意味着在7 分钟锻炼的情况下,将传递给组件的 URL 将是/builder/workout/7MinuteWorkout。
我们如何知道这个锻炼名称是 ID 的正确参数?正如您回忆的那样,当我们设置处理锻炼屏幕上的锻炼瓷砖点击的事件时,我们将锻炼名称指定为 ID 的参数,如下所示:
onSelect(workout: WorkoutPlan) {
this.router.navigate( ['./builder/workout', workout.name] );
}
在这里,我们正在使用路由器的编程接口构建路由(我们在上一章节详细介绍了路由,所以这里不再赘述)。router.navigate方法接受一个数组。这被称为链接参数数组。数组中的第一项是路由的路径,第二项是指定锻炼的 ID 的路由参数。在这种情况下,我们将id参数设置为锻炼名称。根据我们在上一章节对路由的讨论,我们知道我们也可以构建相同类型的 URL 作为路由链接的一部分,或者简单地在浏览器中输入它以到达锻炼屏幕并编辑特定的锻炼。
另外两个路由中的一个以/new结尾。由于这个路由没有token参数,路由器将简单地将 URL 不加修改地传递给WorkoutComponent。然后,WorkoutComponent需要解析传入的 URL,以确定它应该创建一个新的组件。
路由守卫
但在链接将用户带到WorkoutComponent之前,还有另一个步骤需要考虑。始终存在一个可能性,即传递到编辑锻炼的 URL 中的 ID 可能不正确或丢失。在这些情况下,我们不希望组件加载,而是希望用户被重定向到另一个页面或返回到他们来自的地方。
Angular 提供了一种使用路由守卫来实现此结果的方法。顾名思义,路由守卫提供了一种阻止导航到路由的方法。路由守卫可用于注入自定义逻辑,可以执行诸如检查授权、加载数据和进行其他验证等操作,以确定是否需要取消导航到组件。所有这些都是在组件加载之前完成的,因此如果取消了路由,则永远不会看到该组件。
Angular 提供了几种路由守卫,包括CanActivate、CanActivateChild、CanDeActivate、Resolve和CanLoad。在这一点上,我们对第一个感兴趣:CanActivate。
实现 CanActivate 路由守卫
CanActivate守卫允许导航继续进行或根据我们提供的实现中设置的条件停止它。在我们的情况下,我们要做的是使用CanActivate来检查传递给现有锻炼的任何 ID 的有效性。具体来说,我们将通过调用WorkoutService来检查该 ID,以检索锻炼计划并查看其是否存在。如果存在,我们将允许导航继续进行;如果不存在,我们将停止它。
从checkpoint 4.5的trainer/src/components下的workout-builder/workout文件夹中复制workout.guard.ts,您将看到以下代码:
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { WorkoutPlan } from "../../../services/model";
import { WorkoutService } from "../../../services/workout-service";
@Injectable()
export class WorkoutGuard implements CanActivate {
publicworkout: WorkoutPlan;
constructor(
public workoutService: WorkoutService,
public router: Router) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
) {
this.workout = this.workoutService.getWorkout(route.params['id']);
if(this.workout){ return true; }
this.router.navigate(['/builder/workouts']);
return false;
}
}
如您所见,守卫是一个可注入的类,实现了CanActivate接口。我们使用CanActivate方法实现接口。CanActivate 方法接受两个参数;ActivatedRouteSnapshot 和 RouterStateSnapshot。在这种情况下,我们只对这两个参数中的第一个感兴趣。该参数包含一个 params 数组,我们从中提取路由的 id 参数。
CanActivate方法可以返回简单的boolean值或Observable<boolean>。如果我们需要在方法中进行异步调用,后者会很有用。如果我们返回Observable,则路由将等待异步调用解析后再继续导航。然而,在这种情况下,我们并没有进行这样的异步调用,因为我们使用的是本地内存数据存储。所以我们只是返回一个简单的 true/false boolean。
注意
在下一章中,当我们开始使用 HTTP 模块向外部数据存储进行异步调用时,我们将重构此代码以返回Observable<boolean>。
这段代码将WorkoutService注入到了守卫中。然后,CanActivate方法使用路由中提供的参数调用WorkoutService的GetWorkout方法。如果锻炼存在,则canActivate返回 true 并进行导航;如果不存在,则重新将用户重定向到锻炼页面并返回 false。
实现WorkoutGuard的最后一步是将其添加到WorkoutComponent的路由配置中。因此,按照以下方式更新workout-builder.routes.ts:
export const workoutBuilderRoutes: Routes = [
{
path: '',
component: WorkoutBuilderComponent,
children: [
{path:'', pathMatch: 'full', redirectTo: 'workouts'},
{path:'workouts', component: WorkoutsComponent },
{path:'workout/new', component: WorkoutComponent },
**{path:'workout/:id', component: WorkoutComponent,
canActivate: [WorkoutGuard] },**
{path:'exercises', component: ExercisesComponent},
{path:'exercise/new', component: ExerciseComponent },
{path:'exercise/:id', component: ExerciseComponent }
]
}
];
使用这个配置,我们将WorkoutGuard分配给WorkoutComponent路由的canActivate属性。这意味着在路由导航到WorkoutComponent之前将调用WorkoutGuard。
继续实现 Workout 组件...
现在我们已经建立了将我们带到Workout组件的路由,让我们转而完成它的实现。因此,从checkpoint 4.5中的trainer/src/components文件夹下的workout-builder/workout文件夹中复制workout.component.ts文件。(还要复制workout-builder.module.ts文件夹中的workout-builder文件夹。当我们开始使用 Angular 表单时,稍后我们将讨论此文件中的更改。)
打开workout.component.ts,你会看到我们添加了一个构造函数,用于注入ActivatedRoute和WorkoutBuilderService:
constructor(
public route: ActivatedRoute,
public workoutBuilderService:WorkoutBuilderService){ }
此外,我们添加了以下ngOnInit方法:
ngOnInit() {
this.sub = this.route.params.subscribe(params => {
let workoutName = params['id'];
if (!workoutName) {
workoutName = "";
}
this.workout = this.workoutBuilderService.startBuilding(
workoutName);
});
}
该方法订阅路由参数并提取锻炼的id参数。如果没有找到 ID,则我们将其视为新的锻炼,因为workout/new是唯一配置在WorkoutBuilderRoutes中允许在没有 ID 的情况下到达此屏幕的路径。在这种情况下,我们在调用WorkoutBuilderService的StartBuilding方法时提供一个空字符串作为参数,这将导致它返回一个新的锻炼。
注意
我们订阅路由参数,因为它们是Observables,可以在组件的生命周期内发生变化。这使我们能够重用相同的组件实例,即使该组件的OnInit生命周期事件只被调用一次。我们将在下一章节详细介绍Observables。
除了这段代码,我们还为Workout Component添加了一系列方法,用于添加、删除和移动训练。这些方法都调用了WorkoutBuilderService上对应的方法,我们不会在这里详细讨论它们。我们还添加了一个durations数组,用于填充持续时间下拉列表。
目前,这对于组件类的实现就足够了。让我们更新相关的Workout模板。
实现训练模板
现在从checkpoint 4.5的trainer/src/components下的workout-builder/workout文件夹中复制workout.component.html文件。运行应用程序,导航到/builder/workouts,双击7 Minute Workout瓷砖。这应该加载7 Minute Workout的详细信息,视图类似于构建训练部分开头显示的视图。
注意
如果出现任何问题,您可以参考GitHub 存储库:分支:checkpoint4.5(文件夹 - trainer)中的checkpoint4.5代码。
我们将花费大量时间在这个视图上,所以让我们在这里了解一些具体情况。
练习列表 div(id="exercise-list")按顺序列出了训练中的练习。我们将它们显示为内容区域左侧的从上到下的瓷砖。从功能上讲,这个模板有:
-
删除按钮,用于删除练习
-
重新排序按钮,将练习上移和下移列表,以及移到顶部和底部
我们使用ngFor来迭代练习列表并显示它们:
<div *ngFor="let exercisePlan of workout.exercises; let i=index" class="exercise-item">
您会注意到我们在ngFor前面使用了*星号,这是<template>标签的简写。我们还使用let来设置两个局部变量:exerisePlan用于标识练习列表中的项目,i用于设置一个索引值,我们将用它来在屏幕上显示练习的编号。我们还将使用索引值来管理重新排序和从列表中删除练习。
用于训练数据的第二个 div 元素(id="workout-data")包含了 HTML 输入元素,用于名称、标题和休息持续时间的详细信息,以及一个保存训练更改的按钮。
完整的列表已经包含在 HTML 表单元素中,以便我们可以利用 Angular 提供的与表单相关的功能。那么这些功能是什么呢?
Angular 表单
表单是 HTML 开发的一个重要部分,以至于任何针对客户端开发的框架都不能忽视它们。Angular 提供了一组小而明确定义的构造,使标准的基于表单的操作更容易。
如果我们仔细思考,任何形式的交互都归结为:
-
允许用户输入
-
根据业务规则验证这些输入
-
将数据提交到后端服务器
对于所有前述的用例,Angular 都有一些东西可以提供。
用户输入允许我们在表单输入元素和底层模型之间创建双向绑定,从而避免我们可能需要为模型输入同步编写的任何样板代码。
它还提供了在提交之前验证输入的构造。
最后,Angular 提供了用于客户端-服务器交互和将数据持久化到服务器的http服务。我们将在第五章中介绍这些服务,支持服务器数据持久化。
由于前两个用例是本章的主要重点,让我们更多地了解一下 Angular 用户输入和数据验证支持。
模板驱动和模型驱动表单
Angular 提供两种类型的表单:模板驱动和模型驱动。我们将在本章讨论这两种类型的表单。因为 Angular 团队表示我们中的许多人主要会使用模板驱动表单,这就是我们将在本章开始讨论的内容。
模板驱动表单
正如其名称所示,模板驱动表单侧重于在 HTML 模板中开发表单,并处理大部分与表单输入、数据验证、保存和更新相关的逻辑-在该模板中放置的表单指令中。结果是与与表单相关的代码在与表单模板相关联的组件类中所需的非常少。
模板驱动表单大量使用ngModel表单指令。我们将在接下来的部分讨论它。它为表单控件提供了双向数据绑定,这确实是一个很好的功能。它允许我们编写更少的样板代码来实现一个表单。它还帮助我们管理表单的状态(例如,表单控件是否已更改以及这些更改是否已保存)。它还使我们能够轻松构建消息,显示如果表单控件的验证要求未满足(例如,未提供必填字段,电子邮件格式不正确等)。
入门
为了在我们的Workout组件中使用 Angular 表单,我们必须首先添加一些额外的配置。首先,打开checkpoint 4.5中trainer文件夹中的systemjs.config.js文件,并将表单添加到ngPackageNames数组中:
var ngPackageNames = [
'common',
'compiler',
'core',
**'forms',**
'http',
'platform-browser',
'platform-browser-dynamic',
'router',
'testing'
];
有了这个,SystemJS 将下载这个模块供我们的应用程序使用。
接下来,打开checkpoint 4.5中trainer/src/components下workout-builder文件夹中的workout-buider.module.ts的副本。您将看到它添加了以下突出显示的代码:
@NgModule({
imports: [
CommonModule,
**FormsModule,**
SharedModule,
workoutBuilderRouting
],
这表明我们将使用表单模块。一旦我们做出这个改变,我们将不必在Workout组件中进行与表单相关的进一步导入。
这引入了我们实现表单所需的所有指令,包括:
-
NgForm -
ngModel
让我们开始使用这些来构建我们的表单。
使用 NgForm
在我们的模板中,我们添加了以下form标签:
<form #f="ngForm" class="row" name="formWorkout" (ngSubmit)="save(f.form)" novalidate>. . .
</form>
让我们看看我们这里有什么。一个有趣的事情是,我们仍然使用标准的<form>标签,而不是特殊的 Angular 标签。我们还使用#来定义一个本地变量#f,我们已经分配了ngForm。创建这个本地变量使我们能够在表单内的其他地方使用它进行与表单相关的活动。例如,您可以看到我们在开放的form标签的末尾使用它作为参数,f.form,它被传递给绑定到(ngSubmit)的onSubmit事件。
最后绑定到(ngSubmit)的内容应该告诉我们这里发生了一些不同的事情。即使我们没有明确添加NgForm指令,我们的<form>现在有了额外的事件,比如ngSubmit,我们可以将动作绑定到这些事件上。这是怎么发生的呢?嗯,这并不是因为我们将ngForm分配给了一个本地变量。相反,这是自动发生的,因为我们在workout-builder.module.ts中导入了表单模块。
有了这个导入,Angular 扫描我们的模板,找到了一个<form>标签,并将该<form>标签包装在NgForm指令中。Angular 文档表明,组件中的<form>元素将升级为使用 Angular 表单系统。这很重要,因为这意味着NgForm指令的各种功能现在可以与表单一起使用。其中包括ngSubmit事件,该事件在用户触发表单提交时发出信号,并提供在提交之前验证整个表单的能力。
ngModel
模板驱动表单的基本构建块之一是ngModel,你会发现它在我们的表单中被广泛使用。ngModel的主要作用之一是支持用户输入和底层模型之间的双向绑定。有了这样的设置,模型中的更改会反映在视图中,视图的更新也会反映在模型上。到目前为止,我们所涵盖的大多数其他指令只支持从模型到视图的单向绑定。这也是因为ngModel仅应用于允许用户输入的元素。
正如你所知,我们已经有一个模型,我们正在用于Workout页面-WorkoutPlan。这是model.ts中的WorkoutPlan模型:
@Injectable()
export class WorkoutPlan {
constructor(
public name: string,
public title: string,
public restBetweenExercise: number,
public exercises: ExercisePlan[],
public description?: string) {
}
totalWorkoutDuration(): number{
. . . . . .
}
注意在description后面使用了?。这意味着它是我们模型中的一个可选属性,不需要创建WorkoutPlan。在我们的表单中,这意味着我们不需要输入描述,一切都可以正常工作。
在WorkoutPlan模型中,我们还引用了由另一种类型的模型实例组成的数组:ExercisePlan。ExercisePlan又由一个数字(duration)和另一个模型(Exercise)组成,看起来像这样:
@Injectable()
export class ExercisePlan {
constructor(public exercise: Exercise, public duration: any) {
}
}
请注意,我们已经用@Injectable装饰了两个模型类。这是为了让 TypeScript 为整个对象层次结构生成必要的元数据,即WorkoutPlan中的嵌套类ExercisePlan和ExercisePlan中的Exercise。这意味着我们可以创建复杂的模型层次结构,所有这些模型都可以在我们的表单中使用NgModel进行数据绑定。
因此,在整个表单中,每当我们需要更新WorkoutPlan或ExercisePlan中的一个值时,我们可以使用NgModel来实现(在以下示例中,WorkoutPlan模型将由一个名为workout的局部变量表示)。
使用 ngModel 与输入和文本区域。
打开workout-component.html并查找ngModel。这里,它只应用于允许用户输入数据的 HTML 元素。这些包括 input、textarea 和 select。练习名称输入设置如下:
<input type="text" name="workoutName" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name">
前面的[(ngModel)]指令建立了输入控件和workout.name模型属性之间的双向绑定。方括号和括号应该都很熟悉。以前,我们将它们分开使用:[]方括号用于属性绑定,()括号用于事件绑定。在后一种情况下,我们通常将事件绑定到与模板关联的组件中的一个方法的调用。您可以在表单中看到这种情况的一个例子,其中用户单击按钮以删除一个练习:
<div class="pull-right" (click)="removeExercise(exercisePlan)"><span class="glyphicon glyphicon-trash"></span></div>
在这里,点击事件明确绑定到了Workout组件类中的一个名为removeExercise的方法。但是对于workout.name输入,我们没有将其明确绑定到组件上的一个方法。那么这里发生了什么,更新是如何进行的,而不需要我们在组件上调用一个方法呢?这个问题的答案是,组合[( )]是将模型属性绑定到输入元素并连接更新模型的事件的速记方式。
换句话说,如果我们在表单中引用一个模型元素,ngModel足够聪明,知道我们想要做的是在用户输入或更改与其绑定的输入字段中的数据时更新该元素(这里是workout.name)。在幕后,Angular 创建了一个更新方法,类似于我们否则必须自己编写的方法。很好!这种方法使我们不必编写重复的代码来更新我们的模型。
Angular 支持大多数 HTML5 输入类型,包括文本、数字、选择、单选和复选框。这意味着模型与任何这些输入类型之间的绑定都可以直接使用。
textarea元素的工作方式与输入框相同:
<textarea name="description" . . . [(ngModel)]="workout.description"></textarea>
在这里我们将textarea绑定到workout.description。在幕后,ngModel会随着我们在文本框中输入的每一次更改而更新我们模型中的锻炼描述。
为了测试这个工作原理,为什么我们不验证一下这个绑定呢?在任何一个链接输入中添加一个模型插值表达式,比如这样一个:
<input type="text". . . [(ngModel)]="workout.name">{{workout.name}}
打开锻炼页面,在输入框中输入一些内容,看看插值是如何立即更新的。双向绑定的魔力!
使用ngModel与选择
让我们看看选择是如何设置的:
<select . . . name="duration" [(ngModel)]="exercisePlan.duration">
<option *ngFor="let duration of durations" [value]="duration.value">{{duration.title}}</option>
</select>
我们在这里使用ngFor来绑定到一个数组durations,它在Workout组件类中。数组看起来是这样的:
[{ title: "15 seconds", value: 15 },
{ title: "30 seconds", value: 30 }, ...]
ngFor组件将循环遍历数组,并使用插值将下拉框的值与数组中的相应值填充,每个项目的标题都使用插值显示--{{duration.title}}。然后[(ngModel)]将下拉框选择绑定到模型中的exercisePlan.duration。请注意,这里我们绑定到了嵌套模型:ExercisePlan。这是一个强大的功能,使我们能够创建具有嵌套模型的复杂表单,所有这些都可以使用ngModel进行数据绑定。
与输入框一样,选择也支持双向绑定。我们看到改变选择会更新模型,但是从模型到模板的绑定可能不太明显。为了验证模型到模板的绑定是否有效,请打开7 分钟锻炼应用程序并验证持续时间下拉框。每个下拉框的值都与模型值(30 秒)一致。
Angular 通过使用ngModel来保持模型和视图同步做得非常棒。改变模型,看到视图更新;改变视图,观察模型立即更新。
现在让我们给表单添加验证。
注意
该代码也可供所有人在 GitHub 上下载:github.com/chandermani/angular2byexample。检查点在 GitHub 上作为分支实现。要下载的分支如下:GitHub 分支:checkpoint4.6(文件夹 - trainer)。或者,如果您不使用 Git,请从以下 GitHub 位置下载 Checkpoint 4.6 的快照(ZIP 文件):github.com/chandermani/angular2byexample/archive/checkpoint4.5.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次强调,如果您正在与我们一起构建应用程序,请确保更新app.css文件,这里我们不讨论。
Angular 验证
俗话说,“不要相信用户输入”。Angular 支持验证,包括标准的 required、min、max 和 pattern,以及自定义验证器。
ngModel
ngModel是我们用来实现验证的基本组件。它为我们做了两件事:维护模型状态,并提供一种识别验证错误并显示验证消息的机制。
要开始,我们需要在所有需要验证的表单控件中将ngModel赋值给一个本地变量。在每种情况下,我们需要为这个本地变量使用一个唯一的名称。例如,对于锻炼名称,我们在该控件的input标签中添加#name="ngModel"。现在,锻炼名称的input标签应该是这样的:
<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required>
继续通过表单,为每个输入将ngModel分配给本地变量。还为所有必填字段添加required属性。
Angular 模型状态
每当我们使用NgForm时,表单中的每个元素,包括输入、文本区域和选择,都有与关联模型定义的一些状态。ngModel为我们跟踪这些状态。跟踪的状态有:
-
原始的:只要用户不与输入交互,这个值就是true。对input字段进行任何更新,ng-pristine就会被设置为false。 -
脏的:这是ng-pristine的相反。当输入数据已经更新时,这个值就是true。 -
触摸的:如果控件曾经获得焦点,这个值就是true。 -
未触摸的:如果控件从未失去焦点,这个值就是true。这只是ng-touched的相反。 -
valid:如果在input元素上定义了验证且它们都没有失败,则为true。 -
invalid:如果元素上定义的任何验证失败,则为true。
pristine`dirty或touched\untouched`是有用的属性,可以帮助我们决定何时显示错误标签。
Angular CSS 类
根据模型状态,Angular 会向输入元素添加一些 CSS 类。这些包括以下内容:
-
ng-valid:如果模型有效,则使用此项 -
ng-invalid:如果模型无效,则使用此项 -
ng-pristine:如果模型是原始的,则使用此项 -
ng-dirty:如果模型是脏的,则使用此项 -
ng-untouched:当输入从未被访问时使用此项 -
ng-touched:当输入获得焦点时使用此项
要验证它,返回到workoutName输入标签,并在input标签内部添加一个模板引用变量命名为spy:
<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required #spy>
然后,在标签下面,添加以下标签:
<label>{{spy.className}}</label>
重新加载应用程序,然后单击Workout Builder中的New Workout链接。在屏幕上什么都不触摸之前,您将看到以下内容显示:
在Name输入框中添加一些内容并切换到其他地方。标签会变成这样:
这里我们看到的是 Angular 随着用户与其交互而改变应用于该控件的 CSS 类。您还可以通过检查开发者控制台中的input元素来看到这些变化。
如果我们想要根据其状态向元素应用视觉提示,这些 CSS 类转换非常有用。例如,看一下这个片段:
input.ng-invalid { border:2px solid red; }
这会在任何具有无效数据的输入控件周围绘制红色边框。
当您向 Workout 页面添加更多验证时,您可以观察(在开发者控制台中)用户与input元素交互时这些类是如何添加和移除的。
现在我们已经了解了模型状态以及如何使用它们,让我们回到验证的讨论(在继续之前,删除您刚刚添加的变量名和标签)。
训练验证
需要对训练数据进行多种条件的验证。
在为我们的input字段添加ngModel的本地变量引用和必需属性之后,我们已经能够看到ngModel如何跟踪这些控件状态的变化以及如何切换 CSS 样式。
显示适当的验证消息
现在输入需要有一个值;否则,验证失败。但是我们如何知道验证失败了呢?ngModel在这里帮了我们。它可以提供特定输入的验证状态。这给了我们显示适当验证消息所需的内容。
让我们回到锻炼名称的输入控件。为了显示验证消息,我们必须首先修改输入标签如下:
<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" required>
我们已经添加了一个名为#name的本地变量,并将ngModel分配给它。这被称为模板引用变量,我们可以将其与以下标签一起使用,以显示输入的验证消息:
<label *ngIf="name.control.hasError('required') && (name.touched)" class="alert alert-danger validation-message">Name is required</label>
我们在事件中显示验证消息,如果名称未提供且控件尚未被触摸。为了检查第一个条件,我们检索控件的hasError属性,并查看错误类型是否为required。我们检查名称输入是否已被touched,因为我们不希望在表单首次加载新锻炼时显示消息。
注意
您会注意到,我们在识别验证错误方面使用了一种更加冗长的风格,这比在这种情况下所需的要多。我们可以使用name.control.hasError('required'),但使用!name. valid也可以完美地工作。然而,使用更冗长的方法允许我们更具体地识别验证错误,这在我们开始向表单控件添加多个验证器时将是至关重要的。我们将在本章稍后看一下使用多个验证器。为了保持一致,我们将坚持使用更冗长的方法。
现在加载新的锻炼页面(/builder/workouts/new)。在名称输入框中输入一个值,然后删除它。错误标签将如下截图所示出现:
添加更多验证
Angular 提供了四个开箱即用的验证器:
-
required -
minLength -
maxLength -
pattern
我们已经看到了required验证器的工作原理。现在让我们看看另外两个开箱即用的验证器:minLength和maxLength。除了使其必填外,我们希望锻炼的标题在 5 到 20 个字符之间(我们将在本章稍后看一下pattern验证器)。
因此,除了我们之前添加到标题输入框的 required 属性之外,我们还将添加 minLength 属性并将其设置为 5,并添加 maxLength 属性并将其设置为 20,如下所示:
<input type="text" . . . minlength="5" maxlength="20" required>
然后,我们添加另一个标签,当此验证未满足时将显示消息:
<label *ngIf="(title.control.hasError('minlength') || title.control.hasError('maxlength')) && workout.title.length > 0" class="alert alert-danger validation-message">Title should be between 5 and 20 characters long.</label>
管理多个验证消息
您将看到显示消息的条件现在测试长度不为零。这可以防止在控件被触摸但保持空白时显示消息。在这种情况下,标题必填消息应该显示。只有在字段中没有输入任何内容时,此消息才会显示,我们通过明确检查控件的 hasError 类型是否为 required 来实现这一点:
<label *ngIf="title.control.hasError('required')" class="alert alert-danger validation-message">Title is required.</label>
由于我们正在为此输入字段附加两个验证器,我们可以通过将两个验证器包装在一个检查该条件是否满足的 div 标签中来合并检查输入是否被触摸:
<div *ngIf="title.touched">
. . . [the two validators] . . .
</div>
注意
我们刚刚展示了如何将多个验证附加到单个输入控件,并在验证条件不满足时显示适当的消息。然而,很明显,这种方法在更复杂的场景下不会“扩展”。一些输入包含许多验证,并且控制验证消息何时显示可能变得复杂。随着处理各种显示的表达式变得更加复杂,我们可能希望重构并将它们移入自定义指令中。如何创建自定义指令将在第六章中详细介绍,《深入解析 Angular 2 指令》。
自定义验证消息用于练习
没有任何练习的锻炼是没有用的。锻炼中至少应该有一个练习,我们应该验证这一限制。
运动次数验证的问题在于它不是用户直接输入并由框架验证的内容。尽管如此,我们仍希望以类似于表单上其他验证的方式验证运动次数。
我们将在表单中添加一个包含练习计数的隐藏输入框。然后,我们将将其绑定到 ngModel 并添加一个模式验证器,以确保有多于一个练习。我们将设置输入框的值为练习的计数:
<input type="hidden" name="exerciseCount" #exerciseCount="ngModel" ngControl="exerciseCount" class="form-control" id="exercise-count" [(ngModel)]="workout.exercises.length" pattern="[1-9][0-9]*">
然后我们将为其附加一个类似于我们刚刚使用其他验证器的验证消息:
<label *ngIf="exerciseCount.control.hasError('pattern')" class="alert alert-danger extended-validation-message">The workout should have at least one exercise!</label>
我们在这里并没有真正使用ngModel。这里没有涉及双向绑定。我们只对使用它进行自定义验证感兴趣。
打开新的 Workout 页面,添加一个练习,然后将其删除;我们应该看到错误:
我们在这里所做的事情本来可以很容易地在不涉及任何模型验证基础设施的情况下完成。但是通过将我们的验证与该基础设施连接起来,我们确实获得了一些好处。现在,我们可以以一种一致和熟悉的方式确定特定模型的错误以及整个表单的错误。最重要的是,如果我们的验证在这里失败,整个表单将无效。
注意
实现自定义验证的方式通常不是您经常想要做的。相反,通常更合理的做法是在自定义指令中实现这种复杂逻辑。我们将在第六章中详细介绍创建自定义指令,Angular 2 指令深入解析。
我们新实现的Exercise Count验证的一个麻烦之处是,当新的Workout屏幕首次出现时会显示。对于这条消息,我们无法使用ng-touched来隐藏显示。这是因为练习是以编程方式添加的,并且我们使用来跟踪它们数量的隐藏输入在添加或删除练习时从未改变过未触摸状态。
为了解决这个问题,当练习列表的状态被减少到零时,我们需要一个额外的值来检查,除非表单首次加载。这种情况发生的唯一方式是用户添加然后从锻炼中删除练习,直到没有更多的练习为止。因此,我们将在组件中添加另一个属性,用于跟踪删除方法是否已被调用。我们称这个值为removeTouched,并将其初始值设置为false:
removeTouched: boolean = false;
然后在删除方法中,我们将该值设置为true:
removeExercise(exercisePlan: ExercisePlan) {
this.removeTouched = true;
this.workoutBuilderService.removeExercise(exercisePlan);
}
接下来,我们将在验证消息条件中添加removeTouched,如下所示:
<label *ngIf="exerciseCount.control.hasError('pattern') && (removeTouched)"
现在,当我们打开一个新的锻炼屏幕时,验证消息将不会显示。但是如果用户添加然后删除所有练习,那么它将显示。
为了理解模型验证如何转化为表单验证,我们需要了解表单级验证提供了什么。然而,甚至在此之前,我们需要实现保存锻炼并从锻炼表单中调用它。
保存锻炼
我们正在构建的锻炼需要被持久化(仅在内存中)。我们需要做的第一件事是扩展WorkoutService和WorkoutBuilderService。
WorkoutService需要两个新方法:addWorkout和updateWorkout:
addWorkout(workout: WorkoutPlan){
if (workout.name){
this.workouts.push(workout);
return workout;
}
}
updateWorkout(workout: WorkoutPlan){
for (var i = 0; i < this.workouts.length; i++) {
if (this.workouts[i].name === workout.name) {
this.workouts[i] = workout;
break;
}
}
}
addWorkout方法对锻炼名称进行基本检查,然后将锻炼推入锻炼数组中。由于没有涉及后备存储,如果我们刷新页面,数据就会丢失。我们将在下一章中修复这个问题,将数据持久化到服务器。
updateWorkout方法在现有的锻炼数组中查找具有相同名称的锻炼,如果找到,则更新并替换它。
我们只向WorkoutBuilderService添加一个保存方法,因为我们已经在跟踪进行锻炼构建的上下文:
save(){
let workout = this.newWorkout ?
this._workoutService.addWorkout(this.buildingWorkout) :
this._workoutService.updateWorkout(this.buildingWorkout);
this.newWorkout = false;
return workout;
}
save方法根据是创建新锻炼还是编辑现有锻炼,调用Workout服务中的addWorkout或updateWorkout方法。
从服务的角度来看,这应该足够了。是时候将保存锻炼的能力集成到Workout组件中,并了解更多关于表单指令的知识!
在更详细地查看NgForm之前,让我们向Workout添加保存方法,以便在单击“保存”按钮时保存锻炼。将以下代码添加到Workout组件中:
save(formWorkout:any){
if (!formWorkout.valid) return;
this.workoutBuilderService.save();
this.router.navigate(['/builder/workouts']);
}
我们使用其无效属性来检查表单的验证状态,然后如果表单状态有效,调用WorkoutBuilderService.save方法。
关于 NgForm 的更多信息
在 Angular 中,表单的角色与将数据提交到服务器的传统表单有所不同。如果我们回过头再看一下表单标签,我们会发现它缺少标准的 action 属性。表单指令上的novalidate属性告诉浏览器不要进行内置输入验证(这不是特定于 Angular 的,而是 HTML 5 属性)。
使用全页回传的标准表单行为在 Angular 这样的 SPA 框架中是没有意义的。在 Angular 中,所有服务器请求都是通过指令或服务发起的异步调用。
这里的表单扮演了不同的角色。当表单封装一组输入元素(例如输入、文本区域和选择)时,它提供了一个 API:
-
确定表单的状态,例如基于其输入控件的脏或原始状态
-
在表单或控件级别检查验证错误
注意
如果您仍希望使用标准表单行为,可以添加一个ngNoForm属性,但这肯定会导致整个页面刷新。当我们查看保存表单和实现验证时,我们将在本章稍后探讨NgForm API 的具体内容。
表单内的FormControl对象的状态由NgForm监视。如果其中任何一个无效,那么NgForm会将整个表单设置为无效。在这种情况下,我们已经能够使用NgForm确定一个或多个FormControl对象无效,因此整个表单的状态也是无效的。
在我们完成本章之前,让我们再看一个问题。
修复表单保存和验证消息
打开一个新的锻炼页面,直接单击保存按钮。由于表单无效,所以什么都没有保存,但是单个表单输入的验证根本不显示出来。现在很难知道是哪些元素导致了验证失败。这种行为背后的原因非常明显。如果我们看一下名称输入元素的错误消息绑定,它看起来是这样的:
*ngIf="name.control?.hasError('required') && name.touched"
请记住,在本章的早些时候,我们明确禁用了在用户触摸输入控件之前显示验证消息。同样的问题又回来找我们了,现在我们需要解决它。
我们没有办法明确地将控件的触摸状态更改为未触摸。相反,我们将采取一些小技巧来完成这项工作。我们将在Workout类定义的顶部引入一个名为submitted的新属性,并将其初始值设置为false,如下所示:
submitted: boolean = false;
变量将在单击“保存”按钮时设置为 true。通过添加突出显示的代码来更新保存实现:
save(formWorkout){
**this.submitted = true;**
if (!formWorkout.valid) return;
this._workoutBuilderService.save();
this.router.navigate(['/builder/workouts']);
}
尽管如此,这有什么帮助呢?好吧,这个修复的另一部分需要我们更改每个正在验证的控件的错误消息。表达式现在变成了:
*ngIf="name.control.hasError('required') && (name.touched || submitted)"
通过这个修复,当控件被触摸或表单提交按钮被按下(submitted为true)时,错误消息将被显示。现在,这个表达式修复现在必须应用于每个验证消息,其中出现了检查。
如果我们现在打开新的Workout页面并点击保存按钮,我们应该能够在输入控件上看到所有的验证消息:
模型驱动表单
Angular 支持的另一种表单类型称为模型驱动表单。顾名思义,模型驱动表单从在组件类中构建的模型开始。通过这种方法,我们使用表单构建器 API在代码中创建一个表单并将其与模型关联起来。
鉴于我们必须编写的最少代码来使模板驱动表单工作,为什么以及何时应该考虑使用模型驱动表单?有几种情况下我们可能想要使用它们。这些情况包括我们想要以编程方式控制创建表单的情况。正如我们将看到的那样,这是特别有益的,当我们试图根据从服务器检索的数据动态创建表单控件时。
如果我们的验证变得复杂,通常更容易在代码中处理。使用模型驱动表单,我们可以将这种复杂的逻辑从 HTML 模板中剥离出来,使模板语法更简单。
模型驱动表单的另一个重要优势是,它使得对表单进行单元测试成为可能,而这在模板驱动表单中是不可能的。我们可以简单地在测试中实例化我们的表单控件,然后在页面上的标记之外对它们进行测试。
模型驱动表单使用了三个我们之前没有讨论过的新表单指令:FormGroup、FormControl和FormArray。这些指令允许在代码中构建的表单对象直接与模板中的 HTML 标记绑定。在组件类中创建的表单控件可以直接在表单中使用。从技术上讲,这意味着我们不需要在模型驱动表单中使用ngModel(这是模板驱动表单的一部分),尽管它可以使用。总体上,这种方法可以使模板更清洁、更简洁,更专注于驱动表单的代码。让我们开始构建一个模型驱动表单。
开始使用模型驱动表单。
我们将利用模型驱动表单来构建添加和编辑练习的表单。除其他外,该表单将允许用户添加 YouTube 上的练习视频链接。由于他们可以添加任意数量的视频链接,我们需要能够动态添加这些视频链接的控件。这个挑战将是模型驱动表单在开发更复杂表单时的有效性的一个很好的测试。这就是表单的样子:
要开始,请打开workout-builder.module.ts并添加以下import:
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
...
@NgModule({
imports: [
CommonModule,
FormsModule,
**ReactiveFormsModule,**
SharedModule,
workoutBuilderRouting
],
ReactiveFormsModule包含了我们构建模型驱动表单所需的内容。
接下来,从checkpoint 4.6的trainer/src/components文件夹下的workout-builder/builder-services文件夹中复制exercise-builder-service.ts并将其导入到workout-builder.module.ts中:
import { ExerciseBuilderService } from "./builder-services/exercise-builder-service";
然后将其作为提供者添加到同一文件中的提供者数组中:
@NgModule({
. . .
providers: [
**ExerciseBuilderService,**
ExerciseGuard,
WorkoutBuilderService,
WorkoutGuard
]
})
注意
您会注意到我们还将ExerciseGuard添加为提供者。我们不会在这里涵盖它,但您也应该从exercise文件夹中复制它,并复制更新后的workout-builder.routes.ts,将其添加为导航到ExerciseComponent的路由守卫。
现在打开exercise.component.ts并在文件顶部添加以下导入语句:
import { Validators, FormArray, FormGroup, FormControl, FormBuilder } from '@angular/forms';
这将引入以下内容,我们将使用它来构建我们的表单:
-
FormBuilder -
FormGroup -
FormControl -
FormArray
最后,我们将FormBuilder(以及Router,ActivatedRoute和ExerciseBuilderService)注入到我们类的构造函数中:
constructor(
public route: ActivatedRoute,
public router: Router,
public exerciseBuilderService:ExerciseBuilderService,
public formBuilder: FormBuilder
){}
完成这些初步步骤后,我们现在可以开始构建我们的表单了。
使用 FormBuilder API
FormBuilder API 是模型驱动表单的基础。您可以将其视为一个工厂,用于生产我们在代码中构建的表单。继续并将ngOnInit生命周期钩子添加到您的类中,如下所示:
ngOnInit():any{
this.sub = this.route.params.subscribe(params => {
let exerciseName = params['id'];
if (exerciseName === 'new') {
exerciseName = "";
}
this.exercise = this.exerciseBuilderService.startBuilding(exerciseName);
});
this.buildExerciseForm();
}
当ngOnInit触发时,它将调用一个用于构建我们的表单的方法(除了设置我们正在构建的练习)。因此,在组件生命周期的这个阶段,我们正在开始在代码中构建我们的表单的过程。
现在让我们通过添加以下代码来实现buildExerciseForm方法:
buildExerciseForm(){
this.exerciseForm = this.formBuilder.group({
'name': [this.exercise.name, [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]],
'title': [this.exercise.title, Validators.required],
'description': [this.exercise.description, Validators.required],
'image': [this.exercise.image, Validators.required],
'nameSound': [this.exercise.nameSound],
'procedure': [this.exercise.procedure],
'videos': this.addVideoArray()
})
}
让我们来看看这段代码。首先,我们使用注入的FormBuilder实例来构建表单,并将其分配给一个本地变量exerciseForm。使用formBuilder.group,我们向我们的表单添加了几个表单控件。我们通过简单的键/值映射添加了每一个:
'name': [this.exercise.name, Validators.required],
映射的左侧是FormControl的名称,右侧是一个数组,其第一个元素是控件的值(在我们的情况下,是我们练习模型上对应的元素),第二个是验证器(在这种情况下是现成的必填验证器)。清晰明了!在模板外设置它们确实更容易看到和理解我们的表单控件。
我们不仅可以以这种方式在我们的表单中构建FormControls,还可以添加包含其中的FormControls的FormControlGroups和FormControlArray。这意味着我们可以创建包含嵌套输入控件的复杂表单。在我们的情况下,正如我们已经提到的,我们需要考虑用户向练习添加多个视频的可能性。我们可以通过添加以下代码来实现这一点:
'videos': this.addVideoArray()
我们在这里做的是将一个FormArray分配给视频,这意味着我们可以在这个映射中分配多个控件。为了构建这个新的FormArray,我们在我们的类中添加了以下addVideoArray方法:
addVideoArray(){
if(this.exercise.videos){
this.exercise.videos.forEach((video : any) => {
this.videoArray.push(new FormControl(video, Validators.required));
});
}
return this.videoArray;
}
这个方法为每个视频构造一个FormControl;然后将每个视频添加到我们表单中的视频控件中的FormArray中。
将表单模型添加到我们的 HTML 视图
到目前为止,我们一直在我们的类中幕后工作来构建我们的表单。下一步是将我们的表单与视图连接起来。为此,我们使用了与我们在代码中构建表单时使用的相同控件:formGroup、formControl和formArray。
打开exercise.component.html并添加以下form标签:
<form [formGroup]="exerciseForm" (ngSubmit)="onSubmit(exerciseForm)" novalidate>
在标签中,我们首先将我们刚刚在代码中构建的exerciseForm分配给formGroup。这建立了我们编码模型与视图中表单之间的连接。我们还将ngSubmit事件与我们代码中的onSubmit方法连接起来(稍后我们将讨论这个方法)。最后,我们使用novalidate关闭浏览器的表单验证。
向我们的表单输入添加表单控件
接下来,我们开始构建表单的输入。我们将从我们的练习名称输入开始:
<input name="name" formControlName="name" class="form-control" id="name" placeholder="Enter exercise name. Must be unique.">
我们将我们编码的表单控件的名称分配给formControlName。这在我们的代码中的控件与标记中的input字段之间建立了链接。这里的另一个有趣的地方是我们不使用required属性。
添加验证
我们接下来要做的事情是向控件添加一个验证消息,以在验证错误发生时显示:
<label *ngIf="exerciseForm.controls.name.hasError('required') && (exerciseForm.controls.name.touched || submitted)" class="alert alert-danger validation-message">Name is required</label>
请注意,这个标记与我们在模板驱动表单中用于验证的标记非常相似,只是用于识别控件的语法有点更冗长。同样,它检查控件的hasError属性的状态,以确保它是有效的。
但等一下!我们如何验证这个输入?我们不是已经从标签中删除了required属性吗?这就是我们在代码中添加的控件映射发挥作用的地方。如果你回顾一下表单模型的代码,你会看到以下映射适用于name控件:
'name': [this.exercise.name, Validators.required],
映射数组中的第二个元素将必填验证器分配给名称表单控件。这意味着我们不必在模板中添加任何内容;相反,表单控件本身附加到了具有必填验证器的模板。在我们的代码中添加验证器的能力使我们能够方便地在模板之外添加验证器。当涉及编写具有复杂逻辑的自定义验证器时,这是特别有用的。
添加动态表单控件
正如我们之前提到的,我们正在构建的练习表单要求我们允许用户向练习中添加一个或多个视频。由于我们不知道用户可能想要添加多少个视频,我们将不得不在用户点击添加视频按钮时动态构建这些视频的input字段。它将是这样的:
我们已经在组件类中看到了用于执行此操作的代码。现在让我们看看它是如何在我们的模板中实现的。
我们首先使用ngFor循环遍历我们的视频列表。然后我们将视频的索引分配给一个本地变量i。到目前为止没有什么意外的:
<div *ngFor="let video of videoArray.controls; let i=index" class="form-group">
在循环内部,我们做三件事。首先,我们添加一个按钮,允许用户删除视频:
<button type="button" (click)="deleteVideo(i)" class="btn alert-danger pull-right">
<span class="glyphicon glyphicon-trash text-danger"></span>
</button>
我们将组件类中的deleteVideo方法绑定到按钮的click事件,并将视频的索引传递给它。
接下来,我们为每个当前练习中的视频动态添加一个视频input字段:
<input type="text" class="form-control" [formControlName]="i" placeholder="Add a related youtube video identified."/>
然后为每个视频input字段添加验证消息。
<label *ngIf="exerciseForm.controls['videos'].controls[i].hasError('required') && (exerciseForm.controls['videos'].controls[i].touched || submitted)" class="alert alert-danger validation-message">Video identifier is required</label>
验证消息遵循本章其他地方使用的显示消息的相同模式。我们深入到exerciseFormControls组中,通过其索引找到特定的控件。再次,语法冗长但足够容易理解。
保存表单
构建模型驱动表单的最后一步是处理保存表单。当我们之前构建表单标签时,我们将ngSubmit事件绑定到我们代码中的以下onSubmit方法:
onSubmit(formExercise:FormGroup){
this.submitted = true;
if (!formExercise.valid) return;
this.mapFormValues(formExercise);
this.exerciseBuilderService.save();
this.router.navigate(['/builder/exercises']);
}
这种方法将submitted设置为true,这将触发显示任何可能之前因为表单未被触摸而被隐藏的验证消息。如果表单上有任何验证错误,它也会返回而不保存。如果没有错误,它将调用以下mapFormValues方法,该方法将表单中的值分配给将要保存的exercise:
mapFormValues(form: FormGroup){
this.exercise.name = form.controls['name'].value;
this.exercise.title = form.controls['title'].value;
this.exercise.description = form.controls['description'].value;
this.exercise.image = form.controls['image'].value;
this.exercise.nameSound = form.controls['nameSound'].value;
this.exercise.procedure = form.controls['procedure'].value;
this.exercise.videos = form.controls['videos'].value;
}
然后调用ExerciseBuilderService中的保存方法,并将用户路由回练习列表屏幕(请记住,任何新练习都不会显示在该列表中,因为我们尚未在应用程序中实现数据持久性)。
我们希望这一点很清楚;当我们试图构建更复杂的表单时,模型驱动的表单提供了许多优势。它们允许将编程逻辑从模板中移除。它们允许以编程方式向表单添加验证器。它们支持在运行时动态构建表单。
自定义验证器
现在在我们结束本章之前,让我们再看一件事。任何在构建 Web 表单(无论是在 Angular 还是其他 Web 技术中)上工作过的人都知道,我们经常被要求创建特定于我们正在构建的应用程序的验证。Angular 为我们提供了灵活性,通过构建自定义验证器来增强我们的模型驱动表单验证。
在构建我们的练习表单时,我们需要确保输入的内容,因为名称只包含字母数字字符,不包含空格。这是因为当我们开始将练习存储在远程数据存储中时,我们将使用练习的名称作为其键。因此,除了标准的必填字段验证器之外,让我们构建另一个验证器,以确保输入的名称只是字母数字形式。
创建自定义控件非常简单。在其最简单的形式中,Angular 自定义验证器是一个函数,它以控件作为输入参数,运行验证检查,并返回 true 或 false。所以让我们首先添加一个名为alphanumeric-validator.ts的 TypeScript 文件。在该文件中,首先从@angular/forms中导入FormControl;然后将以下类添加到该文件中:
export class AlphaNumericValidator {
static invalidAlphaNumeric(control: FormControl):{ [key:string]:boolean } {
if ( control.value.length && !control.value.match(/^[a-z0-9]+$/i) ){
return {invalidAlphaNumeric: true };
}
return null;
}
}
代码遵循了我们刚提到的创建验证器的模式。唯一可能有点令人惊讶的是,当验证失败时它返回 true!只要你清楚了这个怪癖,你应该没有问题编写自己的自定义验证器。
将自定义验证器整合到我们的表单中
那么我们如何将我们的自定义验证器插入到我们的表单中呢?如果我们使用模型驱动表单,答案非常简单。当我们在代码中构建我们的表单时,我们就像添加内置验证器一样添加它。让我们这样做。打开exercise.component.ts并首先为我们的自定义验证器添加一个导入:
import {AlphaNumericValidator} from "./alphanumeric-validator";
然后,修改表单构建器代码,将验证器添加到name控件中:
buildExerciseForm(){
this.exerciseForm = this._formBuilder.group({
'name': [this.exercise.name, [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]],
. . . [other form controls] . . .
});
}
由于名称控件已经有一个必填验证器,我们使用一个包含两个验证器的数组将AlphaNumericValidator作为第二个验证器添加进去。该数组可用于向控件添加任意数量的验证器。
最后一步是将控件的适当验证消息整合到我们的模板中。打开workout.component.html并在显示必填验证器消息的标签下方添加以下标签:
<label *ngIf="exerciseForm.controls.name.hasError('invalidAlphaNumeric') && (exerciseForm.controls.name.touched || submitted)" class="alert alert-danger validation-message">Name must be alphanumeric</label>
运动屏幕现在将显示一个验证消息,如果在名称输入框中输入了非字母数字值:
正如我们希望您所看到的,模型驱动表单使我们能够以一种简单的方式向我们的表单添加自定义验证器,这样我们就可以在我们的代码中保持验证逻辑,并轻松地将其集成到我们的模板中。
注意
您可能已经注意到,在本章中,我们还没有涵盖如何在模板驱动表单中使用自定义验证器。这是因为实现它们需要额外的步骤来构建自定义指令。我们将在第六章中进行介绍,深入了解 Angular 2 指令。
总结
现在我们有了一个个人教练应用程序。将特定的7 分钟训练应用程序转换为通用的个人教练应用程序的过程帮助我们学习了许多新概念。
我们通过定义新的应用程序要求开始了本章。然后,我们将模型设计为一个共享服务。
我们为个人教练应用程序定义了一些新视图和相应的路由。我们还使用了子路由和异步路由,将训练构建器与应用程序的其余部分分开。
然后,我们将重点转向训练构建。本章的主要技术重点之一是 Angular 表单。训练构建器使用了许多表单输入元素,我们使用了模板驱动和模型驱动的形式实现了许多常见的表单场景。我们还深入探讨了 Angular 验证,并实现了自定义验证器。
接下来的章节将全面讨论客户端-服务器交互。我们创建的训练和练习需要被持久化。在下一章中,我们将构建一个持久化层,这将允许我们在服务器上保存训练和练习数据。
在结束本章之前,这里有一个友好的提醒。如果您还没有完成个人教练的练习构建例程,请继续完成。您可以随时将您的实现与伴随代码库中提供的内容进行比较。您还可以添加一些原始实现中没有的内容,比如为练习图片上传文件,以及一旦您更熟悉客户端-服务器交互,就可以进行远程检查,以确定 YouTube 视频是否真实存在。