Angular6 示例第三版(二)
原文:
zh.annas-archive.org/md5/ea9e0be757a76027d750b88ab0b9bedf译者:飞龙
第三章:更多 Angular – 单页应用和路由
上一章我们讲述了在 Angular 中构建我们的第一个有用应用,然后这一章将介绍如何为它添加大量的 Angular 优点。在学习曲线中,我们已经开始了探索一个技术平台,现在我们可以使用 Angular 构建一些基本的应用。但这只是开始!在我们能够有效地在一个中等规模的应用中使用 Angular 之前,还有很多东西要学习。这一章让我们更接近实现这一目标。
7 分钟锻炼应用还有一些粗糙的边缘,我们可以在改进整体应用体验的同时修复它们。这一章完全是关于添加这些增强功能和特性。而且,像往常一样,这个应用构建过程为我们提供了足够的机会来增强我们对框架的理解,并学习关于它的新事物。
本章我们将涵盖以下主题:
-
探索 Angular 的单页应用(SPA):我们探索 Angular 的 SPA 功能,包括路由导航、链接生成和路由事件。
-
理解依赖注入:这是平台的核心功能之一。在这一章中,我们学习 Angular 如何有效地使用依赖注入来注入组件和服务到应用程序中。
-
Angular 纯(无状态)和纯(有状态)管道:当我们构建一些新的管道时,我们将更详细地探索 Angular 的主要数据转换结构——管道。
-
跨组件通信:由于 Angular 的一切都是关于组件及其交互,我们来看看如何在父组件-子组件和兄弟组件设置中进行跨组件通信。我们学习 Angular 的模板变量和事件如何促进这种通信。
-
创建和消费事件:我们学习一个组件如何公开其自己的事件,以及如何从 HTML 模板和其他组件中绑定到这些事件。
作为旁注,我期望你正在定期使用7 分钟锻炼来锻炼你的身体。如果不是,现在就进行七分钟的锻炼休息吧。我坚持这样做!
希望锻炼很有趣!现在让我们回到一些严肃的业务。让我们从探索 Angular 的单页应用(SPA)功能开始。
我们从第二章构建我们的第一个应用 - 7 分钟锻炼中继续进行。checkpoint2.4 Git 分支可以作为本章的基础。代码也已在 GitHub (github.com/chandermani/angular6byexample) 上提供,供大家下载。检查点作为 GitHub 中的分支实现。如果你不使用 Git,可以从 GitHub 位置下载checkpoint2.4的快照(ZIP 文件):bit.ly/ng6be-checkpoint-2-4。在首次设置快照时,请参考trainer文件夹中的README.md文件。
探索单页应用功能
7 分钟健身法在加载应用时开始,但最后一个练习会永久地停留在屏幕上。这不是一个非常优雅的解决方案。我们为什么不给应用添加一个开始和结束页面呢?这会让应用看起来更专业,并允许我们理解 Angular 的单页命名空间。
Angular SPA 基础设施
随着现代 Web 框架,如 Angular 和 Vue.js,我们现在已经习惯了不执行全页刷新的应用。但如果你是新手,那么提到SPAs是值得的。
单页应用(SPAs)是基于浏览器的应用,没有全页刷新。在这样的应用中,一旦初始 HTML 加载完毕,未来的页面导航将通过 AJAX 和 HTML 片段检索,并注入到已加载的视图中。Google Mail 是 SPA 的一个很好的例子。SPAs 提供了极佳的用户体验,因为用户会得到类似桌面应用的感觉,没有常规的回发和页面刷新,这些都是传统 Web 应用通常具有的。
就像任何现代 JavaScript 框架一样,Angular 也提供了 SPA 实现所需的必要结构。让我们了解它们,并添加我们的应用页面。
Angular 路由
Angular 支持使用其路由基础设施进行 SPA 开发。这个基础设施跟踪浏览器 URL,启用超链接生成,公开路由事件,并为响应 URL 变化的视图提供一组指令/组件。
有四个主要的框架组件协同工作,以支持 Angular 的路由基础设施:
-
路由器(Router):实际上是提供组件导航的主要基础设施组件。
-
路由配置(Route):组件路由依赖于路由配置来设置路由。
-
路由器组件:
RouterOutlet组件是用于加载特定路由视图的占位符容器(宿主)。 -
路由器链接指令:这个指令生成可以在锚标签中嵌入的超链接,用于导航。
下面的图示突出了这些组件在路由设置中的作用:
我强烈建议大家在设置7 分钟健身法的路由时,不断回顾这张图。
路由器是这个完整设置中的核心组件;因此,快速了解路由器将很有帮助。
Angular 路由
如果你曾经使用过任何支持 SPA 的 JavaScript 框架,那么工作原理是这样的。框架会监视浏览器 URL,并根据加载的 URL 提供视图。为此,有专门的框架组件。在 Angular 的世界里,这种跟踪是通过一个框架服务,路由器来完成的。
在 Angular 中,任何提供一些通用功能的类、对象或函数都被称为服务。*Angular 没有为组件、指令和管道提供任何特殊的声明服务结构,就像它对它们所做的那样。任何可以被组件/指令/管道消费的东西都可以称为服务。路由器就是这样一种服务。框架中还有许多其他服务。如果你来自 Angular 1 领域,这会是一个令人愉快的惊喜——没有服务、工厂、提供者、值或常量!
Angular 路由器的作用是:
-
在路由更改时启用组件之间的导航
-
在组件视图之间传递路由数据
-
使当前路由的状态对活动/加载的组件可用
-
提供 API,允许从组件代码中进行导航
-
跟踪导航历史,使我们能够使用浏览器按钮在组件视图之间前后移动
-
提供生命周期事件和守卫条件,使我们能够根据某些外部因素影响导航
路由器还支持一些高级路由概念,例如父子路由。这使我们能够在组件树内部定义多级路由。父组件可以定义路由,子组件可以进一步向父路由定义中添加更多子路由。这是我们在第四章“构建个人教练”中详细讨论的内容。
路由器不能单独工作。如前图所示,它依赖于其他框架组件以实现预期结果。让我们添加一些应用页面并处理每个拼图的一部分。
路由设置
Angular 路由器不是 Angular 核心框架的一部分。它有一个独立的 Angular 模块和自己的 npm 包。Angular CLI 已经作为项目设置的一部分安装了此包。查看package.json以确认这一点:
"@angular/platform-browser-dynamic": "6.0.0", "@angular/router": "6.0.0",
由于路由器已经安装,我们只需将其集成到7 分钟锻炼中。
我们可以先向index.html的head部分添加base引用(突出显示),如果尚未存在:
<title>Trainer</title>
<base href="/">
路由器需要设置base href。href值指定了用于 HTML 文档中所有相对 URL 的基本 URL,包括链接到 CSS、脚本、图像以及其他资源。此设置有助于路由器创建导航 URL。
添加开始和结束页面
此处的计划是为7 分钟锻炼提供三个页面:
-
开始页面:这成为应用的着陆页
-
锻炼页面:我们目前拥有的
-
完成页面:当锻炼完成后,我们将导航到这个页面
锻炼组件及其视图(workout-runner.component.ts和workout-runner.component.html)已经存在。因此,让我们创建StartComponent和FinishComponent。
再次使用 Angular CLI 生成开始和结束组件的样板代码。导航到trainer/src/app文件夹并执行组件生成命令:
ng generate component start -is
ng generate component finish -is
接下来,从checkpoint3.1 Git 分支(下载位置为bit.ly/ng6be-3-1-app)复制start和finish组件的视图。
start和finish组件的实现都是空的。有趣的部分在视图中。开始组件视图有一个链接可以导航到锻炼运行者组件(<a routerLink="/workout" ...),结束组件也是如此。我们还没有定义路由。
start和finish组件已经被添加到app module中,因为它们是基本的视图,与拥有自己WorkoutRunnerModule模块的锻炼运行者不同。
所有三个组件都已准备就绪。现在是定义路由配置的时候了!
路由配置
要设置7-Minute Workout的路由,我们将创建一个route definition module file。在trainer/src/app文件夹中创建一个名为app-routing.module.ts的文件,定义应用的顶级路由。添加以下路由设置或从checkpoint3.1 Git 分支复制:
import { NgModule } 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';
const routes: Routes = [
{ path: 'start', component: StartComponent },
{ path: 'workout', component: WorkoutRunnerComponent },
{ path: 'finish', component: FinishComponent },
{ path: '**', redirectTo: '/start' }
];
@NgModule({
imports: [RouterModule.forRoot(routes, { enableTracing: true })],
exports: [RouterModule]
})
export class AppRoutingModule { }
Angular CLI 还支持为模块生成样板路由。我们没有使用该功能。我们可以从 CLI 文档中了解它,文档地址为bit.ly/ng-cli-routing。
routes变量是一个Route对象的数组。每个Route定义了一个单独路由的配置,它包含:
-
path:要匹配的目标路径 -
component:当路径被访问时需要加载的组件
这样的路由定义可以解释为:“当用户导航到 URL(在path中定义)时,加载component属性中定义的相应组件。”以第一个路由示例为例;导航到http://localhost:9000/start将加载StartComponent的组件视图。
你可能已经注意到最后一个Route定义看起来有点不同。path看起来很奇怪,它也没有component属性。带有**的路径表示一个通配符路径或应用的通配符路由。任何不匹配前三个路由的导航都将匹配通配符路由,导致应用导航到起始页面(在redirectTo属性中定义)。
路由设置完成后,我们可以尝试一下。输入任何随机的路由,例如http://localhost:9000/abcd,应用会自动重定向到http://localhost:9000/start。
我们最终通过调用RouterModule.forRoot创建并导入一个新的模块到AppRoutingModule中。通过重新导出 Angular 的RouterModule,我们可以导入AppRoutingModule而不是RouterModule,并访问所有路由构造以及AppModule中可用的应用路由。
forRoot 函数参数上的 enableTracing: true 属性允许我们在导航发生并正确解析路由时监控 路由事件(例如 NavigationStart、NavigationEnd 和 NavigationCancel)。日志在浏览器的调试控制台中可见。仅用于调试目的,请从生产构建中移除。
前面的路由设置是否可以在 AppModule 内完成?是的,这绝对可能,但我们建议不要这样做。随着路由数量的增加和路由设置的复杂性增加,有一个独立的路由模块有助于我们更好地组织代码。
这里要强调的一个重要事项是:路由定义中的路由顺序很重要。由于路由匹配是自顶向下的,它会在定义你的特定路由之前停止,在任何一个通用的捕获所有路由之前,例如在我们的定义中声明的 ** 通配符路由,这是在最后声明的。
默认的路由设置使用 pushstate 机制进行 URL 导航。在这种设置中,URL 看起来像:
-
localhost:4200/start -
localhost:4200/workout -
localhost:4200/finish
这可能看起来不是什么大问题,但请记住,我们正在进行客户端导航,而不是我们如此习惯的全页重定向。正如 开发者指南 所述:
现代 HTML 5 浏览器支持 history.pushState,这是一种在不触发服务器页面请求的情况下改变浏览器位置和历史的技巧。路由器可以组合一个“自然”的 URL,这个 URL 与需要页面加载的 URL 无法区分。
Pushstate API 和服务器端 URL 重写
路由器在两种情况下使用 pushstate API:
-
当我们点击视图中嵌入的链接(
<a>标签) -
当我们使用路由器 API
在这两种情况下,路由器拦截任何导航事件,加载适当的组件视图,并最终更新浏览器 URL。请求永远不会发送到服务器。
但如果我们刷新浏览器窗口会怎样呢?
Angular 路由无法拦截浏览器的刷新事件,因此会发生完整的页面刷新。在这种情况下,服务器需要响应仅存在于客户端的资源请求(URL)。典型的服务器响应是发送应用入口文件(例如 index.html)以响应任何可能导致 404 (Not Found) 错误的任意请求。这就是我们所说的服务器 URL 重写。这意味着对任何不存在的 URL 的请求,例如 /start、/workout 或 /finish,都会加载索引页面。
每个服务器平台都有不同的机制来支持 URL 重写。我们建议您查看您使用的服务器堆栈文档,以启用 Angular 应用的 URL 重写。
当应用路由完成后,我们可以看到服务器端重写的实际效果。完成后,尝试刷新应用并查看浏览器的网络日志;服务器每次都会发送相同的 生成的 *index.html 内容,无论请求的 URL 是什么。
路由模块定义现在已完成。在继续之前,打开 app.module.ts 并导入 AppRoutingModule:
import { FinishComponent } from './finish/finish.component';
import { AppRoutingModule } from './app-routing.module';
@NgModule({
imports: [..., StartModule, FinishModule, AppRoutingModule],
现在我们已经拥有了所有必需的组件和所有已定义的路由,在路由更改时我们在哪里注入这些组件?我们只需要在宿主视图中定义一个占位符即可。
使用 router-outlet 渲染组件视图
查看当前的 AppComponent 模板(app.component.html),它包含一个内嵌的 WorkoutRunnerComponent:
<abe-workout-runner></abe-workout-runner>
这需要改变,因为我们需要根据 URL(/start,/workout,或 /finish)渲染不同的组件。删除前面的声明,并用一个 router 指令 替换它:
<router-outlet></router-outlet>
RouterOutlet 是 Angular 组件指令,它在路由更改时充当加载特定路由组件的占位符。它与路由服务集成,根据当前浏览器 URL 和路由定义加载适当的组件。
以下图表帮助我们轻松地可视化 router-outlet 设置中正在发生的事情:
我们几乎完成了;现在是时候触发导航。
路由导航
与标准浏览器导航一样,Angular 导航发生:
-
当用户直接在浏览器地址栏中输入 URL
-
在点击锚点标签上的链接
-
在使用脚本/代码进行导航时
如果尚未启动,请启动应用程序并加载 http://localhost:4200 或 http://localhost:4200/start。启动页面应该被加载。
点击 Start 按钮,锻炼应该在 http://localhost:4200/workout URL 下开始。
Angular 路由器还支持基于 hash (#)-based routing 的旧式路由。当启用基于 hash 的路由时,路由如下所示:
-
localhost:9000/#/start -
localhost:9000/#/workout -
localhost:9000/#/finish
要将其更改为基于 hash 的路由,顶级路由的路由配置应在 RouterModule.forRoot 函数(第二个参数)中增加一个额外的 useHash:true 属性。
有趣的是,在 StartComponent 视图定义中的锚点链接没有 href 属性。相反,有一个 RouterLink 指令(指令名称为 RouterLink,选择器为 routerLink):
<a routerLink="/workout">
在前面的例子中,由于路由是固定的,指令采用了一个常量表达式("/workout")。我们在这里不使用标准的方括号表示法([]),而是将指令分配一个固定值。这被称为 一次性绑定。对于动态路由,我们可以使用模板表达式和链接参数数组。我们很快就会涉及到动态路由和链接参数数组。
注意在先前的路由路径中的 / 前缀。/ 用于指定绝对路径。Angular 路由器也支持相对路径,这在处理子路由时非常有用。我们将在接下来的几章中探讨子路由的概念。
刷新应用并检查渲染的 HTML 中的StartComponent;前面的锚标签被渲染为正确的href值:
<a ... href="/workout">
避免硬编码路由链接
虽然你可以直接使用<a href="/workout">,但为了避免硬编码路由,建议使用routerLink。
链接参数数组
当前7 分钟锻炼的路由设置相当简单,不需要在生成链接时传递参数。但非平凡路由需要动态参数时,这个功能是存在的。看看这个例子:
@RouteConfig([
{ path: '/users/:id', component: UserDetail },
{ path: '/users', component: UserList},
])
这就是如何生成第一个路由的方法:
<a [routerLink]="['/users', 2] // generates /users/2
分配给RouterLink指令的数组就是我们所说的链接参数数组。该数组遵循特定的模式:
['routePath', param1, param2, {prop1:val1, prop2:val2} ....]
第一个元素始终是路由路径,下一组参数用于替换在路由模板中定义的占位符标记。
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私有变量中。这个魔法是通过依赖注入框架完成的。
现在只需要将console.log("Workout complete!");语句替换为对navigation路由的调用即可:
this.router.navigate( ['/finish'] );
navigate方法接受与RouterLink指令相同的链接参数数组。我们可以通过耐心等待锻炼完成来验证实现。
如果你在运行代码时遇到问题,请查看checkpoint3.1Git 分支,以获取我们迄今为止所做工作的一个可工作版本。或者,如果您不使用 Git,可以从bit.ly/ng6be-checkpoint-3-1下载checkpoint3.1的快照(ZIP 文件)。在首次设置快照时,请参考训练文件夹中的README.md文件。
在我们定义的7 分钟健身法中的路线是标准的简单路线。但如果存在需要参数的动态路线,我们如何使这些参数在我们的组件中可用?Angular 有一个服务可以做到这一点,那就是ActivatedRoute服务。
使用 ActivatedRoute 服务访问路由参数
有时候,应用程序需要访问活动路由状态。在组件实现过程中,有关当前 URL 片段、当前路由参数和其他与路由相关的数据等信息可能会派上用场。
ActivatedRoute服务是所有当前路由相关查询的一站式商店。它有几个属性,包括url和paramMap,可以用来查询路由的当前状态。
让我们看看一个参数化路由的例子以及如何访问从组件传递过来的参数,给定这个路由:
{ path: '/users/:id', component: UserDetailComponent },
当用户导航到/user/5时,底层组件可以通过首先将其构造函数中的ActivatedRoute注入来访问:id参数值:
export class UserDetailComponent {
constructor( private route: ActivatedRoute ...
然后,在代码中需要参数的任何地方,调用ActivatedRoute.paramMap属性的get方法:
ngOnInit() {
let id = +this.route.paramMap.get('id'); // (+) converts string 'id' to a number
var currentUser=this.getUser(id)
}
ActivatedObject上的paramMap属性实际上是一个可观察对象。我们将在本章后面部分了解更多关于可观察对象的内容,但就目前而言,理解可观察对象是对象,通过引发其他人可以监听的事件来让外界了解其状态变化就足够了。
我们将在后面的章节中使用这个路由器功能,在那里我们将构建一个新的应用程序,可以创建健身计划并编辑现有的健身计划。在即将到来的章节中,我们还将探讨一些高级路由概念,包括子路由、懒加载路由和守卫条件。
我们已经涵盖了 Angular 路由的基础知识,现在是时候集中精力在一个久违的话题上了:依赖注入。
Angular 依赖注入
Angular 大量使用依赖注入来管理应用程序和框架的依赖。令人惊讶的是,我们可以在开始讨论路由器之前忽略这个话题。在这段时间里,Angular 依赖注入框架一直在支持我们的实现。一个好的依赖注入框架的标志是,消费者可以在不太多关注内部结构和很少的仪式的情况下使用它。
如果你不确定依赖注入是什么,或者只是对它有一个模糊的概念,那么 DI 的介绍肯定不会伤害任何人。
依赖注入 101
对于任何应用程序,其组件(不要与 Angular 组件混淆)并不是独立工作的。它们之间存在依赖关系。一个组件可能使用其他组件来实现其所需的功能。"依赖注入"是一种管理此类依赖的模式。
依赖注入模式在许多编程语言中都很流行,因为它允许我们以松耦合的方式管理依赖。有了这样的框架,依赖对象由 DI 容器管理。这使得依赖可交换,整体代码更加解耦和可测试。
依赖注入背后的理念是,一个对象不创建/管理自己的依赖。相反,依赖由外部提供。这些依赖可以通过构造函数提供,这被称为构造函数注入(Angular 也这样做)或者通过直接设置对象属性,这被称为属性注入。
这里是一个依赖注入操作的初步示例。考虑一个名为Tracker的类,它需要一个Logger来进行日志操作:
class Tracker() {
logger:Logger;
constructor() {
this.logger = new Logger();
}
}
Logger类的依赖在Tracker内部硬编码,因为Tracker本身实例化了Logger实例。如果我们外部化这个依赖呢?所以这个类变成了:
class Tracker {
logger:Logger;
constructor(logger:Logger) {
this.logger = logger;
}
}
这个看似无害的更改产生了重大影响。通过添加提供依赖外部的能力,我们现在可以:
- 解耦这些组件并启用可扩展性。依赖注入模式允许我们修改
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的实现。
- 模拟依赖:模拟依赖的能力使得我们的组件更容易测试。
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实例注入为例。为了回答“是什么”和“如何做”的问题,我们在AppRoutingModule中通过导入RouterModule来注册Router服务:
imports: [..., AppRoutingModule];
AppRoutingModule是一个模块,它导出多个路由以及所有与 Angular-router 相关的服务(技术上它重新导出RouterModule)。
“在哪里”和“何时”是由需要依赖项的组件决定的。WorkoutRunnerComponent的构造函数接受一个Router依赖项。这会通知注入器在创建WorkoutRunnerComponent作为路由导航的一部分时注入当前的Router实例。
在内部,注入器根据从 TypeScript 转换为 ES5 代码(由 TypeScript 编译器完成)时反射的元数据来确定类的依赖关系。只有当我们向类添加装饰器,如 @Component 或 @Pipe 时,才会生成元数据。
如果我们将 Router 注入到另一个类中会发生什么?是否使用相同的 Router 实例?简短的答案是是的。Angular 注入器创建和缓存依赖关系以供将来重用,因此这些服务本质上是单例的。
虽然注入器中的依赖关系是单例的,但在任何给定时间,Angular 应用程序中可能有多个注入器处于活动状态。你很快就会了解注入器层次结构。与路由器一样,还有另一层复杂性。由于 Angular 支持子路由概念,这些子路由中的每一个都有自己的路由实例。等到我们下一章介绍子路由时,你就可以理解其中的复杂性了!
让我们创建一个 Angular 服务来跟踪锻炼历史记录。这个过程将帮助你了解如何使用 Angular DI 连接依赖关系。
跟踪锻炼历史记录
如果我们能跟踪我们的锻炼历史记录,那将是一个很好的补充。我们上次是什么时候锻炼的?我们完成了吗?我们花了多少时间?
为了回答这些问题,我们需要跟踪锻炼开始和结束的时间。然后需要将这些跟踪数据持久化到某个地方。
一种可能的解决方案是将所需的函数扩展到我们的 WorkoutRunnerComponent 中。但这会给 WorkoutRunnerComponent 增加不必要的复杂性,这不是它的主要任务。
我们需要为这项工作创建一个专门的历史跟踪服务,一个跟踪历史数据并在整个应用程序中共享的服务。让我们开始构建 workout-history-tracker 服务。
构建 workout-history-tracker 服务
workout-history-tracker 服务将跟踪锻炼进度。该服务还将公开一个接口,允许 WorkoutRunnerComponent 开始和停止锻炼跟踪。
再次受到 Angular 风格指南 的启发,我们将创建一个新的模块,核心模块,并将服务添加到这个模块中。核心模块的作用是托管应用程序中可用的服务。这也是添加在应用程序启动时所需的单次使用组件的好地方。导航栏和忙碌指示器就是这样的组件示例。
在命令行中,导航到 trainer/src/app 文件夹并生成一个新的模块:
ng generate module core --module app
这将创建一个新的 CoreModule 模块并将其导入到 AppModule 中。接下来,在 trainer/src/app/core 文件夹中创建一个新的服务,再次使用 Angular CLI:
ng generate service workout-history-tracker
生成的代码相当简单。生成器创建了一个新的类 WorkoutHistoryTrackerService (workout-history-tracker.service.ts),并在类上应用了 @Injectable 装饰器:
@Injectable({
providedIn: 'root'
})
export class WorkoutHistoryTrackerService {
...
}
Injectable 上的 providedIn:'root' 属性指示 Angular 使用 root injector 创建 一个提供者。这个提供者的唯一任务是创建 WorkoutHistoryTrackerService 服务并在 Angular 的 DI 注入器需要时返回它。我们创建/使用的任何服务都需要在注入器上注册。正如 Angular 关于 providers 的文档所描述的,
提供者告诉注入器如何创建服务。没有提供者,注入器将不知道它负责注入服务,也无法创建服务。
在 Angular 中,服务只是一个已注册到 Angular DI 框架的类。它们没有什么特别之处!
有时,将服务作为模块的一部分而不是与根注入器注册可能是希望的。在这种情况下,服务可以在模块级别注册。有两种方法可以实现这一点:
- 选项 1:使用
providedIn属性引用模块:
@Injectable({
providedIn: CoreModule
})
export class WorkoutHistoryTrackerService {
- 选项 2:在模块上注册服务,使用
providers数组:
@NgModule({
providers: [WorkoutHistoryTrackerService],
})
export class CoreModule { }
在模块级别注册服务在模块是懒加载的场景中具有优势。
使用 Injectable(选项 1)注册服务还有另一个优点。它使 Angular CLI 构建能够执行代码捆绑的高级优化,省略任何已声明但从未使用的服务(这个过程称为 tree shaking)。
不论我们使用哪两种选项,服务仍然通过提供者(provider)与根注入器注册。
我们将使用 Injectable 方法在本书中注册依赖项,除非另有说明。打开 workout-history-tracker.service.ts 并添加以下实现:
import { ExercisePlan } from '../workout-runner/model';
import { CoreModule } from './core.module';
import { Injectable } from '@angular/core';
@Injectable({
providedIn: CoreModule
})
export class WorkoutHistoryTrackerService {
private maxHistoryItems = 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) { }
}
定义了两个类:WorkoutHistoryTrackerService 和 WorkoutLogEntry。正如其名称所暗示的,WorkoutLogEntry 定义了单个锻炼执行的日志数据。maxHistoryItems 允许我们配置要存储在包含历史数据的 workoutHistory 数组中的最大项目数。get tracking() 方法在 TypeScript 中定义了 workoutTracked 的 getter 属性。在锻炼执行期间,workoutTracked 被设置为 true。
让我们向 WorkoutHistoryTrackerService 添加开始跟踪、停止跟踪和完成锻炼的函数:
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 函数应在完成每个作为锻炼一部分的锻炼时调用。为了节省您的按键次数,您可以从此 gist 获取到目前为止的实现完整代码:bit.ly/ng6be-gist-workout-history-tracker-v1-ts。
服务实现现在还包括一个获取锻炼历史数据的函数:
getHistory(): Array<WorkoutLogEntry> {
return this.workoutHistory;
}
这就完成了WorkoutHistoryTrackerService的实现;现在,是时候将其集成到锻炼执行中了。
与WorkoutRunnerComponent集成
WorkoutRunnerComponent需要WorkoutHistoryTrackerService来跟踪锻炼历史;因此有一个依赖项需要满足。我们已经在 Angular 的 DI 框架中使用Injectable装饰器注册了WorkoutHistoryTrackerService,现在是时候使用构造函数注入来消费这个服务了。
使用构造函数注入注入依赖项
消费依赖项很容易!通常情况下,我们使用构造函数注入来消费依赖项。
在顶部添加import语句,并更新WorkoutRunnerComponent构造函数,如下所示:
import { WorkoutHistoryTrackerService } from '../core/workout-history-tracker.service';
...
constructor(private router: Router,
private tracker: WorkoutHistoryTrackerService
) {
与router一样,当创建WorkoutRunnerComponent时,Angular 也会注入WorkoutHistoryTrackerService。很简单!
一旦服务被注入并可供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中:
export class WorkoutRunnerComponent implements OnInit, OnDestroy {
...
ngOnDestroy() {
this.tracker.endTracking(false);
}
锻炼历史跟踪实现已完成。我们渴望开始锻炼历史页面/组件的实现,但在完成我们对 Angular DI 能力的讨论之前,我们不会这么做。
如果你想保持应用构建速度,现在可以自由跳过下一节。用清新和放松的心态回到这一节。在下一节中,我们将分享一些非常重要的核心概念。
深入了解依赖注入
让我们先尝试理解我们可以使用WorkoutHistoryTrackerService作为示例来注册依赖项的不同位置。
注册依赖项
注册依赖的标准方式是在根/全局级别注册。这可以通过在NgModule装饰器的provides属性(数组)中传递依赖类型,或者使用Injectable服务装饰器的providedIn属性来完成。
记得我们的WorkoutHistoryTrackerService注册吗?请检查以下内容:
@Injectable({
providedIn: CoreModule
})
export class WorkoutHistoryTrackerService {
同样的事情也可以在模块声明中完成,如下所示:
@NgModule({...providers: [WorkoutHistoryTrackerService],})
从技术上讲,当使用上述任何一种机制时,服务都会注册到应用的根注入器,而不管它在哪个 Angular 模块中声明。从今往后,任何跨模块的 Angular 工件都可以使用该服务(WorkoutHistoryTrackerService)。根本不需要任何模块导入。
这种行为与组件/指令/管道注册不同。这样的工件必须从一个模块导出,以便另一个模块可以使用它们。
依赖项可以注册的另一个地方是在组件上。@Component 装饰器有一个 providers 数组参数来注册依赖项。有了这两个依赖项注册级别,我们需要回答的明显问题是,哪一个该使用?
显然,如果依赖项仅由组件及其子组件使用,它应该注册在 @Component 装饰器级别。但并非如此!在我们可以回答这个问题之前,我们还需要了解很多。需要介绍一个全新的分层注入器世界。让我们等待,并通过继续讨论提供者来学习注册依赖项的其他方法。
当 Angular 注入器请求时,提供者创建依赖项。这些提供者有创建这些依赖项的配方。虽然类似乎是可以注册的明显依赖项,但我们也可以注册:
-
一个特定的对象/值
-
工厂函数
使用 Injectable 装饰器注册 WorkoutHistoryTrackerService 是最常见的注册模式。但有时我们需要在依赖项注册方面有一定的灵活性。要注册一个对象或工厂函数,我们需要使用在 NgModule 上可用的提供者注册的扩展版本。
要了解这些变化,我们需要更详细地探索提供者和依赖项注册。
Angular 提供者
提供者创建由 DI 框架提供的依赖项。
看看在 NgModule 上完成的这个 WorkoutHistoryTrackerService 依赖项注册:
providers: [WorkoutHistoryTrackerService],
这种语法是以下版本的简写形式:
providers:({ provide: WorkoutHistoryTrackerService, useClass: WorkoutHistoryTrackerService })
第一个属性(provide)是一个用作注册依赖项键的令牌。这个键也允许我们在依赖注入期间定位依赖项。
第二个属性(useClass)是一个提供者定义对象,它定义了创建依赖项值的配方。
使用 useClass,我们正在注册一个类提供者。类提供者通过实例化请求的对象类型来创建依赖项。还有一些其他的提供者类型。
值提供者
类提供者创建类对象并满足依赖项,但有时我们想在 DI 提供者中注册一个特定的对象/原始值。值提供者解决了这个用例。
如果我们使用这种技术注册 WorkoutHistoryTrackerService,注册将看起来像这样:
{provide: WorkoutHistoryTrackerService, useValue: new WorkoutHistoryTrackerService()};
使用值提供者,我们有责任为 Angular DI 提供一个服务/对象/原始实例。
使用value provider,由于我们手动创建依赖项,因此我们也负责构建任何子依赖项(如果存在)。再次以WorkoutHistoryTrackerService为例。如果WorkoutHistoryTrackerService有一些依赖项,这些依赖项也需要通过手动注入来满足:
{provide: WorkoutHistoryTrackerService, useValue: new WorkoutHistoryTrackerService(new LocalStorage())});
在先前的例子中,我们不仅要创建WorkoutHistoryTrackerService的实例,还要创建LocalStorage服务的实例。对于具有复杂依赖图的服务,使用值提供者设置该服务变得具有挑战性。
在可能的情况下,优先选择class provider而不是value provider。
在特定的场景中,值提供者仍然很有用。例如,我们可以使用值提供者注册一个通用的应用程序配置:
{provide: AppConfig, {useValue: {name:'Test App', gridSetting: {...} ...}}
或者,在单元测试时注册一个模拟依赖项:
{provide:WorkoutHistoryTrackerService, {useValue: new MockWorkoutHistoryTracker()}
工厂提供者
有时候,依赖项构建并不是一件简单的事情。构建取决于外部因素。这些因素决定了创建和返回哪些对象或类实例。工厂提供者做这项繁重的工作。
以一个例子为例,我们想要为开发和生产发布提供不同的配置。我们可以很好地使用工厂实现来选择正确的配置:
{provide: AppConfig, useFactory: () => {
if(PRODUCTION) {
return {name:'Production App', gridSetting: {...} ...}
}
else {
return {name:'Test App', gridSetting: {...} ...}
}
}
工厂函数也可以有自己的依赖。在这种情况下,语法略有变化:
{provide: WorkoutHistoryTrackerService, useFactory: (environment:Environment) => {
if(Environment.isTest) {
return new MockWorkoutHistoryTracker();
}
else {
return new WorkoutHistoryTrackerService();
},
deps:[Environment]
}
依赖作为参数传递给工厂函数,并在提供者定义对象的属性deps上注册(在先前的例子中Environment是注入的依赖)。
如果依赖项的构建复杂,且在依赖项连接期间不能决定所有内容,请使用UseFactory提供。
虽然我们有多种选项来声明依赖,但消费依赖要简单得多。我们在“使用构造函数注入注入依赖”这一节中看到了一种方法。
使用注入器进行显式注入
我们甚至可以使用 Angular 的Injector 服务进行显式注入。这是 Angular 用来支持 DI 的同一个注入器。以下是使用Injector注入WorkoutHistoryTrackerService服务的方法:
constructor(private router: Router, private injector:Injector) {
this.tracker=injector.get(WorkoutHistoryTrackerService);
我们注入Injector服务,然后明确请求WorkoutHistoryTrackerService实例。
什么时候有人会想这样做呢?嗯,几乎从不会。避免这种模式,因为它将 DI 容器暴露给实现,并增加了一些噪音。
我们现在知道如何注册依赖和如何消费它,但 DI 框架是如何定位这些依赖的呢?
依赖项令牌
记住之前展示的依赖注册的扩展版本:
{ provide: WorkoutHistoryTrackerService, useClass: WorkoutHistoryTrackerService }
provide 属性值是一个 令牌。此令牌用于标识要注入的依赖。在先前的示例中,我们使用类名或类型来标识依赖,因此该令牌被称为 类令牌。
根据 前面的注册,每当 Angular 看到类似以下语句时,它将根据类类型注入正确的依赖,这里 WorkoutHistoryTrackerService:
constructor(tracker: WorkoutHistoryTrackerService)
Angular 还支持一些其他的令牌。
使用 InjectionToken
有时候,我们定义的依赖项要么是原始数据类型、对象或函数。在这种情况下,类令牌不能使用,因为没有类。Angular 通过使用 InjectionToken (或我们稍后将看到的字符串令牌)来解决这个问题。如果不存在 AppConfig 类,我们之前分享的应用配置注册示例可以使用字符串令牌重写。
要使用 InjectionToken 注册依赖,我们首先需要创建 InjectionToken 类实例:
export const APP_CONFIG = new InjectionToken('Application Configuration');
然后,使用令牌来注册依赖:
{ provide: APP_CONFIG, useValue: {name:'Test App', gridSetting: {...} ...});
最后,使用 @Inject 装饰器在任何地方注入依赖:
constructor(@Inject(APP_CONFIG) config) { }
有趣的是,当 @Inject() 不存在时,注入器使用参数的类型/类名(类令牌)来定位依赖。
使用字符串令牌
Angular 也支持 字符串令牌,允许我们使用字符串字面量来标识和注入依赖。使用字符串令牌的前一个示例变为:
{ provide: 'appconfig', useValue: {name:'Test App', gridSetting: {...} ...});
...
constructor(@Inject('appconfig') config) { }
字符串令牌的一个缺点是,你可能会在声明和注入过程中拼写错误令牌。
呼吁!这是关于 Angular 依赖注入的一个非常长的部分,还有很多内容要介绍。现在,让我们回到正轨,添加锻炼历史页面。
添加锻炼历史页面
在执行锻炼过程中收集的锻炼历史数据现在可以在视图中呈现。让我们添加一个 History 组件。该组件将在 /history 位置可用,可以通过点击应用页眉部分中的链接来加载。
在 app.routes.ts 中更新路由定义以包含新的路由和相关导入:
import { WorkoutHistoryComponent } from './workout-history/workout-history.component';
...
export const routes: Routes = [
...,
{ path: 'history', component: WorkoutHistoryComponent } ,
{ path: '**', redirectTo: '/start' }
])
需要将 History 链接添加到应用页眉部分。让我们将页眉部分重构为其自己的组件。更新 app.component.html 模板,并用以下代码替换 nav 元素:
<div id="header">
<abe-header></abe-header>
</div>
将 nav 元素移动到页眉组件中,我们仍然需要创建它。在 trainer/src/app/core 文件夹内运行命令来使用 ng generate 生成一个新的 HeaderComponent 组件:
ng generate component header -is
此语句创建了一个新的页眉组件,并在核心模块中声明了它。接下来,从 checkpoint3.2 Git 分支(GitHub 位置:bit.ly/ng6be-3-2-header)更新页眉组件的定义(header.component.ts)及其视图(header.component.html)。
虽然我们已经向app.component.html添加了标题元素,但除非我们导入核心模块并从核心模块导出组件,否则标题组件不会渲染。Angular CLI 为我们完成了第一部分,对于第二部分,更新core.module.ts如下:
imports: [ CommonModule, RouterModule],
declarations: [HeaderComponent],
exports: [HeaderComponent]
如果你查看HeaderComponent的视图,历史链接现在就在那里。我们必须导入RouterModule,因为以下链接是使用RouterLink指令生成的,而RouterLink是RouterModule的一部分:
<a class="nav-link" routerLink="/history" title="Workout History">History</a>
让我们先通过生成组件的模板来添加锻炼历史组件。从命令行导航到trainer/src/app并运行:
ng generate component workout-history -is
WorkoutHistoryComponent的实现可以在checkpoint3.2Git 分支中找到;文件夹是workout-history(GitHub 位置:bit.ly/ng6be-3-2-workout-history)。
至少可以说,WorkoutHistoryComponent的视图代码很简单:一些 Angular 构造,包括ngFor和ngIf。组件实现也很简单。注入WorkoutHistoryTrackerService服务依赖项,并在WorkoutHistoryComponent初始化时加载历史数据:
ngOnInit() {
this.history = this.tracker.getHistory();
}
这次,我们使用Location服务而不是Router来从history组件导航离开:
goBack() {
this.location.back();
}
位置服务用于与浏览器 URL 交互。根据 URL 策略,要么使用 URL 路径(例如/start或/workout),要么使用 URL 哈希段(例如#/start或#/workout)来跟踪位置变化。路由器服务内部使用位置服务来触发导航。
路由器与位置
虽然Location服务允许我们执行导航,但使用Router是执行路由导航的首选方式。我们在这里使用位置服务是因为需要导航到最后一个路由,而不必担心如何构建路由。
我们已经准备好测试我们的锻炼历史实现。加载起始页面并点击“历史”链接。历史页面加载了一个空网格。返回,开始锻炼,并完成一项练习。再次检查历史页面;应该有一个锻炼被列出:
看起来不错!如果我们多次运行锻炼并让历史记录列表累积,我们会发现这个列表中有一个痛点。历史数据没有按倒序时间排序,最新数据在顶部。此外,如果我们有一些过滤功能那就太好了。
使用管道排序和过滤历史数据
在第二章,“构建我们的第一个应用 - 7 分钟锻炼”,我们探讨了管道。我们甚至构建了自己的管道来格式化秒数值为 hh:mm:ss。管道的主要目的是转换数据,而且令人惊讶的是,它们也可以在数组上工作!对于数组,管道可以排序和过滤数据。我们创建了两个管道,一个用于排序,一个用于过滤。
AngularJS 预建了用于此目的的过滤器(在 Angular 中,过滤器是管道),orderBy 和 filter。Angular 并不自带这些管道,这有一个很好的原因。这些管道容易导致性能不佳。在框架文档中了解关于管道的决策背后的理由:bit.ly/ng-no-filter-orderby-pipe。
让我们从 orderBy 管道开始。
排序管道
我们实现的 orderBy 管道将根据对象的任何属性对对象数组进行排序。根据 fieldName 属性按升序排序的项目使用模式如下:
*ngFor="let item of items| orderBy:fieldName"
对于按降序排序项目,使用模式如下:
*ngFor="let item of items| orderBy:-fieldName"
注意 fieldName 前面的额外连字符(-)。
我们计划在新的共享模块中添加 OrderByPipe。你在想,为什么不添加到核心模块中呢?按照惯例,核心模块包含全局服务和一次性使用的组件。每个应用程序只有一个核心模块。另一方面,共享模块包含跨模块共享的组件/指令/管道。这样的共享模块也可以在多个级别上定义,跨越父模块和子模块。在这种情况下,我们将在 AppModule 内定义共享模块。
通过在 trainer/src/app 目录中运行此命令来创建新的 SharedModule 模块:
ng generate module shared --module app
从命令行导航到 trainer/src/app/shared 文件夹并生成排序管道模板:
ng generate pipe order-by
打开 order-by.pipe.ts 并更新定义,从 checkpoint3.2 代码(GitHub 位置:bit.ly/ng6be-3-2-order-by-pipe)中获取。虽然我们不会深入探讨管道的实现细节,但一些相关部分需要突出显示。看看这个管道概要:
@Pipe({ name: 'orderBy' })
export class OrderByPipe {
transform(value: Array<any>, field:string): any {
...
}
}
前面的 field 变量接收需要排序的字段。如果字段有 - 前缀,我们在按降序排序数组之前截断前缀。
该管道还使用了扩展运算符 ...,这可能对你来说很新。在 MDN 上了解更多关于扩展运算符的信息:bit.ly/js-spread。
要使用 OrderByPipe,更新 workout 历史视图的模板:
<tr *ngFor="let historyItem of history|orderBy:'-startedOn'; let i = index">
再次,我们需要从共享模块导出管道,允许 WorkoutHistoryComponent 使用它。在 SharedModule 上添加一个 exports 属性并将其设置为 OrderByPipe:
declarations:[...],
exports:[OrderByPipe]
历史数据现在将根据 startedOn 字段按降序排序。
注意管道参数('-startedOn')周围的单引号。我们正在将一个字面字符串传递给 orderBy 管道。管道参数支持数据绑定,也可以绑定到组件属性。
对于 orderBy 管道来说,这就足够了。让我们来实现过滤功能。
与搜索管道的管道链
我们首先通过在 trainer/src/app/shared 文件夹中运行以下命令来创建搜索管道模板:
ng generate pipe search
实现现在可以从 checkpoint3.2(GitHub 位置:bit.ly/ng6be-3-2-search-pipe)复制。SearchPipe执行基于基本相等性的过滤。没有什么特别的。
看看管道代码;管道接受两个参数,第一个是要搜索的字段,第二个是要搜索的值。我们使用 JavaScript 数组的filter函数来过滤记录,进行严格的相等性检查。关于Pipe装饰器上的pure属性有什么疑问吗?这将是下一节讨论的主题。
让我们更新锻炼历史视图并包含搜索管道。打开workout-history.component.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是点击的单选按钮。
现在可以将search管道添加到ngFor指令表达式中。我们将链式连接search和orderBy管道。更新ngFor表达式为:
<tr *ngFor="let historyItem of history |search:'completed':completed |orderBy:'-startedOn';
let i = index">
这是 Angular 管道链式功能的一个很好的例子!
正如我们在OrderByPipe中所做的那样,SearchPipe也需要在使用之前从共享模块中导出。
search管道首先过滤历史数据,然后由orderBy管道重新排序。请特别注意search管道的参数:第一个参数是一个表示要搜索字段的字符串字面量(historyItem.completed),而第二个参数是从组件的completed属性派生出来的。能够将管道参数绑定到组件属性使我们具有很大的灵活性。
继续验证历史页面的搜索功能。根据单选按钮的选择,历史记录被过滤,当然,它们根据锻炼开始日期按逆时间顺序排序。
虽然使用数组的管道看起来很简单,但如果我们不了解管道何时被评估,它可能会带来一些惊喜。
数组管道的注意事项
为了理解应用于数组的管道的问题,让我们重现这个问题。
打开search.pipe.ts并移除@Pipe装饰器的pure属性。同时,取以下语句:
if (searchTerm == null) return [...value];
然后将其更改为这样:
if (searchTerm == null) return [value];
在workout-history.component.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 管道无状态,只需撤销我们做的第一个更改,并在 Pipe 装饰器上添加 pure: false:
@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 的数据绑定基础设施确定哪些视图部分需要更新。每个数据绑定框架都需要解决这个问题,并且这些框架跟踪更改的方法各不相同。甚至从 AngularJS 到 Angular 也有所不同。
要理解 Angular 中的变更检测是如何工作的,我们需要注意以下几点。
-
一个 Angular 应用不过是由组件组成的层次结构,从根到叶。
-
我们绑定到视图的组件属性并没有什么特殊之处;因此,Angular 需要一个高效的机制来知道这些属性何时发生变化。它不能持续轮询这些属性的变化。
-
为了检测属性值的变化,Angular 在先前值和当前值之间进行严格比较(
===)。对于引用类型,这意味着只比较引用。不进行深度比较。
正是因为这个原因,我们不得不将我们的搜索管道标记为有状态的。向现有数组添加元素不会改变数组引用,因此 Angular 无法检测到数组中的任何变化。一旦管道被标记为有状态的,无论数组是否已更改,管道都会被评估。
由于 Angular 无法知道任何绑定属性何时自动更新,因此它会在触发变更检测运行时检查每个绑定属性。从组件树的根开始,Angular 在遍历组件层次结构时检查每个绑定属性的变化。如果检测到变化,则标记该组件为刷新。值得重申的是,绑定属性的变化并不会立即更新视图。相反,变更检测运行分为两个阶段。
-
在第一阶段,它遍历组件树,并标记因模型更新需要刷新的组件。
-
在第二阶段,实际视图与底层模型同步。
在变更检测运行期间,模型更改和视图更新永远不会交织在一起。
我们现在只需要回答两个更多的问题:
-
变更检测运行何时被触发?
-
它运行了多少次?
当以下任何事件被触发时,Angular 的变更检测运行会被触发:
-
用户输入/浏览器事件:我们点击按钮、输入文本、滚动内容。这些操作中的每一个都可以更新视图(以及底层模型)。
-
远程 XHR 请求:这是视图更新的另一个常见原因。从远程服务器获取数据以显示在网格上,以及获取用户数据以渲染视图都是这种情况的例子。
-
setTimeout 和 setInterval:实际上,我们可以使用
setTimeout和setInterval来异步执行一些代码,并在特定的时间间隔内执行。这样的代码也可以更新模型。例如,一个setInterval计时器可能会定期检查股票报价,并在 UI 上更新股票价格。
为了回答“多少次”,答案是 1 次。每个组件模型只检查一次,以自顶向下的方式,从根组件到树叶子。
当 Angular 配置为生产模式运行时,上述说法是正确的。在开发模式下,组件树会被遍历两次以检测更改。Angular 期望在第一次树遍历后模型是稳定的。如果情况不是这样,Angular 会在开发模式下抛出错误,并在生产模式下忽略更改。我们可以在调用bootstrap函数之前通过调用enableProdMode函数来启用生产模式。
现在是时候选择另一个与 Angular 的依赖注入相关的主题了。分层注入器的概念将成为我们接下来讨论的主题。这是一个非常强大的功能,在我们使用 Angular 构建更大更好的应用时非常有用。
分层注入器
在 Angular 的依赖注入设置中,注入器是一个容器,负责存储依赖项并在需要时提供它们。之前分享的提供者注册示例实际上是将依赖项注册到一个全局注入器中。
注册组件级别的依赖
我们迄今为止所做的所有依赖项注册都是在模块上完成的。Angular 更进一步,允许在组件级别注册依赖项。在@Component装饰器上也有类似的providers属性,允许我们在组件级别注册依赖项。
我们完全可以在WorkoutRunnerComponent上注册WorkoutHistoryTrackerService依赖项。大致如下:
@Component({
selector: 'abe-workout-runner',
providers: [WorkoutHistoryTrackerService]
...
})
但我们是否应该这样做是我们在这里讨论的问题。
在关于分层注入器的讨论背景下,重要的是要理解 Angular 为每个组件创建一个注入器(简化说明)。在组件级别完成的依赖注册在组件及其后代中都是可用的。
我们还了解到依赖项本质上是单例的。一旦创建,注入器将始终在每次请求时返回相同的依赖项。这一特性在锻炼历史实现中很明显。
WorkoutHistoryTrackerService与CoreModule注册,然后注入到两个组件中:WorkoutRunnerComponent和WorkoutHistoryComponent。这两个组件都获得WorkoutHistoryTrackerService的相同实例。下个图例突出了这个注册和注入:
为了确认,只需在WorkoutHistoryTrackerService构造函数中添加一个console.log语句:
console.log("WorkoutHistoryTrackerService instance created.")
通过点击页眉链接刷新应用并打开历史页面。无论我们运行锻炼或打开历史页面多少次,消息日志只生成一次。
这也是一种新的交互/数据流模式!
仔细思考;正在使用一个服务在两个组件之间共享状态。WorkoutRunnerComponent正在生成数据,而WorkoutHistoryComponent正在消费它。并且没有任何相互依赖。我们正在利用依赖本质上是单例的事实。这种数据共享/交互/数据流模式可以用来在任意数量的组件之间共享状态。实际上,这是我们武器库中非常强大的武器。下次需要在不相关的组件之间共享状态时,想想服务。
但这一切与分层注入器有什么关系?好吧,我们不再绕弯子;让我们直接进入正题。
虽然与注入器注册的依赖项是单例的,但Injector本身不是!在任何给定的时间点,应用程序中都有多个活动注入器。实际上,注入器是在与组件树相同的层次结构中创建的。Angular 为组件树中的每个组件创建一个Injector实例(这是一个简化的说法;请参见下一个信息框)。
Angular 并不是为每个组件都创建一个注入器。正如 Angular 开发者指南中解释的那样:每个组件不需要自己的注入器,为没有好处的目的创建大量的注入器将会非常低效。但确实,每个组件都有一个注入器(即使它与其他组件共享该注入器),并且可能存在许多不同的注入器实例在不同的组件树层级上运行。假装每个组件都有自己的注入器是有用的。
当一个锻炼正在进行时,组件和注入器树看起来大致如下:
插入文本框表示组件名称。根注入器是在应用程序引导过程中创建的注入器。
这个注入器层次结构的意义是什么?为了理解其影响,我们需要了解当组件请求依赖项时会发生什么。
Angular DI 依赖项遍历
当请求一个依赖项时,Angular 首先尝试从组件自己的注入器中满足依赖项。如果它找不到请求的依赖项,它会查询父组件注入器以获取依赖项,如果探测失败,它会查询父组件的父组件,以此类推,直到找到依赖项或达到根注入器。要点:任何依赖项搜索都是基于层次的。
在我们之前注册WorkoutHistoryTrackerService时,它是与根注入器一起注册的。来自WorkoutRunnerComponent和WorkoutHistoryComponent的WorkoutHistoryTrackerService依赖项请求由根注入器满足,而不是由它们自己的组件注入器满足。
这种层次注入器结构带来了很多灵活性。我们可以在不同的组件级别配置不同的提供者,并在子组件中覆盖父提供者配置。这仅适用于在组件上注册的依赖项。如果依赖项被添加到模块中,它将在根注入器上注册。
此外,如果依赖项在组件级别注册,其生命周期将与组件的生命周期绑定。每次组件加载时都会创建依赖项,当组件被销毁时销毁。与仅在第一次请求时创建的模块级别依赖项不同。
让我们在使用它的组件中尝试覆盖全局的WorkoutHistoryTrackerService服务,以了解在这样覆盖时会发生什么。这将很有趣,我们将学到很多!
打开workout-runner.component.ts并为@Component装饰器添加一个providers属性:
providers: [WorkoutHistoryTrackerService]
在workout-history.component.ts中也这样做。现在如果我们刷新应用,开始锻炼,然后加载历史页面,网格是空的。无论我们尝试运行锻炼多少次,历史网格总是空的。
原因很明显。在为每个WorkoutRunnerComponent和WorkoutHistoryComponent设置WorkoutHistoryTrackerService提供者之后,依赖项由各自的组件注入器本身来满足。当请求时,两个组件注入器都会创建自己的WorkoutHistoryTrackerService实例,因此历史跟踪被破坏。查看以下图表以了解在两种情况下请求是如何被满足的:
一个快速问题:如果我们不在模块上而是在根组件TrainerAppComponent中注册依赖项,会发生什么?类似于以下这样:
@Component({
selector: 'abe-root',
providers:[WorkoutHistoryTrackerService]
}
export class AppComponent {
有趣的是,在这种设置下,一切工作得都很完美。这一点很明显;TrainerAppComponent是RouterOutlet的父组件,它内部加载WorkoutRunnerComponent和WorkoutHistoryComponent。因此,在这种设置中,依赖项由TrainerAppComponent注入器来满足。
如果中间组件已声明自己为宿主组件,则可以在组件层次结构上操作依赖项查找。我们将在后面的章节中了解更多关于它的内容。
层次注入器允许我们在组件级别注册依赖项,避免需要全局注册所有依赖项。
此功能的典型用例是在构建 Angular 库组件时。此类组件可以注册自己的依赖项,而无需要求库的消费者注册库特定的依赖项。
记住:如果你在加载正确的服务/依赖项时遇到麻烦,请确保检查任何级别的组件层次结构中是否进行了覆盖。
我们现在理解了组件中依赖项解析的工作方式。但是,如果一个服务有依赖项会发生什么?还有更多未知的领域要探索。让我们进一步扩展我们的应用程序。
在继续进一步之前,移除在组件上完成的任何provider注册。
使用@Injectable进行依赖注入
WorkoutHistoryTrackerService有一个基本缺陷:历史数据没有持久化。刷新应用程序,历史数据就会丢失。我们需要添加持久化逻辑来存储历史数据。为了避免任何复杂的设置,我们将使用浏览器本地存储来存储历史数据。
通过从trainer/src/app/core文件夹调用此 CLI 命令添加一个新的LocalStorageService服务:
ng generate service local-storage
将以下两个函数复制到生成的类中,或者从checkpoint3.2GitHub 分支复制它们:
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对象的包装器。
就像任何其他依赖项一样,将其注入到WorkoutHistoryTrackerService构造函数(workout-history-tracker.ts文件)中,并使用必要的导入:
import {LocalStorage} from './local-storage';
...
constructor(private storage: LocalStorageService) {
建议在服务上应用默认的Injectable装饰器,即使我们在模块上注册了依赖项(NgModule提供者注册语法)。特别是当服务本身有依赖项时,就像前面的WorkoutHistoryTrackerService示例一样。在使用基于模块的服务注册时,不要使用Injectable的providedIn装饰器属性。
通过添加@Injectable装饰器,我们迫使 TypeScript 编译器为WorkoutHistoryTrackerService类生成元数据。这包括有关构造函数参数的详细信息。Angular DI(依赖注入)消耗这些生成的元数据以确定服务具有的依赖项类型,并在服务创建时满足这些依赖项。
那么,使用WorkoutHistoryTrackerService的WorkoutRunnerComponent呢?我们没有在那里使用@Injectable,但仍然,DI(依赖注入)工作。我们不需要。任何装饰器都行,并且已经应用了@Component装饰器到所有组件上。
实际上,LocalStorage服务与WorkoutHistoryTrackerService之间的集成是一个平凡的过程。
按照以下方式更新WorkoutHistoryTrackerService的构造函数:
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 = 'workouts';
构造函数从本地存储中加载锻炼日志。调用map函数是必要的,因为存储在localStorage中的所有内容都是字符串。因此,在反序列化时,我们需要将字符串转换回日期值。
在startTracking、exerciseComplete和endTracking函数中最后添加以下语句:
this.storage.setItem(this.storageKey, this.workoutHistory);
每当历史数据发生变化时,我们都会将锻炼历史保存到本地存储中。
就这样!我们已经通过localStorage构建了锻炼历史跟踪。验证一下!
在我们继续到重要项目——音频支持之前,需要做一些小的修复以提供更好的用户体验。第一个修复与“历史”链接有关。
使用路由服务跟踪路由更改
在 Header 组件中,History 链接对所有路由都是可见的,除了当锻炼正在进行时。我们不希望用户不小心点击历史链接而丢失正在进行的锻炼。此外,在锻炼时,没有人对了解锻炼历史感兴趣。
修复很简单。我们只需要确定当前路由是否是锻炼路由,并隐藏链接。Router 服务将帮助我们完成这项工作。
打开 header.component.ts 并查看高亮显示的实现:
import { Router, NavigationEnd } from '@angular/router';
import 'rxjs/add/operator/filter'; ...
export class HeaderComponent {
private showHistoryLink= true;
constructor(private router: Router) {
this.router.events.pipe(
filter(e => e instanceof NavigationEnd))
.subscribe((e: NavigationEnd) => {
this.showHistoryLink = !e.url.startsWith('/workout');
});
}
showHistoryLink 属性绑定到视图,并决定是否向用户显示历史链接。在构造函数中,我们注入 Router 服务并使用 subscribe 函数订阅 events 可观察对象。
我们将在本章后面学习更多关于可观察对象的知识,但就目前而言,了解可观察对象是引发事件的对象,并且可以被订阅就足够了。由于路由器在整个组件生命周期中引发了许多事件,filter 操作符允许我们过滤我们感兴趣的事件,而 subscribe 函数注册了一个回调函数,该函数在每次路由更改时被调用。
要了解其他路由器事件,包括 NavigationStart、NavigationEnd、NavigationCancel 和 NavigationError,请查看路由器文档 (bit.ly/ng-router-events) 以了解事件何时被引发。
回调实现只是根据当前路由 URL 切换 showHistoryLink 状态。要在视图中使用 showHistoryLink,只需更新标题模板行中的锚点标签为:
<li *ngIf="showHistoryLink"><a routerLink="/history" ...>...</a></li>
就这样!在锻炼页面上不会显示 History 链接。
如果你在运行代码时遇到问题,请查看 checkpoint3.2 Git 分支以获取我们迄今为止所做的工作的版本。或者如果你不使用 Git,请从 bit.ly/ng6be-checkpoint-3-2 下载 checkpoint3.2 的快照(ZIP 文件)。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。
另一个修复/增强与锻炼页面上的视频面板有关。
修复视频播放体验
当前视频面板的实现最多只能称为业余水平。默认播放器的尺寸很小。当我们播放视频时,锻炼不会暂停。在锻炼转换时,视频播放会被中断。此外,整体视频加载体验在每次锻炼程序开始时都会增加明显的延迟。这清楚地表明,这种视频播放方法需要一些修复。
这是我们将要做的来修复视频面板:
-
显示锻炼视频的缩略图而不是加载视频播放器本身
-
当用户点击缩略图时,加载一个包含更大视频播放器的弹出/对话框,该播放器可以播放所选视频
-
在视频播放时暂停锻炼
让我们开始工作吧!
使用视频缩略图
将video-player.component.html中的ngForHTML 模板替换为以下片段:
<div *ngFor="let video of videos" class="row">
<div class="col-sm-12 p-2">
<img class="video-image" [src]="'//i.ytimg.com/vi/'+video+'/hqdefault.jpg'" />
</div>
</div>
我们已经放弃了 iframe,而是加载了视频的缩略图图像(检查img标签)。这里显示的所有其他内容都是为了样式化图像。
我们参考了 Stack Overflow 帖子(bit.ly/so-yt-thumbnail)来确定我们视频的缩略图图像 URL。
开始一个新的锻炼;图像应该会显示出来,但播放功能是损坏的。我们需要添加一个视频播放对话框。
使用 ngx-modialog 库
要在对话框中显示视频,我们将集成一个第三方库,ngx-modialog,可在 GitHub 上找到,bit.ly/ngx-modialog。让我们安装和配置这个库。
在命令行(在trainer文件夹内),运行以下命令来安装库:
npm i ngx-modialog@5.0.0 --save
正在进行的 Angular v6 兼容的ngx-modialog库工作(github.com/shlomiassaf/ngx-modialog/issues/426)。要使用依赖于较旧版本的 RxJS 的版本 5 库,在继续之前,从命令行安装rxjs-compat包,npm i rxjs-compat --save。
接下来在核心模块中导入和配置库。打开core.module.ts并添加以下高亮配置:
import { RouterModule } from '@angular/router';
import { ModalModule } from 'ngx-modialog';
import { BootstrapModalModule } from 'ngx-modialog/plugins/bootstrap';
...
imports: [
...
ModalModule.forRoot(),
BootstrapModalModule
],
现在库已经准备好使用。
虽然ngx-modialog提供了一些预定义的模板用于标准对话框,如警告、提示和确认,但这些对话框在外观和感觉方面提供的定制很少。为了更好地控制对话框 UI,我们需要创建一个自定义对话框,幸运的是,这个库支持这样做。
创建自定义对话框
ngx-modialog中的自定义对话框不过是包含了一些特殊库结构的 Angular 组件。
让我们从构建一个显示 YouTube 视频的弹出对话框的视频对话框组件开始。通过导航到trainer/src/app/workout-runner/video-player并运行以下命令来生成组件的模板:
ng generate component video-dialog -is
从checkpoint3.3Git 分支(GitHub 位置:bit.ly/ng6be-3-3-video-dialog)中的workout-runner/video-player/video-dialog文件夹复制视频对话框实现到您的本地设置中。您需要更新组件实现和视图。
接下来,更新workout-runner.module.ts并在模块装饰器中添加一个新的entryComponents属性:
...
declarations: [..., VideoDialogComponent],
entryComponents:[VideoDialogComponent]
新创建的VideoDialogComponent需要添加到entryComponents中,因为它在组件树中没有被明确使用。
VideoDialogComponent 是一个标准的 Angular 组件,包含一些模态对话框和特定的实现,我们将在后面进行描述。在 VideoDialogComponent 内部声明的 VideoDialogContext 类是为了将点击的 YouTube 视频的 videoId 传递给对话框实例而创建的。库使用这个上下文类在调用代码和模态对话框之间传递数据。VideoDialogContext 类继承了一个配置类,该配置类是对话框库用来改变模态对话框的行为和 UI 的 BSModalContext。
为了更好地了解 VideoDialogContext 的使用方法,让我们在点击视频图片时从锻炼运行器调用前面的对话框。
更新 video-player.component.html 中的 ngFor div 并添加一个 click 事件处理器:
<div *ngFor="let video of videos" (click)="playVideo(video)" ...>
前面的处理器调用 playVideo 方法,传入点击的视频。playVideo 函数反过来打开相应的视频对话框。将 playVideo 实现添加到 video-player.component.ts 中,如下所示:
import { Modal } from 'ngx-modialog/plugins/bootstrap';
import { VideoDialogComponent, VideoDialogContext } from './video-dialog/video-dialog.component';
import { overlayConfigFactory } from 'ngx-modialog';
...
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 方法,传入要打开的对话框组件和一个包含 YouTube 视频的 videoId 的新实例的 VideoDialogContext 类。在继续之前,删除 ngOnChange 函数和接口声明。
回到 VideoDialogComponent 的实现,该组件实现了模态库所需的 ModalComponent<VideoDialogContext> 接口。看看上下文(VideoDialogContext)是如何传递给构造函数的,以及我们是如何从上下文中提取并分配 videoId 属性的。然后只需将 videoId 属性绑定到模板视图(见 HTML 模板)并渲染 YouTube 播放器即可。
我们就可以开始了。加载应用并开始锻炼。然后点击任何锻炼视频图片。视频对话框应该会加载,现在我们可以观看视频了!
在我们调用对话框实现完成之前,还有一个小问题需要修复。当对话框打开时,锻炼应该暂停:目前还没有这样做。我们将在下一节的末尾使用 Angular 的事件基础设施帮助你修复它。
如果你在运行代码时遇到问题,请查看 checkpoint3.3 Git 分支,那里有我们到目前为止所做的工作的可用版本。或者如果你不使用 Git,可以从 bit.ly/ng6be-checkpoint-3-3 下载 checkpoint3.3 的快照(ZIP 文件)。在第一次设置快照时,请参考 trainer 文件夹中的 README.md 文件。
在完成应用并使用 Angular 构建新的东西之前,我们计划向 7-Minute Workout 添加最后一个功能:音频支持。这也教会了我们一些新的跨组件通信模式。
使用 Angular 事件进行跨组件通信
在上一章学习 Angular 的绑定基础设施时,我们提到了事件。现在是时候更深入地研究事件了。让我们为7 分钟健身法添加音频支持。
使用音频跟踪锻炼进度
对于7 分钟健身法应用程序,添加声音支持至关重要。一个人不能在一直盯着屏幕的同时进行锻炼。音频线索帮助用户有效地完成锻炼,因为他们只需遵循音频指示。
这里是我们将如何使用音频线索支持练习跟踪的方法:
-
一个滴答作响的时钟音轨在锻炼期间显示进度
-
中途指示器发出声音,表示锻炼已进行了一半
-
当练习即将结束时,会播放一个完成练习的音频剪辑
-
在休息阶段播放音频剪辑,并通知用户下一个练习
每种情况都会有音频剪辑。
现代浏览器对音频有很好的支持。HTML5 <audio> 标签提供了一个将音频剪辑嵌入 HTML 内容的方法。我们也将使用<audio>标签来播放我们的剪辑。
由于计划使用 HTML <audio> 元素,我们需要创建一个包装指令,以便我们从 Angular 控制音频元素。记住,指令是 HTML 扩展,但没有视图。
checkpoint3.4 Git 和trainer/static/audio文件夹包含所有用于播放的音频文件;首先复制它们。如果你不使用 Git,本章代码的快照可在bit.ly/ng6be-checkpoint-3-4找到。下载并解压缩内容,然后复制音频文件。
构建 Angular 指令以包装 HTML 音频
如果你与 JavaScript 和 jQuery 有很多工作,你可能已经意识到我们故意避免直接访问 DOM 来进行任何组件实现。我们没有这个必要。Angular 的数据绑定基础设施,包括属性、属性和事件绑定,帮助我们操作 HTML 而不接触 DOM。
对于音频元素,访问模式也应该是 Angular 风格的。在 Angular 中,唯一可以接受并实践直接 DOM 操作的地方是在指令内部。让我们创建一个包装音频元素访问的指令。
导航到trainer/src/app/shared并运行此命令以生成模板指令:
ng generate directive my-audio
由于这是我们第一次创建指令,我们鼓励您查看生成的代码。
由于指令添加到共享模块中,因此也需要导出。在exports数组中添加MyAudioDirective引用(shared.module.ts)。然后使用以下代码更新指令定义:
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 属性允许框架识别应用指令的位置。我们将生成的 [abeMyAudioDirective] 属性选择器替换为 audio。使用 audio 作为选择器使得我们的指令为 HTML 中使用的每个 <audio> 标签加载。新的选择器作为一个元素选择器工作。
在标准场景中,指令选择器是基于属性的(例如,[abeMyAudioDirective] 用于生成的代码),这有助于我们识别指令被应用的位置。我们偏离了这个规范,并为 MyAudioDirective 指令使用了一个元素选择器。我们希望这个指令为每个音频元素加载,因此逐个音频声明添加特定指令的属性变得繁琐。因此,我们使用了元素选择器。
当我们在视图模板中使用此指令时,exportAs 的使用变得清晰。
构造函数中注入的 ElementRef 对象是 Angular 元素(在这种情况下是 audio),该指令被加载。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)和三个获取器(currentTime、duration 和一个名为 playbackComplete 的布尔属性)。这些函数和属性的实现只是封装了音频元素函数。
在此处了解这些音频函数的 MDN 文档:bit.ly/html-media-element。
要了解我们如何使用音频指令,让我们创建一个新的组件来管理音频播放。
为音频支持创建 WorkoutAudioComponent
如果我们回顾一下所需的音频提示,有四个不同的音频提示,因此我们将创建一个包含五个嵌入 <audio> 标签的组件(两个音频标签一起用于下一个音频)。
从命令行进入 trainer/src/app/workout-runner 文件夹,并使用 Angular CLI 添加一个新的 WorkoutAudioComponent 组件。
打开 workout-audio.component.html 并将现有的视图模板替换为以下 HTML 片段:
<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
<audio #nextUpExercise="MyAudio" [src]="'/assets/audio/' + nextupSound"></audio>
<audio #halfway="MyAudio" src="img/15seconds.wav"></audio>
<audio #aboutToComplete="MyAudio" src="img/321.wav"></audio>
有五个 <audio> 标签,每个标签对应以下内容:
-
滴答音频:第一个音频标签产生滴答声,一旦锻炼开始就立即启动。
-
下一个音频和练习音频:接下来的两个音频标签一起工作。第一个标签产生“下一个”声音。实际的练习音频由第三个标签(在之前的代码片段中)处理。
-
** halfway 音频**:第四个音频标签在练习进行到一半时播放。
-
即将完成音频:最后的音频标签播放一段音乐以表示练习的完成。
你注意到每个audio标签中使用的#符号了吗?有一些变量赋值以#开头。在 Angular 世界中,这些变量被称为模板引用变量或有时称为模板变量。
根据平台指南定义:
模板引用变量通常是对模板中的 DOM 元素或指令的引用。
不要将它们与我们在之前的ngFor指令中使用的模板输入变量混淆,即*ngFor="let video of videos"。模板输入变量(在这种情况下为video)的作用域在其声明的 HTML 片段内,而模板引用变量可以在整个模板中访问。
看看定义MyAudioDirective的最后部分。exportAs元数据设置为MyAudio。我们在为每个音频标签分配template reference variable时重复相同的MyAudio字符串:
#ticks="MyAudio"
exportAs的作用是定义可以在视图中使用的名称,以便将此指令分配给变量。记住,单个元素/组件可以应用多个指令。exportAs允许我们根据等号右侧的内容选择将哪个指令分配给模板引用变量。
通常,一旦声明,模板变量就可以访问它们附加到的视图元素/组件,以及其他视图部分,我们将在稍后讨论这一点。但在我们的情况下,我们将使用模板变量来引用父组件代码中的多个MyAudioDirective。让我们了解如何使用它们。
使用以下大纲更新生成的workout-audio.compnent.ts:
import { Component, OnInit, ViewChild } from '@angular/core';
import { MyAudioDirective } from '../../shared/my-audio.directive';
@Component({
...
})
export class WorkoutAudioComponent implements OnInit {
@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;
constructor() { }
...
}
这个大纲中有趣的部分是对五个属性的反向@ViewChild装饰器。@ViewChild装饰器允许我们将子组件/指令/元素引用注入其父组件。传递给装饰器的参数是模板变量名称,这有助于 DI 匹配要注入的元素/指令。当 Angular 实例化主WorkoutAudioComponent时,它根据@ViewChild装饰器和传递的模板引用变量名称注入相应的音频指令。在我们详细查看@ViewChild之前,让我们完成基本类实现。
在MyAudioDirective指令上未设置exportAs的情况下,@ViewChild注入将相关ElementRef实例注入,而不是MyAudioDirective实例。我们可以通过从myAudioDirective中移除exportAs属性,然后在WorkoutAudioComponent中查看注入的依赖项来确认这一点。
剩下的任务只是正确地在正确的时间播放正确的音频组件。将这些函数添加到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);
}
}
写这些函数有困难吗?它们在 checkpoint3.3 Git 分支中可用。
在前面的代码中使用了两个新的模型类。将它们的声明添加到 model.ts 中,如下所示(同样在 checkpoint3.3 中可用):
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 实现消费这些数据。请记住在 workout-audio.component.ts 中导入 ExerciseProgressEvent 和 ExerciseProgressEvent 的引用。
再次强调,音频组件通过定义两个事件处理器来消费事件:onExerciseProgress 和 onExerciseChanged。随着我们的深入,事件是如何生成的将变得清晰。
start 和 resume 函数在锻炼开始、暂停或完成时停止和恢复音频。resume 函数的额外复杂性在于处理在下一个即将完成或音频播放中途暂停的锻炼情况。我们只想从我们离开的地方继续。
应该调用 onExerciseProgress 函数来报告锻炼进度。它用于根据锻炼的状态播放中途音频和即将完成的音频。传递给它的参数是一个包含锻炼进度数据的对象。
当锻炼改变时,应该调用 onExerciseChanged 函数。输入参数包含当前和下一个即将进行的锻炼,并帮助 WorkoutAudioComponent 决定何时播放下一个即将进行的锻炼音频。
在本节中,我们提到了两个新的概念:模板引用变量和将子元素/指令注入父元素。在我们继续实施之前,值得更详细地探索这两个概念。我们将从学习更多关于模板引用变量开始。
理解模板引用变量
模板引用变量是在视图模板上创建的,并且主要从视图中被消费。正如你已经学到的,这些变量可以通过用于声明它们的 # 前缀来识别。
模板变量的最大好处之一是它们在视图模板级别促进了跨组件通信。一旦声明,这些变量就可以被兄弟元素/组件及其子元素引用。查看以下片段:
<input #emailId type="email">Email to {{emailId.value}}
<button (click)= "MailUser(emaild.value)">Send</button>
emailId, and then references it in the interpolation and the button click expression.
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="img/tick10s.mp3"></audio>
<input #emailId type="email">Email to {{emailId.value}}
<workout-runner #runner></workout-runner>
分配给变量的内容取决于变量在哪里声明。这由 Angular 的规则控制:
-
如果元素上存在指令,例如在前面示例中显示的
MyAudioDirective,则指令设置值。MyAudioDirective指令将ticks变量设置为MyAudioDirective的一个实例。 -
如果没有指令存在,则将分配底层的 HTML DOM 元素或组件对象(如
input和workout-runner示例中所示)。
我们将使用这项技术来实现锻炼音频组件与锻炼运行组件的集成。这种介绍为我们提供了我们需要的先发优势。
我们承诺要介绍的其他新概念是使用 ViewChild 和 ViewChildren 装饰器进行子元素/指令注入。
使用 @ViewChild 装饰器
@ViewChild 装饰器指示 Angular DI 框架在组件树中搜索一些特定的子组件/指令/元素,并将其注入到父组件中。这允许父组件通过子组件的引用与子组件/元素进行交互,这是一种新的通信模式!
在前面的代码中,音频元素指令(MyAudioDirective 类)被注入到 WorkoutAudioComponent 代码中。
为了建立上下文,让我们重新检查 WorkoutAudioComponent 的一个视图片段:
<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
Angular 将指令(MyAudioDirective)注入到 WorkoutAudioComponent 的属性 ticks 中。搜索是基于传递给 @ViewChild 装饰器的选择器进行的。让我们再次看看音频示例:
@ViewChild('ticks') private ticks: MyAudioDirective;
ViewChild 上的选择器参数可以是一个字符串值,在这种情况下,Angular 会搜索匹配的模板变量,就像之前一样。
或者它可以是类型。这是有效的,应该注入 MyAudioDirective 的一个实例:
@ViewChild(MyAudioDirective) private ticks: MyAudioDirective;
然而,在我们的情况下它不起作用。为什么?因为 WorkoutAudioComponent 视图中声明了多个 MyAudioDirective 指令,每个 <audio> 标签一个。在这种情况下,第一个匹配项被注入。这并不很有用。如果视图中只有一个 <audio> 标签,传递类型选择器就会起作用!
被 @ViewChild 装饰的属性在组件的 ngAfterViewInit 事件钩子被调用之前一定会被设置。这意味着如果在这个构造函数内部访问这些属性,它们将是 null。
Angular 还有一个装饰器可以定位和注入多个子组件/指令:@ViewChildren。
@ViewChildren 装饰器
@ViewChildren 与 @ViewChild 的工作方式类似,但它可以用来将多个子类型注入父组件。再次以之前的音频组件为例,使用 @ViewChildren,我们可以获取 WorkoutAudioComponent 中的所有 MyAudioDirective 指令实例,如下所示:
@ViewChildren(MyAudioDirective) allAudios: QueryList<MyAudioDirective>;
仔细观察;allAudios 不是一个标准的 JavaScript 数组,而是一个自定义类,QueryList<Type>。QueryList 类是一个不可变集合,包含 Angular 根据传递给 @ViewChildren 装饰器的筛选标准所能定位到的组件/指令的引用。这个列表最好的地方是 Angular 将会保持这个列表与视图状态同步。当指令/组件在视图中动态添加/删除时,这个列表也会更新。使用 ng-for 生成的组件/指令是这种动态行为的典型例子。考虑之前的 @ViewChildren 使用和这个视图模板:
<audio *ngFor="let clip of clips" src="img/ "+{{clip}}></audio>
Angular 创建的 MyAudioDirective 指令的数量取决于 clips 的数量。当使用 @ViewChildren 时,Angular 将正确的 MyAudioDirective 实例数量注入到 allAudio 属性中,并在向 clips 数组添加或删除项目时保持同步。
虽然 @ViewChildren 的使用允许我们获取所有 MyAudioDirective 指令,但它不能用来控制播放。你看,我们需要获取单个 MyAudioDirective 实例,因为音频播放的时间不同。因此,有独特的 @ViewChild 实现。
一旦我们掌握了每个音频元素附加的 MyAudioDirective 指令,就只需在正确的时间播放音频轨道。
集成 WorkoutAudioComponent
虽然我们已经将音频播放功能组件化到 WorkoutAudioComponent 中,但它始终与 WorkoutRunnerComponent 的实现紧密耦合。WorkoutAudioComponent 从 WorkoutRunnerComponent 中获取其操作智能。因此,这两个组件需要交互。WorkoutRunnerComponent 需要提供 WorkoutAudioComponent 状态变化数据,包括锻炼开始、运动进度、锻炼停止、暂停和恢复。
实现这种集成的一种方法是在 WorkoutRunnerComponent 中使用当前公开的 WorkoutAudioComponent API(停止、恢复和其他函数)。
可以通过将 WorkoutAudioComponent 注入到 WorkoutRunnerComponent 中来完成某些操作,就像我们之前将 MyAudioDirective 注入到 WorkoutAudioComponent 中一样。
在 WorkoutRunnerComponent's 视图中声明 WorkoutAudioComponent,例如:
<div class="row pt-4">...</div>
<abe-workout-audio></abe-workout-audio>
这样做给了我们 WorkoutRunnerComponent 实现内部的 WorkoutAudioComponent 引用:
@ViewChild(WorkoutAudioComponent) workoutAudioPlayer: WorkoutAudioComponent;
然后,可以从代码的不同位置调用 WorkoutAudioComponent 的功能,从 WorkoutRunnerComponent 中调用。例如,这是 pause 如何改变的方式:
pause() {
clearInterval(this.exerciseTrackingInterval);
this.workoutPaused = true;
this.workoutAudioPlayer.stop();
}
要播放下一个音频,我们需要更改startExerciseTimeTracking函数的部分:
this.startExercise(next);
this.workoutAudioPlayer.onExerciseChanged(new ExerciseChangedEvent(next, this.getNextExercise()));
这是一个完全可行的选项,其中WorkoutAudioComponent成为由WorkoutRunnerComponent控制的哑组件。这个解决方案的唯一问题是它给WorkoutRunnerComponent的实现增加了一些噪音。WorkoutRunnerComponent现在还需要管理音频播放。
然而,有一个替代方案。
WorkoutRunnerComponent可以暴露在健身执行的不同时间触发的事件,例如健身开始、练习开始和健身暂停。拥有WorkoutRunnerComponent暴露事件的优点是,它允许我们使用相同的事件将其他组件/指令与WorkoutRunnerComponent集成。无论是WorkoutAudioComponent还是我们未来创建的组件。
暴露WorkoutRunnerComponent事件
到目前为止,我们只探讨了如何消费事件。Angular 还允许我们触发事件。Angular 组件和指令可以使用EventEmitter类和@Output装饰器来暴露自定义事件。
在变量声明部分的末尾添加这些事件声明:
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和ExerciseChangeEvent导入到已经声明的model中。并将Output和EventEmitter导入到@angular/core中。
让我们尝试理解@Output装饰器和EventEmitter类的作用。
@Output装饰器
在第二章“构建我们的第一个应用 - 7 分钟健身”中,我们介绍了相当多的 Angular 事件处理能力。具体来说,我们学习了如何使用括号()语法在组件、指令或 DOM 元素上消费任何事件。那么,我们自己触发事件呢?
在 Angular 中,我们可以创建和触发我们自己的事件,这些事件表示在我们的组件/指令中发生了值得注意的事情。使用@Output装饰器和EventEmitter类,我们可以定义和触发自定义事件。
这也是回顾我们在第二章“构建我们的第一个应用 - 7 分钟健身”中学习的关于事件知识的好时机,重新查看“事件处理子节”和“Angular 事件绑定基础设施”部分。
记住这一点:组件通过事件与外部世界进行通信。当我们声明:
@Output() exercisePaused: EventEmitter<number> = new EventEmitter<number>();
这表示WorkoutRunnerComponent暴露了一个事件exercisePaused(在健身暂停时触发)。
要订阅此事件,我们可以执行以下操作:
<abe-workout-runner (exercisePaused)="onExercisePaused($event)"></abe-workout-runner>
这看起来与我们在健身运行者模板中进行的 DOM 事件订阅非常相似。看看这个从健身运行者视图摘录的示例:
<div id="pause-overlay" (click)="pauseResumeToggle()" (window:keyup)="onKeyPressed($event)">
@Output 装饰器指示 Angular 使此事件可用于模板绑定。没有 @Output 装饰器创建的事件不能在 HTML 中引用。
@Output 装饰器还可以接受一个参数,表示事件的名称。如果没有提供,装饰器将使用属性名称:@Output("workoutPaused") exercisePaused: EventEmitter<number> ...。这声明了一个 workoutPaused 事件而不是 exercisePaused。
就像任何装饰器一样,@Output 装饰器只是为了向 Angular 框架提供元数据。真正的重活是由 EventEmitter 类完成的。
使用 EventEmitter 的事件处理
Angular 采用 reactive programming(也称为 Rx-style programming)来支持使用事件进行异步操作。如果你第一次听到这个术语或者对反应式编程不太了解,你并不孤单。
反应式编程完全是关于针对 异步数据流 进行编程。这样的流不过是基于它们发生的时间顺序排列的一系列持续事件。我们可以想象一个流是一个生成数据(以某种方式)并将其推送到一个或多个订阅者的管道。由于这些事件是由订阅者异步捕获的,因此它们被称为异步数据流。
数据可以是任何东西,从浏览器/DOM 元素事件到用户输入,再到使用 AJAX 加载远程数据。使用 Rx 风格,我们统一消费这些数据。
在 Rx 世界中,有观察者和可观察者,这是一个来自非常流行的 观察者设计模式 的概念。可观察者是发出数据的流。另一方面,观察者订阅这些事件。
Angular 中的 EventEmitter 类主要负责提供事件支持。它既充当 观察者 又充当 可观察者。我们可以在它上面触发事件,它也可以监听事件。
EventEmitter 上有两个对我们有意义的函数:
-
emit:正如其名所示,使用此函数来触发事件。它接受一个单一参数,即事件数据。"emit" 是 可观察者端。 -
subscribe:使用此函数来订阅由EventEmitter引发的事件。"subscribe" 是观察者端。
让我们进行一些事件发布和订阅,以了解前面函数的工作方式。
从 WorkoutRunnerComponent 中引发事件
看一下 EventEmitter 的声明。这些已经使用 type 参数声明。EventEmitter 上的 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
这两个组件现在只需要相互通信。
如果父组件需要与其子组件通信,它可以这样做:
- 属性绑定:父组件可以设置子组件的属性绑定,以便将数据推送到子组件。例如,这种属性绑定可以在锻炼暂停时停止音频播放:
<workout-audio [stopped]="workoutPaused"></workout-audio>
在这种情况下,属性绑定工作得很好。当锻炼暂停时,音频也会停止。但并不是所有情况都可以使用属性绑定来处理。播放下一个练习音频或半程音频需要更多的控制。
- 在子组件上调用函数:如果父组件能够获取到子组件,它也可以在子组件上调用函数。我们已经在
WorkoutAudioComponent实现中看到了如何使用@ViewChild和@ViewChildren装饰器来实现这一点。这种方法及其不足之处也在集成 WorkoutAudioComponent部分中简要讨论过。
还有一个不太好的选择。不是父组件引用子组件,而是子组件引用父组件。这允许子组件调用父组件的公共函数或订阅父组件的事件。
我们将尝试这种方法,然后废弃实现,寻找更好的方案!从我们计划实施的不是很理想的解决方案中可以学到很多。
将父组件注入到子组件中
在WorkoutRunnerComponent视图中的最后一个关闭div之前添加WorkoutAudioComponent:
<abe-workout-audio></abe-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,我们创建了一个依赖循环。
循环依赖对于任何依赖注入框架来说都是一项挑战。当创建具有循环依赖的组件时,框架必须以某种方式解决这个循环。在先前的例子中,我们通过使用@Inject装饰器和传递使用forwardRef()全局框架函数创建的令牌来解决循环依赖问题。
一旦正确完成注入,在构造函数中,我们使用EventEmitter的subscribe函数将处理程序附加到WorkoutRunnerComponent事件。传递给subscribe的箭头函数在事件发生时(带有特定的事件参数)被调用。我们将所有订阅收集到一个subscription数组中。当我们需要取消订阅以避免内存泄漏时,这个数组非常有用。
关于EventEmitter的一些信息:EventEmitter的订阅(subscribe函数)接受三个参数:
subscribe(generatorOrNext?: any, error?: any, complete?: any) : any
-
第一个参数是一个回调,它在事件发出时被调用
-
第二个参数是一个错误回调函数,当可观察者(生成事件的那个部分)出错时被调用
-
最后一个参数接受一个回调函数,当可观察者完成发布事件时会被调用
我们已经做了足够的事情来使音频集成工作。运行应用程序并开始锻炼。除了滴答声之外,所有的\音频剪辑都在正确的时间播放。你可能需要等待一段时间才能听到其他音频剪辑。问题是什么?
结果表明,我们在锻炼开始时从未启动滴答声剪辑。我们可以通过在ticks音频元素上设置autoplay属性或使用组件生命周期事件来触发滴答声来修复它。让我们采取第二种方法。
使用组件生命周期事件
在WorkoutAudioComponent中注入的MyAudioDirective,如下所示,在视图初始化之前不可用:
<audio #ticks="MyAudio" loop src="img/tick10s.mp3"></audio>
<audio #nextUp="MyAudio" src="img/nextup.mp3"></audio>
...
我们可以通过在构造函数中访问ticks变量来验证它;它将是 null。Angular 还没有完成它的魔法,我们需要等待WorkoutAudioComponent的子组件初始化。
组件的生命周期钩子可以帮助我们在这里。当组件的视图初始化完成后,会调用AfterViewInit事件钩子,因此这是一个安全的地方,可以从中访问组件的子指令/元素。让我们快速完成它。
通过添加接口实现和必要的导入来更新WorkoutAudioComponent,如高亮所示:
import {..., AfterViewInit} from '@angular/core';
...
export class WorkoutAudioComponent implements OnInit, AfterViewInit {
ngAfterViewInit() {
this.ticks.start();
}
好吧,去测试一下应用。应用现在有了完整的音频反馈,很棒!
虽然表面上看起来一切都很正常,但现在应用程序中存在内存泄漏。如果在锻炼过程中我们离开锻炼页面(到开始或结束页面),然后再返回到锻炼页面,多个音频剪辑会在随机时间播放。
看起来WorkoutRunnerComponent在路由导航时没有被销毁,因此,包括WorkoutAudioComponent在内的所有子组件都没有被销毁。最终结果是什么?每次我们导航到锻炼页面时都会创建一个新的WorkoutRunnerComponent,但在离开导航时却从未从内存中移除。
这种内存泄漏的主要原因是我们添加到WorkoutAudioComponent中的事件处理器。当音频组件卸载时,我们需要从这些事件中取消订阅,否则WorkoutRunnerComponent引用将永远不会被解除引用。
另一个组件生命周期事件在这里发挥了救星的作用:OnDestroy。将以下实现添加到WorkoutAudioComponent类中:
ngOnDestroy() {
this.subscriptions.forEach((s) => s.unsubscribe());
}
还记得要添加对OnDestroy事件接口的引用,就像我们为AfterViewInit所做的那样。
希望我们在事件订阅期间创建的subscription数组现在看起来是有意义的。一次性取消订阅!
这种音频集成现在已完成。虽然这种方法不是整合两个组件的糟糕方式,但我们能做得更好。子组件引用父组件似乎是不受欢迎的。
在继续之前,请删除从workout-audio.component.ts的`将父组件注入到子组件中部分开始添加的代码。
使用事件和模板变量进行兄弟组件交互
如果WorkoutRunnerComponent和WorkoutAudioComponent被组织为兄弟组件会怎样?
如果WorkoutAudioComponent和WorkoutRunnerComponent成为兄弟组件,我们可以充分利用 Angular 的事件和模板引用变量。困惑吗?好吧,首先,组件应该这样布局:
<workout-runner></workout-runner>
<workout-audio></workout-audio>
这让你想起什么吗?从这个模板开始,你能猜出最终的 HTML 模板会是什么样子吗?在你继续之前先想想。
仍然感到困惑?一旦我们将它们作为兄弟组件,Angular 模板引擎的力量就显现出来了。以下模板代码足以整合WorkoutRunnerComponent和WorkoutAudioComponent:
<abe-workout-runner (exercisePaused)="wa.stop()"
(exerciseResumed)="wa.resume()"
(exerciseProgress)= "wa.onExerciseProgress($event)"
(exerciseChanged)= "wa.onExerciseChanged($event)"
(workoutComplete)="wa.stop()"
(workoutStarted)="wa.resume()">
</abe-workout-runner>
<abe-workout-audio #wa></abe-workout-audio>
WorkoutAudioComponent模板变量wa通过在WorkoutRunnerComponent的事件处理器表达式中引用变量来被操作。相当优雅!我们仍然需要解决这个方法中最大的难题:前面的代码去哪里了?记住,WorkoutRunnerComponent是作为路由加载的一部分加载的。在代码的任何地方都没有这样的语句:
<workout-runner></workout-runner>
我们需要重新组织组件树,引入一个可以托管WorkoutRunnerComponent和WorkoutAudioComponent的容器组件。然后路由器加载这个容器组件而不是WorkoutRunnerComponent。让我们开始吧。
通过导航到trainer/src/app/workout-runner并在命令行中执行来生成新的组件代码:
ng generate component workout-container -is
将描述事件的 HTML 代码复制到模板文件中。锻炼容器组件就准备好了。
我们只需要重新配置路由设置。打开app-routing.module.ts。更改锻炼运行者的路由并添加必要的导入:
import {WorkoutContainerComponent}
from './workout-runner/workout-container/workout-container.component';
..
{ path: '/workout', component: WorkoutContainerComponent },
我们有一个清晰、简洁且令人愉悦的音频集成工作!
现在是结束本章的时候了,但在结束之前,我们需要解决在早期部分引入的视频播放器对话框故障。当视频播放器对话框打开时,锻炼不会停止/暂停。
我们不会在这里详细说明修复方法,并敦促读者在没有查阅checkpoint3.4代码的情况下尝试修复。
这里有一个明显的提示。使用事件基础设施!
另一个提示:从VideoPlayerComponent引发事件,每个播放开始和结束时引发一个。
最后一个提示:对话框服务(Modal)上的open函数返回一个承诺,当对话框关闭时该承诺被解决。
如果你在运行代码时遇到问题,请查看checkpoint3.4Git 分支,以获取我们迄今为止所做工作的一个工作版本。或者如果你不使用 Git,请从bit.ly/ng6be-checkpoint-3-4下载checkpoint3.4的快照(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 分钟训练 应用程序转变为一个通用的 训练运行者 应用程序,该应用程序可以运行我们使用 个人教练 构建的训练。
在下一章中,我们将展示 Angular 的表单功能,同时构建一个 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 分钟锻炼应用程序时定义的。锻炼和锻炼的两个核心概念对个人教练同样适用。
现有的锻炼模型唯一的问题是它位于workout-runner目录中。这意味着为了使用它,我们必须从该目录导入它。将模型移动到core文件夹中更有意义,这样就可以清楚地知道它可以跨功能使用。我们将在本章中这样做。
开始编写个人教练的代码
首先,从 GitHub 仓库中书的checkpoint4.1下载新的个人教练应用程序的基础版本。
代码可在 GitHub github.com/chandermani/angular6byexample 上供大家下载。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.1。如果你不使用 Git,可以从以下 GitHub 位置下载 Checkpoint 4.1 的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.1.zip。在首次设置快照时,请参考 trainer 文件夹中的 README.md 文件。
这段代码包含了完整的 7 分钟健身 (Workout Runner) 应用。我们添加了一些更多内容来支持新的 个人教练 应用。一些相关的更新包括:
-
添加新的
WorkoutBuilder功能。这个功能包含与 个人教练 相关的实现。 -
更新应用布局和样式。
-
在
trainer/src/app目录下的workout-builder文件夹中添加一些组件和带有占位符内容的 HTML 模板,用于 个人教练。 -
定义一个新的路由到
WorkoutBuilder功能。我们将在下一节中介绍如何在应用中设置此路由。 -
正如我们刚才提到的,将现有的
model.ts文件移动到core文件夹。
让我们讨论我们将如何使用模型。
在 trainer/src/app 目录下的 workout-builder 文件夹中使用个人教练模型。
在最后一章,我们专门用一节来介绍学习 Angular 服务,我们发现服务对于在控制器和其他 Angular 构造之间共享数据很有用。打开位于 app 目录下 core 文件夹中的 model.ts 文件。在这个类中,我们实际上没有任何数据,而是一个描述数据形状的蓝图。计划使用服务来公开这个模型结构。我们已经在 Workout Runner 中做到了这一点。现在,我们将在 Workout Builder 中做同样的事情。
model.ts 文件已被移动到 core 文件夹,因为它在 Workout Builder 和 Workout Runner 应用之间是共享的。注意:在 checkpoint4.1 中,我们已经更新了 workout-runner.component.ts、workout-audio.component.ts 和 workout-history-tracker-service.ts 中的导入语句,以反映这一变化。
在第二章,“构建我们的第一个应用 - 7 分钟健身”,我们回顾了模型文件中的类定义:Exercise、ExercisePlan 和 WorkoutPlan。 正如我们当时提到的,这三个类构成了我们的基础模型。我们现在将开始在新的应用中使用这个基础模型。
在模型设计方面就这些了。接下来我们要做的是为新应用定义结构。
个人教练布局
个人教练 的骨架结构如下所示:
这包含以下组件:
-
顶部导航:这包含应用品牌标题和历史链接。
-
子导航:这包含根据活动组件变化的导航元素。
-
左侧导航:这包含依赖于活动组件的元素。
-
内容区域:这是我们的组件主视图将显示的地方。这是大多数动作发生的地方。我们将创建/编辑练习和锻炼,并在这里显示练习和锻炼的列表。
查看源代码文件;在 trainer/src/app 下有一个新的文件夹 workout-builder。它为之前描述的每个组件都有文件,其中包含一些占位符内容。我们将随着本章的进行构建这些组件。
然而,我们首先需要在应用程序中链接这些组件。这需要我们定义锻炼构建器应用程序的导航模式,并相应地定义应用程序路由。
带有路由的个人教练导航
我们计划为应用程序使用的导航模式是列表-详情模式。我们将为应用程序中可用的练习和锻炼创建列表页面。点击任何列表项将带我们到项目的详细视图,在那里我们可以执行所有 CRUD 操作(创建/读取/更新/删除)。以下路由遵循此模式:
| 路由 | 描述 |
|---|---|
/builder | 这只是重定向到 builder/workouts |
/builder/workouts | 这列出了所有可用的锻炼。这是 锻炼构建器 的着陆页 |
/builder/workout/new | 这创建一个新的锻炼 |
/builder/workout/:id | 这编辑具有特定 ID 的现有锻炼 |
/builder/exercises | 这列出了所有可用的练习 |
/builder/exercise/new | 这创建一个新的练习 |
/builder/exercise/:id | 这编辑具有特定 ID 的现有练习 |
开始使用个人教练导航
在这一点上,如果您查看 src/app 文件夹中的 app-routing.module.ts 中的路由配置,您将找到一个新路由定义,builder:
const routes: Routes = [
...
{ path: 'builder', component: WorkoutBuilderComponent },
...
];
如果您运行应用程序,您将看到启动屏幕显示了另一个链接,创建一个锻炼:
在幕后,我们已将另一个路由链接添加到 start.component.html:
<a routerLink="/builder" class="btn btn-primary btn-lg btn-block" role="button" aria-pressed="true">
<span>Create a Workout</span>
<span class="ion-md-add"></span>
</a>
如果您点击此链接,您将被带到以下视图:
再次,在幕后,我们已将 workout-builder.component.ts 添加到 trainer/src/app/workout-builder 文件夹中,并具有以下内联模板:
template: `
<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.html 模板中的路由出口显示:
<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';
import { ExerciseComponent } from './exercise/exercise.component';
import { ExercisesComponent } from './exercises/exercises.component';
import { WorkoutComponent } from './workout/workout.component';
import { WorkoutsComponent } from './workouts/workouts.component';
@NgModule({
imports: [
CommonModule
],
declarations: [WorkoutBuilderComponent, ExerciseComponent, ExercisesComponent, WorkoutComponent, WorkoutsComponent]
})
export class WorkoutBuilderModule { }
在这里,与其他我们创建的模块相比,唯一可能看起来不同的地方是,我们导入的是 CommonModule 而不是 BrowserModule。这避免了第二次导入整个 BrowserModule,这在我们实现此模块的懒加载时会产生错误。
最后,我们在 app.module.ts 中添加了对该模块的导入:
...
@NgModule({
imports: [
...
WorkoutBuilderModule],
...
所以,这里没有什么令人惊讶的。这些都是我们在前几章中介绍的基本组件构建和路由模式。遵循这些模式,我们现在应该开始考虑为我们的新功能添加之前概述的附加导航。然而,在我们着手做这件事之前,还有一些事情我们需要考虑。
首先,如果我们开始将我们的路由添加到 app.routing-module.ts 文件中,那么存储在那里的路由数量将会增加。这些新的 Workout Builder 路由也将与 Workout Runner 的路由混合在一起**。虽然我们现在添加的路由数量可能看起来微不足道,但长期来看,这可能会成为一个维护问题。
其次,我们需要考虑的是,我们的应用程序现在由两个功能组成——Workout Runner 和 Workout Builder。我们应该思考如何在我们的应用程序中分离这些功能,以便它们可以独立于彼此开发。
换句话说,我们希望我们构建的功能之间有松散耦合。使用这种模式允许我们在不影响其他功能的情况下,在我们的应用程序中替换掉一个功能。例如,在某个时候,我们可能希望将 Workout Runner 转换为移动应用,但保留 Workout Builder 作为基于网络的程序。
回到第一章,我们强调了这种将我们的组件彼此分离的能力是使用 Angular 实现的 组件设计模式 的关键优势之一。幸运的是,Angular 的路由器为我们提供了将我们的路由分离成逻辑上组织良好的 路由配置 的能力,这些配置与应用程序中的功能紧密匹配。
为了实现这种分离,Angular 允许我们使用 子路由,这样我们就可以隔离我们每个功能的路由。在本章中,我们将使用 子路由 来分离 Workout Builder 的路由。
向 Workout Builder 引入子路由
Angular 通过提供在应用程序中创建路由组件层次结构的能力来支持我们隔离新 Workout Builder 路由的目标。我们目前只有一个路由组件,它位于我们应用程序的根组件中。但 Angular 允许我们在根组件下添加所谓的 子路由组件。这意味着一个功能可以无视另一个功能使用的路由,每个功能都可以自由地根据该功能内部的变化调整其路由。
回到我们的应用程序,我们可以使用 Angular 的 子路由 来匹配我们应用程序两个功能的路由与将使用它们的代码。因此,在我们的应用程序中,我们可以将路由结构化为以下路由层次结构,用于我们的 Workout Builder(在这个阶段,我们将 Workout Runner 保持原样,以展示前后对比):
采用这种方法,我们可以通过功能对路由进行逻辑分离,使它们更容易管理和维护。
因此,让我们开始通过向我们的应用程序添加子路由来启动。
从本节此点开始,我们将添加本章之前下载的代码。如果您想查看下一节的完整代码,可以从 GitHub 仓库中的checkpoint 4.2下载。如果您想在我们构建本节代码时一起工作,请确保添加trainer/src文件夹中包含此检查点的styles.css中的更改,因为我们在这里不会讨论它们。同时,请确保添加来自仓库中trainer/src/app/workout-builder文件夹的练习(exercise)、锻炼(workout)和导航文件。在这个阶段,这些只是占位符文件,我们将在本章的后面实现它们。然而,您需要这些占位符文件来实现锻炼构建器模块的导航。代码可在 GitHub 上供所有人下载,网址为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.2。如果您不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.2的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.2.zip。在首次设置快照时,请参阅trainer文件夹中的README.md文件。
添加子路由组件
在workout-builder目录下,添加一个名为workout-builder.routing.module.ts的新 TypeScript 文件,并包含以下导入:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { WorkoutBuilderComponent } from './workout-builder.component';
import { WorkoutsComponent } from './workouts/workouts.component';
import { WorkoutComponent } from './workout/workout.component';
import { ExercisesComponent } from './exercises/exercises.component';
import { ExerciseComponent } from './exercise/exercise.component';
如您所见,我们正在导入刚才提到的组件;它们将成为我们锻炼构建器(exercise、exercises、workout 和 workouts)的一部分。与这些导入一起,我们还从 Angular 核心模块中导入NgModule,从 Angular 路由模块中导入Routes和RouterModule。这些导入将使我们能够添加和导出子路由。
我们在这里没有使用 Angular CLI,因为它没有创建路由模块的独立蓝图。然而,您可以在创建模块时使用--routing选项让 CLI 创建路由模块。在这种情况下,我们已经有了一个现有的模块创建,所以不能使用该标志。有关如何操作的更多详细信息,请参阅github.com/angular/angular-cli/blob/master/docs/documentation/stories/routing.md。
然后,将以下路由配置添加到文件中:
const routes: 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或我们在该文件中配置的其他任何路由)时发生重定向。
最后,添加以下类声明,前面加上@NgModule装饰器,该装饰器定义了模块的导入和导出:
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class WorkoutBuilderRoutingModule { }
这个导入与app.routing-module.ts中的导入非常相似,只有一个区别:我们使用的是RouterModule.forChild而不是RouterModule.forRoot。这种差异的原因可能看起来很明显:我们正在创建子路由,而不是应用程序根目录中的路由,这就是我们表示的方式。然而,在底层,有一个显著的区别。这是因为我们的应用程序中不能有多个活动的路由服务。forRoot创建路由服务,但forChild不创建。
更新 WorkoutBuilder 组件
接下来,我们需要更新WorkoutBuilder组件以支持我们新的子路由。为此,将Workout Builder的@Component装饰器更改为:
-
移除
selector -
在模板中添加一个
<abe-sub-nav-main>自定义元素 -
在模板中添加一个
<router-outlet>标签
装饰器现在应该看起来像以下这样:
@Component({
template: `<div class="container-fluid fixed-top mt-5">
<div class="row mt-5">
<abe-sub-nav-main></abe-sub-nav-main>
</div>
<div class="row mt-2">
<div class="col-sm-12">
<router-outlet></router-outlet>
</div>
</div>
<div>`
})
我们正在移除选择器,因为WorkoutBuilderComponent将不会嵌入到应用程序根目录app.component.ts中。相反,它将通过路由从app.routing-module.ts访问。虽然它将处理来自app.routes.ts的传入路由请求,但它将反过来将它们路由到 Workout Builder 功能中包含的其他组件。
这些组件将使用我们刚刚添加到WorkoutBuilder模板中的<router-outlet>标签来显示它们的视图。鉴于Workout BuilderComponent的模板将是简单的,我们使用内联template而不是templateUrl。
通常,对于组件的视图,我们建议使用指向单独 HTML 模板文件的templateUrl。当你预计视图将涉及超过几行 HTML 时,这一点尤其正确。在这种情况下,在它自己的 HTML 文件中处理视图要容易得多。
我们还添加了一个<abe-sub-nav-main>元素,它将被用来创建一个用于在Workout Builder功能中导航的二级顶级菜单。我们将在本章稍后讨论这一点。
更新 Workout Builder 模块
现在,让我们更新WorkoutBuilderModule。首先,将以下导入添加到文件中:
import { WorkoutBuilderRoutingModule } from './workout-builder-routing.module';
它导入了我们刚刚设置的子路由。
接下来,更新@NgModule装饰器以添加workoutBuilderRoutingModule:
...
@NgModule({
imports: [
CommonModule,
WorkoutBuilderRoutingModule
],
...
})
最后,添加可在checkpoint4.2中找到的新导航组件的导入和声明:
import { LeftNavExercisesComponent } from './navigation/left-nav-exercises.component';
import { LeftNavMainComponent } from './navigation/left-nav-main.component';
import { SubNavMainComponent } from './navigation/sub-nav-main.component';
...
declarations: [
...
LeftNavExercisesComponent,
LeftNavMainComponent,
SubNavMainComponent]
更新 App 路由模块
最后一步:回到app.routing-module.ts,移除对WorkoutBuilderComponent的导入以及指向构建器的路由定义:{ path: 'builder', component: WorkoutBuilderComponent },。
请确保在app.module.ts中保持对WorkoutBuilderModule的导入不变。我们将在下一节讨论懒加载时讨论移除它。
整合所有内容
从上一章,我们已经知道如何设置应用程序的根路由。但现在,我们有的不是根路由,而是包含子路由的区域或功能路由。我们已经能够实现之前讨论的关注点分离,因此现在所有与Workout Builder相关的路由都分别包含在其自己的路由配置中。这意味着我们可以在WorkoutBuilderRoutes组件中管理所有与Workout Builder相关的路由,而不会影响应用程序的其他部分。
如果我们现在从起始页面导航到 Workout Builder,我们可以看到路由器是如何将app.routes.ts中的路由与workout-builder.routes.ts中的默认路由组合起来的。
如果我们在浏览器中查看 URL,它是/builder/workouts。你可能还记得,起始页面上的路由链接是['/builder']。那么路由器是如何带我们到这个位置的?
它这样做:当点击链接时,Angular 路由器首先在app-routing.module.ts中查找builder路径,因为该文件包含我们应用程序根路由的配置。路由器找不到该路径,因为我们已经从该文件的路由中移除了它。
然而,WorkoutBuilderModule已被导入到我们的AppModule中,而该模块又导入workoutBuilderRoutingModule。后一个文件包含我们刚刚配置的子路由。路由器发现该文件中的builder是父路由,因此它使用该路由。它还发现默认设置,在builder路径以空字符串结束的情况下(在本例中就是这样),将重定向到子路径workouts。
如果您查看屏幕,您会看到它正在显示Workouts视图(而不是之前的Workout Builder)。这意味着路由器已成功将请求路由到WorkoutsComponent,这是我们在workoutBuilderRoutingModule中设置的子路由配置中的默认路由组件。
这里展示了路由解析的过程:
最后关于子路由的一点思考。当您查看我们的子路由组件workout-builder.component.ts时,您会看到它没有对其父组件app.component.ts的引用(正如我们之前提到的,<selector>标签已被移除,因此WorkoutBuilderComponent没有被嵌入到根组件中)。这意味着我们已经成功地将WorkoutBuilderComponent(以及所有在WorkoutBuilderModule中导入的相关组件)封装起来,这样我们就可以将其移动到应用程序的任何其他位置,甚至可以移动到一个新的应用程序中。
现在,是我们将 Workout Builder 的路由转换为使用懒加载并构建其导航菜单的时候了。如果您想查看下一节完成的代码,可以从checkpoint 4.3的配套代码库中下载。再次提醒,如果您在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们未对其进行讨论。
代码也已在 GitHub 上供所有人下载,链接为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.3(文件夹 - trainer)。如果您不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.3的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.3.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。
路由的懒加载
当我们推出我们的应用程序时,我们预计我们的用户将每天访问Workout Runner(我们知道这对你来说也是这样!)。但是,我们预计他们只会偶尔使用Workout Builder来构建他们的锻炼和训练计划。因此,如果我们能在用户只在Workout Runner中做锻炼时避免加载Workout Builder的开销,那就太好了。相反,我们更希望用户在想要添加或更新他们的锻炼和训练计划时才按需加载 Workout Builder。这种方法被称为懒加载。懒加载允许我们在加载模块时采用异步方法。这意味着我们可以只加载启动应用程序所需的资源,然后根据需要加载其他模块。
在幕后,当我们使用 Angular CLI 构建和提供我们的应用程序时,它使用 WebPack 的打包和分块功能来实现懒加载。我们将随着在应用程序中实现懒加载的过程来讨论这些功能。
因此,在我们的个人教练应用程序中,我们希望改变应用程序,使其仅在需要时才加载Workout Builder。Angular 路由器允许我们通过懒加载来实现这一点。
在我们开始实现懒加载之前,让我们先看看我们的当前应用程序以及它是如何加载我们的模块的。在“源”标签页中打开开发者工具,启动应用程序;当启动页面出现在你的浏览器中时,如果你在源树中的 webpack 节点下查看,你会看到应用程序中的所有文件都已加载,包括Workout Runner和Workout Builder文件:
因此,即使我们可能只想使用Workout Runner,我们也必须加载Workout Builder。从某种意义上说,如果你将我们的应用程序视为单页应用程序(SPA),这就有道理了。为了避免往返服务器,SPA 通常会在用户首次启动应用程序时加载所有将需要的资源。但在我们的情况下,重要的点是我们在应用程序首次加载时不需要Workout Builder。相反,我们希望在用户决定添加或更改锻炼或练习时才加载这些资源。
那么,让我们开始实现这一目标。
首先,修改app.routing-module.ts以添加以下路由配置WorkoutBuilderModule:
const routes: Routes = [
...
{ path: 'builder', loadChildren: './workout-builder/workout-builder.module#WorkoutBuilderModule'},
{ path: '**', redirectTo: '/start' }
];
注意到loadChildren属性是:
module file path + # + module name
此配置提供了加载和实例化WorkoutBuilderModule所需的信息。
接下来回到workout-builder-routing.module.ts,将path属性更改为空字符串:
export const Routes: Routes = [
{
path: '',
. . .
}
];
我们进行此更改是因为我们现在将路径(builder)设置为WorkoutBuilderRoutes,这是我们在app.routing-module.ts中添加的新配置。
最后,回到app-module.ts文件,并从该文件的@NgModule配置中移除WorkoutBuilderModule导入。这意味着,我们不是在应用程序首次启动时加载锻炼构建者功能,而是在用户访问锻炼构建者路由时才加载它。
让我们再次使用ng serve构建和运行应用程序。在终端窗口中,你应该会看到以下类似的输出:
这里有趣的是最后一行,它显示了名为workout.builder.module的单独文件,即workout-builder.module.chunk.js。WebPack使用了所谓的代码拆分,将我们的锻炼构建者模块分割成单独的块。这个块将在需要时(即,当路由导航到WorkoutBuilderModule时)才被加载到我们的应用程序中。
现在,在 Chrome 开发者工具中保持“源”标签页打开,再次在浏览器中打开应用程序。当起始页面加载时,只有与锻炼运行者相关的文件出现,而没有与锻炼构建者相关的文件,如图所示:
然后,如果我们清除“网络”标签页并点击“创建锻炼”链接,我们将看到workout-builder.module块被加载:
这意味着我们已经实现了新功能的封装,并且通过异步路由,我们能够使用懒加载仅在需要时加载所有组件。
儿童和异步路由使得实现既能拥有蛋糕又能吃掉蛋糕的应用程序变得简单。一方面,我们可以构建具有强大客户端导航的单页应用(SPAs),另一方面,我们还可以将功能封装在独立的子路由组件中,并在需要时才加载它们。
这种 Angular 路由的强大和灵活性使我们能够通过将应用程序的行为和响应性紧密映射到用户使用应用程序的方式,来满足用户期望。在这种情况下,我们利用了这些能力来实现我们的目标:立即加载锻炼运行者,以便我们的用户可以立即开始锻炼,同时避免加载锻炼构建者的开销,并且只在用户想要构建锻炼时才提供它。
现在,我们已经将路由配置设置在锻炼构建者中,我们将把注意力转向构建子级和左侧导航;这将使我们能够使用这种路由。接下来的几节将介绍如何实现这种导航。
集成子级和侧级导航
将子级和侧级导航集成到应用程序中的基本思想是提供上下文感知的子视图,这些视图根据活动视图而变化。例如,当我们处于列表页面而不是编辑项目时,我们可能希望在导航中显示不同的元素。电子商务网站是这种情况的一个很好的例子。想象一下亚马逊的搜索结果页面和产品详情页面。当上下文从产品列表变为特定产品时,加载的导航元素也会发生变化。
子级导航
我们首先将在 Workout Builder 中添加子级导航。我们已将 SubNavMainComponent 导入到 Workout Builder 中。但,目前它只是显示占位符内容:
我们现在将替换该内容为三个路由链接:主页、新建锻炼和新建练习。
打开 sub-nav-main.component.html 文件,将其中的 HTML 更改为以下内容:
<nav class="navbar fixed-top navbar-dark bg-primary mt-5">
<div>
<a [routerLink]="['/builder/workouts']" class="btn btn-primary">
<span class="ion-md-home"></span> Home
</a>
<a [routerLink]="['/builder/workout/new']" class="btn btn-primary">
<span class="ion-md-add"></span> New Workout
</a>
<a [routerLink]="['/builder/exercise/new']" class="btn btn-primary">
<span class="ion-md-add"></span> New Exercise
</a>
</div>
</nav>
现在,重新运行应用程序,您将看到三个导航链接。如果我们点击“新建练习”链接按钮,我们将被路由到 ExerciseComponent,其视图将在 Workout Builder 视图的 Router Outlet 中显示:
新建锻炼链接按钮将以类似的方式工作;当点击时,它将用户带到 WorkoutComponent 并在路由出口中显示其视图。点击主页链接按钮将用户返回到 WorkoutsComponent 并查看。
侧边导航
在 Workout Builder 中的侧级导航将根据我们导航到的子组件而有所不同。例如,当我们第一次导航到 Workout Builder 时,我们将被带到 Workouts 屏幕,因为 WorkoutsComponent 路由是 Workout Builder 的默认路由。该组件将需要侧边导航;它将允许我们选择查看锻炼列表或练习列表。
Angular 的组件化特性为我们提供了一个简单的方法来实现这些上下文敏感的菜单。我们可以为每个菜单定义新的组件,然后将它们导入到需要它们的组件中。在这种情况下,我们有三个组件需要侧边菜单:Workouts、Exercises 和 Workout。其中前两个组件实际上可以使用相同的菜单,所以我们实际上只需要两个侧边菜单组件:LeftNavMainComponent,它将类似于前面的菜单,将被 Exercises 和 Workouts 组件使用,以及 LeftNavExercisesComponent,它将包含现有练习的列表,并将被 Workouts 组件使用。
我们已经有了两个菜单组件的文件,包括模板文件,并将它们导入到 WorkoutBuilderModule 中。我们现在将它们集成到需要它们的组件中。
首先,修改 workouts.component.html 模板以添加菜单的选择器:
<div class="row">
<div>
<abe-left-nav-main></abe-left-nav-main>
</div>
<div class="col-sm-10 builder-content">
<h1 class="text-center">Workouts</h1>
</div>
</div>
然后,将left-nav-main.component.html中的占位文本替换为指向WorkoutsComponent和ExercisesComponent的导航链接:
<div class="left-nav-bar">
<div class="list-group">
<a [routerLink]="['/builder/workouts']" class="list-group-item list-group-item-action">Workouts</a>
<a [routerLink]="['/builder/exercises']" class="list-group-item list-group-item-action">Exercises</a>
</div>
</div>
运行应用程序,你应该会看到以下内容:
按照完全相同的步骤完成Exercises组件的侧菜单。
我们在这里不会展示这个菜单的代码,但你可以在 GitHub 仓库的checkpoint 4.3中的trainer/src/app目录下的workout-builder/exercises文件夹中找到它。
对于锻炼屏幕的菜单,步骤相同,但你应该将left-nav-exercises.component.html更改为以下内容:
<div class="left-nav-bar">
<h3>Exercises</h3>
</div>
我们将使用此模板作为构建屏幕左侧将出现并可以选择包含在锻炼中的锻炼列表的起点。
实现锻炼和锻炼列表
在我们开始实现锻炼和锻炼列表页面之前,我们需要一个用于锻炼和锻炼数据的存储库。当前的计划是使用内存中的存储库并通过 Angular 服务公开它。在第五章支持服务器数据持久性中,我们将讨论服务器交互,我们将把此数据移动到服务器存储库以实现长期持久性。目前,内存存储库就足够了。让我们添加存储库实现。
将 WorkoutService 作为锻炼和锻炼仓库
此处的计划是创建一个WorkoutService实例,该实例负责在两个应用程序之间公开锻炼和锻炼数据。该服务的主要职责包括:
-
与锻炼相关的 CRUD 操作:获取所有锻炼,根据名称获取特定锻炼,创建锻炼,更新锻炼,以及删除它
-
与锻炼相关的 CRUD 操作:这些操作与与锻炼相关的操作类似,但针对的是锻炼实体
代码可在 GitHub 上下载,网址为github.com/chandermani/angular6byexample。要下载的分支如下:GitHub 分支:checkpoint4.4(文件夹—trainer)。如果你不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.4的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.4.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次提醒,如果你在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们不做讨论。因为本节中的一些文件相当长,我们有时会建议你直接将文件复制到你的解决方案中。
在trainer/src/core文件夹中定位workout-service.ts。该文件中的代码应该如下所示,除了省略了长度较长的两个方法setupInitialExercises和setupInitialWorkouts:
import {Injectable} from '@angular/core';
import {ExercisePlan} from './model';
import {WorkoutPlan} from './model';
import {Exercise} from "./model";
import { CoreModule } from './core.module';
@Injectable({
providedIn: CoreModule
})
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装饰它。在@Injectable装饰器中,我们将provided-in属性设置为CoreModule。这会将WorkoutService注册为 Angular 依赖注入框架的一个提供者,并使其在整个应用程序中可用。
在类定义中,我们首先创建两个数组:一个用于Workouts,一个用于Exercises。这两个数组分别是WorkoutPlan和Exercise类型,因此我们需要从model.ts导入WorkoutPlan和Exercise以获取它们的类型定义。
构造函数调用两个方法来设置训练和服务的列表。目前,我们只是使用一个内存存储,用数据填充这些列表。
如其名称所示,两个方法getExercises和getWorkouts分别返回一个锻炼和训练列表。由于我们计划使用内存存储来存储训练和锻炼数据,Workouts和Exercises数组存储这些数据。随着我们的进行,我们将在服务中添加更多功能。
是时候构建训练和锻炼列表的组件了!
训练和锻炼列表组件
首先,在trainer/src/app/workout-builder/workouts文件夹中打开workouts.component.ts文件,并按如下方式更新导入:
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { WorkoutPlan } from '../../core/model';
import { WorkoutService } from '../../core/workout.service';;
这段新代码导入了 Angular 的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] );
}
}
这段代码在构造函数中添加了Router和WorkoutService的注入。然后ngOnInit方法调用WorkoutService的getWorkouts方法,并用从该方法调用返回的WorkoutPlans列表填充workoutList数组。我们将使用这个workoutList数组来填充将在Workouts组件视图中显示的训练计划列表。
你会注意到我们将调用WorkoutService的代码放入了ngOnInit方法中。我们不想在构造函数中放置这段代码。最终,我们将用对外部数据存储的调用替换掉这个服务使用的内存存储,我们不希望组件的实例化受到这个调用的影响。将这些方法调用添加到构造函数中也会使组件的测试变得复杂。
为了避免这种意外的副作用,我们将其代码放置在ngOnInit方法中。该方法实现了 Angular 的生命周期钩子之一OnInit,Angular 在创建服务实例后调用此方法。这样,我们依赖 Angular 以可预测的方式调用此方法,而不会影响组件的实例化。
接下来,我们将对Exercises组件进行几乎相同的更改。与Workouts组件一样,这段代码将锻炼服务注入到我们的组件中。这次,我们使用锻炼服务来检索锻炼内容。
由于它与我们在Workouts组件中刚刚展示的内容非常相似,所以我们在这里不会展示那段代码。只需从checkpoint 4.4中的workout-builder/exercises文件夹添加即可。
锻炼和锻炼列表视图
现在,我们需要实现迄今为止一直为空的列表视图!
在本节中,我们将更新checkpoint 4.3中的代码,以包含checkpoint 4.4中的内容。所以如果你正在与我们一起编码,只需遵循本节中概述的步骤。如果你想查看完成的代码,只需将checkpoint 4.4中的文件复制到你的解决方案中即可。
锻炼列表视图
为了使视图工作,打开workouts.component.html并添加以下标记:
<div class="row">
<div>
<abe-left-nav-main></abe-left-nav-main>
</div>
<div class="col-sm-10 builder-content">
<h1 class="text-center">Workouts</h1>
<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" title="Duration"><span class="ion-md-time"></span> - {{(workout.totalWorkoutDuration? workout.totalWorkoutDuration(): 0)|secondsToTime}}</span>
<span class="float-right" title="Exercise Count"><span class="ion-md-list"></span> - {{workout.exercises.length}}</span>
</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 的末尾:
锻炼屏幕
锻炼列表视图
我们将遵循与Workouts列表视图相同的方法来处理Exercises列表视图,只是在这种情况下,我们将实际实现两个视图:一个用于Exercises组件(当用户导航到该组件时将在主要内容区域显示)和一个用于LeftNavExercisesComponent练习上下文菜单(当用户导航到Workouts组件以创建或编辑锻炼时将显示)。
对于Exercises组件,我们将遵循与我们在Workouts组件中显示锻炼列表几乎相同的方法。所以我们不会在这里展示那段代码。只需从checkpoint 4.4添加exercises.component.ts和exercises.component.html文件即可。
当你完成文件复制后,点击左侧导航中的“练习”链接来加载你在WorkoutService中已经配置的 12 个练习。
与Workouts列表一样,这设置了导航到锻炼详情页的设置。点击练习列表中的项目将带我们到锻炼详情页。选定的练习名称作为路由/URL的一部分传递到锻炼详情页。
在最终列表视图中,我们将添加一个将在Workout Builder屏幕的左侧上下文菜单中显示的练习列表。当我们创建或编辑锻炼时,此视图将在左侧导航中加载。使用 Angular 的基于组件的方法,我们将更新leftNavExercisesComponent及其相关视图以提供此功能。我们在这里不会展示那段代码。只需从trainer/src/app/navigation文件夹中的checkpoint 4.4添加left-nav-exercises.component.ts和left-nav-exercises.component.html文件即可。
完成那些文件的复制后,点击Workout Builder中的子导航菜单上的“新建锻炼”按钮,你现在将看到在左侧导航菜单中显示的练习列表——这些是我们已经在WorkoutService中配置好的练习。
是时候添加加载、保存和更新锻炼/锻炼数据的能力了!
建立锻炼
Personal Trainer的核心功能围绕着锻炼和锻炼构建。所有这些都是为了支持这两个功能。在本节中,我们专注于使用 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监控应用程序用户正在构建的锻炼状态。它:
-
跟踪当前锻炼
-
创建新的锻炼
-
加载现有锻炼
-
保存锻炼
从trainer/src/app下的workout-builder/builder-services文件夹中的checkpoint 4.5复制workout-builder-service.ts
代码也可在 GitHub 上供所有人下载,链接为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.5(文件夹—trainer)。如果您不使用 Git,可以从以下 GitHub 位置下载Checkpoint 4.5的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.5.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次提醒,如果您在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们不做讨论。
虽然我们通常使服务在应用程序范围内可用,但WorkoutBuilderService将仅用于Workout Builder功能。因此,我们不是在AppModule的提供者中注册它,而是在WorkoutBuilderModule的提供者数组中注册它,如下所示(在文件顶部添加导入之后):
@NgModule({
....
providers: [WorkoutBuilderService]
})
将其作为提供者在这里意味着它仅在访问Workout Builder功能时加载,并且不能从该模块外部访问。这意味着它可以独立于应用程序中的其他模块进行发展,并且可以修改而不会影响应用程序的其他部分。
让我们看看服务的一些相关部分。
WorkoutBuilderService需要WorkoutPlan、ExercisePlan和WorkoutService的类型定义,因此我们将这些导入到组件中:
import { WorkoutPlan, ExercisePlan } from '../../core/model';
import { WorkoutService } from '../../core/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,我们还没有添加。请从 trainer/src/services 文件夹下的 workout-service.ts 文件中复制 getWorkout 的实现。由于实现相当简单,我们不会过多关注新的服务代码。
让我们回到左侧导航并实现剩余的功能。
使用锻炼导航添加锻炼
要将锻炼添加到我们正在构建的锻炼计划中,我们只需导入 WorkoutBuilderService 和 ExercisePlan,将 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 来更改当前锻炼的锻炼列表。
由于服务是共享的,需要注意一些陷阱。由于服务可以通过系统注入,我们无法阻止任何组件依赖任何服务并以不一致的方式调用其函数,从而导致不期望的结果或错误。例如,在调用 addExercise 之前,WorkoutBuilderService 需要通过调用 startBuilding 来初始化。如果在初始化之前组件调用了 addExercise 会发生什么?
实现 Workout 组件
WorkoutComponent 负责管理锻炼。这包括创建、编辑和查看锻炼。由于引入了 WorkoutBuilderService,该组件的整体复杂性将降低。除了与模板视图集成、暴露和交互的主要责任外,我们将大部分其他工作委托给 WorkoutBuilderService。
WorkoutComponent 与两个 routes/views 相关联,即 /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. 在这一点上,我们感兴趣的是 Resolve 路由守卫**. **Resolve 守卫将允许我们不仅检查是否存在一个锻炼项目,而且在加载 WorkoutComponent 之前加载与该锻炼项目相关的数据。这样做的好处是,我们避免了在 WorkoutComponent 中检查数据是否已加载的必要性,并且消除了在其组件模板中添加条件逻辑以确保数据在渲染时存在的需求。 这在下一章我们将开始使用 observables 时将特别有用,我们必须等待可观察对象完成,才能确保获得它将提供的数据。Resolve 守卫将处理等待可观察对象完成,这意味着 WorkoutComponent 在加载之前将确保拥有所需的数据。
实现 resolve 路由守卫
Resolve 守卫允许我们预取一个锻炼项目的数据。在我们的情况下,我们想要做的是使用 Resolve 来检查传递给现有锻炼项目的任何 ID 的有效性。具体来说,我们将通过调用 WorkoutBuilderService 来运行对该 ID 的检查,以检索锻炼计划并查看它是否存在。如果存在,我们将加载与锻炼计划相关的数据,以便它对 WorkoutComponent 可用;如果不存在,我们将重定向回锻炼项目屏幕。
将 workout.resolver.ts 从 trainer/src/app/workout 下的 workout-builder/workout 文件夹复制到 checkpoint 4.5,你将看到以下代码:
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/take';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Router, Resolve, RouterStateSnapshot,
ActivatedRouteSnapshot } from '@angular/router';
import { WorkoutPlan } from '../../core/model';
import { WorkoutBuilderService } from '../builder-services/workout-builder.service';
@Injectable()
export class WorkoutResolver implements Resolve<WorkoutPlan> {
public workout: WorkoutPlan;
constructor(
public workoutBuilderService: WorkoutBuilderService,
public router: Router) {}
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot): WorkoutPlan {
let workoutName = route.paramMap.get('id');
if (!workoutName) {
workoutName = '';
}
this.workout = this.workoutBuilderService.startBuilding(workoutName);
if (this.workout) {
return this.workout;
} else { // workoutName not found
this.router.navigate(['/builder/workouts']);
return null;
}
}
}
如您所见,WorkoutResolver 是一个可注入的类,它实现了 Resolve 接口。 代码将 WorkoutBuilderService 和 Router 注入到类中,并通过 resolve 方法实现接口。resolve 方法接受两个参数;ActivatedRouteSnapshot 和 RouterStateSnapshot。在这种情况下,我们只对这两个参数中的第一个感兴趣,即 ActivatedRouteSnapshot。它包含一个 paramMap,从中我们提取出路由的 ID 参数。
然后,resolve方法使用路由中提供的参数调用WorkoutBuildingService的startBuilding方法。如果工作存在,则resolve返回数据并继续导航;如果不存在,它将用户重定向到工作页面并返回 false。如果传递new作为 ID,WorkoutBuilderService将加载一个新的工作,并且Resolve守卫将允许导航继续到WorkoutComponent。
resolve方法可以返回一个Promise、一个Observable或同步值。如果我们返回一个Observable,我们需要确保在导航之前Observable已经完成。然而,在这种情况下,我们正在对本地内存数据存储进行同步调用,所以我们只是返回一个值。
要完成WorkoutResolver的实现,首先确保将其导入并作为提供者添加到WorkoutBuilderModule中:
....
import { WorkoutResolver } from './workout/workout.resolver';
@NgModule({
....
providers: [WorkoutBuilderService, WorkoutResolver]
})
....
然后,通过更新workout-builder-routing.module.ts将其添加到WorkoutComponent的路由配置中,如下所示:
....
import { WorkoutResolver } from './workout/workout.resolver';
....
const routes: Routes = [
{
path: '',
component: WorkoutBuilderComponent,
children: [
{path: '', pathMatch: 'full', redirectTo: 'workouts'},
{path: 'workouts', component: WorkoutsComponent },
{path: 'workout/new', component: WorkoutComponent, resolve: { workout: WorkoutResolver} },
{path: 'workout/:id', component: WorkoutComponent, resolve: { workout: WorkoutResolver} },
{path: 'exercises', component: ExercisesComponent},
{path: 'exercise/new', component: ExerciseComponent },
{path: 'exercise/:id', component: ExerciseComponent }
]
},
];
如你所见,我们将WorkoutResolver添加到路由模块的导入中。然后,我们将resolve { workout: WorkoutResolver }添加到workout/new和workout/:id路由配置的末尾。这指示路由器使用WorkoutResolver的解析方法,并将返回值分配给路由数据中的workout。这种配置意味着在路由器导航到WorkoutComponent之前,WorkoutResolver将被调用,并且当WorkoutComponent加载时,工作数据将可用。我们将在下一节中看到如何在WorkoutComponent中提取这些数据。
实现 Workout 组件继续...
现在我们已经建立了通往Workout组件的路由,让我们转向完成其实现。因此,从trainer/src/app下的workout-builder/workout文件夹中的checkpoint 4.5复制workout.component.ts文件。(同时,从workout-builder文件夹复制workout-builder.module.ts。我们将在讨论 Angular 表单时稍后讨论该文件中的更改。)
打开workout.component.ts,你会看到我们添加了一个构造函数,该构造函数注入了ActivatedRoute和WorkoutBuilderService:
constructor(
public route: ActivatedRoute,
public workoutBuilderService:WorkoutBuilderService){ }
此外,我们还添加了以下ngOnInit方法:
ngOnInit() {
this.sub = this.route.data
.subscribe(
(data: { workout: WorkoutPlan }) => {
this.workout = data.workout;
}
);
}
该方法订阅了route并从route.data中提取了workout。没有必要检查工作是否存在,因为我们已经在WorkoutResolver中做了这个检查。
我们订阅了route.data,因为作为一个ActivatedRoute,route将其data暴露为一个Observable,它可以在组件的生命周期内发生变化。这使我们能够使用相同的组件实例以不同的参数重用,尽管该组件的OnInit生命周期事件只被调用一次。我们将在下一章中详细介绍Observables。
除了这段代码,我们还向Workout 组件添加了一系列方法,用于添加、删除和移动锻炼。这些方法都调用了WorkoutBuilderService上的相应方法,我们在这里不会详细审查它们。我们还添加了一个durations数组,用于填充持续时间下拉列表。
目前,这已经足够用于组件类的实现了。让我们更新相关的Workout模板。
实现 Workout 模板
现在,从trainer/src/app下的workout-builder/workout文件夹中的checkpoint 4.5复制workout.component.html文件。运行应用程序,导航到/builder/workouts,然后双击7 分钟锻炼磁贴。这应该会加载7 分钟锻炼的详细信息,视图类似于构建锻炼部分开始时所示。
如果有任何问题,您可以参考GitHub 仓库中的checkpoint4.5代码:分支:checkpoint4.5(文件夹 - trainer)。
我们将在这个视图中投入大量时间,所以让我们了解一些具体细节。
练习列表 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 表单,我们必须首先添加一些额外的配置。打开workout-buider.module.ts文件,该文件位于trainer/src/app下的workout-builder文件夹中,在checkpoint 4.5中。你会看到它导入了FormsModule:
....
import { FormsModule } from '@angular/forms';
....
@NgModule({
imports: [
CommonModule,
FormsModule,
SharedModule,
workoutBuilderRouting
],
这将包括我们实现表单所需的所有内容,包括:
-
NgForm -
ngModel
让我们开始使用这些来构建我们的表单。
使用 NgForm
在我们的模板(workout.component.html)中,我们添加了以下form标签:
<form #f="ngForm" class="row" name="formWorkout" (ngSubmit)="save(f.form)">. . .
</form>
让我们来看看这里有什么。一个有趣的事情是我们仍在使用标准的 <form> 标签,而不是特殊的 Angular 标签。我们还使用了 # 来定义一个局部变量 f,并将其分配给 ngForm。创建这个局部变量为我们提供了便利,可以在表单的其他地方使用它来进行与表单相关的活动。例如,你可以看到我们在打开 form 标签的末尾使用它,作为一个参数 f.form,它被传递到绑定到 (ngSubmit) 事件的 onSubmit 事件中。
最后绑定到 (ngSubmit) 应该告诉我们这里正在发生一些不同的事情。尽管我们没有明确添加 NgForm 指令,但我们的 <form> 现在有了额外的 ngSubmit 等事件,我们可以绑定操作。这是怎么发生的呢?嗯,这并不是因为我们将 ngForm 分配给一个局部变量而触发的。相反,它是因为我们自动地将表单模块导入到 workout-builder.module.ts 中。
在设置好这个导入之后,Angular 检查了我们的模板中的 <form> 标签,并将其包裹在 NgForm 指令中。Angular 文档指出,组件中的 <form> 元素将被升级以使用 Angular 表单系统。这很重要,因为它意味着 NgForm 的各种功能现在都可以与表单一起使用。这包括 ngSubmit 事件,它表示用户已触发表单提交,并提供在提交之前验证整个表单的能力。
ngModel
模板驱动的表单的一个基本构建块是 ngModel,你会在我们的整个表单中找到它的使用。ngModel 的一个主要作用是支持用户输入和底层模型之间的双向绑定。在这种设置下,模型的变化会在视图中反映出来,视图的更新也会反映回模型。我们之前提到的其他大多数指令只支持从模型到视图的单向绑定。ngModel 是双向的。但是,请注意,它仅在 NgForm 内部可用,用于允许用户输入的元素。
如你所知,我们已经有了一个用于 Workout 页面的模型,WorkoutPlan。以下是来自 model.ts 的 WorkoutPlan 模型:
export class WorkoutPlan {
constructor(
public name: string,
public title: string,
public restBetweenExercise: number,
public exercises: ExercisePlan[],
public description?: string) {
}
totalWorkoutDuration(): number{
. . . [code calculating the total duration of the workout]. . .
}
注意在 description 后面使用的 ?。这意味着它是我们模型中的一个可选属性,并且不是创建 WorkoutPlan 所必需的。在我们的表单中,这意味着我们不需要输入描述,并且即使没有它,一切也会正常工作。
在 WorkoutPlan 模型中,我们还有一个指向由另一种类型模型的实例组成的数组的引用:ExercisePlan。ExercisePlan 又由一个数字(duration)和另一个模型(Exercise)组成,看起来像这样:
export class Exercise {
constructor(
public name: string,
public title: string,
public description: string,
public image: string,
public nameSound?: string,
public procedure?: string,
public videos?: Array<string>) { }
}
这些嵌套类的使用表明,我们可以创建复杂的模型层次结构,所有这些都可以在我们的表单中使用NgModel进行数据绑定。因此,在整个表单中,每当我们需要更新WorkoutPlan或ExercisePlan中的任何一个值时,我们都可以使用NgModel来完成(在以下示例中,WorkoutPlan模型将由名为workout的局部变量表示)。
使用 ngModel 与 input 和 textarea 绑定
打开workout-component.html并查找ngModel.。它已经应用于允许用户数据输入的表单元素。这些包括输入、文本区域和选择。锻炼名称输入的设置如下:
<input type="text" name="workoutName" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name">
前面的[(ngModel)]指令在输入控件和workout.name模型属性之间建立了一个双向绑定。方括号和圆括号都应该看起来很熟悉。之前,我们分别使用它们:方括号[]用于属性绑定,圆括号()用于事件绑定。在后一种情况下,我们通常将事件绑定到与模板关联的组件中的方法调用。您可以在用户点击以删除练习的按钮表单中看到这个例子:
<span class="btn float-right trashcan" (click)="removeExercise(exercisePlan)"><span class="ion-ios-trash-outline"></span></span>
在这里,点击事件被明确地绑定到我们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}}
打开“Workout”页面,在输入框中输入一些内容,看看插值是如何即时更新的。双向绑定的魔法!
使用 ngModel 与 select 绑定
让我们看看select是如何设置的:
<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。并且,我们可能有多项练习需要应用此绑定。在这种情况下,我们必须使用另一个 Angular 表单指令——ngModelGroup——来处理这些绑定。ngModelGroup 将允许我们在模型中创建一个嵌套组,该组将包含锻炼中包含的练习列表,然后依次遍历每个练习,将其持续时间绑定到模型上。
首先,我们将 ngModelGroup 添加到我们在表单中创建的 div 标签中,以保存我们的练习列表:
<div id="exercises-list" class="col-sm-2 exercise-list" ngModelGroup="exercises">
这样我们就完成了创建嵌套的练习列表。现在,我们必须处理列表中的单个练习,我们可以通过为包含每个练习的单独 div 添加另一个 ngModelGroup 来实现这一点:
<div class="exercise tile" [ngModelGroup]="i">
在这里,我们使用循环中的索引来动态为我们的每个练习创建一个单独的模型组。这些模型组将嵌套在我们最初创建的第一个模型组内部。暂时在表单底部添加标签 <pre>{{ f.value | json }}</pre>,你将能够看到这个嵌套模型的结构:
{
"exercises": {
"0": {
"duration": 15
},
"1": {
"duration": 60
},
"2": {
"duration": 45
},
"exerciseCount": 3
},
"workoutName": "1minworkout",
"title": "1 Minute Workout",
"description": "desc",
"restBetweenExercise": 30
}
这是一种强大的功能,使我们能够创建具有嵌套模型的复杂表单,所有这些都可以使用 ngModel 进行数据绑定**。**
你可能已经注意到了我们刚刚引入的两个 ngModelGroup 指令标签之间的细微差别。第二个标签被括号 <[]> 包围,而第一个则不是。这是因为,在第一个标签中,我们只是命名我们的模型组,而在第二个标签中,我们使用循环的索引动态地将它绑定到每个练习的 div 标签上。
与输入类似,选择也支持双向绑定。我们看到了如何通过更改选择来更新模型,但模型到模板的绑定可能并不明显。为了验证模型到模板的绑定是否工作,请打开 7 分钟健身 应用并验证持续时间下拉菜单。每个下拉菜单都有一个与模型值(30 秒)一致的值。
Angular 使用 ngModel 做得非常好,能够保持模型和视图同步。更改模型,查看视图是否更新;更改视图,并观察模型是否立即更新。
现在,让我们给我们的表单添加验证。
下一个部分的代码也可以在 GitHub 上供每个人下载,网址为github.com/chandermani/angular6byexample。检查点作为 GitHub 上的分支实现。要下载的分支如下:GitHub Branch: checkpoint4.6(文件夹—trainer)。或者如果您不使用 Git,可以从以下 GitHub 位置下载检查点 4.6 的快照(ZIP 文件):github.com/chandermani/angular6byexample/archive/checkpoint4.6.zip。在首次设置快照时,请参考trainer文件夹中的README.md文件。再次提醒,如果您在我们构建应用程序的同时工作,请确保更新styles.css文件,这里我们不做讨论。
Angular 验证
正如俗话所说,永远不要相信用户输入。Angular 支持验证,包括标准的必填、最小值、最大值和模式,以及自定义验证器。
ngModel
ngModel是我们将用于实现验证的基本构建块。它为我们做两件事:维护模型状态并提供识别验证错误和显示验证消息的机制。
要开始,我们需要将ngModel分配给所有我们将要验证的表单控件中的局部变量。在每种情况下,我们需要为这个局部变量使用一个唯一名称。例如,对于锻炼名称,我们在该控件的input标签内添加#name="ngModel",同时添加 HTML 5 的required属性。现在,锻炼名称的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分配给每个输入的局部变量。同时,为所有必填字段添加所需的属性。
Angular 模型状态
每当我们使用NgForm时,我们表单中的每个元素,包括输入、文本区域和选择,都在关联的模型上定义了一些状态。ngModel为我们跟踪这些状态。跟踪的状态包括:
-
pristine: 只要用户没有与输入交互,此值就为true。任何对input字段和ng-pristine的更新都将ng-pristine设置为false。 -
dirty: 这是ng-pristine的反面。当输入数据被更新时,此值为true。 -
touched: 如果控件曾经获得过焦点,则此值为true。 -
untouched: 如果控件从未失去焦点,则此值为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>
重新加载应用程序并点击锻炼构建器中的“新建锻炼”链接。在触摸屏幕上的任何内容之前,您将看到以下内容显示:
在名称输入框中添加一些内容,然后从它那里切换标签。标签变为如下:
我们看到的是 Angular 根据用户与该控件交互更改应用于此控件的 CSS 类。您也可以通过在开发者控制台中检查input元素来看到这些更改。
如果我们想根据元素的状态应用视觉提示,这些 CSS 类转换非常有用。例如,看看这个片段:
input.ng-invalid { border:2px solid red; }
这会在任何具有无效数据的输入控件周围绘制红色边框。
随着您向锻炼页面添加更多验证,您可以在开发者控制台观察到,随着用户与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'),但这同样可以完美工作。然而,使用更冗长的方法可以让我们更具体地识别验证错误,这在开始向表单控件添加多个验证器时将变得至关重要。我们将在本章稍后探讨使用多个验证器。为了保持一致性,我们将坚持使用更冗长的方法。
现在加载新的锻炼页面(/builder/workouts/new)。在名称输入框中输入一个值,然后删除它。错误标签将如以下截图所示出现:
添加更多验证
Angular 提供了几个内置验证器,包括:
-
required -
minLength -
maxLength -
email -
pattern
要查看所有内置验证器的完整列表,请参阅Validators类的文档,链接为angular.io/api/forms/Validators.
我们已经看到了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。这里没有双向绑定。我们只对用它来进行自定义验证感兴趣。
打开新的锻炼页面,添加一个运动项目,然后删除它;我们应该看到这个错误:
我们在这里所做的事情可以很容易地完成,而不涉及任何模型验证基础设施。但是,通过将我们的验证钩入该基础设施,我们确实获得了一些好处。现在我们可以以一致和熟悉的方式确定特定模型和整个表单的错误。最重要的是,如果这里的验证失败,整个表单将被无效化。
正如我们刚才所做的那样实施自定义验证并不是你经常想做的事情。相反,通常在自定义指令内部实现这种复杂的逻辑会更有意义。我们将在第六章“深入探讨 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']);
}
我们使用表单的 invalid 属性检查验证状态,然后如果表单状态有效,就调用WorkoutBuilderService.save方法。
更多关于 NgForm 的内容
与将数据发送到服务器的传统表单相比,Angular 中的表单扮演着不同的角色。如果我们回顾一下表单标签,我们会看到它缺少标准的 action 属性。使用像 Angular 这样的 SPA 框架,使用完整页面的 post-back 来发送数据到服务器没有意义。在 Angular 中,所有服务器请求都是通过来自指令或服务的异步调用发起的。
在底层,Angular 也在关闭浏览器的内置验证。正如你在本章中看到的,我们仍在使用类似于原生 HTML 验证属性的验证属性,如required。然而,正如 Angular 文档所解释的,在 Angular 表单中,“Angular 使用指令将属性与框架中的验证函数相匹配。”请参阅angular.io/guide/form-validation#template-driven-validation。
此处的表单扮演着不同的角色。当表单封装了一组输入元素(如输入、文本区域和选择)时,它提供了一个 API 用于:
-
根据表单上的输入控件确定表单的状态,例如根据输入控件确定表单是否为脏或纯净
-
在表单或控件级别检查验证错误
如果你仍然想要标准表单行为,你可以向form元素添加ngNoForm属性,但这将肯定导致整个页面刷新。你还可以通过添加ngNativeValidate属性来开启浏览器的内置验证。我们将在本章稍后探讨NgForm API 的细节,当我们查看保存表单和实现验证时。
NgForm正在监控表单内FormControl对象的状态。如果其中任何一个无效,那么NgForm将整个表单设置为无效。在这种情况下,我们已经能够使用NgForm来确定一个或多个FormControl对象无效,因此整个表单的状态也是无效的。
在我们完成本章之前,让我们再看看一个问题。
修复表单的保存和验证消息
打开一个新的锻炼页面并直接点击保存按钮。由于表单无效,所以没有任何内容被保存,但单个表单输入的验证并不会显示出来。现在很难知道哪些元素导致了验证失败。这种行为背后的原因相当明显。如果我们查看名称输入元素的错误信息绑定,它看起来像这样:
*ngIf="name.control?.hasError('required') && name.touched"
记住,在本章的早期,我们明确禁用了显示验证消息,直到用户触摸输入控件。同样的问题又回来了,我们现在需要修复它。
我们没有方法可以显式地改变我们控件的被触摸状态为未触摸。相反,我们将求助于一些小技巧来完成这项工作。我们将引入一个新的属性,称为submitted。在Workout类定义的顶部添加它,并将其初始值设置为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),将显示错误消息。现在必须将这个表达式修复应用到每个出现检查的验证消息。
如果我们现在打开新的锻炼页面并点击保存按钮,我们应该在输入控件上看到所有验证消息:
响应式表单
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包含了我们构建响应式表单所需的所有内容。
接下来,从 trainer/src/app 下的 workout-builder/builder-services 文件夹中的 checkpoint 4.6 复制 exercise-builder-service.ts 并将其导入到 workout-builder.module.ts:
import { ExerciseBuilderService } from "./builder-services/exercise-builder-service";
然后,将其作为额外的提供者添加到该文件的提供者数组中:
@NgModule({
. . .
providers: [
WorkoutBuilderService,
WorkoutResolver,
ExerciseBuilderService,
ExerciseResolver
]
})
你会注意到这里我们还添加了 ExerciseResolver 作为提供者。我们在这里不会详细讲解,但你应该从 exercise 文件夹中复制它,并且也要复制更新后的 workout-builder-routing.module.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() {
this.sub = this.route.data
.subscribe(
(data: { exercise: Exercise }) => {
this.exercise = data.exercise;
}
);
this.buildExerciseForm();
}
当 ngOnInit 触发时,它将从 ExerciseResolver 检索并返回的路由数据中提取现有或新的 exercise 数据。这与我们初始化 Workout 组件时遵循的模式相同。
现在,让我们通过添加以下代码来实现 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,还可以添加 FormControlGroups 和 FormControlArray,它们包含内部的 FormControls。这意味着我们可以创建包含嵌套输入控件的复杂表单。正如我们提到的,在我们的情况下,我们需要为用户添加多个视频到练习的可能性做好准备。我们可以通过添加以下代码来实现这一点:
'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 class="row" [formGroup]="exerciseForm" (ngSubmit)="onSubmit(exerciseForm)">
在标签内,我们首先将代码中刚刚构建的exerciseForm分配给formGroup。这建立了我们的编码模型与视图中的表单之间的连接。我们还把ngSubmit事件连接到我们代码中的onSubmit方法(我们稍后会讨论这个方法)。
将表单控件添加到我们的表单输入项中
接下来,我们开始构建我们表单的输入项。我们将从练习名称的输入项开始:
<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-row align-items-center">
在循环内部,我们做三件事。首先,我们为当前在练习中的每个视频动态添加一个视频input字段:
<div class="col-sm-10">
<input type="text" class="form-control" [formControlName]="i" placeholder="Add a related youtube video identified."/>
</div>
接下来,我们添加一个按钮,允许用户删除一个视频:
<span class="btn alert-danger" title="Delete this video." (click)="deleteVideo(i)">
<span class="ion-ios-trash-outline"></span>
</span>
我们将组件类中的deleteVideo方法绑定到按钮的click事件,并将被删除视频的索引传递给它。
我们然后为每个视频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中的保存方法,并将用户路由回练习列表屏幕(记住,任何新的练习都不会显示在该列表中,因为我们尚未在我们的应用程序中实现数据持久性)。
我们希望这使它变得清晰;当我们试图构建更复杂的表单时,响应式表单提供了许多优势。它们允许将编程逻辑从模板中移除。它们允许以编程方式向表单添加验证器。而且,它们支持在运行时动态构建表单。
自定义验证器
现在,在我们结束这一章之前,我们再来看一件事情。正如任何参与构建网页表单(无论是 Angular 还是任何其他网络技术)的人所知,我们经常被要求创建适用于我们正在构建的应用的独特验证。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 指令”中介绍这一点。
运行验证的配置选项
在我们继续讨论验证之前,还有一个话题需要介绍,那就是运行验证的配置选项。到目前为止,我们一直在使用默认选项,该选项在每次输入事件上运行验证检查。然而,您可以选择将它们配置为在“blur”(即用户离开输入控件时)或表单提交时运行。您可以在表单级别或按控件逐个设置此配置。
例如,我们可能会决定为了避免在锻炼表单中处理缺失练习的复杂性,我们将该表单设置为仅在提交时进行验证。我们可以通过向表单标签添加以下高亮的NgFormOptions赋值来实现这一点:
<form #f="ngForm" name="formWorkout" (ngSubmit)="save(f.form)" [ngFormOptions]="{updateOn: 'submit'}" class="row">
这指示 Angular 仅在submit时运行我们的验证。尝试一下,你会发现当你输入表单时不会出现任何验证。留空表单并按下保存按钮,你将看到验证消息出现。当然,采取这种方法意味着在用户按下保存按钮之前,没有关于验证的视觉提示。
此外,使用这种方法在我们的表单中还有一些其他意想不到的副作用。第一个是,当我们输入标题到标题输入框时,标题不再在屏幕顶部更新。该值只有在按下保存按钮时才会更新。其次,如果你添加了一个或多个锻炼并删除了所有这些,你将看到验证消息出现。这是因为我们为这个控件设置了特殊条件,导致它在正常的验证流程之外触发。
因此,我们可能应该采取不同的方法。Angular 提供了通过允许我们在控件级别使用ngModelOptions来实施更精细的验证流程控制的选项。例如,让我们从表单标签中移除ngFormOptions赋值,并修改标题输入控件以添加ngModelOptions,如下所示:
<input type="text" name="title" class="form-control" #title="ngModel" id="workout-title" placeholder="What would be the workout title?" [(ngModel)]="workout.title" [ngModelOptions]="{updateOn: 'blur'}" minlength="5" maxlength="20" required>
你会注意到,当你将标题输入到输入框中时,它不会在屏幕上更新标题,直到你离开它(这会触发updateOn事件):
如你所记,默认选项会在每次按键时更新标题。这是一个假设的例子,但它说明了这些配置之间的差异是如何工作的。
你可能在这里看不到使用 on blur 设置的必要性。但是,如果你可能通过调用外部数据存储来进行验证,这种方法可以帮助限制所进行的调用次数。而进行这样的远程调用正是我们在第六章,“深入理解 Angular 指令”中将要做的,当我们实现一个自定义指令时。该指令将检查远程数据存储中是否已存在重复的名称。因此,让我们从标题输入控件中移除此配置,并将其放置在名称输入控件上,如下所示:
<input type="text" name="workoutName" #name="ngModel" class="form-control" id="workout-name" placeholder="Enter workout name. Must be unique." [(ngModel)]="workout.name" [ngModelOptions]="{updateOn: 'blur'}" required>
我们还可以在响应式表单中设置验证时间选项。根据我们已学到的关于响应式表单的知识,你不会对我们在代码中而不是模板中应用这些设置感到惊讶。例如,为了设置表单组的语法如下:
new FormGroup(value, {updateOn: 'blur'}));
我们还可以将它们应用于单个表单控件,这正是我们在练习表单中的做法。就像锻炼表单一样,我们希望能够通过远程调用验证名称的唯一性。因此,我们希望以类似的方式限制验证检查。我们将通过向创建名称表单控件的代码中添加以下内容来实现这一点:
buildExerciseForm() {
this.exerciseForm = this.formBuilder.group({
'name': [
this.exercise.name,
{
updateOn: 'blur',
validators: [Validators.required, AlphaNumericValidator.invalidAlphaNumeric]
}
],
....
});
}
注意,我们将设置以及validators数组放在一个花括号内的options对象中。
摘要
现在我们有一个个人教练应用程序。将特定的7 分钟锻炼应用程序转换为通用的个人教练应用程序的过程帮助我们学习了许多新概念。
我们以定义新的应用程序需求开始本章。然后,我们将模型设计为一个共享服务。
我们为个人教练应用程序定义了一些新的视图和相应的路由。我们还使用了子路由和异步路由来将锻炼构建器从应用程序的其他部分分离出来。
然后,我们将注意力转向锻炼构建。本章的一个主要技术重点是 Angular 表单。锻炼构建器使用了多个表单输入元素,我们使用模板驱动和响应式表单实现了许多常见的表单场景。我们还深入探讨了 Angular 验证,并实现了一个自定义验证器。我们还介绍了配置运行验证的时间选项。
下一章全部关于客户端-服务器交互。我们创建的锻炼和练习需要持久化。在下一章中,我们将构建一个持久化层,这将允许我们在服务器上保存锻炼和练习数据。
在我们结束本章之前,这里有一个友好的提醒。如果您还没有完成个人教练的练习构建流程,请继续进行。您始终可以将您的实现与配套代码库中提供的内容进行比较。您还可以向原始实现添加一些内容,例如上传练习图片的文件,以及一旦您更熟悉客户端-服务器交互,可以进行远程检查以确定 YouTube 视频实际上是否存在。