Свои Custom Controls в Angular

в 9:48, , рубрики: angular, forms, TypeScript

Пролог

Поговорим о реактивных формах в angular, узнаем за кастомные контролы, как их создавать, использовать и валидировать. Статья предполагает что вы уже знакомы с фреймворком angular, но хотите больше погрузиться в её специфику. Хорошее желание, начнем.

Reactive и Template Driven формы

Пара слов to be sure we are on the same page. Ангуляр имеет два типа форм: Template Driven Forms и Reactive Forms.

Template Driven Forms это формы основанные на two-way binding (привет, angularjs). Указываем поле в классе (например, username), в html у тега input связываем [(value)]=«username», и при изменении значения инпута изменяется значение username. В 2011 году это было чертовой магией! Окей, но есть нюанс. Таким способом строить сложные формы будет… сложно.

Reactive Forms это удобный инструмент для построения простых и сложных форм. Они говорят нам «создай экземпляр класса формы (FormGroup), передай ему экземпляры контролов (FormControl) и просто отдай это в html, а я сделаю все остальные». Окей, попробуем:

class UsefullComponent {
 public control = new FormControl('');
 public formG = new FormGroup({username: control});
}

<form [formGroup]="formG">
 <input type="text" formControlName="username">
</form>

В итоге получаем реактивную форму, с блэкджеком и… ну вы поняли, со всеми приятностями. Например, formG.valueChanges даст нам Observable (поток) изменений формы. Так же можно добавлять новые контролы, удалять существующие, изменять правила валидации, получать значение формы (formG.value) и многое другое. И все это работая с одним экземпляром formG.

А чтобы каждый раз не создавать экземпляры вышеописанных классов вручную, разработчики angular'а дали нам удобный FormBuilder с помощью которого пример выше можно переписать так:

class UsefullComponent {
 public formG: FormGroup;

 constructor(private fb: FormBuilder) {
  this.formG = fb.group({
   name: '' // никаких new FormControl() !!
  })
 }

}

Custom Controls

Ангуляр говорит нам «бро, если тебе не хватает нативных контролов (input, date, number и другие), или они тебе вдруг чем то не понравились, вот тебе простой, но в то же время очень мощный инструмент для создания своих». Кастомные контролы можно использовать хоть с реактивными, хоть с template-driven формами, а реализуются они с помощью директив (удивили!). Зная что компоненты это директивы с шаблоном (то что надо), напишем:

import { Component, Input } from '@angular/core';

@Component({
 selector: 'counter-control',
 template: `
  <button (click)="down()">Down</button>
   {{ value }}
  <button (click)="up()">Up</button>
 `
})
class CounterControlComponent {
  @Input()
  value = 0;
 
 up() {
  this.value++;
 }
 
 down() {
  this.value - ;
 }
}

Как и любую директиву, эту нужно задекларировать в модуле (чтобы статья не разрасталась, такие нюансы опустим).

Теперь мы можем использовать созданную компоненту:

import { Component } from '@angular/core';

@Component({
 selector: 'parent-component',
 template: `
  <counter-control></counter-control>
 `
})
class ParentComponent {}

Это все конечно же работает, но где формы?! Хотя бы template-driven….
Без паники, все будет, после знакомства с ControlValueAccessor.

Control Value Accessor

Для того чтобы механизмы angular'а могли взаимодействовать с вашим кастомным контролом, нужно чтобы этот контрол имплементировал определенный интерфейс. Этот интерфейс называется ControlValueAccessor. Это достаточно хороший пример полиморфизма из ООП, когда наш объект (в данном случае, контрол) реализует интерфейс, а другие объекты (формы ангуляра) через этот интерфейс взаимодействуют с нашим объектом.

Благодаря ControlValueAccessor у нас есть единый способ работы с контролами, который, кстати говоря, используется не только при создании кастомных контролов. Angular под капотом так же использует этот интерфейс. Для чего? А просто для приведения к единому виду поведение нативных контролов. Например, у input значение содержится в атрибуте value, у чекбокса значение определяется через атрибут checked. Таким образом, у каждого типа контролов имеется свой ControlValueAccessor: DefaultValueAccessor - для input'ов и textarea, CheckboxControlValueAccessor - для чекбоксов, RadioControlValueAccessor для радиокнопок и так далее.

Но что angular делает используя ControlValueAccessor? Все довольно просто, записывает значение из модели в DOM (view), а так же поднимает событие изменения контрола до FormGroup и других директив.

Теперь, когда мы узнали про ControlValueAccessor, можем применить его к нашему контролу.

Посмотрим на его интерфейс:

interface ControlValueAccessor {
  writeValue(value: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
}

writeValue(value: any) — вызывается при задании исходного (new FormControl('I am default value')) или нового значения сверху control.setValue('I am setted value').

registerOnChange(fn: any) — метод определяющий обработчик, который должен быть вызван при изменении значения (fn является callback'ом, который уведомит форму о том, что в этом контроле изменилось значение).

registerOnTouched(fn: any)  — аналогично registerOnChange, определяет обработчик на touch-события

import { Component, Input } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';

@Component({
 selector: 'counter-control',
 template: `
  <button (click)="down()">Down</button>
   {{ value }}
  <button (click)="up()">Up</button>
 `
})
class CounterControlComponent implements ControlValueAccessor {
 @Input()
 value = 0;

 onChange(_: any) {}

 up() {
  this.value++;
 }

 down() {
  this.value - ;
 }

 writeValue(value: any) {
  this.value = value;
 }

 registerOnChange(fn) {
  this.onChange = fn;
 }

 registerOnTouched() {}
}

Для того чтобы родительская форма знала об изменениях контрола, нам нужно вызывать метод onChange на каждое изменение значения value. Чтобы не писать вызов onChange в каждом методе (up и down), реализуем поле value через геттеры и сеттеры:

// …
class CounterControlComponent implements ControlValueAccessor {
 private _value;

 get value() {
  return this._value;
 }

 @Input()
 set value(val) {
  this._value = val;
  this.onChange(this._value);
 }

 onChange(_: any) {}
 
 up() {
  this.value++;
 }
 
 down() {
  this.value - ;
 }
 // …
}

На данный момент ангуляр не курсе, что компонент имплементирующий ControlValueAccessor должен рассматриваться как кастомный контрол, скажем ему об этом:

import { Component, Input, forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
 …
 providers: [{ 
  provide: NG_VALUE_ACCESSOR,
  useExisting: forwardRef(() => CounterControlComponent),
  multi: true
 }]
})
class CounterControlComponent implements ControlValueAccessor {
 …
}

В данном участке кода мы говорим ангуляру «я хочу зарегать новый кастомный контрол (provide: NG_VALUE_ACCESSOR), в качестве значения используй уже существующий (на этот момент наша компонента будет инициализированной) экземпляр компоненты (useExisting: forwardRef(() => CounterControlComponent))». 

multi: true говорит о том что зависимостей с таким токеном (NG_VALUE_ACCESSOR) может быть несколько.

Не просто ж так создавали

Самое время заюзать наш кастомный контрол. Не забываем добавить FormsModule / ReactiveFormsModule в импорты модуля где используется этот контрол.

Используем в Template Driven формах

Тут все просто, используя двухстороннее связывание через ngModel получаем изменение view при изменении модели и наоборот:

import { Component } from '@angular/core';

@Component({
 selector: 'parent-component',
 template: `
  <counter-control [(ngModel)]="controlValue"></counter-control>
 `
})
class ParentComponent {
 controlValue = 10;
}

Используем в Reactive Forms

Как говорилось в начале статьи, создаем экземпляр реактивной формы через FormBuilder, отдаем в html и получаем удовольствие:

import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';

@Component({
 selector: 'parent-component',
 template: `
  <form [formGroup]="form">
   <counter-control formControlName="counter"></counter-control>
  </form>
 `
})
class ParentComponent implements OnInit {
 form: FormGroup;

 constructor(private fb: FormBuilder) {}

 ngOnInit() {
  this.form = this.fb.group({
   counter: 5
  });
 }
}

Теперь это полноценная реактивная форма с нашим кастомным контролом, при этом все механизмы работают также, как и с нативными контролами (я уже говорил про полиморфизм?). Чтобы не нагружать текущую статью, поговорим за валидацию и отображение ошибок кастомных контролов в следующей статье.

Материалы:

Статья о кастомных контролах от Pascal Precht.
Оф доки форм в ангуляре.
Серия статей о rxjs.

Автор: Ильгам Габдуллин

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js