陌上人如玉
公子世无双

7.3 加载态与错误态设计

7.3 加载态与错误态设计

一句话:异步 UI 有四种状态(idle / loading / success / error,通常还加 empty)——用状态机建模(而不是多个布尔值),并为每种状态设计明确、不闪烁的 UI。

一、为什么不要用多个布尔值

// ❌ 反过来:多个布尔值 → 会产生「非法组合」
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<Data | null>(null);

// 可能出现:
// loading=true 且 data!=null   ← 到底该显示什么?
// error 有值 且 data 有值      ← 显示旧数据还是错误?
// loading=false 且 data=null   ← 空状态还是没开始?
非法组合的来源:三个独立布尔值有 2³ = 8 种组合,但只有 4 种是合法的。
「允许非法状态存在」是 bug 的温床(渲染逻辑要考虑各种组合)。
// ✅ 用「判别联合」建模(状态机)
type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };
// ✅ 渲染时用 switch —— 类型系统会强制你处理每一种
function Content<T>({ state }: { state: AsyncState<T> }) {
  switch (state.status) {
    case 'idle':
      return null;
    case 'loading':
      return <Skeleton />;
    case 'error':
      return <ErrorView error={state.error} />;
    case 'success':
      return <DataView data={state.data} />;      // ⭐ 只有这里能访问 data
  }
}
方案 非法状态 类型安全 可读性
多个布尔值 ❌ 会产生 ❌ 需要判断组合
判别联合 + switch ✅ 无法表示 ✅(穷尽检查) ✅

二、加上「空状态」与「刷新中」

// ✅ 实际项目常见的五态(在四态之外区分「刷新中」)
type ViewState<T> =
  | { status: 'idle' }
  | { status: 'loading' }                      // 首次加载(显示骨架)
  | { status: 'refreshing'; data: T }          // 刷新中(保留旧数据)
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error; data?: T };  // 错误时可能仍有旧数据
// ✅ 渲染时区分「首次加载」与「刷新」
function UserList() {
  const { data, isLoading, isFetching, error, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
    placeholderData: (prev) => prev,        // ⭐ 刷新时保留上一份数据
  });

  if (isLoading) return <Skeleton count={5} />;          // 首次
  if (error) return <ErrorView error={error} onRetry={refetch} />;

  return (
    <div>
      {isFetching && <TopProgressBar />}                 {/* ⭐ 刷新中:轻提示 */}
      {data.length === 0 ? <EmptyState /> : <List items={data} />}
    </div>
  );
}
为什么要区分「首次加载」与「刷新」?
① 首次加载:没有任何数据 → 必须显示骨架(否则页面空白)
② 刷新中:已有旧数据 → 应该「保留旧数据 + 轻提示」(而不是清空重显骨架)
   → 否则页面会「闪一下变成骨架再变回来」,体验很差

三、骨架屏设计原则

// ✅ 骨架的形状要「贴合真实内容」
function UserCardSkeleton() {
  return (
    <div className="card">
      <div className="skeleton skeleton--avatar" />     {/* 头像 */}
      <div className="skeleton skeleton--line" />       {/* 标题行 */}
      <div className="skeleton skeleton--line short" /> {/* 短行 */}
    </div>
  );
}
五条骨架屏原则:
① 【贴合结构】——骨架的行数、宽度、位置尽量接近真实内容
   (否则内容出现时会有明显的位置跳动 CLS)
② 【固定高度】——给骨架容器设定固定高度(避免加载前后布局抖动)
③ 【数量一致】——骨架数量贴近预期数据量(如列表预期 5 条就渲染 5 个)
④ 【适度动效】——微弱的 shimmer/pulse 能表达「在加载」,但别太花哨
⑤ 【不要过久】——延迟 100~200ms 再显示骨架(加载很快时直接显示内容,避免闪烁)

⚠️ 不要用「转圈 spinner」代替骨架(spinner 只有「在加载」的信息,
   没有「即将出现什么」的信息,体验比骨架差)。
// ⑤ 的「延迟显示」技巧(避免快速加载时的闪烁)
function useDelayedLoading(isLoading: boolean, delay = 150) {
  const [show, setShow] = useState(false);

  useEffect(() => {
    if (!isLoading) {
      setShow(false);
      return;
    }
    const id = setTimeout(() => setShow(true), delay);
    return () => clearTimeout(id);
  }, [isLoading, delay]);

  return show;
}

// 使用:加载很快时不会闪骨架
function List() {
  const { data, isLoading } = useQuery(...);
  const showSkeleton = useDelayedLoading(isLoading);

  if (showSkeleton) return <Skeleton />;
  if (!data) return null;
  return <List items={data} />;
}

四、错误态设计

// ✅ 错误态要包含:原因、可操作性(重试)、兜底(返回/联系客服)
function ErrorView({ error, onRetry }: { error: Error; onRetry?: () => void }) {
  const isNetwork = error.message.includes('网络');

  return (
    <div className="error-view" role="alert">
      <ErrorIcon />
      <h3>{isNetwork ? '网络连接失败' : '加载失败'}</h3>
      <p className="error-view__detail">{error.message}</p>

      <div className="error-view__actions">
        {onRetry && <button onClick={onRetry}>重试</button>}
        <button onClick={() => window.location.reload()}>刷新页面</button>
      </div>
    </div>
  );
}
错误分类与对应处理:

① 网络错误(断网/CORS/超时)
   → 提示「检查网络」+ 重试
② 401 未认证
   → 跳登录(拦截器统一处理,页面一般不展示)
③ 403 无权限
   → 提示「无权限」+ 返回上一页(不要跳登录)
④ 404 资源不存在
   → 专门的「不存在」页面 + 返回列表
⑤ 4xx 业务错误(参数/校验)
   → 就地展示(如表单字段旁)
⑥ 429 限流
   → 提示「操作过于频繁,请稍后再试」+ 冷却倒计时
⑦ 5xx 服务异常
   → 提示「服务异常」+ 重试 + 上报
// ✅ 重试 UI:显示进度与次数限制
function RetryableError({ error, onRetry }: { error: Error; onRetry: () => void }) {
  const [retrying, setRetrying] = useState(false);

  const handleRetry = async () => {
    setRetrying(true);
    try {
      await onRetry();
    } finally {
      setRetrying(false);
    }
  };

  return (
    <div role="alert">
      {error.message}
      <button onClick={handleRetry} disabled={retrying}>
        {retrying ? '重试中…' : '重试'}
      </button>
    </div>
  );
}
⚠️ 「自动重试」与「手动重试」的取舍:
· 自动重试(如 3 次指数退避)适合「网络抖动」→ 用户无感
· 但对 4xx(参数错误、权限问题)【不应该重试】(重试也不会成功)
· 手动重试给用户控制感(且能在「后端恢复后」重新请求)
✅ 实践:只对「网络错误 + 5xx」自动重试,其余交给用户手动重试

五、空状态设计

// ✅ 空状态要「说明 + 引导」
function EmptyState({ keyword, onCreate }: { keyword?: string; onCreate?: () => void }) {
  // 区分「没有数据」与「搜索无结果」——两者的引导不同
  if (keyword) {
    return (
      <div className="empty">
        <SearchIcon />
        没有找到与「{keyword}」相关的内容
        <p className="empty__hint">试试更短的关键词,或检查拼写</p>
      </div>
    );
  }

  return (
    <div className="empty">
      <FolderIcon />
      还没有任何项目
      {onCreate && <button onClick={onCreate}>创建第一个项目</button>}   {/* ⭐ 给出行动 */}
    </div>
  );
}
⭐ 空状态最容易被遗漏,但它是「新用户看到的第一屏」:
① 区分「真的没数据」与「筛选/搜索后没结果」(引导不同)
② 提供「下一步动作」(创建、导入、看示例)
③ 不要显示「暂无数据」四个字了事

六、避免「闪烁」

// ❌ 常见的闪烁来源一:刷新时清空数据
function Bad() {
  const [data, setData] = useState<Data | null>(null);

  const refetch = () => {
    setData(null);                       // ⚠️ 清空 → 界面从「有内容」变「骨架」
    fetchData().then(setData);
  };
}

// ✅ 保留旧数据,只显示轻提示
function Good() {
  const { data, isFetching, refetch } = useQuery({ ... });
  return (
    <>
      {isFetching && <TopProgressBar />}
      {data && <Content data={data} />}   {/* ⭐ 旧数据继续显示 */}
    </>
  );
}
// ❌ 闪烁来源二:分页切换时清空列表
// ✅ 用 placeholderData / keepPreviousData 保留上一页
const { data, isPlaceholderData } = useQuery({
  queryKey: ['items', page],
  queryFn: () => fetchItems(page),
  placeholderData: (prev) => prev,       // ⭐ 翻页时显示上一页数据
});

// 可以配合「半透明」提示数据正在更新
<div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>{/* ... */}</div>
// ❌ 闪烁来源三:数据变化导致布局跳动
// ✅ 骨架/占位要有「确定高度」
<img src={src} width={40} height={40} alt="" />          {/* ⭐ 设定宽高 */}
<div style={{ minHeight: 200 }}>{content}</div>           {/* ⭐ 预留空间 */}

七、局部 loading vs 全局 loading

// ❌ 全局 loading:一个请求就让整页转圈
function Bad() {
  const { isLoading } = useGlobalLoading();
  if (isLoading) return <FullPageSpinner />;      // ⚠️ 体验差(导航栏也消失了)
  return <Page />;
}

// ✅ 局部 loading:只在「数据区域」显示骨架
function Good() {
  return (
    <div>
      <TopNav />                                   {/* 保持稳定 */}
      <main>
        <Suspense fallback={<ContentSkeleton />}>
          <DataSection />                          {/* ⭐ 只有这块显示骨架 */}
        </Suspense>
      </main>
    </div>
  );
}
场景 推荐
页面首次进入 局部骨架(保持布局稳定)
表单提交 按钮 loading + 禁用(局部、明确)
下拉刷新 顶部进度条
列表加载更多 列表底部 loading(不要整页)
路由切换 顶部进度条 + 内容区骨架
「必须阻塞操作」的场景 才考虑全局遮罩(慎用)

八、常见坑

// ① 用多个布尔值表示状态(非法组合)
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);

// ② 忘记「空状态」(用户看到一片空白)
if (isLoading) return <Skeleton />;
if (error) return <Error />;
return <List items={data} />;              // ⚠️ data.length === 0 时空白

// ③ 刷新时清空数据(导致骨架闪烁)
setData(null);                             // ⚠️

// ④ 分页切换没保留上一页(列表闪白)
// ⑤ 骨架「形状与真实内容不一致」(内容出现时布局跳动)
// ⑥ 骨架/图片没有固定高度(CLS 高)
// ⑦ 加载很快也显示骨架(闪烁,应该延迟显示)
// ⑧ 对 4xx 错误也自动重试(永远失败、白白重试)
// ⑨ 错误信息直接暴露后端原始 message(可能含敏感信息/难懂的技术细节)
// ⑩ 用一个全局 loading 管理所有请求
// ⑪ 提交按钮不禁用(重复提交)
// ⑫ 在「错误状态」下仍然显示旧数据但不说明(用户以为看到的是新数据)
//    ✅ 用「轻提示 + 半透明」表达「正在刷新/数据可能过期」
// ⑬ 没有 `role="alert"`(无障碍:错误信息没被读屏播报)
// ⑭ 骨架动画太重(CPU 高、低端机掉帧)

九、面试延伸

  1. 「异步 UI 应该怎么建模状态?」
    用「判别联合(状态机)」而不是多个布尔值:{ status: 'idle' | 'loading' | 'success' | 'error' },成功时带 data、失败时带 error。好处是:① 非法状态无法表示(不会出现「loading 且 data 有值」);② 类型系统强制穷尽处理(switch 会提醒你漏了哪个分支);③ 渲染逻辑清晰(每个分支对应一种 UI)。实际项目还会区分「首次加载」与「刷新中」(后者保留旧数据)。

  2. 「为什么不该用多个布尔值?」
    因为三个独立布尔值有 8 种组合,但只有 4 种是合法的——「允许非法状态存在」会让 bug 有生存空间(渲染逻辑要考虑「loading 且 error 且 data 都有值」该怎么办)。而判别联合从结构上禁止了非法组合,同时配合 TypeScript 的穷尽检查,能让「新增状态时忘记处理」变成编译错误。这是「让非法状态无法表示」这一设计原则的典型应用。

  3. 「怎么避免加载时的闪烁?」
    三个来源与对应解法:① 刷新时清空数据 → 改为「保留旧数据 + 轻提示」(placeholderData / keepPreviousData / isFetching 进度条);② 分页/筛选切换时列表清空 → 用 placeholderData: (prev) => prev 保留上一页,并可用「半透明」表达正在更新;③ 骨架与真实内容形状不一致 → 骨架要贴合结构并设固定高度(避免内容出现时布局跳动 CLS)。还有一个技巧:延迟 100~200ms 再显示骨架(加载很快时直接显示内容)。

  4. 「骨架屏和 spinner 有什么区别?该用哪个?」
    骨架屏携带「即将出现什么」的信息(形状、数量、位置),用户能预判布局,体验更平稳;spinner 只有「在加载」这一条信息,且通常居中、会「顶掉」内容导致跳动。所以:内容型区域(列表、卡片、详情)优先用骨架屏(形状贴合真实内容);操作型场景(按钮提交、局部刷新)用 spinner 或进度条;整页级别尽量不用(会失去布局上下文)。

  5. 「错误态应该怎么设计?」
    三个要素:① 说明原因(网络问题?无权限?服务异常?——不同原因对应不同文案与出路);② 可操作(重试按钮、返回、联系客服);③ 不暴露敏感信息(不要把后端原始堆栈/内部错误码给用户看)。此外要按错误类型分流:401 跳登录(拦截器统一处理)、403 提示无权限(不要跳登录)、404 显示「不存在」页、4xx 业务错误就地提示、5xx 提示「服务异常」+ 重试。自动重试只对「网络错误 + 5xx」,4xx 重试没有意义。

  6. 「空状态为什么重要?」
    因为它是「新用户看到的第一屏」——很多产品在「没有数据时」只显示「暂无数据」四个字,用户不知道下一步做什么。好的空状态应该:① 区分场景(「真的没数据」vs「筛选/搜索后无结果」——后者的引导是「换个关键词」);② 给出行动(「创建第一个项目」「导入数据」「查看示例」);③ 传达价值(一两个词说明这个功能能做什么)。空状态是被遗漏最多、但影响首次体验最大的状态。

  7. 「刷新数据时为什么要保留旧数据?」
    因为「清空 → 显示骨架 → 再显示数据」会让页面闪一下(视觉不稳定),而且用户会失去上下文(比如正在看列表第 3 项,刷新后位置全变了)。保留旧数据的做法是「旧数据继续显示 + 用一个轻量提示表达「正在更新」」(顶部进度条、区域半透明、小 spinner)。这是「乐观 UI」的一种温和形式,能显著提升「流畅感」。placeholderData、keepPreviousData、isFetching 都是为此设计的。

  8. 「全局 loading 和局部 loading 怎么选?」
    原则是「loading 的作用范围要尽量贴近「真正在等的那个区域」」。① 局部——页面首次进入(内容区骨架)、表单提交(按钮 loading)、列表加载更多(底部 loading)、路由切换(内容区骨架 + 顶部进度条);② 全局遮罩——只在「必须阻塞用户操作」时使用(如「提交订单时防止重复点击」「支付跳转前」),而且要有进度说明。默认选局部——全局 spinner 会让「导航栏也消失」,用户失去空间感与退路。

一句话速记

异步 UI 用「状态机(判别联合)」而不是多个布尔值——让非法状态无法表示,并区分「首次加载」与「刷新中」;四种必做状态是 idle / loading / success / error(+ empty),空状态最容易被漏(要给「引导 + 行动」);避免闪烁三招是「刷新时保留旧数据 + 轻提示」「分页用 placeholderData」「骨架贴合结构与固定高度(避免 CLS)」,并可延迟 150ms 再显示骨架;错误态要有原因 + 重试 + 不暴露敏感信息,按类型分流(401 跳登录、403 就地提示、4xx 不重试、5xx/网络才自动重试);loading 尽量局部(全局遮罩只在必须阻塞时用)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.3 加载态与错误态设计

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫