Redux в Angular. Использование Akita.
В данной статье приводится реализация redux
с помощью Akita.
Реализация redux
в akita
выглядит следующим образом:
Отличия Akita
от redux
в более прокаченных селекторах, которые называются query
, а также с неким отсутствием экшенов и эффектов, но которые можно добавить при необходимости.
Установка Akita
Для установки необходимо запустить команду:
yarn add @datorama/akita @ngneat/effects-ng
- @datorama/akita — пакет, реализующий логику хранилища;
- @ngneat/effects-ng — пакет, реализующий экшены и эффекты.
Этого достаточно, чтобы начать использовать хранилище. Но для того, чтобы работали эффекты, нужно подключить эффекты в AppModule
:
@NgModule({
imports: [
...,
EffectsNgModule.forRoot([])
],
})
export class AppModule {}
Root Store
Для использования devtools
и логирования экшенов нужно установить пару пакетов:
yarn add @ngneat/elf @ngneat/elf-devtools
После установки добавить логирование:
export function initElfDevTools(actions: Actions) {
return () => {
devTools({
name: 'Sample Application',
actionsDispatcher: actions,
});
};
}
И в модуле соответственно добавить в модуль:
@NgModule({
imports: [EffectsNgModule.forRoot([], { dispatchByDefault: true })],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
useFactory: initElfDevTools,
deps: [Actions],
},
],
})
export class RootStoreDevelopmentModule {}
Из-за того что логирование идет из другого state management
, то в проекте используется только эффекты без отладки:
import { NgModule } from '@angular/core';
import { EffectsNgModule } from '@ngneat/effects-ng';
// Note: Sample for using devtools with @ngneat
// export function initElfDevTools(actions: Actions) {
// return () => {
// devTools({
// name: 'Sample Application',
// actionsDispatcher: actions,
// });
// };
// }
/**
* Akita root store for production
*/
@NgModule({
imports: [EffectsNgModule.forRoot([])],
})
export class RootStoreModule {}
/**
* Akita root store for development
*/
@NgModule({
imports: [EffectsNgModule.forRoot([])],
providers: [
// Note: For enable logging actions, install @ngneat/elf, @ngneat/elf-devtools, and use initElfDevTools
// {
// provide: APP_INITIALIZER,
// multi: true,
// useFactory: initElfDevTools,
// deps: [Actions],
// },
],
})
export class RootStoreDevelopmentModule {}
Создание Feature Store
Для генерации новой фичи используется следующая команда:
nx g af post --plain
После выполнения команды будут сгенерированы следующие файлы:
post
├── state
│ ├── post.query.ts
│ ├── post.service.ts
│ └── post.store.ts
├── posts-state.module.ts
└── ...
post.query.ts
— файл, который содержит все селекторы;post.service.ts
— сервис доступа к данным;post.store.ts
— файл, в котором хранится реализация управления сущностями.
Так как статья является циклом статей и в проекте есть сервис для данных — PostFacade
, то заменим post.service.ts
на post.facade.ts
.
Так как необходимы эффекты, то необходимо создать еще 2 файла:
post.actions.ts
— файл, в котором хранятся все экшены;post.effects.ts
— файл, в котором реализованы эффекты.
Итоговая структура будет следующей:
post
├── state
│ ├── post.actions.ts
│ ├── post.effects.ts
│ ├── post.facade.ts
│ ├── post.query.ts
│ └── post.store.ts
├── posts-state.module.ts
└── ...
Для сайта новостей понадобятся следующие экшены:
- Загрузка списка новостей;
- Загрузка новости по
id
; - Создание новой новости;
- Изменение новости;
- Удаление новости;
- Удаление всех новостей;
- Восстановление новостей (сброс новостей до дефолтного списка новостей.
Обычно при работе с API используются цепочки событий. Например, для загрузки списка новостей будет использоваться цепочка:
load
— экшен, который вызывает загрузку списка новостей путем выполнения запроса к серверу;loadSuccess
— экшен, который испускается только в том случае, если список новостей был успешно загружен;loadFailure
— экшен, который испускается в случае если получили ошибку от сервера при попытке загрузить список новостей.
Аналогично создаются другие цепочки событий: change
, changeSuccess
, changeFailure
, remove
, removeSuccess
, removeFailure
и так далее.
Для akita
список экшенов будет выглядеть так:
Как в случае с Ngrx
и Ngxs
все экшены представлены цепочками экшенов.
Далее реализуется само хранилище. Интерфейс state
для списка новостей определяется следующим образом:
import { Injectable } from '@angular/core';
import { EntityState, EntityStore, StoreConfig } from '@datorama/akita';
import { Post } from '@angular-samples/redux/posts/common';
export interface PostState extends EntityState<Post, string> {
readonly loaded: boolean;
}
@Injectable()
@StoreConfig({ name: 'posts', idKey: 'uuid', resettable: true })
export class PostStore extends EntityStore<PostState> {
constructor() {
super();
}
}
PostState
расширяет EntityState
, который предоставляет методы для добавления и удаления сущностей.
Декоратор StoreConfig
конфигурирует хранилище, где указывается name
и idKey
, который будет использоваться в качестве ключа в словаре.
@StoreConfig({ name: 'posts', idKey: 'uuid', resettable: true })
Как было сказано ранее, селекторы представлены с помощью query
:
Пример получения всех сущностей:
posts$ = this.selectAll();
Пример фильтрации сущностей:
postsPromo$ = this.selectAll({
filterBy: [({ promo }) => promo],
sortBy: (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime(),
});
Пример поиска сущности по id
:
post$ = (uuid: string) => this.selectEntity(uuid).pipe(map((post) => post ?? null));
Эффекты реализуются аналогично Ngrx
:
Пример эффекта с загрузкой списка новостей:
load$ = createEffect(() => {
return this.actions.pipe(
ofType(PostActions.load),
tap((a) => console.log(a)),
switchMap(() =>
this.postApiService.get().pipe(
tap((posts) => this.postStore.set(posts)),
map((posts) => PostActions.loadSuccess({ posts })),
catchError((error) => of(PostActions.loadFailure({ error })))
)
)
);
});
Как видно из примера, из-за того что нету reducer
, то запись в хранилище осуществляется после успешной загрузки.
this.postApiService.get().pipe(
tap((posts) => this.postStore.set(posts))
)
Единственное, чего нету в эффектах akita
— это момент инициализации. Для этого приходится использовать грязный хак при создании модуля:
@NgModule({
imports: [PostApiModule, EffectsNgModule.forFeature([PostEffects])],
providers: [
PostStore,
PostQuery,
{
provide: PostFacade,
useClass: AkitaPostFacade,
},
],
})
export class PostsStateModule {
// TODO: Dirty fix for emulate OnInitEffects
constructor(postFacade: PostFacade) {
postFacade.load();
}
}
Фасад будет выглядеть следующим образом:
Пример доступа к селектору:
@Injectable()
export class AkitaPostFacade implements PostFacade {
constructor(private readonly postQuery: PostQuery) {}
loaded$ = this.postQuery.loaded$;
//...
}
Пример подписки на экшен:
import { ofType } from '@ngneat/effects';
@Injectable()
export class AkitaPostFacade implements PostFacade {
constructor(private readonly actions: Actions) {}
loadSuccess$ = this.actions.pipe(
ofType(PostActions.loadSuccess),
map(({ posts }) => posts)
);
//...
}
Пример вызова экшена:
@Injectable()
export class AkitaPostFacade implements PostFacade {
load(): void {
dispatch(PostActions.load());
}
//...
}
Продолжение Akita — Elf
Elf
— это дальнейшее развитие akita
.
Например, пример для Angular
взятый из документации:
import { Injectable } from '@angular/core';
import { propsArrayFactory, createStore } from '@ngneat/elf';
import {
selectAllEntities,
setEntities,
withEntities,
selectEntities,
} from '@ngneat/elf-entities';
import { map, withLatestFrom } from 'rxjs/operators';
export interface Book {
id: string;
volumeInfo: {
title: string;
authors: Array<string>;
};
}
const {
withCollectionIds,
selectCollectionIds,
addCollectionIds,
removeCollectionIds,
inCollectionIds,
} = propsArrayFactory('collectionIds', { initialValue: [] as string[] });
const store = createStore(
{ name: 'books' },
withEntities<Book>(),
withCollectionIds()
);
@Injectable({ providedIn: 'root' })
export class BooksRepository {
books$ = store.pipe(selectAllEntities());
ownBooks$ = store.pipe(selectCollectionIds()).pipe(
withLatestFrom(store.pipe(selectEntities())),
map(([ids, books]) => ids.map((id) => books[id]))
);
setBooks(books: Book[]) {
store.update(setEntities(books));
}
removeFromCollection(bookId: string) {
store.update(removeCollectionIds(bookId));
}
addToCollection(bookId: string) {
if (!store.query(inCollectionIds(bookId))) {
store.update(addCollectionIds(bookId));
}
}
}
С точки зрения реализации, там все тоже самое что и в akita
, только теперь используются функции, а не сервисы.
Для поддержки эффектов необходимо добавить тоже пакет эффектов.
Akita + Ngrx
Из-за того что в akita
отсутствуют эффекты и экшены как таковые, то никто не мешает использовать Ngrx
для поддержки эффектов.
Необходимо добавить пакет эффектов в AppModule
и создать соответствующие экшены.
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { PostApiModule } from '@angular-samples/redux/posts/api';
import { PostFacade } from '@angular-samples/redux/posts/facade';
import { PostQuery } from '../post.query';
import { PostStore } from '../post.store';
import { PostEffects } from './post.effects';
import { AkitaNgrxPostFacade } from './post.facade';
/**
* Post state with Akita and Ngrx Effects
*/
@NgModule({
imports: [PostApiModule, EffectsModule.forFeature([PostEffects])],
providers: [
PostStore,
PostQuery,
{
provide: PostFacade,
useClass: AkitaNgrxPostFacade,
},
],
})
export class PostsStateWithNgrxModule {}
Тогда эффект будет выглядеть также как и в ngrx:
load$ = createEffect(() => {
return this.actions$.pipe(
ofType(PostActions.load),
fetch({
id: () => 'load',
run: () =>
this.postApiService.get().pipe(
tap((posts) => this.postStore.set(posts)),
map((posts) => PostActions.loadSuccess({ posts }))
),
onError: (action, error) => PostActions.loadFailure({ error }),
})
);
});
Пример подобной реализации можно глянуть в проекте.
Ссылки
Предыдущая статья — Использование Ngxs.
Следующая статья — Сравнение разных реализаций redux. Ngrx vs Ngxs vs Akita.
Все исходники находятся на github, в репозитории:
Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — redux
.
Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln