Как использовать менеджер состояний NgRx для Angular-проектов

от автора

Всем привет! Меня зовут Ильмир, я frontend-разработчик SimbirSoft. Это моя первая статья, в которой я хотел бы разобрать тему менеджера состояний в Angular.

Назначение

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

Особенности применения

Сразу скажу, что инструмент для использования лучше всего определить до начала работ, чтобы в дальнейшем не тратить время на его внедрение. Рассмотрим признаки, по которым можно решить, что внедрение в проект менеджера будет полезным: 

  • Сложное состояние. Если ваше приложение имеет сложную иерархию состояний, управление которыми становится затруднительным, NgRx может помочь структурировать эти состояния более эффективно.

  • Необходимость в многоуровневой передаче данных, когда данные должны быть доступны для большого количества компонентов на разных уровнях дерева, NgRx предоставляет централизованное хранилище store.

  • Потребности в масштабируемости. Если проект планируется расширять, NgRx поможет создать более управляемую архитектуру, которая упростит добавление новых функций.

  • Наличие побочных эффектов в приложении. Если приложению нужны сложные асинхронные операции, такие как HTTP-запросы или взаимодействия с API, NgRx Effects поможет управлять побочными эффектами через четко организованную архитектуру.

  • Параллельная работа с несколькими источниками данных. Если ваше приложение извлекает и обрабатывает данные из разных источников, NgRx может помочь управлять взаимодействием с этими источниками более эффективно.

  • Высокие требования к производительности. Если приложение должно обрабатывать большие объемы данных и сохранять высокую скорость работы, NgRx может помочь избежать повторных рендеров и оптимизировать работу с состоянием.

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

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

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

Состояния, состояния…

Во frontend может присутствовать много различных состояний (схема 1), например, состояние в url, клиентское состояние (то есть состояние полей ввода или других html тегов), состояние локального хранилища, веб-сокеты, если мы их используем, и много других.

Схема 1

Схема 1

А если это все у нас как-то взаимодействует с бэком… В итоге когда приложение разрастается, получается мешанина, в которой бывает очень сложно разобраться. Например, когда разные разработчики независимо друг от друга настраивают разные запросы для получения одних и тех же данных с бэка. Иными словами, если приложение большое и команда разработчиков большая, то контролировать состояния приложения становится очень сложно. В такой ситуации нам и может понадобиться NgRx со специальными инструментами для работы с состояниями и хранением данных. У него есть сущности, которые разбивают работу с состояниями на понятные и осмысленные части. Об этом речь пойдет далее.

import { NgModule, isDevMode } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { StoreModule } from '@ngrx/store'; import { reducers, metaReducers } from './reducers'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { EffectsModule } from '@ngrx/effects'; import { AppEffects } from './app.effects'; import { HttpClientModule } from '@angular/common/http';   @NgModule({   declarations: [AppComponent],   imports: [     BrowserModule,     AppRoutingModule,     StoreModule.forRoot(reducers, {       metaReducers,     }),     StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: !isDevMode() }),     EffectsModule.forRoot([AppEffects]),     HttpClientModule   ],   providers: [],   bootstrap: [AppComponent], }) export class AppModule {}

(app.module.ts)

Установка

Чтобы начать пользоваться NgRx, необходимо установить несколько пакетов:

npm install @ngrx/effects @ngrx/store @ngrx/store-devtools @ngrx/component-store

Самым важным пакетом здесь является @ngrx/store. Он необходим для того, чтобы хранить состояния state. Store – это объект, который хранит в себе все state и способен создавать actions.

@ngrx/effects нужен для создания эффектов.

@ngrx/store-devtools – для того, чтобы можно было работать в браузере и видеть все изменения, которые происходят в store.

@ngrx/component-store предназначен для управления локальным состоянием компонентов, что позволяет избежать избыточного использования глобального хранилища NgRx Store, если данные нужны лишь в рамках одного компонента.

Также в браузере необходимо установить расширение Redux, там мы будем видеть, какие action вызываются. Есть и другие пакеты, но в этой статье мы их затрагивать не будем, для начала работы этого будет достаточно. После установки необходимо обновить app.module, подключив установленные пакеты. (см. app.module.ts)

Принцип работы

Схема 2

Схема 2

Давайте рассмотрим принцип работы NgRx. Для этого в документации  есть наглядная схема (схема 2). 

Итак, у нас есть сущность store (хранилище), которая является глобальной для всего приложения По сути это объект с данными, которые хранятся в одном месте и берутся по уникальным ключам. Допустим, у нас есть какие-то данные, которые используют какое-то количество компонентов. Как только данные в store обновятся, компоненты обновят эти данные внутри себя и перерисуют представление. До введения NgRx в приложение компонент вызывал сервисы и брал данные оттуда, теперь он берет данные из селекторов, которые связаны с глобальным store, а методы самих сервисов вызываются с помощью эффектов, но об этом позднее. Как видно по схеме, компоненты вызывают actions – уникальные события, которые происходят в нашем приложении.

Actions

Для изменения какого-то значения в store необходимо создать action через функцию createAction. Action – это объект, у которого есть свойство type – уникальная строка, которая позволяет различать их. Например:

export const increase = createAction('[COUNTER] increase');

Далее в компоненте мы уже можем вызвать метод dispatch глобального объекта store, в который и положим этот action:

 increment(): void {     this.store.dispatch(increase());   }

Также есть возможность создать группу actions, например, для одной и той же сущности. Ниже у нас представлена группа actions, которая работает с сущностью user.

export const UsersActions = createActionGroup({   source: 'Users',   events: {     '[USERS] Add User': props<{ user: User }>(),     '[USERS] Remove User': props<{ userId: number }>(),     '[USERS] Update User': props<{ userId: number; userData: User }>(),     '[USERS] Select User': props<{ userId: number }>(),     '[USERS] Select Users': props<{ users: User[] }>(),   }, });

Основываясь на своем опыте, хочу поделиться важным правилом, которого следует строго придерживаться: action создается и вызывается в приложении  всего один раз. Благодаря этому будет легче дебажить приложение.

Reducers

Далее управление передается в reducers, которые отвечают за смену состояния хранилища в Angular-приложении в ответ на возникновение действия. При этом каждый reducer может изменять только определенную часть состояния. Любое действие, отправляемое в хранилище методом dispatch(), передается всем редюсерам, каждый из которых либо изменяет состояние согласно текущему действию, либо возвращает состояние нетронутым, если обработка такого действия в нем не предусмотрена.

Важно отметить, что reducers являются чистыми функциями, у которых есть определенные преимущества:

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

  • Отсутствие побочных эффектов – чистые функции не изменяют состояние вне своей области видимости, что способствует лучшему управлению состоянием приложения.

  • Упрощение тестирования – чистые функции легко тестировать, поскольку для их проверки достаточно передать входные данные и проверить результат без необходимости учитывать дополнительные состояния или контексты.

  • Улучшенная производительность – чистые функции могут быть легко оптимизированы такими методами, как мемоизация. Это может значительно повысить производительность в приложениях с большим объемом данных.

 Итак, наш reducers принимает два аргумента:

  • часть текущего состояния, за обработку которого он ответственен;

  • обрабатываемое действие.

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

export const usersState: UsersState = {   users: [],   selectedUserId: null, }; export const userReducer = createReducer(   usersState,   on(UsersActions.removeUser, (state, { userId }) => ({     ...state,     users: state.users.filter((user) => user.userId !== userId),   })),   on(UsersActions.addUser, (state, { user }) => ({     ...state,     users: [...state.users, user],   })),   on(UsersActions.updateUser, (state, { userId, userData }) => ({     ...state,     users: state.users.map((user) =>       user.userId === userId ? { ...user, ...userData } : user     ),   })),   on(UsersActions.selectUser, (state, { userId }) => ({     ...state,     selectedUserId: userId,   })),   on(UsersActions.selectUsers, (state, { users }) => ({     ...state,     selectedUsers: users,   })) );

Здесь мы имеем начальный state — usersState, где хранится:

— состояние массива users, которое мы можем изменять в зависимости от того, какой action у нас срабатывает, 

— методы on, которые выполняют какую-то функцию в зависимости от вызванного action. 

Функция on принимает в себя два аргумента. Первый – начальное состояние state и второй объект, который включает в себя данные, переданные из action при вызове метода dispatch. Метод on возвращает объект, который включает в себя начальный state и производит какие-то манипуляции с данными этого начального состояния. Количество таких reducers равно количеству состояний, с которыми мы работаем. В данном примере мы работаем с состоянием массива пользователей, которое у нас есть в приложении. Таких состояний может быть очень много, и благодаря этому инструменту в процессе разработки приложения можно отслеживать, какие данные есть. Таким образом, рассмотрев эту часть NgRx можно уже сделать вывод, что этот инструмент предоставляет удобный функционал для хранения и обработки всех возможных состояний приложения.

Также стоит сказать, что обязательно этот reducer должен быть положен в качестве аргумента в вызов статического метода forRoot, это можно увидеть в app.module.ts.

Подытоживая, можно сказать, что в компоненте вызывается action, в ответ на какое-то событие, который заставляет сработать reducer, меняя часть глобального состояния в store. Сами данные из store мы можем получить благодаря селекторам, поговорим немного о них.

Selectors

import { createSelector, createFeatureSelector } from '@ngrx/store'; import { Book } from '../book-list/books.model'; import { BOOKS_KEY } from './books.reducer'; import { COLLECTION_KEY } from './collection.reducer';   export const selectBooks =   createFeatureSelector<ReadonlyArray<Book>>(BOOKS_KEY);   export const selectCollectionState =   createFeatureSelector<ReadonlyArray<string>>(COLLECTION_KEY);   export const selectBookCollection = createSelector(   selectBooks,   selectCollectionState,   (books, collection) => {     return collection.map((id) => books.find((book) => book.id === id)!);   } );

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

Функция createSelector может принимать в себя до 8 функций селектора, которые будут содержать в себе 8 разных состояний приложения. Последним аргументом в нее передается callback, который принимает в себя определенное количество аргументов, равное количеству переданных функций селектора, и возвращает определенное значение вычисленное на основе этих аргументов. В описанном выше примере у нас есть два createFeatureSelector, которые хранят в себе ключи для работы с определенным срезом данных из store. Помещая его в функцию createSelector, мы даем ему понимание, что работа ведется с данными по ключам BOOKS_KEY и COLLECTION_KEY. Функция createFeatureSelector нужна для того, чтобы из большого объема данных, хранимых в глобальном store, достать что-то одно по ключу. Таким образом, у нас появляется возможность вернуть данные, хранимые в store. Возможны более сложные варианты использования функции createSelector, где мы передаем несколько ключей и, комбинируя данные, выдаем определенный результат.

Чтобы получить данные в компоненте, необходимо вызвать функцию select, в которую мы и передаем функцию, описанную выше. 

  books$ = this.store.select(selectBooks);   bookCollection$ = this.store.select(selectBookCollection);

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

Effects

В схеме 2, описанной выше, также есть побочные эффекты, которые вызываются в ответ на изменение данных в глобальном store. Они, как правило, срабатывают на action и вызывают другой action, поэтому на схеме можно видеть двойную стрелку. К примеру, если мы вызываем action — Select Users, необходимо сделать запрос в базу данных, чтобы подгрузить список пользователей. Согласно документации эффекты реализуют побочные эффекты, работающие на основе библиотеки RxJS, применительно к хранилищу. Отслеживая поток действий, отправляемых в store, они могут генерировать новые действия, например, на основе результатов выполнения http-запросов или сообщений, полученных через web-sockets. Если в приложении Angular обычно подобная логика выполняется в сервисах, то при подключении NgRx эффекты являются изолирующим слоем, отделяющим сервисы от компонентов.

import { Component } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { User } from './state/books.actions';   @Component({   selector: 'app-root',   template: `   <div *ngFor="let user of users$ | async">     <p>       Id пользователя: <span>{{ user.id }}</span>     </p>     <p>       Имя пользователя: <span>{{ user.name }}</span>     </p>     <p>       Возраст пользователя: <span>{{ user.age }}</span>     </p>   </div>`, }) export class AppComponent {   users$: Observable<User[]> = this.store.select((state: any) => state.users);     constructor(private store: Store<{ users: User[] }>) {}     ngOnInit() {     this.store.dispatch({ type: '[USERS] Select Users' });   } }

Для наглядности приведу пример. Допустим, у нас есть главный компонент (app.component.ts), в котором мы должны получить список всех пользователей системы. Для этого в методе ngOnInit мы вызываем метод dispatch, чтобы вызвался action на подгрузку списка пользователей. Для получения данных через селектор, необходимо их сначала подгрузить.  Далее вся логика выполняется в файле users.effect.ts

import { Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { of, map, switchMap, catchError } from 'rxjs'; import { UsersService } from "./rxjs-learn/users.service";   @Injectable() export class AppEffects {   loadUsers$ = createEffect(() =>     this.actions$.pipe(       ofType('[USERS] Select Users'),       switchMap(() =>         this.userService.getAll().pipe(           map((users) => ({             type: '[USERS] Users Loaded Success',             payload: users,           })),           catchError(() => of({ type: '[USERS] Users Loaded Error' }))         )       )     )   );     constructor(private actions$: Actions, private userService: UsersService) {} }

Эффект loadUsers$ прослушивает все отправленные действия через action — ‘[USERS] Select Users’ и использует для этого ofType оператор, который фильтрует список action согласно переданному типу.

В данном примере используются три экшена: ‘[USERS] Select Users’, ‘[USERS] Users Loaded Success’ и ‘[USERS] Users Loaded Error.

  • Select Users – экшен, который запускает процесс загрузки списка пользователей;

  • Users Loaded Success – экшен, который принимает список пользователей и говорит о том, что данные были успешно загружены;

  • Users Loaded Error – экшен, который говорит об ошибке загрузки данных;

  • actions$ – это все экшены, которые существуют в приложении;

  • ofType – оператор, который фильтрует список экшенов согласно переданному типу.

Поток обрабатывается с помощью оператора switchMap, добавляя логику загрузки всех пользователей из сервиса userService. Метод возвращает observable, который в зависимости от успеха или неудачи операции обрабатывается соответствующим образом. Действие отправляется в Store, где оно может быть обработано редукторами, когда требуется изменение состояния. Также важно обрабатывать ошибки при работе с наблюдаемыми потоками, чтобы эффекты продолжали работать. Другими словами, эффект вызывается на action и вызывает другой action, в данном случае мы видим что эффект вызывается на срабатывание action — Select Users. В ответ он может вызывать другой action — Users Loaded Success или Users Loaded Error. Таким образом, мы убрали лишнюю логику обращения к сервису из компонента, передав эту ответственность в effect.

Локальное состояние компонента

Также вкратце хочу затронуть тему локального управления состоянием для отдельного компонента. Если нам необходимо управление локальным состоянием в компонентах Angular, для создания более изолированных и независимых компонентов, которые могут управлять своим состоянием без необходимости использования глобального хранилища (NgRx Store), мы можем использовать для этой цели @ngrx/component-store. Он хорошо подходит для случаев, когда не требуется глобальное состояние, а нужно локальное управление состоянием, связанное с конкретным компонентом. Приведу небольшой пример с компонентом, в котором он используется.

import { Component } from '@angular/core'; import { CounterState } from "./reducers/counter"; import { ComponentStore } from "@ngrx/component-store";   @Component({   selector: 'app-counter',   template: `   <div>     <h1>Count: {{ count$ | async }}</h1>     <button (click)="increment()">Increment</button>     <button (click)="decrement()">Decrement</button>   </div>`, }) export class CounterComponent {   private readonly initialState: CounterState = { count: 0 };     constructor(private store: ComponentStore<CounterState>) {     this.store.setState(this.initialState);   }     readonly count$ = this.store.select((state) => state.count);     readonly increment = this.store.updater((state) => ({     ...state,     count: state.count + 1,   }));     readonly decrement = this.store.updater((state) => ({     ...state,     count: state.count - 1,   })); }

Внутри компонента CounterComponent мы инжектируем ComponentStore и устанавливаем начальное состояние с помощью метода setState(). Метод select используется для наблюдения за изменениями состояния count. Объявляем его как readonly count$, чтобы другие компоненты могли подписываться на это состояние. Для изменения состояния создаем методы increment и decrement, которые используют метод updater() для управления состоянием. Эти методы безопасно модифицируют текущее состояние.

Итак, @ngrx/component-store позволяет эффективно управлять локальным состоянием в Angular-компонентах, делает код более модульным, изолированным и идеально подходит для компонентов, которые требуют управления состоянием, но не нуждаются в глобальном хранилище NgRx.

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

Плюсы и минусы использования NgRx

Плюсы NgRx:

  • NgRx – это централизованное управление состоянием, оно позволяет хранить состояние в одном месте, что значительно упрощает его управление и отслеживание.

  • NgRx предоставляет инструменты для отладки и мониторинга приложения (Store Devtools), тем самым облегчается разработка и тестирование.

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

  • Наличие NgRx-эффектов, которые позволяют обрабатывать запросы к API в отдельном месте, что делает архитектуру приложения более чистой.

Минусы NgRx:

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

  • Если использовать NgRx для простых операций, коих может быть много, может привести к перегрузке проекта излишним объемом кода.

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

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

Аналоги NgRx

Есть несколько аналогов NgRx.  Я хочу выделить два – это Acita и Ngxs. Не буду здесь представлять их реализацию – это темы для отдельных статей, перечислю лишь достоинства и недостатки, которые можно выделить в сравнении с NgRx.

Akita:

Преимущества:

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

  • Поддержка работы с реляционными данными и возможность создания нормализованных структур данных.

  • Более легкая настройка и меньше шаблонного кода по сравнению с NgRx.

Недостатки:

  • Меньшее сообщество и количество обучающих материалов по сравнению с NgRx.

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

NGXS:

Преимущества:

  • Проще в освоении, чем NgRx, благодаря более простому синтаксису и меньше шаблонного кода.

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

Недостатки:

  • Меньше возможностей по сравнению с NgRx в плане продвинутых паттернов и расширяемости.

  • Редкая поддержка сообществом и меньшая популярность по сравнению с NgRx.

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

Спасибо за внимание!

Больше авторских материалов для frontend-разработчиков от моих коллег читайте в соцсетях SimbirSoft – ВКонтакте и Telegram.


ссылка на оригинал статьи https://habr.com/ru/articles/851362/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *