61. Паттерн Facade
64. Паттерн Facade 🎭
Заголовок раздела «64. Паттерн Facade 🎭»Привет! Яша здесь. Facade — один из самых полезных паттернов в Angular. Он прячет сложность NgRx/NGXS за простым API сервиса. Разберём как это делается, почему это важно и как тестировать фасады 🚀
Что такое Facade паттерн
Заголовок раздела «Что такое Facade паттерн»Представь: у тебя NgRx с actions, reducers, effects, selectors. Компонент должен знать ВСЁ это.
Без Facade (компонент знает про NgRx):
// Компонент напрямую работает с NgRxconstructor( private store: Store<AppState>, private actions$: Actions,) {}
ngOnInit() { this.users$ = this.store.select(selectUsers); this.loading$ = this.store.select(selectUsersLoading); this.store.dispatch(UsersActions.loadUsers({ page: 1 }));}
deleteUser(id: string) { this.store.dispatch(UsersActions.deleteUser({ id }));}С Facade (компонент не знает про NgRx):
// Компонент знает только про UsersFacadeconstructor(private usersFacade: UsersFacade) {}
ngOnInit() { this.users$ = this.usersFacade.users$; this.usersFacade.loadUsers();}
deleteUser(id: string) { this.usersFacade.deleteUser(id);}Facade — это anti-corruption layer: компонент не зависит от реализации стейт-менеджмента.
NgRx Facade: полная реализация
Заголовок раздела «NgRx Facade: полная реализация»import { Injectable, inject } from '@angular/core';import { Store } from '@ngrx/store';import { Observable } from 'rxjs';import * as UsersActions from './store/users.actions';import * as UsersSelectors from './store/users.selectors';import { User } from './user.model';
@Injectable({ providedIn: 'root' })export class UsersFacade { private store = inject(Store);
// ─── Selectors (публичные Observable) ───────────────────────────────────── readonly users$: Observable<User[]> = this.store.select(UsersSelectors.selectAllUsers); readonly selectedUser$: Observable<User | null> = this.store.select(UsersSelectors.selectSelectedUser); readonly loading$: Observable<boolean> = this.store.select(UsersSelectors.selectUsersLoading); readonly error$: Observable<string | null> = this.store.select(UsersSelectors.selectUsersError); readonly totalCount$: Observable<number> = this.store.select(UsersSelectors.selectUsersTotalCount);
// ─── Actions (публичные методы) ─────────────────────────────────────────── loadUsers(page = 1, limit = 20): void { this.store.dispatch(UsersActions.loadUsers({ page, limit })); }
loadUserById(id: string): void { this.store.dispatch(UsersActions.loadUser({ id })); }
selectUser(id: string): void { this.store.dispatch(UsersActions.selectUser({ id })); }
createUser(user: Omit<User, 'id'>): void { this.store.dispatch(UsersActions.createUser({ user })); }
updateUser(id: string, changes: Partial<User>): void { this.store.dispatch(UsersActions.updateUser({ id, changes })); }
deleteUser(id: string): void { this.store.dispatch(UsersActions.deleteUser({ id })); }
clearSelection(): void { this.store.dispatch(UsersActions.clearSelectedUser()); }
resetError(): void { this.store.dispatch(UsersActions.resetUsersError()); }}ComponentStore Facade (без NgRx)
Заголовок раздела «ComponentStore Facade (без NgRx)»// users-local.facade.ts — ComponentStore как Facadeimport { Injectable } from '@angular/core';import { ComponentStore } from '@ngrx/component-store';import { Observable, switchMap, tapResponse } from 'rxjs';import { HttpClient } from '@angular/common/http';import { User } from './user.model';
interface UsersState { users: User[]; loading: boolean; error: string | null; selectedId: string | null;}
@Injectable()export class UsersLocalFacade extends ComponentStore<UsersState> { constructor(private http: HttpClient) { super({ users: [], loading: false, error: null, selectedId: null }); }
// ─── Selectors ───────────────────────────────────────────────────────────── readonly users$ = this.select(state => state.users); readonly loading$ = this.select(state => state.loading); readonly error$ = this.select(state => state.error); readonly selectedUser$ = this.select(state => state.users.find(u => u.id === state.selectedId) ?? null );
// ─── Updaters (синхронные мутации) ──────────────────────────────────────── readonly setUsers = this.updater((state, users: User[]) => ({ ...state, users, loading: false, error: null, }));
readonly addUser = this.updater((state, user: User) => ({ ...state, users: [...state.users, user], }));
readonly removeUser = this.updater((state, id: string) => ({ ...state, users: state.users.filter(u => u.id !== id), }));
// ─── Effects (асинхронные операции) ────────────────────────────────────── readonly loadUsers = this.effect<void>(trigger$ => trigger$.pipe( switchMap(() => { this.patchState({ loading: true }); return this.http.get<User[]>('/api/users').pipe( tapResponse( users => this.setUsers(users), error => this.patchState({ error: error.message, loading: false }), ) ); }) ) );
// ─── Публичные методы (Facade API) ─────────────────────────────────────── selectUser(id: string): void { this.patchState({ selectedId: id }); }
deleteUser(id: string): void { this.removeUser(id); // В реальном приложении — HTTP запрос через effect }}Signals Facade (Angular 17+)
Заголовок раздела «Signals Facade (Angular 17+)»import { Injectable, signal, computed, effect } from '@angular/core';import { HttpClient } from '@angular/common/http';import { User } from './user.model';
@Injectable({ providedIn: 'root' })export class UsersSignalsFacade { // ─── Приватное состояние ────────────────────────────────────────────────── private _users = signal<User[]>([]); private _loading = signal(false); private _error = signal<string | null>(null); private _selectedId = signal<string | null>(null);
// ─── Публичное состояние (readonly) ────────────────────────────────────── readonly users = this._users.asReadonly(); readonly loading = this._loading.asReadonly(); readonly error = this._error.asReadonly();
// ─── Вычисляемые значения ──────────────────────────────────────────────── readonly selectedUser = computed(() => this._users().find(u => u.id === this._selectedId()) ?? null ); readonly totalCount = computed(() => this._users().length); readonly activeUsers = computed(() => this._users().filter(u => u.status === 'active') );
constructor(private http: HttpClient) { // Логируем изменения (side effect) effect(() => { console.log('Users updated:', this._users().length, 'total'); }); }
// ─── Публичные методы ───────────────────────────────────────────────────── async loadUsers(): Promise<void> { this._loading.set(true); this._error.set(null);
try { const users = await this.http.get<User[]>('/api/users').toPromise(); this._users.set(users!); } catch (err: unknown) { this._error.set((err as Error).message); } finally { this._loading.set(false); } }
selectUser(id: string): void { this._selectedId.set(id); }
updateUser(id: string, changes: Partial<User>): void { this._users.update(users => users.map(u => u.id === id ? { ...u, ...changes } : u) ); }
deleteUser(id: string): void { this._users.update(users => users.filter(u => u.id !== id)); }}Тестирование Facade
Заголовок раздела «Тестирование Facade»import { TestBed } from '@angular/core/testing';import { MockStore, provideMockStore } from '@ngrx/store/testing';import { UsersFacade } from './users.facade';import * as UsersActions from './store/users.actions';import * as UsersSelectors from './store/users.selectors';
describe('UsersFacade', () => { let facade: UsersFacade; let store: MockStore;
const initialState = { users: { loading: false, error: null, selectedId: null, } };
beforeEach(() => { TestBed.configureTestingModule({ providers: [ UsersFacade, provideMockStore({ initialState }), ] });
facade = TestBed.inject(UsersFacade); store = TestBed.inject(MockStore); store.overrideSelector(UsersSelectors.selectAllUsers, initialState.users.users); });
it('должен вернуть users из store', (done) => { facade.users$.subscribe(users => { expect(users.length).toBe(1); expect(users[0].name).toBe('Яша'); done(); }); });
it('должен диспатчить loadUsers action', () => { const dispatchSpy = spyOn(store, 'dispatch'); facade.loadUsers(1, 20); expect(dispatchSpy).toHaveBeenCalledWith( UsersActions.loadUsers({ page: 1, limit: 20 }) ); });
it('должен диспатчить deleteUser action', () => { const dispatchSpy = spyOn(store, 'dispatch'); facade.deleteUser('1'); expect(dispatchSpy).toHaveBeenCalledWith( UsersActions.deleteUser({ id: '1' }) ); });});
// В компонентах — мокируем Facade, а не Store!@Injectable()class MockUsersFacade { loading$ = of(false); error$ = of(null); loadUsers = jasmine.createSpy('loadUsers'); deleteUser = jasmine.createSpy('deleteUser');}Playground 🎮
Заголовок раздела «Playground 🎮»Визуализатор паттерна Facade: до и после: