如何结合Angular Material、响应式表单、ControlValueAccessor与errorStateMatcher实现自定义表单控件
Angular Material 自定义表单控件最优实现方案
原代码存在的核心问题
- 结构错误:父组件的TS文件错误复用了自定义控件的组件定义,没有实现自身的FormGroup初始化逻辑
- 性能冗余:通过
ngDoCheck反复调用校验方法会触发无意义的变更检测循环,拉低页面运行效率 - 校验冲突:手动给父级传入的FormControl设置错误时,会覆盖父组件配置的原有校验规则,无法兼容父级传入的自定义Validators
- 规范缺失:没有正确实现
MatFormFieldControl接口要求的属性,会导致Material表单的错误展示、悬浮标签、禁用态切换等原生功能失效 - 绑定错位:内嵌input控件的变更没有正确同步到父级表单的ControlValueAccessor通道,会出现值更新不触发父级表单状态变化的问题
优化后可直接使用的完整实现
自定义控件TS代码
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { Component, ViewChild, HostBinding, Input, ChangeDetectionStrategy, Optional, Self, OnDestroy } from "@angular/core"; import { ControlValueAccessor, NgControl, FormControl, ValidatorFn, AbstractControl, ValidationErrors } from "@angular/forms"; import { MatFormFieldControl, ErrorStateMatcher, MatInput } from "@angular/material"; import { Subject } from "rxjs"; // 默认错误状态匹配规则 export class DefaultErrorStateMatcher implements ErrorStateMatcher { isErrorState(control: FormControl | null, formSubmitted: boolean): boolean { return !!(control && control.invalid && (control.dirty || control.touched || formSubmitted)); } } @Component({ selector: "custom-input", templateUrl: "./custom-input.component.html", providers: [ { provide: MatFormFieldControl, useExisting: CustomInputComponent } ], changeDetection: ChangeDetectionStrategy.OnPush }) export class CustomInputComponent implements ControlValueAccessor, MatFormFieldControl<any>, OnDestroy { static nextId = 0; stateChanges = new Subject<void>(); @HostBinding() id = `custom-input-${CustomInputComponent.nextId++}`; @HostBinding("attr.aria-describedby") describedBy = ""; @ViewChild("input", { static: false }) inputRef: MatInput; // MatFormFieldControl 接口必填属性 @Input() placeholder: string; @Input() label: string; focused = false; touched = false; controlType = "custom-input"; // 自定义控件内部专属校验逻辑,作为内置 Validator 挂载 private internalValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { const value = control.value; if (!value) return null; // 内部校验:最小长度3位 if (value.length < 3) { return { minLength: { requiredLength: 3, actualLength: value.length } }; } return null; }; @Input() get value(): any { return this._value; } set value(val: any) { this._value = val; this.onChange(val); this.stateChanges.next(); } private _value: any = null; @Input() get required(): boolean { return this._required; } set required(value: boolean) { this._required = coerceBooleanProperty(value); this.stateChanges.next(); } private _required = false; @Input() get disabled(): boolean { return this._disabled; } set disabled(value: boolean) { this._disabled = coerceBooleanProperty(value); this.stateChanges.next(); } private _disabled = false; get errorState(): boolean { return this.errorMatcher.isErrorState(this.ngControl.control as FormControl, false); } onChange: (value: any) => void = () => {}; onTouched: () => void = () => {}; constructor( @Optional() @Self() public ngControl: NgControl, private errorMatcher: ErrorStateMatcher = new DefaultErrorStateMatcher() ) { // 绑定自身为父级表单的值访问器 if (this.ngControl) { this.ngControl.valueAccessor = this; } } ngOnInit() { // 将内部校验逻辑合并到父级传入的FormControl上,不覆盖原有父级校验 const existingValidators = this.ngControl.control.validator; this.ngControl.control.setValidators([existingValidators, this.internalValidator]); this.ngControl.control.updateValueAndValidity(); // 订阅控件状态变化自动更新错误文案 this.ngControl.control.statusChanges.subscribe(() => { this.updateErrorMessage(); }); } // ControlValueAccessor 标准实现 writeValue(obj: any): void { this._value = obj; } registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } // 自动匹配错误文案,兼容父级自定义校验 updateErrorMessage() { const control = this.ngControl.control; if (control.hasError("required")) return "该字段为必填项"; if (control.hasError("minlength")) return "输入长度至少为3个字符"; if (control.errors) { const firstErrorKey = Object.keys(control.errors)[0]; return control.errors[firstErrorKey]?.message || "输入内容不合法"; } return ""; } ngOnDestroy() { this.stateChanges.complete(); } }
自定义控件HTML模板
<mat-form-field appearance="outline" [floatLabel]="'always'" class="example-full-width"> <mat-label>{{label}}</mat-label> <input matInput [id]="id" #input [value]="value" (input)="value = $event.target.value" (focus)="focused = true" (blur)="onTouched(); focused = false" [disabled]="disabled" [placeholder]="placeholder" /> <mat-error>{{errorState ? updateErrorMessage() : ''}}</mat-error> </mat-form-field>
核心优化点说明
- 校验全兼容:将自定义控件内部的专属校验作为
ValidatorFn合并到父级传入的FormControl校验链中,不覆盖父组件配置的任何规则,父级和内部的校验错误可以同时触发、同步展示 - 性能优化:删除无意义的
ngDoCheck循环调用,改用Rxjs订阅statusChanges事件触发错误文案更新,避免不必要的变更检测 - Material规范对齐:完整实现
MatFormFieldControl接口要求的所有属性,保证自定义控件和原生MatInput的交互体验完全一致 - 错误状态统一:复用可注入的
ErrorStateMatcher判断错误展示时机,不再手动维护touched/dirty状态 - 耦合降低:内部input的输入事件直接走
ControlValueAccessor的通道同步值,不再通过嵌套引用反向获取FormControl,彻底解耦控件和父级表单的依赖
父组件使用方式
无需额外适配,直接用原有方式引入即可:
<form [formGroup]="myForm" (submit)="submitForm()"> <custom-input placeholder="Favorite Food" label="Food" formControlName="food" [required]="true" ></custom-input> <button>Submit</button> </form>
内容的提问来源于stack exchange,提问作者Judson Terrell
相关产品推荐
相关产品推荐

