Проект в 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._%-]+@[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
и создать подформу.
Читайте также:
- Веселимся с Angular и трансформаторами в TypeScript
- Использование Angular Elements с расширением Chrome
- Динамические заголовки страницы в Angular
А еще вы можете проверить ваши знания:
Перевод статьи Eliran Eliassy: Reducing the forms boilerplate — make your Angular forms reusable