Меня зовут Павел, я фронтенд-разработчик Tinkoff.ru. Наша команда занимается разработкой интернет-банка для юридических лиц. Фронтенд наших проектов был реализован с применением AngularJS, с которого мы перешли, частично с использованием Angular Upgrade, на новый Angular (ранее позиционировался как Angular 2).
Наш продукт предназначен для юридических лиц. Такая тематика требует множества форм со сложным поведением. Поля ввода включают в себя не только стандартные, реализованные в браузерах, но и поля с масками (например, для ввода телефона), поля для работы с тегами, ползунки для ввода числовых данных, различные выпадающие списки.
В этой статье мы заглянем «под капот» реализации форм в Angular и разберёмся, как создавать кастомные поля ввода.
Предполагается, что читатель знаком с основами Angular, в частности, со связыванием данных и внедрением зависимостей (ссылки на официальные гайды на английском языке). На русском языке со связыванием данных и основами Angular в целом, включая работу с формами, можно познакомиться здесь. На Хабрахабре уже была статья про внедрение зависимостей в Angular, но нужно учитывать, что написана она была задолго до выхода релизной версии.
Работая с большим количеством форм, важно иметь мощные, гибкие и удобные инструменты для создания форм и управления ими.
Возможности работы с формами в Angular гораздо шире, чем в AngularJS. Определены два вида форм: шаблонные, то есть управляемые шаблоном (template-driven forms) и реактивные, управляемые моделью (model-driven/reactive forms).
Подробную информацию можно получить в официальном гайде (англ.). Здесь разберём основные моменты, за исключением валидации, которая будет рассмотрена в следующей статье.
В шаблонных формах поведение поля управляется установленными в шаблоне атрибутами. В результате с формой можно взаимодействовать способами, знакомыми из AngularJS.
Чтобы использовать шаблонные формы, нужно импортировать модуль FormsModule:
Директива NgModel из этого модуля делает доступными для полей ввода одностороннее связывание значений через
Форма задаётся директивой NgForm. Эта директива создаётся, когда мы просто используем тег
Поля ввода с директивами NgModel, находящиеся внутри формы, будут добавлены в форму и отражены в значении формы.
Директиву NgForm также можно назначить, используя конструкцию
Форму можно структурировать, добавляя группы (которые в значении формы будут представлены объектами) при помощи директивы ngModelGroup:
После назначения директивы NgForm любым способом можно обработать событие отправки по
Живой пример шаблонной формы
Реактивные формы заслужили своё название за то, что взаимодействие с ними построено на парадигме реактивного программирования.
Структурной единицей реактивной формы является контрол — модель поля ввода или группы полей, наследник базового класса AbstractControl. Контрол одного поля ввода (форм-контрол) представлен классом FormControl.
Компоновать значения полей шаблонной формы можно только в объекты. В реактивной нам доступны также массивы — FormArray. Группы представлены классом FormGroup. И у массивов, и у групп есть свойство controls, в котором контролы организованы в соответствующую структуру данных.
В отличие от шаблонной формы, для создания и управления реактивной не обязательно представлять её в шаблоне, что позволяет легко покрывать такие формы юнит-тестами.
Контролы создаются либо непосредственно через конструкторы, либо при помощи средства FormBuilder.
Метод this.formBuilder.group принимает объект, ключи которого станут именами контролов. Если значения не являются контролами, то они станут значениями новых форм-контролов, что и обуславливает удобство создания групп через FormBuilder. Если же являются, то будут просто добавлены в группу. Элементы массива в методе this.formBuilder.array обрабатываются таким же образом.
Чтобы связать контрол и поле ввода в шаблоне, нужно передать ссылку на контрол директивам formGroup, formArray, formControl. У этих директив есть «братья», которым достаточно передать строку с именем контрола: formGroupName, formArrayName, formControlName.
Для использования директив реактивных форм следует подключить модуль ReactiveFormsModule. Кстати, он не конфликтует с FormsModule, и директивы из них можно применять вместе.
Корневая директива (в данном случае formGroup) должна обязательно получить ссылку на контрол. Для вложенных контролов или даже групп у нас есть возможность обойтись именами:
Структуру формы в шаблоне повторять совсем не обязательно. Например, если поле ввода связано с контролом через директиву formControl, ему не требуется быть внутри элемента с директивой formGroup.
Директива formGroup обрабатывает submit и отправляет наружу
Взаимодействие с массивами в шаблоне происходит немного по-другому, нежели с группами. Для отображения массива нам нужно получить для каждого форм-контрола либо его имя, либо ссылку. Количество элементов массива может быть любым, поэтому придётся перебирать его директивой
Теперь выведем поля:
Для массива полей пользователю иногда требуются операции добавления и удаления. У FormArray есть соответствующие методы, из которых мы будем использовать удаление по индексу и вставку в конец массива. Соответствующие кнопки и методы для них можно увидеть в живом примере.
Изменение значения формы — Observable, на который можно подписаться:
У каждой разновидности контрола предусмотрены методы взаимодействия с ним, как унаследованные от класса AbstractControl, так и уникальные. Подробнее с ними можно познакомиться в описаниях соответствующих классов.
Живой пример реактивной формы
Поле ввода не обязательно должно быть привязано к форме. Мы можем взаимодействовать с одним полем почти так же, как и с целой формой.
Для уже созданного контрола реактивной формы всё совсем просто. Шаблон:
В коде нашего компонента можно подписаться на его изменения:
Поле ввода шаблонной формы тоже самостоятельно:
В реактивных формах можно делать и так:
Всё связанное с ngModel при этом будет обрабатываться директивой formControl, а директива ngModel задействована не будет: поле ввода с атрибутом formControl не подпадает под селектор последней.
Живой пример взаимодействия с самостоятельными полями
Шаблонные формы — не совсем отдельная сущность. При создании любой шаблонной формы фактически создаётся реактивная. В живом примере шаблонной формы есть работа с экземпляром директивы NgForm. Мы присваиваем его локальной переменной шаблона formDir и обращаемся к свойству value для получения значения. Таким же образом мы можем получить и группу, которую создаёт директива NgForm.
Свойство form — экземпляр класса FormGroup. Экземпляры этого же класса создаются при назначении директивы NgModelGroup. Директива NgModel создаёт FormControl.
Таким образом, все директивы, назначаемые полям ввода, как «шаблонные», так и «реактивные», служат вспомогательным механизмом для взаимодействия с основными сущностями форм в Angular — контролами.
При создании реактивной формы мы сами создаём контролы. Если мы работаем с шаблонной формой, эту работу берут на себя директивы. Мы можем получить доступ к контролам, но такой способ взаимодействия с ними не самый удобный. Кроме того, директивный подход шаблонной формы не даёт полного контроля над моделью: если мы возьмём управление структурой модели на себя, возникнут конфликты. Тем не менее, получать данные из контролов при необходимости можно, и это есть в живом примере.
Реактивная форма позволяет создать более сложную структуру данных, чем шаблонная, предоставляет больше способов взаимодействия с ней. Также реактивные формы можно проще и полнее покрывать юнит-тестами, чем шаблонные. Наша команда приняла решение использовать только реактивные формы.
Живой пример реактивной природы шаблонной формы
В Angular есть набор директив, обеспечивающих работу с большинством стандартных (браузерных) полей ввода. Они назначаются незаметно для разработчика, и именно благодаря им мы можем сразу связать с моделью любой элемент input.
Когда же возможности требуемого поля ввода выходят за рамки стандартных, или логика его работы требует переиспользования, мы можем создать кастомное поле ввода.
Сперва нам нужно познакомиться с особенностями взаимодействия поля ввода и контрола.
Контролы, как было сказано выше, сопоставляются каждому полю ввода явным или неявным образом. Каждый контрол взаимодействует со своим полем через его интерфейс ControlValueAccessor.
ControlValueAccessor (в этом тексте я буду называть его просто аксессором) — интерфейс, описывающий взаимодействие компонента поля с контролом. При инициализации каждая директива поля ввода (ngModel, formControl или formControlName) получает все зарегистрированные аксессоры. На одном поле ввода их может быть несколько — пользовательский и встроенные в Angular. Пользовательский аксессор имеет приоритет перед встроенными, но он может быть только один.
Для регистрации аксессора используется мультипровайдер с токеном NG_VALUE_ACCESSOR. Его следует добавить в список провайдеров нашего компонента:
В компоненте мы должны реализовать методы registerOnChange, registerOnTouched и writeValue, а также можем реализовать метод setDisabledState.
Методы registerOnChange, registerOnTouched регистрируют колбэки, используемые для отправки данных из поля ввода в контрол. Сами колбэки приходят в методы в качестве аргументов. Чтобы их не потерять, ссылки на колбэки записывают в свойства класса. Инициализация контрола может произойти позже создания поля ввода, поэтому в свойства нужно заранее записать функции-пустышки. Методы registerOnChange и registerOnTouched при вызове должны их перезаписать:
Функция onChange при вызове отправляет в контрол новое значение. Функцию onTouched вызывают, когда поле ввода теряет фокус.
Метод writeValue вызывается контролом при каждом изменении его значения. Основная задача метода — отобразить изменения в поле. Следует учитывать, что значением может быть null или undefined. Если внутри шаблона есть тег нативного поля, для этого используется Renderer (в Angular 4+ — Renderer2):
Метод setDisabledState вызывается контролом при каждом изменении состояния disabled, поэтому его тоже стоит реализовать.
Вызывается он только реактивной формой: в шаблонных формах для обычных полей ввода используется связывание с атрибутом
Таким образом организована работа с полем ввода в директиве DefaultValueAccessor, которая применяется к любым, в том числе к обычным, текстовым полям ввода. Если вы захотите сделать компонент, работающий с нативным полем ввода внутри себя, это необходимый минимум.
В живом примере я создал простейшую реализацию компонента ввода рейтинга без встроенного нативного поля ввода:
Отмечу несколько моментов. Шаблон компонента состоит из одного повторяемого тега:
Массив values нужен для правильной работы директивы
Поскольку компонент не имеет внутреннего поля ввода, значение хранится просто в свойстве класса:
Состояние disabled может быть присвоено как шаблонной, так и реактивной формой:
Живой пример кастомного поля ввода
В следующей статье подробно рассмотрим статусы и валидацию форм и полей, включая кастомные. Если есть вопросы по созданию кастомных полей ввода, можно писать в комментарии или лично в мой Telegram @tmsy0.
Наш продукт предназначен для юридических лиц. Такая тематика требует множества форм со сложным поведением. Поля ввода включают в себя не только стандартные, реализованные в браузерах, но и поля с масками (например, для ввода телефона), поля для работы с тегами, ползунки для ввода числовых данных, различные выпадающие списки.
В этой статье мы заглянем «под капот» реализации форм в 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.