Реактивное приложение без Redux/NgRx

  • Tutorial


Сегодня мы детально разберем реактивное angular-приложение (репозиторий на github), написанное целиком по стратегии OnPush. Еще приложение использует reactive forms, что вполне типично для enterprise-приложения.

Мы не будем использовать Flux, Redux, NgRx и вместо этого воспользуемся возможностями уже имеющимися в Typescript, Angular и RxJS. Дело в том, что данные инструменты не являются серебряной пулей и могут внести излишнюю сложность даже в простые приложения. Нас об этом честно предупреждают и один из авторов Flux, и автор Redux и автор NgRx.

Но эти инструменты дают нашим приложениям очень приятные характеристики:

  • Predictable data flow;
  • Поддержка OnPush by design;
  • Неизменяемость данных, отсутствие накопленных side effect-ов и прочие приятные мелочи.

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

Как вы сами убедитесь к концу статьи, это довольно простая задача — если убрать из статьи детали работы Angular и OnPush, то остается лишь несколько простых идей.

Статья не предлагает новый универсальный паттерн, а лишь делится с читателем несколькими идеями, которые, при всей своей простоте, почему-то пришли в голову не сразу. Также, разработанное решение не противоречит и не заменяет собой Flux/Redux/NgRx. Их можно подключить, если в этом действительно возникнет необходимость.

Для комфортного чтения статьи понадобится понимание терминов smart, presentational и container components.

План действий


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

  1. Раздели данные для чтения (GET) и записи (PUT/POST)
  2. Загрузи state как поток в container component
  3. Раздай State по иерархии OnPush-компонентам
  4. Сообщай Angular об изменениях компонентов
  5. Редактируй данные в инкапсулированной форме

Для реализации OnPush нам понадобится разобрать все способы запуска change detection в Angular. Таких способов всего четыре, и мы последовательно рассмотрим их по ходу статьи.

Итак, поехали.

Раздели данные для чтения и записи


Для взаимодействия frontend и backend приложения обычно используют типизированные контракты (а иначе зачем вообще typescript?).

У рассматриваемого нами демо-проекта нет настоящего backend, но в нем лежит заранее заготовленный файл описания swagger.json. На его основе генерируются typescript-контракты утилитой sw2dts.

Сгенерированные контракты обладают двумя важными свойствами.

Во-первых, чтение и запись выполняются при помощи разных контрактов. Мы используем небольшое соглашение и именуем контракты чтения с суффиксом “State”, а контракты записи с суффиксом “Model”.

Разделяя контракты подобным образом мы разделяем поток данных в приложении. Сверху вниз по иерархии компонентов распространяется state, используемый только для чтения. Для изменения данных создается model, который изначально заполняется данными из state, но существует как отдельный объект. По окончании редактирования model отправляется на backend как команда.

Вторым важным моментом является то, что все поля State помечены модификатором readonly. Так мы получаем поддержку иммутабельности на уровне typescript. Теперь мы не сможем случайно изменить state в коде или привязаться к нему при помощи [(ngModel)] — при компиляции приложения в AOT-режиме мы получим ошибку.

Загрузи state как поток в container component


Для загрузки и инициализации state мы будем использовать обычные angular-сервисы. Они будут отвечать за следующие сценарии:

  • Классический пример — загрузка через HttpClient по параметру id, полученному компонентом из router.
  • Инициализация пустого state при создании новой сущности. Например, если поля имеют значения по умолчанию или для инициализации нужно запросить дополнительные данные с backend.
  • Перезагрузка уже загруженного state после выполнения пользователем операции, изменившей данные на backend.
  • Перезагрузка state по push-уведомлению, например, при совместном редактировании данных. В этом случае сервис занимается слиянием локального состояния и состояния, полученного с backend.

В демо-приложении мы рассмотрим первые два сценария, как самые типичные. Также эти сценарии просты и позволят реализовать сервиса как простые stateless-объекты и не отвлекаться на сложность, которая не является предметом для данной конкретной статьи.

Пример сервиса можно посмотреть в файле some-entity.service.ts.

Осталось получить сервис через DI в container-компоненте и загрузить state. Обычно это делается примерно так:

route.params
    .pipe(
        pluck('id'),
        filter((id: any) => {
            return !!id;
        }),
        switchMap((id: string) => {
            return myFormService.get(id);
        })
    )
    .subscribe(state => {
        this.state = state;
    });

Но при таком подходе возникают две проблемы:

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

На помощь приходит async pipe. Он слушает Observable напрямую и сам от него отпишется, когда будет нужно. Также при использовании async pipe Angular автоматически запускает change detection каждый раз, когда Observable публикует новое значение.

Пример использования async pipe можно посмотреть в шаблоне компонента some-entity.component.

А в коде компонента мы вынесли повторяемую логику в кастомные RxJS-операторы, добавили сценарий создания пустого state, слияние обоих источников State в один поток оператором merge и создание формы для редактирования, которую мы рассмотрим позже:

this.state$ = merge(
            route.params.pipe(
                switchIfNotEmpty("id", (requestId: string) =>
                    requestService.get(requestId)
                )
            ),
            route.params.pipe(
                switchIfEmpty("id", () => requestService.getEmptyState())
            )
        ).pipe(
            tap(state => {
                this.form = new SomeEntityFormGroup(state);
            })
        );

Это все, что требовалось сделать в container component. А мы кладем в копилку первый способ вызвать change detection в OnPush-компоненте — async pipe. Он пригодится нам еще не один раз.

Раздай State по иерархии OnPush-компонентам


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

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

Раз мы собираемся реализовать все компоненты как OnPush, давайте ненадолго отвлечемся и обсудим, что это вообще такое и как Angular работает с OnPush компонентами. Если вам уже знаком этот материал — смело пролистывайте до конца раздела.

Во время компиляции приложения Angular генерирует для каждого компонента специальный класс change detector, который “запоминает” все биндинги, использованные в шаблоне компонента. Во время исполнения созданный класс запускает проверку сохраненных выражений при каждом цикле change detection. Если проверка показала, что результат какого-либо выражения изменился, то Angular перерисовывает компонент.

По умолчанию Angular ничего не знает о наших компонентах и не может определить, каких компонентов коснется, к примеру, только что сработавший setTimeout или завершившийся AJAX-запрос. Поэтому он вынужден проверять все приложение целиком буквально на каждое событие внутри приложения — даже простой скролл окна многократно запускает change detection для всей иерархии компонентов приложения.

Здесь кроется потенциальный источник проблем с производительностью — чем сложнее шаблоны компонентов, тем сложнее проверки в change detector. А если компонентов много и проверки запускаются часто, то change detection начинает занимать значительное время.

Что же делать?

Если компонент не зависит от каких-либо глобальных эффектов (к слову, компоненты лучше так и проектировать), то его внутреннее состояние определяется:

  • Входными параметрами (@Input);
  • Событиями, произошедшими в самом компоненте (@Output).

Отложим пока второй пункт и предположим, что состояние нашего компонента зависит только от Input-параметров.

Если все Input параметры компонента являются immutable объектами, то мы можем пометить компонент как OnPush. Тогда перед запуском change detection Angular проверит, не изменились ли ссылки на Input параметры компонента с момента предыдущей проверки. И, если не изменились, то Angular пропустит change detection для самого компонента и всех его дочерних компонентов.

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

Поскольку State в нашем приложении уже immutable, то и в Input-параметры дочерних компонентов передаются immutable объекты. То есть мы уже готовы включить OnPush для дочерних компонентов и они будут реагировать на изменения состояния.
Например, это компоненты readonly-info.component и nested-items.component

Теперь давайте разберемся, как реализовать изменение собственного состояния компонентов в парадигме OnPush.

Говори с Angular о своем состоянии


Presentation state — это параметры, отвечающие за внешний вид компонента: индикаторы загрузки, флаги видимости элементов или доступности пользователю того или иного действия, склеенные из трех полей в одну строку ФИО пользователя и т.п.

Каждый раз, когда изменяется presentation state компонента, мы должны уведомлять об этом Angular, чтобы он смог отобразить изменения на UI.

В зависимости от того, что является источником состояния компонента, есть несколько способов уведомлять Angular.

Presentation state, вычисляемый на основе Input-параметров


Это самый простой вариант. Помещаем логику вычисления presentation state в хук ngOnChanges. Change detection же запустится сам за счет изменения @Input-параметров. В демо-приложении это readonly-info.component.

export class ReadOnlyInfoComponent implements OnChanges {
    @Input()
    public state: Backend.SomeEntityState;
    public traits: ReadonlyInfoTraits;
    public ngOnChanges(changes: { state: SimpleChange }): void {
        this.traits = new ReadonlyInfoTraits(changes.state.currentValue);
    }
}

Все предельно просто, но есть один момент, которому стоит уделить внимание.

Если presentation state компонента сложный, и особенно если одни его поля вычисляются на основе других, тоже вычисляемых по Input-параметрам — вынесите состояние компонента в отдельный класс, сделайте его immutable и пересоздавайте при каждом запуске ngOnChanges. В демо-проекте примером является класс ReadonlyInfoComponentTraits. Используя такой подход, вы защитите себя от необходимости заниматься синхронизацией зависимых данных при их изменении.

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

Собственные события компонента


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

В демо-проекте совершенно синтетическим, но все же примером является компонент submit-button.component, который бросает событие formSaved. Компонент-контейнер подписывается на это событие и выводит alert с уведомлением.

Использовать Output-события следует по назначению, то есть создавать их для коммуникации с родительскими компонентами, а не ради запуска change detection. В противном случае, есть вероятность спустя месяцы и годы не вспомнить, зачем же здесь это никому не нужное событие, и удалить его, все сломав.

Изменения в smart components


Иногда состояние компонента определяется сложной логикой: асинхронным вызовом сервиса, подключением к web-сокету, проверками, запущенными через setInterval, да мало ли чего еще. Такие компоненты называют smart components.

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

Простейший способ связать состояние smart компонента с change detection — превратить его в Observable и использовать async pipe, уже рассмотренный выше. Например, если источником изменений является вызов сервиса или статус реактивной формы, то это уже готовый Observable. В случае, если состояние формируется из чего-то более сложного, можно использовать fromPromise, websocket, timer, interval из состава RxJS. Или генерировать поток самостоятельно при помощи Subject.

Если ни один из вариантов не подходит


На случаи, если ни один из трех уже изученных способов не подходит, у нас остается пуленепробиваемый вариант — использование ChangeDetectorRef напрямую. Речь идет про методы detectChanges и markForCheck данного класса.

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

За все время работы мы нашли лишь несколько кейсов, где может понадобиться данный способ:

  1. Ручная работа с change detection — используется при реализации низкоуровневых компонентов и как раз является случаем “вы четко понимаете, что делаете”.
  2. Сложные взаимосвязи между компонентами — например, когда нужно создать ссылку на компонент в шаблоне и передать ее как параметр в другой компонент, находящийся выше по иерархии или вообще в другой ветке иерархии компонентов. Звучит сложно? Так и есть. И такой код лучше просто зарефакторить, потому что он доставит боль не только с change detection.
  3. Специфика поведения самого Angular — например, при реализации кастомного ControlValueAccessor вы можете столкнуться с тем, что изменение значения контрола выполняется Angular-ом асинхронно и изменения не применяются в нужный цикл change detection.

В качестве примеров использования в демо-приложении есть базовый класс OnPushControlValueAccessor, решающий проблему, описанную в последнем пункте. Также в проекте есть наследник данного класса — кастомный radio-button.component.

Теперь мы обсудили все четыре способа запуска change detection и варианты реализации OnPush для всех трех разновидностей компонентов: container, smart, presentational. Переходим к заключительному пункту — редактирование данных с reactive forms.

Редактируй данные в инкапсулированной форме


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

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

По сути, реактивная форма представляет из себя этакий мини-store, который инкапсулирует работу с состоянием: данными и статусами disabled/valid/pending.

Нам остается максимально поддержать эту инкапсуляцию и избегать смешивания presentation-логики и логики работы формы.

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

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

Внутри самой формы мы конструируем контролы и “распихиваем” по ним пришедшие данные, преобразуя их из контракта State в контракт Model. Структура форм, насколько это возможно, совпадает с контрактами моделей. В результате, свойство value формы отдает нам готовый model для отправки на backend.

Если в будущем структура state или model изменится, то мы получим ошибку компиляции typescript ровно в том месте, где нам необходимо добавить/удалить поля, что очень удобно.

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

Итого, логика формы изолирована от presentation-логики в компонентах и живет “сама по себе”, не повышая сложность data flow нашего приложения в целом.

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

  1. Изменения в форме, приводящие к изменению presentation state — например, видимости блока данных в зависимости от введенного значения. Реализуем в компоненте, подписываясь на события формы. Можно делать это через immutable traits, рассмотренные ранее.
  2. Если нужен асинхронный валидатор, вызывающий backend — в компоненте конструируем AsyncValidatorFn и передаем в конструктор формы его, а не сервис.

Таким образом, вся «пограничная» логика остается на самом видном месте — в компонентах.

Выводы


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

Прежде всего, разработка по стратегии OnPush вынуждает нас вдумчиво проектировать data flow приложения, поскольку теперь мы диктуем Angular-у правила игры, а не он нам.

Последствий у такой ситуации два.

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

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

Контролируемая сложность и отсутствие “магии” уменьшают вероятность возникновения целого класса проблем, связанных, например, с циклическими обновлениями данных или накопленными побочными эффектами. Вместо этого мы имеем дело с заметными уже при разработке проблемами, когда приложение просто не работает. И волей-неволей приходится делать так, чтобы приложение работало просто и четко.

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

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

На этом мы заканчиваем наше повествование.

Будем на связи!
True Engineering
77.73
Специалисты по цифровой трансформации бизнеса
Share post

Comments 0

Only users with full accounts can post comments. Log in, please.