Pull to refresh

angular-ngrx-data — state management и CRUD за пять минут

Reading time 11 min
Views 32K
image
На сегодняшний день ни одно большое SPA приложение не обходится без state management (управления состоянием). Для Angular по данному направлению есть несколько решений. Самым популярным из них является NgRx. Он реализует Redux паттерн с использованием библиотеки RxJs и обладает хорошим инструментарием.

В данной статье мы кратко пройдемся по основным модулям NgRx и более детально сосредоточимся на библиотеке angular-ngrx-data, которая позволяет сделать полноценный CRUD со state management за пять минут.

Обзор NgRx


Детально про NgRx можно почитать в следующих статьях:

Реактивные приложения на Angular/NGRX. Часть 1. Введение
Реактивные приложения на Angular/NGRX. Часть 2. Store
Реактивные приложения на Angular/NGRX. Часть 3. Effects

Кратко рассмотрим основные модули NgRx, его плюсы и минусы.

NgRx/store — реализует Redux паттерн.

Простая реализация store
counter.actions.ts
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';

counter.reducer.ts

import { Action } from '@ngrx/store';
const initialState = 0;

export function counterReducer(state: number = initialState, action: Action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1;

    case DECREMENT:
      return state - 1;

    case RESET:
      return 0;

    default:
      return state;
  }
}
.
Подключение в модуль
import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store';
import { counterReducer } from './counter';

@NgModule({
  imports: [StoreModule.forRoot({ count: counterReducer })],
})
export class AppModule {}

Использование в компоненте
import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { INCREMENT, DECREMENT, RESET } from './counter';

interface AppState {
  count: number;
}

@Component({
  selector: 'app-my-counter',
  template: `
    <button (click)="increment()">Increment</button>
    <div>Current Count: {{ count$ | async }}</div>
    <button (click)="decrement()">Decrement</button>

    <button (click)="reset()">Reset Counter</button>
  `,
})
export class MyCounterComponent {
  count$: Observable<number>;

  constructor(private store: Store<AppState>) {
    this.count$ = store.pipe(select('count'));
  }

  increment() {
    this.store.dispatch({ type: INCREMENT });
  }

  decrement() {
    this.store.dispatch({ type: DECREMENT });
  }

  reset() {
    this.store.dispatch({ type: RESET });
  }
}


NgRx/store-devtools — позволяет отслеживать изменения в приложении через redux-devtools.

Пример подключения
import { StoreDevtoolsModule } from '@ngrx/store-devtools';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers),
    // Модуль должен быть подключен после StoreModule
    StoreDevtoolsModule.instrument({
      maxAge: 25, // Хранятся последние 25 состояний
    }),
  ],
})
export class AppModule {}


NgRx/effects — позволяет добавлять в хранилище данные, приходящие в приложение, такие как http запросы.

Пример
./effects/auth.effects.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Action } from '@ngrx/store';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { catchError, map, mergeMap } from 'rxjs/operators';

@Injectable()
export class AuthEffects {
  // Listen for the 'LOGIN' action
  @Effect()
  login$: Observable<Action> = this.actions$.pipe(
    ofType('LOGIN'),
    mergeMap(action =>
      this.http.post('/auth', action.payload).pipe(
        // If successful, dispatch success action with result
        map(data => ({ type: 'LOGIN_SUCCESS', payload: data })),
        // If request fails, dispatch failed action
        catchError(() => of({ type: 'LOGIN_FAILED' }))
      )
    )
  );

  constructor(private http: HttpClient, private actions$: Actions) {}
}

Подключение эффекта в модуль

import { EffectsModule } from '@ngrx/effects';
import { AuthEffects } from './effects/auth.effects';

@NgModule({
  imports: [EffectsModule.forRoot([AuthEffects])],
})
export class AppModule {}


NgRx/entity — предоставляет возможность работать с массивами данных.

Пример
user.model.ts

export interface User {
  id: string;
  name: string;
}

user.actions.ts

import { Action } from '@ngrx/store';
import { Update } from '@ngrx/entity';

import { User } from './user.model';

export enum UserActionTypes {
  LOAD_USERS = '[User] Load Users',
  ADD_USER = '[User] Add User',
  UPSERT_USER = '[User] Upsert User',
  ADD_USERS = '[User] Add Users',
  UPSERT_USERS = '[User] Upsert Users',
  UPDATE_USER = '[User] Update User',
  UPDATE_USERS = '[User] Update Users',
  DELETE_USER = '[User] Delete User',
  DELETE_USERS = '[User] Delete Users',
  CLEAR_USERS = '[User] Clear Users',
}

export class LoadUsers implements Action {
  readonly type = UserActionTypes.LOAD_USERS;

  constructor(public payload: { users: User[] }) {}
}

export class AddUser implements Action {
  readonly type = UserActionTypes.ADD_USER;

  constructor(public payload: { user: User }) {}
}

export class UpsertUser implements Action {
  readonly type = UserActionTypes.UPSERT_USER;

  constructor(public payload: { user: User }) {}
}

export class AddUsers implements Action {
  readonly type = UserActionTypes.ADD_USERS;

  constructor(public payload: { users: User[] }) {}
}

export class UpsertUsers implements Action {
  readonly type = UserActionTypes.UPSERT_USERS;

  constructor(public payload: { users: User[] }) {}
}

export class UpdateUser implements Action {
  readonly type = UserActionTypes.UPDATE_USER;

  constructor(public payload: { user: Update<User> }) {}
}

export class UpdateUsers implements Action {
  readonly type = UserActionTypes.UPDATE_USERS;

  constructor(public payload: { users: Update<User>[] }) {}
}

export class DeleteUser implements Action {
  readonly type = UserActionTypes.DELETE_USER;

  constructor(public payload: { id: string }) {}
}

export class DeleteUsers implements Action {
  readonly type = UserActionTypes.DELETE_USERS;

  constructor(public payload: { ids: string[] }) {}
}

export class ClearUsers implements Action {
  readonly type = UserActionTypes.CLEAR_USERS;
}

export type UserActionsUnion =
  | LoadUsers
  | AddUser
  | UpsertUser
  | AddUsers
  | UpsertUsers
  | UpdateUser
  | UpdateUsers
  | DeleteUser
  | DeleteUsers
  | ClearUsers;

user.reducer.ts
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { User } from './user.model';
import { UserActionsUnion, UserActionTypes } from './user.actions';

export interface State extends EntityState<User> {
  // additional entities state properties
  selectedUserId: number | null;
}

export const adapter: EntityAdapter<User> = createEntityAdapter<User>();

export const initialState: State = adapter.getInitialState({
  // additional entity state properties
  selectedUserId: null,
});

export function reducer(state = initialState, action: UserActionsUnion): State {
  switch (action.type) {
    case UserActionTypes.ADD_USER: {
      return adapter.addOne(action.payload.user, state);
    }

    case UserActionTypes.UPSERT_USER: {
      return adapter.upsertOne(action.payload.user, state);
    }

    case UserActionTypes.ADD_USERS: {
      return adapter.addMany(action.payload.users, state);
    }

    case UserActionTypes.UPSERT_USERS: {
      return adapter.upsertMany(action.payload.users, state);
    }

    case UserActionTypes.UPDATE_USER: {
      return adapter.updateOne(action.payload.user, state);
    }

    case UserActionTypes.UPDATE_USERS: {
      return adapter.updateMany(action.payload.users, state);
    }

    case UserActionTypes.DELETE_USER: {
      return adapter.removeOne(action.payload.id, state);
    }

    case UserActionTypes.DELETE_USERS: {
      return adapter.removeMany(action.payload.ids, state);
    }

    case UserActionTypes.LOAD_USERS: {
      return adapter.addAll(action.payload.users, state);
    }

    case UserActionTypes.CLEAR_USERS: {
      return adapter.removeAll({ ...state, selectedUserId: null });
    }

    default: {
      return state;
    }
  }
}

export const getSelectedUserId = (state: State) => state.selectedUserId;

// get the selectors
const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();

// select the array of user ids
export const selectUserIds = selectIds;

// select the dictionary of user entities
export const selectUserEntities = selectEntities;

// select the array of users
export const selectAllUsers = selectAll;

// select the total user count
export const selectUserTotal = selectTotal;

reducers/index.ts

import {
  createSelector,
  createFeatureSelector,
  ActionReducerMap,
} from '@ngrx/store';
import * as fromUser from './user.reducer';

export interface State {
  users: fromUser.State;
}

export const reducers: ActionReducerMap<State> = {
  users: fromUser.reducer,
};

export const selectUserState = createFeatureSelector<fromUser.State>('users');

export const selectUserIds = createSelector(
  selectUserState,
  fromUser.selectUserIds
);
export const selectUserEntities = createSelector(
  selectUserState,
  fromUser.selectUserEntities
);
export const selectAllUsers = createSelector(
  selectUserState,
  fromUser.selectAllUsers
);
export const selectUserTotal = createSelector(
  selectUserState,
  fromUser.selectUserTotal
);
export const selectCurrentUserId = createSelector(
  selectUserState,
  fromUser.getSelectedUserId
);

export const selectCurrentUser = createSelector(
  selectUserEntities,
  selectCurrentUserId,
  (userEntities, userId) => userEntities[userId]
);


Что в итоге?


Мы получаем полноценный state management с кучей плюсов:

— единый источник данных для приложения,
— состояние хранится отдельно от приложения,
— единый стиль написания для всех разработчиков в проекте,
changeDetectionStrategy.OnPush во всех компонентах приложения,
— удобная отладка через redux-devtools,
— легкость тестирования, т.к. reducers являются “чистыми” функциями.

Но есть и минусы:

— большое количество непонятных на первый взгляд модулей,
— много однотипного кода, на который без грусти не взглянешь,
— сложность в освоении из-за всего выше перечисленного.

CRUD


Как правило, значительную часть приложения занимает работа с объектами (создание, чтение, обновление, удаление), поэтому для удобства работы была придумана концепция CRUD (Create, Read, Update, Delete). Таким образом, базовые операции для работы со всеми типами объектов стандартизированы. На бэкенде это уже давно процветает. Многие библиотеки помогают реализовать данную функциональность и избавиться от рутинной работы.

В NgRx за CRUD отвечает модуль entity, и если посмотреть пример его реализации, сразу видно, что это самая большая и наиболее сложная часть NgRx. Именно поэтому John Papa и Ward Bell создали angular-ngrx-data.

angular-ngrx-data


angular-ngrx-data — это библиотека-надстройка над NgRx, которая позволяет работать с массивами данных без написания лишнего кода.
Помимо создания полноценного state management, она берет на себя создание сервисов с http для взаимодействия с сервером.

Рассмотрим на примере


Установка

npm install --save @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools ngrx-data

Модуль angular-ngrx-data

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
 EntityMetadataMap,
 NgrxDataModule,
 DefaultDataServiceConfig
} from 'ngrx-data';

const defaultDataServiceConfig: DefaultDataServiceConfig = {
 root: 'crud'
};

export const entityMetadata: EntityMetadataMap = {
 Hero: {},
 User:{}
};

export const pluralNames = { Hero: 'heroes' };

@NgModule({
 imports: [
   CommonModule,
   NgrxDataModule.forRoot({ entityMetadata, pluralNames })
 ],
 declarations: [],
 providers: [
   { provide: DefaultDataServiceConfig, useValue: defaultDataServiceConfig }
 ]
})
export class EntityStoreModule {}

Подключение в приложение

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    StoreModule.forRoot({}),
    EffectsModule.forRoot([]),
    EntityStoreModule,
    StoreDevtoolsModule.instrument({
      maxAge: 25, 
    }),
  ],
  declarations: [
    AppComponent
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}

Только что мы получили сгенерированное API для работы с бэком и интеграцию API с NgRx, не написав при этом ни одного effect, reducer и action и selector.

Разберем более подробно то, что тут происходит


Константа defaultDataServiceConfig задает конфигурацию для нашего API и подключается в providers модуля. Свойство root указывает, куда обращаться за запросами. Если его не задать, то по умолчанию будет «api».

const defaultDataServiceConfig: DefaultDataServiceConfig = {
 root: 'crud'
};

Константа entityMetadata определяет названия сторов, которые будут созданы при подключении NgrxDataModule.forRoot.

export const entityMetadata: EntityMetadataMap = {
 Hero: {},
 User:{}
};
...
NgrxDataModule.forRoot({ entityMetadata, pluralNames })

Путь к API состоит из базового пути (в нашем случае «crud») и имени стора.
Например, для получения пользователя с определенным номером путь будет такой — «crud/user/{userId}».

Для получения полного списка пользователелей в конце имени стора по умолчанию добавляется буква «s» — «crud/users».

Если для получения полного списка нужен другой роут (например, «heroes», а не «heros»), его можно изменить, задав pluralNames и подключив их в NgrxDataModule.forRoot.

export const pluralNames = { Hero: 'heroes' };
...
NgrxDataModule.forRoot({ entityMetadata, pluralNames })

Подключение в компоненте


Для подключения в компоненте необходимо передать в конструктор entityServices и через метод getEntityCollectionService выбрать сервис нужного хранилища

import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { Observable } from 'rxjs';
import { Hero } from '@appModels/hero';
import { EntityServices, EntityCollectionService } from 'ngrx-data';

@Component({
  selector: 'app-heroes',
  templateUrl: './heroes.component.html',
  styleUrls: ['./heroes.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroesComponent implements OnInit {
  heroes$: Observable<Hero[]>;
  heroesService: EntityCollectionService<Hero>;

  constructor(entityServices: EntityServices) {
    this.heroesService = entityServices.getEntityCollectionService('Hero');
  }

 ...
}

Для привязки списка к компоненту достаточно взять из сервиса свойство entities$, а для получения данных с сервера вызвать метод getAll().

ngOnInit() {
  this.heroes$ = this.heroesService.entities$;
  this.heroesService.getAll();
}

Также, помимо основных данных, можно получить:

loaded$, loading$ — получение статуса загрузки данных,
errors$ — ошибки при работе сервиса,
count$ — общее колличество записей в хранилище.

Основные методы взаимодействия с сервером:

getAll() — получение всего списка данных,
getWithQuery(query) — получение списка, отфильтрованного с помощью query-параметров,
getByKey(id) — получение одной записи по идентификатору,
add(entity) — добавление новой сущности с запросом на бэк,
delete(entity) — удаление сущности с запросом на бэк,
update(entity) — обновление сущности с запросом на бэк.

Методы локальной работы с хранилищем:

addManyToCache(entity) — добавление массива новых сущностей в хранилище,
addOneToCache(entity) — добавление новой сущности только в хранилище,
removeOneFromCache(id) — удаление одной сущности из хранилища,
updateOneInCache(entity) — обновление сущности в хранилище,
upsertOneInCache(entity) — если сущность с указанным id существует, она обновляется, если нет — создается новая,
— и др.

Пример использования в компоненте

import { EntityCollectionService, EntityServices } from 'ngrx-data';
import { Hero } from '../../core';

@Component({
  selector: 'app-heroes',
  templateUrl: './heroes.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeroesComponent implements OnInit {
  heroes$: Observable<Hero[]>;
  heroesService: EntityCollectionService<Hero>;

  constructor(entityServices: EntityServices) {
    this.heroesService = entityServices.getEntityCollectionService('Hero');
  }

  ngOnInit() {
    this.heroes$ = this.heroesService.entities$;
    this.getHeroes();
  }

  getHeroes() {
    this.heroesService.getAll();
  }

  addHero(hero: Hero) {
    this.heroesService.add(hero);
  }

  deleteHero(hero: Hero) {
    this.heroesService.delete(hero.id);
  }

  updateHero(hero: Hero) {
    this.heroesService.update(hero);
  }
}

Все методы angular-ngrx-data делятся на работающие локально и взаимодействующие с сервером. Это позволяет использовать библиотеку при манипуляциях с данными как на клиенте, так и с использованием сервера.

Логирование


Для логирования необходимо заинжектить EntityServices в компонент или сервис и использовать свойства:

reducedActions$ — для логирования действий,
entityActionErrors$ — для логирования ошибок.

import { Component, OnInit } from '@angular/core';
import { MessageService } from '@appServices/message.service';
import { EntityServices } from 'ngrx-data';

@Component({
  selector: 'app-messages',
  templateUrl: './messages.component.html',
  styleUrls: ['./messages.component.css']
})
export class MessagesComponent implements OnInit {
  constructor(
    public messageService: MessageService,
    private entityServices: EntityServices
  ) {}
  ngOnInit() {
    this.entityServices.reducedActions$.subscribe(res => {
      if (res && res.type) {
        this.messageService.add(res.type);
      }
    });
  }
}

Переезд в основной репозиторий NgRx


Как было объявлено на ng-conf 2018, angular-ngrx-data в ближайшее время будет перенесен в основной репозиторий NgRx.

Видео с докладом Reducing the Boilerplate with NgRx - Brandon Roberts & Mike Ryan


Ссылки


Создатели anguar-ngrx-data:
— John Papa twitter.com/John_Papa
— Ward Bell twitter.com/wardbell

Оффициальные репозитории:
NgRx
angular-ngrx-data

Пример приложения:
с NgRx без angular-ngrx-data
c NgRx и angular-ngrx-data

Русскоговорящее Angular сообщество в Telegram
Tags:
Hubs:
+11
Comments 11
Comments Comments 11

Articles