Свои Custom Controls в Angular

Пролог


Поговорим о реактивных формах в 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)  — определяет callback, который вызывается на blur событие (control помечается флагом touched)

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.

Комментарии 10

  • НЛО прилетело и опубликовало эту надпись здесь
      0
      Спасибо за статью!

      У меня возник вопрос. Допустим, наш компонент реализован с использованием реактивной формы внутри и поддерживает валидаторы. Существует ли стандартный подход для поддержки метода markAsTouched в таком компоненте?

      Объясню проблему… На кастомный контрол, в родительской форме, установлен валидатор, проверяющий пустое значение. Далее вызывается метод markAsTouched. Контрол в родительской форме становится invalid, так как значение пустое. Однако статус внутренней формы кастомного контрола не меняется, соотвественно не выполняется поведение при статусе invalid. Есть ли способ задавать коллбек для метод markAsTouched по аналогии с onChange?
        0
        Существует ли стандартный подход для поддержки метода markAsTouched в таком компоненте?

        Touched событий у формы в принципе нету… Но как всегда есть костыль — можно проверять touched внутри DoCheck и обновлять внутреннюю форму. Правда в этом случае есть проблемы с тем как получить ссылку на внешний FormControl, легче и лучше всего это сделать через @Input() formControl (правда оно не будет работать с formControlName, но зато максимально прозрачно и без костылей...)

          0

          Так и делал, но что-то мне подсказывает, что я решаю задачу неправильно. Либо не стоит использовать реактивную форму внутри, либо есть возможность иначе связать родительскую форму с внутренней.

            0

            Всё нормально, расслабьтесь, это ангуляр, тут подобных костылей на каждом шагу по десятку...

              0

              Ну а если серьезно, то


              либо есть возможность иначе связать родительскую форму с внутренней.

              Никак.


              Либо не стоит использовать реактивную форму внутри

              Это зависит от того что вам надо, возможно, в вашем случае можно заменить родительный контрол на FormGroup (которая сейчас внутри), это решит проблемы с touched, но в сложных формах, когда это невозможно — только костылить пытаясь заставить это всё работать....

            +1
            Спасибо за ваш интерес к статье!

            Если я правильно вас понял, то есть необходимость в обработке ошибок внутри кастомного контрола
            Для этого вам нужна ссылка на FormControl из внешней формы
            Сделать это достаточно просто с DI (и долей грязной черной магии)

            в классе кастом контрола инжектим:
            constructor(
                @Optional() @Self() public control: NgControl
              ) {
                if (control) {
                  control.valueAccessor = this; // иначе ошибка Uncaught Error: Template parse errors: Cannot instantiate cyclic dependency! NgControl
                }
            } 
            


            и в метаданных убираем провайдер NG_VALUE_ACCESSOR. Взято отсюда.
              0
              @Optional() @Self() public control: NgControl

              Oно не работает c NG_VALIDATORS...

            0
            registerOnTouched(fn: any)  — аналогично registerOnChange, определяет обработчик на touch-события
            Что вы имеете в виду по touch-событиями?
            registerOnTouched на самом деле определяет обработчик onBlur: angular.io/api/forms/ControlValueAccessor#registerOnTouched
              0
              Спасибо за ваш комментарий!
              Действительно была неточность, вы правы
              Статью поправил.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое