Несколько месяцев назад я написал статью «Как мы делаем базовые компоненты в Taiga UI более гибкими: концепция контроллеров компонента в Angular». Я рассказал о том, как мы добавляем гибкости и избавляемся от дублирования кода с помощью DI. Пришло время продолжить статью.

Сегодня посмотрим на более хитрые трюки с такими директивами-контроллерами и попробуем сделать метаконтроллер, который, в свою очередь, состоит из других контроллеров. Все это будет происходить в рамках безграничных возможностей Dependency Injection в Angular.

Возвращаемся в контекст задачи

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

Все инпуты базового компонента я разбил на три категории:

  • Первая группа динамических инпутов часто меняется в процессе использования компонента: сейчас — поле disabled, а через минуту уже нет. 

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

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

Чего мы хотим достичь в итоге

Давайте посмотрим на компонент InputTime:

У него всегда есть customContent в виде часиков и filler для подсказки формата ввода, которые задаются внутри компонента InputTime. Но при этом остальные настройки текстфилда в нем не переопределяются: пользователь компонента все еще может указать наличие крестика, размер и так далее.

Мы хотим иметь возможность совмещать разные настройки на разных уровнях. Чтобы часики задали внутри InputTime, один разработчик при использовании InputTime добавил на него крестик, а другой выше повесил размер «L» для всех полей ввода формы. Все это желательно собрать воедино в одну сущность контроллера и получить из DI в текстфилде.

Создаем директиву-контроллер

За каждую функцию будет отвечать отдельная директива контроллер. Вот так будет выглядеть директива для включения/выключения крестика:

@Directive({
   selector: '[tuiTextfieldCleaner]',
   providers: [
       {
           provide: TUI_TEXTFIELD_CLEANER,
           useExisting: forwardRef(() => TuiTextfieldCleanerDirective),
       },
   ],
})
export class TuiTextfieldCleanerDirective extends Controller {
   @Input('tuiTextfieldCleaner')
   cleaner = false;
}

Компонент наследуется от класса Controller, созданного в прошлой статье:

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

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

export const TUI_TEXTFIELD_CLEANER = new InjectionToken<TuiTextfieldCleanerDirective>(
   'tuiTextfieldCleaner',
   {factory: cleanerDirectiveFactory},
);

export function cleanerDirectiveFactory(): TuiTextfieldCleanerDirective {
   return new TuiTextfieldCleanerDirective();
}

Из всех токенов мы в дальнейшем будем собирать полноценный контроллер. Зачем использовать токен, если можно сразу инжектить из DI директиву? В токен мы можем заложить еще и простейшую фабрику. Если никто по DI не повесил директиву, то нам вернется директива с дефолтными значениями — в такой ситуации именно они нам и нужны.

Организуем контроллеры

Мне удобно создавать отдельный файл для каждого из контроллеров, чтобы всегда легко находить нужную логику.

При этом все они экспортируются наружу из единого Secondary Entry Point и декларируются в едином TuiTextfieldControllerModule, чтобы разработчикам не приходилось импортировать каждое поле по отдельности.

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

Собираем метаконтроллер

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

export class TuiTextfieldController {
   constructor(
       readonly change$: Observable<void>,
       private readonly autocompleteDirective: TuiTextfieldAutocompleteDirective,
       private readonly cleanerDirective: TuiTextfieldCleanerDirective,
       // other directives...
   ) {}

   get autocomplete(): TuiAutofillFieldName | null {
       return this.autocompleteDirective.autocomplete;
   }

   get cleaner(): boolean {
       return this.cleanerDirective.cleaner;
   }

   // other directives...
}

Метаконтроллер будет просто классом, принимающим в себя все сущности директив и стрим проверки изменений — когда кто-то меняет значение инпута одной из директив, мы пересчитываем геттеры и актуализируем параметры.

Экземпляр этого класса будет создаваться для каждого текстфилда в рамках DI-провайдера с фабрикой:

export const TUI_TEXTFIELD_WATCHED_CONTROLLER = new InjectionToken<TuiTextfieldController>(
   'watched textfield controller',
);

export const TEXTFIELD_CONTROLLER_PROVIDER: Provider = [
   TuiDestroyService,
   {
       provide: TUI_TEXTFIELD_WATCHED_CONTROLLER,
       deps: [
           ChangeDetectorRef,
           TuiDestroyService,
           TUI_TEXTFIELD_AUTOCOMPLETE,
           TUI_TEXTFIELD_CLEANER,
           TUI_TEXTFIELD_CUSTOM_CONTENT,
           TUI_TEXTFIELD_EXAMPLE_TEXT,
           TUI_TEXTFIELD_INPUT_MODE,
           TUI_TEXTFIELD_LABEL_OUTSIDE,
           TUI_TEXTFIELD_MAX_LENGTH,
           TUI_TEXTFIELD_SIZE,
           TUI_TEXTFIELD_TYPE,
       ],
       useFactory: textfieldWatchedControllerFactory,
   },
];

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

export function textfieldWatchedControllerFactory(
   changeDetectorRef: ChangeDetectorRef,
   destroy$: Observable<void>,
   ...controllers: [
       TuiTextfieldAutocompleteDirective,
       TuiTextfieldCleanerDirective,
       TuiTextfieldCustomContentDirective,
       TuiTextfieldExampleTextDirective,
       TuiTextfieldInputModeDirective,
       TuiTextfieldLabelOutsideDirective,
       TuiTextfieldMaxLengthDirective,
       TuiTextfieldSizeDirective,
       TuiTextfieldTypeDirective,
   ]
): TuiTextfieldController {
   const change$ = merge(
     ...controllers.map(({change$}) => change$)
   ).pipe(
       takeUntil(destroy$),
       tap(() => changeDetectorRef.markForCheck()),
   );

   change$.subscribe();

   return new TuiTextfieldController(change$, ...controllers);
}

Так мы формируем стрим изменения всех контроллеров и подписываемся на него. Здорово, что здесь легко можно учесть безопасную отписку за счет TuiDestroyService из taiga-ui/cdk.

Используем контроллер в textfield

Теперь нам нужно подключить получившийся контроллер и запустить его в работу:

@Component({
   // …,
   providers: [TEXTFIELD_CONTROLLER_PROVIDER],
})
export class TuiPrimitiveTextfieldComponent {
    constructor(
        @Inject(TUI_TEXTFIELD_WATCHED_CONTROLLER)
        readonly controller: TuiTextfieldController,
    ) {
        super();
    }

    get hasCleaner(): boolean {
       return (
           this.controller.cleaner && this.hasValue && !this.disabled && !this.readOnly
       );
   }

   // ...
}

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

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

Оцениваем пользу

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

Гибкость

Такая кастомизация получается очень гибкой. Допустим, мы хотим сделать форму из пяти контролов размера L и лейблом снаружи ([labelOutside]=“true”). Давайте сравним.

Было: мы добавляем контролы в форму в шаблоне и для каждого отдельного контрола вручную передаем два значения инпутов size и labelOutside с одинаковыми значениями. Под капотом эти инпуты просто передаются в более базовый компонент — никакой логики, лишь дополнительные килобайты в бандле.

Стало: мы можем повесить директивы size и labelOutside на форму и они будут автоматически распространяться на все контролы этой формы благодаря иерархичности DI. Или мы можем повесить директивы прямо на tui-root в app.component, и тогда все контролы приложения по умолчанию будут иметь размер L. 

Сами контролы ничего не знают ни о размерах, ни о лейблах: данные из DI достает только базовый компонент и только в том месте, где это необходимо.

Возможности DI

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

Например, у нас есть контроллер с настройками дропдаунов, который по умолчанию делает дропдаун по ширине контента внутри. Но еще у нас есть и ряд компонентов, в которых по дизайну стандартный дропдаун должен появляться по всей ширине хоста. Давайте просто напишем провайдер для таких компонентов, который позволит переопределить стандартное значение:

export function fixedDropdownControllerFactory(
   directive: TuiDropdownControllerDirective | null,
): TuiDropdownControllerDirective {
   directive = directive || new TuiDropdownControllerDirective();
   directive.limitWidth = 'fixed';

   return directive;
}

export const FIXED_DROPDOWN_CONTROLLER_PROVIDER: Provider = [
   {
       provide: TUI_DROPDOWN_CONTROLLER,
       deps: [[new Optional(), TuiDropdownControllerDirective]],
       useFactory: fixedDropdownControllerFactory,
   },
];

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

Легкость

Раньше десятки наших компонентов содержали однотипные инпуты, которые фактически были им не нужны.

Теперь эти инпуты из них ушли и полностью переместились в DI. Компоненты теперь легче читать, потому что там осталась только их логика. Из-за ушедших инпутов похудеет бандл библиотеки и использующих ее приложений, а у пользователей в рантайме будет задействовано хоть и совсем чуточку, но меньше RAM.

Код

Если вы желаете исследовать показанный код подробнее, предлагаю сделать это на реальных кейсах Taiga UI:

Вместо заключения

Такое хитросплетение сущностей может показаться сложным, но к нему легко привыкнуть после пары кейсов использования контроллеров. В конечном итоге мы даже не используем ничего специфичного или самодельного — лишь директивы Ангуляра, DI-токены и фабрики.

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

Комментарии (6)


  1. kemsky
    21.09.2021 13:45

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

    С этим есть одна неприятность - у вложенных компонентов нельзя ограничить наследование каким-то уровнем, сервис может протекать куда не надо.


    1. MarsiBarsi Автор
      21.09.2021 17:51

      А я наоборот люблю DI за то, что мы можем подкладывать и переопределять сущности на любом уровне. Даже если возникает ситуация, где нам хочется ограничить какой-то скоуп от нашей сущности, то всегда можно переопределить ее на null в обычных или viewProviders


      1. kemsky
        21.09.2021 22:31

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


        1. CrUsH20
          29.11.2021 12:47

          Дальше текущего шаблона по идее есть viewProviders.


  1. alex-aa-jr
    21.09.2021 17:51

    Спасибо за статью!

    Есть вопрос по организации кода: у вас большое количество компонент и теперь появляется большое количество директив-контроллеров с конкретной реализацией логики. Как удаётся отслеживать появление новых директив, дублирующих существующую функциональность?

    И ещё вопрос, связанный с миграцией старого кода на новый: как организовали переход существующих компонент на директивы-контроллеры?


    1. MarsiBarsi Автор
      21.09.2021 18:05

      В директивы-контроллеры не выносится никакая реальная логика, они фактически лишь сокращают путь между местом, куда данные передают (верхнеуровневый компонент), и местом, где их получают (внутренний компонент Textfield), поэтому логика не может перемешаться — она одна и написана в текстфилде. Если мы сделаем еще один передатчик для той же самой логики, то не сможем пропустить это в самом текстфилде.

      Про миграцию отличный вопрос, стоило включить это в статью.

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

      Для совсем полной смены подхода можно написать миграцию для ng update, которая будет перепахивать кодовую базу проектов при бампе версии библиотеки и подменять импорты с одного модуля на модуль + набор отдельных модулей контроллеров. Тут могу порекомендовать наш инструмент ng-morph, который упрощает процесс написания + его легче использовать для обычного проекта (если у вас не библиотека и ng update)