Redux в Angular. Использование Ngxs.
В данной статье приводится реализация redux
с помощью Ngxs.
Ngxs задумывался как redux
более приближенной к фреймворку, в отличии от ngrx
.
Реализация redux
в ngxs
выглядит следующим образом:
Компонент диспатчит экшен. Экшен вызывает соответствующие апи запросы и изменяет store
. После того как store
был изменен, обновляются селекторы.
Установка Ngrx
Для того чтобы использовать ngxs
необходимо установить зависимости, запустив команду:
yarn add @ngxs/store @ngxs/devtools-plugin
После выполнения команды необходимо добавить модуль в AppModule
:
@NgModule({
imports: [
...,
NgxsModule.forRoot([])
],
})
export class AppModule {}
Root Store
Для использования devtools
и логирования экшенов нужно использовать NgxsReduxDevtoolsPluginModule
.
Для оптимизации были созданы два модуля для prod
и development
версий:
import { NgModule } from '@angular/core';
import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';
import { NgxsModule } from '@ngxs/store';
@NgModule({
imports: [NgxsModule.forRoot([])],
})
export class RootStoreModule {}
@NgModule({
imports: [
NgxsModule.forRoot([], {
developmentMode: true,
}),
NgxsReduxDevtoolsPluginModule.forRoot(),
],
})
export class RootStoreDevelopmentModule {}
Тогда подключение root store
выглядит следующим образом:
@NgModule({
imports: [
...
environment.production
? RootStoreModule,
: RootStoreDevelopmentModule
],
...
})
export class AppModule {}
Создание Feature Store
Для генерации новой фичи используется следующая команда:
ngxs --name post
Для работы команды необходимо установить ngxs cli.
Стоит отметить, что в момент написания статьи
ngxs cli
не работали наmacbook
. Поэтому в статье все файлы созданы руками, а не генераторами.
После выполнения команды будут сгенерированы следующие файлы:
feature
├── state
│ ├── feature.actions.ts
│ ├── feature.facade.ts
│ └── feature.state.ts
├── feature.module.ts
└── ...
Каждый файл содержит конкретные абстракции redux
:
feature.actions.ts
— файл, который содержит все экшены;feature.state.ts
— файл, который содержит все селекторы, мутации и асинхронные экшены;feature.facade.ts
— файл, который скрывает прямое обращение к конкретной реализацииredux
.
Файл facade не генерируется в ngxs, но так как статья является часть цикла статей, добавляется данный фасад.
Для сайта новостей понадобятся следующие экшены:
- Загрузка списка новостей;
- Загрузка новости по
id
; - Создание новой новости;
- Изменение новости;
- Удаление новости;
- Удаление всех новостей;
- Восстановление новостей (сброс новостей до дефолтного списка новостей.
Обычно при работе с API используются цепочки событий. Например, для загрузки списка новостей будет использоваться цепочка:
Load
— экшен, который вызывает загрузку списка новостей путем выполнения запроса к серверу;LoadSuccess
— экшен, который испускается только в том случае, если список новостей был успешно загружен;loadFailure
— экшен, который испускается в случае если получили ошибку от сервера при попытке загрузить список новостей.
Аналогично создаются другие цепочки событий: Change
, ChangeSuccess
, ChangeFailure
, Remove
, RemoveSuccess
, RemoveFailure
и так далее.
Для новостного сайта список экшенов в ngxs
выглядит следующим образом:
В примере выше видно, что все экшены представлены цепочками экшенов.
В Ngxs
в качестве reducer
выступает сервис, который называют state
, а сам стейт model
:
Для списка новостей модель будет следующей:
export interface PostStateModel {
readonly loaded: boolean;
readonly ids: string[];
readonly entities: Record<string, Post>;
}
export const initialPostState: PostStateModel = {
ids: [],
entities: {},
loaded: false,
};
В данном случае имеем классическую реализацию коллекций:
ids
— список идентификаторов сущностей;entities
— словарь с новостями, ключом которого являетсяid
новости;loaded
— признак загрузки списка новостей.
Начальное значение модели вынесено в отельную константу — initialPostState
.
Реализация редьюсера представлена сервисом:
@State<PostStateModel>({
name: 'posts',
defaults: initialPostState,
})
@Injectable()
export class PostState implements NgxsOnInit {
...
}
@State
— декоратор сервиса, который задает имя фичи (name
) и начальное значение (defaults
);NgxsOnInit
— интерфейс для подписки на инициализацию эффектов дляfeature store
.
В данном случае сервис совмещает в себе следующий функционал:
reducer
— изменение состояниеstate
;selectors
— доступ к свойствамstate
;effects
— выполнение асинхронных экшенов.
Пример реализации селектора:
@Injectable()
export class PostState {
// ...
@Selector()
static loaded(state: PostStateModel): boolean {
return state.loaded;
}
}
Пример реализации селектора с параметром:
@Injectable()
export class PostState {
// ...
static post(uuid: string): (state: PostStateModel) => Post | null {
return createSelector([PostState], (state: PostStateModel) => {
return state.entities[uuid] ?? null;
});
}
}
Пример создания асинхронного экшена/цепочки экшенов/эффекта:
@Injectable()
export class PostState {
// ...
@Action(PostActions.Load)
load(ctx: StateContext<PostStateModel>) {
return this.postApiService.get().pipe(
map((posts) => {
const state = ctx.getState();
ctx.setState({
...state,
ids: posts.map((post) => post.uuid),
entities: posts.reduce((acc, current) => ({ ...acc, [current.uuid]: current }), {}),
});
return ctx.dispatch(new PostActions.LoadSuccess(posts));
}),
catchError((error: unknown) => ctx.dispatch(new PostActions.LoadFailure(error)))
);
}
}
Если создавать цепочки подобным образом, то результат будет следующим:
Из-за того, что экшен в Ngxs
имеет свой жизненый путь, то и экшены будут выполнятся соответвенно жизненому циклу. В данном случае Load
это асинхронный экшен, а LoadSuccess
синхронный. Из-за того, что LoadSuccess
вызывается в момент исполнения Load
, то сначала залогируется LoadSuccess
, а только потом Load
.
Жизненный цикл экшена:
Подробнее о экшенах в документации — Actions Life Cycle.
Выше приведенной реализации достаточно, чтобы реализовать фасад:
Для обращения к селекторам используется декоратор select
:
@Select(PostState.loaded)
loaded$!: Observable<boolean>;
Для подписки на экшены используется сервис Actions
:
loadSuccess$ = this.actions.pipe(
ofActionDispatched(PostActions.LoadSuccess),
map(({ posts }) => posts)
);
Для диспатча событий используется глобальный сервис store
:
load() {
this.store.dispatch(new PostActions.Load());
}
Ngxs labs
Вышеприведенных примеров достаточно для того чтобы создавать states
и использовать их. Однако, знающий разработчик может заметить, что в приведенной реализации не использовались решения из ngxs labs
, в частности решение по управления коллекциями.
@ngxs-labs/data
— он же @angular-ru/ngxs
расширение для ngxs
, которое пытается уменьшить и упростить количество кода.
Реализация redux
в ngxs
с использованием решения от angular-ry/ngxs
:
Краткий пример, который был взят из документации:
Код state
до использования плагина counter.state.ts
:
import { State, Action, StateContext } from '@ngxs/store';
export class Increment {
static readonly type = '[Counter] Increment';
}
export class Decrement {
static readonly type = '[Counter] Decrement';
}
@State<number>({
name: 'counter',
defaults: 0
})
export class CounterState {
@Action(Increment)
increment(ctx: StateContext<number>) {
ctx.setState(ctx.getState() + 1);
}
@Action(Decrement)
decrement(ctx: StateContext<number>) {
ctx.setState(ctx.getState() - 1);
}
}
и файл компонента app.component.ts
:
import { Component } from '@angular/core';
import { Select, Store } from '@ngxs/store';
import { CounterState, Increment, Decrement } from './counter.state';
@Component({
selector: 'app-root',
template: `
<ng-container *ngIf="counter$ | async as counter">
<h1>{{ counter }}</h1>
</ng-container>
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
`
})
export class AppComponent {
@Select(CounterState) counter$: Observable<number>;
constructor(private store: Store) {}
increment() {
this.store.dispatch(new Increment());
}
decrement() {
this.store.dispatch(new Decrement());
}
}
После применения плагина, файл со стейтом:
import { State } from '@ngxs/store';
import { DataAction, StateRepository } from '@angular-ru/ngxs/decorators';
import { NgxsDataRepository } from '@angular-ru/ngxs/repositories';
@StateRepository()
@State<number>({
name: 'counter',
defaults: 0
})
@Injectable()
export class CounterState extends NgxsDataRepository<number> {
@DataAction() increment() {
this.ctx.setState((state) => ++state);
}
@DataAction() decrement() {
this.ctx.setState((state) => --state);
}
}
и файл с компонентом соответственно:
import { Component } from '@angular/core';
import { CounterState } from './counter.state';
@Component({
selector: 'app-root',
template: `
<h1>{{ counter.snapshot }}</h1>
<button (click)="counter.increment()">Increment</button>
<button (click)="counter.decrement()">Decrement</button>
`
})
export class AppComponent {
constructor(counter: CounterState) {}
}
С одной стороны выглядит привлекательно, что множество ненужного кода убирается, но с другой стороны теряются концепты redux
, реализация которых скрыта глубоко внутри плагина, и пользователю предоставляется новая абстракция.
У меня было желание сделать еще один проект с другой реализацией
ngxs
, но я понял что решение отangular-ru
предоставляет свойstate management
концепт, отличный отredux
. И поэтому я пытался воссоздать максимально близкое решение к классическомуredux
, в ущерб оптимизации и количеству кода.
Последним примером будет реализация коллекций с помощью плагина:
import { Injectable } from '@angular/core';
import { createEntityCollections, EntityCollections } from '@angular-ru/cdk/entity';
import { DataAction, Payload, StateRepository } from '@angular-ru/ngxs/decorators';
import { NgxsDataEntityCollectionsRepository } from '@angular-ru/ngxs/repositories';
import { NgxsOnInit, State } from '@ngxs/store';
import { PostApiService } from '@angular-samples/redux/posts/api';
import { Post } from '@angular-samples/redux/posts/common';
interface PostStateOptions {
loaded: boolean;
}
export type PostStateModel = EntityCollections<Post, string, PostStateOptions>;
export const initialPostState: PostStateModel = {
...createEntityCollections(),
loaded: false,
};
@StateRepository()
@State<PostState>({
name: 'posts',
defaults: initialPostState,
})
@Injectable()
export class PostStateRepository extends NgxsDataEntityCollectionsRepository<Post, string, PostStateOptions> implements NgxsOnInit {
constructor(private readonly postApiService: PostApiService) {
super();
}
override selectId(entity: Post): string {
return entity.uuid;
}
@DataAction()
setLoaded(@Payload('loaded') loaded: boolean): void {
const state = this.getState();
this.setEntitiesState({
...state,
loaded,
});
}
override ngxsOnInit(ctx: StateContext<PostStateModel>): void {
// TODO: Need to call ctx.dispatch(new PostActions.Load());
}
}
И тут начинаются некоторые проблемы для интеграции с текущим циклом статей:
- Первая проблема в отсутствии экшенов. Непонятно как вызывать автозагрузку при старте.
- Вторая проблема связана с тем, что непонятно как подписываться на определенные экшены.
Из-за этого реализацию пришлось отложить, и продолжить уже с другим решением — Akita
.
Ссылки
Предыдущая статья — Ngrx в действии: создание и тестирование.
Следующая статья — Использование Akita.
Все исходники находятся на github, в репозитории:
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — redux
.
Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln