Предисловие

Я занимаюсь разработкой 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)


  1. desfar
    25.03.2024 12:25
    +2

    Мы у себя на проекте уже прошли стадию ликования от декораторов в стадию последствий от них, и выработали следующее правило:
    Декоратор не должен влиять на конечный тип поля(функции)
    Иначе у тайпскрипта не будет даже возможности помочь вам найти ошибку в случае изменения ибо вы сами фиктивно пишите какой тип у вашего поля и потом магически создаете ее в декораторе

    В вашем случае решение куда проще, обычная утилитная функция:

    export function isFormDisabled(control: AbstractControl, streams: Observable<boolean>[]): Observable<boolean>;

    в такой функции меньше кода(ибо нет кода декоратора и всяких проверок), ее легче тестировать и вариаций переиспользований больше


    1. alexey-ovsyanikov Автор
      25.03.2024 12:25

      Да, имеет место быть, но я не люблю прокидывать функции в компоненты, а ваше решение это предполагает. На счет сохранения конечного типа - надо подумать. Просто декораторы в ngxs не контролируют конечные типы. Это уже наша ответственность )


      1. wdhappyk
        25.03.2024 12:25

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


  1. aidarDev
    25.03.2024 12:25

    pointer-events: none?


    1. alexey-ovsyanikov Автор
      25.03.2024 12:25

      Не помогает. Клики будут все равно проходить


  1. Uri88
    25.03.2024 12:25

    Мне кажется можно было бы ещё сделать директиву с селектором кнопки и по DI внутри директивы подтянуть компонент ну и реализовать соответственно всю логику внутри директивы. Тогда эта логика будет автоматом срабатывать для всех p-button в приложении. Надо только доп инпут с именем контрола / формы конечно


    1. alexey-ovsyanikov Автор
      25.03.2024 12:25

      Дело в том, что PrimeNG не дает нормального АПИ для работы с флагом disabled. В статье упомянул об этом


      1. MZ7
        25.03.2024 12:25

        Так в чем проблема с disabled у Прайма?

        Вроде все там прекрасно дизэблится.


        1. alexey-ovsyanikov Автор
          25.03.2024 12:25

          Сам Input-параметр есть и он работает отлично. Но если вы захотите им управлять через директиву, к примеру, не выйдет. Имелось ввиду под АПИ - набор методов, если Вы сможете зацепить его как ElementRef, через директиву и туда перенести логику, связанную с disabled.


          1. MZ7
            25.03.2024 12:25

            Извините, но или я чего-то не понимаю, или это какой-то дикий изврат (лезть за ElementRef в button, чтобы заюзать его disabled). Мы тоже несколько лет как, используем PrimeNG и никогда не былопроблем с этим свойством.


            1. alexey-ovsyanikov Автор
              25.03.2024 12:25

              Одно из возможных решений - это атрибут директива, в которую можно было бы переместить логику по управлению состоянием disabled. Но методов по управлению данно»мы состоянием у PrimeNG через ElementRef нет (Мы можем получить через DI сам компонент прайма - кнопку). Я не говорю, что это решение правильное. Проблемы здесь возникли из-за использования click, а не onClick. Но есть и вторая - большое условие для состояния disabled.


              1. MZ7
                25.03.2024 12:25

                Ваше состояние на самом деле не такое уж большое, просто вы его не скомпоновали. Сегодня у вас один саблоадинг, завтра ещё с десяток, к примеру, наверное стоит задуматься, что это должно быть отдельное свойство для того же сервиса откуда вообще эти лоадинги "растут". Тоже самое и с контролом. И вот у вас уже только 2 условия. Ну и писать код внутри шаблона компонента..., хуже читабельность, хуже отладка.


  1. Gareev88
    25.03.2024 12:25

    Что за тема в vscode


    1. alexey-ovsyanikov Автор
      25.03.2024 12:25

      Dark+ (Default Dark+)


  1. wdhappyk
    25.03.2024 12:25

    Почему бы не заменить декоратор использованием функций-конструктора потока disabled$? Тогда можно было бы уйти от решения передачи ключей в пользу ссылок, да и решение было бы более типизированным


    1. alexey-ovsyanikov Автор
      25.03.2024 12:25

      Простите, не совсем понял


      1. wdhappyk
        25.03.2024 12:25

        данный декоратор просто строит поток, его вполне можно заменить на функцию, при этом сохранив типизацию.
        что-то типа
        function makeIsDisabled(control?: AbstractControl | null, streams?: Observable<boolean>[]) {...}

        тогда использование было бы более явным и типизированным:
        class ... {
        disabled$: makeIsDisabled(this.control, [this.loading$, this.subloading$, ...])
        }


        1. 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, ...);
          }


  1. Onlylonely32
    25.03.2024 12:25

    Ахах, хохма да и только.

    Если бы кто-нибудь прочитал доку для primeng button можно было бы свести решение к обработке события на элементе p-button onClick-а вместо нативного события click, что наверняка свело бы на нет причину написания этой статьи:)


    1. alexey-ovsyanikov Автор
      25.03.2024 12:25

      click - это одна из проблем. Действительно такой EventEmitter, как onClick, есть, но что делать с большими и повторяющимися условиями для disabled? Данный emitter не покрывает этот кейс, а только отсекает сам клик. Я предложил нестандартное решение через декоратор. Использовать его или нет - дело Ваше.


      1. MZ7
        25.03.2024 12:25

        Мне кажется есть более академичный способ исправить поведение, если оно вас не устраивает - написать свой компонент, который будет использовать button из Прайма и может иметь все теже доп. свойства, что и ваш декоратор, если вам не нравится вызов функции.


        1. alexey-ovsyanikov Автор
          25.03.2024 12:25

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


          1. MZ7
            25.03.2024 12:25

            Когда то давно, когда я только начинал разбираться с aнгуляром, на профильном ТГ канале один человек сказал фразу о том, что надо "переключаться на работу с компонентами, Вы привыкните". Со временем я понял насколько он был прав.


          1. MZ7
            25.03.2024 12:25

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


            1. alexey-ovsyanikov Автор
              25.03.2024 12:25

              Да. Согласен. Применять их налево и направо не стоит, но знать и помнить о ней нужно


      1. Onlylonely32
        25.03.2024 12:25

        Согласен. Проблема с большим количеством проверяемых стейтов есть и через такой декоратор красиво решается.


    1. MZ7
      25.03.2024 12:25

      К сожалению из-за модерации не смог дописать об этом в своем комментарии выше. Абсолютно согласен, что перепутаны нативный клик и клик компонента Прайма.