Обложка: Реактивные формы в Angular

Реактивные формы в Angular

Данил Сабиров

Данил Сабиров

Старший Архитектор WaveAccess

Несмотря на появление фронтенд-фреймворков, разработка форм в Angular остаётся трудозатратным процессом, который вызывает сложности. Зайдите на Stackoverlow и проверьте количество запросов по «‎Angular forms» — увидите более 48 тысяч. Почему так много? На мой взгляд, одна из проблем — два подхода реализации форм в Angular из коробки. Это шаблонный подход (Template-Driven Form) и реактивные формы (Reactive Form). Новички могут смешивать их в коде — отсюда и значительная часть вопросов.

Для создания форм в Angular я рекомендую Reactive Forms. Так можно использовать реактивную парадигму программирования, что более естественно для языка.

Работа с реактивными формами

Кратко работу с реактивными формами можно описать так: вы явно описываете форму в контроллере, включая валидацию и маппите свою модель данных на модель формы. А все необходимые операции с данными производите явно через методы формы. Также можно зарефакторить переиспользуемую логику. Модель формы описывается отдельно, благодаря этому модель данных останется неизменной во время работы пользователей с формой.

Для наглядности возьмём пример по Template-Driven Form из туториала Tour of Hero и перепишем его на реактивные формы. В результате получим следующий код контроллера hero-form.component.ts:

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

import { Hero } from '../hero';

@Component({
  selector: 'app-hero-form',
  templateUrl: './hero-form.component.html'
})
export class HeroFormComponent {

  powers = ['Really Smart', 'Super Flexible',
            'Super Hot', 'Weather Changer'];

  model = new Hero(18, 'Dr IQ', this.powers[0], 'Chuck Overstreet');

  submitted = false;

  heroForm: FormGroup = this.fb.group({
    name: [null, Validators.required],
    alterEgo: [null],
    power: [null, Validators.required]
  });

  constructor(private fb: FormBuilder) {
  }

  onSubmit() { this.submitted = true; }

  newHero() {
    this.model = new Hero(42, '', '');
  }
}

Первое на что стоит обратить внимание: помимо модели данных, мы создали объект класса FormGroup. С ним мы сможем описать модель представления формы и затем передать ее непосредственно форме.

Из примера выше, можно заметить, что наша FormGroup создается через другой класс FormBuilder. По сути, это класс-помощник, позволяющий создать объекты формы: FormGroup, FormControl и FormArray. К тому же наша модель формы сейчас почти идентично повторяет модель данных model.

Операция маппинга модели данных на модель формы (и в последующем обратный маппинг) требует дополнительный код. Но несмотря на это нашего подхода есть один очень большой плюс. Модель данных на время работы с формой остаётся неизменной. Это позволяет сделать логику более гибкой. Плюс к вашему коду будет проще написать тесты. Замечу, что в своём примере, я пока намеренно не маппил модель данных на модель формы, чтобы осветить этот вопрос подробнее чуть ниже.

Примечание Несмотря на то, что в Template-Driven Form работа с формой происходит с директивой ngForm, «под капотом» она тоже неявно создаст объект FormGroup (советую для общего понимания ознакомится с исходниками).

Теперь рассмотрим код получившегося шаблона hero-form.component.html:

<div class="container">
  <div [hidden]="submitted">
    <h1>Hero Form</h1>
    <form [formGroup]="heroForm" (ngSubmit)="onSubmit()">
      <div class="form-group">
        <label for="name">Name</label>
        <input type="text" class="form-control" formControlName="name">
        
        <div [hidden]="heroForm.get('name')?.valid ||heroForm.get('name')?.pristine" class="alert alert-danger">
          Name is required
        </div>
      </div>

      <div class="form-group">
        <label for="alterEgo">Alter Ego</label>
        <input type="text" class="form-control" formControlName="alterEgo">
      </div>

      <div class="form-group">
        <label for="power">Hero Power</label>
        <select class="form-control" formControlName="power">
          <option *ngFor="let pow of powers" [value]="pow">{{pow}}</option>
        </select>
        <div [hidden]="heroForm.get('power')?.valid || heroForm.get('power')?.pristine" class="alert alert-danger">
          Power is required
        </div>
      </div>

      <button type="submit" class="btn btn-success" [disabled]="!heroForm.valid">Submit</button>
      <button type="button" class="btn btn-default" (click)="newHero();">New Hero</button>
    </form>
  </div>

  <div [hidden]="!submitted">
    <h2>You submitted the following:</h2>
    <div class="row">
      <div class="col-xs-3">Name</div>
      <div class="col-xs-9">{{ heroForm.value.name }}</div>
    </div>
    <div class="row">
      <div class="col-xs-3">Alter Ego</div>
      <div class="col-xs-9">{{ heroForm.value.alterEgo }}</div>
    </div>
    <div class="row">
      <div class="col-xs-3">Power</div>
      <div class="col-xs-9">{{ heroForm.value.power }}</div>
    </div>
    <br>
    <button class="btn btn-primary" (click)="submitted=false">Edit</button>
  </div>
</div>

Первое, что замечаем — это передача нашей модели формы heroForm директиве [formGroup]. Angular для инициализации реактивной формы использует директивной подход с помощью селектора [formGroup], тем самым создавая объект FormGroupDirective c обязательным параметром типа FormGroup. Второе, на что стоит обратить внимание — мы подписываемся на событие ngSubmit, которое вызовется в случае сабмита формы (в нашем случае по нажатию по кнопке Submit).

Третий пункт нашей формы — это атрибуты formControlName, по которым сработает другая реактивная директива FormControlName. Она принимает в качестве параметра названия поля формы и создаёт объект FormControl. А также привязывает его к родительскому FormGroup и связывает контрол по указанному полю с html элементом.

Опционально можно описать для каждого из контролов элементы валидации. Так, в нашем примере в разметке пользователю мы показываем что поля Name и Power обязательные. Делается это довольно просто:

  1. Получаем необходимый FormControl с помощью метода get формы;
  2. Далее получаем стандартный набор состояний контрола: valid, invalid, pending, disabled, enabled, dirty, pristine, touched, untouched (см. более подробно официальное API);
  3. Реализуем нужное поведение элемента валидации, поверяя необходимые свойства контрола.

В качестве финального шага, проверяем факт валидности нашей формы с помощью valid. И не даём засабмитить форму до тех пор, пока форма не валидна.

Если запустить код проекта, мы увидим, что форма пустая. Дело как раз в том, что мы незамаппили нашу модель. Попробуем сделать это через метод setValue:

constructor(private fb: FormBuilder) {
  this.heroForm.setValue(this.model);
}

Вместо того, чтобы увидеть нашу форму в браузере, в консоли получим ошибку:

core.js:6157 ERROR Error: Cannot find form control with name: id. at FormGroup._throwIfControlMissing (forms.js:4082)

Проблема в том, что setValue ищет для каждого поля модели соответствующее поле в модели формы. Мы не описали поле id, так как оно излишне в  представлении. Есть три пути решения проблемы. Самый верный — вручную замаппить модель данных на модель формы:

constructor(private fb: FormBuilder) {
    this.heroForm.setValue({
      name: this.model.name,
      alterEgo: this.model.alterEgo,
      power: this.model.power
    });
  }

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

heroForm: FormGroup = this.fb.group({
    id: [null],
    name: [null, Validators.required],
    alterEgo: [null],
    power: [null, Validators.required]
  });

  constructor(private fb: FormBuilder) {
    this.heroForm.setValue(this.model);
  }

  onSubmit() { this.submitted = true; }

  newHero() {
  this.heroForm.setValue(new Hero(42, '', '', ''));
  }

Примечание resetForm сбросит все поля в дефолтные.

Наконец, можем воспользоваться методом patchValue, который для каждого найденного поля объекта в форме перепишет значение. А если не найдёт, то просто пропустит его. Это допустимо, если вы точно знаете, что модель данных всегда придёт со всеми необходимыми полями для формы:

heroForm: FormGroup = this.fb.group({
    name: [null, Validators.required],
    alterEgo: [null],
    power: [null, Validators.required]
  });

  constructor(private fb: FormBuilder) {
    this.heroForm.patchValue(this.model);
  }

  onSubmit() { this.submitted = true; }

  newHero() {
   this.heroForm.patchValue(new Hero(42, '', '', ''));
  }

Дочерние группы

Следующий пункт, который я хочу осветить — создание внутри объекта модели дочерние группы или одной FormGroup внутри другой. В реальных проектах это необходимо, потому что формы, как правило, составные. Рассмотрим добавление подгрупп на нашем примере. Сначала создадим класс Phone и добавим поле phone в нашу модель данных:

phone.ts:

export class Phone {

  constructor(
    public type: string,
    public number: string
  ) {  }
}

hero.ts:

import { Phone } from "./phone";

export class Hero {

  constructor(
    public id: number,
    public name: string,
    public power: string,
    public alterEgo?: string,
    public phone?: Phone
  ) {  }

}

Теперь добавляем в модель формы поле phone:

phoneTypes = ['mobile', 'home', 'work'];

  heroForm: FormGroup = this.fb.group({
    name: [null, Validators.required],
    alterEgo: [null],
    power: [null, Validators.required],
    phone: this.fb.group({
      type: [this.phoneTypes[0]],
      number: [null]
    })
  });

В шаблоне добавляем разметку для отображения телефона:

<fieldset formGroupName="phone">
  <legend>Phone</legend>
  <div class="form-group">
    <label for="power">Type</label>
    <select class="form-control" formControlName="type">
      <option *ngFor="let phoneType of phoneTypes" [value]="phoneType">{{phoneType}}</option>
    </select>
  </div>

  <div class="form-group">
    <label for="alterEgo">Number</label>
    <input type="text" class="form-control" formControlName="number">
  </div>

</fieldset>

Как видим, логика проставления здесь похожа, за исключением того, что мы связываем группу по директиве formGroupName. Остальное на себя возьмёт Angular, который свяжет её с родительской формой.

На практике вложенные формы обычно вынесены в отдельные компоненты, чтобы их можно было переиспользовать. Поэтому для нашего примера создадим отдельный компонент phone. Шаблон оставим таким же, а контроллер сделаем так:

import { Component } from '@angular/core';
import { ControlContainer, FormGroupDirective } from '@angular/forms';

@Component({
  selector: 'app-phone-form',
  templateUrl: './phone-form.component.html',
  viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }]
})
export class PhoneFormComponent {

  phoneTypes = ['mobile', 'home', 'work'];
}

Строка viewProviders нужна  директиве FormGroupName, которая будет искать форму внутри host-элемента. В нашем случае у группы нет формы, и мы передаём провайдер, который найдёт родительскую форму. Если этого не сделать, мы получим ошибку:

formGroupName must be used with a parent formGroup directive.  You'll want to add a formGroup
      directive and pass it an existing FormGroup instance (you can create one in your class).

Подводим итоги

Реактивные формы в Angular создаются так:

  1. Создаём объект формы FormGroup, которая описывает вашу модель данных и правила валидации;
  2. Маппим модель данных на модель формы;
  3. Angular создаст реактивную форму, когда найдёт директиву [formGroup] с переданной ей моделью формы в шаблоне
  4. Дочерние элементы формы в шаблоне связываются по следующим атрибутам formGroupName, formControlName и formAttayName ;
  5. Объект формы FormGroup содержит все необходимые методы для того, чтобы получить отдельный контрол и проверить его состояние и состояние всей формы. А также для того, чтобы установить или обновить значение контролов формы. И ещё включает событие ngSubmit, чтобы отловить сабмит формы.

***

Рекомендуемые материалы

  1. Исходный код проекта
  2. Общее описание реализации форм с официального сайта Angular
  3. Более подробное описание Reactive Forms с примерами на сайте Angular
  4. Api по формам с сайта Angular
  5. Исходники Angular, где вы можете более подробно изучить код форм