Меня зовут Павел, я фронтенд-разработчик Tinkoff.ru. Наша команда занимается разработкой интернет-банка для юридических лиц. Фронтенд наших проектов был реализован с применением AngularJS, с которого мы перешли, частично с использованием Angular Upgrade, на новый Angular (ранее позиционировался как Angular 2).
Наш продукт предназначен для юридических лиц. Такая тематика требует множества форм со сложным поведением. Поля ввода включают в себя не только стандартные, реализованные в браузерах, но и поля с масками (например, для ввода телефона), поля для работы с тегами, ползунки для ввода числовых данных, различные выпадающие списки.
В этой статье мы заглянем «под капот» реализации форм в Angular и разберёмся, как создавать кастомные поля ввода.
Предполагается, что читатель знаком с основами Angular, в частности, со связыванием данных и внедрением зависимостей (ссылки на официальные гайды на английском языке). На русском языке со связыванием данных и основами Angular в целом, включая работу с формами, можно познакомиться здесь. На Хабрахабре уже была статья про внедрение зависимостей в Angular, но нужно учитывать, что написана она была задолго до выхода релизной версии.
Введение в формы
Работая с большим количеством форм, важно иметь мощные, гибкие и удобные инструменты для создания форм и управления ими.
Возможности работы с формами в Angular гораздо шире, чем в AngularJS. Определены два вида форм: шаблонные, то есть управляемые шаблоном (template-driven forms) и реактивные, управляемые моделью (model-driven/reactive forms).
Подробную информацию можно получить в официальном гайде (англ.). Здесь разберём основные моменты, за исключением валидации, которая будет рассмотрена в следующей статье.
Шаблонные формы
В шаблонных формах поведение поля управляется установленными в шаблоне атрибутами. В результате с формой можно взаимодействовать способами, знакомыми из AngularJS.
Чтобы использовать шаблонные формы, нужно импортировать модуль FormsModule:
import {FormsModule} from '@angular/forms';
Директива NgModel из этого модуля делает доступными для полей ввода одностороннее связывание значений через [ngModel]
, двустороннее — через [(ngModel)]
, а также отслеживание изменений через (ngModelChange)
:
<input type="text"
name="name"
[(ngModel)]="name"
(ngModelChange)="countryModelChange($event)" />
Форма задаётся директивой NgForm. Эта директива создаётся, когда мы просто используем тег <form></form>
или атрибут ngForm
внутри нашего шаблона (не забыв подключить FormsModule).
Поля ввода с директивами NgModel, находящиеся внутри формы, будут добавлены в форму и отражены в значении формы.
Директиву NgForm также можно назначить, используя конструкцию #formDir="ngForm"
— таким образом мы создадим локальную переменную шаблона formDir, в которой будет содержаться экземпляр директивы NgForm. Её свойство value, унаследованное от класса AbstractControlDirective, содержит значение формы. Это может быть нужно для получения значения формы (показано в живом примере).
Форму можно структурировать, добавляя группы (которые в значении формы будут представлены объектами) при помощи директивы ngModelGroup:
<div ngModelGroup="address">
<input type="text" name="country" ngModel />
<input type="text" name="city" ngModel />
...
</div>
После назначения директивы NgForm любым способом можно обработать событие отправки по (ngSubmit)
:
<form #formDir="ngForm"
(ngSubmit)="submit($event)">
...
</form>
Реактивные формы
Реактивные формы заслужили своё название за то, что взаимодействие с ними построено на парадигме реактивного программирования.
Структурной единицей реактивной формы является контрол — модель поля ввода или группы полей, наследник базового класса AbstractControl. Контрол одного поля ввода (форм-контрол) представлен классом FormControl.
Компоновать значения полей шаблонной формы можно только в объекты. В реактивной нам доступны также массивы — FormArray. Группы представлены классом FormGroup. И у массивов, и у групп есть свойство controls, в котором контролы организованы в соответствующую структуру данных.
В отличие от шаблонной формы, для создания и управления реактивной не обязательно представлять её в шаблоне, что позволяет легко покрывать такие формы юнит-тестами.
Контролы создаются либо непосредственно через конструкторы, либо при помощи средства FormBuilder.
export class OurComponent implements OnInit {
group: FormGroup;
nameControl: FormControl;
constructor(private formBuilder: FormBuilder) {}
ngOnInit() {
this.nameControl = new FormControl('');
this.group = this.formBuilder.group({
name: this.nameControl,
age: '25',
address: this.formBuilder.group({
country: 'Россия',
city: 'Москва'
}),
phones: this.formBuilder.array([
'1234567',
new FormControl('7654321')
])
});
}
}
Метод this.formBuilder.group принимает объект, ключи которого станут именами контролов. Если значения не являются контролами, то они станут значениями новых форм-контролов, что и обуславливает удобство создания групп через FormBuilder. Если же являются, то будут просто добавлены в группу. Элементы массива в методе this.formBuilder.array обрабатываются таким же образом.
Чтобы связать контрол и поле ввода в шаблоне, нужно передать ссылку на контрол директивам formGroup, formArray, formControl. У этих директив есть «братья», которым достаточно передать строку с именем контрола: formGroupName, formArrayName, formControlName.
Для использования директив реактивных форм следует подключить модуль ReactiveFormsModule. Кстати, он не конфликтует с FormsModule, и директивы из них можно применять вместе.
Корневая директива (в данном случае formGroup) должна обязательно получить ссылку на контрол. Для вложенных контролов или даже групп у нас есть возможность обойтись именами:
<form [formGroup]="personForm">
<input type="text" [formControl]="nameControl" />
<input type="text" formControlName="age" />
<div formGroupName="address">
<input type="text" formControlName="country" />
<input type="text" formControlName="city" />
</div>
</form>
Структуру формы в шаблоне повторять совсем не обязательно. Например, если поле ввода связано с контролом через директиву formControl, ему не требуется быть внутри элемента с директивой formGroup.
Директива formGroup обрабатывает submit и отправляет наружу (ngSubmit)
точно так же, как и ngForm:
<form [formGroup]="group" (ngSubmit)="submit($event)">
...
</form>
Взаимодействие с массивами в шаблоне происходит немного по-другому, нежели с группами. Для отображения массива нам нужно получить для каждого форм-контрола либо его имя, либо ссылку. Количество элементов массива может быть любым, поэтому придётся перебирать его директивой *ngFor
. Напишем геттер для получения массива:
get phonesArrayControl(): FormArray {
return <FormArray>this.group.get('phones');
}
Теперь выведем поля:
<input type="text" *ngFor="let control of phonesArrayControl.controls" [formControl]="control" />
Для массива полей пользователю иногда требуются операции добавления и удаления. У FormArray есть соответствующие методы, из которых мы будем использовать удаление по индексу и вставку в конец массива. Соответствующие кнопки и методы для них можно увидеть в живом примере.
Изменение значения формы — Observable, на который можно подписаться:
this.group.valueChanges.subscribe(value => {
console.log(value);
});
У каждой разновидности контрола предусмотрены методы взаимодействия с ним, как унаследованные от класса AbstractControl, так и уникальные. Подробнее с ними можно познакомиться в описаниях соответствующих классов.
Самостоятельные поля ввода
Поле ввода не обязательно должно быть привязано к форме. Мы можем взаимодействовать с одним полем почти так же, как и с целой формой.
Для уже созданного контрола реактивной формы всё совсем просто. Шаблон:
<input type="text" [formControl]="nameControl" />
В коде нашего компонента можно подписаться на его изменения:
this.nameControl.valueChanges.subscribe(value => {
console.log(value);
});
Поле ввода шаблонной формы тоже самостоятельно:
<input type="text" [(ngModel)]="name" />
В реактивных формах можно делать и так:
<input type="text" [formControl]="nameControl" [(ngModel)]="name" />
Всё связанное с ngModel при этом будет обрабатываться директивой formControl, а директива ngModel задействована не будет: поле ввода с атрибутом formControl не подпадает под селектор последней.
Живой пример взаимодействия с самостоятельными полями
Реактивная природа всех форм
Шаблонные формы — не совсем отдельная сущность. При создании любой шаблонной формы фактически создаётся реактивная. В живом примере шаблонной формы есть работа с экземпляром директивы NgForm. Мы присваиваем его локальной переменной шаблона formDir и обращаемся к свойству value для получения значения. Таким же образом мы можем получить и группу, которую создаёт директива NgForm.
<form #formDir="ngForm"
(ngSubmit)="submit($event)">
...
</form>
...
<pre>{{formDir.form.value | json}}</pre>
Свойство form — экземпляр класса FormGroup. Экземпляры этого же класса создаются при назначении директивы NgModelGroup. Директива NgModel создаёт FormControl.
Таким образом, все директивы, назначаемые полям ввода, как «шаблонные», так и «реактивные», служат вспомогательным механизмом для взаимодействия с основными сущностями форм в Angular — контролами.
При создании реактивной формы мы сами создаём контролы. Если мы работаем с шаблонной формой, эту работу берут на себя директивы. Мы можем получить доступ к контролам, но такой способ взаимодействия с ними не самый удобный. Кроме того, директивный подход шаблонной формы не даёт полного контроля над моделью: если мы возьмём управление структурой модели на себя, возникнут конфликты. Тем не менее, получать данные из контролов при необходимости можно, и это есть в живом примере.
Реактивная форма позволяет создать более сложную структуру данных, чем шаблонная, предоставляет больше способов взаимодействия с ней. Также реактивные формы можно проще и полнее покрывать юнит-тестами, чем шаблонные. Наша команда приняла решение использовать только реактивные формы.
Живой пример реактивной природы шаблонной формы
Взаимодействие формы с полями
В Angular есть набор директив, обеспечивающих работу с большинством стандартных (браузерных) полей ввода. Они назначаются незаметно для разработчика, и именно благодаря им мы можем сразу связать с моделью любой элемент input.
Когда же возможности требуемого поля ввода выходят за рамки стандартных, или логика его работы требует переиспользования, мы можем создать кастомное поле ввода.
Сперва нам нужно познакомиться с особенностями взаимодействия поля ввода и контрола.
Контролы, как было сказано выше, сопоставляются каждому полю ввода явным или неявным образом. Каждый контрол взаимодействует со своим полем через его интерфейс ControlValueAccessor.
ControlValueAccessor
ControlValueAccessor (в этом тексте я буду называть его просто аксессором) — интерфейс, описывающий взаимодействие компонента поля с контролом. При инициализации каждая директива поля ввода (ngModel, formControl или formControlName) получает все зарегистрированные аксессоры. На одном поле ввода их может быть несколько — пользовательский и встроенные в Angular. Пользовательский аксессор имеет приоритет перед встроенными, но он может быть только один.
Для регистрации аксессора используется мультипровайдер с токеном NG_VALUE_ACCESSOR. Его следует добавить в список провайдеров нашего компонента:
@Component({
...
providers: [
...
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomInputField),
multi: true
}
]
})
export class CustomInputField implements ControlValueAccessor {
...
}
В компоненте мы должны реализовать методы registerOnChange, registerOnTouched и writeValue, а также можем реализовать метод setDisabledState.
Методы registerOnChange, registerOnTouched регистрируют колбэки, используемые для отправки данных из поля ввода в контрол. Сами колбэки приходят в методы в качестве аргументов. Чтобы их не потерять, ссылки на колбэки записывают в свойства класса. Инициализация контрола может произойти позже создания поля ввода, поэтому в свойства нужно заранее записать функции-пустышки. Методы registerOnChange и registerOnTouched при вызове должны их перезаписать:
onChange = (value: any) => {};
onTouched = () => {};
registerOnChange(callback: (change: any) => void): void {
this.onChange = callback;
}
registerOnTouched(callback: () => void): void {
this.onTouched = callback;
}
Функция onChange при вызове отправляет в контрол новое значение. Функцию onTouched вызывают, когда поле ввода теряет фокус.
Метод writeValue вызывается контролом при каждом изменении его значения. Основная задача метода — отобразить изменения в поле. Следует учитывать, что значением может быть null или undefined. Если внутри шаблона есть тег нативного поля, для этого используется Renderer (в Angular 4+ — Renderer2):
writeValue(value: any) {
const normalizedValue = value == null ? '' : value;
this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', normalizedValue);
}
Метод setDisabledState вызывается контролом при каждом изменении состояния disabled, поэтому его тоже стоит реализовать.
setDisabledState(isDisabled: boolean) {
this._renderer.setElementProperty(this._elementRef.nativeElement, 'disabled', isDisabled);
}
Вызывается он только реактивной формой: в шаблонных формах для обычных полей ввода используется связывание с атрибутом disabled
. Поэтому, если наш компонент будет использоваться в шаблонной форме, нам нужно дополнительно обрабатывать атрибут disabled.
Таким образом организована работа с полем ввода в директиве DefaultValueAccessor, которая применяется к любым, в том числе к обычным, текстовым полям ввода. Если вы захотите сделать компонент, работающий с нативным полем ввода внутри себя, это необходимый минимум.
В живом примере я создал простейшую реализацию компонента ввода рейтинга без встроенного нативного поля ввода:
Отмечу несколько моментов. Шаблон компонента состоит из одного повторяемого тега:
<span class="star"
*ngFor="let value of values"
[class.star_active]="value <= currentRate"
(click)="setRate(value)">★</span>
Массив values нужен для правильной работы директивы *ngFor
и формируется в зависимости от параметра maxRate
(по умолчанию — 5).
Поскольку компонент не имеет внутреннего поля ввода, значение хранится просто в свойстве класса:
setRate(rate: number) {
if (!this.disabled) {
this.currentRate = rate;
this.onChange(rate);
}
}
writeValue(newValue: number) {
this.currentRate = newValue;
}
Состояние disabled может быть присвоено как шаблонной, так и реактивной формой:
@Input() disabled: boolean;
// ...
setDisabledState(disabled: boolean) {
this.disabled = disabled;
}
Живой пример кастомного поля ввода
Заключение
В следующей статье подробно рассмотрим статусы и валидацию форм и полей, включая кастомные. Если есть вопросы по созданию кастомных полей ввода, можно писать в комментарии или лично в мой Telegram @tmsy0.
Автор: sy0