You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular自定义表单控件(必填验证)失效问题求助

问题描述

刚接触Angular自定义表单控件,在使用mat-stepper时遇到必填字段验证失效的问题。制作了可复用的地址模板,已在HTML和TS代码中设置必填属性,但点击next按钮仍能跳转到下一步,求解决方案。


相关代码

地址类模型

export declare class Address {
    unitNumber: string;
    streetName: string;
    suburb: string;
    city: string;
    province: string;
    postalCode: string;
}

地址组件TS代码

import { Component, ElementRef, HostBinding, Inject,
         Input, OnDestroy, Optional, Self } from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { Address, Helper, SelectOption } from '@trade-up/common';
import { Subject } from 'rxjs';
import { MatFormField, MatFormFieldControl, MAT_FORM_FIELD } from '@angular/material/form-field';
import { coerceBooleanProperty } from '@angular/cdk/coercion';

@Component({
  selector: 'app-address-form',
  templateUrl: './address-form.component.html',
  styleUrls: ['./address-form.component.scss'],
  providers: [
    {provide: MatFormFieldControl, useExisting: AddressFormComponent}
  ]
})

export class AddressFormComponent implements OnDestroy, ControlValueAccessor, MatFormFieldControl<Address> {
  @HostBinding('attr.id')
  externalId: string;

  @HostBinding('class.floating')
  get shouldLabelFloat(): boolean {
    return this.focused || !this.empty;
  }

  @HostBinding('class.invalid')
  get valid(): boolean {
    return this.touched;
  }

  @Input()
  set id(value: string) {
    this._ID = value;
    this.externalId = null;
  }

  get id(): string {
    return this._ID;
  }

  provinces: SelectOption[] = Helper.provinces;
  address: Address;
  stateChanges = new Subject<void>();
  /*tslint:disable-next-line*/
  private _ID: string;
  /*tslint:disable-next-line*/
  private _placeholder: string;
  /*tslint:disable-next-line*/
  private _required: boolean;
  /*tslint:disable-next-line*/
  private _disabled: boolean;
  focused: boolean;
  errorState: boolean;
  controlType?: string;
  autofilled?: boolean;
  userAriaDescribedBy?: string;
  touched: boolean;

  get empty(): boolean {
    return !this.address.unitNumber && !this.address.streetName && !this.address.suburb
    && !this.address.province && !this.address.city && !this.address.postalCode;
  }

  @Input()
  get placeholder(): string {
   return this._placeholder;
  }

  set placeholder(plh) {
    this._placeholder = plh;
    this.stateChanges.next();
   }

  @Input()
  get required(): boolean {
    return this._required;
  }
  set required(req) {
    this._required = coerceBooleanProperty(req);
    this.stateChanges.next();
  }

  @Input()
  get disabled(): boolean {
    return this._disabled;
  }
  set disabled(dis) {
    this._disabled = coerceBooleanProperty(dis);
    this.stateChanges.next();
  }

  onChange = () => {};
  onTouched = () => {};

  registerOnChange(fn: any): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn;
  }

  writeValue(add: Address): void {
    this.address = add;
  }

  get value(): Address {
    return this.address;
  }
  set value(add: Address) {
    this.address = add;
    this.stateChanges.next();
  }

  setDescribedByIds(ids: string[]): void {
    this.userAriaDescribedBy = ids.join(' ');
  }

  onContainerClick(): void {
  }

  ngOnDestroy(): void {
    this.stateChanges.complete();
  }

  /*tslint:disable-next-line*/
  constructor(@Optional() @Self() public ngControl: NgControl, @Optional() @Inject(MAT_FORM_FIELD) public _formField: MatFormField,
              /*tslint:disable-next-line*/
              private _elementRef: ElementRef<HTMLElement>) {
  if (ngControl !== null) {
  ngControl.valueAccessor = this;
  }

  this.address = new Address();
  }
}

地址组件HTML代码

<div role="group" class="input-container"
    [class.invalid]="valid"
    [attr.aria-labelledby]="_formField?.getLabelId()">

    <mat-form-field>
    <mat-label>Unit Number</mat-label>
      <input matInput
             [(ngModel)]="address.unitNumber"
             [class.invalid]="valid"
             type="text"
             maxlength="5"
             required>
    </mat-form-field>

    <br>

    <mat-form-field>
      <input matInput
             [(ngModel)]="address.streetName"
             [class.invalid]="valid"
             type="text"
             placeholder="Street Name"
             maxLength="45"
             required>
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
      <input matInput
             [(ngModel)]="address.suburb"
             [class.invalid]="valid"
             type="text"
             placeholder="Suburb"
             maxLength="45"
             required>
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
        <mat-label>Province</mat-label>
        <mat-select placeholder="Province"
                    [(ngModel)]="address.province"
                    [class.invalid]="valid"
                    required>
            <mat-option *ngFor="let province of provinces" [value]="province.value">
            {{province.viewValue}}
            </mat-option>
        </mat-select>
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
      <input matInput
             type="text"
             [(ngModel)]="address.city"
             [class.invalid]="valid"
             placeholder="City"
             maxLength="45"
             required>
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
      <input matInput
             type="text"
             [(ngModel)]="address.postalCode"
             [class.invalid]="valid"
             placeholder="Postal Code"
             maxlength="4"
             pattern="[0-9]*"
             required>
    </mat-form-field>
</div>

地址收集组件TS代码

this.homeCollectionForm = this.formBuilder.group({
  homeAddress: [new Address(), Validators.required],
});

地址收集组件HTML代码

<mat-horizontal-stepper linear="true">
 <mat-step [stepControl]="homeCollectionForm">
  <form [formGroup]="homeCollectionForm">
    <mat-card>
      <mat-card-content>

          <mat-form-field class="mat-form-field-center">
            <app-address-form formControlName="homeAddress" required></app-address-form>
          </mat-form-field>

          <button
            mat-raised-button
            matStepperNext
            color="warn">
            Next
          </button>

      </mat-card-content>
    </mat-card>
  </form>
 </mat-step>
</mat-horizontal-stepper>

解决方案

问题核心在于自定义控件未正确实现验证逻辑,父表单无法感知内部字段的有效性。以下是具体修复步骤:

1. 给自定义控件添加验证接口实现

修改地址组件TS,实现Validator接口,添加内部字段的验证逻辑:

// 补充导入
import { ControlValueAccessor, NgControl, Validator, ValidationErrors, AbstractControl } from '@angular/forms';

// 实现Validator接口
export class AddressFormComponent implements OnDestroy, ControlValueAccessor, MatFormFieldControl<Address>, Validator {
  // ... 原有代码 ...

  // 实现验证方法,检查所有地址字段是否完整
  validate(control: AbstractControl): ValidationErrors | null {
    const addr = control.value as Address;
    const errors: ValidationErrors = {};

    // 检查必填字段
    if (!addr.unitNumber) errors['unitNumberRequired'] = true;
    if (!addr.streetName) errors['streetNameRequired'] = true;
    if (!addr.suburb) errors['suburbRequired'] = true;
    if (!addr.province) errors['provinceRequired'] = true;
    if (!addr.city) errors['cityRequired'] = true;
    if (!addr.postalCode) errors['postalCodeRequired'] = true;
    // 检查邮编格式
    else if (!/^[0-9]{4}$/.test(addr.postalCode)) errors['postalCodeInvalid'] = true;

    return Object.keys(errors).length > 0 ? errors : null;
  }

  // 更新验证状态并通知表单
  private updateValidation() {
    if (this.ngControl?.control) {
      this.ngControl.control.updateValueAndValidity();
      this.errorState = !!this.ngControl.errors;
      this.stateChanges.next();
    }
  }

  // 修正value setter,触发值变更和验证
  set value(add: Address) {
    this.address = add || new Address();
    this.onChange(this.address);
    this.updateValidation();
    this.stateChanges.next();
  }

  // 处理内部输入变化的方法
  onInputChange() {
    this.onChange(this.address);
    this.updateValidation();
  }

  // 修正valid getter逻辑,正确标记无效状态
  @HostBinding('class.invalid')
  get valid(): boolean {
    return this.touched && this.errorState;
  }

  // 标记控件为已触摸
  markAsTouched() {
    if (!this.touched) {
      this.touched = true;
      this.onTouched();
      this.updateValidation();
      this.stateChanges.next();
    }
  }

  // 实现Validator接口的可选方法
  registerOnValidatorChange?(fn: () => void): void {}

  // ... 原有代码 ...
}

2. 修改组件HTML,绑定输入事件和触摸事件

给每个输入/选择框添加值变更事件和触摸事件,触发验证和状态更新:

<div role="group" class="input-container"
    [class.invalid]="valid"
    [attr.aria-labelledby]="_formField?.getLabelId()">

    <mat-form-field>
    <mat-label>Unit Number</mat-label>
      <input matInput
             [(ngModel)]="address.unitNumber"
             [class.invalid]="touched && !address.unitNumber"
             type="text"
             maxlength="5"
             required
             (ngModelChange)="onInputChange()"
             (blur)="markAsTouched()">
    </mat-form-field>

    <br>

    <mat-form-field>
      <input matInput
             [(ngModel)]="address.streetName"
             [class.invalid]="touched && !address.streetName"
             type="text"
             placeholder="Street Name"
             maxLength="45"
             required
             (ngModelChange)="onInputChange()"
             (blur)="markAsTouched()">
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
      <input matInput
             [(ngModel)]="address.suburb"
             [class.invalid]="touched && !address.suburb"
             type="text"
             placeholder="Suburb"
             maxLength="45"
             required
             (ngModelChange)="onInputChange()"
             (blur)="markAsTouched()">
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
        <mat-label>Province</mat-label>
        <mat-select placeholder="Province"
                    [(ngModel)]="address.province"
                    [class.invalid]="touched && !address.province"
                    required
                    (selectionChange)="onInputChange()"
                    (blur)="markAsTouched()">
            <mat-option *ngFor="let province of provinces" [value]="province.value">
            {{province.viewValue}}
            </mat-option>
        </mat-select>
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
      <input matInput
             type="text"
             [(ngModel)]="address.city"
             [class.invalid]="touched && !address.city"
             placeholder="City"
             maxLength="45"
             required
             (ngModelChange)="onInputChange()"
             (blur)="markAsTouched()">
    </mat-form-field>
    
    <br>
    
    <mat-form-field>
      <input matInput
             type="text"
             [(ngModel)]="address.postalCode"
             [class.invalid]="touched && (!address.postalCode || !/^[0-9]{4}$/.test(address.postalCode))"
             placeholder="Postal Code"
             maxlength="4"
             pattern="[0-9]*"
             required
             (ngModelChange)="onInputChange()"
             (blur)="markAsTouched()">
    </mat-form-field>
</div>

3. 初始化触摸状态

在地址组件构造函数中添加touched初始化:

constructor(...) {
  // ... 原有代码 ...
  this.touched = false;
}

修复说明
  1. 原代码中父表单的Validators.required仅检查homeAddress是否为null/undefined,但new Address()创建的是带空字符串属性的对象,因此验证会通过。
  2. 自定义控件内部的required是原生HTML验证,不会同步到Angular表单控件状态,必须通过实现Validator接口将内部字段的有效性传递给父表单。
  3. 通过绑定输入事件和触摸事件,确保控件值变化或失去焦点时,及时更新验证状态并通知父表单,最终让mat-stepper的stepControl能正确识别表单有效性,阻止next按钮跳转。

内容的提问来源于stack exchange,提问作者Nick

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.12 12:55:21