Loading 不是 Boolean
isLoading: boolean 并没有错。
它只是经常不足以表达真正的问题。
对于简单场景,一个 Boolean 完全够用:点击按钮,请求开始,请求结束。没有既有数据,没有错误状态,没有刷新,也没有并行请求。
这时使用 isLoading 很务实。
但很多前端流程并没有这么简单。
麻烦也正是从这里开始。
因为 Boolean 只能表达两种情况:
isLoading = true;isLoading = false;而真正的异步状态通常远不止两种含义。
加载过程不是一个开关。
它是一个状态空间。
常见状态包括:
idle—— 尚未发起请求loading—— 首次请求正在进行success—— 数据已经成功加载error—— 请求失败refreshing—— 已有数据仍然可用,但后台正在刷新
如果只用一个 Boolean,这些状态都会被压缩成:
isLoading: boolean;其余含义并不会消失,只会分散到其他变量里。
isLoading = false;data: Task[] | null = null;error: string | null = null;问题随之而来:下面这个状态到底意味着什么?
isLoading = false;data = null;error = null;还从未加载过? 成功加载了,但没有数据? 错误被清掉了,同时数据也没了? 导航后的初始状态? 一次 Reload 过程中的中间状态?
没人能仅凭这三个字段明确判断。
代码只能去猜。
而当代码开始猜测状态含义时,架构边界就已经开始漏了。

被隐藏起来的状态
Section titled “被隐藏起来的状态”isLoading 真正的问题通常不是 Boolean 本身。
问题是那些没有被显式建模的状态。
isLoading: boolean;data: Task[] | null;error: string | null;这三个字段共同描述一个状态,却假装彼此独立。
于是会产生业务上根本不合理的组合。
isLoading = true;data = null;error = 'Fehler beim Laden';现在到底是在加载,还是已经出错?还是两者同时成立?
再看一个例子:
isLoading = false;data = [];error = 'Fehler beim Laden';空列表是结果,还是旧数据残留?UI 应该显示错误,还是显示列表?
还有一个经典情况:
isLoading = true;data = existingTasks;error = null;这是首次加载,还是已有数据情况下的刷新?
对 UI 来说,两者差别很大。
首次加载时,也许应该显示完整的 Skeleton State;刷新时,则通常继续展示现有数据,只附加一个较小的 Spinner。
Boolean 无法表达这种区别。
Refreshing 是最能暴露问题的场景
Section titled “Refreshing 是最能暴露问题的场景”loading 与 refreshing 的区别,非常适合说明为什么 isLoading 经常过于粗糙。
type TasksState = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: Task[] } | { status: 'refreshing'; data: Task[] } | { status: 'error'; message: string };现在:
{ status: 'loading';}明确表示:
目前还没有数据,首次请求正在进行。
而:
{ status: 'refreshing', data: tasks }明确表示:
数据已经存在,只是在后台进行新一轮请求。
这不是视觉上的小差别。
它会直接影响 UI。
@if (state.status === 'loading') {<app-skeleton />} @if (state.status === 'refreshing') {<app-task-list [tasks]="state.data" /><app-small-spinner />}如果没有显式状态,这种区别往往会被塞进 Template、辅助变量或临时条件里。
@if (isLoading && !data) {<app-skeleton />} @if (isLoading && data) {<app-task-list [tasks]="data" /><app-small-spinner />}这样也能工作。
但含义不在模型里。
它藏在条件表达式里。
而条件通常只会越来越多。
更好的选择:Discriminated Union
Section titled “更好的选择:Discriminated Union”在 TypeScript 中,可以很自然地用 Discriminated Union 表达一个状态空间。
type LoadingState<T> = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'refreshing'; data: T } | { status: 'error'; message: string };它的价值不只是类型安全。
更重要的是明确性。
每个状态都有名字,每个状态只允许携带属于自己的数据,不可能出现的组合则直接从模型中消失。
const state: LoadingState<Task[]> = { status: 'success', data: [],};这明确表示:
请求成功,结果就是一个空列表。
而不是:
也许还没加载。
也不是:
也许发生了一个没有错误信息的错误。
更不是:
也许只是某个中间状态。
“成功但为空”与“尚未初始化”不是一回事。
模型应该把这个区别说出来。
Template 会变得更无聊
Section titled “Template 会变得更无聊”有了显式状态之后,Template 不一定更短。
但它会更诚实。
@switch (state.status) { @case ('idle') {<app-empty-state />} @case ('loading') {<app-skeleton />} @case ('success') {<app-task-list [tasks]="state.data" />} @case ('refreshing') {<app-task-list [tasks]="state.data" /><app-small-spinner />} @case ('error') {<app-error-message [message]="state.message" />} }Template 不再读取三个变量,然后试图从它们的组合里反推出含义。
它只是渲染一个已经命名的状态。
这就是区别。
在 Signal Store 中
Section titled “在 Signal Store 中”在 Signal Store 或 Facade 中,可以清晰地持有并派生这个状态。
type TasksFeatureState = { tasks: LoadingState<Task[]>;};
const initialState: TasksFeatureState = { tasks: { status: 'idle' },};首次加载:
patchState(store, { tasks: { status: 'loading' },});成功:
patchState(store, { tasks: { status: 'success', data: tasks },});刷新:
const current = store.tasks();
if (current.status === 'success') { patchState(store, { tasks: { status: 'refreshing', data: current.data }, });}错误:
patchState(store, { tasks: { status: 'error', message: 'Aufgaben konnten nicht geladen werden.' },});当然,这比一个 Boolean 多了一点建模工作。
但它少了很多猜测。
而减少猜测,通常就是好的架构。
什么时候 Boolean 就够了
Section titled “什么时候 Boolean 就够了”不是每个加载状态都需要 Union。
以下情况下 Boolean 完全没问题:
- 状态确实只是局部 UI 状态
- 不需要保留已有数据
- 错误状态无需单独表达
- 不需要区分 Refresh
- 不存在并行状态
- 代码规模小且含义清晰
例如:
readonly saving = signal(false);对一个单独的 Submit Button 来说,这完全够用。
但如果随后又出现 data、error、empty、loaded、refreshing 或 initialized,那就是一个信号。
这里说的不是 Angular Signal。
而是架构上的信号。
你很可能正在用一组松散的 Flag 去模拟一个没有被建模的状态空间。
isLoading 不是敌人。
但对于很多问题来说,isLoading 太小了。
异步过程并不只有“正在加载”和“没有加载”两种状态。
它可能尚未开始,可能正在进行,可能成功,可能失败,也可能在已有数据的基础上刷新。
如果这些状态在业务上具有不同含义,那么模型也应该明确区分它们。
简短的规则是:
真正的开关用 Boolean。过程用状态模型。
再短一点:
Loading 不是 Boolean。Loading 是状态。