Технические долги при проектировании компонентов с помощью Angular

Иногда библиотеки компонентов не вполне соответствуют потребностям компании. Поэтому необходимо расширить функциональность и/или изменить стили компонентов. Но каким образом? Ответом на этот вопрос и является данная статья.

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

Вот три лучших подхода, с которыми я сталкивался.

  1. Превалирование ControlValueAccessor. Все пользовательские компоненты реализуют ControlValueAccessor. Поэтому не только простые, или dumb-компоненты (неинтеллектуальные), но и сложные компоненты используют этот подход.
  2. Применение FormControl в качестве входных данных. Реактивные формы используются во всем приложении. Поэтому каждый пользовательский компонент располагает входом с экземпляром FormControl.
  3. Оборачивание всех компонентов. Разработчики стремятся обеспечить себе лучший опыт работы с приложениями. Поэтому все компоненты, предоставляемые библиотекой компонентов, оборачиваются, а стили настраиваются в соответствии с потребностями компании.

Следующее изображение демонстрирует эти три подхода на простом примере. Задачи должны быть отправлены кому-то, в данном случае получателю или списку получателей. А этот получатель может включать человека-получателя и организацию-получателя. При этом такие простые, или dumb-компоненты, как input и ng-select, предоставляются библиотекой компонентов.

При первом подходе все компоненты, даже task-receiver-list, реализуют ControlValueAccessor. Второй подход использует FormControls в качестве входных данных, а третий  —  оборачивает все dumb-компоненты, предоставляемые библиотекой компонентов.

Конечно, эти три подхода можно совмещать, что приведет к появлению дополнительных подходов. Например, некоторые компоненты используют ControlValueAccessor, а некоторые передают FormControl в качестве входных данных. Однако в целях упрощения ограничимся тремя основными подходами.

Рассмотрим детально каждый этих трех подходов.

1. Превалирование ControlValueAccessor

Пользовательские компоненты не взаимодействуют автоматически с API Angular Forms. Из-за этого привязка к ngModel, formControl и formControlName не будет работать “из коробки”. Однако в Angular появился интерфейс ControlValueAccessor.

ControlValueAccessor действует как мост между API Angular Forms и нативым элементом DOM.

С помощью этого интерфейса можно определить значение, которое должно быть присвоено, и нерабочее состояние. Кроме того, можно определить пользовательские валидаторы.

Имейте в виду: валидаторы, определенные на компоненте более высокого уровня, не будут автоматически распространяться на пользовательский компонент; функции propagateErrors() или propagateStateChange() отсутствуют.

Чтобы не объявлять постоянно функции registerOnChange, registerOnTouched, onChange и onTouched для каждого пользовательского компонента, можно создать вспомогательный класс.

import {ControlValueAccessor as NgControlValueAccessor} from '@angular/forms';

export abstract class ControlValueAccessor<T = any> implements NgControlValueAccessor {
abstract writeValue(value: T): void;

onChange? = (value: T | null) => {};

onTouched? = () => {};

registerOnChange(fn: (value: T | null) => void): void {
this.onChange = fn;
}

registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
}

Теперь рассмотрим этот подход на примере. В интернете множество простых примеров. В свою очередь, мне хотелось бы показать пользовательский компонент, содержащий FormArray. Пользователи могут определить несколько получателей, и эти получатели включают получателя-человека и получателя-организацию.

const formGroup = new FormGroup<ReceiverFormGroup>({
receiverList: new FormControl<unknown>([
{
person: {
firstName: 'Max',
lastName: 'Mustermann',
},
organization: {
name: 'Personal',
},
},
]),

В следующем фрагменте кода показан минимальный пример компонента task-receiver-list.

@Component({
seletctor: 'xyz-task-receiver-list',
templateUrl: './task-receiver-list.component.html',
styleUrls: ['./task-receiver-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: TaskReceiverListComponent,
multi: true,
},
],
})
export class TaskReceiverListComponent extends ControlValueAccessor<ReceiverData[]> implements OnInit, AfterViewInit, OnDestroy {

readonly destroy$ = new Subject<void>();

readonly receiverFormArray = new FormArray<ReceiverDataGroup>(new FormGroup<ReceiverDataForm>({empfaenger: new FormControl<Empfaenger>({
person: new FormControl<Person>('', {nonNullable: true}),
organization: new FormControl<Organization>('', {nonNullable: true}),
});

constructor(private readonly injector: Injector) {
super();
}

ngOnInit(): void {
this.receiverFormArray.value$.pipe(takeUntil(this.destroy$)).subscribe(() => {
const value = this.receiverFormArray.controls.map((receiver) => receiver.value);
if (value !== null) {
this.onChange?.(value);
}
this.onTouched?.();
});
}

writeValue(value: ReceiverData[]): void {
this.receiverFormArray.clear();
value.forEach((receiver, index) => {
this.receiverFormArray.insert(
index,
new FormGroup<ReceiverDataForm>({
person: new FormControl(receiver.name, {nonNullable: true}),
organization: new FormControl(receiver.orgUnit, {nonNullable: true}),
})
);
});
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

...
}

Однако ошибки от компонентов нижнего уровня не будут автоматически распространяться на родительские. Так что все ошибки должны быть заданы вручную. Этого можно добиться с помощью нижеприведенного кода. Обратите внимание: в данном случае все валидаторы устанавливаются для компонентов нижнего уровня, для родительского компонента ReceiverFormGroup валидаторы не определены.

ngAfterViewInit() {
this.receiverFormArray.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((status) => {
if (status !== 'PENDING') {
this.getReceiverListControl().setErrors(<Partial<any> | null>this.receiverFormArray.errors);
}
});
}

getReceiverListControl(): FormControl<unknown> {
const formControlName: FormControlName = <FormControlName>this.injector.get(NgControl);
const formDirective = <FormGroupDirective>formControlName.formDirective;
const parentFormGroup = <{controls: {receiverList: FormControl<unknown>}}>(<unknown>formDirective.form);
return parentFormGroup.controls.receiverList;
}

Если необходимо передать ошибки от родительского компонента к дочернему, то нужно прослушать statusChanges свойства ngControl. Следующий код показывает, как это можно сделать в случае простого компонента ввода.

ngOnInit(): void {
this.ngControl = this.injector.get(NgControl, null);
this.ngControl?.statusChanges?.pipe(startWith(this.ngControl.status), distinctUntilChanged(), takeUntil(this.destroy$)).subscribe((status) => {
if (status !== 'PENDING') {
this.inputValue.setErrors(this.ngControl?.errors ?? []);
}
});
}

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

@Component({
selector: 'xyz-input-field',
templateUrl: './input-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: InputFieldComponent,
multi: true,
},
],
})
export class InputFieldComponent extends ControlValueAccessor<string | undefined> implements OnInit, OnDestroy {
@Input() labelText = '';

inputControl = new FormControl<string>('');

private readonly destroy$ = new Subject<void>();

ngOnInit(): void {
this.inputControl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((newValue) => {
this.onChange?.(newValue?.trim());
this.onTouched?.();
});
}

ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}

writeValue(value: string): void {
this.inputControl.setValue(value);
}

setDisabledState(isDisabled: boolean): void {
disableControl(this.inputControl, isDisabled);
}
}

2. Использование FormControl в качестве входных данных

В отличие от первого подхода, ни значения, ни различные состояния (errors, disabled, touched…) не нужно распространять. Валидаторы и все состояния, такие как dirty (“измененное”, то есть форма поля была изменена пользователем) и touched (“затронутое”, то есть пользователь взаимодействовал с формой поля), могут управляться как компонентом, определяющим FormGroup, так и всеми компонентами, содержащими экземпляр FormControl. В результате пользовательские компоненты не содержат никакого шаблонного кода и гораздо проще, чем ControlValueAccessor.

Однако такой подход не поддерживает формы на основе шаблона. Он будет работать только с реактивными формами.

@Component({
seletctor: 'xyz-task-receiver-list',
templateUrl: './task-receiver-list.component.html',
styleUrls: ['./task-receiver-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TaskReceiverListComponent implements OnInit, OnDestroy {

@Input() receiverListControl: FormArray<ReceiverDataGroup>();

constructor() {}

...
}

3. Обертывание всех компонентов

Одним из самых распространенных примеров обертывания компонентов в Angular является ng-select. Возможно, вы захотите придать компоненту не только особый внешний вид и впечатление от использования, но и некую пользовательскую логику. Поэтому имеет смысл обернуть компонент и предоставить все необходимое через Inputs и Outputs.

@Component({
selector: 'xyz-dropdown',
templateUrl: './xyz-dropdown.component.html',
styleUrls: ['./xyz-dropdown.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [{provide: SELECTION_MODEL_FACTORY, useValue: selectionFactory}],
})
export class DropdownComponent {
@Input() multiple = true;

@Input() set options(options: DropdownOption[]) {
this.loading$.next(false);
this.dropdownOptions$.next(options);
}

@Input() set selectedOptions(options: DropdownOption[] | DropdownOption) {
this.internalValue = options;
}

@Output() readonly optionChange = new EventEmitter<DropdownOption[]>();

@ContentChild(DropdownOptionDirective, {read: TemplateRef}) dropdownOptionTemplate?: TemplateRef<never>;

internalValue: DropdownOption[] | DropdownOption = this.multiple ? [notOption] : notOption;
readonly dropdownOptions$ = new BehaviorSubject<DropdownOption[]>([notOption]);
readonly loading$ = new BehaviorSubject<boolean>(false);

onOptionChange(options: DropdownOption[] | DropdownOption): void {
if (options instanceof Event) {
return;
}
if (Array.isArray(options)) {
this.optionChange.emit(options);
} else {
this.optionChange.emit([options]);
}
}

...
}

Технические долги

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

Для начала выясним, что такое технические долги в отношении дизайна компонентов?

Лично я бы сказал, что технические долги  —  это не только проблемы в структуре кода, о которых знают разработчики, но и решения, которые были приняты, чтобы сократить время на реализацию компонента.

Однако иногда решения, принимаемые для экономии времени, оказываются не такими уж мудрыми. В результате затраты на исправление ошибок в компонентах оказываются довольно высокими.

1. Превалирование ControlValueAccessor

ControlValueAccessor  —  это очень мощная, но и довольно продвинутая функция. В результате решения получаются более сложными и не такими простыми в обслуживании.

Лично я бы указал следующие технические долги.

  • Смешивание валидаторов. Некоторые компоненты имеют собственные валидаторы, другие определяются родительским компонентом, при определении FormGroup.
  • Обертывание существующих компонентов без какой-либо пользовательской логики. Настройка стилей не является достаточно веской причиной для выбора этого подхода.
  • Создание пользовательских компонентов для минимизации шаблонного кода. Иногда возникает необходимость установить определенные свойства по умолчанию или обернуть пользовательский компонент в шаблонный код, который всегда применяется по умолчанию.

2. Использование FormControl в качестве входных данных

Этот подход намного проще и понятнее. Однако поддерживаются только реактивные формы. Имейте в виду, что ни значения, ни состояния не нужно распространять. Все работает по умолчанию, поскольку передается весь экземпляр FormControl.

Однако есть случаи, когда такой подход не имеет смысла.

  • Создание библиотеки компонентов. Если вы хотите создать библиотеку компонентов, то формы на основе шаблонов также должны поддерживаться.
  • Соображения производительности. Я сталкивался с появлением множества проблем в структуре кода при использовании этого подхода. Все они были связаны с обнаружением изменений. Поэтому, если хотите избавиться от проблем с производительностью, возможно, проще выбрать один из двух других подходов.

3. Обертывание всех компонентов

Иногда имеет смысл обернуть существующие компоненты, особенно если можно изменить логику компонента. Однако такой подход не всегда оправдан.

Вот технические долги, которыми чреват этот подход.

  • Корректировка стилей. Если необходимо изменить внешний вид компонента, стили можно перезаписать глобально. Это означает, что существует папка styles со всеми перезаписанными стилями компонента. Помните, что у каждого компонента должен быть свой файл перезаписанных стилей, например _input.scss и _ng-select.scss.
  • Добавление свойств по умолчанию. Добавление свойств по умолчанию уменьшит количество шаблонного кода, но приведет к реализации дополнительного кода для распространения значений и событий (таких как размытие).
  • Улучшение опыта разработчика. Не оборачивайте компоненты, ведь кому-то может потребоваться другой селектор. Обертывание компонентов в таком случае только увеличит время на поддержку компонентов. Каждое событие должно распространяться, а также некоторые состояния, такие как отключение или состояние ошибки.

Вывод

Мы поговорили о трех различных стратегиях реализации пользовательских компонентов: превалирование ControlValueAccessor, использование FormControl в качестве входных данных и оборачивание компонентов. Также были рассмотрены технические долги, связанные со всеми тремя стратегиями.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Robert Maier-Silldorff: Technical Debts in Component Design using Angular

Предыдущая статьяШардинг как паттерн архитектуры базы данных
Следующая статьяСамые значимые психологические исследования в UX-дизайне