Предисловие
Я занимаюсь разработкой web-приложений на Angular уже более 6-ти лет и в силу своего не малого опыта я постепенно отошел от кодинга типичных фичей, таких как сверстать формочку и отправить данные на бэк, к более глобальным и важным аспектам разработки web-приложения - архитектуре. Довольно часто мне приходиться делать как обычный code-review на этапе разработки приложений, так и меня могут позвать оценить качество уже написанного приложения. И вот недавно я занимался просмотром кода очередного продукта и заметил то, чем хотел бы поделиться с Вами, коллеги.
Функционал во всем приложении следующий - на каждой странице, которая отвечает за отображение списков неких сущностей (абстрагируемся) почти всегда есть форма, отвечающая за вывод детальной информации о выбранной сущности из списка. На данной форме есть типичные кнопки с вариантами действий: "Отменить" и "Сохранить" внесенные изменения. Логика проста, не внесли данные в форму - кнопка "Сохранить" не активна. Внесли данные - кнопка "Сохранить" активна. Разработчики, естественно, использовали атрибут disabled и как наверняка вы догадались интерполировали его и записывали в него результат выполнения ряда логических условий. И все бы ничего, но иногда это выглядело следующим образом:
<p-button
(click)="createOrUpdate()"
[disabled]="someControlVeryLongName.invalid || !someControlVeryLongName.dirty || (loading$ | async) === true || (subloading$ | async) === true"
На первый взгляд мы видим только одну проблему - ооооооочень большое выражение, для такой малой "персоны", как disabled. Но проблема не заканчивается, а только начинается в данном компоненте и везде, где есть похожая логика. Дело в том, что атрибут disabled пропускает событие click в любом случае... Это означает, что данную проверку мы вынуждены тащить в компонент, а именно в данном случае в метод createOrUpdate. Вторая проблема (неудобство) - это потоки: loading$ и subloading$. В методе мы должны их скомбинировать со значениями dirty и invalid контрола и если все все хорошо, позволить отправку данных на обновление или создание некой сущности. Третья проблема, вся логика дублируется по всем компонентам в приложении - DRY.
Пути решения
Конечно можно было бы не предавать этой проблеме столь важное значение (работает - не трогай), но у меня появился "спортивный интерес" к тому, как ее можно "красиво" решить.
Первое, что приходит в голову это перенести логику на другой механизм, которыми Angular в прямом смысле кишит. Сперва я подумал о директиве. Что если создать атрибут-директиву и просто вешать ее на кнопки, где нужно отслеживать состояние disabled от нужных мне условий? Так как проект использует state-management NGXS - достать потоки из него внутри директивы не проблема. Но что делать для состояния invalid/dirty , на которые директива должна обращать анимание? Причем на каких-то страницах это FormGroup, а на других FormControl. Зная иерархию контролов (все формы в приложении написаны с использованием реактивных форм RF) я понимаю, что данные флаги есть у AbstractControl, а он является базовым для FormGroup и FormControl. Мне остается просто прокинуть в директиву нужный контрол, а input-параметр сделать типа AbstractControl. Казалось бы проблема решена, но... Если вы обратите внимание на контрол кнопки - это элемент из UI-Framework PrimeNG. У него нет API, которое мне поможет повлиять на параметр disabled. Даже если и было, то мне пришлось бы использовать dirty-checking (принудительно вызывать detect changes), а я не большой фанат так делать. Вот как бы выглядела директива:

Есть ли другие способы? Я уверен, что есть, но на сколько они эффективны и объемны не знаю. И тут я подумал о декораторах...
Великий и могучий Angular
В реальных проектах, написанных на Angular, как правило, не требуется написание собственных декораторов, а если требуется, то скорее всего вы создаете свою библиотеку, для упрощения "жизни" коллегам программистам. Angular имеет множество build-in декораторов на все случаи жизни (Input, Output, Component, Directive, Optional, Inject и многие другие) и их действительно хватает, и их написание - нонсенс. Но в данной ситуации я не увидел действительно хорошего решения из базовых или типичных. Согласитесь, было бы удобно иметь инструмент под рукой, похожий на этот:
View-model компонента:
@Disabled(...)
public disabled$!: Observable<boolean>;
View компонента
<p-button [disabled]="!!(disabled$ | async)" (click)="createOrUpdate()">
Как по мне выглядит круто.
Декораторы
Если вы уже имели дело с TypeScript - вы знаете, что декораторы - это не фича Angular, а фича языка TypeScript. Она позволяет изменять сущность, к которой применяется, на специфическом уровне, который называется Reflection. Этот уровень предназначен для работы с типами и их параметрами (дескрипторами). Важно помнить, что декоратор это обычная функция, которая вызывается(применятется) к сущности. Всего типов декораторов 4:
Декоратор класса;
Декоратор свойства класса;
Декоратор метода класса;
Декоратор параметра метода (функции)
К каждой сущности можно применить более одного декоратора. В данной статье мы на реальном примере рассмотрим применение декоратора второго типа (декоратор свойства).
Написание декоратора
Любое написание декоратора сводится к одному - описанию функции, которая должна применить к целевой сущности "декорирование". То есть добавить нужные поля/методы по вашему правилу, а сущность этими полезностями будет пользоваться.
Сперва вспомним, что для каждого компонента, который нуждается в функционале по определению disabled для кнопки, всегда есть AbstractControl, а также потоки, от которых зависит состояние disabled.
Проба пера:

На первый взгляд некоторым может показаться, что это "магия в не Хогварса" и я хотел бы немного остановиться более детально и пояснить, что магии или колдовства здесь нет. Когда мы декорируем нашим декоратором свойство/метод/атрибут/класс - наш декоратор вызывается! (Как и любые другие декораторы) И в этот момент мы можем передавать какую-либо настройку для его дальнейшей работы, как в обычную функцию. В данном случае я просто хочу передать имя контрола в компоненте, от которого и зависит состояние disabled моей кнопки. Функция Object.defineProperty просто помогает мне объявить в компоненте свойство с таким же именем, к которому применяется мой декоратор. В нашем случае так:
target - компонент, в котором будет объявлено свойство;
key - имя нового свойства;
descriptor - дескриптор. Именно он описывает как себя будет вести и как будет доступно из вне наше новое свойство.
Третий параметр функции (дескриптор) параметрами enumerable и configurable говорит, что свойство доступно из вне для перебора (например для цикла for...in) и может конфигурироваться, так как мы можем применять и другие декораторы к текущему новому свойству. И конечно же метод get. То есть что делать и что возвращать, когда кто-то старается наше свойство прочитать. Именно здесь мы и встраиваем нашу логику по получению итогового состояния нашей кнопки.
Не просто так здесь использовалась именно анонимная функция, а не стрелочная, чтобы не потерять контекст. Контекстом и будет в итоге наш с вами компонент и именно через this мы получаем все данные, которые нам нужны для определения disabled.
View-model:
@Disabled('agentNameControl')
public disabled$!: Observable<boolean>;
View:
<p-button [disabled]="!!(disabled$ | async)" (click)="createOrUpdate()">
Улучшения декоратора
Конечно мы можем ошибиться в указании имени контрола, а также проверка потоков может быть избыточна или на оборот, их количество может увеличится. Для ограничения передаваемого ключа в качестве первого параметра декоратора я буду использовать оператор keyof, а потоки вынесу в дополнительный параметр декоратора и немного дополню саму логику определения состояния disabled.

View-model:


Заключение
Как я и говорил ранее, написание декораторов в Angular-приложениях это нонсенс, но иногда они действительно могут помочь улучшить код. И конечно же, все мы понимаем, что данный декоратор можно улучшать бесконечно. Надеюсь данная статья была для вас полезна и возможно она покажет вам декораторы с другой стороны или хотя бы вы будете иметь в рукаве и такое решение на будущее.
Комментарии (27)
Uri88
25.03.2024 12:25Мне кажется можно было бы ещё сделать директиву с селектором кнопки и по DI внутри директивы подтянуть компонент ну и реализовать соответственно всю логику внутри директивы. Тогда эта логика будет автоматом срабатывать для всех p-button в приложении. Надо только доп инпут с именем контрола / формы конечно
alexey-ovsyanikov Автор
25.03.2024 12:25Дело в том, что PrimeNG не дает нормального АПИ для работы с флагом disabled. В статье упомянул об этом
MZ7
25.03.2024 12:25Так в чем проблема с disabled у Прайма?
Вроде все там прекрасно дизэблится.
alexey-ovsyanikov Автор
25.03.2024 12:25Сам Input-параметр есть и он работает отлично. Но если вы захотите им управлять через директиву, к примеру, не выйдет. Имелось ввиду под АПИ - набор методов, если Вы сможете зацепить его как ElementRef, через директиву и туда перенести логику, связанную с disabled.
MZ7
25.03.2024 12:25Извините, но или я чего-то не понимаю, или это какой-то дикий изврат (лезть за ElementRef в button, чтобы заюзать его disabled). Мы тоже несколько лет как, используем PrimeNG и никогда не было
проблем с этим свойством.
alexey-ovsyanikov Автор
25.03.2024 12:25Одно из возможных решений - это атрибут директива, в которую можно было бы переместить логику по управлению состоянием disabled. Но методов по управлению данно»мы состоянием у PrimeNG через ElementRef нет (Мы можем получить через DI сам компонент прайма - кнопку). Я не говорю, что это решение правильное. Проблемы здесь возникли из-за использования click, а не onClick. Но есть и вторая - большое условие для состояния disabled.
MZ7
25.03.2024 12:25Ваше состояние на самом деле не такое уж большое, просто вы его не скомпоновали. Сегодня у вас один саблоадинг, завтра ещё с десяток, к примеру, наверное стоит задуматься, что это должно быть отдельное свойство для того же сервиса откуда вообще эти лоадинги "растут". Тоже самое и с контролом. И вот у вас уже только 2 условия. Ну и писать код внутри шаблона компонента..., хуже читабельность, хуже отладка.
wdhappyk
25.03.2024 12:25Почему бы не заменить декоратор использованием функций-конструктора потока disabled$? Тогда можно было бы уйти от решения передачи ключей в пользу ссылок, да и решение было бы более типизированным
alexey-ovsyanikov Автор
25.03.2024 12:25Простите, не совсем понял
wdhappyk
25.03.2024 12:25данный декоратор просто строит поток, его вполне можно заменить на функцию, при этом сохранив типизацию.
что-то типа
function makeIsDisabled(control?: AbstractControl | null, streams?: Observable<boolean>[]) {...}
тогда использование было бы более явным и типизированным:
class ... {
disabled$: makeIsDisabled(this.control, [this.loading$, this.subloading$, ...])
}wdhappyk
25.03.2024 12:25Если нужно как в примере привязаться к наличию loading$ и subloading$, то можно сделать обертку данной функции для модалок, добавив дженерик:
makeIsDisabledForDialog<T extends { loading$: Observable<boolean>, subloading$: Observable<boolean>}>(dialogInstance: T, control?: ..., streams?: ...) {
return makeIsDisabled(control, streams ?? [dialogInstance.loading$, dialogInstance.subloading$]);
}
и использовать так:
class ... {
disabled$ = makeIsDisabledForModal(this, this.control, ...);
}
Onlylonely32
25.03.2024 12:25Ахах, хохма да и только.
Если бы кто-нибудь прочитал доку для primeng button можно было бы свести решение к обработке события на элементе p-button onClick-а вместо нативного события click, что наверняка свело бы на нет причину написания этой статьи:)
alexey-ovsyanikov Автор
25.03.2024 12:25click - это одна из проблем. Действительно такой EventEmitter, как onClick, есть, но что делать с большими и повторяющимися условиями для disabled? Данный emitter не покрывает этот кейс, а только отсекает сам клик. Я предложил нестандартное решение через декоратор. Использовать его или нет - дело Ваше.
MZ7
25.03.2024 12:25Мне кажется есть более академичный способ исправить поведение, если оно вас не устраивает - написать свой компонент, который будет использовать button из Прайма и может иметь все теже доп. свойства, что и ваш декоратор, если вам не нравится вызов функции.
alexey-ovsyanikov Автор
25.03.2024 12:25Я не уверен, что хорошая практика писать компонент ради компонента. Но решение имеет место быть. Основной посыл статьи возможно не передал ( первый раз писал ) , но он был в том, что не стоит забывать о декораторах и просто иметь в виду, что они могут реально помочь )
MZ7
25.03.2024 12:25Когда то давно, когда я только начинал разбираться с aнгуляром, на профильном ТГ канале один человек сказал фразу о том, что надо "переключаться на работу с компонентами, Вы привыкните". Со временем я понял насколько он был прав.
MZ7
25.03.2024 12:25По поводу декораторов, да интересная вещь, даже пару раз пригодилась, но на мой взгляд специфичная и не стоит ей подменять основную идею ангуляр - компонентный подход.
alexey-ovsyanikov Автор
25.03.2024 12:25Да. Согласен. Применять их налево и направо не стоит, но знать и помнить о ней нужно
Onlylonely32
25.03.2024 12:25Согласен. Проблема с большим количеством проверяемых стейтов есть и через такой декоратор красиво решается.
MZ7
25.03.2024 12:25К сожалению из-за модерации не смог дописать об этом в своем комментарии выше. Абсолютно согласен, что перепутаны нативный клик и клик компонента Прайма.
desfar
Мы у себя на проекте уже прошли стадию ликования от декораторов в стадию последствий от них, и выработали следующее правило:
Декоратор не должен влиять на конечный тип поля(функции)
Иначе у тайпскрипта не будет даже возможности помочь вам найти ошибку в случае изменения ибо вы сами фиктивно пишите какой тип у вашего поля и потом магически создаете ее в декораторе
В вашем случае решение куда проще, обычная утилитная функция:
в такой функции меньше кода(ибо нет кода декоратора и всяких проверок), ее легче тестировать и вариаций переиспользований больше
�
alexey-ovsyanikov Автор
Да, имеет место быть, но я не люблю прокидывать функции в компоненты, а ваше решение это предполагает. На счет сохранения конечного типа - надо подумать. Просто декораторы в ngxs не контролируют конечные типы. Это уже наша ответственность )
wdhappyk
Ниже оставил комментарий более подробный про утилитарную функцию. А по поводу декораторов можно смотреть на команду ангуляра, которая сама от них избавляется в пользу функций, в том числе как раз ради автоматического вывода типа переменных