javascript

Формы и кастомные поля ввода в Angular 2+

  • суббота, 27 мая 2017 г. в 03:14:47
https://habrahabr.ru/company/tinkoff/blog/323270/
  • Разработка веб-сайтов
  • JavaScript
  • AngularJS
  • Блог компании Tinkoff.ru


imageМеня зовут Павел, я фронтенд-разработчик 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.