Сегодня мы разберем по косточкам реактивное angular-приложение (репозиторий на github), написанное целиком по стратегии OnPush. Также приложение использует reactive forms, что вполне типично для enterprise-приложения.
При этом мы не будем использовать Flux, Redux, NgRx, Immutable.js и другие подобные инструменты. Вместо этого мы воспользуемся возможностями уже имеющимися в Typescript, Angular и RxJS.
Эта статья не знаменует рождение нового паттерна. Текст — лишь попытка поделиться с читателем несколькими идеями, которые, при всей своей простоте, почему-то пришли в голову не сразу. Также, разработанное решение не противоречит и не заменяет собой Flux/Redux/NgRx. Эти библиотеки можно будет без проблем подключить, если действительно возникнет необходимость.
Зачем вообще писать реактивное приложение без Flux/Redux/NgRx?
Эти паттерны не являются серебряной пулей и могут внести излишнюю сложность даже в небольшие и простые приложения. Нас об этом честно предупреждают и один из авторов Flux, и автор Redux и автор NgRx.
С другой стороны, эти паттерны дают нашему приложению очень приятные характеристики:
- Predictable data flow;
- Поддержка OnPush by design;
- Неизменяемость данных, отсутствие накопленных side effect-ов и прочие приятные мелочи.
Мы попытаемся получить эти же характеристики, но без внесения дополнительной сложности.
Как вы сами убедитесь к концу статьи, это довольно простая задача — если выбросить из статьи детали работы Angular и OnPush, то останется лишь несколько простых идей.
План действий
Для комфортного чтения необходимо понимание терминов smart, presentational и container components.
Мы начнем с того, что обсудим ключевые моменты взаимодействия приложения с backend.
Далее мы займемся непосредственно приложением. И логику его работы, и последовательность изложения материала можно описать в виде следующих шагов:
- Загрузи readonly state в container component и создай из него реактивный поток,
- Данные для чтения раздай OnPush-компонентам,
- Сообщай Angular об изменениях в состоянии,
- Редактируй данные в инкапсулированной реактивной форме.
Поскольку мы пишем приложение по стратегии OnPush, нам нужно будет разобрать все способы запуска change detection в Angular. Таких способов всего четыре, и мы последовательно рассмотрим их по ходу статьи.
Все начинается с backend
Для взаимодействия frontend и backend нужны контракты. Мы, как и многие, генерируем их описание при помощи swagger (демо-проект не имеет backend-а, поэтому мы положили в него заранее подготовленный swagger.json). Далее, swagger.json скармливается генератору, который создает typescript-декларации контрактов.
Например, мы используем генератор sw2dts.
Генерация запускается командой npm run generate-types, результат сохраняется в файл backend.d.ts.
Давайте посмотрим, как выглядят сгенерированные контракты.
Первым принципиальным моментом является то, что контракты чтения (GET) и изменения (POST и PUT) полностью отделены друг от друга.
Контракты чтения мы называем state и именуем их с суффиксом “State”. Контракты для изменения называем Model и именуем их с суффиксом “Model”.
Дополнительно в контрактах для чтения все поля являются readonly. Это дает нам поддержку иммутабельности уже на уровне языка. Теперь мы не сможем случайно изменить данные для чтения и даже не сможем привязаться к такому контракту при помощи [(ngModel)], поскольку при компиляции приложения в AOT-режиме мы получим ошибку.
Чем обосновано разделение контрактов на чтение и изменение?
В общем случае, чем сложнее предметная область системы, тем больше данных она генерирует сама, а пользователю дает их только посмотреть. То есть контракты чтения и записи не идентичны, и разница между ними будет тем больше, чем сложнее разрабатываемая система.
Если вам известны такие концепции, как CQRS и Event Sourcing, то вы знаете, что для чтения и обновления данных иногда используются даже разные БД с разной структурой. А все операции модификации данных описываются в виде маленьких специализированных команд.
Поэтому контракты имеет смысл разделить с самого начала, задавая семантику отдельно для каждой операции. Как мы вскоре увидим, это разделение поможет нам и на frontend.
Загрузи readonly state
Теперь нам нужен ответственный за загрузку и инициализацию state в приложении. Это будут обычные angular-сервисы, и они будут по команде отдавать state в виде Observable.
То есть ответственностью слоя сервисов становятся следующие сценарии:
- Классический пример — загрузка через HttpClient по параметру id, вытащенному из router-а.
- Инициализация пустого state при создании новой сущности. Например, если многие поля имеют значения по умолчанию или для инициализации нужно запросить дополнительные данные с backend.
- Перезагрузка уже загруженного state после выполнения какой-либо операции пользователем.
- Перезагрузка state по push-уведомлению, пришедшему с backend. Например, при реализации совместного редактирования данных. В этом случае задачей сервиса также становится слияние локального состояния с состоянием, полученным с backend.
В демо-приложении мы реализуем первые два сценария и без реального backend. Код находится в файле 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 и слияние обоих источников состояния при помощи оператора 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 components и кладем в копилку первый способ вызвать change detection — async pipe. Он пригодится нам еще не раз.
Данные для чтения раздай OnPush-компонентам
Когда нужно отобразить сложный состояние, мы создаем иерархию небольших компонентов — так мы боремся со сложностью.
Как правило, компоненты разбиваются на иерархию, схожую с иерархией данных. Каждый компонент получает свой фрагмент данных от container component через Input-параметры и отображает их нужным образом в шаблоне.
Давайте теперь обсудим, что такое OnPush и как вообще Angular работает с компонентами. Если вам этот материал уже знаком — смело пролистывайте до конца раздела.
Во время компиляции приложения Angular генерирует для каждого компонента специальный класс change detector, который “запоминает” все биндинги, использованные в шаблоне компонента. Во время работы созданный класс запускает проверку этих выражений при каждом цикле change detection. Если проверка показала, что результат какого-либо выражения изменился, то Angular перерисовывает компонент.
По умолчанию Angular ничего не знает о наших компонентах и не может определить, каких компонентов коснется, к примеру, только что сработавший setTimeout или завершившийся AJAX-запрос. Поэтому он вынужден проверять все приложение целиком буквально на каждое событие внутри приложения — даже простой скролл многократно запускает change detection для всей иерархии компонентов приложения.
И это потенциальный источник проблем с производительностью — чем сложнее шаблоны компонентов, тем сложнее проверки в change detector. Когда компонентов много, а проверки запускаются часто, change detection начинает занимать значительное время.
Что же делать?
Если компонент не зависит от каких-либо глобальных эффектов (к слову, компоненты лучше так и проектировать), то его внутреннее состояние определяется:
Отложим пока второй пункт и предположим, что состояние нашего компонента зависит только от Input-параметров.
Если все Input параметры компонента являются immutable объектами, то мы можем пометить компонент как OnPush. Теперь перед запуском change detection для компонента Angular проверит, не изменились ли ссылки на Input параметры с момента предыдущего цикла проверок. Если не изменились, то Angular пропустит проверку для самого компонента и всех компонентов, находящихся под ним.
И, если мы построим все наше приложение на стратегии OnPush, то устраним целый класс проблем с производительностью с самого начала.
Поскольку State в нашем приложении уже immutable, то и в Input-параметры дочерних компонентов мы передаем immutable объекты. То есть мы уже готовы включить OnPush для дочерних компонентов и они будут реагировать на изменения состояния. Например, это компоненты readonly-info.component и nested-items.component
Говори с Angular о своем состоянии
Presentation state — это параметры, отвечающие за внешний вид компонента: индикаторы загрузки, флаги видимости элементов или доступности пользователю того или иного действия, склеенные из трех полей в одну строку ФИО пользователя и т.п.
В зависимости от того, что является источником состояния компонента, есть несколько способов уведомлять 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 слушает все Ouput-события компонентов.
В демо-проекте совершенно синтетическим, но все же примером является компонент submit-button.component, который бросает событие formSaved. Компонент-контейнер подписывается на это событие и выводит alert с уведомлением. В реальном сценарии компонент может, например, перезагружать состояние с сервера.
Использовать Output-события следует по назначению, то есть создавать их для уведомления внешних компонентов, а не ради запуска change detection. Иначе, есть вероятность спустя месяцы и годы не вспомнить, зачем же здесь это никому не нужное событие, и удалить его, все сломав.
Изменения в smart components
Иногда состояние компонента определяется сложной логикой: асинхронным вызовом сервиса, подключением к web-сокету, проверками, запущенными через setInterval, да мало ли чего еще. Такие компоненты называют smart components.
Вообще, чем меньше в приложении будет smart-компонентов, которые при этом не являются container компонентами — тем проще будет жить. Но иногда без них не обойтись.
Простейший способ связать состояние smart компонента с change detection — проектировать состояние в виде Observable и использовать async pipe, уже рассмотренный выше.
Если источником изменений является вызов сервиса или статус реактивной формы, то это уже готовый Observable. Например в компоненте nested-items.component мы завели признак isEditable$, который представляет из себя Observable, созданный на основании состояния формы, и на его основе скрываются/показываются кнопки удаления/добавления элементов.
В случае, если состояние формируется из чего-то более сложного, можно использовать фабрики fromPromise, websocket, timer, interval из состава RxJS. Или генерировать поток самостоятельно при помощи Subject.
Если ни один из вариантов не подходит
На случаи, если ни один из трех уже изученных способов не подходит, у нас остается пуленепробиваемый вариант — использование ChangeDetectorRef напрямую. Речь идет про методы detectChanges и markForCheck данного класса.
Документация исчерпывающие отвечает на все вопросы, поэтому не будем подробно останавливаться на его работе. Но заметим, что использование ChangeDetectorRef следует ограничить до случаев, когда вы четко понимаете, что делаете, поскольку это все же внутренняя кухня Angular.
За все время работы мы нашли лишь несколько кейсов, где может понадобиться данный способ:
- Ручная работа с change detection — используется при реализации низкоуровневых компонентов и как раз является случаем “вы четко понимаете, что делаете”.
- Сложные взаимосвязи между компонентами — например, когда нужно создать ссылку на компонент в шаблоне и передать ее как параметр в другой компонент, находящийся выше по иерархии или вообще в другой ветке иерархии компонентов. Звучит сложно? Верно. Такой код лучше просто зарефакторить, потому что он доставит боль не только с change detection.
- Специфика поведения самого Angular — например, при реализации кастомного ControlValueAccessor вы можете столкнуться с тем, что изменение значения контрола выполняется Angular-ом через setTimeout, и потому изменения не применятся в нужный цикл change detection.
Касательно последнего пункта, в демо-приложении мы реализовали базовый класс OnPushControlValueAccessor и пример его наследника — кастомный radio-button.component. Еще одним примером работы с ChangeDetectorRef является ValidationErrorComponent, использующий markForCheck по той же причине — статус валидности control-а зависит от значения, которое выставляется Angular-ом через setTimeout.
Итак, мы обсудили все четыре способа запуска change detection и варианты реализации OnPush для всех трех разновидностей компонентов: container, smart, presentational. Переходим к последнему пункту — редактирование данных с reactive forms.
Редактируй данные в инкапсулированной реактивной форме
Реактивные формы имеют ряд ограничений, но все же это одна из лучших вещей, которые случились в экосистеме Angular.
Прежде всего, реактивные формы неплохо инкапсулируют работу с состоянием и дают все необходимые инструменты для реагирования на изменения в реактивной манере.
По сути, реактивная форма представляет из себя этакий мини-store, который инкапсулирует в себе работу с состоянием формы: как с данными, так и с disabled/valid/pending статусами.
Нам остается лишь использовать эту инкапсуляцию и избегать смешивания presentation-логики и логики работы формы.
В демо-приложении вы можете увидеть отдельные классы форм, прячущие логику валидации, создания дочерних FormGroup, работы с disabled.
Корневую форму мы создаем в container component в момент загрузки state и при каждой перезагрузке state форма создается заново. Так мы можем обеспечить отсутствие накопленных эффектов в логике работы формы.
Внутри класса формы мы конструируем контролы и “распихиваем” по ним пришедшие данные, преобразуя их из контракта State в контракт Model. Структура форм, насколько это возможно, совпадает с контрактами «xxxModel». В результате, по окончании редактирования формы, мы получим готовый для отправки на backend набор данных через свойство value формы.
Если в будущем структура state или model изменится, то мы получим ошибку компиляции typescript ровно в том месте, где нам необходимо добавить/удалить поля, что очень удобно.
Также, если объекты state и model имеют абсолютно идентичную структуру, то typescript это понимает и у нас нет необходимости строить бессмысленный маппинг одного в другое.
Итого, логика формы изолирована от presentation-логики в компонентах и живет “сама по себе”, не повышая сложность data flow нашего приложения в целом.
На этом почти все. Остались пограничные кейсы, когда мы не можем изолировать логику формы от остального приложения:
- Обработчики изменений, приводящие к изменению presentation state — например, видимости блока данных в зависимости от введенного значения. Реализуем в компоненте, подписываясь на события формы.
- Если нужен асинхронный валидатор, вызывающий backend — конструируем AsyncValidatorFn в компоненте. В конструктор формы передаем готовый валидатор, а не сервис.
В результате вся «пограничная» логика остается на самом видном месте — в компонентах.
Выводы
Давайте подведем итоги, что мы получили и какие еще есть моменты для изучения и развития.
Прежде всего, разработка по стратегии OnPush вынуждает нас четко вдумчиво проектировать data flow нашего приложения, поскольку теперь мы диктуем Angular-у правила игры, а не он нам.
Последствий у такой ситуации два.
Во-первых, мы получаем приятное чувство контроля над приложением. Больше не остается магии, которая “как-то работает”. Вы четко осознаете, что происходит в каждый момент времени в вашем приложении. Постепенно развивается интуиция, которая позволяет понять причину, к примеру, найденного бага, еще до того, как вы открыли код.
Второе приятное последствие: теперь придется тратить больше времени на проектирование приложения, но результатом всегда будет самое “прямое”, а значит, самое простое решение. Это заметно приближает к нулю вероятность того, что со временем приложение превратится в монстра огромной сложности. Значит, разработчики не потеряют контроль над кодом и разработка не превратится в мистические обряды.
Контролируемая сложность и отсутствие “магии” уменьшают вероятность возникновения целого класса проблем, связанных, например, с циклическими зависимостями в данных или накопленными побочными эффектами. Вместо этого мы имеем куда более заметные проблемы, когда приложение просто не работает еще при разработке и вам волей-неволей приходится делать так, чтобы все работало просто и четко. Хотя можно и бездумно втыкать changeDetector.detectChanges по всему приложению — тут уже каждый сам себе судья.
Про хорошие последствия для performance мы уже упоминали. В дальнейшем, используя очень простые инструменты, такие как profiler.timeChangeDetection, мы можем в любой момент проверить, что наше приложение по прежнему в хорошей форме.
Также, если у вас не очень сложное приложение, то грех не попробовать отключить NgZone. Во-первых, это позволит вам не загружать при старте приложения целую библиотеку. Во-вторых, это уберет еще изрядное количество магии из вашего приложения.
На этом мы заканчиваем наше повествование.
Будем на связи!