Let's say you are faced with the task of writing a frontend application. There is a technical task with a description of the functionality, tickets in the bug tracker. But the choice of a specific architecture is up to you.
, , . . , ? - , . — .
.
: .
. , , - . 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 . , , . , ( ; , ), — . , (, ), . , (, , ), . , , .
. , Angular .
, , , . , . ?
:
, : . , Angular , ChangeDetectionStrategy.Default, (, - , ).
, , : . , , .
:
, - . — 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$;
}
}
, , . ? :
-
OnPush
- , , . async . - .
—
.
— : , . .
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, , . , :
- . , - , .
- . , 5–10 store-.
- . , Foo Bar, , Foo, , Foo Bar, . .
- , . , . . , - (, 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;
- NGXS;
- MobX + MobX Angular (
, ).
, .
NgRx
NgRx
NgRx Redux Angular. Redux , react-.
Redux NgRx . . — store. , . , «» — , ( ) . «» — , .
API - (, ), Middleware ( NgRx — Effect).
, Redux NgRx:
- view - ( , , XHR- . .).
- .
- , . , Middleware (Effect), API.
- , .
- View .
- API ( ):
- ;
- 4 5.
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());
}
}
@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,
}
,
:
- .
- .
- .
- .
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 . - ( , ) .
:
- , — .
- , — .
- , , — , :
- , , HTTP API, ( backend-). backend- , store-.
- : «» «». «» «» . «» , . «»
@Input
@Output
. «» , «» . - , . , .
- , , , NgRx.
- NgRx, . NgRx. NgRx, — , .
- , , , , NgRx. .