Всем привет! Меня зовут Ильмир, я 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.

Комментарии (2)


  1. PML
    17.10.2024 16:00

    Спасибо за материал, все по делу и без воды.


  1. Rashid111
    17.10.2024 16:00

    Круто, давно не видел, чтобы так емко, кратко и поделу.