Strona główna / Artykuły

Zastępowanie flag ładowania w Angularze zastosowaniem złożonej unii typów AsyncState

Modeluj operacje asynchroniczne w Angularze jako jedną złożoną typizację, uzyskuj wartości logiczne za pomocą sygnałów obliczanych oraz steruj każdą zmianą stanu za pomocą pojedynczego operatora RxJS.

Wydawca: REACTAPP.TOP11 min czytania2637 słów

Źródła: Stop Juggling `isLoading` Booleans: A Type-Safe Async State Pattern for Angular

Prawie każdy komponent Angular, który wywołuje API, kończy tym, że śledzi swój postęp za pomocą kilku flag boolowskich: jednej dla stanu ładowania, jednej dla błędów oraz po jednej na każdy przycisk, który może być zajęty. Każda z tych flag jest sensowna sama w sobie, ale razem pozwalają one komponentowi przechodzić w stany, których nikt nie zaprojektował, a szablon zamienia się w ręcznie napisaną tabelę prawd. Ten przewodnik pokazuje, jak zastąpić te flagi pojedynczą unią dyskryminowaną dla każdej operacji asynchronicznej, wyznaczyć potrzebne flagi za pomocą computed, przenieść wszystkie przejścia do jednego małego operatora RxJS oraz wyświetlić wynik za pomocą @switch, dzięki czemu narzędzie do sprawdzania typów szablonu będzie kontrolować to, co interfejs może pokazać.

wersja boolowska i dlaczego wydaje się w porządku

Oto typowy ekran zamówień, który ładuje listę przy uruchomieniu i oferuje przycisk realizacji zakupu. Policz pola, które istnieją wyłącznie w celu śledzenia postępu:

@Component({ /* ... */ })
export class OrdersComponent {
  isLoading = false;
  isRefreshing = false;
  isCheckingOut = false;
  isCancelling = false;
  hasError = false;
  errorMessage: string | null = null;
  orders: Order[] = [];

  ngOnInit(): void {
    this.isLoading = true;
    this.api.getOrders().subscribe({
      next: (orders) => {
        this.orders = orders;
        this.isLoading = false;
      },
      error: (err) => {
        this.hasError = true;
        this.errorMessage = err.message;
        this.isLoading = false;
      },
    });
  }

  checkout(): void {
    this.isCheckingOut = true;
    this.hasError = false;
    this.api.checkout(this.orders).subscribe({
      next: () => (this.isCheckingOut = false),
      error: (err) => {
        this.isCheckingOut = false;
        this.hasError = true;
        this.errorMessage = err.message;
      },
    });
  }
}

Środek szablonu musi połączyć te flagi, aby zdecydować, co ma zostać wyświetlone, a przycisk zakupu musi być świadomy każdej operacji, która może być wykonywana:

@if (isLoading) { <app-spinner /> }
@if (!isLoading && hasError) { <app-error [message]="errorMessage" /> }
@if (!isLoading && !hasError && orders.length === 0) { <app-empty /> }
@if (!isLoading && !hasError && orders.length > 0) { <app-order-list [orders]="orders" /> }

<button [disabled]="isLoading || isRefreshing || isCheckingOut || isCancelling">
  Checkout
</button>

Nic z tego nie jest błędne i przeszłoby większość audytów. Problemem jest rozwój: każda nowa funkcjonalność dodaje kolejną flagę.

Dlaczego niezależne flagi nie mogą opisywać wyłącznych stanów

Pięć wartości logicznych daje 2 w potęgę 5, czyli 32 możliwe kombinacje. Ekran został zaprojektowany dla mniej więcej pięciu z nich: bezczynny, ładowanie, załadowany, pusty i błąd. Pozostałe 27 stanów jest możliwe do osiągnięcia w trakcie działania programu, a system typów nie ma sposobu, by stwierdzić, że są one nieważne. Ta niezgodność objawia się w różny sposób:

  • Dozwolone są sprzeczne stany. Nic nie przeszkadza temu, by zarówno isLoading, jak i hasError miały wartość true. To, co zobaczy użytkownik, zależy wtedy od kolejności bloków @if – to właśnie dzięki temu spinner i baner z błędem pojawiają się na ekranie jednocześnie.
  • Ścieżka szablonu przypomina tabelę prawdziwości. Warunek taki jak !isLoading && !hasError && orders.length > 0 stanowi wiersz w tabeli, która nigdy nie została spisana, a każda nowa flaga zwiększa liczbę wierszy.
  • Funkcje powodują rozszerzanie się klasy w bok. Funkcja odświeżania dodaje isRefreshing; optymistyczne anulowanie dodaje isCancelling oraz prawdopodobnie cancellingId. Rzeczywiste zachowanie komponentu ginie pod warstwą logiki księgowej.
  • Testy sprawdzają flagi zamiast zachowania. Twierdzenia przybierają postać isLoading === false && hasError === true, zamiast opisywać „użytkownik widzi komunikat o błędzie”.
  • Powodem jest błąd w modelowaniu. Flagi są od siebie niezależne, ale stany, które mają opisywać, są wzajemnie wykluczające się. Gdy typ nie pasuje do domeny, pojawiają się błędy. Zmiana typu sprawia, że większość tych objawów znika.

    Krok 1: przedstawienie stanu jako pojedynczej wartości

    Zjednoczenie dyskryminowane bezpośrednio odzwierciedla tę wykluczalność. Każda wariantowa forma zawiera literał status, a dane treściowe istnieją tylko w tych wariantach, gdzie mają sens:

    import { HttpErrorResponse } from '@angular/common/http';
    
    export type AsyncState<T, E = HttpErrorResponse> =
      | { readonly status: 'idle' }
      | { readonly status: 'loading' }
      | { readonly status: 'success'; readonly data: T }
      | { readonly status: 'error'; readonly error: E };
    

    Tylko jeden status może być przechowywany jednocześnie, a data istnieje tylko wtedy, gdy status to 'success'. To, co wcześniej sugerowały tylko wartości logiczne, teraz jest egzekwowane przez kompilator.

    Niewielkie funkcje konstruktorów sprawiają, że miejsca wywołań są czytelne i oszczędzają czas na pisaniu literówek ciągów znaków we wszystkich miejscach. Predykat taki jak isLoading jest przydatny do późniejszego obliczania wartości pochodnych:

    export const idle = () => ({ status: 'idle' }) as const;
    export const loading = () => ({ status: 'loading' }) as const;
    export const success = <T>(data: T) => ({ status: 'success', data }) as const;
    export const failure = <E>(error: E) => ({ status: 'error', error }) as const;
    
    export const isLoading = (state: AsyncState<unknown, unknown>): boolean =>
      state.status === 'loading';
    

    Aserty as const gwarantują, że każdy konstruktor zwraca wartość typu status w postaci literówki, a nie rozszerzony typ string, co umożliwia przypisanie tych obiektów do unii typów.

    Dlaczego interfejs z polami nullable nie wystarcza

    Częsty pierwszy pomysł wygląda tak:

    interface LoadState<T> {
      loading: boolean;
      data: T | null;
      error: HttpErrorResponse | null;
    }
    

    To ta sama zbiór niezależnych flag umieszczonych w typie nazwanym. Nic nie powstrzymuje { loading: true, data: [...], error: {...} }, więc każdy użytkownik nadal musi zgadywać, które pole jest autorytatywne. Kluczowym elementem jest discriminant – bez niego jedynie przenosisz problem zamiast go rozwiązać.

    Krok 2: jeden stan na operację asynchroniczną

    Daj każdej operacji własny AsyncState, zamiast pary wartości logicznych dla każdego problemu:

    readonly orders = signal<AsyncState<Order[]>>(idle());
    readonly checkout = signal<AsyncState<Receipt>>(idle());
    

    Liczba pól stanu teraz odpowiada liczbie operacji, które mogą być wykonywane jednocześnie, co jest dokładną liczbą, zamiast jednej flagi na każdą możliwą przyczynę błędu.

    Krok 3: wyprowadzaj wartości logiczne zamiast ich przechowywać

    Środowiska szablonowe nadal wymagają wartości logicznych. [disabled] przyjmuje taką wartość, a pytanie „czy coś jest zajęte?” to pytanie typu tak/nie. To w pełni wystarczające: wartości logiczne dobrze sprawdzają się jako wyniki pochodne, ale słabo jako źródło prawdy.

    readonly isBusy = computed(() =>
      [this.orders(), this.checkout()].some(isLoading),
    );
    
    readonly canCheckout = computed(
      () => this.orders().status === 'success' && this.checkout().status !== 'loading',
    );
    

    Ponieważ te wartości są obliczane na podstawie stanów bazowych, nie mogą ulec zmianom. Dodanie lub usunięcie operacji powoduje automatyczne dostosowanie się flag pochodnych; nie ma nic do sformatowania ani zapomnienia.

    Krok 4: centralizacja przejść w jednym operatorze RxJS

    Najczęściej błędy związane z flagami pojawiają się przy ręcznym ustawianiu isLoading = true przed żądaniem oraz false w dwóch funkcjach zwrotnych. Zamiast tego operator niestandardowy może zarządzać wszystkimi przejściami:

    import { OperatorFunction, of } from 'rxjs';
    import { catchError, map, startWith } from 'rxjs/operators';
    
    export function toAsyncState<T, E = HttpErrorResponse>(): OperatorFunction<T, AsyncState<T, E>> {
      return (source$) =>
        source$.pipe(
          map((data) => success(data)),
          catchError((error: E) => of(failure(error))),
          startWith(loading()),
        );
    }
    

    Ciało składa się z trzech operatorów, a każdy z nich zapewnia określoną gwarancję:

    • map otacza każdą wyemitowaną wartość stanem success.
  • catchError przekształca błąd w wartość failure, dzięki czemu sam strumień nigdy nie zgłasza błędu. To zapewnia, że elementy takie jak toSignal, rxMethod oraz rurociąg async pozostają aktywne po nieudanej prośbie.
  • startWith emituje sygnał loading synchronicznie przy subskrypcji, więc interfejs użytkownika nigdy nie pokazuje stanu idle przed rozpoczęciem żądania.
  • A ponieważ nie pozostały już żadne flagi, nie ma nic, co trzeba by zapomnieć zresetować.

    Dla danych pobranych podczas tworzenia komponentu, wszystkie elementy łączą się w jedną deklarację:

    readonly orders = toSignal(
      this.api.getOrders().pipe(toAsyncState()),
      { requireSync: true },
    );
    

    Użycie requireSync: true jest tutaj bezpieczne właśnie ze względu na startWith. Obserwowalny obiekt emituje dane synchronicznie po subskrypcji, więc sygnał przechowuje wartość z pierwszej iteracji wykrywania zmian, więc nie trzeba definiować initialValue. Usunięcie startWith spowodowałoby natomiast wywołanie błędu przez toSignal.

    Krok 5: polecenia uruchamiane przez użytkownika w magazynie sygnałów

    Ładowanie na starcie to prosty przypadek. Polecenia takie jak checkout, cancel lub retry są uruchamiane przez użytkownika i wymagają zasad współbieżności. W NgRx Signal Store operator jest bezpośrednio wstawiany do rxMethod, a ta sama struktura może być użyta w zwykłej usłudze, jeśli nie korzystamy z NgRx:

    interface OrdersState {
      orders: AsyncState<Order[]>;
      checkout: AsyncState<Receipt>;
    }
    
    const initialState: OrdersState = { orders: idle(), checkout: idle() };
    
    export const OrdersStore = signalStore(
      withState(initialState),
      withComputed(({ orders, checkout }) => ({
        isBusy: computed(() => [orders(), checkout()].some(isLoading)),
        canCheckout: computed(
          () => orders().status === 'success' && checkout().status !== 'loading',
        ),
      })),
      withMethods((store, api = inject(OrdersApi)) => ({
        loadOrders: rxMethod<void>(
          pipe(
            switchMap(() => api.getOrders().pipe(toAsyncState())),
            tap((orders) => patchState(store, { orders })),
          ),
        ),
        checkout: rxMethod<Order[]>(
          pipe(
            exhaustMap((orders) => api.checkout(orders).pipe(toAsyncState())),
            tap((checkout) => patchState(store, { checkout })),
          ),
        ),
      })),
      withHooks({ onInit: (store) => store.loadOrders() }),
    );
    

    Warto zwrócić uwagę na kilka kwestii. Stan sklepu przechowuje jeden obiekt AsyncState na każdą operację. Funkcja withComputed definiuje wszystkie flagi w jednym miejscu. Funkcja loadOrders wykorzystuje switchMap, dzięki czemu nowe załadunek anuluje stary. Funkcja checkout natomiast używa exhaustMap, który ignoruje nowe wyzwalacze, gdy proces zakupów jest już w trakcie.

    To ostatnie rozwiązanie zastępuje konieczny w wersji boolowskiej mechanizm ochronny, taki jak if (this.isCheckingOut) return;, który zapobiega podwójnym wysyłkom. Takie mechanizmy ochronne często znikają podczas refaktoryzacji, ponieważ ich cel nie jest oczywisty. W tym przypadku zasada współbieżności znajduje się bezpośrednio w łańcuchu przetwarzania, obok żądania, które chroni.

    Krok 6: szablon odzwierciedlający maszynę stanów

    Ponieważ istnieje tylko jeden status do przełączania, szablon wymienia każdy stan wizualny dokładnie raz:

    @let orders = store.orders();
    
    @switch (orders.status) {
      @case ('loading') {
        <app-spinner />
      }
      @case ('error') {
        <app-error [error]="orders.error" (retry)="store.loadOrders()" />
      }
      @case ('success') {
        @if (orders.data.length) {
          <app-order-list [orders]="orders.data" />
        } @else {
          <app-empty />
        }
      }
    }
    
    <button [disabled]="!store.canCheckout()" (click)="store.checkout(orders.data)">
      Checkout
    </button>
    

    W porównaniu z czterema nakładającymi się blokami @if z początku nie pozostały już żadne kombinacje do rozważenia. W przypadku 'success' dostępny jest orders.data o poprawnym typie; w przypadku 'loading' nie istnieje on, a narzędzie sprawdzania typów w szablonie odrzuca wszelkie próby jego użycia.

    Deklaracja @let pełni ważną funkcję. TypeScript nie może sprecyzować typu store.orders().status przy oddzielnych wywołaniach, ponieważ z punktu widzenia kompilatora każde takie wywołanie może zwrócić inny obiekt. Zapisanie tego wartości do lokalnej zmiennej daje możliwość ustalenia stabilnego odniesienia do tej wartości, co pozwala na sprecyzowanie typu, dokładnie tak jak w przypadku const w zwykłym TypeScript. Bez tego trzeba by używać konwersji typów lub pomocniczych metod pobierania danych.

    Jedna uwaga dotyczy przycisku zakupów w tym fragmencie: znajduje się on poza blokiem @switch, więc orders.data nie jest tam zawężane, a ścisła weryfikacja typów szablonu to zauważy. Należy albo przenieść przycisk do gałęzi 'success', albo dostarczyć sklepowi metodę zakupów, która sama odczytuje aktualne zamówienia.

    Zachowanie istniejących danych podczas odświeżenia

    Wypełnianie ekranu na nowo za każdym razem, gdy użytkownik go odświeża, jest kiepskim doświadczeniem. Używając flag, utrzymanie widoczności starych danych okazuje się zaskakująco delikatne: warunek taki jak isLoading && orders.length > 0 działa, dopóki ktoś nie usunie danych z orders przed wysłaniem żądania. Z użyciem unii jest to po prostu kolejna wersja, która przechowuje poprzednie dane:

    export type AsyncState<T, E = HttpErrorResponse> =
      | { readonly status: 'idle' }
      | { readonly status: 'loading' }
      | { readonly status: 'reloading'; readonly data: T }
      | { readonly status: 'success'; readonly data: T }
      | { readonly status: 'error'; readonly error: E };
    

    Operator może przyjąć poprzedni stan i odpowiednio wybrać swoją początkową wersję:

    export function toAsyncState<T, E = HttpErrorResponse>(
      previous?: AsyncState<T, E>,
    ): OperatorFunction<T, AsyncState<T, E>> {
      const initial =
        previous?.status === 'success' ? reloading(previous.data) : loading();
    
      return (source$) =>
        source$.pipe(
          map((data) => success(data)),
          catchError((error: E) => of(failure(error))),
          startWith(initial),
        );
    }
    

    Jeśli poprzedni stan był pomyślny, strumień rozpoczyna się od reloading i zachowuje stare dane; w przeciwnym razie rozpoczyna się od loading. Należy zauważyć, że ten fragment zakłada istnienie konstruktora reloading() obok innych, zdefiniowanego w taki sam sposób jak success(). W szablonie gałąź @case ('reloading') może wyświetlić istniejącą listę z cienką tarczą postępu, zachowując pełnoekranowy spinner na pierwsze załadunek. Stany „Mamy dane i ściągamy więcej” można łatwo przedstawić jako variantę, ale użycie dwóch zmiennych logicznych stanowi stałe źródło błędów.

    A co z globalną usługą ładowania?

    Wiele aplikacji liczy żądania w trakcie działania za pomocą interceptora HTTP, aby wyświetlić globalną suwak postępu. To rozsądne zastosowanie. Jednak stanowi to słabą podstawę dla interfejsu użytkownika komponentów: licznik nie może wskazać, które żądanie jest w oczekiwaniu, uruchamiają je również tła pollingowe oraz ruch analityczny, przez co każdy ekran ma identyczny wskaźnik.

    Jeśli potrzebujesz obu rozwiązań, wywodź globalny wskaźnik ze stanu lokalnego – na przykład z wartości computed opartej na isBusy każdego magazynu danych – a nie odwrotnie. Stan lokalny nigdy nie powinien polegać na liczniku, którego nie posiada. Jeśli chodzi konkretnie o radzenie sobie z błędami, nasz artykuł na temat projektowania aplikacji w Angularze pod kątem nieudanych żądań omawia interceptrzy oraz strategie ponawiania prób.

    Gdzie pasują resource() i httpResource()

    API resource() i httpResource() w Angularu udostępniają sygnał status() o wartościach takich jak 'loading', 'reloading', 'resolved' oraz 'error'. Framework przyjął tę samą koncepcję: jeden określony stan zamiast zbioru flag. Te API ewoluowały wraz z najnowszymi wersjami Angulara, dlatego sprawdź aktualną dokumentację, aby poznać dokładne wartości stanów w Twojej wersji.

    Gdy twoje dane pochodzą z zapytania sterowanego danymi wejściowymi typu sygnał, takimi jak ID, filtr czy numer strony, najpierw skorzystaj z resource(). Unia AsyncState obejmuje przypadki, w których ten mechanizm nie jest przydatny: polecenia typu POST i DELETE, kod źródłowy w dużej mierze oparty na RxJS oraz stany specyficzne dla danej domeny, których framework nie modeluje. Model mentalny pozostaje identyczny we wszystkich przypadkach: jeden stan, a nie wiele flag.

    Kiedy boolean nadal jest odpowiednim narzędziem

    Ekran z jednym żądaniem, pojedynczym elementem wizualizującym proces i brakiem obsługi błędów funkcjonuje dobrze przy użyciu isLoading. Umieszczanie tego w strukturze typu union byłoby tylko formalnością.

    Praktyczna zasada: drugi boolean jest sygnałem do refaktoryzacji. Gdy tylko zamierzasz dodać isSaving obok isLoading, przejdź na strukturę typu union. To wymaga jednego pliku z definicją typu i jednego operatora.

    Główne wnioski

    • Proces ładowania, sukces i błąd są wzajemnie wykluczające się, więc należy je modelować jako jedną złożoną strukturę typu union, a nie jako niezależne flagi.
    • Używaj jednego obiektu AsyncState na każdą operację asynchroniczną, a nie jednej flagi na każdy rodzaj błędu.
    • Traktuj booleany jako pochodne modele do odczytu utworzone za pomocą computed, a nie jako przechowywany stan.
  • Jeden operator toAsyncState() kontroluje każdą transformację i utrzymuje strumienie aktywne, przekształcając błędy w wartości.
  • Wyrażaj zasady równoczesności za pomocą operatorów spłaszczania (switchMap, exhaustMap) zamiast ręcznych mechanizmów ochrony.
  • Łącz @let z @switch, aby szablon odpowiadał maszynie stanów, a sprawdzacz typów to egzekwował.
  • Komponent przedstawiony na początku tego przewodnika zawierał siedem pól do rejestrowania danych; wersja po refaktoryzacji ma dwa, a oba reprezentują rzeczywiste stany. Różnica nie jest stylistyczna. Jeden typ może opisywać 32 stany, drugi dokładnie te pięć, dla których została zbudowana interfejs użytkownika.

    Literatura pokrewna

  • Code-Splitting Heavy Angular Services on Demand z użyciem injectAsync — Dlaczego statycznie wstrzykiwany serwis ściąga swoje biblioteki do głównego pliku Angular i jak injectAsync ładuje serwisy w trybie opóźnionym w zależności od intencji użytkownika, w tym przy ładowaniu warunkowym.
  • Nieblokujące uruchamianie Angulara: Ładowanie konfiguracji za pomocą rxResource zamiast innych metod — Dowiedz się, dlaczego metoda APP_INITIALIZER blokująca powoduje, że użytkownicy Angulara widzą pustą stronę, oraz jak rxResource wraz z narzędziami do obsługi szkieletu aplikacji i tras pozwalają na ładowanie konfiguracji bez jej użycia.
  • Szybszy Angular robiąc mniej: Dziesięć technik poza OnPush i trackBy — Zmniejsz marnotrawstwo zasobów sieciowych, pracy reaktywnej, DOM oraz CPU w nowoczesnym Angular dzięki @defer, stopniowej hydratacji, funkcji resource(), sygnałom obliczonym, fazom renderowania oraz Web Workers.