Angular:创建自定义表单元素并将表单状态传递给它

我们的Web应用程序中的各种形式通常是使用相同的Brick元素构建的。组件框架可帮助我们摆脱可重复的代码,现在我想考虑这些方法之一。因此,按照Angular的惯例。

技术任务:


  • 您需要创建组件“用于输入SNILS的表单元素”;
  • 该组件应通过掩码格式化输入的值;
  • 组件必须验证输入。
  • 该组件应作为反应形式的一部分;
  • 不完整的表格必须在两次重启之间保持其状态;
  • 加载页面时,一旦编辑后的表单应立即显示错误;
  • 由Angular Material使用。

创建项目并安装依赖项


创建一个测试项目


ng new input-snils

安装第三方库


首先,角材料本身

ng add @angular/material

然后我们需要根据计算校验和规则对 SNILS本身进行屏蔽和检查

安装库:

npm install ngx-mask ru-validation-codes

保存表格数据


准备与localStorage一起使用的服务


表单数据将存储在localStorage中。

您可以立即采用浏览器方法来处理LS,但是在Angular中,习惯于尝试编写通用代码,并使所有外部依赖项都受到控制。它还简化了测试。

因此,当类从DI容器获取所有依赖项时,它将是正确的。记住猫Matroskin-为了从喷油器购买不必要的东西,您必须首先向喷油器出售不必要的东西。

创建一个window.provider.ts提供者



import { InjectionToken } from '@angular/core';

export function getWindow() {
  return window;
}

export const WINDOW = new InjectionToken('Window', {
  providedIn: 'root',
  factory: getWindow,
});

这里发生了什么?Angular的DI注入器会记住令牌并赠送与它们关联的实体。令牌-它可以是InjectionToken对象,字符串或类。这将创建一个新的根级别的InjectionToken并与工厂通信,然后返回浏览器window

现在我们有了一个窗口,让我们创建一个用于使用LocalStorage storage.service.ts的简单服务


@Injectable({
  providedIn: 'root'
})
export class StorageService {
  readonly prefix = 'snils-input__';

  constructor(
    @Inject(WINDOW) private window: Window,
  ) {}

  public set<T>(key: string, data: T): void {
    this.window.localStorage.setItem(this.prefix + key, JSON.stringify(data));
  }

  public get<T>(key: string): T {
    try {
      return JSON.parse(this.window.localStorage.getItem(this.prefix + key));
    } catch (e) { }
  }

  public remove(key: string): void {
    this.window.localStorage.removeItem(this.prefix + key);
  }
}

StorageService从注入器获取一个窗口,并提供自己的包装器以保存和读取数据。我没有将前缀配置为可配置,以免使本文过多地介绍如何使用配置创建模块。

表单持久性模块


我们创建了一个用于保存表单数据的简单服务。

形式持续服务

@Injectable({
  providedIn: 'root'
})
export class FormPersistService {
  private subscriptions: Record<string, Subscription> = {};

  constructor(
    private storageService: StorageService,
  ) { }

  /**
   * @returns restored data if exists
   */
  public registerForm<T>(formName: string, form: AbstractControl): T {
    this.subscriptions[formName]?.unsubscribe();
    this.subscriptions[formName] = this.createFormSubscription(formName, form);

    return this.restoreData(formName, form);
  }

  public unregisterForm(formName: string): void {
    this.storageService.remove(formName);

    this.subscriptions[formName]?.unsubscribe();
    delete this.subscriptions[formName];
  }

  public restoreData<T>(formName: string, form: AbstractControl): T {
    const data = this.storageService.get(formName) as T;
    if (data) {
      form.patchValue(data, { emitEvent: false });
    }

    return data;
  }

  private createFormSubscription(formName: string, form: AbstractControl): Subscription {
    return form.valueChanges.pipe(
      debounceTime(500),
    )
      .subscribe(value => {
        this.storageService.set(formName, value);
      });
  }
}

FormPersistService能够使用传递的字符串键注册表单。注册意味着表格数据每次更改都会保存在LS中。
注册时,还将返回从LS提取的值,以便可以理解该表单已被更早保存。

取消注册(unregisterForm)会终止保存过程并删除LS中的条目。

我想声明性地描述存储功能,而不是每次在组件代码中都这样做。 Angular让您借助指令来创造奇迹,现在就是这种情况。

创建form-persist.directive.ts指令


@Directive({
  selector: 'form[formPersist]', // tslint:disable-line: directive-selector
})
export class FormPersistDirective implements OnInit {
  @Input() formPersist: string;

  constructor(
    private formPersistService: FormPersistService,
    @Self() private formGroup: FormGroupDirective,
  ) { }

  @HostListener('submit')
  onSubmit() {
    this.formPersistService.unregisterForm(this.formPersist);
  }

  ngOnInit() {
    const savedValue = this.formPersistService.registerForm(this.formPersist, this.formGroup.control);
    if (savedValue) {
      this.formGroup.control.markAllAsTouched();
    }
  }
}

当叠加在表单上时,FormPersistDirective从本地注入器中提取另一个指令-FormGroupDirective,并从那里获取反应性表单对象以向FormPersistService注册。

注册密钥必须从模板中获取,表单本身没有固有的唯一标识符。

提交表格时,应取消注册。为此,请使用HostListener监听Submit事件。

该指令还需要传递到可以使用它的组件。优良作法是为每个重用实体创建单独的小模块。

form-persist.module.ts

@NgModule({
  declarations: [FormPersistDirective],
  exports: [FormPersistDirective]
})
export class FormPersistModule { }

SNILS表单的元素


分配了哪些任务?

首先,它必须验证数据。

snilsValidator


Angular允许您将验证器附加到表单控件,是时候制作自己的了。要检查SNILS,我使用外部ru-validation-codes库,验证器将非常简单。

snils.validator.ts

import { checkSnils } from 'ru-validation-codes';

export function snilsValidator(control: AbstractControl): ValidationErrors | null {
  if (control.value === ''  || control.value === null) {
    return null;
  }

  return checkSnils(control.value)
    ? null
    : { snils: 'error' };
}

组件InputSnilsComponent


组件模板包含一个包装的输入字段,这是Angular Material库中的经典版本。

加上一点点,就可以使用外部库ngx-mask将输入掩码叠加在输入上,在这里,输入参数mask-设置mask和dropSpecialCharacters-关闭从值中除去特殊掩码字符的功能。

有关更多详细信息,请参见ngx-mask文档

这是input-snils.component.html组件模板


<mat-form-field appearance="outline">
  <input
    matInput
    autocomplete="snils"
    [formControl]="formControl"
    [mask]="mask"
    [dropSpecialCharacters]="false"
    [placeholder]="placeholder"
    [readonly]="readonly"
    [required]="required"
    [tabIndex]="tabIndex"
  >
  <mat-error [hidden]="formControl | snilsErrors: 'required'">  </mat-error>
  <mat-error [hidden]="formControl | snilsErrors: 'format'">   </mat-error>
  <mat-error [hidden]="formControl | snilsErrors: 'snils'"> </mat-error>
</mat-form-field>

问题是,这个formControl是什么?snilsErrors?这是一个用于显示错误的自定义管道,现在我们将创建它。

snils-errors.pipe.ts

type ErrorType = 'required' | 'format' | 'snils';

@Pipe({
  name: 'snilsErrors',
  pure: false,
})
export class SnilsErrorsPipe implements PipeTransform {

  transform(control: AbstractControl, errorrType: ErrorType): boolean {
    switch (errorrType) {
      case 'required': return !control.hasError('required');
      case 'format': return !control.hasError('Mask error');
      case 'snils': return control.hasError('Mask error') || !control.hasError('snils');
      default: return false;
    }
  }
}

管道不干净,这意味着它将在每次检测到更改时执行。

管道接受错误类型参数,并检测三种类型的错误:

  • “必需”-此错误来自内置的Angular指令RequiredValidator
  • “ Snils”-此错误来自我们的验证器snilsValidator
  • “掩码错误”-来自ngx-mask库的MaskDirective指令的此错误

它返回一个布尔值-是否存在这样的错误。

现在,让我们看一下input-snils.component.ts组件代码
本身

@Component({
  selector: 'app-input-snils',
  templateUrl: './input-snils.component.html',
  styleUrls: ['./input-snils.component.css'],
  encapsulation: ViewEncapsulation.None,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputSnilsComponent),
      multi: true
    },
    {
      provide: NG_VALIDATORS,
      useExisting: forwardRef(() => InputSnilsComponent),
      multi: true,
    },
    {
      provide: STATE_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputSnilsComponent),
    },
  ]
})
export class InputSnilsComponent implements OnInit, ControlValueAccessor, StateValueAccessor, OnDestroy {
  public mask = '000-000-000 00';
  public formControl = new FormControl('', [snilsValidator]);
  private sub = new Subscription();

  @Input() readonly: boolean;
  @Input() placeholder = '';
  @Input() tabIndex = 0;
  @Input() required: boolean;

  private onChange = (value: any) => { };
  private onTouched = () => { };
  registerOnChange = (fn: (value: any) => {}) => this.onChange = fn;
  registerOnTouched = (fn: () => {}) => this.onTouched = fn;


  ngOnInit() {
    this.sub = this.linkForm();
  }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }

  private linkForm(): Subscription {
    return this.formControl.valueChanges.subscribe(value => {
      this.onTouched();
      this.onChange(value);
    });
  }

  writeValue(outsideValue: string): void {
    if (outsideValue) {
      this.onTouched();
    }
    this.formControl.setValue(outsideValue, { emitEvent: false });
  }

  setDisabledState(disabled: boolean) {
    disabled
      ? this.formControl.disable()
      : this.formControl.enable();
  }

  validate(): ValidationErrors | null {
    return this.formControl.errors;
  }

  setPristineState(pristine: boolean) {
    pristine
      ? this.formControl.markAsPristine()
      : this.formControl.markAsDirty();

    this.formControl.updateValueAndValidity({ emitEvent: false });
  }

  setTouchedState(touched: boolean) {
    touched
      ? this.formControl.markAsTouched()
      : this.formControl.markAsUntouched();

    this.formControl.updateValueAndValidity({ emitEvent: false });
  }
}

有很多东西,我将不介绍如何使用ControlValueAccessor,您可以在Angular文档中阅读有关此内容的信息,例如,在这里tyapk.ru/blog/post/angular-custom-form-field-control

这里需要解释什么?

首先,我们使用formControl形式的内部控件,将其附加到其更改,以通过onChange和onTouched方法将值更改发送到顶部。
反之亦然,外部形式的更改通过writeValue和setDisabledState方法传递给我们,并反映在formControl中。

其次,有一个未知的STATE_VALUE_ACCESSOR令牌,一个未知的StateValueAccessor接口以及两个额外的setPristineState和setTouchedState方法。它们将在后面说明。

同时,为组件创建一个个人模块
input-snils.module.ts

@NgModule({
  declarations: [InputSnilsComponent, SnilsErrorsPipe],
  imports: [
    CommonModule,
    MatFormFieldModule,
    MatInputModule,
    NgxMaskModule.forChild(),
    ReactiveFormsModule,
  ],
  exports: [InputSnilsComponent],
})
export class InputSnilsModule { }

将状态传递给项目


使用ControlValueAccessor时,存在以下细微差别:

反应形式具有触摸状态和原始状态(以下简称为“状态”)。

  • pristine最初为true,从模板更改控制值后变为false
  • 当控件失去焦点时,最初变为false,然后变为true

也可以强制设置它们,但这不会影响ControlValueAccessor内部的控件,因为我们的组件是formControl。

而且只有在触摸当前控件时才会出现垫错误错误。我们要求还原的表单立即显示验证错误,因此,如果已从localStorage读取表单值,则FormPersistDirective将执行markAllAsTouched。但是不会显示垫错误,因为它们在我们的ControlValueAccessor组件内部,它们依赖于formControl控件,并且在此独立控件上,触摸状态仍然为false。

我们需要一种引发这些状态的机制。为此,您可以创建自己的ControlValueAccessor类似物,我们将其称为StateValueAccessor。

首先,您需要创建一个令牌和接口。

状态值访问器。令牌

export const STATE_VALUE_ACCESSOR = new InjectionToken<StateValueAccessor>('STATE_VALUE_ACCESSOR');


状态值访问器.interface.ts
export interface StateValueAccessor {
  setTouchedState?(touched: boolean): void;
  setPristineState?(pristine: boolean): void;
}

该接口描述了实现它的类具有(可选)两个指定方法的要求。这些方法在InputSnilsComponent中实现,并在内部控件formControl上强制这些状态。

然后,您需要一个指令来绑定NgControl和实现StateValueAccessor的组件。查明表单状态变化的时刻是不可能的,但是我们知道,只要表单发生变化,Angular就会将组件标记为等待更改检测周期。被测试的组件及其后代执行ngDoCheck生命周期挂钩,我们的指令将使用该挂钩。

FormStatuses指令


创建指令
form-statuses.directive.ts

const noop: (v?: boolean) => void = () => { };

@Directive({
  selector: '[formControlName],[ngModel],[formControl]' // tslint:disable-line: directive-selector
})
export class FormStatusesDirective implements DoCheck, OnInit {
  private setSVATouched = noop;
  private setSVAPristine = noop;

  constructor(
    @Self() private control: NgControl,
    @Self() @Optional()  @Inject(STATE_VALUE_ACCESSOR) private stateValueAccessor: StateValueAccessor,
  ) { }

  ngOnInit() {
    if (this.stateValueAccessor?.setTouchedState) {
      this.setSVATouched = wrapIfChanges(touched => this.stateValueAccessor.setTouchedState(touched));
    }

    if (this.stateValueAccessor?.setPristineState) {
      this.setSVAPristine = wrapIfChanges(pristine => this.stateValueAccessor.setPristineState(pristine));
    }
  }

  ngDoCheck() {
    this.setSVAPristine(this.control.pristine);
    this.setSVATouched(this.control.touched);
  }
}

FormStatusesDirective叠加在所有可能的控件上,并检查StateValueAccessor的存在。为此,从注入器请求对STATE_VALUE_ACCESSOR令牌的可选依赖关系,实现StateValueAccessor的组件应该已经检查了依赖关系。

如果令牌没有发现任何东西,那么什么也不会发生,则setSVATouched和setSVAPristine方法将只是空函数。

如果找到了StateValueAccessor,则将为每个检测到的状态更改调用其setTouchedState和setPristineState方法。

仍然需要为该指令提供用于导出form-statuses.module.ts的模块


@NgModule({
  declarations: [FormStatusesDirective],
  exports: [FormStatusesDirective]
})
export class FormStatusesModule { }

主页


现在,您需要创建表单本身。为了避免大惊小怪,请将其放在AppComponent的主页上。当然,在实际应用中,最好为表单创建一个单独的组件。App.component.html
模板


<section class="form-wrapper">
  <form
    class="form"
    [formGroup]="form"
    formPersist="inputSnils"
  >
    <app-input-snils
      class="input-snils"
      formControlName="snils"
      [required]="true"
    ></app-input-snils>

    <button
      class="ready-button"
      mat-raised-button
      [disabled]="form.invalid"
      type="submit"
    >
      Submit
    </button>
  </form>
</section>

FormPersistDirective指令挂在表单上,​​Angular通过表单[formPersist]选择器了解它。

模板需要提供变量,让我们执行
app.component.ts

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  public form = new FormGroup({
    snils: new FormControl('', [Validators.required])
  });
}

带有形式的组件代码非常简单,并且不包含任何多余的内容。

看起来像这样:


源代码可以在GitHub
获取stackblitz 的演示stackblitz上
的代码略有不同,因为那里的打字稿版本尚不支持elvis运算符。
此外,本文中仍然存在一些细微差别,如果有人需要,我将对其进行补充。

All Articles