首页 / React 学习笔记 / 32

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

数据获取:fetch / axios 与 useEffect 取数

实战核心#数据#请求

最朴素的取数:fetch 与它的两个坑

第 9 篇学过 useEffect 处理副作用,第 31 篇把 URL 参数解析到手——现在把两件事接起来:拿参数 → 发请求 → 渲染数据。这就是前端最日常的「服务端状态」消费场景(第 17 篇埋的伏笔终于上线)。先从浏览器原生的 fetch 说起,它的 API 简洁得名副其实,但有两个坑专坑后端转来的你:

fetchUser.ts · fetch 的正确打开方式
// 坑 1:404、500 不会 reject——照样走进「成功」流程
// 坑 2:res.json() 返回的是 Promise,不是对象,还得再 await 一次
async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);

  if (!res.ok) {                    // HTTP 错误必须手动检查
    throw new Error(`请求失败:${res.status}`);
  }

  const user = await res.json();    // 坑 2 在这:再 await 一次
  return user;
}

展开说说这两个坑。坑 1:只有网络层挂掉(断网、DNS 解析失败)fetch 才 reject;服务器回了 404、500,那也是一个「完整送达的 HTTP 响应」,fetch 照样 resolve,只是 res.ok 是 false。你如果只写 try-catch,404 的响应体会被当成正常数据往下走,报错报在十万八千里之外。坑 2res.json() 之所以返回 Promise,是因为响应体要流式读完才能解析——所以读 JSON 永远是两步 await

后端类比:RestTemplate 会抛,fetch 不会

RestTemplate 默认对 4xx/5xx 直接抛 HttpClientErrorException——「HTTP 错误即异常」是你多年的肌肉记忆。fetch 的哲学相反:拿到响应就是成功,状态码自己看。结果就是你必须给每个请求补上 if (!res.ok),漏了就是「界面显示异常数据但不报错」这种最难查的 bug。

为什么 fetch 要这么设计?规范制定者认为 404 也是一个「合法的 HTTP 响应」,如何解读是调用方的自由。哲学归哲学,业务上你几乎总想把它当错误处理——所以记住结论即可:用 fetch,if (!res.ok) 一行都不能省。

换 axios:封装一次,处处受益

真实项目里,每个请求都要做三件同样的事:拼 baseURL、带 token、统一处理错误。散落在每个组件里就是灾难——改个头信息要全局搜索。所以前端惯例是封装一个客户端实例,全项目共用:

api/client.ts · axios 实例 + 拦截器
import axios from "axios";

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_BASE ?? "/api",
  timeout: 10_000,
});

// 请求拦截器:统一带上 token(对照 Feign 的 RequestInterceptor)
api.interceptors.request.use((config) => {
  const token = localStorage.getItem("token");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器:统一解包、统一处理 401
api.interceptors.response.use(
  (res) => res.data,   // 调用处拿到的直接是业务数据,省掉 res.data.data
  (error) => {
    if (error.response?.status === 401) {
      // 未登录 / token 过期:踢去登录页,第 35 篇细讲
    }
    return Promise.reject(error);
  }
);

顺带一提,axios 替你填掉了 fetch 的两个坑:非 2xx 默认 reject(不用手写 if (!res.ok)),响应体自动 JSON.parse(不用二次 await)。业务代码退化成一句 const user = await api.get("/users/42")

后端类比:拦截器是个你天天在写的概念

Feign 的 RequestInterceptor、RestTemplate 的 ClientHttpRequestInterceptor、OkHttp 的 Interceptor、Servlet Filter——「横切关注点集中一处,请求流过管道」这个模型完全一致,唯一区别是这次拦截发生在浏览器里。token 注入、统一错误码、日志埋点,前后端用的是同一张设计图。

放进 useEffect:完整取数模板

现在把请求放进组件。先复习第 9 篇的一条铁律:useEffect 的回调不能是 async 函数——它的返回值必须是清理函数,而 async 函数返回的是 Promise。所以套路是在 effect 内部定义一个 async 函数再调用它。再加上第 31 篇的参数,一个能上生产的取数组件长这样:

UserDetail.tsx · useEffect 取数完整模板
import { useEffect, useState } from "react";
import axios from "axios";
import { useParams } from "react-router-dom";
import { api } from "../api/client";

interface User {
  id: number;
  name: string;
  email: string;
}

function UserDetail() {
  const { id } = useParams<{ id: string }>();
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let ignore = false;               // 本次 effect 是否已作废
    const controller = new AbortController();

    async function load() {
      setLoading(true);
      setError(null);
      try {
        const data = await api.get(`/users/${id}`, {
          signal: controller.signal,  // 请求层面也支持取消
        });
        if (!ignore) setUser(data);
      } catch (e) {
        if (!ignore && !axios.isCancel(e)) {
          setError(e as Error);       // 被主动取消的不算错误
        }
      } finally {
        if (!ignore) setLoading(false);
      }
    }

    load();
    return () => {
      ignore = true;         // 依赖变了 / 组件卸载:旧的一次作废
      controller.abort();    // 真正掐断在途的请求,省流量省内存
    };
  }, [id]);

  if (loading) return <p>加载中…</p>;
  if (error) return <p>出错了:{error.message}</p>;
  return <h2>{user?.name}({user?.email})</h2>;
}

两个保护机制各司其职,别混为一谈:ignore 标志管的是「结果还能不能 setState」——React 保证下次 effect 执行前先跑上一次的 cleanup,所以只有时间上最新的那次 effect 的 ignore 是 false;AbortController 管的是「请求本身还要不要飞」——ignore 只是不认结果,请求还在白耗带宽和后端资源,abort 才是真取消。一个是丢弃判决书,一个是拦下快递员,双保险。

再看渲染部分:loading / error / data 三个 state、三段 if,这还只是最简版——没有重试、没有骨架屏、没有错误码分支。加载态、错误态、竞态的系统打法,第 34 篇专门展开;这里你只需要先闻到一丝味道:这活儿又重复又容易错。这一站先按下不表,最后一站算总账。

竞态:两次请求的时序车祸

如果上一站的 ignore 和 abort 你觉得是花架子,看看这场车祸。用户详情页有个「下一个」按钮,用户手快连点,id 从 1 切到 2——两次请求同时在天上飞,而网络的快慢不由代码说了算:

race.ts · 复现步骤
// t1: 点「下一个」→ 发出请求 A(id=1),恰好赶上网络抖动,飞得慢
// t2: 再点「下一个」→ 发出请求 B(id=2),一路畅通,先返回
//     → setUser(用户2),屏幕显示用户 2,看起来一切正常
// t3: 请求 A 迟到返回 → setUser(用户1) → 屏幕被旧数据覆盖!
//     而此时地址栏明明是 /users/2
没有防护:迟到的旧请求覆盖新结果 t1 点用户1 t2 点用户2 时间 → 请求 A(id=1) A 迟到,覆盖! 请求 B(id=2) B 先返回,渲染 屏幕显示 id=1 的数据,地址栏却是 /users/2 —— 错 cleanup + ignore:过期的结果直接丢弃 请求 A(id=1) A 迟到返回 被 ignore 拦下 请求 B(id=2) B 先返回,渲染 屏幕始终显示 id=2 —— 对
图 1竞态时间线:上图旧请求迟到覆盖新数据;下图 cleanup 里的 ignore 把过期结果拦下

为什么上一站的模板能治它?因为 React 保证:下一次 effect 执行前,先执行上一次的 cleanup。时间线里 t2 时刻点下用户 2 的瞬间,第一次 effect 的 cleanup 先跑(A 的 ignore 变 true、A 被 abort),然后才发起 B——迟到的 A 就算回来了,if (!ignore) 也把它挡在 setState 门外。治愈的关键不是「取消得快」,而是给每一次 effect 发一张时效戳,过期的判决一律作废

后端类比:过期写入,你处理并发写的老朋友

两个事务并发写同一行,先发起的不一定先提交,你不会让迟到的旧事务覆盖新数据——数据库靠版本号(乐观锁)判过期,谁「最后发言」由提交顺序决定。useEffect 的 cleanup 就是 React 发给你的版本号:ignore=false 的那一次才是最新版本。思路同源,只是战场从数据库行搬到了组件 state。

算总账:这些痛点指向第 33 篇

手写一遍之后,把账摊开算算。哪怕只做一个最普通的详情页,你已经付过的成本和欠下的债:

痛点手写的代价第 33 篇 TanStack Query 的答案
loading / error / data 三态三个 useState + 三段 if,每个组件抄一遍useQuery 一行,三态直接返回
竞态ignore + abort 靠自觉,忘了就是线上 bug框架内部按 key 自动丢弃过期结果
没有缓存列表 → 详情 → 返回列表,列表又白屏重请求按 key 缓存,返回秒显旧数据、后台静默刷新
没有去重两个组件同时挂载,同一接口发两次同 key 的并发请求自动合并成一次
失效与重取提交表单后手动到处调 refresh,越理越乱声明式失效:改完数据 invalidate 对应 key

前两行你已经亲身踩过,后三行是还没爆的雷:缓存缺失意味着「每次切页都白屏」;没有去重意味着「同一份数据被请求 N 遍」;失效混乱意味着「用户改完头像,列表里还是旧的」。每一项单看都能忍,加起来就是每个前端项目里那坨越写越重的「数据层胶水代码」。

后端类比:手写 JDBC vs MyBatis

手写取数像手写 JDBC:getConnection、prepareStatement、execute、close,每个 DAO 重复一遍,漏一步就是连接泄漏——于是有了 MyBatis,你只声明「SQL + 映射」,事务和资源管理框架全包。TanStack Query 就是前端界的那一步:你只声明「queryKey + fetcher」,缓存、去重、竞态、重试、失效全归框架。你在后端早就完成过一次这次跃迁。

核心要点
  • fetch 两个坑:HTTP 错误不 reject(必写 if (!res.ok))、res.json() 是异步要二次 await;axios 默认替你处理这两条。
  • axios 实例 + 拦截器 = 前端版的 Feign/OkHttp 拦截器:baseURL、token、统一错误一处收口。
  • useEffect 取数模板三件套:内部 async 函数、ignore 标志、AbortController——ignore 管结果作废,abort 管请求取消。
  • 竞态的本质是「迟到的旧响应覆盖新状态」;cleanup 就是 React 给每次 effect 的时效戳。
  • 三态样板、无缓存、无去重、无失效——这份痛点清单就是 TanStack Query 的功能清单。

本章回顾

这一篇打通了「参数到手 → 发起请求 → 三态渲染」的完整链路:fetch 便宜但有坑,axios 用拦截器把横切关注点收口,useEffect 模板用 ignore 和 AbortController 防住了竞态。但你也亲眼看到了:防一个竞态要写多少仪式感代码,而这还只是「单个请求」的账——缓存、去重、失效根本还没开始算。手写 JDBC 的日子该结束了:下一篇第 33 篇,请出前端界的 MyBatis——TanStack Query,把这份痛点清单逐条划掉。

Comments · 评论