Как стать автором
Обновить
287.32
TINKOFF
IT’s Tinkoff — просто о сложном

Как мы делаем базовые компоненты в Taiga UI более гибкими: концепция контроллеров компонента в Angular

Время на прочтение 6 мин
Количество просмотров 11K

В процессе эволюции нашей библиотеки компонентов Taiga UI мы стали замечать, что некоторые компоненты посложнее имеют @Input просто для того, чтобы прокинуть его значение в @Input другого нашего базового компонента внутри себя. Иногда встречается такая вложенность даже в три слоя.

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

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

Textfield в былой «Тайге»: хороший кейс, когда можно применить контроллеры

У нас есть базовый компонент ввода, который называется Primitive Textfield.

Этот компонент представляет собой стилизованный под нашу тему нативный инпут с оберткой для него. Он не работает с формами Angular и нужен для построения полноценных контролов. 

Самая первая версия текстфилда была довольно простой и использовалась в нескольких составных компонентах ввода. Но вскоре он стал усложняться: добавлялись новые возможности, @Input'ов у компонента становилось все больше. 

К моменту начала подготовки опенсорсной версии «Тайги» на основе Textfield было сделано 17 компонентов ввода. Отсюда начали развиваться две фундаментальные проблемы:

  • Компоненты имеют некоторые @Input’ы исключительно для того, чтобы пробросить их дальше в текстфилд, никак не преобразовывая. Получается, при добавлении нового такого поля в textfield нам по-хорошему нужно расширить такой возможностью все 17 компонентов с ним.

  • Некоторые @Input’ы используются довольно редко, но должны быть у всех компонентов. Это начинает раздувать бандл: добавляем один @Inputs в текстфилд и по одному такому же — в компоненты. В 10 проектах у всех полей ввода появляется проперти, которое сейчас нужно только одному из них. Не очень.

Что ж, давайте попробуем сделать иначе.

Вариант с одноуровневой директивой

Среди @Input’ов текстфилда были три для тултипа, ими и займемся в первую очередь. В текстфилд передается, что и как мы хотим показать, а он внутри себя реализует логику вывода подсказки: по ховеру или по фокусу поля ввода с клавиатуры (доступность для пользователей без мышки).

Эти три @Input’а просто прокидываются в текстфилд из других компонентов, причем и используются они не так уж и часто. Кроме того, подобные подсказки бывают не только в полях ввода. Давайте сделаем отдельную директиву-контроллер для конфигурации подсказок в нашей библиотеке:

@Directive({
   selector: '[tuiHintContent]'
})
export class TuiHintControllerDirective {
   @Input('tuiHintContent')
   content: PolymorpheusContent = ’’;
 
   @Input('tuiHintDirection')
   direction: TuiDirection = 'bottom-left';
 
   @Input('tuiHintMode')
   mode: TuiHintMode | null = null;
}

Это самый минимум для нашего контроллера — три @Input’а с информацией, которая нам нужна. Селектор директивы содержит только “tuiHintContent”, потому что при отсутствии контента нам нет смысла выравнивать или перекрашивать подсказку.

Эту директиву уже можно вешать на текстфилд или любой его родительский элемент. Далее инжектим сущность директивы с помощью DI и получаем из нее данные в нашем текстфилде. Но есть еще пара моментов, которые хотелось бы учесть.

Сейчас при изменении @Input’а директивы не будет происходить проверка изменений в текстфилде при OnPush-стратегии, что не соответствует поведению ангуляровских @Input’ов. Для этого давайте добавим стрим, который будет стрелять, когда @Input контроллера поменяется. Для удобства предлагаю вынести такой стрим в абстрактный класс Controller, от которого будут наследоваться все контроллеры:

export abstract class Controller implements OnChanges {
   readonly change$ = new Subject<void>();
 
   ngOnChanges() {
       this.change$.next();
   }
}

При изменении инпута директивы будет вызван ngOnChanges, который уже и оповестит стрим. Отнаследуем директиву от абстрактного класса:

@Directive({
   selector: '[tuiHintContent]'
 })
export class TuiHintControllerDirective extends Controller {
    // ...
}

Теперь давайте придумаем, как мы будем обрабатывать change$ в компоненте. Самый простой вариант — инжектить в текстфилде эту директиву и ChangeDetectorRef, вызывая markForCheck по каждому эмиту change$. Этот вариант подойдет, если контроллер будет использоваться только с одним компонентом:

constructor(
  @Inject(ChangeDetectorRef) private readonly changeDetectorRef: ChangeDetectorRef,
  @Optional()
  @Inject(TuiHintControllerDirective)
  readonly hintController: TuiHintControllerDirective | null,
) {
  if (!hintController) {
    return;
  }

  hintController.change$.pipe(takeUntil(this.destroy$)).subscribe(() => {
    changeDetectorRef.markForCheck();
  });
}

Вот так это можно использовать. Вариант не финальный — позже мы это причешем и абстрагируем.

Чтобы у текстфилда появилась подсказка, нужно лишь повесить директиву “tuiHintContent” на сам компонент textfield или на любой из его родительских элементов.

На этом этапе мы хорошо подчищаем вес нашего бандла: у всех компонентов-оберток больше нет @Input’ов для проброса. А каждая созданная сущность не будет содержать лишних для нее полей.

Минус такой схемы проявляется, если контроллер может использоваться для разных компонентов: нам надо вспоминать об этом стриме и дублировать такой код в каждом компоненте, использующем контроллер.

Абстрагируем проверку изменений в провайдеры

Чтобы компонент сразу мог получать сущность директивы и использовать ее без подписок на изменения, проверок на null, мы воспользуемся возможностями DI-провайдеров в Angular:

constructor(
  @Inject(TUI_HINT_WATCHED_CONTROLLER)
  readonly hintController: TuiHintControllerDirective,
) {}

Вот такой вариант работы мы хотим получить в текстфилде. Давайте добавим токен TUI_HINT_WATCHED_CONTROLLER и провайдер для него:

export const TUI_HINT_WATCHED_CONTROLLER = new InjectionToken('watched hint controller');
 
export const HINT_CONTROLLER_PROVIDER: Provider = [
   TuiDestroyService,
   {
       provide: TUI_HINT_WATCHED_CONTROLLER,
       deps: [[new Optional(), TuiHintControllerDirective], ChangeDetectorRef, TuiDestroyService],
       useFactory: hintWatchedControllerFactory,
   },
];
 
export function hintWatchedControllerFactory(
   controller: TuiHintControllerDirective | null,
   changeDetectorRef: ChangeDetectorRef,
   destroy$: Observable<void>,
): Controller {
  if (!controller) {
     return new TuiHintControllerDirective();
  }
 
   controller.change$.pipe(takeUntil(destroy$)).subscribe(() => {
      changeDetectorRef.markForCheck();
   });
 
   return controller;
}

Так мы создали токен, при инжекте которого будет происходить подписка на изменения директивы через HINT_CONTROLLER_PROVIDER. Этот провайдер мы добавим в “providers” компонента текстфилда, чтобы получить в deps актуальные значения ChangeDetectorRef и TuiDestroyService. Этот сервис мы провайдим тут же, он просто подхватывает ngOnDestroy хук инжектора компонента и после этого некстит и комплитит сабжект, которым сам же и является (если не поняли, то лучше загляните в реализацию по ссылке).

Осталось только добавить провайдер и заинжектить токен:

@Component({
   //...
   providers: [HINT_CONTROLLER_PROVIDER],
})
export class TuiPrimitiveTextfieldComponent {
   constructor(
       //...
       @Inject(TUI_HINT_WATCHED_CONTROLLER)
       readonly hintController: TuiHintControllerDirective,
   ) {}
}

Теперь мы можем повесить директиву хинта на сам текстфилд или на любой компонент или элемент, внутри которого спрятан текстфилд. При изменении @Input’а директивы будет вызываться и проверка изменений для текстфилда за счет безопасной подписки в фабрике.

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

В развернутом выше решении напрашивается одно улучшение: hintWatchedControllerFactory можно сделать общей фабрикой, которая будет работать со всеми контроллерами. Мы в библиотеке сделали это после появления второго контроллера, но пока можем оставить так.

Что дальше?

Мы разобрали лишь один самый простой случай создания контроллера. В описанных мною @Input’ах текстфилда есть также ряд настроек, которые мы вынесли в более сложный контроллер. Он многоуровневый и работает для любой вложенности: можно одну настройку повесить на сам текстфилд, одну — на оборачивающий его компонент, а третью и вовсе определить для всех полей ввода в форме. Более того, любой из настроек можно переопределить значение по умолчанию на любом уровне вложенности. И все это сделано лишь на чистом DI ангуляра, хоть и с его использованием на максимуме.

Я готов написать об этом статью-продолжение, но сначала хочу узнать у вас, нужна ли она. Если вам было бы интересно почитать статью о более хитрых трюках — дайте мне знать.

Итого

За счет небольшого количества кода и трюков с DI ангуляра мы смогли убрать лишнее переиспользование кода, сделать сущности приложения легче, а также подразгрузить бандл как библиотеки, так и использующих ее приложений. Решение не самое простое для понимания и может быть избыточно для небольших приложений и библиотек, но в нашем случае оно сильно помогло разгрузить крупную часть пакета, спрятав ее в маленький набор точечной и аккуратной работы с DI, скрытым за лаконичным API.

Теги:
Хабы:
+26
Комментарии 14
Комментарии Комментарии 14

Публикации

Информация

Сайт
www.tinkoff.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия