NgRx Store: архитектура для Angular приложений

5958
views

RxJs Store архитектура Angular приложений

Чаще всего в туториалах по ngrx можно встретить отличные материалы, которые помогают настроить и запустить Store в вашем приложении, но подобные статьи упускают практики, которые приходят с опытом разработки крупных проектов. Речь пойдет о root store, чистом разделении редьюсеров и селекторов на feature модули и архитектуру в целом.

Предварительные требования

Данная статья подразумевает, что вы работаете с приложением, которое было сгенерировано с помощью Angular CLI.

Установка NgRx зависимостей

Прежде всего необходимо установить необходимые NgRx модули:

npm install @ngrx/{store,store-devtools,entity,effects}

The Root Store Module

Создайте корневой Store модуль, в котором будет подключаться вся NgRx логика. Все feature модули будут импортироваться в него, а сам root store module будет подключаться к App module.

  1. Генерируем RootStoreModule с помощью Angular CLI.
    ng g module root-store —-flat false —-module app.module.ts
  2. Генерируем интерфейс RootState в котором будет описан весь state вашего приложения.
    ng g interface root-store/root-state

    Обратите внимание, что мы вернемся к данным файлам повторно для подключения созданных feature модулей.

Feature Store Module(s)

Feature модули будут содержать в себе все элементы определенного функционала приложения, такие как: state, actions, reducers, selectors и effects. Затем все feature модули будут подключены в RootStoreModule. Таким образом у вас будет чистый и организованный код в виде подпапок для каждой фичи. Далее вы обратите внимание, что все actions, states и селекторы будут иметь в названии префикс feature модуля.

NgRx Entity Feature модули или стандартные Feature модули?

Все зависит от того, насколько типизированные данные будут храниться в Store. Если подразумевается, что ваши фичи будут обладать схожим функционалом и типом хранимых данных, то имеет смысл использовать адаптеры NgRx Entity. Если же данные могут быть не типизированными – лучше использовать стандартные модули. Ниже я приведу оба примера.

Реализация Entity Feature Module

  1. Генерируем MyFeatureStoreModule с помощью Angular CLI
    ng g module root-store/my-feature-store --flat false --module root-store/root-store.module.ts
  2. Создадим файл actions.ts в папке app/root-store/my-feature-store.
    import { Action } from '@ngrx/store';
    import { MyModel } from '../../models';
    
    export enum ActionTypes {
      LOAD_REQUEST = '[My Feature] Load Request',
      LOAD_FAILURE = '[My Feature] Load Failure',
      LOAD_SUCCESS = '[My Feature] Load Success'
    }
    
    export class LoadRequestAction implements Action {
      readonly type = ActionTypes.LOAD_REQUEST;
    }
    
    export class LoadFailureAction implements Action {
      readonly type = ActionTypes.LOAD_FAILURE;
      constructor(public payload: { error: string }) {}
    }
    
    export class LoadSuccessAction implements Action {
      readonly type = ActionTypes.LOAD_SUCCESS;
      constructor(public payload: { items: MyModel[] }) {}
    }
    
    export type Actions = LoadRequestAction | LoadFailureAction | LoadSuccessAction;
  3. Создадим файл state.ts в той же директории app/root-store/my-feature-store.
    import { createEntityAdapter, EntityAdapter, EntityState } from '@ngrx/entity';
    import { MyModel } from '../../models';
    
    export const featureAdapter: EntityAdapter<
      MyModel
    > = createEntityAdapter<MyModel>({
      selectId: model => model.id,
      sortComparer: (a: MyModel, b: MyModel): number =>
        b.someDate.toString().localeCompare(a.someDate.toString())
    });
    
    export interface State extends EntityState<MyModel> {
      isLoading?: boolean;
      error?: any;
    }
    
    export const initialState: State = featureAdapter.getInitialState(
      {
        isLoading: false,
        error: null
      }
    );
  4. Создадим файл reducer.ts в той же директории app/root-store/my-feature-store.
    import { Actions, ActionTypes } from './actions';
    import { featureAdapter, initialState, State } from './state';
    
    export function featureReducer(state = initialState, action: Actions): State {
      switch (action.type) {
        case ActionTypes.LOAD_REQUEST: {
          return {
            ...state,
            isLoading: true,
            error: null
          };
        }
        case ActionTypes.LOAD_SUCCESS: {
          return featureAdapter.addAll(action.payload.items, {
            ...state,
            isLoading: false,
            error: null
          });
        }
        case ActionTypes.LOAD_FAILURE: {
          return {
            ...state,
            isLoading: false,
            error: action.payload.error
          };
        }
        default: {
          return state;
        }
      }
    }
  5. Создадим файл selectors.ts в той же директории app/root-store/my-feature-store.
    import {
      createFeatureSelector,
      createSelector,
      MemoizedSelector
    } from '@ngrx/store';
        
    import { MyModel } from '../models';
    
    import { featureAdapter, State } from './state';
    
    export const getError = (state: State): any => state.error;
    
    export const getIsLoading = (state: State): boolean => state.isLoading;
    
    export const selectMyFeatureState: MemoizedSelector<
      object,
      State
    > = createFeatureSelector<State>('myFeature');
    
    export const selectAllMyFeatureItems: (
      state: object
    ) => MyModel[] = featureAdapter.getSelectors(selectMyFeatureState).selectAll;
    
    export const selectMyFeatureById = (id: string) =>
      createSelector(this.selectAllMyFeatureItems, (allMyFeatures: MyModel[]) => {
        if (allMyFeatures) {
          return allMyFeatures.find(p => p.id === id);
        } else {
          return null;
        }
      });
    
    export const selectMyFeatureError: MemoizedSelector<object, any> = createSelector(
      selectMyFeatureState,
      getError
    );
    
    export const selectMyFeatureIsLoading: MemoizedSelector<
      object,
      boolean
    > = createSelector(selectMyFeatureState, getIsLoading);
  6. Создадим файл effect.ts в той же директории app/root-store/my-feature-store.
    import { Injectable } from '@angular/core';
    import { Actions, Effect, ofType } from '@ngrx/effects';
    import { Action } from '@ngrx/store';
    import { Observable, of as observableOf } from 'rxjs';
    import { catchError, map, startWith, switchMap } from 'rxjs/operators';
    import { DataService } from '../../services/data.service';
    import * as featureActions from './actions';
    
    @Injectable()
    export class MyFeatureStoreEffects {
      constructor(private dataService: DataService, private actions$: Actions) {}
    
      @Effect()
      loadRequestEffect$: Observable<Action> = this.actions$.pipe(
        ofType<featureActions.LoadRequestAction>(
          featureActions.ActionTypes.LOAD_REQUEST
        ),
        startWith(new featureActions.LoadRequestAction()),
        switchMap(action =>
          this.dataService
            .getItems()
            .pipe(
              map(
                items =>
                  new featureActions.LoadSuccessAction({
                    items
                  })
                ),
                catchError(error =>
                  observableOf(new featureActions.LoadFailureAction({ error }))
                )
          	)
         )
      );
    }

Реализация Standard Feature Module

  1. Генерируем MyFeatureStoreModule с помощью Angular CLI
    ng g module root-store/my-feature-store --flat false --module root-store/root-store.module.ts
  2. Создадим файл actions.ts в папке app/root-store/my-feature-store.
    import { Action } from '@ngrx/store';
    import { User } from '../../models';
    
    export enum ActionTypes {
      LOGIN_REQUEST = '[My Feature] Login Request',
      LOGIN_FAILURE = '[My Feature] Login Failure',
      LOGIN_SUCCESS = '[My Feature] Login Success'
    }
    
    export class LoginRequestAction implements Action {
      readonly type = ActionTypes.LOGIN_REQUEST;
      constructor(public payload: { userName: string; password: string }) {}
    }
    
    export class LoginFailureAction implements Action {
      readonly type = ActionTypes.LOGIN_FAILURE;
      constructor(public payload: { error: string }) {}
    }
    
    export class LoginSuccessAction implements Action {
      readonly type = ActionTypes.LOGIN_SUCCESS;
      constructor(public payload: { user: User }) {}
    }
    
    export type Actions = LoginRequestAction | LoginFailureAction | LoginSuccessAction;
  3. Теперь создадим файл state.ts в той же директории app/root-store/my-feature-store.
    import { User } from '../../models';
    
    export interface State {
      user: User | null;
      isLoading: boolean;
      error: string;
    }
    
    export const initialState: State = {
      user: null,
      isLoading: false,
      error: null
    }
  4. Затем файл reducer.ts в той же директории app/root-store/my-feature-store.
    import { Actions, ActionTypes } from './actions';
    import { initialState, State } from './state';
    
    export function featureReducer(state = initialState, action: Actions): State {
       switch (action.type) {
          case ActionTypes.LOGIN_REQUEST:
            return {
              ...state,
              error: null,
              isLoading: true
            };
          case ActionTypes.LOGIN_SUCCESS:
            return {
              ...state,
              user: action.payload.user,
              error: null,
              isLoading: false,
    
            };
          case ActionTypes.LOGIN_FAILURE:
            return {
              ...state,
              error: action.payload.error,
              isLoading: false
            };
          default: {
             return state;
          }
        }
     }
  5. Еще создадим файл selectors.ts в той же директории app/root-store/my-feature-store.
    import {
      createFeatureSelector,
      createSelector,
      MemoizedSelector
    } from '@ngrx/store';
    
    import { User } from '../models';
    
    import { State } from './state';
    
    const getError = (state: State): any => state.error;
    
    const getIsLoading = (state: State): boolean => state.isLoading;
    
    const getUser = (state: State): any => state.user;
    
    export const selectMyFeatureState: MemoizedSelector<
      object,
      State
    > = createFeatureSelector<State>('myFeature');
    
    export const selectMyFeatureError: MemoizedSelector<object, any> = createSelector(
      selectMyFeatureState,
      getError
    );
    
    export const selectMyFeatureIsLoading: MemoizedSelector<
      object,
      boolean
    > = createSelector(selectMyFeatureState, getIsLoading);
    
    export const selectMyFeatureUser: MemoizedSelector<
      object,
      User
    > = createSelector(selectMyFeatureState, getUser);
  6. И наконец создадим файл effect.ts в той же директории app/root-store/my-feature-store.
    import { Injectable } from '@angular/core';
    import { Actions, Effect, ofType } from '@ngrx/effects';
    import { Action } from '@ngrx/store';
    import { Observable, of as observableOf } from 'rxjs';
    import { catchError, map, startWith, switchMap } from 'rxjs/operators';
    import { DataService } from '../../services/data.service';
    import * as featureActions from './actions';
    
    @Injectable()
    export class MyFeatureStoreEffects {
      constructor(private dataService: DataService, private actions$: Actions) {}
    
      @Effect()
      loginRequestEffect$: Observable<Action> = this.actions$.pipe(
        ofType<featureActions.LoginRequestAction>(
          featureActions.ActionTypes.LOGIN_REQUEST
        ),
        switchMap(action =>
          this.dataService
      .login(action.payload.userName, action.payload.password)
      .pipe(
        map(
          user =>
            new featureActions.LoginSuccessAction({
        user
            })
        ),
        catchError(error =>
          observableOf(new featureActions.LoginFailureAction({ error }))
        )
      )
        )
      );
    }

После того, как мы создали feature модуль, Entity или Standard, нам необходимо импортировать все созданные файлы (state, actions, reducer, effects, selectors) в Angular NgModule. В дополнение мы создадим файл index.ts для экспорта файлов. Таким образом мы сделаем дальнейший импорт чистым, понятным и с назначенным пространством имен.

  1. Обновим наш app/root-store/my-feature-store/my-feature-store.module.ts следующим образом:
    import { CommonModule } from '@angular/common';
    import { NgModule } from '@angular/core';
    import { EffectsModule } from '@ngrx/effects';
    import { StoreModule } from '@ngrx/store';
    import { MyFeatureStoreEffects } from './effects';
    import { featureReducer } from './reducer';
    
    @NgModule({
      imports: [
        CommonModule,
        StoreModule.forFeature('myFeature', featureReducer),
        EffectsModule.forFeature([MyFeatureStoreEffects])
      ],
      providers: [MyFeatureStoreEffects]
    })
    export class MyFeatureStoreModule {}
  2. Реализуем barrel export создав файл app/root-store/my-feature-store/index.ts. Обратите внимание, что мы создадим алиасы при импорте наших файлов. Это и будет по сути «name-space» наших компонентов стора.
    import * as MyFeatureStoreActions from './actions';
    import * as MyFeatureStoreSelectors from './selectors';
    import * as MyFeatureStoreState from './state';
    
    export {
      MyFeatureStoreModule
    } from './my-feature-store.module';
    
    export {
      MyFeatureStoreActions,
      MyFeatureStoreSelectors,
      MyFeatureStoreState
    };

Вернемся к Root Store Module

Вернемся к корневым файлам, в частности app/root-store/root-state.ts и добавим к нему созданную фичу.

import { MyFeatureStoreState } from './my-feature-store';
import { MyOtherFeatureStoreState } from './my-other-feature-store';

export interface State {
  myFeature: MyFeatureStoreState.State;
  myOtherFeature: MyOtherFeatureStoreState.State;
}

Теперь обновим app/root-store/root-store.module.ts и импортнем в него все feature модули, а также подключим NgRx модули ( StoreModule.forRoot({}) и EffectsModule.forRoot([]) ).

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { MyFeatureStoreModule } from './my-feature-store/';
import { MyOtherFeatureStoreModule } from './my-other-feature-store/';

@NgModule({
  imports: [
    CommonModule,
    MyFeatureStoreModule,
    MyOtherFeatureStoreModule,
    StoreModule.forRoot({}),
    EffectsModule.forRoot([])
  ],
  declarations: []
})
export class RootStoreModule {}

После этого создадим файл app/root-store/selectors.ts, который будет содержать стейты корневого уровня, такие как состояния лоадеров или ошибок.

import { createSelector, MemoizedSelector } from '@ngrx/store';
import {
  MyFeatureStoreSelectors
} from './my-feature-store';

import {
  MyOtherFeatureStoreSelectors
} from './my-other-feature-store';

export const selectError: MemoizedSelector<object, string> = createSelector(
  MyFeatureStoreSelectors.selectMyFeatureError,
  MyOtherFeatureStoreSelectors.selectMyOtherFeatureError,
  (myFeatureError: string, myOtherFeatureError: string) => {
    return myFeature || myOtherFeature;
  }
);

export const selectIsLoading: MemoizedSelector<
  object,
  boolean
> = createSelector(
  MyFeatureStoreSelectors.selectMyFeatureIsLoading,
  MyOtherFeatureStoreSelectors.selectMyOtherFeatureIsLoading,
  (myFeature: boolean, myOtherFeature: boolean) => {
    return myFeature || myOtherFeature;
  }
);

Создадим теперь app/root-store/index.ts для экспорта нашего корневого модуля.

import { RootStoreModule } from './root-store.module';
import * as RootStoreSelectors from './selectors';
import * as RootStoreState from './state';
export * from './my-feature-store';
export * from './my-other-feature-store';
export { RootStoreState, RootStoreSelectors, RootStoreModule };

Подключим Root Store Module к приложению

После того, как мы полностью объявили модуль стора со всеми подключенными feature модулями, нам необходимо подключить его к нашему приложению в файле app.module.ts. Для этого вам необходимо лишь импортнуть import { RootStoreModule } from ‘./root-store’; и добавить RootStoreModule в массив imports в вашем NgModule.

Вот пример, как может выглядеть компонент вашего приложения с использованием NgRx Store с архитектурой, описанной выше:

import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { MyModel } from '../../models';
import {
  RootStoreState,
  MyFeatureStoreActions,
  MyFeatureStoreSelectors
} from '../../root-store';

@Component({
  selector: 'app-my-feature',
  styleUrls: ['my-feature.component.css'],
  templateUrl: './my-feature.component.html'
})
export class MyFeatureComponent implements OnInit {
  myFeatureItems$: Observable<MyModel[]>;
  error$: Observable<string>;
  isLoading$: Observable<boolean>;

  constructor(private store$: Store<RootStoreState.State>) {}

  ngOnInit() {
    this.myFeatureItems$ = this.store$.select(
      MyFeatureStoreSelectors.selectAllMyFeatureItems
    );

    this.error$ = this.store$.select(
      MyFeatureStoreSelectors.selectUnProcessedDocumentError
    );

    this.isLoading$ = this.store$.select(
      MyFeatureStoreSelectors.selectUnProcessedDocumentIsLoading
    );

    this.store$.dispatch(
      new MyFeatureStoreActions.LoadRequestAction()
    );
  }
}

Финальная структура проекта

Если организовать работу со Store подобным образом, финальная структура папок у вас должна получиться такой:

├── app
 │ ├── app-routing.module.ts
 │ ├── app.component.css
 │ ├── app.component.html
 │ ├── app.component.ts
 │ ├── app.module.ts
 │ ├── components
 │ ├── containers
 │ │    └── my-feature
 │ │         ├── my-feature.component.css
 │ │         ├── my-feature.component.html
 │ │         └── my-feature.component.ts
 │ ├── models
 │ │    ├── index.ts
 │ │    └── my-model.ts
 │ │    └── user.ts
 │ ├── root-store
 │ │    ├── index.ts
 │ │    ├── root-store.module.ts
 │ │    ├── selectors.ts
 │ │    ├── state.ts
 │ │    └── my-feature-store
 │ │    |    ├── actions.ts
 │ │    |    ├── effects.ts
 │ │    |    ├── index.ts
 │ │    |    ├── reducer.ts
 │ │    |    ├── selectors.ts
 │ │    |    ├── state.ts
 │ │    |    └── my-feature-store.module.ts
 │ │    └── my-other-feature-store
 │ │         ├── actions.ts
 │ │         ├── effects.ts
 │ │         ├── index.ts
 │ │         ├── reducer.ts
 │ │         ├── selectors.ts
 │ │         ├── state.ts
 │ │         └── my-other-feature-store.module.ts
 │ └── services
 │      └── data.service.ts
 ├── assets
 ├── browserslist
 ├── environments
 │ ├── environment.prod.ts
 │ └── environment.ts
 ├── index.html
 ├── main.ts
 ├── polyfills.ts
 ├── styles.css
 ├── test.ts
 ├── tsconfig.app.json
 ├── tsconfig.spec.json
 └── tslint.json

Примечание

В статье описанная архитектура не является эталонной. Эта архитектура – пример, как можно организовать работу со Store в большом enterprise Angular приложении.

Подписывайтесь

Для получения уведомлений о новых публикациях подписывайтесь на мой блог или страницы в соц. сетях: Twitter, Facebook.

Подписаться на блог по эл. почте