跳转到内容

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 过程中的中间状态?

没人能仅凭这三个字段明确判断。

代码只能去猜。

而当代码开始猜测状态含义时,架构边界就已经开始漏了。

一个过程不是一个开关。

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 无法表达这种区别。

loadingrefreshing 的区别,非常适合说明为什么 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 />
}

这样也能工作。

但含义不在模型里。

它藏在条件表达式里。

而条件通常只会越来越多。

在 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 不一定更短。

但它会更诚实。

@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 或 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 多了一点建模工作。

但它少了很多猜测。

而减少猜测,通常就是好的架构。

不是每个加载状态都需要 Union。

以下情况下 Boolean 完全没问题:

  • 状态确实只是局部 UI 状态
  • 不需要保留已有数据
  • 错误状态无需单独表达
  • 不需要区分 Refresh
  • 不存在并行状态
  • 代码规模小且含义清晰

例如:

readonly saving = signal(false);

对一个单独的 Submit Button 来说,这完全够用。

但如果随后又出现 dataerroremptyloadedrefreshinginitialized,那就是一个信号。

这里说的不是 Angular Signal。

而是架构上的信号。

你很可能正在用一组松散的 Flag 去模拟一个没有被建模的状态空间。

isLoading 不是敌人。

但对于很多问题来说,isLoading 太小了。

异步过程并不只有“正在加载”和“没有加载”两种状态。

它可能尚未开始,可能正在进行,可能成功,可能失败,也可能在已有数据的基础上刷新。

如果这些状态在业务上具有不同含义,那么模型也应该明确区分它们。

简短的规则是:

真正的开关用 Boolean。过程用状态模型。

再短一点:

Loading 不是 Boolean。Loading 是状态。