REACT · Vol.V · LESSON 34 · 路由与数据获取
加载态 / 错误态 / 竞态处理
先把「请求的状态」建模成类型
上一篇第 33 篇,TanStack Query 把数据稳稳送到组件手里,还附赠 isPending / isError 标志位。但很多人拿到后只写 if (isPending) return 转圈,界面依然事故频出:转圈盖住旧数据、空数据当成失败、慢请求反杀新请求——问题在「请求的状态」没被建模清楚。一次异步请求就四种状态:idle 还没发起;loading 进行中、没有数据;success 拿到数据(可能是空数组);error 失败、带着原因。
这个模型你其实在第 28 篇见过——discriminated union(可辨识联合类型)。翻成 TypeScript:
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 炸了」的事故;类型把四态锁死,事故在编译期消失。
四态模型就是你后端的订单状态机:CREATED / PAID / SHIPPED,每个状态有自己的合法字段与允许操作,非法迁移直接拒绝。RequestState 同理——「成功才许有数据,失败才许有原因」,TypeScript 替你守住这份约束。带着状态图写 UI 和带着状态机写后端,是同一种职业素养。
每一态该长什么样
模型有了,第二个问题是每态的 UI 设计。新手只做「转圈」和「数据」两态,讲究的做法是四态各有其职:
| 状态 | 用户看到什么 | 设计要点 |
|---|---|---|
| loading | 骨架屏或 spinner | 位置可预估的主体用骨架屏,占住真实布局防加载完跳动;按钮等小区域用 spinner;短于 300ms 什么都不显示 |
| success | 真实内容,或空态引导 | 空数组要单独设计:「还没有任何数据,去创建第一个吧」——空态是成功,不是失败,更不是加载中 |
| error | 错误说明 + 重试按钮 | 信息说人话别甩状态码;必须给 refetch() 出口 |
最常见的翻车是把「空数据」渲染成 loading 或报错:loading 是「还没回来」,空是「回来了但没有」,两码事。用 TanStack Query 的标志位把分支写全:
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,每个取数组件都得带一份:
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 里这段样板整个消失,竞态在源头被拆掉:
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>;
}
两层防线,各自堵的是不同的洞:
- queryKey 隔离:「re」和「react」是两条不同缓存,慢的旧请求返回了也只写进它自己 key 的缓存,当前界面读当前 key——谁也污染不了谁。这防「数据串台」,保证正确性。
- signal 取消:key 变化或卸载时,Query 自动触发你接进 fetch 的 AbortSignal,请求真被掐断。这省流量省算力,就算不接 signal,第一层也已保证正确性。
手写 ignore 标志本质是「加锁」:所有请求抢同一个 state 变量,靠标志位仲裁谁有资格写。Query 的做法是「按 key 分桶」——每个 key 一份存储,天然隔离,像按订单号分片的写路径互不干扰,根本不需要锁。signal 取消则像 Future.cancel(true):调用方不要了,底层任务尽早中断。
防抖 + 取消:搜索框的组合拳
竞态解决「正确性」,防抖解决「别发这么多请求」:用户打一个词要敲七八下键盘,没必要敲一下发一次。第 13 篇写过 useDebounce 自定义 Hook,直接拿来用:
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 隔离组装成一个生产级搜索框:
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 库包在最外层,一句话带过:
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 · 评论