Cómo administrar el estado en Angular a medida que crece su aplicación



Digamos que se enfrenta a la tarea de escribir una aplicación frontend. Hay una tarea técnica con una descripción de la funcionalidad, tickets en el rastreador de errores. Pero la elección de una arquitectura específica depende de usted.



, , . . , ? - , . — .



.



: .



. , , - . HTTP API.



. , . , . , , .



, .







    

    

         :

         :

    

         —

         —



    

     NgRx

         NgRx

         NgRx

         NgRx

         NgRx





, ( app-table) ( app-filters). , . — , ( ), ( ).



, . :



import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common';

@Component({
  selector: 'app-root',
  template: `
      <app-filters (selectedChange)="onFilterChange($event)" [filtersData]="filtersData$ | async"></app-filters>
      <app-table [data]="data$ | async"></app-table>
  `,
})
export class VerySimpleAppComponent implements OnInit {
  data$: Observable<Item[]>;
  filtersData$: Observable<any>;

  constructor(public http: HttpClient) {
    this.data$ = this.http.get<Item[]>('/api/data');
    this.filtersData$ = this.http.get<any>('/api/filtersData');
  }

  onFilterChange(filterValue: any) {
    this.data = this.filterData(filterValue);
  }

  private filterData(filterValue): Item[] {
    //   
  }
}






: . , . :



@Component({
  selector: 'app-root',
  template: `
    <app-filters (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters>
    <router-outlet></router-outlet> <!--       -->
  `,
})
export class AppComponent {
}


. .

, -? , - . layout :



@Component({
  selector: 'app-root',
  template: `
    <router-outlet></router-outlet> <!--         -->
  `,
})
export class AppComponent {
}

//   
@Component({
  selector: 'app-first-page',
  template: `
    <!--   -->
    <app-filters-common (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters-common> 

    <!-- ,     -->
    <app-filters-for-first-table><app-filters-for-first-table>

    <app-table-first [data]="data"></app-table-first>
  `,
})
export class FirstPageComponent {}

@Component({
  selector: 'app-second-page',
  template: `
    <!--   -->
    <app-filters-common (selectedChange)="onFilterChange($event)" [filtersData]="filtersData"></app-filters-common>

    <!-- ,     -->
    <app-filters-for-second-table><app-filters-for-second-table>

    <app-table-second [data]="data"></app-table-second>
  `,
})
export class SecondPageComponent {}


, : , . : ( , ) . , . , . app-root , - .

, , . :



. , . (, , ). . Angular : DI.



, , . Angular . , , . , ( ; , ), — . , (, ), . , (, , ), . , , .



. , Angular .





, , , . , . ?



:



, : . , Angular , ChangeDetectionStrategy.Default, (, - , ).



: . OnPush . - , .



, , : . , , .



:



, - . — RxJS.



Observable-:



import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StorageService {
  // 
  // data = 'initial value';

  // 
  private dataSubject: BehaviorSubject<string> = new BehaviorSubject('initial value');
  data$: Observable<string> = this.dataSubject.asObservable();

  setData(newValue: string) {
    this.dataSubject.next(newValue);
  }
}


BehaviorSubject. Subject (. . Observable, Observer), , . data$. .



RxJS :



@Component({
  selector: 'my-app',
  template: `{{ data }}`,
})
export class AppComponent implements OnDestroy {
  data: any;

  subscription: Subscription = null;

  constructor(public storage: StorageService) {
    this.subscription = this.storage.data$.subscribe(data => this.data = data);
  }

  ngOnDestroy(): void {
    //    !
    if (this.subscription !== null) {
      this.subscription.unsubscribe();
      this.subscription = null;
    }
  }
}


, - , . , , (, ).



Angular , Promise Observable, — async. :



@Component({
  selector: 'my-app',
  template: `{{ data$ | async }}`, //   
})
export class AppComponent  {
  data$: Observable<any>;

  constructor(public storage: StorageService) {
    this.data$ = this.storage.data$;
  }
}


, , . ? :



  1. OnPush- , , . async .
  2. .


. , OnPush : async, OnPush- .







.



— : , . .



Observable-, :



export class BehaviorSubjectItem<T> {
  readonly subject: BehaviorSubject<T>;
  readonly value$: Observable<T>;

  //  :     `BehaviorSubject`,         .
  get value(): T {
    return this.subject.value;
  }

  set value(value: T) {
    this.subject.next(value);
  }

  constructor(initialValue: T) {
    this.subject = new BehaviorSubject(initialValue);
    this.value$ = this.subject.asObservable();
  }
}


:



@Injectable()
export class FiltersStore {
  private apiUrl = '/path/to/api';

  readonly filterData: BehaviorSubjectItem<{ value: string; text: string }[]> = new BehaviorSubjectItem([]);
  readonly selectedFilters: BehaviorSubjectItem<string[]> = new BehaviorSubjectItem([]);

  constructor(private http: HttpClient) {
    this.fetch(); //  -      
  }

  fetch() {
    this.http.get(this.apiUrl).subscribe(data => this.filterData.value = data);
  }

  setSelectedFilters(value: { value: string; text: string }[]) {
    this.selectedFilters.value = value;
  }
}


:



@Injectable()
export class TableStore {
  private apiUrl = '/path/to/api';

  tableData: BehaviorSubjectItem<any[]>;
  loading: BehaviorSubjectItem<boolean> = new BehaviorSubjectItem(false);

  constructor(private filterStore: FilterStore, private http: HttpClient) {
    this.fetch();
  }

  fetch() {
    this.tableData$ = this.filterStore.selectedFilters.value$.pipe(
      tap(() => this.loading.value = true),
      switchMap(selectedFilters => this.http.get(this.apiUrl, { params: selectedFilters })),
      tap(() => this.loading.value = false),
      share(),
    );
  }
}


(, ), combineLatest:



this.tableData$ = combineLatest(firstSource$, secondSource$).pipe(
  // start loading
  switchMap([firstData, secondData] => /* ... */)
  // end loading
);


:



@Component({
  selector: 'first-table-page',
  template: `
    <app-filters (selectedChange)="onFilterChange($event)" [filterData]="filterData$ | async"></app-filters>
    <app-table-first [data]="tableData$ | async" [loading]="loading$ | async"></app-table-first>
  `,
})
export class FirstTablePageComponent {
  filterData$: Observable<FilterData[]>;
  tableData$: Observable<TableItem[]>;
  loading$: Observable<boolean>;

  constructor(private filtersStore: FiltersStore, private tableStore: FirstTableStore) {
    this.filterData$ = filtersStore.filterData.value$;
    this.tableData$ = tableStore.tableData.value$;
    this.loading$ = tableStore.loading.value$;
  }

  onFilterChange(selectedFilters) {
    this.filtersStore.setSelectedFilters(selectedFilters)
    //   ,   
    this.filtersStore.selected.value = selectedFilters;
  }
}


:



  • - ;
  • - (, ) ;
  • ;
  • : , .




, store- : Single Responsibility, , . , :



  1. . , - , .
  2. . , 5–10 store-.
  3. . , Foo Bar, , Foo, , Foo Bar, . .
  4. , . , . . , - (, localStorage), . .


, . , : . , .



-, HTTP API , , . . — .



-, store- , :



export interface FilterItem<T> { 
  value: T; 
  text: string;
}

export interface FilterState<T> {
  data: FilterItem<T>[];
  selected: string[];
}

type ColorsFilterState = FilterState<string>;
type SizesFilterState = FilterState<int>;

export interface FiltersState {
  colors: ColorsFilterState;
  sizes: SizesFilterState;
}

const FILTERS_INITIAL_STATE: FiltersState = {
  colors: {
    data: [],
    selected: [],
  },
  sizes: {
    data: [],
    selected: [],
  },
};

@Injectable()
export class FiltersStore {
  filtersState: BehaviorSubjectItem<FiltersState> = new BehaviorSubjectItem(FILTERS_INITIAL_STATE);
}


-, :



@Injectable()
export class FiltersStore {
  filtersState: BehaviorSubjectItem<FiltersState> =  = new BehaviorSubjectItem({/* ... */});

  setColorsData(data: FilterItem<string>[]) {
    const oldState = this.filtersState.value;
    this.filtersState.value = {
      ...oldState,
      colors: {
        ...oldState.colors,
        data,
      },
    };
  }

  setColotsSelected(selected: string[]) {
    const oldState = this.filtersState.value;
    this.filtersState.value = {
      ...oldState,
      colors: {
        ...oldState.colors,
        selected,
      },
    };
  }
}


-, :



@Injectable()
export class FiltersStore {
  filtersState: BehaviorSubjectItem<FiltersState> =  = new BehaviorSubjectItem({/* ... */});
  setColorsData(data: FilterItem<string>[]) {/* ... */}
  setColotsSelected(selected: string[]) {/* ... */}

  colorsFilter$: Observable<ColorsFilterState> = this.filtersState.value$.pipe(
    map(filtersState => filtersState.colors), //  pluck('colors')
  );

  colorsFilterData$: Observable<FilterItem<string>[]> = this.colorsFilter$.pipe(
    map(colorsFilter => colorsFilter.data),
  );

  colorsFilterSelected$: Observable<string[]> = this.colorsFilter$.pipe(
    map(colorsFilter => colorsFilter.selected),
  );
}




. : . .





. . observable-, : , . , ( ). Flux, Facebook.



. :



  • - ;
  • ;
  • .


- . , , DI:



export interface AppState {/* ... */}

export const INITIAL_STATE: InjectionToken<AppState> = new InjectionToken('InitialState');

// -  AppModule
const appInitialState: AppState = {/* ... */};
@NgModule({
  providers: [
    Store,
    { provide: INITIAL_STATE, useValue: appInitialState },
  ]
})
export class AppModule {}


init , APP_INITIALIZER:



@Injectable()
export class Store<AppState> {
  state: BehaviorSubjectItem<AppState>;

  init(initialState: AppState) {
    this.state = new BehaviorSubjectItem(initialState);
  }
}

const appInitialState: AppState = {/* ... */};

export function initStore(store: Store<AppState>) {
  return () => store.init();
} 

@NgModule({
  providers: [
    Store,
    { 
      provide: APP_INITIALIZER,
      useFactory: initStore,
      deps: [Store], 
      multi: true,
    }
  ]
})
export class AppModule {}


, . — , .

. :



//       :
export interface ColorsFilterState {
  data: FilterItem<string>[];
  selected: string[];
}


: . - , , . , , :



interface ChangeColorsDataAction {
  type: 'changeData';
  payload: FilterItem<string>[];
}

interface ChangeColorsSelectedAction {
  type: 'changeSelected';
  payload: string[];
}

type ColorsActions = ChangeColorsDataAction | ChangeColorsSelectedAction;


, :



export const changeColorsDataState: (oldState: ColorsFilterState, data: FilterItem<string>[]) => FilterState = (oldState, data) => ({ ...oldState, data });

export const changeColorsSelectedState: (oldState: ColorsFilterState, selected: string[]) => FilterState = (oldState, selected) => ({ ...oldState, selected });


. , , :



export const changeColorsState: (oldState: ColorsFilterState, action: ColorsActions) => ColorsFilterState = (state, action) => {
  if (action.type === 'changeData') {
    return changeColorsDataState(oldState, action.payload);
  }
  if (action.type === 'changeSelected') {
    return changeColorsSelectedState(oldState, action.payload);
  }
  return oldState;
}


? , , ColorsFilterState:



@Injectable()
export class Store<ColorsFilterState> {
  state: BehaviorSubjectItem<ColorsFilterState>;
  init(
    state: ColorsFilterState, 
    changeStateFunction: (oldState: ColorsFilterState, action: ColorsActions) => ColorsFilterState, // <-      ,      
  ) {/* ... */}

  changeState(action: ColorsActions) {
    this.state.value = this.changeStateFunction(this.state.value, action);
  }
}
// 
this.store.changeState({ type: 'changeSelected', payload: ['red', 'orange'] });


, , :



  • ;
  • ;
  • , ;
  • ,

    .


. . , changeStateFunction, . . - , :



//     :
export interface FiltersState {
  colors: ColorsFilterState;
  sizes: SizesFilterState;
}
//        
export const changeColorsState = (state, action) => {/* ... */};
//        
export const changeSizeState = (state, action) => {/* ... */};

export const changeFunctionsMap = {
  colors: changeColorsState,
  sizes: changeSizeState,
};

export function combineChangeStateFunction(fnsMap) {
  return function (state, action) {
    const nextState = {};
    Object.entries(fnsMap).forEach(([key, changeFunction]) => {
      nextState[key] = changeFunction(state[key], action);
    });
    return nextState;
  }
}


- EventBus:



  • — ;
  • changeStateFunction

    ;
  • , ,

    - .


, , state manager'. - , , . , :





, .



NgRx



NgRx



NgRx Redux Angular. Redux , react-.



Redux NgRx . . — store. , . , «» — , ( ) . «» — , .



API - (, ), Middleware ( NgRx — Effect).



, Redux NgRx:





  1. view - ( , , XHR- . .).
  2. .
  3. , . , Middleware (Effect), API.
  4. , .
  5. View .
  6. API ( ):

    • ;
    • 4 5.


, . Redux .



NgRx



@ngrx/store



: . — Store. . Observable, — RxJS async-. , . , select. map pluck.



, . dispatch store .



export interface FilterItem<T> { 
  key: string; 
  value: T;
}
//  
export enum FiltersActionTypes {
  Change = '[Filter] Change',
  Reset = '[Filter] Reset',
}

export class ChangeFilterAction implements Action {
  readonly type = FiltersActionTypes.Change;
  constructor(public payload: { filterItem: FilterItem<string> }) {}
}

export class ResetFilterAction implements Action {
  readonly type = FiltersActionTypes.Reset;
  constructor() {}
}

export type FiltersActions = ChangeFilterAction | ResetFilterAction;

//      
export interface FiltersState {
  filterData: FilterItem<string>[];
  selected: FilterItem<string>;
}

export const FILTERS_INIT_STATE: FiltersState = {
  filterData: [
    { key: '-', value: '---' },
    { key: 'red', value: '' },
    { key: 'green', value: '' },
    { key: 'blue', value: '' },
  ],
  selected: { key: '-', value: '---' },
};

// ,     
export function filtersReducer(state: FiltersState = FILTERS_INIT_STATE, action: FiltersActions) {
  switch (action.type) {
    case FiltersActionTypes.Change:
      return {
        ...state,
        selected: action.payload.filterItem,
      };
    case FiltersActionTypes.Reset:
      return {
        ...state,
        selected: { key: '-', value: '---' },
      };
    default:
      return state;
  }
}

//     -,     select
//   ,  ,      
export const selectedFilterSelector = createSelector(state => state.filters.seleced);

//  
export interface AppState {
  filters: FiltersState;
}

export const reducers: ActionReducerMap<AppState> = {
  filters: filtersReducer,
}

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

// 
@Component({
  selector: 'app-root',
  template: `
    <app-filters [data]="filtersData$ | async" 
                 [selected]="selectedFilter$ | async" 
                 (change)="onChange($event)"
                 (reset)="onReset()">
  `,
})
export class AppComponent {
  filtersData$: Observable<FilterItem<string>[]>;
  selectedFilter$: Observable<FilterItem<string>>;

  constructor(private store: Store<AppState>) {
    this.filtersData$ = this.store.pipe(select('filters', 'data'));
    //  -
    this.selectedFilter$ = this.store.pipe(select(selectedFilterSelector));
  }

  onChange(filterItem: FilterItem) {
    this.store.dispatch(new ChangeFilterAction({ filterItem }));
  }

  onReset() {
    this.store.dispatch(new ResetFilterAction());
  }
}


. action, cli-.



@ngrx/effects



: -, . , , . , . @ngrx/store «» .



- : API, , . Redux Middleware, NgRx — . — @Effect.



, Effect, , . Observable<Action>. . Actions , - : , API . . - .



@Injectable()
export class AppEffects {
  //    
  @Effect({ dispatch: false })
  loginSuccess$ = this.actions$.pipe(
    ofType(LoginActionTypes.Success),
    tap(() => {
      this.logger.log('Login success')
    }),
  );

  //        API
  //   
  //    FiltersActionTypes.LoadStarted    API
  //        FiltersDataLoadSuccess     
  //  FiltersDataLoadFailure,   
  @Effect()
  loadFilterData$ = this.actions$.pipe(
    ofType(FiltersActionTypes.LoadStarted),
    switchMap(() => this.filtersBackendService.fetch()),
    map(filtersData => new FiltersDataLoadSuccess({ filtersData })),
    catchError(error => new FiltersDataLoadFailure({ error })),
  );

  // Actions —  Observable   @ngrx/store
  //        
  constructor(private actions$: Actions, private filtersBackendService: FiltersBackendService) {}
}


, -:



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


@ngrx/store-devtools



Redux DevTools NgRx. . :



  • ;
  • ;
  • - .


@ngrx/store-devtools. — , — :





@ngrx/router-store



NgRx Angular. :



  • ;
  • ;
  • NgRx.


, . «» , DevTools . URL , , .



NgRx



NgRx — . , . , .



, , :



export interface FiltersState {/* ... */}
export const FILTERS_INIT_STATE: FiltersState = {/* ... */};
export function filtersReducer(state, action) {/* ... */}

export interface AppState {
  filters: FiltersState;
}

export const reducers: ActionReducerMap<AppState> = {
  filters: filtersReducer,
}


,

:



  1. .
  2. .
  3. .
  4. .


AppState :



export interface FirstTableState {/* ... */}
export const TABLE_INIT_STATE: FirstTableState = {/* ... */};
export function firstTableReducer(state, action) {/* ... */}
export const firstTableDataSelector = state => state.firstTable.data;
export const firstTableLoadingSelector = state => state.firstTable.loading;

export interface AppState {
  filters: FiltersState;
  firstTable: FirstTableState;
}

export const reducers: ActionReducerMap<AppState> = {
  filters: filtersReducer,
  firstTable: firstTableReducer,
}


, lazy loading . , ? , . , . , . , , , .



, NgRx. , . , . feature- . , :



@NgModule({
  imports: [
    StoreModule.forFeature('auth', authReducer),
    StoreModule.forFeature('user', userReducer),
    StoreModule.forFeature('config', configReducer),
    EffectsModule.forFeature([ AuthEffects, UserEffects, ConfigEffects ]),
  ],
})
export class LibModule {}


API . , API .



LibModule . API, .



NgRx





, . , NgRx .



NgRx . , . NgRx : , , .



. . , (, withLatestFrom RxJS). , .



. , . :



//   qux    foo  bar
// { foo: { qux: 1 }, bar: { baz: 2 } } => { foo: {}, bar: { qux: 1, baz: 2 } }
// 
export const fooReducer = (state, action) => {/* ... */}
export const quxSelector = state => state.foo.qux;

// 
export const barReducer = (state, action) => {/* ... */}
export const quxSelector = state => state.bar.qux;

export const reducers = {
  foo: fooReducer,
  bar: barReducer,
};

//    
@Component({})
export class AppComponent {
  constructor(private store: Store<AppState>) {
    this.qux$ = this.store.pipe(select(quxSelector));
  }
}


. NgRx :



@Injectable()
export class AppEffects {
  @Effect({ dispatch: false })
  log$ = this.actions$.pipe(tap(action => this.logger.log(action)));
  constructor(private actions$: Actions, private logger: Logger) {}
}


, . Actions , , :



@Injectable()
export class AppEffects {
  @Effect({ dispatch: false })
  log$ = this.actions$.pipe(tap(action => this.logger.log(action)));
  constructor(private actions$: Actions, private logger: Logger) {}
}


.





, NgRx .



. , .



. NgRx - . , ag-Grid. , , . «» ag-Grid . , - . ag-Grid , . , (, , ) , . , ag-Grid NgRx, ag-Grid . API, NgRx . : , , NgRx.



. , . Angular, . .





, , Angular.



, Angular 2 . .



NgRx . - ( , ) .



:



  1. , — .
  2. , — .
  3. , , — , :

    • , , HTTP API, ( backend-). backend- , store-.
    • : «» «». «» «» . «» , . «» @Input @Output. «» , «» .
    • , . , .
  4. , , , NgRx.
  5. NgRx, . NgRx. NgRx, — , .
  6. , , , , NgRx. .



All Articles