Всем привет! Я Александр Бухтатый, 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
Если есть вопросы - буду рад обсудить в комментариях!
astec
Ничего непонятно, но очень интересно!
Можете написать статью в чём схожесть и отличие TUI от Ionic Framework?
alex_mayak Автор
Спасибо за комментарий.
Постараюсь в следующих публикациях понятней расписывать материал.
Вы можете ознакомится с демо на stackblitz
Если кратко то мы делаем удобней работу с внешней библиотекой компонентов