Галоўная / Артыкулы

Замена флага загрузкі в Angular на аб’юнкцію DiscriminatedAsyncState

Моделюйце асінхронныя операцыі Angular як адна типаваная унія, вылучайце логічныя значэння за дапамою вырахаваных сігналоў і керуйце кожным пераходам стану за дапамою аднаго тэлефона RxJS.

Выдавец: REACTAPP.TOP11 хв чытання2637 слоў

Крыніцы: Stop Juggling `isLoading` Booleans: A Type-Safe Async State Pattern for Angular

Практычна кожны компанент Angular, які запошчае API, вынужданы фіксаваць статус роботы за дапамою калькі логічных значэнняў: адна для показу стану завантажэння, адна для пазначэння наявнасці будзь-якіх аднойшчын, по адна на кожную кнопку, якая можа быць зайнята. Кожна з гэтых маркераў сама па сабе ёсць разумной, але разам яны дазволяюць компаненту пераводзіцца ў статусы, якія ніхто не планаваў, а шаблон ператвараецца на ручна напісаную таблыцу істын. У гэтым кярырантаванні паказана, як заменіць гэтыя маркеры на адну спецыяльную структуру для кожной асінхронной операцыі, вырашыць логічныя значэння, якія ўсё ж патрэбны, за дапамою computed, аднакова обробіць усе пераходы за дапамою аднаго маленькага опэратора RxJS і вывести рэзультат за дапамою @switch, так што контролер типаў шаблона будзе страховаць тое, што можа быть паказана на інтэрфейсе.

Версія з логічнымі значэннямі і чаму яна здаецца прыемной

Ёсць тыповы экран замовленняў, які пад час запуску завантажае спіс і прымае кнопку для адправкі замовлення. Працаваце над тым, сколькі ўсё-такі існуе палей, якія існуюць толькі для фіксацыі прагрэсу:

@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;
      },
    });
  }
}

Шаблон, які выкарыстоўваецца для адпаведнасці, павінен з’яеднаць гэтыя флагі, каб вырашыць, што паказваць, а кнопка пракупкі павінна ведаць пра кожную операцыю, якая можа выконваліцца:

@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>

Нічага з гэтага не ўражана, і гэта пройшла бы большасць пераглядоў. Проблема — рост: кожная функцыя дадае ўтолькі што ўжо аднаго флага.

Чаму незалежныя флагі не можаюць описваць эксклюзыўныя станы

Пяць булевых значэнняў даюць 2 у ступені 5, тыяму 32, можлівых комбінацый. Экран быў спроектаваны для працы толькі з пяцьма з іх: без дзеяння, завантажэнне, завантажана, порожня і адмова. Рэшты 27 станоў можна досягнуць пад час выконвання, і система типаў не мае спосабу з’ясаваць, што яны невалідны. Гэта неадпаведнасць праблескаецца у звычныя способы:

  • Дазволены супрацоўкі станаў. Нічога не заважае таму, каб isLoading і hasError абодва былі true. Тое, што бачыць корыстнік, залежыць ад порядку блакоў @if — самэй так спінер і адзначка пра аблыканне показваюцца на экране разам.
  • Шаблон стае тэблай правядзібнасці. Умова на кшталт !isLoading && !hasError && orders.length > 0 ўпамінаецца як рядок у тэбле, якая ніколі не была запісана, а кожны новы флаг збільшае колькісць рядоў.
  • Функцыі расшырваюць клас у бокі. Функцыя апшчупвання дадае isRefreshing; оптымістычная ануляцыя дадае isCancelling і, верагатна, cancellingId. Фактычная праця компонента захоплена падтрымкай такіх станаў.
  • Тэсты перакантролююць флагі, а не працэсаванне. Асерцыі выражаюцца як isLoading === false && hasError === true, а не як „пользователь бачыць паведамленне пра адказку“.
  • Асновной прычынай ёсць адміністрацыйны бяг. Флагі є незалежнымі адзін ад другога, але станы, якія яны маюць описваць, є взаімнасупэрнікамі. Калі тип не падходзіць да домэна, з’яўляюцца багі. Зменіўшы тип, большасць гэтых сімптамаў зникае.

    Шаг 1: представіць стан як адзін значэнне

    Дискримінаваная унія безпасебна фіксуе взаімнасупэрніцу. Кожны варіант несе літерал status, а пэйлоады існуюць толькі ў тых варіантах, дзе яны маюць сэнс:

    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 };
    

    Як і ранейш, можа выступаць толькі адзін status, а data існуе толькі тады, калі статус равен 'success'. Тое, што ранейш простаяяўся за дапамой логічных значэнняў, тепер прымусова рэалізуецца кампайлярам.

    Маленькія функцыі-канстрактары спакойнаяюць месца вызоў і захоўваюць вас ад неабходнасці пісаць стрынговыя літералы ў кожным месцы. Предыкаты на кшталт isLoading ўжоць падходзяць для пазнейшага вылучэння похадных значэнняў:

    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';
    

    Асерцыі as const гарантуюць, што кожны канстрактар вяртае літеральны тип status, а не расширэны тип string; гэта і дазволяе такім об’ектам быть прызначаным у ўніюн.

    Чаму інтэрфейс з полем, якое можа быть null, недастатковы

    Тыповая першая спроба выглядае так:

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

    Это той самыя незалежныя флагі, якія знаходзяцца ў паводзе іменаванага типу. Нічыга не заважае выкарыстоўваць { loading: true, data: [...], error: {...} }, таму кожны корыстнік все рава мусіць здагадвацца, якае поле є афідавальным. Дыскримінант — гэта асновны элемент. Без яго вы толькі пераместілі проблему, а не распрацавалі ёю.

    Шаг 2: адна становая значення на кожную асінхронную операцыю

    Даўкі кожнай операцыі сваёе сабстотнае AsyncState, а не пару булевых значэнняў для кожнага аспекта:

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

    Колькасць становых полей тепер падпараднае колькасці операцыяў, якія можаць выконваліцася адночасна, што ёсць правдзівы паказанні, а не адны флаг на кожны варыянт можлівай памылкі.

    Шаг 3: вырахоўваць булевыя значэння заместа ўтримвання іх

    Шаблонам яшчэ патрэбны буліяны. [disabled] прыймае адзин такі параметр, а запитанне «Ці ўсё зайнята?» являе сабою запитанне «так/ні». Гэта абсалютна нормальна: буліяны добра падходзяць як выніклыя значэння, але паслаба як джерела правды.

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

    Пакалькі гэтыя значэння вылічваюцца з асновных станоў, яны не можу пакрысці. Як толькі дадаецца або аднімаецца якая-небудзь операцыя, выніклыя флагі автаматычна зменяюцца; няма нічога, што трэба было б скасаваць чы забыць.

    Шаг 4: цэнтрызаваць переходы ў адным операторы RxJS

    Ручнае задаўанне значэння isLoading = true пры запуску запиту і false у двух калебэках — гэта прычына большасці бягоў у флагах. Замест таго можна выкарыстоўваць спецыяльны оператор, які будзе кераваць усіма переходамі:

    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()),
        );
    }
    

    У цэй структуре є тры операторы, і кожны з яных дае паведамленне пра гарантію:

    • map абгортае кожнае выданае значэнне ў стан success.
  • catchError ператвараея прычыну кашталоў у значэнне failure, таму самы стрім ніколі не выклекае кашталоў. Гэта дазволяе такім элементам, як toSignal, rxMethod і канэкцыя async, заставацца працоўнымі пасля невялікага запиту.
  • startWith сінхронна выпускае сігнал loading пад час падпісання, таму інтэрфейс ніколі не паказвае стан idle прычынай таго, што запит яшчэ не пачаўся.
  • А ўсёлькі, калі флагі вярнуліся ў пачатковы стан, нема чаго забыць перазначыць.

    Для дадзеных, якія запрашваюцца пад час стварэння компонента, усе элементы з’еднаюцца ў адну декларацыю:

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

    Іспытанне requireSync: true тут ў безпецы самэўсёлкі з-за startWith. Аб’ект observable выдае данні сінхронна пасля падключэння, таму сігнал зберагае значэнне з першай перапрацоўкі даных і не трэба ствараць initialValue. Якщо адмахнуцца ад startWith, то toSignal будзе выклікаць адхыленні.

    Шаг 5: команды, якія запускаецца корыстувальнікам, у хранільні сігналаў

    Завантажэнне пад час ініцыялізацыі — гэта просты случай. Команды такія як выкарыстанне, анулюванне чы перапрабавка запускаюцца корыстувальнікам і вымагаюць правіл канкурэнцыі. За дапамогою NgRx Signal Store аператары прыўязуюцца безпасова да rxMethod, а тая ж структура можа быць выкарыстана ў звычайнай службе, якщо не викорыстоваць 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() }),
    );
    

    Є калькі, які варта зазначыць. Стан магазіна храніць адны AsyncState на кожную операцыю. withComputed визначае флагі, які выраховуюцца, ў аднам месцы. loadOrders выкарыстоўвае switchMap, таму новы запуск анулюе стары. А checkout выкарыстоўвае exhaustMap, які ігнаруе новыя запускі, калі процес выкарыстання вже ў роботе.

    Гэты выбор заменяе захоўнік, які быў неабходны ў булевай версіи, напрыклад if (this.isCheckingOut) return;, каб запобiec подвойным адправкам. Такія захоўнікі часта зникаюць падчас рефакторавання, таму што их мэта не ўсё така зрозумелая. У гэтым случыку правілу паралельнасці паўніста ўключана ў ланцюг обробкі, прычым яна знаходзіцца ведаў за запитам, які ёй павінен захоўваць.

    Шаг 6: шаблон, які відпавае машыне станоў

    Калі є адны статус, які можна зменіць, шаблон перыякраз перыякраз паказвае кожны візуальны стан:

    @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>
    

    Параўняючы з чатырма перакрытымі блакамі @if з самага пачатку, не застаўся жаданых камбінацыйях, пра якія можна было б размышляць. У варыянте 'success' атрыбут orders.data є і мае правильны тип; у варыянте 'loading' яго няма, і перакрытак типаў адхіляе будзь-якія спробы яго выкарыстоўвання.

    Декларацыя @let выканае важлівую роля. TypeScript не можа сузіць значэнне store.orders().status пасля разных вызваў, таму што, на думку кампайлера, кожны вызов store.orders() можа вернуць разны об’ект. Чытанне значэння сігналу ў локальную зменную дае стабільны аб’ект для сузкага выкарыстоўвання, так сама як const у звычным TypeScript. Без цього давялося бы вярнуцца да прымусовага перакладвання типаў або викорыстоўвання дапаможных гэтэр.

    Є адзін момант, якій трэба зважыць ўз час викорыстоўвання кнопкі адправкі пакупкі ў гэтым фрагменте: яна знаходзится за межамі @switch, таму orders.data не сяродзіцца ў межах гэтага блока, і строгая перапаказвання типа шаблона будзе адзначыць гэта. Трэба альбо перасунуць кнопку ў галузь 'success', альбо стварыць у магазіне метод адправкі пакупкі, які сам вычытвае текущыя замовленні.

    Зберагчэнне існуючых дадзенняў пад час апдэта

    Працоўваць з порожнім экранам паказуе кожны раз, калі корыстнік апдэтуе сторону, — гэта паслаблены досвід. За дапамогою флагоў зберагчэнне старых дадзенняў ёсць настолькі чутлівым: умова на кшталт isLoading && orders.length > 0 працюе, пакуль хтось не спчыстіць масэвую зменную orders пры выкананні запиту. За дапамогою уніяны гэта проста ўсё той жа варыянт, які несе пакульшыя дадзенні:

    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 };
    

    Аператар можа прыйняць пакульшы стан і выбраць відпаведны выхадны варыянт:

    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),
        );
    }
    

    Якщо пярэдні статус быў успехам, стрым начынаецца з reloading і застаеіць старыя даны; інакш якраз пачынаецца з loading. Заўважкай, што гэты фрагмент прыпускае наяўнасць канстрактара reloading() разам з іншымі, заданага так сама, як і success(). У шаблоне ветка @case ('reloading') можа адобразіць існуючы список з простай палкой прагрэсу, заставіўшы спінер на всім экране для першага завантажэння. Фраза «У нас ёсць даны, і мы запрашываем больш» — гэта просты статус, які можна выразіць у варіанте, але ён таксама створае постацейныя проблемы, калі викорыстоўваюцца два булевыя значэння.

    А што з глобальным сервісам завантажэння?

    Багатыя дапрынкі лічаюць запыткі пад час роботы пры выкарыстоўванні HTTP-інтэрсептара, каб прадвыклаць глобальную стрэлку праграсу. Гэта ўзважаны спосаб выкарыстоўвання. Аднак гэта слабая база для інтэрфейсу компаненты: лічылка не можа паказаць, які запыт застаўся незавершаным, яе таксама запускаюць фонавыя запыткі та аналітычны трафік, і кожны экран у падсумку мае ідэнтычны показчык.

    Якщо вам трэба і тое, і другое, неабходна вывести глобальны показчык з локальнага стану, напрыклад, за дапамою computed, якай выраховваеся на адной з isBusy кожнага сховішча, а не наадворач. Локальны стан ніколі не должен залежаць ад лічылкі, якая не належыць ўжо йому. Часткам для адрабаткі непаводакоў наша стаття па прымэтам проектавання для непаводакоў у Angular рассказвае пра інтэрсептары та стратэгіі павторных спроб.

    Дзе падходяць resource() і httpResource()

    API-я Angularа resource() і httpResource() адаптуюць сігнал status() з такімі значэннямі, як 'loading', 'reloading', 'resolved' і 'error'. Фрэймворк выбраў тую ж ідею: адзін відмежаваны статус у заместо скупкі флагаў. Што стосуецца гэтых API, яны развіліся ў новыя версіі Angularа, таму для абавесці ўсабоўных значэнняў статуса ў вашай версіі трэба адзірнуцца ў актуальную дакументацыю.

    Калі вашы даны атрымваюцца на адпаведнасць запыту, які керуецца сігналамі, такімі як ID, фільтр чы счытак сторанкі, спачатку трэба вжываць resource(). Альянс AsyncState пакрывае тыя ситуацыі, якія не ўключаны ў resource(): команды на кшталт POST і DELETE, кодавесці, якія ў значнай меры заўсёды базуюцца на RxJS, а таксама статусы, спецыфічныя для певнай домэны, якія фрэймворк не моделюе. Ментальная модель у обох случаях ідэнтычна: адзін статус, а не калькі флагаў.

    Калі булева змэнная ўсё ж застойная

    Экран з адним запытам, адным індыкаторам загрузкі і без обробкі адзінакоў цяпер жа падходзіць з isLoading. Адкладанне гэтага ў союз было б толькі формальнасцю.

    Практычная правіла: другая булева змэнная — гэта сігнал да рефакторавання. Як толькі вы плануеце дадаць isSaving разам з isLoading, перейдзіце на союз. Гэта коштуе аднаго файла з типамі і аднаго аператара.

    Ключовыя выводы

    • Загрузка, успех і адзінакі є взаімнасуперактывацыяныя, таму іх трэба моделіраваць як адны дискримінаваны союз, а не як незалежныя флагі.
    • Для кожнай асінхронной операцыі выкорыстоўваеце аднаго AsyncState, а не адзін флаг на кожны спосаб адзінака.
    • Спрыяйце булевым змэнным як выведзеным моделям для чытання, створанным за дапамою computed, а не як збераганым станам.
  • Адзін аператар toAsyncState() кантролюе кожную пераходчыцу і падтрымлівае стрэмы, ператвараючы адзінакі на значэнні.
  • Выражайце правіла паралельнасці за дапамою аператара флэтанавання (switchMap, exhaustMap) заместо ручных захоўнікаў.
  • Спалучайце @let з @switch, каб шаблон следаваў за машынай станоў, а перакалькавач типа яго контролаваў.
  • Компанента, паказаная ў начале гіду, мела семь полей для ведэння урачыстоў; переработаная версія мае два, і оба яны представляюць рэальныя станы. Разлік не ў стыле. Одны тип можа описваць 32 станы, а другі — толькі пяць станоў, для якіх была створена UI.