Redux в Angular. Использование Akita.

Aleksandr Serenko
F.A.F.N.U.R
Published in
6 min readNov 24, 2022

В данной статье приводится реализация 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

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock