Всем привет! Меня зовут Ильмир, я frontend-разработчик SimbirSoft. Это моя первая статья, в которой я хотел бы разобрать тему менеджера состояний в Angular.
Назначение
Итак, начнем с определения. В контексте веб-разработки менеджер состояний NgRx — это инструмент, который управляет глобальным для всего приложения состоянием. Согласно документации, он является инструментом изоляции побочных эффектов для создания более чистой архитектуры компонентов. Также с введением NgRx появляются инструменты для разработки и отладки, расширяющие возможности создания различных типов приложений.
Особенности применения
Сразу скажу, что инструмент для использования лучше всего определить до начала работ, чтобы в дальнейшем не тратить время на его внедрение. Рассмотрим признаки, по которым можно решить, что внедрение в проект менеджера будет полезным:
Сложное состояние. Если ваше приложение имеет сложную иерархию состояний, управление которыми становится затруднительным, NgRx может помочь структурировать эти состояния более эффективно.
Необходимость в многоуровневой передаче данных, когда данные должны быть доступны для большого количества компонентов на разных уровнях дерева, NgRx предоставляет централизованное хранилище store.
Потребности в масштабируемости. Если проект планируется расширять, NgRx поможет создать более управляемую архитектуру, которая упростит добавление новых функций.
Наличие побочных эффектов в приложении. Если приложению нужны сложные асинхронные операции, такие как HTTP-запросы или взаимодействия с API, NgRx Effects поможет управлять побочными эффектами через четко организованную архитектуру.
Параллельная работа с несколькими источниками данных. Если ваше приложение извлекает и обрабатывает данные из разных источников, NgRx может помочь управлять взаимодействием с этими источниками более эффективно.
Высокие требования к производительности. Если приложение должно обрабатывать большие объемы данных и сохранять высокую скорость работы, NgRx может помочь избежать повторных рендеров и оптимизировать работу с состоянием.
Наличие многофункциональной команды. NgRx может помочь улучшить совместную работу и код-ревью команды из нескольких человек благодаря стандартизированному подходу к управлению состоянием.
Необходимость в четком контроле за состоянием. Если для вашего приложения важно иметь четкое управление состоянием и откатывание событий, NgRx предоставляет возможность отслеживать все изменения состояния.
Из перечисленных пунктов можно сделать вывод, что в случае с NgRx лучше всего изначально понимать, стоит им пользоваться или нет, потому что временные затраты на разработку могут не окупиться. Если приложение недостаточно крупное, то вполне может обойтись без этой надстройки к архитектуре Angular, которая сама по себе уже подразумевает разделение приложения на какие-то логические части.
Состояния, состояния...
Во frontend может присутствовать много различных состояний (схема 1), например, состояние в url, клиентское состояние (то есть состояние полей ввода или других html тегов), состояние локального хранилища, веб-сокеты, если мы их используем, и много других.
А если это все у нас как-то взаимодействует с бэком… В итоге когда приложение разрастается, получается мешанина, в которой бывает очень сложно разобраться. Например, когда разные разработчики независимо друг от друга настраивают разные запросы для получения одних и тех же данных с бэка. Иными словами, если приложение большое и команда разработчиков большая, то контролировать состояния приложения становится очень сложно. В такой ситуации нам и может понадобиться 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)
Принцип работы
Давайте рассмотрим принцип работы 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.
PML
Спасибо за материал, все по делу и без воды.