在 Angular 中用带区分条件的异步状态联合体替换加载标志
将 Angular 的异步操作建模为一种带类型的联合类型,通过计算信号生成布尔值,并利用单一的 RxJS 运算符驱动所有的状态转换。
来源: Stop Juggling `isLoading` Booleans: A Type-Safe Async State Pattern for Angular
几乎所有调用 API 的 Angular 组件都会使用几個布尔值来跟踪处理进度:一个用于表示加载状态,一个用于表示错误状态,每个处于忙碌状态的按钮还有一个对应的布尔值。这些标志单独来看都合情合理,但组合在一起却会让组件进入原本未设计过的状态,模板也会变成类似手写真值表的复杂结构。本指南将介绍如何用每个异步操作对应的单一区分联合类型来替代这些标志,利用 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,而非“用户看到错误信息”这样的描述。根本原因在于建模错误。这些标志彼此独立,但它们本应描述的状态却是互斥的。当类型与领域不匹配时,错误就会随之出现。更改类型后,大多数此类问题都会消失。
第一步:将状态表示为单一值
区分联合类型可以直接体现状态的互斥性。每个变体都包含一个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值,而只有当状态为'success'时才存在data。编译器现在会强制执行那些布尔值原本所暗示的规则。
使用小型构造函数可以让调用代码更易理解,同时避免在各个地方重复输入字符串字面量。像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类型,这正是这些对象能够被赋值给联合类型的理由。
为何仅靠带有可空字段的接口不够
常见的初步尝试如下:
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 },
);
由于使用了 startWith,在此处使用 requireSync: true 是安全的。可观察对象在订阅时会同步发出数据,因此信号会保留第一次变化检测时的值,无需额外定义 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() 都可能返回不同的对象。将该值读取到局部变量中后,就可以为这个联合类型提供一个稳定的引用以便进行类型缩小,这就像普通 TypeScript 中的 const 一样。如果没有这个机制,就只能依靠类型转换或辅助获取器了。
这段代码中的结算按钮存在一个需要注意的问题:它位于 @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模式启动。需要注意的是,该代码片段假设存在与其它函数类似、定义方式与success()相同的reloading()构造函数。在模板中,@case ('reloading')分支可以显示带有简单进度条的现有列表,而将全屏旋转加载动画保留到首次加载时使用。“已有数据,正在获取更多数据”这种状态用单个布尔值即可表示,但若使用两个布尔值则容易引发错误。
那全局加载服务呢?
许多应用会在 HTTP 拦截器中统计正在处理的请求数,以此驱动全局进度条。这种用法是合理的。不过,将其作为组件 UI 的基础则不够妥当:计数器无法显示是哪项请求处于处理中状态,后台轮询和分析流量也会触发计数器的更新,最终所有界面都会显示相同的进度指示。
如果需要同时实现这两点,应从本地状态中推导出全局指示器,例如通过每个存储对象的 isBusy 属性计算得出 computed 值,而非反其道而行之。本地状态绝不应依赖它本身并不拥有的计数器。至于错误处理,我们的文章 关于在 Angular 中处理失败请求的设计 已详细介绍了拦截器及重试策略。
resource() 和 httpResource() 的适用场景
Angular的resource()和httpResource()接口提供了一个status()信号,其取值包括'loading'、'reloading'、'resolved'和'error'等。该框架也采用了相同的思路:使用单一的明确状态而非一系列标志位。这些接口在近期的Angular版本中有所演变,因此请查阅当前文档以获取您所使用版本的具体状态值。
当您的数据是通过信号输入驱动的查询结果,比如ID、筛选条件或页码时,应首先使用resource()。而AsyncState类型则用于处理其他情况:如POST和DELETE等命令、仍以RxJS为主的代码库,以及框架未建模的领域特定状态。两者的思维模型是一致的:都是单一状态,而非多个标志位。
何时布尔值仍是合适的选择
仅包含一次数据获取、一个加载指示器且没有错误处理的界面,使用isLoading就已经足够。将其封装在联合类型中纯粹是多此一举。
一条实用原则:第二个布尔值就是提示需要重构的信号。一旦你打算在isLoading旁边再加一个isSaving,就应该改用联合类型。这样做只需多出一个类型文件和一个运算符。
核心要点
- 加载、成功和错误状态是互斥的,因此应将其建模为单一的区分式联合类型,而非独立的标志位。
- 每个异步操作使用一个
AsyncState,而非为每种错误情况单独设置一个标志位。 - 应将布尔值视为通过
computed生成的派生读取模型,而非存储的状态。
toAsyncState() 操作符掌控着所有的状态转换,并通过将错误转换为值来保持数据流的活跃。switchMap、exhaustMap)来表达并发规则,而非依赖手动防护机制。@let 与 @switch 结合使用,这样模板就能遵循状态机逻辑,同时类型检查器也能对其进行约束。本指南开头的组件曾包含七个用于状态记录的字段;重构后的版本仅有两个,且这两个字段都代表真实状态。这种差异并非出于风格考虑。一种类型可以描述32种状态,而另一种则仅能描述构建UI时所需要的5种状态。