React11:数据请求与 TanStack Query

第 11 课 · 数据请求与 TanStack Query

axios 层已经学过了,这课的新东西不是发请求,而是「请求到的数据放在哪、什么时候重新拿」。TanStack Query(React Query)就是干这个的,它是国内中后台 React 项目的列表页标配,也是面试出现频率最高的 React 话题之一。

🎯 本课目标

  • 说清「服务端状态」和「客户端状态」的区别,能回答「React Query 解决什么问题」
  • 会挂载 QueryClientProvider,理解它相当于 Vue 里的 app.use(pinia)
  • 会用 useQuery 写列表请求:queryKey / queryFn / staleTime / { data, isLoading, error, refetch },并掌握 v5 的对象参数风格
  • 掌握列表页核心模式:参数必须进 queryKey,实现「关键字变就重查、变回旧值缓存秒出」
  • 会用 useMutation + queryClient.invalidateQueries 做「写完自动刷新」,并说清它与手动 refetch 的差别
  • 能判断什么时候值得上 React Query,什么时候 useEffect 请求一次就够了

1. 请求层:axios

Vue 里你这样写(React 项目里一模一样)

ts 复制代码
// src/api/http.ts ------ 与 Vue 项目完全一致,直接复制过来就能用
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE,
  timeout: 10000,
})

http.interceptors.request.use((config) => {
  const token = useAuthStore.getState().token   // 第 10 课:非组件环境读 store
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

http.interceptors.response.use(
  (res) => res.data,
  (err) => {
    if (err.response?.status === 401) {
      useAuthStore.setState({ token: null, user: null })  // 清登录态
    }
    return Promise.reject(err)
  },
)

React 里这样写

企业里通常再按领域拆一层 api 函数,让组件不直接拼 URL:

ts 复制代码
// src/api/user.ts
import { http } from './http'
import type { User, PageResult } from '@/types'

export function getUsers(params: { keyword: string; page: number; pageSize: number }) {
  return http.get<PageResult<User>>('/api/users', { params })
}

注意:本课程项目没有安装 axios,Demo 里用 fetch 调公共测试接口 jsonplaceholder。请求层的写法思路完全一致,进企业换回 axios 即可。

关键差异与注意点

  • 请求层零差异,迁移的是工程习惯而不是语法。
  • Vue 里你习惯「发完请求把结果塞进 ref」,React 里新手会塞进 useState ------ 单页一次性数据没问题,列表页会立刻遇到下面的四个问题。

2. 手写请求 hook 的天花板:为什么需要「服务端状态」这个概念

Vue 里你这样写

第 8 课的 useFetch 已经解决了 loading / error:

ts 复制代码
const { data, loading, error } = useFetch(() => getUsers(params))

React 里这样写

但下面四个需求,手写 hook 一个都解决不了(或者说要写一大堆容易出错的代码):

需求 手写 hook 的现状
缓存 组件卸载数据就没了,切回列表页重新 loading
请求去重 两个组件同时请求同一份数据 → 发两次
失效刷新 新增/删除后要手动刷列表,写多了必漏
窗口聚焦重取 用户切走 10 分钟回来,看到的是旧数据

关键差异与注意点(高频面试)

  • 客户端状态 :交互产生的状态,前端是唯一拥有者 ------ 表单值、弹窗开关、选中项。归 useState / Zustand 管。
  • 服务端状态:数据源在服务端,前端拿到的只是「某一刻的快照」,会过期、会被别人改 ------ 列表、详情、下拉字典。归 TanStack Query 管。
  • 把服务端数据塞进 Zustand,你就得自己实现缓存、去重、过期、刷新 ------ 这正是 React Query 替你做的事。一条铁律:Zustand 放客户端状态,React Query 放服务端状态。
  • Vue 生态的对应物是 TanStack Query 本身(Vue 版 API 几乎相同),所以这课的经验将来在 Vue 项目里也能用。

3. QueryClientProvider:一次挂载,全局可用

Vue 里你这样写

ts 复制代码
// main.ts
app.use(createPinia())

React 里这样写

tsx 复制代码
// main.tsx ------ 真实项目挂在根组件外面,全项目共用一个 client
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,        // 默认重试 3 次,中后台一般调成 0 或 1
      staleTime: 0,    // 默认 0:拿到就算过期,挂载/聚焦就重取
    },
  },
})

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
)

关键差异与注意点

  • QueryClientProvider 相当于 app.use(pinia):放一个「缓存仓库」进 React 上下文,useQuery 从中读写缓存。
  • 本课程 Demo 里每个组件自己 new QueryClient() 并包一层 Provider ,是为了各 demo 缓存互不污染;真实项目只挂一次,且不要在组件内部 new(每次渲染都会新建缓存)。
  • retry 默认 3 次(指数退避):调试接口报错时会「卡很久」才显示错误,企业里常调成 0/1;401/403 这类不该重试。
  • 错误兜底:请求报错除了组件内展示,还可以配 ErrorBoundary 做页面级兜底(React 没有全局 onErrorCaptured 的等价仪式,用组件实现)。

4. useQuery:queryKey 是缓存身份

Vue 里你这样写

Vue 里没有等价物,最接近的心智是「一个自动维护的 ref + `watch(key) 重新请求 + 结果缓存」。

React 里这样写

Demo1 就是这份代码,请求的是 jsonplaceholder 公共测试接口:

tsx 复制代码
import { useQuery } from '@tanstack/react-query'

interface ApiUser {
  id: number
  name: string
  email: string
}

async function fetchUsers(): Promise<ApiUser[]> {
  const res = await fetch('https://jsonplaceholder.typicode.com/users')
  if (!res.ok) throw new Error(`请求失败:HTTP ${res.status}`)
  return res.json()
}

function UsersPanel() {
  // v5 统一为对象参数风格(v4 及更早的 ['users'], fetchUsers 位置参数写法已废弃)
  const { data, isLoading, isFetching, error, refetch } = useQuery({
    queryKey: ['users'],   // 缓存身份:唯一标识这份数据
    queryFn: fetchUsers,   // 怎么拿:必须返回 Promise;抛错 = 请求失败
    staleTime: 10_000,     // 10 秒内认为数据新鲜,不再重复请求
  })

  if (isLoading) return <div>加载中...</div>
  if (error) return <div>出错了:{error.message} <button onClick={() => refetch()}>重试</button></div>

  return <ul>{(data ?? []).map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}

关键差异与注意点

  • 返回值解构:data(缓存里的数据,首次为 undefined)、isLoading(没有缓存数据且正在请求 )、isFetching(正在请求 ,含后台静默刷新,所以 refetch 时不闪 loading)、error、refetch。
  • isLoading vs isFetching 一句话 :首次加载两个都是 true;有缓存后的重取只有 isFetching 是 true。列表页用 isLoading 防闪屏、用 isFetching 显示右上角小转圈。
  • queryFn 抛出的错误会进 error,所以请求函数里要「把非 2xx 变成 throw」------ axios 的拦截器已经帮你做了 reject。
  • staleTime 是「多久内不用重新请求」;gcTime(旧名 cacheTime,默认 5 分钟)是「没有组件使用后缓存保留多久」。面试常一起问。

5. 带参查询:搜索 / 分页列表页的核心模式

Vue 里你这样写

ts 复制代码
const keyword = ref('')
const list = ref<User[]>([])
watch(keyword, async (kw) => {
  list.value = await getUsers({ keyword: kw })   // 上一次的结果被你覆盖丢弃了
})

React 里这样写

Demo2 可交互体验「切回旧关键字缓存秒出」:

tsx 复制代码
function SearchPanel() {
  const [keyword, setKeyword] = useState('')

  const { data, isLoading } = useQuery({
    // 参数必须进 key!['staff', '前端'] 和 ['staff', '测试'] 是两条不同缓存
    queryKey: ['staff', keyword],
    queryFn: () => searchStaffApi(keyword),   // 闭包里的 keyword 与 key 保持一致
  })

  return <UserTable list={data ?? []} loading={isLoading} />
}

分页同理,把页码一起放进 key:

tsx 复制代码
const { data, isPreviousData } = useQuery({
  queryKey: ['users', keyword, page],
  queryFn: () => getUsers({ keyword, page, pageSize: 10 }),
  placeholderData: (prev) => prev,   // 翻页时先显示上一页数据,避免闪 loading
})

关键差异与注意点

  • key 变 → 自动重新请求;key 变回 → 命中缓存秒出。手写 hook 永远做不到第二条,这是两种方案最直观的体感差异。
  • key 里漏参数 = 不同参数共用一条缓存 = 「查了 A 显示 B」的经典线上 bug。写 key 的口诀:把决定这条数据是什么的所有变量都放进去(关键字、页码、排序、id、当前租户...)。
  • 闭包陷阱自查:queryFn: () => searchStaffApi(keyword) 里的 keyword 必须就是 key 里那个,别写成组件里另一个 state。
  • 搜索输入要做防抖再进 key(300ms),否则每个字符都是一条缓存,缓存空间会被灌满。

6. useMutation + invalidateQueries:写完自动刷新

Vue 里你这样写

ts 复制代码
async function handleCreate() {
  await createUser(form)
  list.value = await getUsers(params)   // 每个「写」操作后面都要手拉一遍列表
}

React 里这样写

Demo3 对比了两种写法(可切换体验):

tsx 复制代码
import { useMutation, useQueryClient } from '@tanstack/react-query'

function AddUserPanel() {
  const queryClient = useQueryClient()

  const { mutateAsync, isPending } = useMutation({
    mutationFn: createUserApi,
    // 推荐:声明「['members'] 这条缓存过期了」,QueryClient 自动重取
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['members'] })
    },
  })

  const { data } = useQuery({ queryKey: ['members'], queryFn: fetchMemberList })
  const list = data ?? []

  const handleSubmit = async () => {
    try {
      await mutateAsync(form.name)
      // 对照写法:await refetch()  ← 手动刷新,每个写操作都要记得刷,容易漏
    } catch (e) {
      // 失败提示(企业里这里弹 message.error)
    }
  }
  // ...
}

关键差异与注意点

  • invalidateQueries 的语义是「标记过期 + 触发重新请求」,按 key 前缀匹配 :invalidateQueries({ queryKey: ['users'] }) 会刷新 ['users']、['users', '前端']、['users', kw, page] 等所有条目 ------ 列表和顶部统计数字一起更新。
  • 企业惯例:mutation 的 onSuccess 里做两件事 ------ 提示用户 + 失效相关缓存 ;需要拿返回值时用 mutateAsync(要自己 try/catch),只关心触发时用 mutate。
  • isPending 是 mutation 的「请求中」,v5 里 mutation 不再有 isLoading(那是查询的字段)------ 按钮防重复提交就靠它(见第 12 课)。
  • 对照 Vue:相当于把「action 完成后重新拉列表」从每个调用点搬到了一个统一位置,写操作多了以后差距非常明显。

7. 何时值得上 React Query

  • 值得上:中后台列表页多、需要缓存与条件筛选、有增删改后刷新、需要跨页保留数据 ------ 基本必上。
  • 可以不上 :纯一次性页面(如登录页发一次请求)、SSR 框架(Next.js)里用框架自带方案、极少数据请求的小工具页 ------ useEffect + useState 足够。
  • 上了的代价:多一个 Provider、多一套心智(key 设计)、多约 10KB;换来的是缓存/去重/失效/聚焦重取全部白嫖。
  • 面试答法:「小页面手写够用;列表页一多,手写 hook 在缓存、去重、失效刷新上都是重复劳动且容易出 bug,所以团队选 React Query 把服务端状态标准化。」

📊 Vue ↔ React 对照速查表

Vue 3 React + TanStack Query 一句话差异
app.use(createPinia()) <QueryClientProvider client={qc}> 全局注入一个「缓存仓库」
ref([]) 存列表数据 useQuery({ queryKey, queryFn }) 数据归缓存管,不再手动 setState
watch(keyword, 重新请求) queryKey: ['users', keyword] 参数进 key,框架自动决定何时重取
上次结果被覆盖丢弃 key 变回旧值 → 缓存秒出 这是手写方案给不了的体感
loading ref 手动维护 isLoading / isFetching 区分「首次加载」与「后台刷新」
await 列表接口 手动刷 invalidateQueries({ queryKey }) 声明式失效,按 key 前缀批量刷新
onErrorCaptured 全局兜底 ErrorBoundary + retry 配置 错误处理用组件与配置表达
useDebounceFn 防抖搜索 300ms 防抖后进 key 相同,别忘了再做
无 staleTime / gcTime 新鲜期与缓存保留期,两个时钟
无(Vue 版 React Query 几乎同 API) useMutation + isPending 写操作的标准容器

🏢 企业实战要点

  • queryKey 用工厂函数组织,避免魔法字符串满天飞:
ts 复制代码
// src/api/queryKeys.ts
export const userKeys = {
  all: ['users'] as const,                     // 前缀,用于批量失效
  list: (kw: string, page: number) => ['users', 'list', { kw, page }] as const,
  detail: (id: number) => ['users', 'detail', id] as const,
}
// 用法:queryKey: userKeys.list(keyword, page)
// 失效:queryClient.invalidateQueries({ queryKey: userKeys.all })
  • retry 要调 :默认 3 次 + 指数退避,调试和用户体验都受影响;一般 retry: 1,401/403 不重试。
  • mutation 的 onSuccess 三件套 :成功提示、invalidateQueries 失效相关 key、必要时 reset() 清理表单;别把跳转逻辑塞进 mutation。
  • staleTime 按数据性质分档 :字典/枚举 10 分钟、列表 10 ~ 30 秒、详情 0 ~ 10 秒;全项目用 defaultOptions 配默认值,特殊页面单独覆盖。
  • 防抖之后再进 key,且分页/筛选参数放进 key 时保持「对象形状稳定」,不要一会儿传对象一会儿传散参。
  • 错误边界提一句 :页面级用 ErrorBoundary 兜底,请求级在组件里判 error 展示重试;两者职责不同,别混。
  • 真实坑:enabled: !!id :详情页参数还没就绪时请求就发了,undefined 进 key 变成一条错误缓存;用 enabled 控制「参数齐了才请求」。
  • 真实坑:闭包旧值 :queryFn 里读到的 state 可能是旧值(回调闭包),解决办法是参数全部从 queryKey 传进去、queryFn 接收 ({ queryKey }) 或用 signal 支持取消。
  • 不要把 React Query 的缓存当成 store 用:需要跨页面共享的「客户端状态」(选中行、弹窗状态)仍放 Zustand。

💼 面试高频

1. React Query 解决什么问题?

它管理「服务端状态」:数据在服务端、前端只是快照、会过期。它替你做了缓存、请求去重、失效刷新、窗口聚焦重取、重试、分页/无限加载。手写 useEffect + useState 只解决了 loading/error,这四件事都要自己造轮子且极易出错。

2. queryKey 的设计原则?

key 是缓存的身份,也是失效的依据。原则:① 所有决定数据内容的参数都要进 key(关键字、页码、id、排序);② 结构稳定可序列化,用数组嵌套或对象;③ 用工厂函数/常量统一管理,避免散落的魔法字符串;④ 设计好前缀(如 ['users'])方便批量 invalidateQueries。漏参数会导致「查 A 显示 B」级别的 bug。

3. invalidateQueries 做了什么?和 refetch 的区别?

它把匹配 key 前缀的所有缓存标记为过期(stale),并对当前有组件订阅的查询自动发起重新请求;没有组件在用的查询只标记、等下次挂载再取。refetch 是对单个查询的命令式重取。区别在「声明式 vs 命令式」:前者一次刷新一批、写在 mutation 的 onSuccess 里统一维护;后者每处写操作都要手动调,容易漏。

4. staleTime 和 gcTime 的区别?

staleTime 决定数据多久内是「新鲜」的:新鲜期内重新挂载、窗口聚焦都不会重新请求。gcTime(旧名 cacheTime)决定「没有任何组件使用」后缓存条目在内存里保留多久(默认 5 分钟),之后被垃圾回收。一个是「要不要重新请求」,一个是「缓存还能活多久」。

5. 和手写 hook(第 8 课 useFetch)比,你怎么选择?

一次性请求、无缓存诉求的页面,手写 hook 更轻、依赖更少。列表页多、有筛选分页、需要增删改后刷新的中后台,React Query 明显更省:缓存命中秒开、去重、失效刷新都是配置出来的。我会在团队里定一条边界:服务端状态归 React Query,客户端状态归 useState/Zustand,不混用。

📌 小结 + 下节预告

这课的核心模型只有一句话:把「服务端状态」交给 React Query,用 queryKey 标识缓存、用 staleTime 控制新鲜期、用 mutation + invalidateQueries 串联写操作。加上你已经会的 axios 层与自定义 hook,列表页的「请求」这一半已经齐了。

下一课是收官实战:把前面 11 课串成一个完整的企业级用户管理页(搜索 + 表格 + 分页 + 增删改查 + 弹层表单),并给你一份可以直接带进工作的 code review checklist。

相关推荐
前端架构师-老李1 小时前
HTML与CSS基础第二课—初识HTML
前端·css·html
视***间1 小时前
视程空间Edge工业边缘盒:给工业机器人装一颗耐造的“本地大脑“
前端·edge·机器人·边缘计算·智慧工业·工业视觉·视程空间
Dovis(誓平步青云)1 小时前
一个分区超温就全棚浇水,控制系统怎样按区下策略
开发语言·前端·javascript·人工智能·机器学习
Sailing1 小时前
🚀🚀【封闭结束5】Agent 中的 SSE 流式推送与 HITL 暂停恢复
前端·面试·agent
java1234_小锋1 小时前
【技术专题】CSS3 网页技术 - CSS3 单位、颜色、字体与文本
前端·css·css3
biaov2 小时前
Jenkins + Docker 搭建前端 CI:前端一键打包部署
前端·docker·jenkins
rich_Chan.2 小时前
阿里云百炼联网搜索教程:web_search 工具开启方法、web_extractor 网页抓取与 json 检索轨迹
前端·阿里云·json