Angular 18自定义输入组件FormControl异常行为排查求助
问题
自定义输入组件的抽象类BaseFormInput中,validate方法内执行console.log(control, control.errors)时出现异常:展开打印的control对象后,errors属性为null(符合控件已输入值且验证通过的预期),但直接打印的control.errors却输出{ required: true },与控件实际有效状态不符。
抽象类代码
// Abstract class export abstract class BaseFormInput<T> implements ControlValueAccessor, Validator, AfterViewInit, OnDestroy { @Input() label: string @Output() onChange: EventEmitter<T> = new EventEmitter<T>() private changeInternal: (obj: T) => void private changeSub: Subscription private disabled$ = new BehaviorSubject(false) private required$ = new BehaviorSubject(false) public input = new FormControl(null) ngOnDestroy() { this.changeSub.unsubscribe() } ngAfterViewInit() { this.changeSub = this.input.valueChanges.subscribe(v => { if (!this.disabled$.getValue()) { this.onChange.emit(v) this.changeInternal(v) } }) } writeValue = (obj: T) => this.input.setValue(obj) registerOnChange = (fn: (obj: T) => void) => this.changeInternal = fn registerOnTouched = (_fn: (obj: any) => void) => {} setDisabledState = (isDisabled: boolean) => this.disabled$.next(isDisabled) validate(control: AbstractControl): ValidationErrors { this.required$.next(control.hasValidator(Validators.required)) // THIS LINE HAS WEIRD BEHAVIOR console.log(control, control.errors) return null } public get isDisabled$(){ return this.disabled$.asObservable() } public get isRequired$(){ return this.required$.asObservable() } }
输入组件实现
@Component({ selector: "ec-input-text", template: `<div class="form-control"> <label *ngIf="label"> {{ label }} <span *ngIf="isRequired$ | async">*</span> </label> <input *ngIf="type !== 'textarea'" [type]="type" [formControl]="input" [attr.disabled]="isDisabled$ | async" /> <textarea *ngIf="type === 'textarea'" [formControl]="input" [attr.disabled]="isDisabled$ | async"></textarea> <ng-template></ng-template> </div>`, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => InputTextComponent), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef(() => InputTextComponent), multi: true } ] }) export class InputTextComponent extends BaseFormInput<string> { @Input() type: "text" | "password" | "email" | "textarea" = "text" @Input() maxLength: number }
注册组件代码
HTML
<form [formGroup]="form"> <ec-input-text label="First name" formControlName="firstName" /> <ec-input-text label="Last name" formControlName="lastName" /> <ec-input-text label="E-mail" formControlName="email" type="email" /> <ec-input-text label="Password" formControlName="password" type="password" /> </form>
TypeScript
public form = new FormGroup({ firstName: new FormControl(null, [Validators.required, Validators.maxLength(50)]), lastName: new FormControl(null, [Validators.required, Validators.maxLength(50)]), email: new FormControl(null, [Validators.required, Validators.maxLength(100)]), password: new FormControl(null, Validators.required) })
原因分析
- 控制台打印的引用特性:浏览器控制台打印引用类型(如
AbstractControl)时,展开查看的是当前最新状态,而直接打印的control.errors是代码执行那一刻的快照。也就是说,执行console.log(control, control.errors)时,control.errors当时确实为{ required: true },后续验证流程完成后errors变为null,此时展开control对象会读取最新值,造成视觉矛盾。 - 双FormControl状态冲突:组件同时维护内部
inputFormControl和外部FormControl(注册组件中的firstName等),尽管通过ControlValueAccessor做了同步,但验证触发时机与状态更新顺序不匹配,导致打印时外部控件的errors尚未更新到最新状态。
解决方案
1. 准确打印即时状态
如果仅用于调试,可通过JSON.stringify将对象转为字符串,避免引用类型的实时更新,获取打印那一刻的真实值:
// 替换原console.log语句 console.log(JSON.stringify(control), control.errors)
2. 移除内部FormControl,统一状态管理
核心问题是双FormControl导致的状态不同步,正确做法是去掉内部input FormControl,完全通过ControlValueAccessor接口与外部控件同步状态:
修改抽象类BaseFormInput
export abstract class BaseFormInput<T> implements ControlValueAccessor, Validator, OnDestroy { @Input() label: string @Output() onChange: EventEmitter<T> = new EventEmitter<T>() private changeInternal: (obj: T) => void private touchedInternal: () => void private disabled$ = new BehaviorSubject(false) private required$ = new BehaviorSubject(false) // 通过ViewChild获取原生输入元素 @ViewChild('inputElement') inputElement: ElementRef<HTMLInputElement | HTMLTextAreaElement> ngOnDestroy() { this.disabled$.complete() this.required$.complete() } writeValue(obj: T): void { if (this.inputElement) { (this.inputElement.nativeElement as any).value = obj ?? '' } } registerOnChange(fn: (obj: T) => void): void { this.changeInternal = fn } registerOnTouched(fn: () => void): void { this.touchedInternal = fn } setDisabledState(isDisabled: boolean): void { this.disabled$.next(isDisabled) if (this.inputElement) { this.inputElement.nativeElement.disabled = isDisabled } } // 输入变化时同步到外部控件 onInputChange(value: T): void { this.changeInternal(value) this.onChange.emit(value) } // 失去焦点时标记控件为 touched onInputBlur(): void { this.touchedInternal() } validate(control: AbstractControl): ValidationErrors { this.required$.next(control.hasValidator(Validators.required)) // 此时打印的是外部控件的真实即时状态 console.log(JSON.stringify(control), control.errors) return null } public get isDisabled$(){ return this.disabled$.asObservable() } public get isRequired$(){ return this.required$.asObservable() } }
修改InputTextComponent模板与类
@Component({ selector: "ec-input-text", template: `<div class="form-control"> <label *ngIf="label"> {{ label }} <span *ngIf="isRequired$ | async">*</span> </label> <input #inputElement *ngIf="type !== 'textarea'" [type]="type" [value]="value" [disabled]="isDisabled$ | async" (input)="onInputChange($event.target.value)" (blur)="onInputBlur()" /> <textarea #inputElement *ngIf="type === 'textarea'" [value]="value" [disabled]="isDisabled$ | async" (input)="onInputChange($event.target.value)" (blur)="onInputBlur()" ></textarea> </div>`, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => InputTextComponent), multi: true }, { provide: NG_VALIDATORS, useExisting: forwardRef(() => InputTextComponent), multi: true } ] }) export class InputTextComponent extends BaseFormInput<string> { @Input() type: "text" | "password" | "email" | "textarea" = "text" @Input() maxLength: number }
修改后组件不再维护内部FormControl,完全通过ControlValueAccessor与外部FormControl同步状态,验证时的control状态即为最新值,打印结果与实际状态一致。
内容的提问来源于stack exchange,提问作者Foreigner
相关产品推荐
相关产品推荐

