
После долгой разработки нашей библиотеки компонентов Taiga UI мы заметили, что некоторые из наших важных компонентов имеют @Inputs Angular только для того, чтобы передавать их в @Inputs других базовых компонентов внутри. Иногда такое гнездование могло быть даже трехслойным.
Мы решили эту проблему с помощью хитрых директив, которые мы назвали Контроллерами. Они помогли убрать вложение и снизили вес библиотеки.
В этой статье я собираюсь показать, как мы организовали систему настроек для всех текстовых полей в нашей библиотеке с помощью этой концепции и возможностей внедрения зависимостей в Angular.
Текстовое поле в старой версии Тайги: хороший случай попробовать контроллеры
У нас есть компонент Primitive Textfield.
Это стилизованный собственный ввод с оболочкой. Он не работает с формами Angular и нужен нам только для создания других компонентов ввода.
Первая версия Textfield была довольно простой и использовалась в качестве основы для нескольких сложных компонентов. Но вскоре все стало сложнее: мы добавили дополнительные функции, и количество свойств, передаваемых через @Inputs, увеличилось.
Все @Inputs текстового поля можно разделить на три группы. Первый - это динамические @Inputs, которые мы часто меняем: отключение формы или изменение значка глаза на значок закрытого глаза для ввода пароля. Второй - это настройки, которые настраивают тип текстового поля: размер настраивается, есть ли в нем чище или нет. Также есть настройки всплывающих подсказок и двусторонняя привязка значений.
И у нас было 17 различных компонентов, основанных на PrimitiveTextfield, в нашей библиотеке, когда мы начали думать об открытии исходного кода.
Итак, возникли две фундаментальные проблемы:
Компоненты высокого уровня имеют некоторые входные данные только для того, чтобы предоставить их в примитивное текстовое поле без каких-либо преобразований. Оказалось, что если мы добавим новый @Input в Textifeld, нам также нужно будет расширить все 17 компонентов на его основе с помощью этого @Input.
Некоторые @Inputs используются редко, но, тем не менее, они есть во всех компонентах. И это увеличивает вес пакета: мы добавляем один @Input в текстовое поле и по одному для каждого компонента, который основан на нем. Десять проектов, использующих нашу библиотеку, теперь имеют дополнительное свойство, необходимое только в одном из них.
Что ж, давайте переделаем его!
Разделение входных данных на директивы и их применение по мере необходимости
Давайте рассмотрим @Inputs старого Textfield. Было три набора @Inputs для отображения всплывающей подсказки: [tooltipContent], [tooltipDirection] и [tooltipMode].
Это своего рода обособленная логика, и я думаю, что это хороший образец, который нужно сначала отрефакторировать. Мы предоставляем контент, который мы хотим отображать в этих входах, и Textfield имеет внутри логику для этого путем наведения или фокусировки (доступность для пользователей, которые не используют мышь).
Итак, эти три входа предоставляются в Textfield из других компонентов и используются не так часто. Более того, такие подсказки можно использовать и в других компонентах, поэтому мы могли бы сделать отдельную директиву Controller для настройки подсказок в нашей библиотеке.
Это самая простая версия Controller: всего три @Inputs с нужной нам информацией. Селектор директивы имеет только tuiHintContent, потому что, если у него нет содержимого, нет смысла менять его направление или режим.
Мы уже можем привязать эту директиву к Textfield или любому из его родительских элементов. Затем нам нужно ввести директиву с DI и получить ее данные в нашем текстовом поле.
Но есть еще пара аспектов, которые я хотел бы рассмотреть.
Теперь при изменении @Input директивы в компоненте Textfield, работающем с OnPush, не происходит обнаружение изменений, потому что директива объявлена выше в дереве DI и ничего не знает о Textfield. Такие @Inputs не соответствуют обычному поведению Angular. Давайте создадим поток RxJS, который генерируется каждый раз при изменении @Input контроллера. Мне также нравится идея разделить этот поток на абстрактный класс Controller, который будут расширять все остальные контроллеры.
Теперь нам нужно обработать это изменение $ внутри компонента. Самый простой способ - внедрить нашу директиву и ChangeDetectorRef для вызова его метода markForCheck после каждого изменения $ emit. Это хороший вариант, если нам нужен контроллер для одного конкретного компонента.
Это можно использовать таким образом. Внимание: это не окончательное решение, мы проведем рефакторинг и абстрагируем его позже.
Теперь, если мы хотим показать подсказку в текстовом поле, нам просто нужно привязать директиву «tuiHintContent» к компоненту или любому из его родительских элементов.
На этом этапе мы очистили наш пакет: все компоненты оболочки Textfield не имеют @Inputs, необходимых для передачи функций подсказки. И каждый экземпляр этих компонентов не содержит лишних свойств.
Но теперь становится сложнее повторно использовать наш контроллер в других базовых компонентах, потому что тогда нам нужно запомнить и повторить тот же код с обнаружением изменений и отменой подписки в каждом компоненте. Например, я хочу добавить поддержку HintController для TextArea (которая не основана на Textfield) тоже: теперь мне нужно написать точно такой же код в конструкторе, который мы видим в предыдущем примере.
Хорошо, давайте рассмотрим абстрактное обнаружение изменений в провайдерах.
Итак, мы хотим, чтобы наш компонент получал контроллер как объект с данными без дополнительных подписок с проблемами обнаружения изменений и нулевой проверки с помощью @Optional. И мы можем сделать это с помощью провайдеров DI в Angular.
Вот что мы хотим получить в компоненте Textfield:
Добавим TUI_HINT_WATCHED_CONTROLLER и его провайдера:
Когда мы вводим такой токен в наш компонент, он автоматически добавляет подписку на изменения внутри фабрики. Мы добавим это HINT_CONTROLLER_PROVIDER в providers компонента Textfield, чтобы deps получило фактические ChangeDetectorRef и TuiDestroyService. Это простой сервис, который мы предоставили выше, провайдер подсказок, который связывается с ngOnDestroy инжектора компонентов и вызывает метод next, который сам также является субъектом (если вы его не получили, просто перейдите по ссылке с реализацией).
Нам просто нужно добавить поставщика и ввести наш новый токен:
Что ж, теперь мы можем привязать директиву к Textfield или любому компоненту или элементу, внутри которого есть Textfield. Обнаружение изменений Textfield будет вызываться после каждого изменения директивы @Input in из-за безопасной подписки на заводе.
Работать с Контроллером в текстовом поле очень удобно: мы просто получаем готовую сущность из дерева DI и используем ее в шаблоне или геттере, не беспокоясь об обнаружении изменений, подписках или их существовании.
Я вижу здесь одно потенциальное улучшение: hintWatchedControllerFactory можно спроектировать как обычную фабрику, которая может работать со всеми контроллерами. Мы сделали это после добавления второго типа контроллера в библиотеку, но текущее решение пока подходит.
Что дальше?
Мы рассмотрели лишь один простой случай создания контроллера. Но у Textfield также есть набор настроек, которые мы разделили в сложный контроллер, который работает с любым уровнем вложенности: мы можем установить один @Input для Textfield, другой - для его родительского компонента, а третий - для всей формы для всех текстовых полей внутри. Более того, каждый @Input можно переназначить на любом уровне вложенности. И все это сделано с использованием чистого Angular DI по умолчанию, хотя и с максимальным его использованием.
Я готов написать об этом еще одну статью, но сначала хочу понять, есть ли люди, которые хотят ее прочитать. Если вам это интересно, дайте мне знать!
Наконец-то
Благодаря нескольким десяткам строк кода и нескольким трюкам с Angular DI мы сократили повторение нашего кода, предварительно настроили компоненты и снизили вес самой библиотеки и всех приложений, которые ее используют.
Это решение непросто для понимания и в некоторых случаях может оказаться чрезмерным. Но в нашей ситуации это помогло нам упростить большую часть нашего пакета с помощью небольшого набора умного использования DI и аккуратного API.