首页 / React 学习笔记 / 34

REACT · Vol.V · LESSON 34 · 路由与数据获取

加载态 / 错误态 / 竞态处理

实战#数据#健壮性

先把「请求的状态」建模成类型

上一篇第 33 篇,TanStack Query 把数据稳稳送到组件手里,还附赠 isPending / isError 标志位。但很多人拿到后只写 if (isPending) return 转圈,界面依然事故频出:转圈盖住旧数据、空数据当成失败、慢请求反杀新请求——问题在「请求的状态」没被建模清楚。一次异步请求就四种状态:idle 还没发起;loading 进行中、没有数据;success 拿到数据(可能是空数组);error 失败、带着原因。

这个模型你其实在第 28 篇见过——discriminated union(可辨识联合类型)。翻成 TypeScript:

types.ts · 四态建模:状态和该状态下才有的数据绑在一起
type RequestState<T> =
  | { status: "idle" }                  // 还没发起
  | { status: "loading" }               // 进行中,没有数据
  | { status: "success"; data: T }      // 只有这个分支摸得到 data
  | { status: "error"; error: Error };  // 只有这个分支摸得到 error

// 不收窄就写 state.data?编译报错:Property 'data' does not exist
function render(state: RequestState<string[]>) {
  if (state.status === "success") console.log(state.data); // 收窄后 data 一定存在
}

这套类型的精髓是:状态和它拥有的数据绑定——loading 时根本没有 data,不收窄就访问,编译器直接拦下。JS 没这层保护,手写取数总能写出「loading 时渲染 undefined.map 炸了」的事故;类型把四态锁死,事故在编译期消失。

idle 未开始 loading 请求中 success 成功 error 失败 发起请求 2xx,有数据 网络失败 / 4xx 5xx 点重试 refetch() invalidate 后台重取 queryKey 变化或缓存失效重取时,都会重新走一遍 loading
图 1请求四态状态机:一切取数 UI 都在这四个分支里。success 与 error 都能回到 loading——重取不是首次加载,UI 要分别照顾。
后端类比:订单状态机

四态模型就是你后端的订单状态机:CREATED / PAID / SHIPPED,每个状态有自己的合法字段与允许操作,非法迁移直接拒绝。RequestState 同理——「成功才许有数据,失败才许有原因」,TypeScript 替你守住这份约束。带着状态图写 UI 和带着状态机写后端,是同一种职业素养。

每一态该长什么样

模型有了,第二个问题是每态的 UI 设计。新手只做「转圈」和「数据」两态,讲究的做法是四态各有其职:

状态用户看到什么设计要点
loading骨架屏或 spinner位置可预估的主体用骨架屏,占住真实布局防加载完跳动;按钮等小区域用 spinner;短于 300ms 什么都不显示
success真实内容,或空态引导空数组要单独设计:「还没有任何数据,去创建第一个吧」——空态是成功,不是失败,更不是加载中
error错误说明 + 重试按钮信息说人话别甩状态码;必须给 refetch() 出口

最常见的翻车是把「空数据」渲染成 loading 或报错:loading 是「还没回来」,空是「回来了但没有」,两码事。用 TanStack Query 的标志位把分支写全:

UserList.tsx · 四态分支渲染完整例子
import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "./api";

function UserList() {
  const { data, isPending, isError, error, refetch } = useQuery({
    queryKey: ["users"],
    queryFn: fetchUsers,
  });

  // 第一分支:加载态。列表位置可预估,用骨架占位防布局跳动
  if (isPending) {
    return <div className="skeleton" />;
  }

  // 第二分支:错误态。说人话 + 给重试出口
  if (isError) {
    return (
      <div>
        <p>加载失败:{error.message}</p>
        <button onClick={() => refetch()}>重试</button>
      </div>
    );
  }

  // 第三分支:成功但空。空态是成功,单独设计
  if (data.length === 0) {
    return <p>还没有任何用户,去创建第一个吧</p>;
  }

  // 第四分支:成功有数据。到这里 data 一定不是 undefined
  return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

四个 if 的顺序也有讲究:先把「非成功」分支全挡掉,走到最后 TypeScript 已收窄出「data 一定存在」,data.map 不用加问号——分支写全,编译器保证不漏。顺带分清 v5 的两个布尔:isPending 是「首次还没数据」,isFetching 是「有请求在飞」(含后台重取)——整块占位用前者,角落小转圈用后者。

竞态:慢请求的反杀与两种解法

竞态的经典现场:搜索框快速输入「re」再「react」,两个请求先后发出;「react」的先返回,「re」的后返回——后到的旧结果把新结果覆盖了。这不是玄学,是并发写共享变量:回调写同一个 state 的顺序没有保证。第 32 篇的手写解法是 ignore 标志加 AbortController,每个取数组件都得带一份:

useSearchUsers.ts · 第 32 篇的手写版:样板随组件复制
useEffect(() => {
  const controller = new AbortController();
  let ignore = false;

  fetch(`/api/users?keyword=${keyword}`, { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => { if (!ignore) setUsers(data); })
    .catch((err) => { if (err.name !== "AbortError") setError(err); });

  return () => {
    ignore = true;        // 过期响应不许写 state
    controller.abort();   // 真取消请求,省流量
  };
}, [keyword]);

TanStack Query 里这段样板整个消失,竞态在源头被拆掉:

SearchResults.tsx · queryKey 隔离 + signal 取消
import { useQuery } from "@tanstack/react-query";

function SearchResults({ keyword }: { keyword: string }) {
  const { data, isPending } = useQuery({
    queryKey: ["users", "search", keyword],  // 每个关键词一条独立缓存
    // signal 接进 fetch:key 变化或卸载时,请求真被取消
    queryFn: ({ signal }) =>
      fetch(`/api/users?keyword=${encodeURIComponent(keyword)}`, { signal })
        .then((res) =>
          res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))
        ),
  });

  return isPending
    ? <p>搜索中…</p>
    : <p>共 {data?.length ?? 0} 条结果</p>;
}

两层防线,各自堵的是不同的洞:

  1. queryKey 隔离:「re」和「react」是两条不同缓存,慢的旧请求返回了也只写进它自己 key 的缓存,当前界面读当前 key——谁也污染不了谁。这防「数据串台」,保证正确性。
  2. signal 取消:key 变化或卸载时,Query 自动触发你接进 fetch 的 AbortSignal,请求真被掐断。这省流量省算力,就算不接 signal,第一层也已保证正确性。
后端类比:按 key 分桶,而不是加锁

手写 ignore 标志本质是「加锁」:所有请求抢同一个 state 变量,靠标志位仲裁谁有资格写。Query 的做法是「按 key 分桶」——每个 key 一份存储,天然隔离,像按订单号分片的写路径互不干扰,根本不需要锁。signal 取消则像 Future.cancel(true):调用方不要了,底层任务尽早中断。

防抖 + 取消:搜索框的组合拳

竞态解决「正确性」,防抖解决「别发这么多请求」:用户打一个词要敲七八下键盘,没必要敲一下发一次。第 13 篇写过 useDebounce 自定义 Hook,直接拿来用:

useDebounce.ts · 第 13 篇的定制逻辑,原样复用
import { useEffect, useState } from "react";

export function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);  // 值再变就重置计时器
  }, [value, delay]);

  return debounced;
}

然后把防抖值、enabled、key 隔离组装成一个生产级搜索框:

SearchBox.tsx · 防抖 + enabled + 自动取消三重保险
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useDebounce } from "../hooks/useDebounce";
import { searchUsers, type User } from "./api";

function SearchBox() {
  const [keyword, setKeyword] = useState("");
  const debounced = useDebounce(keyword, 300);  // 停止输入 300ms 才生效

  const { data, isPending } = useQuery({
    // 防抖值进 key:每个关键词一条缓存,竞态天然消失
    queryKey: ["users", "search", debounced],
    queryFn: ({ signal }) => searchUsers(debounced, signal),
    // 空白搜索不发请求:没输入就别惊动后端
    enabled: debounced.trim().length > 0,
    // 换关键词重取时先显示上一次结果,列表不闪烁
    placeholderData: keepPreviousData,
  });

  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      {isPending ? <p>搜索中…</p> : data?.map((u: User) => <p key={u.id}>{u.name}</p>)}
    </div>
  );
}

拆解分工:防抖砍掉 80% 的请求;enabled 挡住空查询;key 隔离 + signal 兜住极端情况;keepPreviousData 管体验,新结果没回来时旧列表还在、页面不闪。四招各管一层,这就是「讲究」和「能跑」的差距。

后端类比:入口限流 + 客户端断连

防抖像网关层合并限流:突发流量攒一攒,只放最后一个;enabled 像条件路由,前置条件不满足就不进处理链;signal 取消像客户端主动断连,服务端及时止损。后端的流量治理,这套组合拳一个不少,只是挪到了浏览器。

分层兜底,把失败当常态

四态分支管住了「请求失败」,但渲染期意外它管不到——数据形状不对、代码 bug,默认把整棵组件树打白屏。对策是错误边界(ErrorBoundary):捕获子树渲染错误的容器,通常用 react-error-boundary 库包在最外层,一句话带过:

main.tsx · 全局兜底:渲染期意外别打成白屏
import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary fallback={<p>页面开小差了,请刷新重试</p>}>
  <App />
</ErrorBoundary>

于是前端错误处理有了三层,分层的思路你在后端天天见:

机制管什么后端对应物
组件内四态分支isPending / isError 分支渲染可预期的失败:超时、404、业务报错局部 try-catch
ErrorBoundary子树错误捕获 + fallback渲染期意外:bug、数据形状不符@ExceptionHandler 兜底
监控上报全局错误监听 + 上报谁都没接住的错误日志聚合 + 告警(第 41 篇)

最后是态度。新手默认「请求都会成功」,错误处理是补丁;成熟工程师默认失败是常态:网络会断、接口会超时、用户会手滑连点。四态模型不是教科书装饰,而是每个取数组件的默认蓝图——先写四个分支,再考虑数据怎么展示。

核心要点
  • 请求四态用 discriminated union 建模(呼应第 28 篇):不收窄状态就摸不到 data。
  • loading:位置可预估用骨架屏,短暂用 spinner;success 要区分空态;error 必须给 refetch() 重试出口。
  • 竞态两层解:queryKey 隔离保正确性(旧响应污染不了新界面),signal 取消省流量——Query 都自动做了。
  • 搜索框组合拳:useDebounce(第 13 篇)砍请求量 + enabled 挡空查询 + keepPreviousData 稳体验 + 取消兜底。
  • 错误分三层:组件内分支、ErrorBoundary 全局兜底、监控上报——后端分层处理的思想原样平移。

本章回顾

这一篇把第 33 篇交给你的标志位升级成 UI 建模方法:四态是类型、分支是骨架、竞态靠 key 隔离加取消、防抖靠自定义 Hook、意外靠 ErrorBoundary 兜底。把失败当常态设计,取数组件从此像后端接口一样可预期。数据稳了、界面稳了,还差最后一环:游客和登录用户看到的世界不该一样——登录态存哪、路由怎么守、token 怎么带?下一篇第 35 篇,用 Spring Security 的思路把认证流程走通。

Comments · 评论