别把页面塞进三个枚举:用“数据、过程、消息”重建 UI 状态

Loading / Success / Error 适合描述一次请求,却经常无法描述一个真实页面:旧数据还在展示时可以刷新,刷新可能失败,筛选条件仍要保留,错误提示只出现一次。本文用 TypeScript 和 React 构造一份不可变的页面快照,把长期数据、并发过程和一次性消息分开建模,并给出 reducer、渲染规则、竞态处理与测试方法。核心目标不是增加状态数量,而是让无效组合更难产生,让用户在弱网和重试场景下仍能看到稳定界面。

趋势观察

2026-08-03 的掘金文章综合榜中,"别再只用 Loading、Success、Error 表示所有页面状态了"位列第 7。原帖从 Jetpack Compose 的 Feed 页面出发,指出初次加载、保留旧内容的刷新、筛选条件和非阻塞错误不是同一个维度。这个问题并不限于 Compose,在 React、Vue、Flutter 和服务端模板中都会出现。

三态模型究竟错在哪里

下面的联合类型本身没有错误:

ts 复制代码
type PageState<T> =
  | { type: "loading" }
  | { type: "success"; data: T }
  | { type: "error"; message: string };

它适合"进入页面后只请求一次,结果互斥"的详情页。问题出在把它套到所有页面上。以列表为例,用户已经看到 20 条数据并触发刷新时,页面同时具有两个事实:

  • 当前有可展示的数据;
  • 一次刷新请求正在进行。

若切到 loading,旧内容会消失;若仍叫 success,刷新指示器又无处保存。继续添加 refreshingrefreshFailedloadingMore 会让状态数量按组合膨胀。改成一组布尔值也不理想,因为 isInitialLoading && hasData && fatalError 之类的无效组合可以被随手构造。

正确的问题不是"还要加几个状态",而是"页面包含哪些相互独立的事实"。

按生命周期拆成三类信息

一份可渲染的页面快照通常包含三类信息。

1. 长期数据

内容、筛选条件、分页游标等会持续存在,直到下一次业务事件改变它们。

2. 过程状态

初次加载、刷新、加载更多是可以并存或互斥的过程。它们需要精确描述范围,而不是共用一个 isLoading

3. 一次性消息

Toast、Snackbar、跳转指令不应伪装成长期页面状态。若只用一个 error?: string,组件重挂载或屏幕旋转后可能重复展示。消息需要身份,并在消费后确认。

ts 复制代码
type InitialLoad =
  | { type: "idle" }
  | { type: "loading" }
  | { type: "failed"; reason: string }
  | { type: "ready" };

type AsyncJob =
  | { type: "idle" }
  | { type: "running"; requestId: string };

type UiMessage = {
  id: string;
  level: "info" | "error";
  text: string;
};

type FeedState<Item> = Readonly<{
  items: readonly Item[];
  filter: "all" | "following";
  initialLoad: InitialLoad;
  refresh: AsyncJob;
  loadMore: AsyncJob;
  nextCursor: string | null;
  messages: readonly UiMessage[];
}>;

这里仍然使用联合类型,但只把真正互斥的小阶段放进联合类型。页面整体则是一张可以同时容纳多个事实的快照。

用事件驱动更新,别让组件随意改字段

状态结构清楚后,还要收紧修改入口。组件发出业务事件,reducer 负责维护不变量:

ts 复制代码
type FeedEvent<Item> =
  | { type: "initialRequested" }
  | { type: "initialSucceeded"; items: readonly Item[]; cursor: string | null }
  | { type: "initialFailed"; reason: string }
  | { type: "refreshRequested"; requestId: string }
  | {
      type: "refreshSucceeded";
      requestId: string;
      items: readonly Item[];
      cursor: string | null;
    }
  | { type: "refreshFailed"; requestId: string; reason: string }
  | { type: "messageConsumed"; id: string };

function reduceFeed<Item>(
  state: FeedState<Item>,
  event: FeedEvent<Item>,
): FeedState<Item> {
  switch (event.type) {
    case "initialRequested":
      return { ...state, initialLoad: { type: "loading" } };

    case "initialSucceeded":
      return {
        ...state,
        items: [...event.items],
        nextCursor: event.cursor,
        initialLoad: { type: "ready" },
      };

    case "initialFailed":
      return {
        ...state,
        initialLoad: { type: "failed", reason: event.reason },
      };

    case "refreshRequested":
      return {
        ...state,
        refresh: { type: "running", requestId: event.requestId },
      };

    case "refreshSucceeded":
      if (
        state.refresh.type !== "running" ||
        state.refresh.requestId !== event.requestId
      ) {
        return state; // 丢弃过期响应
      }
      return {
        ...state,
        items: [...event.items],
        nextCursor: event.cursor,
        initialLoad: { type: "ready" },
        refresh: { type: "idle" },
      };

    case "refreshFailed":
      if (
        state.refresh.type !== "running" ||
        state.refresh.requestId !== event.requestId
      ) {
        return state;
      }
      return {
        ...state,
        refresh: { type: "idle" },
        messages: [
          ...state.messages,
          { id: crypto.randomUUID(), level: "error", text: event.reason },
        ],
      };

    case "messageConsumed":
      return {
        ...state,
        messages: state.messages.filter((item) => item.id !== event.id),
      };
  }
}

requestId 很重要。用户连续切换筛选条件时,较早的请求可能较晚返回;如果不核对身份,旧响应会覆盖新页面。也可以用 AbortController 主动取消,但服务端可能已经完成请求,客户端仍应防御过期结果。

渲染规则应由事实推导

不要再保存 showEmptyshowFullScreenError 等可计算字段,否则同一事实会有两份来源。把渲染条件写成纯函数:

ts 复制代码
function deriveView<Item>(state: FeedState<Item>) {
  const hasContent = state.items.length > 0;

  return {
    showSkeleton:
      !hasContent && state.initialLoad.type === "loading",
    showFatalError:
      !hasContent && state.initialLoad.type === "failed",
    showEmpty:
      !hasContent && state.initialLoad.type === "ready",
    showContent: hasContent,
    showRefreshIndicator: state.refresh.type === "running",
  };
}

这会自然得到更符合用户预期的行为:

  • 首次加载且无缓存时显示骨架屏;
  • 首次失败且无内容时显示整页错误;
  • 有旧内容时刷新失败,保留列表并展示非阻塞消息;
  • 空数组只有在请求成功后才代表"暂无内容",不能与"还没加载"混为一谈。

状态恢复的边界

不是所有字段都该持久化。建议按恢复成本判断:

  • 筛选条件、查询词、草稿等用户输入可以写入 URL、路由状态或持久化容器;
  • 服务端可重新获取的数据只保留缓存键与时间戳,避免保存过期实体;
  • refresh.running、Toast 队列等瞬时状态通常不应跨进程恢复;
  • 涉及支付、提交等操作时,恢复的应是服务端幂等键和业务状态,而不是本地的"正在提交"布尔值。

用状态转移测试代替截图碰运气

reducer 是纯函数,适合用表驱动测试覆盖关键转移:

ts 复制代码
import { describe, expect, it } from "vitest";

describe("refresh", () => {
  it("刷新失败时保留旧数据", () => {
    const running = reduceFeed(seedState, {
      type: "refreshRequested",
      requestId: "r-1",
    });
    const failed = reduceFeed(running, {
      type: "refreshFailed",
      requestId: "r-1",
      reason: "网络不可用",
    });

    expect(failed.items).toEqual(seedState.items);
    expect(failed.refresh.type).toBe("idle");
    expect(failed.messages).toHaveLength(1);
  });

  it("过期响应不能覆盖新请求", () => {
    const running = {
      ...seedState,
      refresh: { type: "running", requestId: "r-2" } as const,
    };
    const next = reduceFeed(running, {
      type: "refreshSucceeded",
      requestId: "r-1",
      items: [],
      cursor: null,
    });
    expect(next).toBe(running);
  });
});

测试至少覆盖:初次成功、初次失败、旧内容刷新、重复点击、乱序响应、空结果、消息消费和组件重挂载。

边界条件

三态模型并非应该被彻底淘汰。支付结果页、只读详情页、一次性导入任务等阶段真正互斥时,密封联合类型更简洁。不要为了"架构完整"给只有一个按钮的页面引入 reducer。

另一个边界是缓存。缓存命中不等于数据最新,建议单独记录 updatedAt 或缓存策略,而不是把缓存数据标成永恒的 success。若页面允许并行刷新和加载更多,还要明确二者是否能同时运行,以及刷新成功后是否使旧分页游标失效。

总结

页面状态不是一次网络请求的返回值,而是 UI 在某一时刻完成渲染所需的全部事实。把长期数据、并发过程和一次性消息分开,用事件维护状态不变量,用请求身份抵御竞态,再从事实推导渲染条件,复杂页面就不必靠不断追加枚举或布尔值维持。Loading / Success / Error 仍然有用,但它应该描述确实互斥的阶段,而不是强行代表整个产品页面。

相关推荐
非优秀程序员1 小时前
Netdata 接入 RTX5090显卡(集群),实现自动化监测及预警邮件发送
人工智能
罗小罗同学1 小时前
Nat. Biomed. Eng最新发表的医学AI基础模型CLEAR,可以将诊断决策与临床概念一一匹配,不再是传统黑箱模型
人工智能·医学图像处理·医工交叉·医学ai
Leslie1651 小时前
多人共享目录如何不失控:用 setgid、ACL、umask 与粘滞位设计 Linux 权限
人工智能
mit6.8241 小时前
“Copilot“ 超级应用“年内问世(`・ω・´)
人工智能
zhbcddxr1 小时前
GEO服务商度量监测能力测评:可见度追踪与报表排行
大数据·人工智能·microsoft
m沐沐1 小时前
【自然语言处理】词向量转换与中文文本情感分类——从CountVectorizer到朴素贝叶斯
人工智能·算法·机器学习·自然语言处理·分类·中文分词·词向量转换
科技发布2 小时前
拆解传播易服务模式,看清小红书团购从入驻变现完整逻辑
大数据·人工智能
用户298698530142 小时前
Excel 转 PDF 免费在线工具,格式完美保留
人工智能·c#·excel
武子康2 小时前
2026 年 7 月 AI 模型发布复盘:真正被比较的是整套工作系统
人工智能·llm·agent