Замена флагов загрузки в Angular с использованием объединения состояний AsyncState с дискриминатором
Моделируйте асинхронные операции в Angular как один типизированный союз, получайте логические значения с помощью вычисляемых сигналов и управляйте каждым переходом состояния с помощью единственного оператора RxJS.
Источники: 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, что позволяет присваивать такие объекты переменным типа union.
Почему интерфейс с полями, могущими иметь значение 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;, предотвращающий двойную отправку данных. Подобные проверки часто исчезают в процессе рефакторинга, поскольку их назначение становится неочевидным. Здесь правило конкурентности находится прямо внутри потока обработки рядом с запросом, который оно защищает.
Шаг 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 состояния, а другой — именно пять, для которых был создан интерфейс.