- PVSM.RU - https://www.pvsm.ru -
Меня зовут Павел, я фронтенд-разработчик Tinkoff.ru. Наша команда занимается разработкой интернет-банка для юридических лиц [1]. Фронтенд наших проектов был реализован с применением AngularJS, с которого мы перешли, частично с использованием Angular Upgrade [2], на новый Angular (ранее позиционировался как Angular 2).
Наш продукт предназначен для юридических лиц. Такая тематика требует множества форм со сложным поведением. Поля ввода включают в себя не только стандартные, реализованные в браузерах, но и поля с масками (например, для ввода телефона), поля для работы с тегами, ползунки для ввода числовых данных, различные выпадающие списки.
В этой статье мы заглянем «под капот» реализации форм в Angular и разберёмся, как создавать кастомные поля ввода.
Предполагается, что читатель знаком с основами Angular, в частности, со связыванием данных [3] и внедрением зависимостей [4] (ссылки на официальные гайды на английском языке). На русском языке со связыванием данных и основами Angular в целом, включая работу с формами, можно познакомиться здесь [5]. На Хабрахабре уже была статья [6] про внедрение зависимостей в Angular, но нужно учитывать, что написана она была задолго до выхода релизной версии.
Работая с большим количеством форм, важно иметь мощные, гибкие и удобные инструменты для создания форм и управления ими.
Возможности работы с формами в Angular гораздо шире, чем в AngularJS. Определены два вида форм: шаблонные, то есть управляемые шаблоном (template-driven forms) и реактивные, управляемые моделью (model-driven/reactive forms).
Подробную информацию можно получить в официальном гайде [7] (англ.). Здесь разберём основные моменты, за исключением валидации, которая будет рассмотрена в следующей статье.
В шаблонных формах поведение поля управляется установленными в шаблоне атрибутами. В результате с формой можно взаимодействовать способами, знакомыми из AngularJS [8].
Чтобы использовать шаблонные формы, нужно импортировать модуль FormsModule:
import {FormsModule} from '@angular/forms';
Директива NgModel [9] из этого модуля делает доступными для полей ввода одностороннее связывание значений через [ngModel]
, двустороннее — через [(ngModel)]
, а также отслеживание изменений через (ngModelChange)
:
<input type="text"
name="name"
[(ngModel)]="name"
(ngModelChange)="countryModelChange($event)" />
Форма задаётся директивой NgForm [10]. Эта директива создаётся, когда мы просто используем тег <form></form>
или атрибут ngForm
внутри нашего шаблона (не забыв подключить FormsModule).
Поля ввода с директивами NgModel, находящиеся внутри формы, будут добавлены в форму и отражены в значении формы.
Директиву NgForm также можно назначить, используя конструкцию #formDir="ngForm"
— таким образом мы создадим локальную переменную шаблона formDir, в которой будет содержаться экземпляр директивы NgForm. Её свойство value, унаследованное от класса AbstractControlDirective [11], содержит значение формы. Это может быть нужно для получения значения формы (показано в живом примере).
Форму можно структурировать, добавляя группы (которые в значении формы будут представлены объектами) при помощи директивы 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>
Живой пример шаблонной формы [12]
Реактивные формы заслужили своё название за то, что взаимодействие с ними построено на парадигме реактивного программирования [13].
Структурной единицей реактивной формы является контрол — модель поля ввода или группы полей, наследник базового класса AbstractControl [14]. Контрол одного поля ввода (форм-контрол) представлен классом FormControl [15].
Компоновать значения полей шаблонной формы можно только в объекты. В реактивной нам доступны также массивы — FormArray [16]. Группы представлены классом FormGroup [17]. И у массивов, и у групп есть свойство controls, в котором контролы организованы в соответствующую структуру данных.
В отличие от шаблонной формы, для создания и управления реактивной не обязательно представлять её в шаблоне, что позволяет легко покрывать такие формы юнит-тестами.
Контролы создаются либо непосредственно через конструкторы, либо при помощи средства FormBuilder [18].
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 [19], на который можно подписаться:
this.group.valueChanges.subscribe(value => {
console.log(value);
});
У каждой разновидности контрола предусмотрены методы взаимодействия с ним, как унаследованные от класса AbstractControl, так и уникальные. Подробнее с ними можно познакомиться в описаниях соответствующих классов.
Живой пример реактивной формы [20]
Поле ввода не обязательно должно быть привязано к форме. Мы можем взаимодействовать с одним полем почти так же, как и с целой формой.
Для уже созданного контрола реактивной формы всё совсем просто. Шаблон:
<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 [21], а директива ngModel задействована не будет: поле ввода с атрибутом formControl не подпадает под селектор последней [22].
Живой пример взаимодействия с самостоятельными полями [23]
Шаблонные формы — не совсем отдельная сущность. При создании любой шаблонной формы фактически создаётся реактивная [24]. В живом примере шаблонной формы есть работа с экземпляром директивы NgForm. Мы присваиваем его локальной переменной шаблона formDir и обращаемся к свойству value для получения значения. Таким же образом мы можем получить и группу, которую создаёт директива NgForm.
<form #formDir="ngForm"
(ngSubmit)="submit($event)">
...
</form>
...
<pre>{{formDir.form.value | json}}</pre>
Свойство form — экземпляр класса FormGroup. Экземпляры этого же класса создаются при назначении директивы NgModelGroup. Директива NgModel создаёт FormControl [25].
Таким образом, все директивы, назначаемые полям ввода, как «шаблонные», так и «реактивные», служат вспомогательным механизмом для взаимодействия с основными сущностями форм в Angular — контролами.
При создании реактивной формы мы сами создаём контролы. Если мы работаем с шаблонной формой, эту работу берут на себя директивы. Мы можем получить доступ к контролам, но такой способ взаимодействия с ними не самый удобный. Кроме того, директивный подход шаблонной формы не даёт полного контроля над моделью: если мы возьмём управление структурой модели на себя, возникнут конфликты. Тем не менее, получать данные из контролов при необходимости можно, и это есть в живом примере.
Реактивная форма позволяет создать более сложную структуру данных, чем шаблонная, предоставляет больше способов взаимодействия с ней. Также реактивные формы можно проще и полнее покрывать юнит-тестами, чем шаблонные. Наша команда приняла решение использовать только реактивные формы.
Живой пример реактивной природы шаблонной формы [26]
В Angular есть набор директив, обеспечивающих работу с большинством стандартных (браузерных) полей ввода. Они назначаются незаметно для разработчика, и именно благодаря им мы можем сразу связать с моделью любой элемент input.
Когда же возможности требуемого поля ввода выходят за рамки стандартных, или логика его работы требует переиспользования, мы можем создать кастомное поле ввода.
Сперва нам нужно познакомиться с особенностями взаимодействия поля ввода и контрола.
Контролы, как было сказано выше, сопоставляются каждому полю ввода явным или неявным образом. Каждый контрол взаимодействует со своим полем через его интерфейс ControlValueAccessor.
ControlValueAccessor [27] (в этом тексте я буду называть его просто аксессором) — интерфейс, описывающий взаимодействие компонента поля с контролом. При инициализации каждая директива поля ввода (ngModel, formControl или formControlName) получает все зарегистрированные аксессоры. На одном поле ввода их может быть несколько — пользовательский и встроенные в Angular. Пользовательский аксессор имеет приоритет перед встроенными, но он может быть только один.
Для регистрации аксессора используется мультипровайдер с токеном [28] 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 [29] (в Angular 4+ — Renderer2 [30]):
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 [31], которая применяется к любым, в том числе к обычным, текстовым полям ввода. Если вы захотите сделать компонент, работающий с нативным полем ввода внутри себя, это необходимый минимум.
В живом примере я создал простейшую реализацию компонента ввода рейтинга без встроенного нативного поля ввода:
Отмечу несколько моментов. Шаблон компонента состоит из одного повторяемого тега:
<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;
}
Живой пример кастомного поля ввода [32]
В следующей статье подробно рассмотрим статусы и валидацию форм и полей, включая кастомные. Если есть вопросы по созданию кастомных полей ввода, можно писать в комментарии или лично в мой Telegram @tmsy0 [33].
Автор: sy0
Источник [34]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/256207
Ссылки в тексте:
[1] интернет-банка для юридических лиц: https://www.tinkoff.ru/business/
[2] Angular Upgrade: https://angular.io/docs/ts/latest/guide/upgrade.html
[3] связыванием данных: https://angular.io/docs/ts/latest/guide/template-syntax.html#binding-syntax-an-overview
[4] внедрением зависимостей: https://angular.io/docs/ts/latest/guide/dependency-injection.html
[5] здесь: https://metanit.com/web/angular2/2.5.php
[6] статья: https://habrahabr.ru/post/281449/
[7] официальном гайде: https://angular.io/docs/ts/latest/guide/forms.html
[8] знакомыми из AngularJS: https://docs.angularjs.org/api/ng/directive/ngModel
[9] Директива NgModel: https://angular.io/docs/ts/latest/api/forms/index/NgModel-directive.html
[10] директивой NgForm: https://angular.io/docs/ts/latest/api/forms/index/NgForm-directive.html
[11] AbstractControlDirective: https://angular.io/docs/ts/latest/api/forms/index/AbstractControlDirective-class.html
[12] Живой пример шаблонной формы: http://plnkr.co/edit/eZ1QXKAixPWD0vPHaMax?p=preview
[13] реактивного программирования: https://ru.wikipedia.org/wiki/%D0%A0%D0%B5%D0%B0%D0%BA%D1%82%D0%B8%D0%B2%D0%BD%D0%BE%D0%B5_%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5
[14] AbstractControl: https://angular.io/docs/ts/latest/api/forms/index/AbstractControl-class.html
[15] FormControl: https://angular.io/docs/ts/latest/api/forms/index/FormControl-class.html
[16] FormArray: https://angular.io/docs/ts/latest/api/forms/index/FormArray-class.html
[17] FormGroup: https://angular.io/docs/ts/latest/api/forms/index/FormGroup-class.html
[18] средства FormBuilder: https://angular.io/docs/ts/latest/api/forms/index/FormBuilder-class.html
[19] Observable: http://reactivex.io/documentation/observable.html
[20] Живой пример реактивной формы: http://plnkr.co/edit/ySGXrX1jmsKybezQiEqv?p=preview
[21] будет обрабатываться директивой formControl: https://github.com/angular/angular/blob/2.4.9/modules/%40angular/forms/src/directives/reactive_directives/form_control_directive.ts#L73-L74
[22] не подпадает под селектор последней: https://github.com/angular/angular/blob/2.4.9/modules/%40angular/forms/src/directives/ng_model.ts#L108
[23] Живой пример взаимодействия с самостоятельными полями: http://plnkr.co/edit/p4JCbkXbUUi0aVNVBa1s?p=preview
[24] фактически создаётся реактивная: https://github.com/angular/angular/blob/2.4.9/modules/%40angular/forms/src/directives/ng_form.ts#L77-L78
[25] создаёт FormControl: https://github.com/angular/angular/blob/2.4.9/modules/%40angular/forms/src/directives/ng_model.ts#L115
[26] Живой пример реактивной природы шаблонной формы: http://plnkr.co/edit/gYVtStKNU0SacCBjY61T?p=preview
[27] ControlValueAccessor: https://github.com/angular/angular/blob/2.4.9/modules/%40angular/forms/src/directives/control_value_accessor.ts
[28] токеном: https://angular.io/docs/ts/latest/guide/dependency-injection.html#!#dependency-injection-tokens
[29] Renderer: https://angular.io/docs/ts/latest/api/core/index/Renderer-class.html
[30] Renderer2: https://angular.io/docs/ts/latest/api/core/index/Renderer2-class.html
[31] DefaultValueAccessor: https://github.com/angular/angular/blob/2.4.9/modules/@angular/forms/src/directives/default_value_accessor.ts
[32] Живой пример кастомного поля ввода: http://plnkr.co/edit/JlOovWDnnINYwQ1edmFR?p=preview
[33] @tmsy0: https://t.me/tmsy0
[34] Источник: https://habrahabr.ru/post/323270/
Нажмите здесь для печати.