RxJS + Управление состоянием: вам больше не нужен NgRx!

Mar 3, 2019

В нашем приложении пользователь может:

  • Искать тикет (вопросы), используя поля (критерии) 'title' или 'assigned-To' (назначени на)
  • Переходить на Search, Tickets, или Ticket Detail страницы (view)
  • Просмотреть детали тикета

Используем RxJS без Управления Состоянием (State Management)

Рассмотрим изначальный вариант основного компонента search-tickets.component.html:

<mat-toolbar>
    <mat-form-field>
        <input type="text" matInput
               placeholder="Find Tickets:"
               [formControl]="searchTerm">
    </mat-form-field>

    <mat-form-field>
        <input type="text" matInput
               placeholder="Assigned To"
               [formControl]="assignedToUser"
               [matAutocomplete]="users">
    </mat-form-field>

    <mat-autocoplete #users="matAutocomplete">
        <mat-option *ngFor="let user of (users$ | async)"
                    [value]="user">{{user}}</mat-option>
    </mat-autocoplete>
</mat-toolbar>

<section class="ticket-search-results">
    <a routerLink="/ticket/{{result. id}}"
       *ngFor="let result of searchResults$ | async">
        <div class="card">
            <span>{{result.message}}</span>
            <span>Status: {{result.status}}</span>
        </div>
    </a>
</section>

В шаблоне мы используем мощный async фильтр, чтобы извлечь user и результаты поиска из RxJS потока.

search-tickets.component.ts:

@Component({
    selector: 'app-search-tickets',
    templateUr: ' ./searh-tickets.component.html',
    styteUrls: [' ./seach-tickets.component.scss'],
})
export class SearchTicketsComponent implements OnInit {

    searchTerm = new FormControl();
    assignedToUser = new FormControl();
    searchResults$: Observable<searchResult[]>;
    users$: Observable<string[]>;

    constructor(private ticketService:TicketService, private userService:UserService) {}

    ngOnInit():void {
        const users$ = throttle(this.assignedToUser.valueChanges);
        const searchBy$ = throttle(this.searchTerm.valueChanges);

        this.searchResults$ = combineLatest(searchBy$, users$).pipe( //**
            switchMap(([ticket, user]) => {
                const hasCriteria = ticket.Length || user.length;
                return !hasCriteria ? of([]) : this.ticketService.searchTickets(ticket, user);
            })
        );

        this.users$ = users$.pipe(
            switchMap(searchTerm => {
                const extractFullNames = users => users.map(it => it.fullName);
                const pending$ = this.userService.users(searchTerm);
                return !searchTerm ? of([]) : pending$.pipe(map(extractFullNames));
            })
        );
    }
}

function throttle(source$: Observable<string>) {
    return source$.pipe(debounceTime(350), distinctUntilChanged(), startWith(''));
}

** combinelatest возвращает массив, а значит данный синтаксис уместен, так как сработает деструктуризация.

Выше показана мощность RxJS. Но первая вещь, которую вы должны отметить это то, что SearchTicketComponent имеет много лигики. View компонент также имеет нетривиальное использование RxJS. Все вместе: это высокий уровень сложности.

Тестирование будет вызовом

Критическая проблема

Критической проблемой является то, что мы не имеем управление сосотоянием.

Без состояния каждый раз при роутинге на SearchTickets предыдущие критерии поиска и результат поиска будут утеряны.

Пользователи очень быстро придут в негодование от ваших некэшируемых интерфейсов.

RxJS + Управление состоянием

Для управления состоянием можно использовать традиционные сервисы или фасады.

Преимущества от использования фасадов:

  • Управление критериями поиска и резальтатами поиска происходит за пределами view-компонента
  • Уменьшает логику во view-компоненте

Насколько это возможно мы хотим, чтобы наши view-компоненты просто рендерили и делегировали события (пользовательское взаимодействие, state changes и т.д.) не-view-компонентам.

  • Представление рендерит данные, полученные от сервисов с данными и
  • observables работают как потоки данных, которые могут быть запушены во view (aka observers)

И поскольку наши Фасады используют АПИ основанное на Observables, спаривание фасадов со view-компонентами является неплохой идеей.

Использование Observables позволяет внешним потребителям (например, view-компонентам) получать уведомления всякий раз, когда меняется наше состояние. Таким образом потребители не должны опрашивать или догадываться изменилось ли состояние.

 

Но какие данные должны изолироваться внутри экземпляра компонента, а какие должны быть общими?

 

Когда управлять состоянием?

  • Состояние общее между компонентами
  • Состояние, которое требуется хранить между перезагрузкой страницы
  • Состояние, которое должно быть доступным при повторной маршрутизации
  • Состояние, которое требуется получить с побочным эффектом (side effect), отделяя проблемы
  • Состояние, которое влияет на другие компоненты или сервисы

План нашего Фасадного АПИ

FormControl обеспечивает поток observable stream на изменение значения (value changes). Мы хотим сконструировать Фасад, чтобы управлять общими и потоками FormControl на уровне view-компонента.

Определим наше АПИ:

@Injectable({
    providedIn: 'root'
})
export class SearchFacade {
    searchResults$: Observable<SearchResult[]>;
    searchCriteria$: Observable<SearchCriteria>;

    // *** Public Methods ***

    updateCriteria(ticket:string, user:string) { /** */ }

    loadUsers(
        source$: Observable<string>
    ):Observable<User[]> { /** */ }

    loadTickets(
        searchBy$:Observable<string>>,
        user$:Observable<string>
    ):Observable<SearchResult[]> { /** */ }
}

Фасад великолепно скрывает детали управления состоянием от внешнего потребителя.

 

Передача Observables внутри Фасада (методы loadUsers() и loadTickets()) обеспечивает 'живой' поток данных valueChanges (критериев) к Фасаду.

 

Внутри Фасад подписывается на эти потоки и использует выходящие значения, чтобы произвести поиск и вызвать REST-запросы. После обновления внутреннего состояния, фасад может эмитить результат через другой поток.

АПИ Фасада легко потребляется view-компонентом, пряча сложное внутреннее управление состоянием, и легко тестируется без любых UI зависимостей.

Наш search-tickets.component.ts на данный момент:

@Component({
    selector: 'app-search-tickets',
    templateUr: ' ./searh-tickets.component.html',
    styteUrls: [' ./seach-tickets.component.scss'],
})
export class SearchTicketsComponent implements OnInit {

    searchTerm = new FormControl();
    assignedToUser = new FormControl();

    users$: Observable<string[]> = this.facade
        .searchUsers(this.assignedToUser.valueChanges)
        .pipe(untilViewDestroyed(this.elRef), map(extractFullName));

    searchResults$: Observable<SearchResult[]> = this.facade
        .searchTickets(this.searchTerm.valueChanges, this.assignedToUser.valueChanges)
        .pipe(untilViewDestroyed(this.elRef));

    constructor(private facade: SearchFacade, private elRef: ElementRef) {}

    ngOnInit():void {
        // при ините
        this.facade.searchCriteria$.pipe(take(l)).subscribe(criterta => {
            this.searchTerm.patchValue(criteria.ticket , { emitEvent : false });
            this.assignedToUser.patIhValue(criteria.user, { emitEvent: false });
        });
    }
}

В нашем коде user$ и searchResults$ делают одновременно:

  • Делегируют изменение значений на инпуте SearchFacade. Фасад в свою очередь внутри сохряняет immutable состояние и вызывает соответствующие службы REST.
  • Создает observable потоки (user$ и searchResults$) для рендеринга в шаблоне.

Отметьте оператор untilViewDestroyed() для предотвращения утечек памяти.

ngOninit принимает начальные значения критериев поиска - которые были закэшированы во внутреннем состоянии SearchFacade - и обновляет экземпляры FormControl.

Отметьте использование {emitEvent: false}, чтобы предотвратить триггер SearchFacade; поскольку мы хотим обновить только UI, чтобы отразить текущее внутреннее состояние.

Использование SearchFacade позволяют нам создавать простые UI компонента, которые рендерят данные и делегируют события (изменения входного значения поиска). Это позволяет SearchTicketComponent фокусироваться на макете, стилях и UX.

Управление состоянием при помощи Фасада

Как SearchFacade управляет состоянием внутри... без использования NgRx?

Во-первых, нам требуется определить 'состояние' (aka data/данные), которые будут 'управляться' (aka cached privately/закэшированы приватно).

// Ticket Search Criteria
export interface SearchCriteria {
    ticket: string;
    user: string;
}

// Ticket search results
interface SearchResult {
    id: number;
    message: string;
    status: string;
}

// Internal state managed by the Facade
class SearchState {
    users: User[] = [];
    tickets: SearchResult[];
    criteria: SearchCriteria = {
        user: '',
        ticket:  ''
    }
}
@Injectable({
    provideIn: 'root'
)}
export class SearchFacade {
    private state = new SearchState();
    private dispatch = new BehaviorSubject<SearchState>(this.state);

    // search to current list of ticket search results
    // (поиск в текущем списке результатов поиска билетов)
    searchResults$:Observable<SearchResult[]> = this.dispatch.asObservable().pipe(
        map(state => state.tickets), startWith([] as SearchResult[])
    );

    // Observable for all search criteria
    searchCriteria$:Observable<SearchCriteria> = this.dispatch.asObservable().pipe(
        map(state => state.criteria)
    );

    constructor(private ticketService:TicketService, private userService:UserService) {}

    // update search criteria for "Assigned to:" (обновить критерии поиска для "Assigned to:")
    // Note: maintains 'state' immutability     (сохряняет 'state' иммутабельным)

    updateCriteria(ticket:string, user:string) {
        const criteria = {...this.state.criteria, user, ticket};
        this.dispatch.next(
            // конструкция для сохранения иммутабельности
            (this.state = {
                ...this.state,
                criteria
            })
        );
    }

    // Build an Observable pipeline to matching userbased on the value contained
    // in the 'Assigned to:' input control values
    searchUsers(source$: Observable<string>, debounceMs = 400): Observable<User[]< {
        return source$.pipe(
            debounceTime(debounceMs),
            distinctUntilChanged(),
            switchMap((user: string) => {
                return !user ? of([]) : this.userService.users{user);
            })
        );
    }

    // Build an Observable pipeline to ticket search results based on the value contained
    // in the 'Find Tickets' input control values
    searchTickets(
        searchBy$: Observable<string>,
        user$: Observable<string>,
        debounceMs = 400
    ): Observable<SearchResult[]> {

        const criteria = this.state.criteria;

        // throttle both searchBy and users input triggers
        searchBy$ = searchBy$.pipe(debounceTime(debounceMs),distinctUntilChanged(),
                                    startWith(criteria.ticket));
        user$ = user$.pipe(debounceTime(debounceMs),distinctUntilChanged(),
                            startWith(criteria.user));

        combineLatest(searchBy$, user$).pipe(
            switchMap(([ticket, user]) => {
                this.updateCriteria(ticket, user);
                const hasCriteria = ticket.length || user.length;
                return !hasCriteria ? of([]) : this.ticketService.searchTickets(ticket, user)
            })
        )
        .subscribe(this.updateTickets.bind(this));

        return this.SearchResult$;
    }

    updateTickets(tickets:SearchResult[]) {
        this.dispatch.next(
            (this.state = {
                ...this.state,
                tickets
            })
        );
    }
}

Здесь state:SearchState представляет наше внутреннее управляемое состояние критериев поиска и результатов поиска. Также отметьте использование dispatch: BehaviorSubject<SearchState> для передачи изменений состояния внешним потребителям.

Каждый API использует dispatch.asObservable().pipe( map(...) ), чтобы извлечь или выбрать подходящее состояние, которое будет доставлено внешним потребителям.

С этими изменениями сложность SearchTicketComponent уменьшена, наше состояние управляемо и пользователь увидит последние результаы поиска при ре-роутинге view.

Деструкторизация на stackoverflow

let { prop1, prop2, prop3 } = someObject;
let data = { prop1, prop2, prop3 };

// data === { prop1: someObject.prop1, ... }
var foo = {
  x: "bar",
  y: "baz"
};

var oof = {
  z: "z",
  x: 11
};

var x = 12;

oof =  {...oof, ...foo, x }

console.log(oof); // {z: "z", x: 12, y: "baz"}

Иммутабельность является обязательным условием для Redux или NgRx и важна для производительности Angular (chande detection).

Источник

Добавить комментарий