Open jiayisheji opened 1 year ago
构建一个功能齐全的自定义表单控件,兼容模板驱动和响应式表单,以及所有内置和自定义表单验证器。
Angular Forms 提供 FormsModule 和 ReactiveFormsModule 模块自带了一系列内置指令,这些指令使得将标准 HTML 表单元素(如 input、select、textarea等)绑定到表单组变得非常简单。
FormsModule
ReactiveFormsModule
HTML
input
select
textarea
除了这些标准的 HTML 表单元素,我们可能还想使用自定义表单控件,比如下拉框、选择框、切换按钮、滑块或许多其他类型的常用自定义表单组件。
在本文中,我们将学习如何使用现有的自定义表单控件组件,并使其与 Angular Forms API 完全兼容,以便该组件能够参与父表单验证和值跟踪机制。
Angular Forms API
这意味着:
ngModel
formControlName
formControl
我们将在本文中构建一个简单的数量选择器组件,它可以用来增加或减少一个值。该组件将成为表单的一部分,如果计数器不匹配有效范围,该组件将被标记为错误。
新的自定义表单控件将完全兼容所需的 Angular 内置表单验证器(required,max),以及任何其他内置或自定义验证器。
Angular
required
max
我们还将在本文中学习如何创建可重用的嵌套表单,这些表单部分可以在许多不同的表单中重用。
我们还将在本文中构建一个嵌套表单的简单示例:包含地址子表单。通过学习如何创建可重用的嵌套表单,这些表单可以在许多不同的表单中重用。
因此,废话不多说,让我们开始学习如何创建自定义表单控件。
为了了解如何构建自定义表单控件,我们需要首先了解 Angular 内置表单控件是如何工作的。
Angular 内置表单控件主要针对原生 HTML 表单元素,例如 input、select、textarea、checkbox 等。
checkbox
下面是一个简单表单的示例,其中有几个普通的 HTML 表单字段:
<div [formGroup]="form"> 标题:<input placeholder="输入标题" formControlName="name"> <label>是否发布<input type="checkbox" formControlName="publish"></label> 描述:<textarea placeholder="输入描述" formControlName="description"></textarea> </div>
正如我们所看到的,我们在这里有几个标准的表单控件,并使用了 formControlName 属性。这就是 Angular 表单绑定到标准 HTML 表单元素的方式。
每当用户与表单输入交互时,表单值和有效性状态将自动重新计算。
那么,这一切是如何运作的呢?
在底层,Angular 表单模块会给每个原生 HTML 元素应用一个内置的 Angular 指令,该指令将负责跟踪字段的值,并将其反馈给父表单。
这种类型的特殊指令被称为控制值访问器指令(control value accessor directive)。
以上面表单的复选框字段为例。响应式表单模块中有一个内置指令,专门用来跟踪复选框的值。
下面是该指令的简化代码: checkbox_value_accessor
@Directive({ selector: 'input[type=checkbox][formControlName], input[type=checkbox][formControl], input[type=checkbox][ngModel]', }) export class CheckboxControlValueAccessor implements ControlValueAccessor { .... }
正如我们从选择器中看到的,这个值跟踪指令只针对 HTML 中 input 元素 checkbox 类型,但只有当 ngModel、formControl 或formControlName 属性应用于它时才适用。
如果这个指令只针对复选框,那么其他类型的表单控件,比如 input 或 textarea 呢?
每一种控制类型都有自己的值访问指令,它不同于 CheckboxControlValueAccessor,其中 input 和 textarea 使用 DefaultValueAccessor。
所有这些指令都是内置在 Angular Forms 模块中的,只涉及标准的 HTML 表单控件。
这意味着,如果我们想要实现我们自己的自定义表单控件,我们将不得不为它实现一个自定义 ControlValueAccessor。
ControlValueAccessor
假设我们想要构建一个自定义表单控件,该控件表示一个带有增加和减少按钮的数字计数器,类似于 <input type="number" /> 一样,我们用于选择订单数量。
<input type="number" />
创建一个自定义表单组件:
import { Component, Input } from '@angular/core'; @Component({ selector: 'my-counter', standalone: true, imports: [], template: ` <button type="button" (click)="decrement()">-</button><span>{{count}}</span><button type="button" (click)="increment()">+</button> `, }) export class Counter { count = 0; @Input() step: number = 1; increment() { this.count += this.step; } decrement() { this.count -= this.step; } }
在当前的形式下,该组件既不兼容模板驱动的表单,也不兼容响应式表单。
我们希望能够像在表单中添加标准 HTML 表单 input 元素一样,通过添加 formControlName 或 ngModel 指令来添加这个组件。
我们还希望该组件与内置验证器兼容,将它们设置必填字段并设置最大值。
@Component({ selector: 'my-app', standalone: true, imports: [CommonModule, Counter, FormsModule, ReactiveFormsModule], template: ` <h1>Hello from {{name}}!</h1> <form [formGroup]="form" (ngSubmit)="onSubmit()"> <div><my-counter [step]="2" formControlName="count" /></div> <button type="submit">提交</button> </form> `, }) export class App { name = 'Angular'; form: FormGroup = new FormGroup({ count: new FormControl(0, [Validators.required, Validators.max(100)]), }); onSubmit() { console.log(this.form.value); } }
但是在控件的当前版本中,如果我们尝试这样做,就会得到一个错误:
ERROR Error: NG01203: No value accessor for form control name: 'count'. Find more at https://angular.io/errors/NG01203
为了修复这个错误,并使 my-counter 组件与 Angular Forms 兼容,我们需要给这个表单控件一个 ControlValueAccessor ,就像原生 HTML 元素的情况一样,比如 input、textarea 等。
my-counter
Angular Forms
为了做到这一点,我们将使组件实现 ControlValueAccessor 接口。
让我们回顾一下 ControlValueAccessor 接口的方法。请记住,它们并不是要通过我们的代码直接调用,因为它们是框架回调。
所有这些方法都只能由表单模块在运行时调用,它们的作用是促进表单控件和父表单之间的通信。
下面是这个接口的方法,以及它们是如何工作的:
writeValue
registerOnChange
registerOnTouched
touched
registerOnToched
setDisabledState
Forms API
enabled
disabled
那我们就给组件实现了 ControlValueAccessor 接口:
import { Component, Input } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ selector: 'my-counter', standalone: true, imports: [], providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: Counter, }, ], template: ` <button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button> `, }) export class Counter implements ControlValueAccessor { _value = 0; set value(value: any) { this._value = value; this.notifyValueChange(); } get value(): any { return this._value; } @Input() step: number = 1; onChange: ((value: number) => {}) | undefined; onTouched: (() => {}) | undefined; touched = false; disabled = false; writeValue(value: number) { this._value = value; } registerOnChange(onChange: (count: number) => {}) { this.onChange = onChange; } registerOnTouched(onTouched: () => {}) { this.onTouched = onTouched; } /** * 通知父表单子控件被触碰 */ markAsTouched() { if (!this.touched) { if (this.onTouched) { this.onTouched(); } this.touched = true; } } /** * 通知父表单值发生变化 */ notifyValueChange(): void { if (this.onChange) { this.onChange(this.value); } } setDisabledState(disabled: boolean) { this.disabled = disabled; } increment() { this.markAsTouched(); this.value += this.step; } decrement() { this.markAsTouched(); this.value -= this.step; } }
现在让我们逐一解释每个方法,看它们是如何实现的。
每当父表单想要在子控件中设置一个值时,Angular 表单模块就会调用 writeValue 方法。
在我们的组件中,我们将获取该值并将其直接赋值给内部 count 属性
count
writeValue(value: number) { this._value = value; }
注意:这里不能直接赋值给 value,这样会触发 registerOnChange 注册的 onChange 回调方法。
value
onChange
父表单可以使用 writeValue 在子控件中设置一个值,但是反过来呢?
如果用户与表单控件交互并增加或减少计数器值,则需要将新值传递回父表单。
第一步是让父表单向子控件注册回调函数,但要使用 registerOnChange 方法
onChange: ((value: number) => {}) | undefined; registerOnChange(onChange: (count: number) => {}) { this.onChange = onChange; }
正如我们所看到的,当调用这个方法时,我们将接收回调函数,然后将其保存在成员变量中。
onChange 成员变量被声明为一个函数,并用一个空函数初始化,这意味着一个具有空函数体的函数。
这样,如果我们的程序由于某种原因在 registerOnChange 调用之前调用了该函数,我们就不会遇到任何错误。
当通过单击自增或自减按钮改变计数器的值时,我们需要通知父表单有一个新值可用。
我们将通过调用回调函数并报告新值来实现这一点:
increment() { this.value += this.step; } decrement() { this.value -= this.step; }
除了向父表单报告新值外,我们还需要在子控件被用户触碰时通知父表单。
初始化表单时,每个表单控件(以及表单组)都被认为处于未触碰状态,并且 ng-untouched 的 CSS 类应用于表单组及其每个子控件。
ng-untouched
这些 ng-touched / ng-untouched 的 CSS 类对于表单中的错误消息样式化非常重要,因此我们的自定义表单控件也需要支持这些。
ng-touched
像前面一样,我们需要注册一个回调,以便子控件可以将其触碰状态报告给父表单:
onTouched: (() => {}) | undefined; registerOnTouched(onTouched: () => {}) { this.onTouched = onTouched; }
现在,我们需要在控件被触碰时调用这个回调函数,只要用户至少单击一次增量或减量按钮,就会调用这个回调函数:
touched = false; increment() { this.markAsTouched(); this.count += this.step; this.onChange(this.count); } decrement() { this.markAsTouched(); this.count -= this.step; this.onChange(this.count); } markAsTouched() { if (!this.touched) { this.onTouched(); this.touched = true; } }
正如我们所看到的,当两个按钮中的一个第一次被点击时,我们将调用 ontouch 回调一次,并且表单控件现在将被父表单认为被触摸了。
ontouch
自定义表单控件将像预期的那样应用 ng-touched 的 CSS类
<my-app ng-version="15.1.2"> <h1>Hello from Angular!</h1> <form novalidate="" ng-reflect-form="[object Object]" class="ng-valid ng-touched ng-dirty"> <div><my-counter formcontrolname="count" ng-reflect-name="count" ng-reflect-step="2" class="ng-valid ng-touched ng-dirty"><button type="button">-</button><span>2</span><button type="button">+</button></my-counter></div> <button type="submit">提交</button> </form> </my-app>
父表单也可以通过调用 setDisabledState 方法来启用或禁用它的任何子控件。我们可以在成员变量 disabled 中保持禁用状态,并使用它来打开和关闭自增/自减功能:
disabled = false; setDisabledState(disabled: boolean) { this.disabled = disabled; } increment() { this.markAsTouched(); if(!this.disabled) { this.value += this.step; } } decrement() { this.markAsTouched(); if(!this.disabled) { this.value -= this.step; } }
最后,正确实现 ControlValueAccessor 接口的最后一个难题是将自定义表单控件注册为依赖注入系统中的已知值访问器:
@Component({ selector: 'my-counter', standalone: true, imports: [], providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: Counter, }, ], template: ` <button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button> `, }) export class Counter implements ControlValueAccessor { }
如果没有这个配置,我们的自定义表单控件将无法正常工作。
那么这种配置是什么?我们正在将组件添加到已知值访问器列表中,这些列表都是用 NG_VALUE_ACCESSOR 唯一依赖注入键(也称为注入令牌)注册的。
NG_VALUE_ACCESSOR
注意,multi 标志设置为 true,这意味着该依赖项提供了一个值列表,而不仅仅是一个值。这很正常,因为除了我们自己的外,Angular 表单中还注册了很多值访问器。
multi
true
例如,所有用于标准 input、textarea 等的内置值访问器也在 NG_VALUE_ACCESSOR 下注册。
每当 Angular 表单模块需要所有可用值访问器的完整列表时,它所要做的就是注入 NG_VALUE_ACCESSOR。
这样,我们的组件现在就能够在表单中设置属性的值了。
不仅如此,该组件现在能够参与表单验证过程,并且已经与内置的 required 和 max 验证器完全兼容。
但是,如果组件需要具有自己的内置验证规则,而这些规则总是在组件的每个实例中都活跃,而不是表单配置独立于表单呢?
在我们的自定义表单控件的情况下,我们希望它确保数量是正的。如果不是,那么表单字段应该被标记为错误,并且对于组件的所有实例都应该始终为 true。
为了实现这个逻辑,我们将让组件实现 Validator 接口。这个接口只包含两个方法:
Validator
validate
null
ValidationErrors
registerOnValidatorChange
现在让我们来看看如何实现这个接口,并做一个组件的最后演示。
我们必须实现的 Validator 的唯一方法是 validate 方法:
validate(control: AbstractControl): ValidationErrors | null { if (control.value < 0) { return { mustBePositive: { actual: control.value, }, }; } return null; }
在此实现中,如果值有效,则返回 null,并返回一个包含有关错误的所有详细信息的 ValidationErrors 对象。
在我们的组件中,我们不需要实现 registerOnValidatorChange,因为实现这个方法是可选的。
例如,如果我们的组件有可配置的验证规则,依赖于某些组件输入,我们只需要这个方法。如果是这样的话,在其中一个验证输入发生变化时,我们可以根据需要触发一个新的验证。
为了使 Validator 接口正常工作,我们还需要用 NG_VALIDATORS 注入令牌注册我们的自定义组件:
NG_VALIDATORS
@Component({ selector: 'my-counter', standalone: true, imports: [], providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: Counter, }, { provide: NG_VALIDATORS, multi: true, useExisting: Counter, }, ], template: ` <button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button> `, }) export class Counter implements ControlValueAccessor, Validator { ... }
注意:如果没有在 NG_VALIDATORS 中正确注册这个类,将永远不会调用 validate 方法。
有了 ControlValueAccessor 和 Validator 这两个接口,我们现在就有了一个功能齐全的自定义表单控件,它既兼容响应式表单,也兼容模板驱动表单,既能设置表单属性的值,又能参与表单验证过程。
这是最终代码:
import { Component, Input } from '@angular/core'; import { AbstractControl, ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, } from '@angular/forms'; @Component({ selector: 'my-counter', standalone: true, imports: [], providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: Counter, }, { provide: NG_VALIDATORS, multi: true, useExisting: Counter, }, ], template: ` <button type="button" (click)="decrement()">-</button><span>{{value}}</span><button type="button" (click)="increment()">+</button> `, }) export class Counter implements ControlValueAccessor, Validator { _value = 0; set value(value: any) { this._value = value; this.notifyValueChange(); } get value(): any { return this._value; } @Input() step: number = 1; onChange: ((value: number) => {}) | undefined; onTouched: (() => {}) | undefined; touched = false; disabled = false; validate(control: AbstractControl): ValidationErrors | null { if (control.value < 0) { return { mustBePositive: { actual: control.value, }, }; } return null; } writeValue(value: number) { this._value = value; } registerOnChange(onChange: (count: number) => {}) { this.onChange = onChange; } registerOnTouched(onTouched: () => {}) { this.onTouched = onTouched; } /** * 通知父表单子控件被触碰 */ markAsTouched() { if (!this.touched) { if (this.onTouched) { this.onTouched(); } this.touched = true; } } /** * 通知父表单值发生变化 */ notifyValueChange(): void { if (this.onChange) { this.onChange(this.value); } } setDisabledState(disabled: boolean) { this.disabled = disabled; } increment() { this.markAsTouched(); if (!this.disabled) { this.value += this.step; } } decrement() { this.markAsTouched(); if (!this.disabled) { this.value -= this.step; } } }
现在让我们在运行时测试这个组件,方法是将它添加到一个带有两个标准验证器的表单中:
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators, } from '@angular/forms'; import { Counter } from './form'; @Component({ selector: 'my-app', standalone: true, imports: [CommonModule, Counter, FormsModule, ReactiveFormsModule], template: ` <h1>Hello from {{name}}!</h1> <form [formGroup]="form" (ngSubmit)="onSubmit()"> <div><my-counter [step]="2" formControlName="count" /></div> <button type="submit">提交</button> </form> `, }) export class App { name = 'Angular'; form = new FormGroup({ count: new FormControl(60, [Validators.required, Validators.max(100)]), }); onSubmit() { console.log(this.form); } }
正如我们所看到的,我们将该字段设置为比填的,并将最大值设置为100。控件的初始值为60,这是一个有效值。
但是如果我们将值设为110会发生什么呢?然后,表单将变得无效,my-counter 控件将有一个与之关联的错误。
我们可以通过检查 form.controls['count'].errors 属性的值来查看错误:
form.controls['count'].errors
{ "max": { "max": 100, "actual": 110 } }
正如我们所看到的,Validators.max(100) 内置验证器启动并将自定义表单控件标记为错误。
Validators.max(100)
但是,如果相反,我们将数量值设置为例如负值 -10 呢?下面是我们控件的 errors 属性:
errors
{ "mustBePositive": { "actual": -10 } }
正如我们所看到的,现在验证方法创建了一个 ValidationErrors 对象,然后将其设置为表单控件的错误的一部分。
我们现在有了一个功能齐全的自定义表单控件,它兼容模板驱动表单、响应式表单和所有内置验证器。
一个非常常见的表单用例是嵌套表单组,它可以跨多个表单重用。
地址表单就是一个很好的例子,它包含了所有常见的地址字段:
注意:这里为了体现嵌套表单组功能,实际项目当作,我们更希望用户选择我们提供的省市区选项,你只需要把输入框换成下拉选择框即可,这里为了例子看起来不那么复杂,重点关注嵌套表单,这里采用输入框形式。
现在假设我们的应用程序有许多需要地址的不同表单。我们不希望在每个表单中重复显示和验证这些字段所需的所有代码。
相反,我们想做的是在 Angular 组件的表单下创建一个可重用的表单部分,然后我们可以将其插入多个表单中,类似于嵌套的可重用子表单。
下面是我们如何使用这样一个地址表单组件:
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <div><my-counter [step]="2" formControlName="count" /></div> <div><my-address formControlName="address" legend="地址" /></div> <button type="submit">提交</button> </form>
如我们所见,我们希望使我们的 my-address 组件与 Angular form 完全兼容,这意味着它应该支持 ngModel,formControl 和formControlName 指令,并能够参与父表单的验证。
my-address
Angular form
听起来很熟悉?
实际上,我们所要做的就是实现 ControlValueAccessor 和 Validator 接口之前所做的那样。那么这是如何运作的呢?
首先,我们需要定义嵌套地址表单组件:
import { Component, Input, OnDestroy } from '@angular/core'; import { AbstractControl, ControlValueAccessor, FormControl, FormGroup, FormsModule, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, ValidationErrors, Validator, Validators, } from '@angular/forms'; import { Subscription } from 'rxjs'; export type AddressForm = { province: string | null; city: string | null; district: string | null; street: string | null; zipCode: string | null; }; @Component({ selector: 'my-address', standalone: true, imports: [FormsModule, ReactiveFormsModule], providers: [ { provide: NG_VALUE_ACCESSOR, multi: true, useExisting: Address, }, { provide: NG_VALIDATORS, multi: true, useExisting: Address, }, ], template: ` <fieldset [formGroup]="form"> <legend>{{legend}}</legend> <div> <label for="province">省/直辖市:</label> <input type="text" id="province" formControlName="province" placeholder="输入省/直辖市" (blur)="markAsTouched()" /> </div> <div> <label for="city">市:</label> <input type="text" id="city" formControlName="city" placeholder="输入市" (blur)="markAsTouched()" /> </div> <div> <label for="district">区:</label> <input type="text" id="district" formControlName="district" placeholder="输入区" (blur)="markAsTouched()" /> </div> <div> <label for="street">街道门牌号:</label> <input type="text" id="street" formControlName="street" placeholder="输入街道门牌号" (blur)="markAsTouched()" /> </div> <div> <label for="zipCode">邮政编码:</label> <input type="text" id="zipCode" formControlName="zipCode" placeholder="输入邮政编码" (blur)="markAsTouched()" /> </div> </fieldset> `, }) export class Address implements ControlValueAccessor, Validator, OnDestroy { @Input() legend!: string; form = new FormGroup({ province: new FormControl<string | null>(null, [Validators.required]), city: new FormControl<string | null>(null, [Validators.required]), district: new FormControl<string | null>(null, [Validators.required]), street: new FormControl<string | null>(null, [Validators.required]), zipCode: new FormControl<string | null>(null, [Validators.required]), }); onChangeSubs: Subscription[] = []; onTouched: (() => {}) | undefined; touched = false; ngOnDestroy() { for (let sub of this.onChangeSubs) { sub.unsubscribe(); } } validate(control: AbstractControl): ValidationErrors | null { if (this.form.valid) { return null; } // from parent form `Validators.required` if (control.hasValidator(Validators.required)) { const errors: ValidationErrors = {}; Object.entries(this.form.controls).reduce( (error, [controlName, control]) => { if (control.errors) { error[controlName] = control.errors; } return error; }, errors ); return errors; } return null; } writeValue(value: AddressForm) { if (value) { this.form.setValue(value, { emitEvent: false }); } } registerOnChange(onChange: (value: Partial<AddressForm>) => void) { this.onChangeSubs.push(this.form.valueChanges.subscribe(onChange)); } registerOnTouched(onTouched: () => {}) { this.onTouched = onTouched; } /** * 通知父表单子控件被触碰 */ markAsTouched() { if (!this.touched) { if (this.onTouched) { this.onTouched(); } this.touched = true; } } setDisabledState(disabled: boolean) { if (disabled) { this.form.disable(); } else { this.form.enable(); } } }
正如我们所看到的,我们嵌套的地址表单本身也是一个 FormGroup,它也在内部使用 Angular form 来收集每个地址字段的值并验证它们的值。
FormGroup
表单对象已经包含了关于此子表单的值和有效性状态的所有信息。我们现在可以使用这些信息来快速实现 ControlValueAccessor 和Validator 接口。
以下是关于此实现的一些重要注意事项:
form
writeValue.setValue
form.enable()
form.disable()
valueChanges
OnDestroy
control.hasValidator(Validators.required)
如果我们尝试填写地址表单的值,我们将看到它们被报告回父表单,并显示在 address 属性下。
address
在输入地址后,这是包含 my-address 的父表单的 value 属性:
{ ... address: { province: '省', city: ‘市’, district: '区', street: '街道' zipCode: ‘100000’ } }
每个表单控件都链接到一个控件值访问器,该访问器负责表单控件与父表单之间的交互。
这包括所有标准的 HTML 表单控件,如 input、select、textarea 等, FormsModule 为此提供了内置的控件值访问器。
对于自定义表单控件,我们必须通过实现 ControlValueAccessor 接口来构建我们自己的控件值访问器,如果我们希望控件执行自定义值验证,那么我们需要实现 Validator 接口。
我们还可以使用相同的技术来实现嵌套表单组(如地址子表单),这些表单组可以跨多个表单重用。
当你有一组输入框需要验证时,那该如何操作,这就需要 FormArray 闪亮登场。有机会我们下次介绍它们。
FormArray
谢谢你读到这里。下面是你接下来可以做的一些事情:
Angular Forms 提供
FormsModule
和ReactiveFormsModule
模块自带了一系列内置指令,这些指令使得将标准HTML
表单元素(如input
、select
、textarea
等)绑定到表单组变得非常简单。除了这些标准的
HTML
表单元素,我们可能还想使用自定义表单控件,比如下拉框、选择框、切换按钮、滑块或许多其他类型的常用自定义表单组件。在本文中,我们将学习如何使用现有的自定义表单控件组件,并使其与
Angular Forms API
完全兼容,以便该组件能够参与父表单验证和值跟踪机制。这意味着:
ngModel
把自定义组件插入到表单中formControlName
或formControl
将自定义组件添加到表单中我们将在本文中构建一个简单的数量选择器组件,它可以用来增加或减少一个值。该组件将成为表单的一部分,如果计数器不匹配有效范围,该组件将被标记为错误。
新的自定义表单控件将完全兼容所需的
Angular
内置表单验证器(required
,max
),以及任何其他内置或自定义验证器。我们还将在本文中学习如何创建可重用的嵌套表单,这些表单部分可以在许多不同的表单中重用。
我们还将在本文中构建一个嵌套表单的简单示例:包含地址子表单。通过学习如何创建可重用的嵌套表单,这些表单可以在许多不同的表单中重用。
因此,废话不多说,让我们开始学习如何创建自定义表单控件。
标准表单控件是如何工作的?
为了了解如何构建自定义表单控件,我们需要首先了解
Angular
内置表单控件是如何工作的。Angular
内置表单控件主要针对原生HTML
表单元素,例如input
、select
、textarea
、checkbox
等。下面是一个简单表单的示例,其中有几个普通的 HTML 表单字段:
正如我们所看到的,我们在这里有几个标准的表单控件,并使用了
formControlName
属性。这就是Angular
表单绑定到标准HTML
表单元素的方式。每当用户与表单输入交互时,表单值和有效性状态将自动重新计算。
那么,这一切是如何运作的呢?
什么是 ControlValueAccessor
在底层,
Angular
表单模块会给每个原生HTML
元素应用一个内置的Angular
指令,该指令将负责跟踪字段的值,并将其反馈给父表单。这种类型的特殊指令被称为控制值访问器指令(control value accessor directive)。
以上面表单的复选框字段为例。响应式表单模块中有一个内置指令,专门用来跟踪复选框的值。
下面是该指令的简化代码: checkbox_value_accessor
正如我们从选择器中看到的,这个值跟踪指令只针对
HTML
中input
元素checkbox
类型,但只有当ngModel
、formControl
或formControlName
属性应用于它时才适用。如果这个指令只针对复选框,那么其他类型的表单控件,比如
input
或textarea
呢?每一种控制类型都有自己的值访问指令,它不同于 CheckboxControlValueAccessor,其中
input
和textarea
使用 DefaultValueAccessor。所有这些指令都是内置在 Angular Forms 模块中的,只涉及标准的 HTML 表单控件。
这意味着,如果我们想要实现我们自己的自定义表单控件,我们将不得不为它实现一个自定义
ControlValueAccessor
。构建自定义表单控件
假设我们想要构建一个自定义表单控件,该控件表示一个带有增加和减少按钮的数字计数器,类似于
<input type="number" />
一样,我们用于选择订单数量。创建一个自定义表单组件:
在当前的形式下,该组件既不兼容模板驱动的表单,也不兼容响应式表单。
我们希望能够像在表单中添加标准
HTML
表单input
元素一样,通过添加formControlName
或ngModel
指令来添加这个组件。我们还希望该组件与内置验证器兼容,将它们设置必填字段并设置最大值。
但是在控件的当前版本中,如果我们尝试这样做,就会得到一个错误:
为了修复这个错误,并使
my-counter
组件与Angular Forms
兼容,我们需要给这个表单控件一个ControlValueAccessor
,就像原生 HTML 元素的情况一样,比如input
、textarea
等。为了做到这一点,我们将使组件实现
ControlValueAccessor
接口。了解 ControlValueAccessor 接口
让我们回顾一下
ControlValueAccessor
接口的方法。请记住,它们并不是要通过我们的代码直接调用,因为它们是框架回调。所有这些方法都只能由表单模块在运行时调用,它们的作用是促进表单控件和父表单之间的通信。
下面是这个接口的方法,以及它们是如何工作的:
writeValue
:表单模块调用此方法将值写入表单控件中registerOnChange
:当由于用户输入而变化表单值时,我们需要将值报告回父表单。这是通过调用回调来完成的,该回调最初使用registerOnChange
方法在控件中注册的registerOnTouched
:当用户第一次与表单控件交互时,会认为该控件已经touched
状态,这对于样式美化很有用。为了向父表单报告控件被触碰,我们需要使用registerOnToched
方法注册的回调setDisabledState
:可以使用Forms API
的enabled
和disabled
控件表单禁用状态。这个状态可以通过setDisabledState
方法传递给表单控件那我们就给组件实现了
ControlValueAccessor
接口:现在让我们逐一解释每个方法,看它们是如何实现的。
实现 ControlValueAccessor 接口
实现 writeValue
每当父表单想要在子控件中设置一个值时,
Angular
表单模块就会调用writeValue
方法。在我们的组件中,我们将获取该值并将其直接赋值给内部
count
属性实现 registerOnChange
父表单可以使用
writeValue
在子控件中设置一个值,但是反过来呢?如果用户与表单控件交互并增加或减少计数器值,则需要将新值传递回父表单。
第一步是让父表单向子控件注册回调函数,但要使用
registerOnChange
方法正如我们所看到的,当调用这个方法时,我们将接收回调函数,然后将其保存在成员变量中。
onChange
成员变量被声明为一个函数,并用一个空函数初始化,这意味着一个具有空函数体的函数。这样,如果我们的程序由于某种原因在
registerOnChange
调用之前调用了该函数,我们就不会遇到任何错误。当通过单击自增或自减按钮改变计数器的值时,我们需要通知父表单有一个新值可用。
我们将通过调用回调函数并报告新值来实现这一点:
实现 registerOnTouched
除了向父表单报告新值外,我们还需要在子控件被用户触碰时通知父表单。
初始化表单时,每个表单控件(以及表单组)都被认为处于未触碰状态,并且
ng-untouched
的 CSS 类应用于表单组及其每个子控件。这些
ng-touched
/ng-untouched
的 CSS 类对于表单中的错误消息样式化非常重要,因此我们的自定义表单控件也需要支持这些。像前面一样,我们需要注册一个回调,以便子控件可以将其触碰状态报告给父表单:
现在,我们需要在控件被触碰时调用这个回调函数,只要用户至少单击一次增量或减量按钮,就会调用这个回调函数:
正如我们所看到的,当两个按钮中的一个第一次被点击时,我们将调用
ontouch
回调一次,并且表单控件现在将被父表单认为被触摸了。自定义表单控件将像预期的那样应用
ng-touched
的 CSS类实现 setDisabledState
父表单也可以通过调用
setDisabledState
方法来启用或禁用它的任何子控件。我们可以在成员变量disabled
中保持禁用状态,并使用它来打开和关闭自增/自减功能:ControlValueAccessor 的依赖注入配置
最后,正确实现
ControlValueAccessor
接口的最后一个难题是将自定义表单控件注册为依赖注入系统中的已知值访问器:如果没有这个配置,我们的自定义表单控件将无法正常工作。
那么这种配置是什么?我们正在将组件添加到已知值访问器列表中,这些列表都是用
NG_VALUE_ACCESSOR
唯一依赖注入键(也称为注入令牌)注册的。注意,
multi
标志设置为true
,这意味着该依赖项提供了一个值列表,而不仅仅是一个值。这很正常,因为除了我们自己的外,Angular
表单中还注册了很多值访问器。例如,所有用于标准
input
、textarea
等的内置值访问器也在NG_VALUE_ACCESSOR
下注册。每当
Angular
表单模块需要所有可用值访问器的完整列表时,它所要做的就是注入NG_VALUE_ACCESSOR
。这样,我们的组件现在就能够在表单中设置属性的值了。
不仅如此,该组件现在能够参与表单验证过程,并且已经与内置的
required
和max
验证器完全兼容。但是,如果组件需要具有自己的内置验证规则,而这些规则总是在组件的每个实例中都活跃,而不是表单配置独立于表单呢?
实现 Validator 接口
在我们的自定义表单控件的情况下,我们希望它确保数量是正的。如果不是,那么表单字段应该被标记为错误,并且对于组件的所有实例都应该始终为
true
。为了实现这个逻辑,我们将让组件实现
Validator
接口。这个接口只包含两个方法:validate
:此方法用于验证表单控件的当前值。每当向父表单报告新值时,将调用此方法。如果没有发现错误,该方法需要返回null
,或者返回一个ValidationErrors
对象,该对象包含正确地向用户显示有意义的错误消息所需的所有细节。registerOnValidatorChange
:这将注册一个回调,允许我们根据需要触发自定义控件的验证。当发出新值时,我们不需要这样做,因为在这种情况下已经触发了验证。只有当影响validate
行为的其他输入发生变化时,我们才需要调用这个方法。现在让我们来看看如何实现这个接口,并做一个组件的最后演示。
我们必须实现的
Validator
的唯一方法是validate
方法:在此实现中,如果值有效,则返回
null
,并返回一个包含有关错误的所有详细信息的ValidationErrors
对象。在我们的组件中,我们不需要实现
registerOnValidatorChange
,因为实现这个方法是可选的。例如,如果我们的组件有可配置的验证规则,依赖于某些组件输入,我们只需要这个方法。如果是这样的话,在其中一个验证输入发生变化时,我们可以根据需要触发一个新的验证。
为了使
Validator
接口正常工作,我们还需要用NG_VALIDATORS
注入令牌注册我们的自定义组件:一个功能齐全的自定义表单控件的示例
有了
ControlValueAccessor
和Validator
这两个接口,我们现在就有了一个功能齐全的自定义表单控件,它既兼容响应式表单,也兼容模板驱动表单,既能设置表单属性的值,又能参与表单验证过程。这是最终代码:
现在让我们在运行时测试这个组件,方法是将它添加到一个带有两个标准验证器的表单中:
正如我们所看到的,我们将该字段设置为比填的,并将最大值设置为100。控件的初始值为60,这是一个有效值。
但是如果我们将值设为110会发生什么呢?然后,表单将变得无效,
my-counter
控件将有一个与之关联的错误。我们可以通过检查
form.controls['count'].errors
属性的值来查看错误:正如我们所看到的,
Validators.max(100)
内置验证器启动并将自定义表单控件标记为错误。但是,如果相反,我们将数量值设置为例如负值 -10 呢?下面是我们控件的
errors
属性:正如我们所看到的,现在验证方法创建了一个
ValidationErrors
对象,然后将其设置为表单控件的错误的一部分。我们现在有了一个功能齐全的自定义表单控件,它兼容模板驱动表单、响应式表单和所有内置验证器。
如何实现嵌套表单组
一个非常常见的表单用例是嵌套表单组,它可以跨多个表单重用。
地址表单就是一个很好的例子,它包含了所有常见的地址字段:
现在假设我们的应用程序有许多需要地址的不同表单。我们不希望在每个表单中重复显示和验证这些字段所需的所有代码。
相反,我们想做的是在
Angular
组件的表单下创建一个可重用的表单部分,然后我们可以将其插入多个表单中,类似于嵌套的可重用子表单。下面是我们如何使用这样一个地址表单组件:
如我们所见,我们希望使我们的
my-address
组件与Angular form
完全兼容,这意味着它应该支持ngModel
,formControl
和formControlName
指令,并能够参与父表单的验证。听起来很熟悉?
实际上,我们所要做的就是实现
ControlValueAccessor
和Validator
接口之前所做的那样。那么这是如何运作的呢?首先,我们需要定义嵌套地址表单组件:
正如我们所看到的,我们嵌套的地址表单本身也是一个
FormGroup
,它也在内部使用Angular form
来收集每个地址字段的值并验证它们的值。表单对象已经包含了关于此子表单的值和有效性状态的所有信息。我们现在可以使用这些信息来快速实现
ControlValueAccessor
和Validator
接口。以下是关于此实现的一些重要注意事项:
form
对象中获取所需的所有信息form
实现writeValue.setValue
,我们通过form.enable()
和form.disable()
实现setDisabledState
。valueChanges
订阅来知道地址form
何时发出了一个新值,并调用onChange
回调来通知父表单。valueChanges
时,我们需要使用OnDestroy
钩子取消订阅,以避免内存泄漏ValidationErrors
对象来实现validate
方法control.hasValidator(Validators.required)
方法来判断父表单是否设置必填验证器如果我们尝试填写地址表单的值,我们将看到它们被报告回父表单,并显示在
address
属性下。在输入地址后,这是包含
my-address
的父表单的value
属性:总结
每个表单控件都链接到一个控件值访问器,该访问器负责表单控件与父表单之间的交互。
这包括所有标准的 HTML 表单控件,如
input
、select
、textarea
等,FormsModule
为此提供了内置的控件值访问器。对于自定义表单控件,我们必须通过实现
ControlValueAccessor
接口来构建我们自己的控件值访问器,如果我们希望控件执行自定义值验证,那么我们需要实现Validator
接口。我们还可以使用相同的技术来实现嵌套表单组(如地址子表单),这些表单组可以跨多个表单重用。
当你有一组输入框需要验证时,那该如何操作,这就需要
FormArray
闪亮登场。有机会我们下次介绍它们。今天就到这里吧,伙计们,玩得开心,祝你好运
谢谢你读到这里。下面是你接下来可以做的一些事情: