Хост директивы: ключ к декомпозиции

В Angular 15 появилась новая фича, которой не уделяют должного внимания, — Directive Composition API. Она добавляет hostDirectives: [...] в декоратор @Component/@Directive. В этом массиве можно перечислить standalone-директивы, которые хотим автоматически навесить на компонент или директиву. Это позволяет очень удобно декомпозировать логику и открывает много дверей для новых подходов к разработке.

df08ed54bf9df48e123788a281693174.png

Я считаю, что сообщество не оценило инструмент по достоинству. Даже сама команда Angular использует этот API всего один раз во всей кодовой базе Angular Material. Думаю, это объясняет все недочеты этой фичи, о которых мы тоже поговорим, но не раньше, чем разберемся, какой крутой инструмент попал к нам в руки. Что нас ждет:

  1. Обзор API

  2. Почему он важен и полезен нам в Taiga UI

  3. Конкретные примеры использования

  4. Проблемы и как с ними бороться

Поехали!

Обзор API

Директивы перечисляются в виде массива классов или объектов, в которых указаны все инпуты и аутпуты, которые мы хотим сделать доступными на хосте. Ментальная модель, которую я применяю к этому API, — провайдеры на стероидах. Раньше мы уже могли разбивать логику на сервисы, добавляя их в провайдеры. Посмотрим, в чем отличия:

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

  2. У провайдеров нет доступа к хост-элементу, кроме как через ElementRef. Хост директивы же — это обычные директивы. Они могут использовать байндинг и слушать события на хосте — это огромный плюс.

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

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

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

Taiga UI

Хотя эта штука появилась в Angular 15, она до сих пор не привлекла должного внимания. Ни одна из крупных библиотек— Material, ng-zorro, PrimeNG, Bootstrap — не использует Directive Composition API. Я работал над UI-компонентами более пяти лет и очень обрадовался, когда этот API стал мне доступен. Этой статьей я надеюсь вызвать больший интерес в сообществе Angular, продемонстрировав, чего теперь можно лаконично и эргономично добиться.

Лучшее место для поиска примеров, о котором я знаю, — это Taiga UI, библиотека, разработанная моей командой. Кроме Taiga обширным применением хост-директив может еще похвастаться Spartan, но у меня нет опыта работы с ним. 

Недавно мы провели огромный рефакторинг для следующей мажорной версии, где обновили Angular до 16 и наконец разблокировали эту функцию. И мы знатно разгулялись! В исходном коде hostDirectives встречается более 70 раз. 

Расскажу, что мы узнали в ходе нашего рефакторинга.

Компонент — это ценный слот

Один элемент может иметь много директив, но только один компонент. До появления Directive Composition API, если вы хотели применить несколько директив разом, вам приходилось создавать компонент-обертку. Представьте, что нужно повесить выпадающий список на кнопку с некоторыми визуальными стилями и, возможно, логикой открытия и закрытия, — это все будут директивы. Так вам приходилось создавать компонент, чтобы их все навесить:


  

С таким кодом внутри:

Недостатки такого подхода:

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

  2. Сложно настроить. Нужно пробросить все параметры, такие как содержимое выпадающего списка, к директивам, навешанным внутри.

  3. Все директивы находятся во вью и, следовательно, недоступны для DI, если хотим использовать их позже.

С Angular 15+ мы можем просто сделать директиву, которая решит все перечисленные проблемы:

@Directive({
  standalone: true,
  selector: '[customDropdown]',
  hostDirectives: [
    VisualDirective,
    OpenCloseLogic,
    {
      directive: Dropdown,
      inputs: ['myDropdown: customDropdown'],
    },
  ],
})
export class CustomDropdown {}

Для инпутов даже можно задать алиас, чтобы максимально упростить использование:

Сайдквест: стили в директивах

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

Сейчас мы можем бандлить стили только в компонентах. Если нам нужно что-то вроде autoprefixr, препроцессора,   стиле вроде @keyframes, или псевдоклассов вроде :hover, необходимо иметь компонент, поскольку эти вещи не работают с инлайн-стилями через [style].

Мы можем использовать глобальные стили, но это решение плохое с точки зрения композиции, не поддерживает tree-shaking и сложно применимо в случае библиотек.

В Taiga UI мы придумали простой workaround, используя неинкапсулированные стили и динамические компоненты.

@Component({
  standalone: true,
  template: '',
  styles: '[myDir]:hover { color: red }',
  encapsulation: ViewEncapsulation.None,
})
class MyDirStyles {}

@Directive({
  standalone: true,
  selector: '[myDir]',
})
export class MyDir {
  protected readonly nothing = withStyles(MyDirStyles);
}

withStyles — небольшая утилитка, которая создает компонент. Создание компонента добавит его стили в head. Нам понадобится DI-токен для отслеживания этих компонентов:

const MAP = new InjectionToken('', {
  factory: () => {
    const map = new Map();

    inject(DestroyRef).onDestroy(() => 
      map.forEach((component) => component.destroy())
    );

    return map;
  }
});

Небольшая вспомогательная функция для инъекции и добавления нашего компонента при создании директивы:

export function withStyles(component: Type) {
  const map = inject(MAP);
  const environmentInjector = inject(EnvironmentInjector);

  if (!map.has(component)) {
    map.set(component, createComponent(component, {environmentInjector}));
  }
}

Теперь наши директивы могут комбинировать логику через hostDirectives и применять стили, используя withStyles. Пришла пора погрузиться в реальные примеры использования.

Примеры из нашей библиотеки

У нас есть много директив, которые мы применяем как хост-директивы. Мы сосредоточимся на: Appearance, Icons, Dropdown, ControlValueAccessor, Maskito.

Appearance. Все наши компоненты используют одну и ту же основную директиву для управления их интерактивным внешним видом. Темизация Taiga UI состоит из объявления CSS-переменных и этих внешних видов. Например, вот исходный код Accent.

Мы используем специальные миксины для состояний :hover и :active, чтобы эффект наведения не применялся на сенсорных устройствах и эти стили состояния работали только на интерактивных элементах, таких как кнопки или ссылки. Так при использовании неинтерактивных значков наведение на них не изменяет цвет. 

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

Отмеченный чекбокс использует внешний вид Primary, а неотмеченный — Whiteblock, так же как кнопки или, потенциально, даже текстовые поля

Отмеченный чекбокс использует внешний вид Primary, а неотмеченный — Whiteblock, так же как кнопки или, потенциально, даже текстовые поля

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

Исходный код

Icons —эта директива немного интереснее. В Taiga UI 4 мы перешли на использование CSS-масок для раскрашивания SVG-иконок с помощью CSS. Это довольно крутая техника, потому что она даже не требует дополнительного DOM-элемента, иконка может находиться внутри псевдоэлемента ::before/::after.

Скрытый текст

Посмотрите этот Stackblitz для более интересных примеров использования CSS-масок.

И поскольку нам не нужен элемент DOM, мы можем использовать директиву. Такой же подход с псевдоэлементами можно применить к кнопкам, ссылкам, вкладкам, элементам выпадающих списков и так далее. Логика, которая находит иконку по ее имени, хранится в директиве, и эта директива открывает наружу инпуты iconStart/iconEnd. Все, что нам нужно сделать, — это подготовить наши компоненты с отступами и паддингами, чтобы, когда добавляются иконки, они были правильно расположены.

Исходный код 

Dropdown. Перейдем от косметики к логике и взглянем на наши выпадающие списки. Ранее я уже исследовал декомпозицию, разбирая выпадающие списки и тултипы в Taiga UI. Я рекомендую прочитать мою предыдущую статью. Хост-директивы позволяют нам развить идеи, изложенные там.

Декомпозиция — ваша суперсила

Можно долго изучать Angular, оттачивая навыки работы с разными аспектами фреймворка, разбирая паттер…

habr.com

Фактически наши выпадающие списки отвечают на следующие вопросы с помощью директив:

  • Что показывать?

  • Когда показывать?

  • Где показывать?

API композиции директив — отличный способ объединить директивы вместе. Например, у нас есть [(tuiDropdownOpen)], который управляет выпадающим списком, вручную передавая true/false. Это двусторонний байндинг, потому что в него входит директива ActiveZone, чтобы выпадашка закрылась, когда мы щелкаем мимо, и сообщила об этом.

Dropdown сама по себе — это коллекция хост-директив, но мы можем добавить ее к текстовым полям и пробросить байндинг, чтобы создавать расширения с выпадашкой (select/combo-box/date-picker). Многослойные хост-директивы могут быть очень полезны!

Исходный код

ControlValueAccessor — еще один хороший случай для хост-директив. А если смотреть шире — решение циклических зависимостей. Представьте, что у вас есть аксессор, который делает большую часть того, что вам нужно. Но вдруг нужно проверить touched у контрола —  для этого потребуется NgControl. Но это будет циклической зависимостью, поскольку он уже инжектит класс в качестве ControlValueAccessor.

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

Например, в Taiga UI есть вертикальные и горизонтальные Tabs — как два разных компонента. Но логика, которая отслеживает текущую активную вкладку, используется повторно между ними как хост-директива. 

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

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

Maskito —  наша фреймворк-независимая библиотека маскирования ввода. Хорошим местом для знакомства с ней может быть мой обзор или статья ее ведущего разработчика Никиты Барсукова. В Angular она поставляется в виде директивы. Иногда конкретно настроенную маску нужно зашить в компонент. Одним из таких примеров будет ввод информации о кредитной карте. 

У нас есть маска для номера карты, которая показывает его кусками по 4 цифры. Eсть срок действия, который не позволит вам ввести 13-й месяц. И CVC, чтобы вводить только 3 цифры. Мы могли бы просто предоставить эти конфигурации масок и позволить людям применять их вручную, но есть и другие полезные вещи, для которых нужен полноценный компонент InputCard. Он может распарсить для вас платежную систему, убрать пробелы из значения, чтобы в контроле лежали только цифры, добавить правильный атрибут автозаполнения.

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

Можно еще долго смотреть разные примеры, но я боюсь вас утомить. Если вы хотите узнать больше, вы можете исследовать исходный код Taiga UI. Maskito потребовал от нас настройки хост-директивы из ее хоста. И это плавно подводит нас к разбору проблем.

Проблемы Directive Composition API

Плотно поработав с Directive Composition API, я выделил три основные проблемы, которые ограничивают эту фичу. Но ничего слишком критичного, это здорово!

Нет встроенного управления. Мы уже видели проблему с последним примером: трудно контролировать входные данные хост-директив изнутри хоста. Я знаю, что команда Angular планирует это как-то решить. Но пока мы можем сделать это сами, используя сигналы и хелпер.

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

Для ручного управления сначала нужно заинжектить директиву, которую мы хотим контролировать, затем указать свойство, которое мы планируем использовать, и предоставить для него значение. Есть два способа: императивные обновления (сеттер) или декларативные (геттер). Сигналы здесь отлично работают. WritableSignal действуют как сеттер, в то время как computed будут работать как геттер:

private readonly setter = binding(MyDir, 'prop1', initialValue);
private readonly getter = binding(MyDir, 'prop2', computed(() => this.signal));

Так выглядит API подобного хелпера. Мы можем вызывать setter.set(value) чтобы обновить значение prop1 и контролировать prop2 с помощью сигналов внутри computed. Посмотреть хелпер живьем можно на этом Stackblitz.

Проброс входных данных вручную. Я бы предпочел, чтобы хост-директивы автоматически открывали все входные данные, а их скрытие было бы опциональным, а не наоборот. Но команда Angular не разделяет эту точку зрения, они беспокоятся о том, что обновление версии какой-либо сторонней библиотеки может привести к появлению неожиданных инпутов на ваших компонентах. Я не вижу ценности в этой дополнительной безопасности. Для меня важна краткость. 

Проблема в том, что вы не можете просто хранить и переиспользовать объект с директивой и входными данными как константу, так как это не будет поддаваться статическому анализу.

В Taiga UI мы пришли к такой конвенции. Представьте, что у вас есть директива A с тремя входными данными. Вместо того чтобы каждый раз писать это как объект с массивом всех этих входных данных, мы можем создать еще одну небольшую директиву:

@Directive({
  standalone: true,
  hostDirectives: [{
    directive: A,
    inputs: ['input1', 'input2', 'input3'],
  }],
})
export class WithA {}

Теперь мы можем легко открыть все эти входные данные с помощью одного класса, добавляя WithA вместо A в hostDirectives, когда это необходимо. 

Когда эта функция только появилась, в документации можно было заметить примечание о производительности. Нас предостерегали от использования слишком большого количества хост-директив. Теперь это замечание убрано, поскольку бенчмаркинг показывает, что накладные расходы на память/производительность несущественны. Вы можете поизучать этот Stackblitz, чтобы самостоятельно протестировать перформанс.

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

Есть некоторые опасения по поводу порядка выполнения. Неясно, какая директива создается первой. И директивы могут конфликтовать, когда они пытаются задать одно и то же свойство на хосте, например.

Обычные директивы создаются в порядке совпадения атрибутов на элементе, так что это можно контролировать. Я бы сказал, что, если ваши директивы зависят от порядка инициализации, это уже огромный антипаттерн и это не должно сдерживать Directive Composition API. С этой проблемой у меня возникло больше всего трудностей, особенно поскольку она может всплыть неожиданно через несколько слоев хост-директив. Поэтому я надеюсь, что команда Angular займется этим. Вы можете проголосовать за эту issue чтобы обратить их внимание.

Самый простой способ столкнуться с проблемой двойного матчинга — открыть инпут директивы, а затем импортировать ее снова. Допустим, у вас есть директива выделения, которая имеет входной цвет:

@Directive({
  standalone: true,
  selector: '[appHighlight]',
})
export class HighlightDir {
  // ...
}

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

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

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

Итоги

Я надеюсь, что эта статья дала представление о Directive Composition API и о том, какие возможности она открывает. У этого подхода, как всегда, есть ограничения. Но с большинством из них можно справиться. Мне очень нравятся новые композиционные паттерны, которые он открыл, и я уверен, что люди могут извлечь из них пользу.

Краткое резюме функции в нескольких пунктах:

  • hostDirectives позволяют вам автоматически вешать директивы на другие директивы или компоненты.

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

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

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

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

© Habrahabr.ru