第 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。 isLoadingvsisFetching一句话 :首次加载两个都是 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。