Данил Сабиров
Старший Архитектор 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. С ним мы сможем описать модель представления формы и затем передать ее непосредственно форме.
https://tproger.ru/jobs/full-stack-razrabotchik/?utm_source=in_text
Из примера выше, можно заметить, что наша 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 элементом.
Реактивное программирование на реальных примерах: подробное введение
tproger.ru
Опционально можно описать для каждого из контролов элементы валидации. Так, в нашем примере в разметке пользователю мы показываем что поля Name и Power обязательные. Делается это довольно просто:
- Получаем необходимый FormControl с помощью метода get формы;
- Далее получаем стандартный набор состояний контрола: valid, invalid, pending, disabled, enabled, dirty, pristine, touched, untouched (см. более подробно официальное API);
- Реализуем нужное поведение элемента валидации, поверяя необходимые свойства контрола.
Если запустить код проекта, мы увидим, что форма пустая. Дело как раз в том, что мы незамаппили нашу модель. Попробуем сделать это через метод 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 создаются так:- Создаём объект формы FormGroup, которая описывает вашу модель данных и правила валидации;
- Маппим модель данных на модель формы;
- Angular создаст реактивную форму, когда найдёт директиву [formGroup] с переданной ей моделью формы в шаблоне
- Дочерние элементы формы в шаблоне связываются по следующим атрибутам formGroupName, formControlName и formAttayName ;
- Объект формы FormGroup содержит все необходимые методы для того, чтобы получить отдельный контрол и проверить его состояние и состояние всей формы. А также для того, чтобы установить или обновить значение контролов формы. И ещё включает событие ngSubmit, чтобы отловить сабмит формы.
Рекомендуемые материалы
- Исходный код проекта
- Общее описание реализации форм с официального сайта Angular
- Более подробное описание Reactive Forms с примерами на сайте Angular
- Api по формам с сайта Angular
- Исходники Angular, где вы можете более подробно изучить код форм