Всем привет! Я Александр Бухтатый, frontend-разработчик в Тинькофф, специализируюсь на Angular. Наша команда работает в монорепозитории с четырьмя проектами. В каждом проекте много форм, нужно сопровождать их и создавать новые.
В статье покажу один из способов работы с формами в Angular-проектах, который упрощает создание новых форм и изолирует зависимость от внешней UI-библиотеки. Будет мало текста и много кода, пристегните ремни, мы начинаем.
Определили проблемы
Мы используем Taiga UI, но можно делать обертки и под другие UI-библиотеки, принцип оберток никак не зависит от той, что вы используете. Taiga UI — хороший и гибкий инструмент для разработки, но при использовании любой UI-библиотеки есть своя цена.
Зависимость. Большая зависимость от UI-библиотеки при обновлении мажорной версии приведет к куче рефакторинга во всех формах всех проектов монорепозитория. При создании поля в форме нужно применить множество разных компонентов, каждый из которых служит поводом вернутся и внести правки в код с полем формы.
Например, если изменятся контракты tui-error, придется вносить правки во все поля всех форм на проекте. Обертки делают инверсию зависимостей, и наши формы зависят от оберток, а те, в свою очередь, зависят от внешней UI-библиотеки.
Такой подход защищает от обратно несовместимых правок в UI-библиотеке и уменьшает количество работы по переходу на ее новую версию.
Много бойлерплейта, который нужно писать для реализации форм при помощи унифицированной UI-библиотеки.
Дублирование кода. К каждому полю нужно дописывать 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:
@Component({ selector: 'ngnx-form-field-combobox', templateUrl: './form-field-combobox.component.html', styleUrls: ['./form-field-combobox.component.scss'], standalone: true, imports: [ TuiComboBoxModule, ReactiveFormsModule, TuiDataListWrapperModule, TuiErrorModule, TuiFieldErrorPipeModule, AsyncPipe, JsonPipe ] }) export class FormFieldComboboxComponent<T> extends FormFieldBase { private readonly itemsHandlers: TuiItemsHandlers<T> = inject(TUI_ITEMS_HANDLERS); @Input() items: any[] | null = null; @Input() identityMatcher: TuiItemsHandlers<T>['identityMatcher'] = this.itemsHandlers.identityMatcher; @Input() stringify: TuiItemsHandlers<T>['stringify'] = this.itemsHandlers.stringify; @Input() placeholder?: string = ''; @Input() valueContent: PolymorpheusContent<TuiValueContentContext<T>> = new PolymorpheusComponent(DefaultOptionTemplateComponent); @Input() itemContent: PolymorpheusContent<TuiValueContentContext<T>> = new PolymorpheusComponent(DefaultOptionTemplateComponent); @Output() search$ = new ReplaySubject<string | null>(); }
Пример обертки combobox.component.html:
<tui-combo-box [formControl]="control" [identityMatcher]="identityMatcher" [valueContent]="valueContent" [stringify]="stringify" (searchChange)="search$.next($event)" > <ng-content></ng-content> <input tuiTextfield [placeholder]="placeholder" /> <tui-data-list-wrapper *tuiDataList [items]="items" [itemContent]="itemContent" ></tui-data-list-wrapper> </tui-combo-box> <tui-error [formControl]="control" [error]="[] | tuiFieldError | async" ></tui-error>
Пример использования обертки Combobox — delivery-form.component.html:
<div [formGroup]="formGroup"> <div class="tui-form__row tui-form__row_multi-fields"> <div class="tui-form__multi-field"> <aff-combobox formControlName="address" [affComboboxDataProvider]="comboboxDataProvider" [stringify]="comboboxStringify" > address </aff-combobox> </div> ... </div>
Пример использования обертки Combobox — delivery-form.component.ts:
@Component({ selector: 'aff-delivery-form', templateUrl: './delivery-form.component.html', styleUrls: ['./delivery-form.component.less'], }) export class DeliveryFormComponent extends FormGroupBase { selectItemsWithHints = [ {id: '1', label: 'Label 1'}, {id: '2', label: 'Label 2'}, {id: '3', label: 'Label 3'}, {id: '4', label: 'Label 4'}, ]; comboboxStringify(item: {label: string}): string { return item.label; } comboboxDataProvider: ComboboxDataProvider<any> = (term: string) => { const foundedItems = this.selectItemsWithHints.filter((item) => term == '' || item.label.toLowerCase() == term.toLowerCase() || item.label.toLowerCase().includes(term.toLowerCase())); return foundedItems && foundedItems.length ? of(foundedItems) : of(null); } }
Некоторые поля формы имеют вариативность: например, выбор пользователя может быть просто по логину или по карточке пользователя с фото. Для таких случаев мы будем использовать варианты и шаблоны.
Шаблон — компоненты, которые отображаются в качестве частей оборачиваемого компонента. Реализация шаблона option-with-hint-content-template.component.ts:
export type OptionWithHint<T> = T & { label: string; hint: string; }; @Component({ selector: 'aff-option-with-hint-content-template', standalone: true, imports: [CommonModule], templateUrl: './option-with-hint-content-template.component.html', styleUrls: ['./option-with-hint-content-template.component.scss'], }) export class OptionWithHintContentTemplateComponent { @Input('label') inputLabel?: string; @Input('hint') inputHint?: string; get label(): string { return this.optionWithHintMapperDirectiveRef?.mapper?.label(this.context?.$implicit) || this.context?.$implicit?.label || this.inputLabel || '-'; } get hint(): string { return this.optionWithHintMapperDirectiveRef?.mapper?.hint(this.context?.$implicit) || this.context?.$implicit?.hint || this.inputHint || '-'; } constructor( @Optional() private optionWithHintMapperDirectiveRef: OptionWithHintMapperDirective, @Optional() @Inject(POLYMORPHEUS_CONTEXT) readonly context: { $implicit: OptionWithHint<any>, active: boolean } ) { } }
Реализация шаблона option-with-hint-content-template.component.html:
<div><b>{{label}}</b></div> <div>{{hint}}</div>
Вариант — директивы, агрегирующие другие директивы и переопределяющие поля компонента так, чтобы тот изменил свой внешний вид или даже поведение. Один вариант равен одному виду поля в макетах Figma.
Реализация варианта combobox-with-hint-variant.directive.ts:
@Directive({ selector: 'aff-combobox[withHint]', standalone: true, }) export class ComboboxWithHintVariantDirective<T> { comboboxComponenRef = inject(ComboboxComponent<T>); constructor() { this.comboboxComponenRef.itemContent = new PolymorpheusComponent( WithHintOptionTemplateComponent ); this.comboboxComponenRef.valueContent = new PolymorpheusComponent( WithHintValueTemplateComponent ); } }
Пример использования варианта для Combobox delivery-form.component.html:
<div [formGroup]="formGroup"> <div class="tui-form__row tui-form__row_multi-fields"> <div class="tui-form__multi-field"> <aff-combobox formControlName="address" withHint [affComboboxDataProvider]="comboboxDataProvider" [stringify]="comboboxStringify" > address </aff-combobox> </div> ... </div>
Пример использования шаблона без директивы-варианта:
<div [formGroup]="formGroup"> <div class="tui-form__row tui-form__row_multi-fields"> <div class="tui-form__multi-field"> <aff-combobox formControlName="address" [affComboboxDataProvider]="comboboxDataProvider" [stringify]="comboboxStringify" [valueContent]="content" [itemContent]="content" > address </aff-combobox> <ng-template #content let-data> <aff-with-hint-option-template [label]="data.label" [hint]="data.hint"></aff-with-hint-option-template> </ng-template> </div> ... </div>
Для удобной работы с разными обертками можно создавать директивы-помощники. Например, сделать директиву, в которую через Input передадим метод, возвращающий список доступных значений каждый раз при обновлении поисковой строки Combobox. Это нужно, чтобы не писать каждый раз логику с подпиской и прокидыванием результата в компонент через шаблон.
Реализация вспомогательной директивы будет такой:
export type ComboboxDataProvider<T> = (term: string) => Observable<Array<T> | null>; @Directive({ selector: '[affComboboxDataProvider]', standalone: true }) export class ComboboxDataProviderDirective<T> implements OnInit, OnDestroy { @Input('affComboboxDataProvider') dataFetchFn!: ComboboxDataProvider<T>; comboboxComponenRef = inject(ComboboxComponent<T>); private subscription!: Subscription; ngOnInit() { this.comboboxComponenRef.search$.pipe( startWith(''), filter((term: string | null) => term !== null), switchMap((term: string | null) => this.dataFetchFn(term)) ).subscribe({ next: (response) => this.comboboxComponenRef.items = response, error: (error) => this.comboboxComponenRef.items = [], }) } ngOnDestroy() { this.subscription?.unsubscribe(); } }
Пример использования мы уже видели ранее в 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:
@Component({ selector: 'aff-contacts-short-form', templateUrl: './contacts-short-form.component.html', styleUrls: ['./contacts-short-form.component.less'], }) export class ContactsShortFormComponent extends FormGroupBase {}
Пример реализации переиспользуемой формы contacts-short-form.component.html:
<div class="tui-form__row tui-form__row_multi-fields" [formGroup]="formGroup"> <div class="tui-form__multi-field"> <aff-input formControlName="name">Name</aff-input> </div> <div class="tui-form__multi-field"> <aff-phone formControlName="phone">Phone</aff-phone> </div> </div>
Пример использования order-form.component.html:
<div class="tui-container tui-container_adaptive tui-space_top-8"> <h1>Pizza order form</h1> <form [formGroup]="formGroup"> ... <h2 class="tui-space_top-8">Contacts</h2> <aff-contacts-short-form formGroupName="contacts"></aff-contacts-short-form> ... </form> </div>
Результаты и полезные ссылки
Создание оберток для всех компонентов Taiga UI заняло один рабочий день, потом пару недель мы обкатывали обертки на формах, с которыми работаем. После успешной обкатки решили целиком перейти на обертки — и не жалеем об этом.
У нас получилось:
-
Сократить время разработки.
-
Улучшить читаемость кода.
-
Сократить количество бойлерплейта — в среднем html-код сократился на 50%.
-
Создать единое место для всего связанного с формами, что снижает вероятность создания дублей.
-
Изолировать зависимость от внешней UI-библиотеки. Если произойдут критичные изменения в UI-библиотеке, мы будем править только обертки, а не все поля у форм во всех проектах монорепозитория.
Полезные ссылки:
-
Концепция директив-контроллеров для компонента в Angular (часть 1, часть 2)
-
Использование директив для расширения компонентов, которыми вы не владеете
-
Building-Up A Complex Objects Using A Multi-Step Form Workflow In ColdFusion
Если есть вопросы — буду рад обсудить в комментариях!
ссылка на оригинал статьи https://habr.com/ru/companies/tinkoff/articles/740706/
Добавить комментарий