- PVSM.RU - https://www.pvsm.ru -

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

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

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], которая применяется к любым, в том числе к обычным, текстовым полям ввода. Если вы захотите сделать компонент, работающий с нативным полем ввода внутри себя, это необходимый минимум.

В живом примере я создал простейшую реализацию компонента ввода рейтинга без встроенного нативного поля ввода:
Формы и кастомные поля ввода в Angular 2+ - 2

Отмечу несколько моментов. Шаблон компонента состоит из одного повторяемого тега:

<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/