Проект в Stackblitz со всеми примерами в конце поста.
Переиспользуемые элементы управления
Проблема
Однажды я писал модуль аутентификации для компании в сфере электронной коммерции. Это кажется просто, но позже я понял: в таком модуле 8 разных страниц:
- Вход.
- Регистрация.
- Сброс пароля.
- Вход через социальные сети.
- Слияние аккаунтов и ещё 3 страницы.
На большинстве из них были одни и те же элементы, одинаковый интерфейс, проверки и сообщения об ошибках.
Рассмотрим поле ввода электронной почты. Вначале оно пустое. Если ввод корректен, указываем на это галочкой, а если нет — показываем сообщение об ошибке. Я подсчитал: в таком простом проекте это встречалось 12 раз!
Решение
Итак, мы хотим создать переиспользуемый элемент управления. Первое, что приходит в голову — сделать его компонентом.
Такой компонент должен:
- Иметь поля с типами text и password.
- Проверять ввод регулярным выражением.
- Показывать результат проверки.
- Сообщать об ошибках.
Начнём с простого компонента с @Input():
export class FirstCustomInputComponent { @Input() type = 'text'; @Input() isRequired: boolean = false; @Input() pattern: string = null; @Input() label: string = null; @Input() placeholder: string; @Input() errorMsg: string; }
И шаблон:
<div class="form-label-group"> <input class="form-control" [type]="type" #input [placeholder]="placeholder" ngModel> <div class="d-flex"> <span class="v" *ngIf="input.valid"> <img src="./assets/images/v.svg"> </span> <label class="mr-auto">{{label}} <span class="required" *ngIf="isRequired">*</span> </label> <span class="error" *ngIf="!input.valid">{{errorMsg}}</span> </div> </div>
Выше мы видим 4 части шаблона:
- Поле ввода. У него будет директива ngModel.
- Знак ✓, если ввод корректен.
- Знак *, если ввод обязателен.
- Сообщение об ошибке, если ввод некорректен.
Проверяем:
<form class="form-signin" (ngSubmit)="onSubmit(f.value)" #f="ngForm"> <div class="text-center mb-4"> <h1 class="h3 mb-3 font-weight-normal">First Try</h1> </div> <app-first-custom-input [placeholder]="'Email'" [isRequired]="true" [errorMsg]="'Please enter your name'" [label] = "'User Email'" [pattern]="'[A-Za-z0-9._%-][email protected][A-Za-z0-9._%-]+\.[a-z]{2,3}'" ngModel name="email"></app-first-custom-input> <button class="btn btn-lg btn-primary btn-block" [disabled]="!f.valid" type="submit">Sign in</button> </form>
Результат, страница First Try в примерах:
В чём ошибка? Мы прикрепили директиву формы туда, где её не должно быть. Angular не знает, что наш компонент — элемент управления.
Решение — интерфейс ControlValueAccessor:
Нам нужен ControlValueAccessor, посредник между API форм и нативными элементами. Он сообщает Angular, что элементу доступны директивы форм. У этого интерфейса 4 метода, 3 из них обязательны:
export interface ControlValueAccessor { writeValue(obj: any): void; registerOnChange(fn: any): void; registerOnTouched(fn: any): void; setDisabledState?(isDisabled: boolean): void; }
Реализуем наш элемент с его помощью:
export class GenericInputComponent implements ControlValueAccessor { @ViewChild('input') input: ElementRef; disabled; @Input() type = 'text'; @Input() isRequired: boolean = false; @Input() pattern: string = null; @Input() label: string = null; @Input() placeholder: string; @Input() errorMsg: string; writeValue(obj: any): void { this.input.nativeElement.value = obj; } registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; } onChange(event) { } onTouched() { } }
Шаблон generic-input.component:
<div class="form-label-group"> <input class="form-control" [type]="type" #input (input)="onChange($event.target.value)" (blur)="onTouched()" [disabled]="disabled" [placeholder]="placeholder"> <div class="d-flex"> <span class="v" *ngIf="isRequired && input.valid && input.touched"> <img src="./assets/images/v.svg"> </span> <label class="mr-auto">{{label}} <span class="required" *ngIf="isRequired">*</span> </label> <span class="error" *ngIf="input && !input.valid && input.touched">{{errorMsg}}</span> </div> </div>
Но этого недостаточно. Мы должны указать токен NG_VALUE_ACCESSOR в метаданных компонента:
@Component({ selector: 'app-generic-input', templateUrl: './generic-input.component.html', styleUrls: ['./generic-input.component.scss'], providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: GenericInputComponent }] }) export class GenericInputComponent implements ControlValueAccessor { //... }
Валидаторы
Мы создали пользовательский элемент управления, а теперь реализуем Validator для проверки ввода:
export interface Validator { validate(c: AbstractControl): ValidationErrors | null; registerOnValidatorChange?(fn: () => void): void; }
И код в GenericComponent:
export class GenericInputComponent implements ControlValueAccessor, Validator { //... validate(c: AbstractControl): ValidationErrors { const validators: ValidatorFn[] = []; if (this.isRequired) { validators.push(Validators.required); } if (this.pattern) { validators.push(Validators.pattern(this.pattern)); } return validators; } }
Не забудьте о токене NG_VALIDATORS:
@Component({ selector: 'app-generic-input', templateUrl: './generic-input.component.html', styleUrls: ['./generic-input.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: GenericInputComponent, multi: true }, { provide: NG_VALIDATORS, useExisting: GenericInputComponent, multi: true } ] })
Попробуем ещё раз:
Пока не видно, корректен ввод в элементе или нет.
Получение ссылки на элемент
Мы хотим показать, верен ли ввод. Но у нас нет экземпляра элемента управления. Может быть, мы можем внедрить зависимость, чтобы получить его? Да, это возможно!
constructor(@Self() public controlDir: NgControl) { this.control.valueAccessor = this; }
Важные замечания:
- Мы внедряем NgControl, родительский для formControlName и ngModel, не связывая его с каким-либо шаблоном или реактивным модулем.
- Декорируем его с помощью @Self(). Это гарантирует, что он не будет перезаписан деревом инжекторов.
- Устанавливаем его valueAccessor. Он должен указывать на GenericComponent.
Обновим шаблон и используем ссылку:
<div class="form-label-group"> <input class="form-control" [type]="type" #input (input)="onChange($event.target.value)" (blur)="onTouched()" [disabled]="disabled" [placeholder]="placeholder"> <div class="d-flex"> <span class="v" *ngIf="(isRequired && controlDir && controlDir.control.valid && controlDir.control.touched)"> <img src="./assets/images/v.svg"> </span> <label class="mr-auto">{{label}} <span class="required" *ngIf="isRequired">*</span> </label> <span class="error" *ngIf="controlDir && !controlDir.control.valid && controlDir.control.touched">{{errorMsg}}</span> </div> </div>
NgControl уже предоставляет NG_VALUE_ACCESSOR и NG_VALIDATOR. Удаляем их, чтобы не возникла циклическая зависимость:
@Component({ selector: 'app-generic-input', templateUrl: './generic-input.component.html', styleUrls: ['./generic-input.component.scss'], providers: [ ] })
Также элементу управления нужны валидаторы:
export class GenericInputComponent implements ControlValueAccessor, Validator, OnInit { constructor(@Self() public controlDir: NgControl) { this.controlDir.valueAccessor = this; } ngOnInit(): void { const control = this.controlDir.control; const validators: ValidatorFn[] = control.validator ? [control.validator] : []; if (this.isRequired) { validators.push(Validators.required); } if (this.pattern) { validators.push(Validators.pattern(this.pattern)); } control.setValidators(validators); control.updateValueAndValidity(); } //...
Страница Login & Register:
Формы — компоненты
Проблема
Представьте безумное: вы хотите использовать одну и ту же форму в нескольких местах. Помните, как дважды приходилось заполнять форму адреса, когда платёжный адрес не совпадал с адресом доставки?
Не хочется снова и снова делать одно и то же. Мы хотим написать только одну форму и переиспользовать её. Переиспользовать? Значит, это компонент!
Снова ControlValueAccessor
Создаём AddressFormComponent:
export class AddressFormComponent implements OnInit { form: FormGroup; constructor(private formBuilder: FormBuilder) { } ngOnInit() { this.form = this.formBuilder.group({ 'firstName': [null, [Validators.required]], 'lastName': [null, [Validators.required]], 'phone': [null, null], 'street': [null, [Validators.required]], 'city': [null, [Validators.required]], 'state': [null], 'zip': [null, [Validators.required]], }); } }
Шаблон:
<form [formGroup]="form"> <div class="form-label-group"> <label>First Name*</label> <input type="text" class="form-control" name="firstName" formControlName="firstName" /> </div> <div class="form-label-group"> <label>Last Name*</label> <input type="text" class="form-control" name="lastName" formControlName="lastName" /> </div> <div class="form-label-group"> <label>Phone</label> <input type="text" class="form-control" name="phone" formControlName="phone" /> </div> <div class="form-label-group"> <label>Street*</label> <input type="text" class="form-control" name="street" formControlName="street" /> </div> <div class="form-label-group"> <label>City*</label> <input type="text" class="form-control" name="city" formControlName="city" /> </div> <div class="form-label-group"> <label>State*</label> <input type="text" class="form-control" name="state" formControlName="state" /> </div> <div class="form-label-group"> <label>Zip*</label> <input type="text" class="form-control" name="zip" formControlName="zip" /> </div> </form>
И опять ControlValueAccessor, но теперь чтобы обернуть всю форму:
export class AddressFormComponent implements OnInit, ControlValueAccessor { form: FormGroup; constructor(private formBuilder: FormBuilder) { } ngOnInit() { this.form = this.formBuilder.group({ 'firstName': [null, [Validators.required]], 'lastName': [null, [Validators.required]], 'phone': [null, null], 'street': [null, [Validators.required]], 'city': [null, [Validators.required]], 'state': [null], 'zip': [null, [Validators.required]], }); } onTouch() { } writeValue(obj: any): void { obj && this.form.setValue(obj, { emitEvent: false }); } registerOnChange(fn: any): void { this.form.valueChanges.subscribe(fn); } registerOnTouched(fn: any): void { this.onTouch = fn; } setDisabledState?(isDisabled: boolean): void { isDisabled ? this.form.disabled : this.form.enabled; } }
Не забудьте о NG_VALUE_ACCESSOR:
@Component({ selector: 'app-address-form', templateUrl: './address-form.component.html', styleUrls: ['./address-form.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: AddressFormComponent, multi: true } ] })
Reusable forms — ControlValueAccessor:
Альтернатива
Утомил ControlValueAccessor? Меня тоже. При переиспользовании всей формы можно внедрить ControlContainer:
@Component({ selector: 'app-address-form', templateUrl: './address-form.component.html', styleUrls: ['./address-form.component.scss'], viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }] })
Запомните: в коде viewProviders, а не providers. Причина в декораторе @Host(). Он используется при внедрении ControlContainer в FormControlName и NgModel. Проверьте директивы FormControlName и NgModel в исходниках.
После предоставления ControlContainer мы можем внедрить его в AddressFormComponent и установить форму адреса равной форме в ControlContainer:
export class AddressFormComponent implements OnInit { @Input() address: Address; form: FormGroup; constructor( private ctrlContainer: FormGroupDirective, private formBuilder: FormBuilder) { } ngOnInit() { this.form = this.ctrlContainer.form; this.form.addControl('addressForm', this.formBuilder.group({ //... })); console.log(this.form); } //... }
Reusable forms — SubForms:
Просто и коротко, но ограниченно
Как только мы предоставили FormGroupDirective или ngModelGroup, то создали связь только с одной реализацией форм (шаблонной или реактивной).
Демо
<figure><iframe width="700" height="376" src="/media/4ce26a2b4ab6df0109c47281ea6e86fc" allowfullscreen=""></iframe></figure>
Итоги
Вот, что мы узнали:
- ControlValueAccessor — мост между нашими компонентами и API формами. Он позволяет создавать настраиваемые, переиспользуемые элементы управления и формы.
- Внедрение зависимости поможет использовать NgControl и его valueAccessor для простого доступа к элементу в шаблоне.
- Можно снова внедрить зависимости, чтобы добавить FormGroupDirective или ngModelGroup и создать подформу.
Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming