Работа с формами в Angular — модуль работы с формами и обертки полей09.06.2023 15:46
Всем привет! Я Александр Бухтатый, frontend-разработчик в Тинькофф, специализируюсь на Angular. Наша команда работает в монорепозитории с четырьмя проектами. В каждом проекте много форм, нужно сопровождать их и создавать новые.
В статье покажу один из способов работы с формами в Angular-проектах, который упрощает создание новых форм и изолирует зависимость от внешней UI-библиотеки. Будет мало текста и много кода, пристегните ремни, мы начинаем.
Определили проблемы
Мы используем Taiga UI, но можно делать обертки и под другие UI-библиотеки, принцип оберток никак не зависит от той, что вы используете. Taiga UI — хороший и гибкий инструмент для разработки, но при использовании любой UI-библиотеки есть своя цена.
Зависимость. Большая зависимость от UI-библиотеки при обновлении мажорной версии приведет к куче рефакторинга во всех формах всех проектов монорепозитория. При создании поля в форме нужно применить множество разных компонентов, каждый из которых служит поводом вернутся и внести правки в код с полем формы.
Например, если изменятся контракты tui-error, придется вносить правки во все поля всех форм на проекте. Обертки делают инверсию зависимостей, и наши формы зависят от оберток, а те, в свою очередь, зависят от внешней UI-библиотеки.
Такой подход защищает от обратно несовместимых правок в UI-библиотеке и уменьшает количество работы по переходу на ее новую версию.
Схема зависимостей полей формы от компонентов UI-библиотеки до применения оберток
Схема зависимостей полей формы от компонентов UI-библиотеки после применения оберток
Много бойлерплейта, который нужно писать для реализации форм при помощи унифицированной UI-библиотеки.
Код Combobox в форме до применения обертки
Код Combobox в форме после применения обертки
Дублирование кода. К каждому полю нужно дописывать tui-error и вспомогательные вещи типа шаблонов или компонентов для работы со списком. Видно в примерах предыдущей проблемы.
Сложно добавлять новое поле в форму —много усилий и постоянное обращение к справке UI-библиотеки.
Чтобы применить тот же комбобокс, нужно скопировать пример, донастроить, обратиться к документации и так далее. С обертками достаточно будет скопировать нужный вариант и реализовать метод получения отфильтрованного списка. Появится отдельный модуль со всем, что нужно для работы с формами, который позволяет посмотреть доступные поля, варианты, валидаторы, маски и директивы сразу в IDE.
Нашли решение
Думая над обозначенными проблемами, мы осознали, что решение все это время было рядом — и это инкапсуляция.
Мы составили список работ:
Завернуть все связанное с формами в один модуль.
Реализовать обертки полей форм так, чтобы с ними было удобно работать, и с возможностью создавать для них варианты. Вариант — конфигурация поля формы через директиву. Он нужен для удобства переиспользования одного и того же поля с разными настройками.
Реализовать вспомогательные инструменты для работы с обертками полей формы, чтобы упростить работу с полями.
Приступили к реализации
Модуль нужен для того, чтобы хранить все связанное с формами в одном месте, это помогает снизить количество дублей, служит хорошей документацией по тому, что нам доступно для работы с формами.
Компоненты модуля содержат:
Core — все вспомогательные классы.
Controls — пользовательские компоненты с реализацией ControlValueAccessor.
Fields — обертки полей формы и их варианты.
Masks — каталог доступных масок для полей формы.
Validators — различные валидаторы для полей формы.
Структура модуля
Обертка поля формы — это компонент с удобным интерфейсом, инкапсулирующий в себя весь бойлерплейт из UI-библиотек. С оберткой можно работать как с компонентом, реализующим ControlValueAccessor, то есть используя Angular-директивы ngModel, FormControl, FormControlName.
Преимущества использования обертки:
Улучшаем читаемость кода.
Снижаем количество бойлерплейта.
Ускоряем разработку за счет переиспользования готовых оберток и их вариантов.
Изолируем зависимость от внешней UI-библиотеки.
Сначала создаем вспомогательный класс, который упростит разработку новых оберток для полей. Базовый класс обертки:
@Directive()
export class FormFieldBase implements OnInit, OnDestroy, ControlValueAccessor {
control!: FormControl;
private subscription!: Subscription;
constructor(
@Optional() @Self() public ngControl: NgControl
) {
if (this.ngControl != null) {
this.ngControl.valueAccessor = this;
}
}
writeValue(obj: any): void {}
registerOnChange(fn: (_: any) => void): void {}
registerOnTouched(fn: any): void {}
ngOnInit() {
if (!this.ngControl) throw new Error('ngControl is undefined');
if (this.ngControl instanceof FormControlName) {
this.control = this.ngControl.control;
} else if (this.ngControl instanceof FormControlDirective) {
this.control = this.ngControl.control;
} else if (this.ngControl instanceof NgModel) {
this.control = this.ngControl.control;
this.subscription = this.control.valueChanges.subscribe((x) =>
this.ngControl.viewToModelUpdate(this.control.value)
);
} else if (!this.ngControl) {
this.control = new FormControl();
}
}
ngOnDestroy() {
this.subscription?.unsubscribe();
}
}
После реализации базового класса можно создавать обертки. Пример обертки combobox.component.ts:
Некоторые поля формы имеют вариативность: например, выбор пользователя может быть просто по логину или по карточке пользователя с фото. Для таких случаев мы будем использовать варианты и шаблоны.
Пример варианта по умолчанию для select
Пример варианта с подсказкой для select
Шаблон — компоненты, которые отображаются в качестве частей оборачиваемого компонента. Реализация шаблона option-with-hint-content-template.component.ts:
Вариант — директивы, агрегирующие другие директивы и переопределяющие поля компонента так, чтобы тот изменил свой внешний вид или даже поведение. Один вариант равен одному виду поля в макетах Figma.
@Directive({
selector: 'aff-combobox[withHint]',
standalone: true,
})
export class ComboboxWithHintVariantDirective {
comboboxComponenRef = inject(ComboboxComponent);
constructor() {
this.comboboxComponenRef.itemContent = new PolymorpheusComponent(
WithHintOptionTemplateComponent
);
this.comboboxComponenRef.valueContent = new PolymorpheusComponent(
WithHintValueTemplateComponent
);
}
}
Пример использования варианта для Combobox delivery-form.component.html:
address
...
Пример использования шаблона без директивы-варианта:
address
...
Для удобной работы с разными обертками можно создавать директивы-помощники. Например, сделать директиву, в которую через Input передадим метод, возвращающий список доступных значений каждый раз при обновлении поисковой строки Combobox. Это нужно, чтобы не писать каждый раз логику с подпиской и прокидыванием результата в компонент через шаблон.
Пример использования мы уже видели ранее в delivery-form.component.html и delivery-form.component.ts.
Мы можем сделать директивы, которые агрегируют в себе другие директивы, и обозвать их вариантом, чтобы не навешивать на наши обертки кучу директив. Можно по аналогии с директивами-контроллерами сделать директивы для схожих инпутов из разных компонентов.
Оборачиваются не только поля формы, но и части форм для переиспользования. Например, мы можем переиспользовать форму для обратной связи, форму с адресом, форму с реквизитами клиента и так далее.
Реализация вспомогательного класса form-group-base.class.ts:
@Directive()
export class FormGroupBase {
get formGroup(): FormGroup {
return this.controlContainer.control as FormGroup;
}
constructor(private controlContainer: ControlContainer) {}
}
Пример реализации переиспользуемой формы contacts-short-form.component.ts:
Пример реализации переиспользуемой формы contacts-short-form.component.html:
Name
Phone
Пример использования order-form.component.html:
Pizza order form
Результаты и полезные ссылки
Создание оберток для всех компонентов Taiga UI заняло один рабочий день, потом пару недель мы обкатывали обертки на формах, с которыми работаем. После успешной обкатки решили целиком перейти на обертки — и не жалеем об этом.
У нас получилось:
Сократить время разработки.
Улучшить читаемость кода.
Сократить количество бойлерплейта — в среднем html-код сократился на 50%.
Создать единое место для всего связанного с формами, что снижает вероятность создания дублей.
Изолировать зависимость от внешней UI-библиотеки. Если произойдут критичные изменения в UI-библиотеке, мы будем править только обертки, а не все поля у форм во всех проектах монорепозитория.
Полезные ссылки:
Если есть вопросы — буду рад обсудить в комментариях!