Головна / Статті

Заміна прапорців завантаження в Angular за допомогою об’єднання Discriminated AsyncState

Моделюйте асинхронні операції в 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: представити стан як єдине значення

    Дискримінована unія безпосередньо враховує взаємовиключність. Кожен варіант містить літерал 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, що дозволяє цим об’єктам бути присвоєними змінній типу union.

    Чому інтерфейс із полем типу nullable недостатній

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

    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. Об’єкт, який спостерігається, генерує дані синхронно під час підписки, тож сигнал зберігає значення з першого проходження перевірки змін, і вам не потрібно вводити параметр 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;, щоб запобігти подвійним надсиланням даних. Такі захисники часто зникають під час рефакторингу, оскільки їхня мета не є очевидною. Тут правило конкурентності розташоване безпосередньо всередині потоку обробки поруч із запитом, який воно захищає.

    Крок 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 охоплює ситуації, коли цей інструмент не підходить: команди типу POST та DELETE, кодові бази, які все ще значною мірою засновані на RxJS, та стани, специфічні для певної галузі, які фреймворк не моделює. Ментальна модель у обох випадках залишається однаковою: один статус, а не кілька флагів.

    Коли булеве значення все ще є правильним інструментом

    Екран з одним запитом, одним індикатором завантаження та без обробки помилок підходить із isLoading. Огортання цього в союз буде лише формальністю.

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

    Ключові висновки

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