Angular 企业就绪的 Web 应用(六)
原文:
zh.annas-archive.org/md5/eaf56b09bedec2a30920ca225cb1149e译者:飞龙
第十一章:配方 – 可重用性、路由和缓存
在接下来的两章中,我们将完成 LemonMart 的主要实现,并完善我们对路由优先方法的覆盖。在本章中,我将通过创建一个可重用且可路由的组件,同时支持数据绑定,来强化解耦组件架构的概念。我们使用 Angular 指令来减少样板代码,并利用类、接口、枚举、验证器和管道,通过 TypeScript 和 ES 特性最大化代码重用。
此外,我们还将创建一个在架构上可扩展且支持响应式设计的多步骤表单。然后,我们将通过引入柠檬评分器和封装名称对象的可重用表单部分来区分用户控件和组件。
确保在实现本章中提到的配方时,你的 lemon-mart-server 正在运行。有关更多信息,请参阅 第十章,RESTful API 和全栈实现。
本章内容丰富。它以配方格式组织,因此当你正在处理项目时,可以快速参考特定的实现。我将涵盖实现的结构、设计和主要组件。我将突出显示重要的代码片段,以解释解决方案是如何组合在一起的。利用你迄今为止所学到的知识,我期望读者能够填写常规实现和配置细节。然而,如果你遇到困难,始终可以参考 GitHub 项目。
在本章中,你将学习以下主题:
-
使用缓存服务响应的 HTTP PUT 请求
-
多步骤响应式表单
-
使用指令重用重复模板行为
-
可扩展的表单架构,具有可重用表单部分
-
输入掩码
-
使用
ControlValueAccessor的自定义控件 -
使用网格列表布局
样本代码的最新版本可在 GitHub 上找到,链接将在稍后提供。该存储库包含代码的最终和完成状态。你可以在本章末尾通过查看 projects 文件夹下的代码快照来验证你的进度。
为了准备本章内容,请执行以下操作:
-
克隆
github.com/duluca/lemon-mart上的存储库。 -
在根目录下执行
npm install以安装依赖项。 -
本章的代码示例位于以下子文件夹下:
projects/ch11 -
要运行本章的 Angular 应用程序,请执行以下命令:
npx ng serve ch11 -
要运行本章的 Angular 单元测试,请执行以下命令:
npx ng test ch11 --watch=false -
要运行本章的 Angular e2e 测试,请执行以下命令:
npx ng e2e ch11 -
要构建本章的生产就绪 Angular 应用程序,请执行以下命令:
npx ng build ch11 --prod注意,存储库根目录下的
dist/ch11文件夹将包含编译结果。
请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码相匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现也可能存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、支持库新版本的修复或多种技术的并排实现,供读者观察。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有问题,请创建一个 issue 或提交一个 pull request 到 GitHub,以惠及所有读者。
你可以在附录 C,保持 Angular 和工具常青中了解更多关于更新 Angular 的信息。您可以从static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdf或expertlysimple.io/stay-evergreen在线找到此附录。
让我们从实现一个用户服务来检索数据开始,这样我们就可以构建一个表单来显示和编辑个人资料信息。稍后,我们将重构此表单以抽象出其可重用部分。
使用 GET 实现用户服务
为了实现用户个人资料,我们需要一个可以执行IUser上的 CRUD 操作的服务。我们将创建一个实现以下接口的用户服务:
export interface IUserService {
getUser(id: string): Observable<IUser>
updateUser(id: string, user: IUser): Observable<IUser>
getUsers(
pageSize: number,
searchText: string,
pagesToSkip: number
): Observable<IUsers>
}
在创建服务之前,请确保启动lemon-mart-server并将应用程序的AuthMode设置为CustomServer。
在本节中,我们将实现getUser和updateUser函数。我们将在第十二章,食谱 – 主/详细信息,数据表和 NgRx中实现getUsers,以支持数据表分页。
首先创建用户服务:
-
在
src/app/user/user下创建一个UserService: -
从前面的片段中声明
IUserService接口,不包括getUsers函数。 -
使用
CacheService扩展UserService类并实现IUserService。 -
如下所示在构造函数中注入
HttpClient:**src/app/user/user/user.service.ts** export interface IUserService { getUser(id: string): Observable<IUser> updateUser(id: string, user: IUser): Observable<IUser> } @Injectable({ providedIn: 'root', }) export class UserService extends CacheService implements IUserService { constructor() { super() } getUser(id: string): Observable<IUser> { throw new Error('Method not implemented.') } updateUser(id: string, user: IUser): Observable<IUser> { throw new Error('Method not implemented.') } } -
如下所示实现
getUser函数:src/app/user/user/user.service.ts getUser(id: string | null): Observable<IUser> { if (id === null) { return throwError('User id is not set') } return this.httpClient.get<IUser>( `${environment.baseUrl}/v2/user/${id}` ) }
我们提供了一个getUser函数,可以加载任何用户的个人资料信息。请注意,此函数的安全性由服务器实现中的认证中间件提供。请求者可以获取自己的个人资料,或者他们需要是管理员。我们将在本章后面使用getUser与解析守卫。
实现带有缓存的 PUT
实现updateUser,它接受一个实现了IUser接口的对象,因此数据可以发送到 PUT 端点:
**src/app/user/user/user.service.ts**
updateUser(id: string, user: IUser): Observable<IUser> {
if (id === '') {
return throwError('User id is not set')
}
// cache user data in case of errors
this.setItem('draft-user', Object.assign(user, { _id: id }))
const updateResponse$ = this.httpClient
.put<IUser>(`${environment.baseUrl}/v2/user/${id}`, user)
.pipe(**map(User.Build)**, catchError(transformError))
updateResponse$.subscribe(
(res) => {
this.authService.currentUser$.next(res)
this.removeItem('draft-user')
},
(err) => throwError(err)
)
return updateResponse$
}
注意使用缓存服务中的 setItem 来保存用户输入的数据,以防 put 调用失败。当调用成功时,我们使用 removeItem 删除缓存数据。同时注意我们如何使用 map(User.Build) 将来自服务器的用户作为 User 对象进行润滑,这调用 class User 的构造函数。
“Hydrate”是一个常用术语,指的是用数据库或网络请求中的数据填充一个对象。例如,我们在组件之间传递或从服务器接收的 User JSON 对象符合 IUser 接口,但它不是 class User 类型。我们使用 toJSON 方法将对象序列化为 JSON。当我们从 JSON 润滑并实例化一个新对象时,我们执行相反的操作并反序列化数据。
需要强调的是,在传递数据时,你应该始终坚持使用接口,而不是像 User 这样的具体实现。这是 SOLID 原则中的 D(依赖倒置原则)。依赖于具体实现会带来很多风险,因为它们经常变化,而像 IUser 这样的抽象很少会变化。毕竟,你不会直接将灯泡焊接在墙上的电线中。相反,你首先将灯泡焊接在插头上,然后使用插头获取所需的电力。
完成此代码后,UserService 现在可以用于基本的 CRUD 操作。
多步骤响应式表单
总体来说,表单与你的应用程序的其他部分不同,它们需要特殊的架构考虑。我不建议过度设计你的表单解决方案,使用动态模板或启用路由的组件。从可维护性和易于实施的角度来看,创建一个巨大的组件比使用上述一些策略和过度设计更好。
我们将实现一个多步骤输入表单,在单个组件中捕获用户配置文件信息。我将在本章的“可重用表单部分和可扩展性”部分介绍我推荐的将表单拆分为多个组件的技术。
由于表单的实现在这部分和本章后面的内容中变化很大,你可以在 GitHub 上找到初始版本的代码,地址为 projects/ch11/src/app/user/profile/profile.initial.component.ts 和 projects/ch11/src/app/user/profile/profile.initial.component.html。
我们还将使用媒体查询使这个多步骤表单对移动设备响应:
-
让我们从添加一些辅助数据开始,这些数据将帮助我们显示带有选项的输入表单:
**src/app/user/profile/data.ts** export interface IUSState { code: string name: string } export function USStateFilter(value: string): IUSState[] { return USStates.filter((state) => { return ( (state.code.length === 2 && state.code.toLowerCase() === value.toLowerCase()) || state.name.toLowerCase().indexOf(value.toLowerCase()) === 0 ) }) } const USStates = [ { code: 'AK', name: 'Alaska' }, { code: 'AL', name: 'Alabama' }, ... { code: 'WY', name: 'Wyoming' }, ] -
将新的验证规则添加到
common/validations.ts:**src/app/common/validations.ts** ... export const OptionalTextValidation = [Validators.minLength(2), Validators.maxLength(50)] export const RequiredTextValidation = OptionalTextValidation.concat([Validators.required]) export const OneCharValidation = [Validators.minLength(1), Validators.maxLength(1)] export const USAZipCodeValidation = [ Validators.required, Validators.pattern(/^\d{5}(?:[-\s]\d{4})?$/), ] export const USAPhoneNumberValidation = [ Validators.required, Validators.pattern(/^\D?(\d{3})\D?\D?(\d{3})\D?(\d{4})$/), ] -
现在,按照以下方式实现
profile.component.ts:**src/app/user/profile/profile.component.ts** import { Role } from '../../auth/auth.enum' import { $enum } from 'ts-enum-util' import { IName, IPhone, IUser, PhoneType } from '../user/user' ... @Component({ selector: 'app-profile', templateUrl: './profile.component.html', styleUrls: ['./profile.component.css'], }) export class ProfileComponent implements OnInit { Role = Role PhoneType = PhoneType PhoneTypes = $enum(PhoneType).getKeys() formGroup: FormGroup states$: Observable<IUSState[]> userError = '' currentUserId: string constructor( private formBuilder: FormBuilder, private uiService: UiService, private userService: UserService, private authService: AuthService ) {} ngOnInit() { this.buildForm() this.authService.currentUser$ .pipe( filter((user) => user !== null), tap((user) => { this.currentUserId = user._id this.buildForm(user) }) ) .subscribe() } private get currentUserRole() { return this.authService.authStatus$.value.userRole } buildForm(user?: IUser) {} ... }
在加载时,我们从authService请求当前用户,但这可能需要一些时间,所以我们首先使用this.buildForm()作为第一条语句构建一个空表单。我们还将用户的 ID 存储在currentUserId属性中,稍后当我们实现save功能时将需要它。
注意,我们过滤掉了null或undefined的用户。
在本章的后面部分,我们将实现一个解析守卫,根据路由上提供的userId加载用户,以提高该组件的可重用性。
表单控件和表单组
如您所忆,FormControl对象是表单的最基本部分,通常代表单个输入字段。我们可以使用FormGroup将一组相关的FormControl对象组合在一起,例如一个人的名字的各个部分(首、中、姓)。FormGroup对象还可以将FormControl、FormGroup和FormArray对象组合在一起,后者允许我们拥有动态重复的元素。FormArray将在本章的“动态表单数组”部分进行介绍。
我们的形式有很多输入字段,因此我们将使用由this.formBuilder.group创建的FormGroup来容纳我们的各种FormControl对象。此外,子FormGroup对象将允许我们保持数据结构的正确形状。
由于表单的实现在这部分和本章后面的部分之间发生了巨大变化,您可以在 GitHub 上找到初始版本的代码,位于projects/ch11/src/app/user/profile/profile.initial.component.ts和projects/ch11/src/app/user/profile/profile.initial.component.html。
开始构建buildForm函数,如下所示:
**src/app/user/profile/profile.component.ts**
...
buildForm(user?: IUser) {
this.formGroup =
this.formBuilder.group({
email: [
{
value: user?.email || '',
disabled: this.currentUserRole !== Role.Manager,
},
EmailValidation,
],
name: this.formBuilder.group({
first: [user?.name?.first || '', RequiredTextValidation],
middle: [user?.name?.middle || '', OneCharValidation],
last: [user?.name?.last || '', RequiredTextValidation],
}),
role: [
{
value: user?.role || '',
disabled: this.currentUserRole !== Role.Manager,
},
[Validators.required],
],
dateOfBirth: [user?.dateOfBirth || '', Validators.required],
address: this.formBuilder.group({
line1: [user?.address?.line1 || '', RequiredTextValidation],
line2: [user?.address?.line2 || '', OptionalTextValidation],
city: [user?.address?.city || '', RequiredTextValidation],
state: [user?.address?.state || '', RequiredTextValidation],
zip: [user?.address?.zip || '', USAZipCodeValidation],
}),
})
}
buildForm函数可以接受一个IUser对象来预填充表单,否则所有字段都将设置为它们的默认值。formGroup属性本身是顶级FormGroup。根据需要,各种FormControls被添加到其中,例如email,并附加了相应的验证器。注意name和address是它们自己的FormGroup对象。这种父子关系确保了表单数据在序列化为 JSON 时的正确结构,这样我们的应用程序和服务器端代码就可以以IUser的结构来利用它。
您将通过遵循本章提供的示例代码独立完成formGroup的实现。我将在接下来的几个部分中逐段解释代码,以解释某些关键功能。
步进器和响应式布局
Angular Material 的步进器自带 MatStepperModule。步进器允许将表单输入分成多个步骤,这样用户就不会一次性处理数十个输入字段而感到不知所措。用户仍然可以跟踪他们在过程中的位置,作为副作用,作为开发者,我们可以将 <form> 实现拆分,并逐步实施验证规则或创建可选的工作流程,其中某些步骤可以跳过或必填。与所有 Material 用户控件一样,步进器是考虑到响应式 UX 而设计的。在接下来的几节中,我们将实现三个步骤,涵盖过程中的不同表单输入技术:
-
账户信息
-
输入验证
-
响应式布局与媒体查询
-
计算属性
-
日期选择器
-
-
联系信息
-
自动完成支持
-
动态表单数组
-
-
复习
-
只读视图
-
保存和清除数据
-
让我们为一些新的材料模块准备 UserModule:
随着我们开始添加子材料模块,将我们的根 material.module.ts 文件重命名为 app-material.modules.ts,以符合 app-routing.module.ts 的命名方式。从现在开始,我将使用后者的约定。
-
将
src/app/material.modules.ts文件重命名为app-material.module.ts,然后将MaterialModule类重命名为AppMaterialModule。 -
创建一个包含以下材料模块的
user-material.module.ts文件:MatAutocompleteModule, MatDatepickerModule, MatDividerModule, MatLineModule, MatNativeDateModule, MatRadioModule, MatSelectModule, MatStepperModule, -
确保
user.module.ts正确导入以下内容:-
新的
user-material.module -
基线
app-material.module -
所需的
ReactiveFormsModule和FlexLayoutModule
-
-
实现一个包含第一步的横向步进器表单:
由于本节和本章后面的表单实现变化很大,你可以在 GitHub 上的
projects/ch11/src/app/user/profile/profile.initial.component.ts和projects/ch11/src/app/user/profile/profile.initial.component.html找到初始版本的代码。**src/app/user/profile/profile.component.html** <mat-toolbar color="accent"> <h5>User Profile</h5> </mat-toolbar> <mat-horizontal-stepper #stepper="matHorizontalStepper"> <mat-step [stepControl]="formGroup"> <form [formGroup]="formGroup"> <ng-template matStepLabel>Account Information</ng-template> <div class="stepContent"> ... </div> </form> </mat-step> </mat-horizontal-stepper> -
现在,开始实现
Account Information步骤中的name行,以替换前一个步骤中的省略号:**src/app/user/profile/profile.component.html** <div fxLayout="row" fxLayout.lt-sm="column" [formGroup]="formGroup.get('name')" fxLayoutGap="10px"> <mat-form-field appearance="outline" fxFlex="40%"> <input matInput placeholder="First Name" aria-label="First Name" formControlName="first"> <mat-error *ngIf="formGroup.get('name.first')?.hasError('required')"> First Name is required </mat-error> <mat-error *ngIf="formGroup.get('name.first')?.hasError('minLength')"> Must be at least 2 characters </mat-error> <mat-error *ngIf="formGroup.get('name.first')?.hasError('maxLength')"> Can't exceed 50 characters </mat-error> </mat-form-field> <mat-form-field appearance="outline" fxFlex="20%"> <input matInput placeholder="MI" aria-label="Middle Initial" formControlName="middle"> <mat-error *ngIf="formGroup.get('name.middle')?.invalid"> Only initial </mat-error> </mat-form-field> <mat-form-field appearance="outline" fxFlex="40%"> <input matInput placeholder="Last Name" aria-label="Last Name" formControlName="last"> <mat-error *ngIf="formGroup.get('name.last')?.hasError('required')"> Last Name is required </mat-error> <mat-error *ngIf="formGroup.get('name.last')?.hasError('minLength')"> Must be at least 2 characters </mat-error> <mat-error *ngIf="formGroup.get('name.last')?.hasError('maxLength')"> Can't exceed 50 characters </mat-error> </mat-form-field> </div> -
请注意理解到目前为止步进器和表单配置是如何工作的。你应该能看到第一行渲染,从 lemon-mart-server 拉取数据:
图 11.1:多步骤表单 – 第 1 步
注意,将 fxLayout.lt-sm="column" 添加到具有 fxLayout="row" 的行中,可以启用表单的响应式布局,如下所示:
图 11.2:移动端的多步骤表单
在我们继续介绍如何实现 出生日期 字段之前,让我们通过实现错误消息来重新评估我们的策略。
使用指令重用重复模板行为
在上一节中,我们为 name 对象的每个字段部分的每个验证错误实现了一个 mat-error 元素。对于三个字段,这会迅速增加到七个元素。在 第八章,设计身份验证和授权 中,我们实现了 common/validations.ts 以重用验证规则。我们可以使用属性指令重用我们在 mat-error 中实现的行为,或者任何其他 div,使用属性指令。
属性指令
在 第一章,Angular 及其概念简介 中,我提到 Angular 组件代表 Angular 应用程序的最基本单元。通过组件,我们定义自己的 HTML 元素,这些元素可以重用模板和一些 TypeScript 代码所表示的功能和特性。另一方面,指令增强了现有元素或组件的功能。在某种程度上,组件是一个超级指令,它增强了基本的 HTML 功能。
考虑到这个视图,我们可以定义三种类型的指令:
-
组件
-
结构指令
-
属性指令
基本上,组件是带有模板的指令,这是你将最常使用的指令类型。结构指令通过添加或删除元素来修改 DOM,*ngIf 和 *ngFor 是典型的例子。最后,属性指令允许你定义可以添加到 HTML 元素或组件的新属性,以向它们添加新行为。
让我们实现一个可以封装字段级错误行为的属性指令。
字段错误属性指令
想象一下我们如何使用指令来减少重复元素以显示字段错误。以下是一个使用姓名字段作为示例的例子:
**example**
<mat-form-field appearance="outline" fxFlex="40%">
<mat-label>First Name</mat-label>
<input matInput aria-label="First Name"
formControlName="first" #name />
<mat-error **[input]="name" [group]="formGroup.get('name')"**
**[appFieldError]="ErrorSets.RequiredText">**
</mat-error>
</mat-form-field>
我们有一个标准布局结构用于材料表单字段,但只有一个 mat-error 元素。mat-error 上有三个新属性:
-
input通过模板引用变量绑定到标记为#name的 HTML 输入元素,这样我们就可以访问输入元素的模糊事件,并能够读取placeholder、aria-label和formControlName属性。 -
group绑定到包含表单控件的父表单组对象,因此我们可以使用输入的formControlName属性来检索formControl对象,同时避免额外的代码。 -
appFieldError绑定到一个数组,该数组包含需要与formControl对象进行校验的验证错误,例如required、minlength、maxlength和invalid。
使用前面的信息,我们可以创建一个指令,可以在 mat-error 元素内渲染一行或多行错误消息,有效地复制我们在上一节中使用的冗长方法。
让我们继续创建一个名为 FieldErrorDirective 的属性指令:
-
在
src/app/user-controls下创建FieldErrorDirective。 -
将指令的选择器定义为名为
appFieldError的可绑定属性:**src/app/user-controls/field-error/field-error.directive.ts** @Directive({ selector: '**[appFieldError]**', }) -
在指令外部,定义一个新的类型名为
ValidationError,它定义了我们将要处理的错误条件类型:**src/app/user-controls/field-error/field-error.directive.ts** export type ValidationError = 'required' | 'minlength' | 'maxlength' | 'invalid' -
类似于我们分组验证的方式,让我们定义两组常见的错误条件,这样我们就不必反复输入它们:
**src/app/user-controls/field-error/field-error.directive.ts** export const ErrorSets: { [key: string]: ValidationError[] } = { OptionalText: ['minlength', 'maxlength'], RequiredText: ['minlength', 'maxlength', 'required'], } -
接下来,让我们定义指令的
@Input目标:**src/app/user-controls/field-error/field-error.directive.ts** export class FieldErrorDirective implements OnDestroy, OnChanges { @Input() appFieldError: | ValidationError | ValidationError[] | { error: ValidationError; message: string } | { error: ValidationError; message: string }[] @Input() input: HTMLInputElement | undefined @Input() group: FormGroup @Input() fieldControl: AbstractControl | null @Input() fieldLabel: string | undefined注意,我们已经讨论了前三个属性的目的。
fieldControl和fieldLabel是可选属性。如果指定了input和group,可选属性可以自动填充。由于它们是类级别的变量,因此公开它们是有意义的,以防用户想要覆盖指令的默认行为。这有助于创建灵活且可重用的控件。 -
在构造函数中导入元素引用,这可以在稍后由
renderErrors函数用于在mat-error元素的内部 HTML 中显示错误:**src/app/user-controls/field-error/field-error.directive.ts** private readonly nativeElement: HTMLElement constructor(private el: ElementRef) { this.nativeElement = this.el.nativeElement } renderErrors(errors: string) { this.nativeElement.innerHTML = errors } -
实现一个函数,该函数可以根据错误类型返回预定义的错误信息:
**src/app/user-controls/field-error/field-error.directive.ts** getStandardErrorMessage(error: ValidationError): string { const label = this.fieldLabel || 'Input' switch (error) { case 'required': return `${label} is required` case 'minlength': return `${label} must be at least ${ this.fieldControl?.getError(error)?.requiredLength ?? 2 } characters` case 'maxlength': return `${label} can\'t exceed ${ this.fieldControl?.getError(error)?.requiredLength ?? 50 } characters` case 'invalid': return `A valid ${label} is required` } }注意,我们可以从
fieldControl动态提取所需的minlength或maxlength值,这大大减少了我们需要生成的自定义消息的数量。 -
实现一个算法,该算法可以使用
getStandardErrorMessage方法遍历appFieldError中的所有元素以及需要显示在数组中的错误:**src/app/user-controls/field-error/field-error.directive.ts** updateErrorMessage() { const errorsToDisplay: string[] = [] const errors = Array.isArray(this.appFieldError) ? this.appFieldError : [this.appFieldError] errors.forEach( (error: ValidationError | { error: ValidationError; message: string }) => { const errorCode = typeof error === 'object' ? error.error : error const message = typeof error === 'object' ? () => error.message : () => this.getStandardErrorMessage(errorCode) const errorChecker = errorCode === 'invalid' ? () => this.fieldControl?.invalid : () => this.fieldControl?.hasError(errorCode) if (errorChecker()) { errorsToDisplay.push(message()) } } ) this.renderErrors(errorsToDisplay.join('<br>')) }最后,我们可以使用
renderErrors方法来显示错误信息。注意函数委托的使用。由于这段代码可能每分钟执行数百次,因此避免不必要的调用非常重要。函数委托有助于更好地组织我们的代码,同时将它们的逻辑执行推迟到绝对必要时。
-
现在,初始化
fieldControl属性,它代表一个formControl。我们将监听控制的valueChanges事件,如果验证状态无效,则执行我们的自定义updateErrorMessage逻辑来显示错误信息:**src/app/user-controls/field-error/field-error.directive.ts** private controlSubscription: Subscription | undefined ngOnDestroy(): void { this.unsubscribe() } unsubscribe(): void { this.controlSubscription?.unsubscribe() } initFieldControl() { if (this.input && this.group) { const controlName = this.input. getAttribute('formControlName') ?? '' this.fieldControl = this.fieldControl || this.group.get(controlName) if (!this.fieldControl) { throw new Error( `[appFieldError] couldn't bind to control ${controlName}` ) } this.unsubscribe() this.controlSubscription = this.fieldControl?.valueChanges .pipe( filter(() => this.fieldControl?.status === 'INVALID'), tap(() => this.updateErrorMessage()) ) .subscribe() } }注意,由于我们正在订阅
valueChanges,我们必须取消订阅。我们使用ngOnDestroy取消订阅一次,然后在订阅之前再次取消订阅。这是因为initFieldControl可能被多次调用。如果我们不清除之前的订阅,将导致内存泄漏和相关性能问题。此外,如果我们无法绑定到
fieldControl,我们将抛出一个错误信息,因为这通常表明编码错误。 -
最后,我们使用
ngOnChanges事件配置所有主要属性,该事件在更新任何@Input属性时触发。这确保了在表单元素可能动态添加或删除的情况下,我们始终考虑最新的值。我们调用initFieldControl以开始监听值变化,实现一个onblur事件处理器,该处理器触发updateErrorMessage()为 HTML 输入元素,并分配fieldLabel的值:**src/app/user-controls/field-error/field-error.directive.ts** ngOnChanges(changes: SimpleChanges): void { **this.initFieldControl()** if (changes.input.firstChange) { if (this.input) { **this.input.onblur = () => this.updateErrorMessage()** **this.fieldLabel** = this.fieldLabel || this.input.placeholder || this.input.getAttribute('aria-label') || '' } else { throw new Error( `appFieldError.[input] couldn't bind to any input element` ) } } }注意,如果我们无法绑定到 HTML
input元素,这通常意味着开发者忘记正确连接这些元素。在这种情况下,我们抛出一个新的Error对象,这在控制台中生成一个有用的堆栈跟踪,以便你可以确定模板中错误发生的位置。
这完成了指令的实现。现在,我们需要将指令打包到一个名为field-error.module.ts的模块中:
**src/app/user-controls/field-error/field-error.directive.ts**
@NgModule({
imports: [CommonModule, ReactiveFormsModule],
declarations: [FieldErrorDirective],
exports: [FieldErrorDirective],
})
export class FieldErrorModule {}
现在继续在我们的现有表单中使用这个指令:
-
在
app.module.ts和user.module.ts中导入模块。 -
使用新指令更新
profile.component.html。 -
使用新指令更新
login.component.html。
确保在component类中将ErrorSets定义为公共属性变量,以便你可以在模板中使用它。
测试你的表单以确保我们的验证消息按预期显示,并且没有控制台错误。
恭喜!你已经学会了如何使用指令将新行为注入其他元素和组件。通过这样做,我们能够避免大量的重复代码,并在我们的应用程序中标准化错误消息。
在继续之前,通过查看 GitHub 上的实现来完成表单的实现。你可以在projects/ch11/src/app/user/profile/profile.initial.component.html找到表单模板的代码,在projects/ch11/src/app/user/profile/profile.initial.component.ts找到component类。
不要包含app-lemon-rater和app-view-user元素,并从电话号码中移除mask属性,我们将在本章后面实现它。
在这里,你可以看到用户资料在 LemonMart 上的显示方式:
图 11.3:基本完成的配置文件组件
接下来,让我们继续查看profile组件,看看出生日期字段是如何工作的。
计算属性和 DatePicker
我们可以根据用户输入显示基于计算属性的值。例如,为了显示一个人的年龄,基于他们的出生日期,引入计算年龄的类属性,并如下显示它:
**src/app/user/profile/profile.component.ts**
now = new Date()
get dateOfBirth() {
return this.formGroup.get('dateOfBirth')?.value || this.now
}
get age() {
return this.now.getFullYear() - this.dateOfBirth.getFullYear()
}
要验证过去一百年内的日期,实现一个minDate类属性:
**src/app/user/profile/profile.component.ts**
minDate = new Date(
this.now.getFullYear() - 100,
this.now.getMonth(),
this.now.getDate()
)
模板中计算属性的使用如下所示:
**src/app/user/profile/profile.component.html**
<mat-form-field appearance="outline" fxFlex="50%">
<mat-label>Date of Birth</mat-label>
<input matInput aria-label="Date of Birth" formControlName="dateOfBirth"
**[min]="minDate" [max]="now"** [matDatepicker]="dateOfBirthPicker" #dob />
<mat-hint *ngIf="formGroup.get('dateOfBirth')?.value">
{{ age }} year(s) old
</mat-hint>
<mat-datepicker-toggle matSuffix [for]="dateOfBirthPicker">
</mat-datepicker-toggle>
<mat-datepicker #dateOfBirthPicker></mat-datepicker>
<mat-error [input]="dob" [group]="formGroup"
[appFieldError]="{error: 'invalid', message: 'Date must be within the last 100 years'}">
</mat-error>
</mat-form-field>
参考前面片段中突出显示的[min]和[max]属性,了解一百年日期范围的适用。
DatePicker的实际效果如下所示:
图 11.4:使用 DatePicker 选择日期
注意,2020 年 4 月 26 日之后的日期将以灰色显示。选择日期后,计算出的年龄将如下显示:
图 11.5:计算年龄属性
现在,让我们继续进行下一步,联系信息,看看我们如何能够方便地显示和输入地址字段的状态部分。
自动完成支持
在 buildForm 中,我们监听 address.state 以支持类型提示过滤下拉菜单体验:
**src/app/user/profile/profile.component.ts**
const state = this.formGroup.get('address.state')
if (state != null) {
this.states$ = state.valueChanges.pipe(
startWith(''),
map((value) => USStateFilter(value))
)
}
在模板上实现 mat-autocomplete,绑定到过滤后的状态数组,并使用 async 管道:
**src/app/user/profile/profile.component.html**
...
<mat-form-field appearance="outline" fxFlex="30%">
<mat-label>State</mat-label>
<input type="text" aria-label="State" matInput formControlName="state"
[matAutocomplete]="stateAuto" #state />
<mat-autocomplete #stateAuto="matAutocomplete">
<mat-option *ngFor="let state of (states$ | async)" [value]="state.name">
{{ state.name }}
</mat-option>
</mat-autocomplete>
<mat-error [input]="state" [group]="formGroup.get('address')"
appFieldError="required">
</mat-error>
</mat-form-field>
...
当用户输入 V 字符时,它看起来是这样的:
图 11.6:带有自动完成支持的下拉菜单
在下一节中,让我们启用多个电话号码的输入。
动态表单数组
注意,phones 是一个数组,可能允许许多输入。我们可以通过使用 this.formBuilder.array 函数构建 FormArray 来实现这一点。我们还定义了几个辅助函数,以使构建 FormArray 更容易:
-
buildPhoneFormControl有助于构建单个条目的FormGroup对象。 -
buildPhoneArray根据需要创建尽可能多的FormGroup对象,或者如果表单为空,则创建一个空条目。 -
addPhone向FormArray添加一个新的空FormGroup对象。 -
get phonesArray()是一个方便的属性,可以从表单中获取phones控件。
让我们看看实现是如何结合在一起的:
**src/app/user/profile/profile.component.ts**
...
phones: this.formBuilder.array(this.buildPhoneArray(user?.phones || [])),
...
private buildPhoneArray(phones: IPhone[]) {
const groups = []
if (phones?.length === 0) {
groups.push(this.buildPhoneFormControl(1))
} else {
phones.forEach((p) => {
groups.push(
this.buildPhoneFormControl(p.id, p.type, p.digits)
)
})
}
return groups
}
private buildPhoneFormControl(
id: number, type?: string, phoneNumber?: string
) {
return this.formBuilder.group({
id: [id],
type: [type || '', Validators.required],
digits: [phoneNumber || '', USAPhoneNumberValidation],
})
}
...
buildPhoneArray 支持使用单个电话输入初始化表单或用现有数据填充它,与 buildPhoneFormControl 一起工作。当用户点击 添加 按钮创建新行时,后者非常有用:
**src/app/user/profile/profile.component.ts**
...
addPhone() { this.phonesArray.push(
this.buildPhoneFormControl(
this.formGroup.get('phones').value.length + 1)
)
}
get phonesArray(): FormArray {
return this.formGroup.get('phones') as FormArray
}
...
phonesArray 属性获取器是一个常见的模式,可以使访问某些表单属性更容易。然而,在这种情况下,这也是必要的,因为 get('phones') 必须转换为 FormArray,这样我们就可以在模板上访问其 length 属性:
**src/app/user/profile/profile.component.html**
...
<mat-list formArrayName="phones">
<h2 mat-subheader>Phone Number(s)
<button mat-button (click)="addPhone()">
<mat-icon>add</mat-icon>
Add Phone
</button>
</h2>
<mat-list-item style="margin-top: 36px;"
*ngFor="let position of phonesArray.controls; let i = index"
[formGroupName]="i">
<mat-form-field appearance="outline" fxFlex="100px">
<mat-label>Type</mat-label>
<mat-select formControlName="type">
<mat-option *ngFor="let type of PhoneTypes"
[value]="**convertTypeToPhoneType(type)**">
{{ type }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" fxFlex fxFlexOffset="10px">
<mat-label>Number</mat-label>
<input matInput type="text" formControlName="digits"
aria-label="Phone number" prefix="+1" />
<mat-error
*ngIf="phonesArray.controls[i].invalid &&
phonesArray.controls[i].touched">
A valid phone number is required
</mat-error>
</mat-form-field>
<button fxFlex="33px" mat-icon-button
(click)="**phonesArray.removeAt(i)**">
<mat-icon>delete</mat-icon>
</button>
</mat-list-item>
</mat-list>
...
注意突出显示的 convertTypeToPhoneType 函数,它将 string 转换为 enum PhoneType。
在前面的代码块中,也突出显示了 remove 函数是如何在模板中内联实现的,这使得它更容易阅读和维护。
让我们看看动态数组应该如何工作:
图 11.7:使用 FormArray 的多个输入
现在我们已经完成了数据输入,我们可以继续到步骤器的最后一步,审查。然而,如前所述,审查步骤使用 <app-view-user> 指令来显示其数据。让我们首先构建这个视图。
创建共享组件
这里是 <app-view-user> 指令的最小实现,它是 审查 步骤的先决条件。
在 user 模块下创建一个新的 viewUser 组件,如下所示:
**src/app/user/view-user/view-user.component.ts**
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'
import { Router } from '@angular/router'
import { BehaviorSubject } from 'rxjs'
import { IUser, User } from '../user/user'
@Component({
selector: 'app-view-user',
template: `
<div *ngIf="currentUser$ | async as currentUser">
<mat-card>
<mat-card-header>
<div mat-card-avatar>
<mat-icon>account_circle</mat-icon>
</div>
<mat-card-title>
{{ currentUser.fullName }}
</mat-card-title>
<mat-card-subtitle>
{{ currentUser.role }}
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<p><span class="mat-input bold">E-mail</span></p>
<p>{{ currentUser.email }}</p>
<p><span class="mat-input bold">Date of Birth</span></p>
<p>{{ currentUser.dateOfBirth | date: 'mediumDate' }}</p>
</mat-card-content>
<mat-card-actions *ngIf="editMode">
<button mat-button mat-raised-button
(click)="editUser(currentUser._id)">
Edit
</button>
</mat-card-actions>
</mat-card>
</div>
`,
styles: [
`
.bold {
font-weight: bold;
}
`,
],
})
export class ViewUserComponent implements OnChanges {
@Input() user: IUser
readonly currentUser$ = new BehaviorSubject(new User())
get editMode() {
return !this.user
}
constructor(private router: Router) {}
ngOnChanges(changes: SimpleChanges): void {
this.currentUser$.next(User.Build(changes.user.currentValue))
}
editUser(id: string) {
this.router.navigate(['/user/profile', id])
}
}
前面的组件使用 @Input 输入绑定从外部组件获取用户数据,符合 IUser 接口。我们实现了 ngOnChanges 事件,该事件在绑定数据更改时触发。在这个事件中,我们使用 User.Build 将存储在 user 属性中的简单 JSON 对象作为 User 类的实例进行填充。
然后,我们定义一个只读的BehaviorSubject,命名为this.currentUser$,这样我们就可以使用下一个函数异步地将其更新。这种灵活性将在我们稍后使该组件在多个上下文中可重用时派上用场。即使我们想这样做,我们也不能直接绑定到user,因为像fullName这样的计算属性只有在数据被注入到User类的实例中时才会工作。
现在,我们准备好完成多步骤表单。
查看并保存表单数据
在多步骤表单的最后一步,用户应该能够查看并保存表单数据。作为一个好的实践,成功的POST请求将返回保存的数据回浏览器。然后我们可以用从服务器返回的信息重新加载表单:
**src/app/user/profile/profile.component.ts**
...
async save(form: FormGroup) {
this.subs.add(
this.userService
.updateUser(this.currentUserId, form.value)
.subscribe(
(res: IUser) => {
**this.formGroup.patchValue(res)**
this.uiService.showToast('Updated user')
},
(err: string) => (this.userError = err)
)
)
}
...
注意,updateUser返回用户的保存值。数据库可能返回与之前不同的user版本,因此我们使用formGroup.patchValue来更新支撑表单的数据。表单会自动更新以反映任何更改。
如果在保存数据时出现错误,它们将被设置为userError以在表单上显示。在保存数据之前,我们以紧凑的格式展示数据,使用可重用的app-view-user组件,我们可以将其绑定到表单数据:
**src/app/user/profile/profile.component.html**
...
<mat-step [stepControl]="formGroup">
<form [formGroup]="formGroup" (ngSubmit)="save(formGroup)">
<ng-template matStepLabel>Review</ng-template>
<div class="stepContent">
Review and update your user profile.
**<app-view-user [user]="formGroup.getRawValue()"></app-view-user>**
</div>
<div fxLayout="row" class="margin-top">
<button mat-button matStepperPrevious>Back</button>
<div class="flex-spacer"></div>
<div *ngIf="userError" class="mat-caption error">
{{ **userError** }}
</div>
<button mat-button color="warn" (click)="**stepper.reset()**">
Reset
</button>
<button mat-raised-button matStepperNext color="primary"
type="submit" [disabled]="formGroup.invalid">
Update
</button>
</div>
</form>
</mat-step>
...
注意,我们使用formGroup.getRawValue()来提取表单数据的 JSON。看看我们是如何将userError绑定以显示错误消息的。此外,重置按钮使用stepper.reset(),它可以方便地重置所有用户输入。
最终产品应该看起来是这样的:
图 11.8:查看步骤
现在用户配置文件输入已完成,我们离最终目标——创建一个主/详细视图还有一半的路要走,在这个视图中,经理可以点击用户并查看他们的配置文件详情。我们还有更多的代码要添加,并且在过程中,我们陷入了添加大量样板代码来加载组件所需数据的模式。
接下来,让我们重构我们的表单,使其代码可重用和可扩展,即使我们的表单有数十个字段,代码仍然是可维护的,我们不会引入指数级成本增加来做出更改。
使用可重用表单部分扩展架构
如在多步骤响应式表单部分的介绍中提到的,表单是紧密耦合的怪物,可能会变得很大,使用错误的架构模式来扩展你的实现可能会在实现新功能或维护现有功能时引起重大问题。
为了展示你如何将表单拆分成多个部分,我们将重构表单,提取以下截图中的突出显示部分,即名字表单组,作为一个单独的组件。完成这一点的技术与你想要将表单的每个步骤放入单独组件时使用的技术相同:
图 11.9:用户配置文件的名字部分被突出显示
通过使名称表单组可重用,你还将了解如何将你构建到该表单组中的业务逻辑在其他表单中重用。我们将名称表单组逻辑提取到一个名为 NameInputComponent 的新组件中。在这个过程中,我们也有机会将一些可重用表单功能提取到 BaseFormComponent 作为 抽象类。
这里将会有几个组件协同工作,包括 ProfileComponent、ViewUserComponent 和 NameInputComponent。我们需要这三个组件中的所有值在用户输入时都保持最新。
ProfileComponent 将拥有主表单,我们需要在其中注册任何子表单。一旦我们这样做,你之前学到的所有表单验证技术仍然适用。
这是让你的表单能够在许多组件之间扩展并继续易于使用的关键方式,同时不会引入不必要的验证开销。因此,回顾这些对象之间的不同交互,有助于巩固你对它们异步和解耦行为性质的理解:
图 11.10:表单组件交互
在本节中,我们将汇集你在本书学习过程中学到的许多不同概念。利用前面的图来理解各种表单组件如何相互交互。
在前面的图中,粗体属性表示数据绑定。下划线函数元素表示事件注册。箭头显示了组件之间的连接点。
工作流程从 ProfileComponent 的实例化开始。组件的 OnInit 事件开始构建 formGroup 对象,同时异步加载可能需要修补到表单中的任何潜在 initialData。请参考前面的图来查看 initialData 从服务或缓存中到达的视觉表示。
NameInputComponent 在 ProfileComponent 表单中以 <app-name-input> 的形式使用。为了使 initialData 与 NameInputComponent 同步,我们使用 async 管道绑定一个 nameInitialData$ 主题,因为 initialData 是异步到达的。
NameInputComponent 实现了 OnChanges 生命周期钩子,因此每当 nameInitialData$ 更新时,其值就会被修补到 NameInputComponent 表单中。
与 ProfileComponent 类似,NameInputComponent 也实现了 OnInit 事件来构建其 formGroup 对象。由于这是一个异步事件,NameInputComponent 需要公开一个 formReady 事件,ProfileComponent 可以订阅它。一旦 formGroup 对象就绪,我们发出事件,ProfileComponent 上的 registerForm 函数被触发。registerForm 将 NameInputComponent 的 formGroup 对象作为子元素添加到父 formGroup 上。
ViewUserComponent 在 ProfileComponent 表单中用作 <app-view-user>。当父表单中的值发生变化时,我们需要 <app-view-user> 保持最新状态。我们绑定到 ViewUserComponent 上的 user 属性,该属性实现了 OnChanges 以接收更新。每次更新时,User 对象都会从 IUser 对象中恢复,以便计算字段如 fullName 可以继续工作。更新的 User 被推送到 currentUser$,该对象通过 async 绑定到模板。
我们将首先构建一个 BaseFormComponent,然后 NameInputComponent 和 ProfileComponent 将实现它。
基础表单组件作为一个抽象类
通过实现一个基抽象类,我们可以共享通用功能并标准化实现所有实现表单的组件。抽象类不能单独实例化,因为它本身没有模板,单独使用是没有意义的。
注意,BaseFormComponent 只是一个 class,而不是 Angular 组件。
BaseFormComponent 将标准化以下内容:
-
@Input initialData,并禁用为绑定目标 -
@Output formReady事件 -
formGroup,在模板的buildForm函数中使用的FormGroup以构建formGroup
在前面的假设下,基类可以提供一些通用功能:
-
patchUpdatedData,可以在不重建的情况下更新formGroup中的数据(部分或全部)。 -
registerForm和deregisterForm可以注册或注销子表单。 -
deregisterAllForms可以自动注销任何已注册的子表单。 -
hasChanged可以确定在ngOnChange事件处理器提供的SimpleChange对象的情况下,initialData是否已更改。 -
patchUpdatedDataIfChanged利用hasChanged并使用patchUpdatedData来更新数据,前提是initialData和formGroup已经初始化,并且有更新。
在 src/common 下创建一个新的类,BaseFormComponent,如下所示:
**src/app/common/base-form.class.ts**
import { EventEmitter, Input, Output, SimpleChange, SimpleChanges }
from '@angular/core'
import { AbstractControl, FormGroup } from '@angular/forms'
export abstract class BaseFormComponent<TFormData extends object> {
@Input() initialData: TFormData
@Input() disable: boolean
@Output() formReady: EventEmitter<AbstractControl>
formGroup: FormGroup
private registeredForms: string[] = []
constructor() {
this.formReady = new EventEmitter<AbstractControl>(true)
}
abstract buildForm(initialData?: TFormData): FormGroup
patchUpdatedData(data: object) {
this.formGroup.patchValue(data, { onlySelf: false })
}
patchUpdatedDataIfChanged(changes: SimpleChanges) {
if (this.formGroup && this.hasChanged(changes.initialData)) {
this.patchUpdatedData(this.initialData)
}
}
emitFormReady(control: AbstractControl | null = null) {
this.formReady.emit(control || this.formGroup)
}
registerForm(name: string, control: AbstractControl) {
this.formGroup.setControl(name, control)
this.registeredForms.push(name)
}
deregisterForm(name: string) {
if (this.formGroup.contains(name)) {
this.formGroup.removeControl(name)
}
}
protected deregisterAllForms() {
this.registeredForms.forEach(() => this.deregisterForm(name))
}
protected hasChanged(change: SimpleChange): boolean {
return change?.previousValue !== change?.currentValue
}
}
让我们使用 BaseFormComponent 实现 NameInputComponent。
实现可重用的表单部分
从 profile 组件代码和模板文件中开始识别名称表单组:
-
以下为名称表单组实现:
**src/app/user/profile/profile.component.ts** ... name: this.formBuilder.group({ first: [user?.name?.first || '', RequiredTextValidation], middle: [user?.name?.middle || '', OneCharValidation], last: [user?.name?.last || '', RequiredTextValidation], }), ...注意,当我们将这些验证规则移动到新组件时,我们仍然希望它们在确定父表单的整体验证状态时仍然有效。我们通过使用上一节中实现的
registerForm函数来实现这一点。一旦我们的新FormGroup与现有的一个注册,它们的工作方式与重构前完全相同。 -
接下来是名称表单组的模板:
**src/app/user/profile/profile.component.html** ... <div fxLayout="row" fxLayout.lt-sm="column" [formGroup]="formGroup.get('name')" fxLayoutGap="10px"> <mat-form-field appearance="outline" fxFlex="40%"> <mat-label>First Name</mat-label> <input matInput aria-label="First Name" formControlName="first" #name /> ... </div> ...你将把大部分代码移动到新组件中。
-
在
user文件夹下创建一个新的NameInputComponent。 -
从
BaseFormComponent扩展类。 -
在
constructor中注入FormBuilder:对于具有小型或有限功能组件,我更喜欢使用内联模板和样式来创建它们,这样就可以更容易地从一处更改代码。
**src/app/user/name-input/name-input.component.ts** export class NameInputComponent extends BaseFormComponent<IName> { constructor(private formBuilder: FormBuilder) { super() } buildForm(initialData?: IName): FormGroup { throw new Error("Method not implemented."); } ... }记住,基类已经实现了
formGroup、initialData、disable和formReady属性,因此您不需要重新定义它们。注意,我们被迫实现
buildForm函数,因为它被定义为抽象的。这是强制开发人员遵守标准的好方法。此外,注意任何基函数都可以通过简单地重新定义函数被实现类覆盖。您将在重构ProfileComponent时看到这一点。 -
实现函数
buildForm。 -
将
ProfileComponent中formGroup的name属性设置为null:**src/app/user/name-input/name-input.component.ts** export class NameInputComponent implements OnInit { ... buildForm(initialData?: IName): FormGroup { const name = initialData return this.formBuilder.group({ first: [name?.first : '', RequiredTextValidation], middle: [name?.middle : '', OneCharValidation], last: [name?.last : '', RequiredTextValidation], }) } -
通过将
ProfileComponent中的内容迁移过来来实现模板:**src/app/user/name-input/name-input.component.ts** template: ` <form [formGroup]="formGroup"> <div fxLayout="row" fxLayout.lt-sm="column" fxLayoutGap="10px"> ... </div> </form> `, -
实现事件处理程序
ngOnInit:**src/app/user/name-input/name-input.component.ts** ngOnInit() { this.formGroup = this.buildForm(this.initialData) if (this.disable) { this.formGroup.disable() } this.formReady.emit(this.formGroup) }在每个
BaseFormComponent的实现中,正确实现ngOnInit事件处理程序至关重要。前例是任何您可能实现的child组件的相当标准的操作。注意,
ProfileComponent中的实现将略有不同。 -
实现事件处理程序
ngOnChanges,利用基类的patchUpdatedDataIfChanged行为:**src/app/user/name-input/name-input.component.ts** ngOnChanges(changes: SimpleChanges) { this.patchUpdatedDataIfChanged(changes) }注意,在
patchUpdatedDataIfChanged函数中,将onlySelf设置为false会导致父表单也会更新。如果您想优化这种行为,您可以重写该函数。现在您已经有一个完全实现的
NameInputComponent,可以将其集成到ProfileComponent中。为了验证您未来的
ProfileComponent代码,请参考projects/ch11/src/app/user/profile/profile.component.ts和projects/ch11/src/app/user/profile/profile.component.html。在您开始使用
NameInputComponent之前,执行以下重构: -
将
ProfileComponent重构为扩展BaseFormComponent,并根据需要符合其默认值。 -
定义一个只读的
nameInitialData$属性,其类型为BehaviorSubject<IName>,并用空字符串初始化它。 -
将
ProfileComponent中的内容替换为新的<app-name-input>组件:**src/app/user/profile/profile.component.html** <mat-horizontal-stepper #stepper="matHorizontalStepper"> <mat-step [stepControl]="formGroup"> <form [formGroup]="formGroup"> <ng-template matStepLabel>Account Information</ng-template> <div class="stepContent"> **<app-name-input [initialData]="nameInitialData$ | async"** **(formReady)="registerForm('name', $event)">** </app-name-input> </div> ... </ng-template> </form> </mat-step> ... </mat-horizontal-stepper>注意,这里使用了基础表单组件函数
registerForm。 -
确保您的
ngOnInit被正确实现。注意,更新的
ProfileComponent中还有一些额外的重构,例如以下片段中看到的patchUser函数。当您更新组件时,不要错过这些更新。**src/app/user/profile/profile.component.ts** ngOnInit() { this.formGroup = this.buildForm() this.subs.sink = this.authService.currentUser$ .pipe( filter((user) => user != null), tap((user) => this.patchUser(user)) ) .subscribe() }当
initialData更新时,重要的是要使用pathUpdatedData以及nameInitialData$更新当前表单的数据。 -
确保正确实现了
ngOnDestroy:**src/app/user/profile/profile.component.ts** ngOnDestroy() { this.subs.unsubscribe() this.deregisterAllForms() }
总是要记得取消订阅,您可以使用SubSink包轻松地这样做。您还可以利用基类功能来自动注销所有子表单。
接下来,让我们了解如何对用户输入进行屏蔽以提高数据质量。
输入掩码
掩码用户输入是一种输入用户体验工具,同时也是数据质量工具。我是ngx-mask库的粉丝,它使得在 Angular 中实现输入掩码变得非常简单。我们将通过更新电话号码输入字段来演示输入掩码,以确保用户输入有效的电话号码,如下面的截图所示:
图 11.11:带有输入掩码的电话号码字段
按以下方式设置您的输入掩码:
-
使用
npm i ngx-mask通过 npm 安装库。 -
导入
forRoot模块:**src/app/app.module.ts** export const options: Partial<IConfig> | (() => Partial<IConfig>) = { showMaskTyped: true, } @NgModule({ imports: [ ... **NgxMaskModule.forRoot(options),** ] }) -
在
user功能模块中导入模块:**src/app/user/user.module.ts** @NgModule({ imports: [ ... NgxMaskModule.forChild(), ] }) -
按以下方式更新
ProfileComponent中的number字段:**src/app/user/profile/profile.component.html** <mat-form-field appearance="outline" fxFlex fxFlexOffset="10px"> <mat-label>Number</mat-label> <input matInput type="text" formControlName="number" prefix="+1" **mask="(000) 000-0000" [showMaskTyped]="true"** /> <mat-error *ngIf="this.phonesArray.controls[i].invalid"> A valid phone number is required </mat-error> </mat-form-field>
简单就是这样。您可以在 GitHub 上了解更多关于模块及其功能的信息:github.com/JsDaddy/ngx-mask。
带有ControlValueAccessor的自定义控件
到目前为止,我们已经学习了使用 Angular Material 提供的标准表单控件和输入控件来使用表单。然而,您也可以创建自定义用户控件。如果您实现了ControlValueAccessor接口,那么您的自定义控件将与表单和ControlValueAccessor接口的验证引擎很好地协同工作。
我们将创建以下截图所示的定制评分控件,并将其放置在ProfileComponent的第一步中:
图 11.12:柠檬评分器用户控件
用户控件本质上是高度可重用、紧密耦合且定制的组件,用于实现丰富的用户交互。让我们来实现一个。
实现自定义评分控件
柠檬评分器将根据用户与控件实时交互时选择的柠檬数量动态突出显示。因此,创建高质量的定制控件是一项耗时的任务。
Lemon Rater 是 Jennifer Wadella 在github.com/tehfedaykin/galaxy-rating-app找到的 Galaxy 评分应用示例的修改版本。我强烈推荐您观看 Jennifer 在 Ng-Conf 2019 上关于ControlValueAccessor的演讲,链接在进一步阅读部分。
按以下方式设置您的自定义评分控件:
-
在
user-controls文件夹下创建一个名为LemonRater的新组件。 -
在同一文件夹中创建一个
LemonRaterModule。 -
声明并导出组件。
-
在
LemonRater中实现ControlValueAccess接口:**src/app/user-controls/lemon-rater/lemon-rater.component.ts** export class LemonRaterComponent implements ControlValueAccessor { disabled = false private internalValue: number get value() { return this.internalValue } onChanged: any = () => {} onTouched: any = () => {} writeValue(obj: any): void { this.internalValue = obj } registerOnChange(fn: any): void { this.onChanged = fn } registerOnTouched(fn: any): void { this.onTouched = fn } setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled } } -
将
NG_VALUE_ACCESSOR提供者与multi属性设置为true。这将注册我们的组件到表单的更改事件,以便在用户与评分器交互时更新表单值:**src/app/user-controls/lemon-rater/lemon-rater.component.ts** @Component({ selector: 'app-lemon-rater', templateUrl: 'lemon-rater.component.html', styleUrls: ['lemon-rater.component.css'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => LemonRaterComponent), multi: true, }, ], -
实现一个自定义评分方案,该方案包含一个函数,允许根据用户输入设置所选评分:
**src/app/user-controls/lemon-rater/lemon-rater.component.ts** export class LemonRaterComponent implements ControlValueAccessor { @ViewChild('displayText', { static: false }) displayTextRef: ElementRef ratings = Object.freeze([ { value: 1, text: 'no zest', }, { value: 2, text: 'neither a lemon or a lime ', }, { value: 3, text: 'a true lemon', }, ]) setRating(lemon: any) { if (!this.disabled) { this.internalValue = lemon.value this.ratingText = lemon.text this.onChanged(lemon.value) this.onTouched() } } setDisplayText() { this.setSelectedText(this.internalValue) } private setSelectedText(value: number) { this.displayTextRef.nativeElement.textContent = this.getSelectedText(value) } private getSelectedText(value: number) { let text = '' if (value) { text = this.ratings .find((i) => i.value === value)?.text || '' } return text } }注意,通过使用
@ViewChild,我们获取了名为#displayText的 HTML 元素(在下面的模板中已突出显示)。使用setSelectText,我们替换了元素的textContent。 -
实现模板,参考
svg标签内容的示例代码:**src/app/user-controls/lemon-rater/lemon-rater.component.html** **<i #displayText></i>** <div class="lemons" [ngClass]="{'disabled': disabled}"> <ng-container *ngFor="let lemon of ratings"> <svg width="24px" height="24px" viewBox="0 0 513 513" [attr.title]="lemon.text" class="lemon rating" [ngClass]="{'selected': lemon.value <= value}" (mouseover)= "displayText.textContent = !disabled ? lemon.text : ''" (mouseout)="setDisplayText()" (click)="setRating(lemon)" > ... </svg> </ng-container> </div>模板中最重要的三个属性是
mouseover、mouseout和click。mouseover显示用户当前悬停的评分文本,mouseout将显示文本重置为所选值,click调用我们实现的setRating方法来记录用户的选择。然而,控件可以通过突出显示用户悬停在评分或选择它时柠檬的数量来提供更丰富的用户交互。我们将通过一些 CSS 魔法来实现这一点。 -
实现用户控件的
css:**src/app/user-controls/lemon-rater/lemon-rater.component.css** .lemons { cursor: pointer; } .lemons:hover .lemon #fill-area { fill: #ffe200 !important; } .lemons.disabled:hover { cursor: not-allowed; } .lemons.disabled:hover .lemon #fill-area { fill: #d8d8d8 !important; } .lemons .lemon { float: left; margin: 0px 5px; } .lemons .lemon #fill-area { fill: #d8d8d8; } .lemons .lemon:hover~.lemon #fill-area { fill: #d8d8d8 !important; } .lemons .lemon.selected #fill-area { fill: #ffe200 !important; } .lemons .dad.heart #ada { fill: #6a0dad !important; }
最有趣的部分是.lemons .lemon:hover~.lemon #fill-area。请注意,运算符~或通用兄弟组合器用于选择一系列元素,以便在用户悬停在它们上时突出显示动态数量的柠檬。
#fill-area指的是在柠檬svg内部定义的<path>,这允许动态调整柠檬的颜色。我不得不手动将此 ID 字段注入到svg文件中。
现在,让我们看看你如何在表单中使用这个新的用户控件。
在表单中使用自定义控件
我们将在profile组件中使用柠檬评分器来记录员工的 Limoncu 等级。
Limoncu,在土耳其语中意味着种植或出售柠檬的人,是 Lemon Mart 的专有员工参与度和绩效测量系统。
让我们集成柠檬评分器:
-
首先在
UserModule中导入LemonRaterModule。 -
确保在
buildForm中初始化级别表单控件:**src/app/user/profile/profile.component.ts** buildForm(initialData?: IUser): FormGroup { ... level: [user?.level || 0, Validators.required], ... } -
将柠檬评分器作为第一个
mat-step的最后一个元素插入到form元素中:**src/app/user/profile/profile.component.html** <div fxLayout="row" fxLayout.lt-sm="column" class="margin-top" fxLayoutGap="10px"> <mat-label class="mat-body-1">Select the Limoncu level: <app-lemon-rater formControlName="level"> </app-lemon-rater> </mat-label> </div>
我们只需通过实现formControlName与任何其他控件一样的方式,简单地与自定义控件集成。
恭喜!你应该有一个与你的表单集成的可工作的自定义控件。
使用网格列表布局
Angular Flex Layout 库非常适合使用 CSS Flexbox 布局内容。Angular Material 通过使用 CSS Grid 及其网格列表功能提供另一种布局内容的机制。演示此功能的一个好方法是在LoginComponent中实现一个用于伪造登录信息的帮助列表,如下所示:
图 11.13:带有网格列表的登录助手
按照以下方式实现你的列表:
-
首先定义一个
roles属性,它是一个包含所有角色的数组:**src/app/login/login.component.ts** roles = Object.keys(Role) -
将
MatExpansionModule和MatGridListModule导入到AppMaterialModule中: -
在现有的
mat-card-content下方实现一个新的mat-card-content:**src/app/login/login.component.html** <div fxLayout="row" fxLayoutAlign="center"> <mat-card fxFlex="400px"> <mat-card-header> <mat-card-title> <div class="mat-headline">Hello, Limoncu!</div> </mat-card-title> </mat-card-header> <mat-card-content> ... </mat-card-content> **<mat-card-content>** **</mat-card-content>** </mat-card> </div> -
在新的
mat-card-content内部,放入一个标签以显示认证模式:**src/app/login/login.component.html** <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px"> <span>Authentication Mode: </span><i>{{ authMode }}</i> </div> -
在标签下方实现一个展开列表:
**src/app/login/login.component.html** <mat-accordion> <mat-expansion-panel> <mat-expansion-panel-header> <mat-panel-title> Fake Login Info </mat-panel-title> </mat-expansion-panel-header> **...** </mat-expansion-panel> </mat-accordion> -
在
mat-expansion-panel-header之后,在上一个步骤中用省略号标记的区域,实现一个角色和电子邮件地址的表格,以及一些有关密码长度的提示文本,使用mat-grid-list,如下面的代码块所示:**src/app/login/login.component.html** <mat-grid-list cols="3" rowHeight="48px" role="list"> <mat-grid-tile [**colspan**]="3" role="listitem" style="background: pink"> Use any 8 character string as password </mat-grid-tile> <mat-grid-tile> <mat-grid-tile-header>Role</mat-grid-tile-header> </mat-grid-tile> <mat-grid-tile [colspan]="2"> <mat-grid-tile-header>E-mail</mat-grid-tile-header> </mat-grid-tile> <div *ngFor="let role of roles; odd as oddRow"> <mat-grid-tile role="listitem" [style.background]="oddRow ? 'lightGray': 'white'"> {{role}} </mat-grid-tile> <mat-grid-tile [**colspan**]="2" role="listitem" [style.background]="oddRow ? 'lightGray': 'white'"> <div **fxFlex fxLayoutAlign="end center"**> <div ***ngIf**="role.toLowerCase() === 'none'**; else otherRoles"** > Any @test.com email </div> <ng-template **#otherRoles**> {{role.toLowerCase()}}@test.com </ng-template> <button mat-button (click)=" this.loginForm.patchValue( { email: role.toLowerCase() + '@test.com', password: 'whatever' } )"> Fill </button> </div> </mat-grid-tile> </div> </mat-grid-list>
我们使用colspan来控制每行和每个单元格的宽度。我们利用fxLayoutAlign将电子邮件列的内容右对齐。我们使用*ngIf; else来选择性地显示内容。最后,一个填充按钮帮助我们用假登录信息填充登录表单。
在你的应用程序中,你可以使用展开面板来向用户传达密码复杂性的要求。
你可以在material.angular.io/components/expansion了解更多关于展开面板的信息,以及在material.angular.io/components/grid-list/overview了解更多关于网格列表的信息。
恢复缓存数据
在本章开头,当在UserService中实现updateUser方法时,我们缓存了user对象,以防任何可能清除用户提供的数据的错误:
**src/app/user/user/user.service.ts**
updateUser(id: string, user: IUser): Observable<IUser> {
...
**this.setItem('draft-user', user)**
...
}
考虑一个场景,当用户尝试保存数据时,他们可能暂时离线。在这种情况下,我们的updateUser函数将保存数据。
让我们看看我们如何在ProfileComponent中加载用户配置文件时恢复这些数据:
-
首先向
ProfileComponent类中添加名为loadFromCache和clearCache的函数:**src/app/user/profile.component.ts** private loadFromCache(): Observable<User | null> { let user = null try { const draftUser = localStorage.getItem('draft-user') if (draftUser != null) { user = User.Build(JSON.parse(draftUser)) } if (user) { this.uiService.showToast('Loaded data from cache') } } catch (err) { localStorage.removeItem('draft-user') } return of(user) } clearCache() { localStorage.removeItem('draft-user') }在加载数据后,我们使用
JSON.parse将数据解析为 JSON 对象,然后使用User.Build来填充User对象。 -
更新模板以调用
clearCache函数,这样当用户重置表单时,我们也会清除缓存:**src/app/user/profile.component.html** <button mat-button color="warn" (click)="stepper.reset(); **clearCache()**"> Reset </button> -
将
ngOnInit更新为有条件地从缓存加载数据或从authService的最新currentUser$:**src/app/user/profile.component.ts** ngOnInit() { this.formGroup = this.buildForm() this.subs.sink = combineLatest([ this.loadFromCache(), this.authService.currentUser$, ]) .pipe( filter( ([cachedUser, me]) => cachedUser != null || me != null ), tap( ([cachedUser, me]) => this.patchUser(cachedUser || me) ) ) .subscribe() }
我们利用combineLatest运算符将loadFromCache和currentUser$的输出合并。我们检查是否有流返回非空值。如果存在缓存的用户,它将优先于从currentUser$接收到的值。
你可以通过将浏览器的网络状态设置为离线来测试你的缓存,如下所示:
图 11.14:离线网络状态
将浏览器的网络状态设置为离线,如下所示:
-
在 Chrome DevTools 中,导航到网络选项卡。
-
在前面的截图标记为2的下拉菜单中选择离线。
-
修改你的表单,例如名称,然后点击更新。
-
你会在表单底部看到发生未知错误的错误信息。
-
在网络选项卡中,你会看到你的 PUT 请求失败了。
-
现在,刷新你的浏览器窗口,观察你输入的新名称仍然存在。
参考以下截图,它显示了从缓存加载数据后你收到的吐司通知:
图 11.15:从缓存加载数据
在缓存周围实现一个优秀的用户体验非常具有挑战性。我提供了一个基本的方法来展示什么是可能的。然而,有许多边缘情况可能会影响你的应用程序中缓存的工作方式。
在我的情况下,缓存固执地存在,直到我们成功将数据保存到服务器。这可能会让一些用户感到沮丧。
恭喜!您已成功实现了一个复杂的表单来捕获用户数据!
练习
进一步增强login组件,以添加AuthMode.CustomServer的登录助手。
摘要
在本章中,我们涵盖了 LemonMart 的表单、指令和用户控制相关功能。我们创建了可重用的组件,可以使用数据绑定嵌入到另一个组件中。我们展示了您可以使用 PUT 向服务器发送数据并缓存用户输入的数据。我们还创建了一个响应屏幕尺寸变化的分步输入表单。通过利用可重用表单部分、基类表单以容纳常用功能以及属性指令来封装字段级错误行为和消息,我们消除了组件中的样板代码。
我们使用日期选择器、自动完成支持和表单数组创建了动态表单元素。我们实现了具有输入掩码和柠檬评分器的交互式控件。通过使用ControlValueAccessor接口,我们将柠檬评分器无缝集成到我们的表单中。我们展示了我们可以通过提取名称作为其自己的表单部分来线性扩展表单的大小和复杂性。此外,我们还介绍了使用网格列表构建布局。
在下一章中,我们将进一步增强我们的组件,以便我们可以使用路由器来编排它们。我们还将实现主/详细视图和数据表,并探索 NgRx 作为使用 RxJS/BehaviorSubject 的替代方案。
进一步阅读
-
响应式表单,2020 年,可在
angular.io/guide/reactive-forms找到 -
属性指令,2020 年,可在
angular.io/guide/attribute-directives找到 -
rxweb: 在 Angular Reactive Forms 中显示错误消息的好方法,Ajay Ojha,2019 年,可在
medium.com/@oojhaajay/rxweb-good-way-to-show-the-error-messages-in-angular-reactive-forms-c27429f51278找到 -
控制值访问器,Jennifer Wadella,2019 年,可在
www.youtube.com/watch?v=kVbLSN0AW-Y找到 -
CSS 组合器,2020 年,可在
developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors#Combinators找到
问题
尽可能地回答以下问题,以确保你在不使用谷歌搜索的情况下理解了本章的关键概念。你需要帮助回答这些问题吗?请参阅附录 D,自我评估答案,在线访问static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf或访问expertlysimple.io/angular-self-assessment。
-
组件和用户控件之间的区别是什么?
-
属性指令是什么?
-
ControlValueAccessor接口的目的是什么? -
序列化、反序列化和活化是什么?
-
在表单上修补值意味着什么?
-
你如何将两个独立的
FormGroup对象相互关联?
第十二章:食谱 – 主/详细,数据表和 NgRx
本章,我们在 LemonMart 上通过实现商业应用中最常用的两个功能:主/详细视图和数据表,完成了路由优先架构的实现。我通过 LemonMart 和 LemonMart Server 的服务器端分页演示了数据表,突出了前端和后端的集成。
确保在实现本章概述的食谱时,你的lemon-mart-server正在运行。有关更多信息,请参阅第十章,RESTful API 和全栈实现。
我们利用路由编排的概念来编排组件如何加载数据或渲染。我们使用解析守卫在导航到组件之前加载数据时减少样板代码。我们使用辅助路由通过路由配置来布局组件。我们在多个上下文中复用相同的组件。
然后,我们使用 LocalCast 天气应用程序深入探讨 NgRx,并使用 LemonMart 探索 NgRx 数据,这样你就可以熟悉 Angular 中更高级的应用程序架构概念。到本章结束时,我们将触及 Angular 和 Angular Material 提供的大多数主要功能。
本章涵盖了大量的内容。它以食谱格式组织,因此当你正在处理项目时,可以快速参考特定的实现。我涵盖了实现架构、设计和主要组件。我突出显示重要的代码片段来解释解决方案是如何组合在一起的。利用你迄今为止所学的内容,我期望读者能够填写常规实现和配置细节。然而,如果你遇到困难,你始终可以参考 GitHub 仓库。
本章,你将学习以下主题:
-
使用解析守卫加载数据
-
带有路由数据的可复用组件
-
使用辅助路由的主/详细视图
-
带有分页的数据表
-
NgRx Store 和 Effects
-
NgRx 数据库
书籍样本代码的最新版本可在以下列表中链接的 GitHub 仓库找到。该仓库包含代码的最终和完成状态。你可以在本章末尾通过查找projects文件夹下的代码快照来验证你的进度。
为了为本章的基于lemon-mart的示例做好准备,请执行以下操作:
-
在根文件夹中执行
npm install以安装依赖项 -
本章的代码示例位于以下子文件夹中:
projects/ch12 -
要运行本章的 Angular 应用程序,请执行以下命令:
npx ng serve ch12 -
要运行本章的 Angular 单元测试,请执行以下命令:
npx ng test ch12 --watch=false -
要运行本章的 Angular e2e 测试,请执行以下命令:
npx ng e2e ch12 -
要构建本章的生产就绪 Angular 应用程序,请执行以下命令:
npx ng build ch12 --prod
注意,存储库根目录下的 dist/ch12 文件夹将包含编译结果。
为了准备本章基于 local-weather-app 的示例,请执行以下步骤:
-
克隆
github.com/duluca/local-weather-app上的 repo -
在根目录下执行
npm install以安装依赖项 -
本章的代码示例位于以下子文件夹中:
projects/ch12 -
要运行本章的 Angular 应用,请执行以下命令:
npx ng serve ch12 -
要运行本章的 Angular 单元测试,请执行以下命令:
npx ng test ch12 --watch=false -
要运行本章的 Angular 端到端测试,请执行以下命令:
npx ng e2e ch12 -
要构建本章的生产就绪 Angular 应用,请执行以下命令:
npx ng build ch12 --prod
记住,存储库根目录下的 dist/ch12 文件夹将包含编译结果。
请注意,书中或 GitHub 上的源代码可能并不总是与 Angular CLI 生成的代码匹配。由于生态系统不断演变,书中代码与 GitHub 上代码之间的实现可能也存在细微差异。随着时间的推移,示例代码发生变化是自然的。在 GitHub 上,您可能会找到更正、修复以支持库的新版本或为读者观察而并排实现多种技术的示例。读者只需实现书中推荐的理想解决方案即可。如果您发现错误或有疑问,请创建一个 GitHub 问题或提交一个拉取请求,以惠及所有读者。
您可以在 附录 C 中了解更多关于更新 Angular 的信息,即 保持 Angular 和工具常青。您可以从 static.packt-cdn.com/downloads/9781838648800_Appendix_C_Keeping_Angular_and_Tools_Evergreen.pdf 或 expertlysimple.io/stay-evergreen 在线找到此附录。
在下一节中,我们将学习解析守卫,以便我们可以简化代码并减少样板代码的数量。
编辑现有用户
在 第十一章 中,我们在 食谱 – 可重用性、路由和缓存 中创建了一个具有 editUser 函数的 ViewUserComponent。在章节后面实现系统中的主/详细视图时,我们需要这个功能,其中经理可以看到系统中的所有用户并具有编辑他们的能力。在我们能够启用 editUser 功能之前,我们需要确保 ViewUserComponent 组件和 ProfileComponent 组件可以加载任何给定其 ID 的用户。
让我们从实现一个可以用于两个组件的解析守卫开始。
使用解析守卫加载数据
如同在第八章中提到的,resolve guard 是一种路由守卫。resolve guard 可以通过读取route参数中的记录 ID 来为组件加载数据,异步加载数据,并在组件激活和初始化时准备好数据。
Resolve guard 的主要优势包括加载逻辑的可重用性、减少了样板代码,以及减少了依赖性,因为组件可以在不导入任何服务的情况下接收所需的数据:
-
在
user/user下创建一个新的user.resolve.ts类:**src/app/user/user/user.resolve.ts** import { Injectable } from '@angular/core' import { ActivatedRouteSnapshot, Resolve } from '@angular/router' import { catchError, map } from 'rxjs/operators' import { transformError } from '../../common/common' import { IUser, User } from './user' import { UserService } from './user.service' @Injectable() export class UserResolve implements Resolve<IUser> { constructor(private userService: UserService) {} resolve(route: ActivatedRouteSnapshot) { return this.userService .getUser(route.paramMap.get('userId')) .pipe(map(User.Build), catchError(transformError)) } }注意,与
UserService中的updateUser方法类似,我们使用map(User.Build)来填充user对象,以便在组件从route快照加载数据时可以使用,正如我们接下来将要看到的。 -
在
user.module.ts中提供解析器。接下来,让我们配置
router和ProfileComponent,以便能够加载现有用户。 -
修改
user-routing.module.ts以添加一个新的路径,profile/:userId,带有路由解析器和canActivate AuthGuard:**src/app/user/user-routing.module.ts** ... { path: 'profile/:userId', component: ProfileComponent, resolve: { user: UserResolve, }, canActivate: [AuthGuard], }, ...记得在
user.module.ts中提供UserResolve和AuthGuard。 -
将
profile组件更新为从route加载数据(如果存在):**src/app/user/profile/profile.component.ts** ... constructor( ... **private route: ActivatedRoute** ) { super() } ngOnInit() { this.formGroup = this.buildForm() if (**this.route.snapshot.data.user**) { **this.patchUser(this.route.snapshot.data.user)** } else { this.subs.sink = combineLatest( [this.loadFromCache(), this.authService.currentUser$] ) .pipe( filter( ([cachedUser, me]) => cachedUser != null || me != null ), tap( ([cachedUser, me]) => this.patchUser(cachedUser || me) ) ) .subscribe() } }
我们首先检查route快照中是否存在用户。如果存在,我们调用patchUser来加载此用户。否则,我们回退到我们的条件缓存加载逻辑。
注意,patchUser方法还设置了currentUserId和nameInitialDate$可观察对象,并调用patchUpdateData基类来更新表单数据。
您可以通过导航到具有您用户 ID 的配置文件来验证解析器是否工作。使用默认设置,此 URL 将类似于http://localhost:5000/user/profile/5da01751da27cc462d265913。
重新使用具有绑定和路由数据的组件
现在,让我们重构viewUser组件,以便我们可以在多个上下文中重用它。根据创建的 mock-ups,用户信息在应用程序中的两个地方显示。
第一个地方是我们之前章节中实现的用户配置文件的Review步骤。第二个地方是在/manager/users路由上的用户管理屏幕,如下所示:
图 12.1:经理用户管理 mock-up
为了最大化代码重用,我们需要确保我们的共享ViewUser组件可以在两种上下文中使用。
对于多步输入表单的Review步骤,我们只需将当前用户绑定到它。在第二个用例中,组件需要使用 resolve guard 来加载自己的数据,因此我们不需要实现额外的逻辑来实现我们的目标:
-
更新
viewUser组件以注入ActivatedRoute对象,并在ngOnInit()中将currentUser$从路由设置:**src/app/user/view-user/view-user.component.ts** ... import { ActivatedRoute } from '@angular/router' export class ViewUserComponent implements OnChanges, OnInit { ... constructor( private route: ActivatedRoute, private router: Router ) {} ngOnInit() { if (this.route.snapshot.data.user) { this.currentUser$.next(this.route.snapshot.data.user) } } ... }ngOnInit仅在组件首次初始化或被路由到时触发一次。在这种情况下,如果已解析路由的任何数据,则它将通过next()函数推送到this.currentUser$。我们现在有两个独立的事件来更新数据;一个用于
ngOnChanges,它处理对@Input值的更新,并将其推送到BehaviorSubject currentUser$,如果this.user已被绑定。为了能够在多个懒加载的模块中使用此组件,我们必须将其包裹在其自己的模块中:
-
在
src/app下创建一个新的shared-components.module.ts:**src/app/shared-components.module.ts** import { CommonModule } from '@angular/common' import { NgModule } from '@angular/core' import { FlexLayoutModule } from '@angular/flex-layout' import { ReactiveFormsModule } from '@angular/forms' import { AppMaterialModule } from './app-material.module' import { ViewUserComponent } from './user/view-user/view-user.component' @NgModule({ imports: [ CommonModule, ReactiveFormsModule, FlexLayoutModule, AppMaterialModule, ], declarations: [ViewUserComponent], exports: [ViewUserComponent], }) export class SharedComponentsModule {}确保将
SharedComponentsModule模块导入到每个你打算使用ViewUserComponent的功能模块中。在我们的例子中,这些将是UserModule和ManagerModule。 -
从
User模块声明中移除ViewUserComponent -
类似地,在
SharedComponentsModule中声明并导出NameInputComponent,然后清理其其他声明 -
在
SharedComponentsModule中导入必要的模块以支持ViewUserComponent和NameInputComponent,例如FieldErrorModule
我们现在已经拥有了开始实现主/详细视图的关键部分。让我们继续下一步。
主/详细视图辅助路由
路由器首先架构的真正力量在于使用辅助路由的实现,我们可以通过路由配置单独影响组件的布局,允许我们进行丰富的场景,其中我们可以将现有组件重新组合到不同的布局中。辅助路由是彼此独立的路由,它们可以在标记中定义的命名出口中渲染内容,例如<router-outlet name="master">或<router-outlet name="detail">。此外,辅助路由可以有自己的参数、浏览器历史记录、子路由和嵌套辅助路由。
在以下示例中,我们将使用辅助路由实现基本的主/详细视图:
-
实现一个具有两个命名出口的简单组件:
**src/app/manager/user-management/user-management.component.ts** template: ` <div class="horizontal-padding"> <router-outlet name="master"></router-outlet> <div style="min-height: 10px"></div> <router-outlet name="detail"></router-outlet> </div> ` -
在 manager 下添加一个新的
userTable组件 -
更新
manager-routing.module.ts以定义辅助路由:**src/app/manager/manager-routing.module.ts** ... { path: 'users', component: UserManagementComponent, children: [ { path: '', component: UserTableComponent, outlet: 'master' }, { path: 'user', component: ViewUserComponent, outlet: 'detail', resolve: { user: UserResolve, }, }, ], canActivate: [AuthGuard], canActivateChild: [AuthGuard], data: { expectedRole: Role.Manager, }, }, ...这意味着当用户导航到
/manager/users时,他们将看到UserTableComponent,因为它使用的是默认路径。 -
在
manager.module.ts中提供UserResolve,因为viewUser依赖于它 -
在
userTable中实现一个临时按钮:**src/app/manager/user-table/user-table.component.html** <a mat-button mat-icon-button [routerLink]="['/manager/users', { outlets: { detail: ['user', { userId: row._id}] } }]" skipLocationChange> <mat-icon>visibility</mat-icon> </a>skipLocationChange指令在导航时不会将新记录推入历史记录。因此,如果用户查看多个记录并点击后退按钮,他们将被带回到上一个屏幕,而不是必须先滚动查看他们查看的记录。想象一下,如果用户点击一个类似于之前定义的查看详情按钮,那么
ViewUserComponent将为具有给定userId的用户渲染。在下一张截图中,你可以看到在下一节实现数据表后,查看详情按钮将看起来是什么样子:图 12.2:查看详情按钮
您可以为主视图和详情视图定义任意多的组合和替代组件,从而实现动态布局的无限可能性。然而,设置
routerLink可能会让人感到沮丧。根据具体条件,您可能需要提供或不需要提供链接中的所有或部分出口。例如,对于前面的场景,如果链接是['/manager/users', { outlets: { master: [''], detail: ['user', {userId: row.id}] } }],则路由将静默失败加载。预计这些怪癖将在未来的 Angular 版本中得到解决。现在我们已经完成了
ViewUserComponent的解析保护器的实现,您可以使用 Chrome DevTools 查看正确加载的数据。在调试之前,请确保运行我们在第十章“RESTful APIs 和全栈实现”中创建的lemon-mart-server。
-
在 Chrome DevTools 中,在
this.currentUser被分配后立即设置一个断点,如图所示:图 12.3:Dev Tools 调试 ViewUserComponent
您将观察到this.currentUser被正确设置,而无需在ngOnInit函数内部加载数据的任何样板代码,这显示了解析保护器的真正好处。"ViewUserComponent"是详情视图;现在让我们实现主视图,作为一个具有分页的数据表。
带分页的数据表
我们已经创建了主/详情视图的框架。在主出口中,我们将有一个用户的分页数据表,因此让我们实现UserTableComponent,它将包含一个名为dataSource的MatTableDataSource属性。我们需要能够使用标准的分页控件,如pageSize和pagesToSkip来批量获取用户数据,并且能够通过用户提供的searchText进一步缩小选择范围。
让我们从向UserService添加必要的功能开始:
-
实现一个新的
IUsers接口来描述分页数据的结构:**src/app/user/user/user.service.ts** ... export interface IUsers { data: IUser[] total: number } -
使用
getUsers函数更新UserService的接口:**src/app/user/user/user.service.ts** ... export interface IUserService { getUser(id: string): Observable<IUser> updateUser(id: string, user: IUser): Observable<IUser> **getUsers(pageSize: number, searchText: string,** **pagesToSkip: number): Observable<IUsers>** } export class UserService extends CacheService implements IUserService { ... -
将
getUsers添加到UserService:**src/app/user/user/user.service.ts** ... getUsers( pageSize: number, searchText = '', pagesToSkip = 0, sortColumn = '', sortDirection: '' | 'asc' | 'desc' = 'asc' ): Observable<IUsers> { const recordsToSkip = pageSize * pagesToSkip if (sortColumn) { sortColumn = sortDirection === 'desc' ? `-${sortColumn}` : sortColumn } return this.httpClient.get<IUsers>( `${environment.baseUrl}/v2/users`, { params: { filter: searchText, skip: recordsToSkip.toString(), limit: pageSize.toString(), sortKey: sortColumn, }, }) } ...注意,排序方向由关键字
asc(升序)和desc(降序)表示。当我们想按升序排序一列时,我们将列名作为参数传递给服务器。要按降序排序一列,我们在列名前加上一个减号。 -
设置
UserTable以支持分页、排序和过滤:**src/app/manager/user-table/user-table.component.ts** ... @Component({ selector: 'app-user-table', templateUrl: './user-table.component.html', styleUrls: ['./user-table.component.css'], }) export class UserTableComponent implements OnDestroy, AfterViewInit { displayedColumns = ['name', 'email', 'role', '_id'] items$: Observable<IUser[]> resultsLength = 0 hasError = false errorText = '' private skipLoading = false private subs = new SubSink() readonly isLoadingResults$ = new BehaviorSubject(true) loading$: Observable<boolean> refresh$ = new Subject() search = new FormControl('', OptionalTextValidation) @ViewChild(MatPaginator, { static: false }) paginator: MatPaginator @ViewChild(MatSort, { static: false }) sort: MatSort constructor( private userService: UserService ) { this.loading$ = this.isLoadingResults$ } getUsers( pageSize: number, searchText: string, pagesToSkip: number, sortColumn: string, sortDirection: SortDirection ): Observable<IUsers> { return this.userService.getUsers( pageSize, searchText, pagesToSkip, sortColumn, sortDirection ) } ngOnDestroy(): void { this.subs.unsubscribe() } ngAfterViewInit() { this.subs.sink = this.sort.sortChange .subscribe(() => this.paginator.firstPage()) if (this.skipLoading) { return } **this.items$ = merge(** **this.refresh$,** **this.sort.sortChange,** **this.paginator.page,** **this.search.valueChanges.pipe(debounceTime(1000))** **).pipe(** **startWith({}),** **switchMap(() => {** **this.isLoadingResults$.next(true)** **return this.getUsers(** **this.paginator.pageSize,** **this.search.value,** **this.paginator.pageIndex,** **this.sort.active,** **this.sort.direction** **)** **}),** **map((results: { total: number; data: IUser[] }) => {** **this.isLoadingResults$.next(false)** **this.hasError = false** **this.resultsLength = results.total** **return results.data** **}),** **catchError((err) => {** **this.isLoadingResults$.next(false)** **this.hasError = true** **this.errorText = err** **return of([])** **})** **)** **this.items$.subscribe()** } }我们定义并初始化各种属性以支持加载分页数据。"items$"存储用户记录,"displayedColumns"定义了我们打算显示的数据列,"paginator"和"sort"提供分页和排序偏好,而"search"提供了我们用于过滤结果的文本。
in pagination, sorting, and filter properties. If one property changes, the whole pipeline is triggered. This is similar to how we implemented the login routine in AuthService. The pipeline contains a call to this.userService.getUsers, which will retrieve users based on the pagination, sorting, and filter preferences passed in. Results are then piped into the this.items$ observable, which the data table subscribes to with an async pipe, so it can display the data. -
创建包含以下 Material 模块的
ManagerMaterialModule:**src/app/manager/manager-material.module.ts** MatTableModule, MatSortModule, MatPaginatorModule, MatProgressSpinnerModule, MatSlideToggleModule, -
确保正确导入
manager.module.ts中的以下内容:-
新的
ManageMaterialModule -
基线
AppMaterialModule -
以下必需模块:
FormsModule、ReactiveFormsModule和FlexLayoutModule
-
-
实现对
userTable的 CSS:**src/app/manager/user-table/user-table.component.css** .loading-shade { position: absolute; top: 0; left: 0; bottom: 56px; right: 0; background: rgba(0, 0, 0, 0.15); z-index: 1; display: flex; align-items: center; justify-content: center; } .filter-row { min-height: 64px; padding: 8px 24px 0; } .full-width { width: 100%; } .mat-paginator { background: transparent; } -
最后,实现
userTable模板:**src/app/manager/user-table/user-table.component.html** <div class="filter-row"> <form style="margin-bottom: 32px"> <div fxLayout="row"> <mat-form-field class="full-width"> <mat-icon matPrefix>search</mat-icon> <input matInput placeholder="Search" aria-label="Search" [formControl]="search" /> <mat-hint>Search by e-mail or name</mat-hint> <mat-error *ngIf="search.invalid"> Type more than one character to search </mat-error> </mat-form-field> </div> </form> </div> <div class="mat-elevation-z8"> <div class="loading-shade" *ngIf="loading$ | async as loading"> <mat-spinner *ngIf="loading"></mat-spinner> <div class="error" *ngIf="hasError"> {{ errorText }} </div> </div> <table mat-table class="full-width" [dataSource]="items$ | async" matSort matSortActive="name" matSortDirection="asc" matSortDisableClear> <ng-container matColumnDef="name"> <th mat-header-cell *matHeaderCellDef mat-sort-header> Name </th> <td mat-cell *matCellDef="let row"> {{ row.fullName }} </td> </ng-container> <ng-container matColumnDef="email"> <th mat-header-cell *matHeaderCellDef mat-sort-header> E-mail </th> <td mat-cell *matCellDef="let row"> {{ row.email }} </td> </ng-container> <ng-container matColumnDef="role"> <th mat-header-cell *matHeaderCellDef mat-sort-header> Role </th> <td mat-cell *matCellDef="let row"> {{ row.role }} </td> </ng-container> <ng-container matColumnDef="_id"> <th mat-header-cell *matHeaderCellDef>View Details </th> <td mat-cell *matCellDef="let row" style="margin-right: 8px"> <a mat-button mat-icon-button [routerLink]="[ '/manager/users', { outlets: { detail: ['user', { userId: row._id }] } } ]" skipLocationChange> <mat-icon>visibility</mat-icon> </a> </td> </ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns"> </tr> </table> <mat-toolbar> <mat-toolbar-row> <button mat-icon-button (click)="refresh$.next()"> <mat-icon title="Refresh">refresh</mat-icon> </button> <span class="flex-spacer"></span> <mat-paginator [pageSizeOptions]="[5, 10, 25, 100]" [length]="resultsLength"> </mat-paginator> </mat-toolbar-row> </mat-toolbar> </div>仅使用主视图,表格如下截图所示(确保你已经更新到 Angular 的最新版本!):
图 12.4:用户表
如果你点击查看图标,
ViewUserComponent将在详情出口中渲染,如下所示:图 12.5:主/详情视图
在上一章中,我们实现了编辑按钮,将
userId传递给UserProfile,以便可以编辑和更新数据。 -
点击编辑按钮,将被带到
ProfileComponent,编辑用户记录,并验证你是否可以更新其他用户的记录 -
确认你可以在数据表中查看更新的用户记录
这个带有分页的数据表演示完成了本书中 LemonMart 的主要功能。现在,在我们继续之前,让我们确保所有测试都通过。
更新单元测试
让我们回顾一下ProfileComponent和UserTableComponent的一些单元测试,看看我们如何利用不同的技术来测试组件:
-
观察单元测试文件
ProfileComponent,并识别使用authServiceMock对象为组件提供初始数据:**src/app/user/profile/profile.component.spec.ts** describe('ProfileComponent', () => { let component: ProfileComponent let fixture: ComponentFixture<ProfileComponent> let authServiceMock: jasmine.SpyObj<AuthService> beforeEach(async(() => { const authServiceSpy = autoSpyObj( AuthService, ['currentUser$', 'authStatus$'], ObservablePropertyStrategy.BehaviorSubject ) TestBed.configureTestingModule({ providers: commonTestingProviders.concat({ provide: AuthService, useValue: authServiceSpy, }), imports: commonTestingModules.concat([ UserMaterialModule, FieldErrorModule, LemonRaterModule, ]), declarations: [ProfileComponent, NameInputComponent, ViewUserComponent], }).compileComponents() authServiceMock = injectSpy(AuthService) fixture = TestBed.createComponent(ProfileComponent) component = fixture.debugElement.componentInstance })) it('should create', () => { authServiceMock.currentUser$.next(new User()) authServiceMock.authStatus$.next(defaultAuthStatus) fixture.detectChanges() expect(component).toBeTruthy() }) })注意,我并不是使用
angular-unit-test-helper中的createComponentMock函数来导入NameInputComponent或ViewUserComponent,而是导入它们的实际实现。这是因为createComponentMock还不够复杂,无法处理将数据绑定到子组件。在进一步阅读部分,我包括了一篇由 Aiko Klostermann 撰写的博客文章,该文章涵盖了使用@Input()属性测试 Angular 组件。 -
打开
UserTableComponent的规范文件:在修复其提供者和导入之后,你会注意到
UserTableComponent抛出了ExpressionChangedAfterItHasBeenCheckedError错误。这是因为组件初始化逻辑需要dataSource被定义。如果未定义,则无法创建组件。然而,我们可以在第二个beforeEach方法中轻松修改组件属性,该方法在TestBed将真实、模拟或伪造的依赖项注入到组件类之后执行。以下片段中突出显示的更改用于测试数据设置:**src/app/manager/user-table/user-table.component.spec.ts** ... beforeEach(() => { fixture = TestBed.createComponent(UserTableComponent) component = fixture.componentInstance **component.items$ = of([new User()])** **Object.assign(component, { skipLoading: true })** fixture.detectChanges() }) ...到现在为止,你可能已经注意到,仅仅通过更新一些我们的核心配置文件,例如
commonTestingProviders和commonTestingModules,一些测试通过了,其余的测试可以通过应用我们在整本书中使用的各种模式来解决。例如,user-management.component.spec.ts使用了我们创建的通用测试模块和提供者:**src/app/manager/user-management/user-management.component.spec.ts** providers: commonTestingProviders, imports: commonTestingModules.concat([ManagerMaterialModule]),当你在模拟提供者时,请记住正在测试的模块、组件、服务或类,并注意只模拟依赖项。
ViewUserComponent是一个特殊情况,我们无法使用我们常见的测试模块和提供者,否则我们最终会创建一个循环依赖。在这种情况下,手动指定需要导入的模块。 -
修复单元测试配置,以确保所有测试都通过且不生成警告。
完成实现的重任后,我们现在可以探索替代架构、工具和库,以更好地理解为各种需求构建 Angular 应用程序的最佳方式。接下来,让我们探索 NgRx。
NgRx Store 和 Effects
如 第一章 中所述,Angular 及其概念简介,NgRx 库基于 RxJS 将响应式状态管理引入 Angular。使用 NgRx 进行状态管理允许开发者编写原子性、自包含和可组合的代码片段,创建动作、reducer 和选择器。这种响应式编程允许在状态变化中隔离副作用。本质上,NgRx 是 RxJS 之上的一个抽象层,以适应 Flux 模式。
NgRx 有四个主要元素:
-
Store:状态信息持久化的中心位置。您在 store 中实现一个 reducer 以存储状态转换,并实现一个选择器以从 store 中读取数据。这些都是原子性和可组合的代码片段。
一个视图(或用户界面)通过使用选择器显示 store 中的数据。
-
动作:在整个应用程序中发生的独特事件。
动作从视图触发,目的是将它们分发给 store。
-
分发器:这是一种将动作发送到 store 的方法。
Store 上的 reducer 监听已分发的动作。
-
效果:这是动作和分发器的组合。效果通常用于不是从视图触发的动作。
让我们重新审视以下 Flux 模式图,现在它突出显示了一个效果:
图 12.6:Flux 模式图
让我们通过一个具体的例子来演示 NgRx 的工作原理。为了使其简单,我们将利用 LocalCast 天气应用。
为 LocalCast 天气实现 NgRx
我们将在 LocalCast 天气应用中实现 NgRx 以执行搜索功能。考虑以下架构图:
图 12.7:LocalCast 天气架构
为了实现我们的实现,我们将同时使用 NgRx store 和 effects 库。NgRx store 动作在图中以浅灰色显示,包括 WeatherLoaded reducer 和应用状态。在顶部,动作被表示为一系列各种数据对象,这些对象要么分发动作,要么对已分发的动作进行操作,使我们能够实现 Flux 模式。NgRx effects 库通过在其自己的模型中隔离副作用,而不在 store 中散布临时数据,从而扩展了 Flux 模式。
以深灰色表示的效果工作流程从步骤 1开始:
-
CitySearchComponent分发search动作 -
search动作出现在@ngrx/action可观察流(或数据流)中 -
CurrentWeatherEffects对search动作进行操作以执行搜索 -
WeatherService执行搜索以从OpenWeather API检索当前天气信息
以浅灰色表示的存储动作从步骤 A开始:
-
CurrentWeatherEffects分发weatherLoaded动作 -
weatherLoaded动作出现在数据流中 -
weatherLoaded减法器对weatherLoaded动作进行操作 -
weatherLoaded减法器将天气信息转换为要存储的新状态 -
新状态是持久化的
search状态,是appStore状态的一部分
注意,存在一个父级 appStore 状态,其中包含一个子 search 状态。我故意保留这种设置来演示当您向存储中添加不同类型的数据元素时,父级状态是如何扩展的。
最后,一个视图从存储中读取,从步骤 a开始:
-
CurrentWeather组件使用async管道订阅selectCurrentWeather选择器 -
selectCurrentWeather选择器监听appStore状态中store.search.current属性的变化 -
appStore状态检索持久化的数据
使用 NgRx,当用户搜索城市时,检索、持久化和在 CurrentWeatherComponent 上显示该信息的动作会通过单个可组合和不可变元素自动发生。
比较 BehaviorSubject 和 NgRx
我们将同时实现 NgRx 和 BehaviorSubjects,这样您可以看到同一功能的实现差异。为此,我们需要一个滑动切换来在两种策略之间切换:
本节使用 local-weather-app 仓库。您可以在 projects/ch12 文件夹下找到本章的代码示例。
-
从在
CitySearchComponent上实现<mat-slide-toggle>元素开始,如下面的截图所示:图 12.8:LocalCast Weather 滑动切换
确保该字段由组件上的
useNgRx属性支持。 -
将
doSearch方法重构为提取名为behaviorSubjectBasedSearch的BehaviorSubject代码作为其自己的函数 -
创建一个名为
ngRxBasedSearch的函数原型:**src/app/city-search/city-search.component.ts** doSearch(searchValue: string) { const userInput = searchValue.split(',').map((s) => s.trim()) const searchText = userInput[0] const country = userInput.length > 1 ? userInput[1] : undefined **if (this.useNgRx) {** **this.ngRxBasedSearch(searchText, country)** **} else {** **this.behaviorSubjectBasedSearch(searchText, country)** **}** }
我们将从您刚刚创建的 ngRxBasedSearch 函数中分发一个动作。
设置 NgRx
您可以使用以下命令添加 NgRx Store 包:
$ npx ng add @ngrx/store
这将创建一个包含 index.ts 文件的 reducers 文件夹。现在添加 NgRx effects 包:
$ npx ng add @ngrx/effects --minimal
我们在这里使用 --minimal 选项来避免创建不必要的样板代码。
接下来,安装 NgRx 模式库,这样您就可以利用生成器为您创建样板代码:
$ npm i -D @ngrx/schematics
由于 NgRx 高度解耦的特性,实现 NgRx 可能会令人困惑,这需要对其内部工作原理有一定的了解。
在projects/ch12下的示例项目配置了@ngrx/store-devtools进行调试。
如果你希望在运行时进行调试或仪表化,并能够console.log NgRx 动作,请参阅附录 A,调试 Angular。
定义 NgRx 动作
在我们可以实现效果或还原器之前,我们首先需要定义我们的应用程序将要能够执行的动作。对于 LocalCast Weather,有两种类型的动作:
-
search:获取正在搜索的城市或邮编的当前天气 -
weatherLoaded:表示已获取新的当前天气信息
通过运行以下命令创建一个名为search的动作:
$ npx ng generate @ngrx/schematics:action search --group --creators
按提示选择默认选项。
--group选项将动作分组在名为action的文件夹下。--creators选项使用创建函数来实现动作和还原器,这是一种更熟悉且直接的方法来实现这些组件。
现在,让我们使用createAction函数实现两个动作,提供一个名称和预期的输入参数列表:
**src/app/action/search.actions.ts**
import { createAction, props, union } from '@ngrx/store'
import { ICurrentWeather } from '../interfaces'
export const SearchActions = {
search: createAction(
'[Search] Search',
props<{ searchText: string; country?: string }>()
),
weatherLoaded: createAction(
'[Search] CurrentWeather loaded',
props<{ current: ICurrentWeather }>()
),
}
const all = union(SearchActions)
export type SearchActions = typeof all
搜索操作名为'[Search] Search',输入参数包括searchText和一个可选的country参数。weatherLoaded操作遵循类似的模式。在文件末尾,我们创建了一个动作的联合类型,这样我们就可以将它们分组在同一个父类型下,以便在应用程序的其余部分使用。
注意,动作名称前缀为[Search]。这是一个帮助开发者在调试期间视觉上分组相关动作的约定。
现在我们已经定义了动作,我们可以实现效果来处理搜索动作并分发一个weatherLoaded动作。
实现 NgRx 效果
如前所述,效果允许我们更改存储的状态,而无需存储导致更改的事件数据。例如,我们希望我们的状态只包含天气数据,而不是搜索文本本身。效果允许我们一步完成这项工作,而不是强迫我们使用中间存储来存储searchText,以及一个更为复杂的链式事件来将其转换为天气数据。
否则,我们不得不在中间实现一个还原器,首先将此值存储在存储中,然后稍后从服务中检索它并分发一个weatherLoaded动作。效果将使数据检索变得简单。
现在我们将CurrentWeatherEffects添加到我们的应用程序中:
$ npx ng generate @ngrx/schematics:effect currentWeather --module=app.module.ts --root --group --creators
按提示选择默认选项。
你将在effects文件夹下有一个新的current-weather.effects.ts文件。
再次强调,--group用于将效果分组在同名文件夹下。--root在app.module.ts中注册效果,我们使用带有--creators选项的创建函数。
在CurrentWeatherEffects文件中,首先实现一个私有的doSearch方法:
**src/app/effects/current-weather.effects.ts**
private doSearch(action: { searchText: string; country?: string }) {
return this.weatherService.getCurrentWeather(
action.searchText,
action.country
).pipe(
map((weather) =>
SearchActions.weatherLoaded({ current: weather })
),
catchError(() => EMPTY)
)
}
注意,我们选择忽略由EMPTY函数抛出的错误。你可以使用类似于为 LemonMart 实现的UiService将这些错误暴露给用户。
此函数接收一个带有搜索参数的动作,调用getCurrentWeather,并在收到响应后,派发weatherLoaded动作,传递当前天气属性。
现在让我们创建效果本身,这样我们就可以触发doSearch函数:
**src/app/effects/current-weather.effects.ts**
getCurrentWeather$ = createEffect(() =>
this.actions$.pipe(
ofType(SearchActions.search),
exhaustMap((action) => this.doSearch(action))
)
)
这是我们接入可观察的动作流this.actions$并监听SearchAction.search类型动作的地方。然后我们使用exhaustMap操作符来注册发射的事件。由于其独特的性质,exhaustMap不会允许在doSearch函数完成其weatherLoaded动作的派发之前处理另一个搜索动作。
对所有不同的 RxJS 操作符感到困惑,担心你永远不会记住它们?请参阅附录 B,Angular Cheat Sheet,以获取快速参考。
实现 reducers
在weatherLoaded动作触发后,我们需要一种方法来摄取当前的天气信息并将其存储在我们的appStore状态中。reducers 将帮助我们处理特定动作,创建一个隔离且不可变的管道,以可预测的方式存储我们的数据。
让我们创建一个搜索 reducer:
$ npx ng generate @ngrx/schematics:reducer search
--reducers=reducers/index.ts --group --creators
采用默认选项。在这里,我们使用--group来保持文件在reducers文件夹下组织,并使用--creators来利用创建 NgRx 组件的创建者风格。我们还使用--reducers指定我们的父appStore状态的位置在reducers/index.ts,这样我们的新 reducer 就可以注册到它。
你可能会注意到reducers.index.ts已经更新以注册新的search.reducer.ts。让我们一步一步地实现它。
在search状态中,我们将存储当前天气,因此实现接口以反映这一点:
**src/app/reducers/search.reducer.ts**
export interface State {
current: ICurrentWeather
}
现在让我们指定initialState。这类似于我们需要定义BehaviorSubject的默认值。重构WeatherService以导出const defaultWeather: ICurrentWeather对象,你可以使用它来初始化BehaviorSubject和initialState。
**src/app/reducers/search.reducer.ts**
export const initialState:
State = { current:
defaultWeather,
}
最后,使用on操作符实现searchReducer以处理weatherLoaded动作:
**src/app/reducers/search.reducer.ts**
const searchReducer = createReducer(
initialState,
on(SearchActions.weatherLoaded, (state, action) => {
return {
...state,
current: action.current,
}
})
)
我们只需注册weatherLoaded动作,并解包其中存储的数据,然后将其传递到search状态。
这当然是一个非常简单的例子。然而,很容易想象一个更复杂的场景,在那里我们可能需要将接收到的数据展平或处理,并以易于消费的方式存储它。以不可变的方式隔离这种逻辑是使用像 NgRx 这样的库的关键价值主张。
使用选择器在 Store 中注册
我们需要CurrentWeatherComponent注册到appStore状态以更新当前天气数据。
首先,通过依赖注入appStore状态并注册选择器来从State对象中提取当前天气:
**src/app/current-weather/current-weather.component.ts**
**import * as appStore from '../reducers'**
export class CurrentWeatherComponent {
current$: Observable<ICurrentWeather>
constructor(**private store: Store<appStore.State**>) {
this.current$ =
**this.store.pipe(select((state: State) => state.search.current))**
}
...
}
我们简单地监听通过商店流动的状态变化事件。使用select函数,我们可以实现一个内联选择,以获取我们所需的数据片段。
我们可以稍微重构一下,通过使用createSelector在reducers/index.ts上创建一个selectCurrentWeather属性,使我们的选择器可重用:
**src/app/reducers/index.ts**
export const selectCurrentWeather = createSelector(
(state: State) => state.search.current,
current => current
)
此外,由于我们希望保持BehaviorSubject的持续操作,我们可以在CurrentWeatherComponent中实现一个merge操作符,以监听WeatherService更新和appStore状态更新:
**src/app/current-weather/current-weather.component.ts**
import * as appStore from '../reducers'
constructor(
private weatherService: WeatherService,
private store: Store<appStore.State>
) {
this.current$ = merge(
**this.store.pipe(select(appStore.selectCurrentWeather)),**
this.weatherService.currentWeather$
)
}
现在我们能够监听商店更新了,让我们实现拼图的最后一部分:分发搜索动作。
分发商店动作
我们需要分发搜索动作,以便我们的搜索效果可以获取当前天气数据并更新商店。在本章的早期部分,您在CitySearchComponent中实现了一个名为ngRxBasedSearch的存根函数。
让我们实现ngRxBasedSearch:
**src/app/city-search/city-search.component.ts**
ngRxBasedSearch(searchText: string, country?: string) {
this.store.dispatch(SearchActions.search({ searchText, country }))
}
不要忘记将appState商店注入到组件中!
就是这样!现在你应该能够运行你的代码并测试是否一切正常工作。
如您所见,NgRx 带来了许多复杂的技巧,以创建使数据转换不可变、定义良好和可预测的方法。然而,这伴随着相当大的实现开销。请使用您的最佳判断来决定您是否真的需要在您的 Angular 应用程序中使用 Flux 模式。通常,前端应用程序代码可以通过实现返回平面数据对象的 RESTful API 来简化,复杂的数据处理在服务器端进行。
单元测试 reducer 和选择器
您可以在search.reducer.spec.ts中为weatherLoadedreducer 和selectCurrentWeather选择器实现单元测试:
**src/app/reducers/search.reducer.spec.ts**
import { SearchActions } from '../actions/search.actions'
import { defaultWeather } from '../weather/weather.service'
import { fakeWeather } from '../weather/weather.service.fake'
import { selectCurrentWeather } from './index'
import { initialState, reducer } from './search.reducer'
describe('Search Reducer', () => {
describe('weatherLoaded', () => {
it('should return current weather', () => {
const action = SearchActions.weatherLoaded({ current: fakeWeather })
const result = reducer(initialState, action)
expect(result).toEqual({ current: fakeWeather })
})
})
})
describe('Search Selectors', () => {
it('should selectCurrentWeather', () => {
const expectedWeather = defaultWeather
expect(selectCurrentWeather({ search: { current: defaultWeather }
})).toEqual(
expectedWeather
)
})
})
这些单元测试相当直接,将确保在商店内不会发生对数据结构的意外更改。
使用 MockStore 进行单元测试组件
您需要更新CurrentWeatherComponent的测试,以便我们可以将模拟的Store注入到组件中,以测试current$属性的值。
让我们看看需要添加到规范文件中的 delta,以配置模拟商店:
**src/app/current-weather/current-weather.component.spec.ts**
import { MockStore, provideMockStore } from '@ngrx/store/testing'
describe('CurrentWeatherComponent', () => {
...
let store: MockStore<{ search: { current: ICurrentWeather } }>
const initialState = { search: { current: defaultWeather } }
beforeEach(async(() => {
...
TestBed.configureTestingModule({
imports: [AppMaterialModule],
providers: [
...
**provideMockStore({ initialState }),**
],
}).compileComponents()
...
**store = TestBed.inject(Store) as any**
}))
...
})
我们现在可以更新'should get currentWeather from weatherService'测试,以查看CurrentWeatherComponent是否与模拟商店一起工作:
**src/app/current-weather/current-weather.component.spec.ts**
it('should get currentWeather from weatherService', (done) => {
// Arrange
store.setState({ search: { current: fakeWeather } })
weatherServiceMock.currentWeather$.next(fakeWeather)
// Act
fixture.detectChanges() // triggers ngOnInit()
// Assert
expect(component.current$).toBeDefined()
component.current$.subscribe(current => {
expect(current.city).toEqual('Bethesda')
expect(current.temperature).toEqual(280.32)
// Assert on DOM
const debugEl = fixture.debugElement
const titleEl: HTMLElement =
debugEl.query(By.css('.mat-title')).nativeElement
expect(titleEl.textContent).toContain('Bethesda')
done()
})
})
模拟商店允许我们设置商店的当前状态,这反过来又允许构造函数中的选择器调用触发并获取提供的假天气数据。
TestBed 不是在 Angular 中编写单元测试的强制要求,这是一个在 angular.io/guide/testing 中得到良好阐述的话题。我的同事和本书的审稿人 Brendon Caulkins 为本章提供了一个无床的规范文件,名为 current-weather.component.nobed.spec.ts。他提到,在运行测试时,由于导入较少和维护较少,测试性能显著提高,但需要更高水平的关注和专业知识来实现测试。如果你在一个大型项目中,你应该认真考虑跳过 TestBed。
你可以在 GitHub 的 projects/ch12 文件夹下找到示例代码。
继续更新你剩余的测试,直到它们全部通过后再继续。
NgRx Data
如果 NgRx 是一个基于配置的框架,那么 NgRx Data 就是 NgRx 的基于约定的兄弟。NgRx Data 自动创建存储、效果、动作、还原器、分发和选择器。如果你的大多数应用程序动作是 CRUD(创建、检索、更新和删除)操作,那么 NgRx Data 可以用更少的代码实现与 NgRx 相同的结果。
NgRx Data 可能是你和你的团队更好地了解 Flux 模式的入门,然后你可以继续学习 NgRx 本身。
@ngrx/data 与 @ngrx/entity 库协同工作。它们一起提供了一套丰富的功能,包括事务性数据管理。更多关于它的信息请参阅 ngrx.io/guide/data。
对于这个示例,我们将切换回 LemonMart 项目。
通过执行以下命令将 NgRx Data 添加到你的项目中:
$ npx ng add @ngrx/store --minimal
$ npx ng add @ngrx/effects --minimal
$ npx ng add @ngrx/entity
$ npx ng add @ngrx/data
projects/ch12 下的示例项目配置了 @ngrx/store-devtools 用于调试。
如果你希望能够在运行时进行调试或仪表化,并使用 console.log NgRx 动作,请参阅 附录 A,调试 Angular。
在 LemonMart 中实现 NgRx/Data
在 LemonMart 中,我们有一个很好的用例来使用 @ngrx/data 库,特别是 User 类和 UserService。它巧妙地表示了一个可以支持 CRUD 操作的实体。通过一些修改和最少的努力,你就可以看到库的实际应用。
本节使用 lemon-mart 仓库。你可以在 projects/ch12 文件夹下找到本章的代码示例。
-
让我们从在
entity-metadata.ts中定义User实体开始:**src/app/entity-metadata.ts** import { EntityMetadataMap } from '@ngrx/data' const entityMetadata: EntityMetadataMap = { User: {}, } export const entityConfig = { entityMetadata, } -
确保将
entityConfig对象注册到EntityDataModule:**src/app/app.module.ts** imports: [ ... StoreModule.forRoot({}), EffectsModule.forRoot([]), EntityDataModule.forRoot(entityConfig), ] -
创建一个
User实体服务:**src/app/user/user/user.entity.service.ts** import { Injectable } from '@angular/core' import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory, } from '@ngrx/data' import { User } from './user' @Injectable({ providedIn: 'root' }) export class UserEntityService extends EntityCollectionServiceBase<User> { constructor( serviceElementsFactory: EntityCollectionServiceElementsFactory ) { super('User', serviceElementsFactory) } }
现在,你已经拥有了所有基本元素,可以将实体服务与组件集成。从某种意义上说,设置 NgRx Data 如此简单。然而,我们需要对其进行一些定制以适应我们现有的 REST API 结构,这将在下一节中详细说明。如果你遵循 NgRx Data 期望的 API 实现模式,那么不需要进行任何更改。
NgRx Data 想要通过 /api 路径访问 REST API,该路径托管在与你的 Angular 应用程序相同的端口上。为了在开发期间完成此操作,我们需要利用 Angular CLI 的代理功能。
在 Angular CLI 中配置代理
通常,发送到我们的 web 服务器和 API 服务器的 HTTP 请求应该有完全相同的 URL。然而,在开发过程中,我们通常在 http://localhost 的两个不同端口上托管两个应用程序。某些库,包括 NgRx Data,要求 HTTP 调用在同一个端口上。这为创建无摩擦的开发体验带来了挑战。因此,Angular CLI 随带了一个代理功能,你可以将 /api 路径指向 localhost 上的不同端点。这样,你可以使用一个端口来提供你的 web 应用程序和 API 请求。
-
在
src下创建一个proxy.conf.json文件,如下所示:如果你正在 lemon-mart-server monorepo 中工作,这将是在
web-app/src。**proxy.conf.json** { "/api": { "target": "http://localhost:3000", "secure": false, "pathRewrite": { "^/api": "" } } } -
使用
angular.json注册代理:**angular.json** ... "serve": { "builder": "@angular-devkit/build-angular:dev-server", "options": { "browserTarget": "lemon-mart:build", "proxyConfig": "proxy.conf.json" }, ... }
现在,当你运行 npm start 或 ng serve 时启动的服务可以重写对 /api 路径的任何调用,使用 http://localhost:3000。这是默认运行 lemon-mart-server 的端口。
如果你的 API 在不同的端口上运行,那么请使用正确的端口号和子路由。
接下来,让我们使用 UserEntityService。
使用实体服务
我们将更新用户管理主视图,因此我们可以选择使用 BehaviorSubject 或我们刚刚创建的 UserEntityService。
-
首先,在
user-table.component.ts中实现一个切换开关,类似于我们在本章前面为 LocalCast Weather 和 NgRx 所做的那样!图 12.9:具有 NgRx 滑动切换的 UserTableComponent
-
将新服务注入到
UserTableComponent中,并将其加载可观察对象与组件上现有的可观察对象合并:**src/app/manager/user-table/user-table.component.ts** useNgRxData = true readonly isLoadingResults$ = new BehaviorSubject(true) loading$: Observable<boolean> constructor( private userService: UserService, **private userEntityService: UserEntityService** **) {** this.loading$ = merge( **this.userEntityService.loading$,** this.isLoadingResults$ ) }由于
EntityDataModule在我们应用程序的根目录app.module.ts中注册,因此我们还需要在app.module.ts中提供UserService,这样我们就可以在UserEntityService中使用它。尽管UserEntityService在UserModule中提供,但 NgRx Data 中的操作顺序不适合与功能模块正确工作。这可能在某个时候得到修复。 -
你可以向组件中添加 CRUD 方法,如下面的代码所示。然而,我们将专注于仅更新
getUsers函数,因此不需要添加其他方法:**src/app/manager/user-table/user-table.component.ts** **getUsers() {** **return this.userEntityService.getAll().pipe(** **map((value) => {** **return { total: value.length, data: value }** **})** **)** **}** add(user: User) { this.userEntityService.add(user) } delete(user: User) { this.userEntityService.delete(user._id) } update(user: User) { this.userEntityService.update(user) } -
在
ngAfterViewInit中,重构对this.userService.getUsers的调用,使其从名为getUsers的方法中调用 -
然后实现一个对
this.userEntityService.getAll()的条件调用,并映射出返回值以适应IUsers接口:**src/app/manager/user-table/user-table.component.ts** ... getUsers(pageSize: number, searchText = '', pagesToSkip = 0) : Observable<IUsers> { if (this.useNgRxData) { return this.userEntityService.getAll().pipe( map((value) => { return { total: value.length, data: value } }) ) } else { return this.userService.getUsers( pageSize, searchText, pagesToSkip, sortColumn, sortDirection ) }
现在,您的组件可以通过切换滑动开关并输入一些新的搜索文本来尝试从任一来源获取数据。然而,我们的端点没有以 NgRx Data 期望的形状提供数据,因此我们需要自定义实体服务来克服这个问题。
自定义实体服务
您可以在多个位置自定义 NgRx Data 的行为。我们感兴趣的是覆盖getAll()函数的行为,以便我们接收到的数据得到适当的初始化,并且可以从项目的对象中提取数据。
对于这个例子,我们不会尝试使用 NgRx Data 恢复完整的分页功能。为了保持简单,我们只关注将数据数组获取到数据表中。
更新用户实体服务以注入UserService并实现一个使用它的getAll函数:
**src/app/user/user/user.entity.service.ts**
...
getAll(options?: EntityActionOptions): Observable<User[]> {
return this.userService
.getUsers(10)
.pipe(map((users) => users.data.map(User.Build)))
}
...
如您所见,我们正在遍历项目的对象,并使用我们的构建函数初始化对象,从而将Observable<IUsers>扁平化和转换为Observable<User[]>。
实施此更改后,您应该能够看到数据如下流入用户表:
图 12.10:NgRx Data 的用户表
注意,所有七个用户同时显示,如图中放大截图所示,分页功能没有正常工作。然而,这个实现足以展示 NgRx Data 带来的好处。
那么,您应该在您的下一个应用中实现 NgRx Data 吗?这取决于。由于该库是 NgRx 之上的抽象层,如果您没有很好地理解 NgRx 的内部结构,您可能会感到迷茫和受限。然而,该库在减少实体数据管理和 CRUD 操作方面的样板代码方面有很大的潜力。如果您在应用中执行大量的 CRUD 操作,您可能会节省时间,但请务必只将实现范围限制在需要它的区域。无论如何,您都应该关注这个伟大库的演变。
摘要
在本章中,我们使用路由优先架构和我们的食谱完成了对所有主要 Angular 应用设计考虑因素的回顾,从而轻松实现了一个业务应用。我们回顾了如何编辑现有用户,利用 resolve guard 加载用户数据,以及在不同的上下文中初始化和重用组件。
我们使用辅助路由实现了主/详细视图,并展示了如何构建具有分页的数据表。然后我们学习了 NgRx 和@ngrx/data库以及它们对我们代码库的影响,使用了local-weather-app和lemon-mart项目。
总体来说,通过使用路由优先的设计、架构和实现方法,我们以良好的高级理解来应对我们的应用程序设计,以实现我们想要达到的目标。通过早期识别代码复用机会,我们能够优化我们的实现策略,提前实现可重用组件,而不会冒着过度工程化解决方案的风险。
在下一章中,我们将在 AWS 上设置一个高可用性基础设施来托管 LemonMart。我们将更新项目以添加新脚本,以实现无停机时间的蓝绿部署。最后,在最后一章中,我们将更新 LemonMart 以使用 Google Analytics,并讨论高级云运维问题。
进一步阅读
-
使用 @Input() 测试 Angular 组件,艾可·克洛斯特曼,2017,可在
medium.com/better-programming/testing-angular-components-with-input-3bd6c07cfaf6找到 -
什么是 NgRx?,2020,可在
ngrx.io/docs找到 -
NgRx 测试,2020,可在
ngrx.io/guide/store/testing找到 -
@ngrx/data,2020,可在
ngrx.io/guide/data找到 -
重新设计 NgRx 的动作创建者,亚历克斯·奥库什科,2019,可在
medium.com/angular-in-depth/ngrx-action-creators-redesigned-d396960e46da找到 -
使用 Observable Store 简化前端状态管理,丹·瓦林,2019,可在
blog.codewithdan.com/simplifying-front-end-state-management-with-observable-store/找到
问题
尽可能地回答以下问题,以确保你已理解本章的关键概念,无需使用 Google。你需要帮助回答这些问题吗?请参阅 附录 D,自我评估答案,可在static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf在线找到,或访问expertlysimple.io/angular-self-assessment。
-
什么是解析守卫?
-
路由编排有什么好处?
-
什么是辅助路由?
-
NgRx 与使用 RxJS/Subject 有何不同?
-
NgRx 数据的价值是什么?
-
在
UserTableComponent中,为什么我们使用readonly isLoadingResults$: BehaviorSubject<Boolean>而不是简单的布尔值来驱动加载指示器?