前言
在开始前,我想讨论一下请求状态管理的困境是什么?
过于简朴的网络请求
在使用原生的 fetch
请求获取数据时,大家通常会这么写:
useEffect(() => {
fetch('https://api.example.com/students')
.then((res) => res.json())
.then((data) => {
setStudents(data)
})
}, [])
上面的代码虽然很简洁,但只能在 demo 里使用,在真正的生产环境无法接受的,因为你对这个请求没有做任何处理。一旦请求时间过长却没有任何反馈、或者请求出错了但前端没有任何响应,对用户体验来说都是致命打击。
过于繁琐的状态处理
所以在日常开发中,我们会再添加 loading 和错误处理逻辑,示例如下:
'use client'
import { useEffect, useState } from 'react'
interface Category {
id: number
name: string
image: string
}
export default function CategoriesList() {
const [categories, setCategories] = useState<Category[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchCategories = async () => {
try {
setIsLoading(true)
const response = await fetch('https://api.escuelajs.co/api/v1/categories')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setCategories(data.slice(0, 5))
}
catch (err) {
setError(err instanceof Error ? err : new Error('发生未知错误'))
}
finally {
setIsLoading(false)
}
}
// 组件挂载时获取数据
useEffect(() => {
fetchCategories()
}, [])
// 加载状态显示
if (isLoading) {
return (
<div className="container m-auto text-center">
加载中...
</div>
)
}
// 错误状态显示
if (error) {
return (
<div className="container m-auto text-center text-orange-600">
发生错误:
{error.message}
</div>
)
}
return (
<div className="grid grid-cols-5 gap-4 container m-auto">
{categories.map(category => (
<article key={category.id}>
<img src={category.image} alt={category.name} className="size-30 rounded !m-0" />
<p>Clothes</p>
</article>
))}
</div>
)
}
- 为了处理数据加载状态,写了一大堆
isLoading
和error
的判断; - 为了实现数据缓存,不得不手动管理
useState
和useEffect
; - 如果为了处理实时数据更新,我还要写更多更复杂的轮询逻辑...
我只是为了处理一个网络请求的衍生逻辑,但代码已经开始变得复杂化,模板化了,这些重复性的工作不仅降低了开发效率,还增加了代码维护的难度!
直到我遇到了 TanStack Query (原 React Query),它彻底改变了我的开发方式。
一、什么是 TanStack Query?
一个强大的异步状态管理工具,可以让你少些很多样板代码,功能如下:
- 数据获取和缓存:自动管理异步数据的获取和缓存,减少不必要的请求。
- 实时数据更新:支持实时数据更新,通过轮询或 WebSocket 等机制获取最新数据。
- 自动重新获取:当网络恢复或窗口重新获得焦点时,自动重新获取数据。
- 分页和无限加载:支持分页和无限滚动,简化处理大数据集的过程。
- 请求重试:在请求失败时自动重试,增加请求的成功率。
- 错误处理:提供简单的错误处理机制,便于捕获和处理请求错误。
- 查询和变更的分离:明确区分数据获取(查询)和数据变更(变更),使代码更清晰。
- 灵活的查询:支持复杂的查询参数,可以轻松管理不同的数据请求。
- DevTools:提供开发者工具,便于调试和监控数据状态。
与 React Query 的关系
React Query 是 v4 以前的叫法,从 v4 起就叫 TanStack Query。之所以改名字,是因为这个团队这套方案推广到除 React 之外的其他框架中去。到目前(2025年5月)最新的 v5 版本已经支持 React、Vue、Angular、Solid、Svelte 5 大框架。
二、快速入门
官方示例
TanStack Query 官方也提供了一个使用 react-query 获取 React Query GitHub 统计信息的简单示例;可以在 StackBlitz 中打开, 代码如下:
'use client'
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import React from 'react'
// 创建实例
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<Example />
</QueryClientProvider>
)
}
function Example() {
// 发请求
const { isPending, error, data, isFetching } = useQuery({
queryKey: ['repoData'],
queryFn: async () => {
const response = await fetch('https://api.github.com/repos/TanStack/query')
return await response.json()
},
})
// 处理请求正在加载中的状态
if (isPending)
return 'Loading...'
// 处理请求出错的状态
if (error)
return `An error has occurred: ${error.message}`
return (
<div>
<h1>{data.full_name}</h1>
<p>{data.description}</p>
<strong>
{' '}
👀
{data.subscribers_count}
</strong>
<strong>
{' '}
✨
{data.stargazers_count}
</strong>
<strong>
{' '}
🍴
{data.forks_count}
</strong>
<div>{isFetching ? 'Updating...' : ''}</div>
</div>
)
}
在上面的例子中,给我们展示了 TanStack Query 最核心的几个 API:
QueryClient
用于管理和配置查询的行为。QueryClientProvider
是使用 TanStack Query 的起点,也就是第一步,我们必须要通过QueryClient
创建一个实例并传入到QueryClientProvider
中。useQuery
获取数据,当加载数据时,我们可以通过isPending
属性来判断是否数据正在加载中,从而去展示加载时的 UI。其中,我们向useQuery
中传入了queryKey
和queryFn
,queryKey
用来作为该查询的标识,而queryFn
对应为获取数据的函数。
三、基本用法
在 TanStack Query 中,创建查询非常简单。我们使用 useQuery
钩子来发起数据请求。这个钩子接受一个配置对象,其中包含查询键(queryKey)和查询函数(queryFn)。
const { data, isLoading, error } = useQuery({
queryKey: any[],
queryFn: ()=> Promise<any>,
})
主要参数说明:
queryKey
:查询的唯一标识符,用于缓存管理queryFn
:实际获取数据的异步函数 (必须返回一个 Promise)enabled
:控制查询是否自动执行staleTime
:数据保持新鲜的时间cacheTime
:数据在缓存中保留的时间
关于 queryFn
queryFn
总是返回一个 Promise,因为 query 并不在乎 queryFn
中是否有一个真实的网络请求,query 只在乎 Promise 的状态。
如果用浏览器的 fetch
来编写网络请求,queryFn
看起来是这样的:
queryFn: async () => {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Error status: ${response.status}`)
}
return response.json()
}
不需要在这里嵌套 try-catch
,直接 throw Error 是为了让 query 知道发生了错误。
关于 queryKey
// id/排序/页码/每页条数/查询关键字 等等都应该放到 query-key 中
useQuery({ queryKey: ['post', id, sort, page, limit, keyword] })
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: [{ status, page }, 'todos'], ...})
queryKey
是一个数组,通常首位是对状态的简要描述。- 请求相关的参数都应该放到
queryKey
数组中,每当queryKey
变更就会重新执行查询函数。 - 如果
queryKey
命中缓存中的 key,会直接返回上次的缓存结果,跳过了阻塞用户操作的loading
,提升用户体验。 queryKey
也许会让人联想到useEffect
的依赖列表,但不完全相同, useQuery 会基于queryKey
计算 hash 值,所以你可以大胆的在queryKey
里使用对象和数组,无视对象键值对的顺序。
简单示例
让我们看一个实际的例子,展示如何使用 useQuery
获取用户数据:
loading...
'use client'
import { useQuery } from '@tanstack/react-query'
import { Input } from 'antd'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
interface User {
id: number
email: string
username: string
address: {
city: string
street: string
number: number
zipcode: string
}
phone: string
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`https://fakestoreapi.com/users/${id}`)
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
}
function UserDetail({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // 只有当 userId 存在时才执行查询
})
if (isLoading)
return <p>loading...</p>
if (error) {
return <p className=" text-red-600 p-4 border-red-400">{error.message}</p>
}
if (!data)
return <p className=" text-orange-600 p-4 border-orange-400"> No data available</p>
return (
<div className="border p-6 flex flex-col gap-2 rounded-md">
<div className="font-semibold">
👩🚀
{data.username}
</div>
<div>
<p>
📩 :
{data.email}
</p>
<p>
📲 :
{data.phone}
</p>
<p>
<span> 📇 :</span>
{data.address.street}
{data.address.number}
,
{data.address.city}
</p>
</div>
</div>
)
}
export default function QueryUser() {
const [inputValue, setInputValue] = useState<string>('1') // 输入框的值
const [userId, setUserId] = useState<string>('1') // 实际查询的用户ID
const handleSearch = () => {
if (inputValue.trim()) {
setUserId(inputValue.trim())
}
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Input value={inputValue} onChange={e => setInputValue(e.target.value)} />
<Button onClick={handleSearch} size="icon" variant="outline">
<Search className="h-4 w-4" />
</Button>
</div>
<div className="w-80">
<UserDetail userId={userId} />
</div>
</div>
)
}
创建变更请求
除了查询数据,我们经常还需要创建、修改、更新、删除数据或执行服务器端副作用。为此,TanStack Query 提供了 useMutation 钩子来处理这些数据变更操作。
什么是 Mutation?
所有状态管理工具本质上都是在做两件事: 一个是 获取数据
,一个是 修改数据
。
比如 React 中的 useState
hook,顾名思义,它本质也是一种状态管理。
const [state, setState] = useState(initialState)
其中 state
是获取数据,setState
用于修改数据。
在 TanStack Query 中,数据获取由 useQuery 负责,而数据修改则由 useMutation 处理。
我个人理解,就是为
POST
/PUT
/DELETE
等请求提供了一个更方便的钩子。
使用 useMutation 钩子
useMutation
钩子的基本结构如下:
const { mutate, isPending, isSuccess, isError } = useMutation({
mutationFn: () => {
return axios.post("/api/user", { name: "jack" })
},
onMutate: () => {
console.log("开始变更")
},
onSuccess: () => {
console.log("变更成功")
},
onError: () => {
console.log("变更失败")
},
onSettled: () => {
console.log("变更完成(成功或失败)")
}
})
主要参数说明:
mutationFn
:执行数据变更的函数onSuccess
:变更成功后的回调onError
:发生错误时的回调onSettled
:无论成功失败都会执行的回调
为什么不用 axios.post()
而使用 useMutation
?
因为useMutation
可以让我们更方便获取状态和拓展功能:
- 内建状态:如
isPending
、isSuccess
、isError
; - 自动重试机制:支持配置
retry
; - 生命周期回调:可以处理 UI 提示、缓存更新等逻辑;
- 和
QueryClient
紧密集成,可直接操作缓存。
- 如果
mutationFn
中返回的 Promise 为reject
状态,那么就算接口请求成功了,onSuccess
也不会触发。 - useMutation 的 throwOnError 参数可以控制是否抛出错误,但默认是
false
- 换句话说,如果
onSuccess
一直不触发,请将 throwOnError 设为 true,查看是否报错。
下面是一个使用 useMutation
创建新用户的例子:
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const userSchema = z.object({
email: z.string().email('Invalid email address'),
username: z.string().min(1, 'Username is required'),
password: z.string().min(6, 'Password must be at least 6 characters'),
name: z.object({
firstname: z.string().min(1, 'First name is required'),
lastname: z.string().min(1, 'Last name is required'),
}),
address: z.object({
city: z.string().min(1, 'City is required'),
street: z.string().min(1, 'Street is required'),
number: z.coerce.string().min(6, 'Street number is required'),
zipcode: z.string().min(1, 'Zipcode is required'),
geolocation: z.object({
lat: z.string().min(1, 'Latitude is required'),
long: z.string().min(1, 'Longitude is required'),
}),
}),
phone: z.string().min(1, 'Phone is required'),
})
export type UserFormData = z.infer<typeof userSchema>
async function createUser(user: UserFormData): Promise<UserFormData & { id: number }> {
const response = await fetch('https://fakestoreapi.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
})
if (!response.ok) {
throw new Error('Network response was not ok')
}
return response.json()
}
export default function CreateUserForm() {
const queryClient = useQueryClient()
const form = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
address: {
geolocation: {
lat: '-37.3159',
long: '81.1496',
},
city: 'kilcoole',
street: 'new road',
number: '7682',
zipcode: '12926-3874',
},
email: 'john@gmail.com',
username: 'johnd',
password: 'm38rmF$',
name: {
firstname: 'john',
lastname: 'doe',
},
phone: '1-570-236-7033',
},
})
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser: UserFormData) => {
console.log('🚀 ~ CreateUserForm ~ newUser:', newUser)
queryClient.invalidateQueries({ queryKey: ['users'] })
toast.success('User created successfully!')
form.reset()
},
onError: (error) => {
toast.error(`Failed to create user: ${error.message}`)
},
})
const handleSubmit = (values: UserFormData) => {
mutation.mutate(values)
}
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader>
<CardTitle>Create New User</CardTitle>
<CardDescription>Fill in the details to create a new user account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Basic Information</h3>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="Enter email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Enter username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="name.firstname"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name.lastname"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>Phone</FormLabel>
<FormControl>
<Input placeholder="Enter phone number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Address Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Address Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="address.city"
render={({ field }) => (
<FormItem>
<FormLabel>City</FormLabel>
<FormControl>
<Input placeholder="Enter city" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address.street"
render={({ field }) => (
<FormItem>
<FormLabel>Street</FormLabel>
<FormControl>
<Input placeholder="Enter street" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="address.number"
render={({ field }) => (
<FormItem>
<FormLabel>Street Number</FormLabel>
<FormControl>
<Input placeholder="Enter street number" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address.zipcode"
render={({ field }) => (
<FormItem>
<FormLabel>Zipcode</FormLabel>
<FormControl>
<Input placeholder="Enter zipcode" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="address.geolocation.lat"
render={({ field }) => (
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input placeholder="Enter latitude" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address.geolocation.long"
render={({ field }) => (
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input placeholder="Enter longitude" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button type="submit" className="w-full" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating User...' : 'Create User'}
</Button>
</form>
</Form>
</CardContent>
</Card>
)
}
这个例子展示中:
- 使用
useMutation
创建数据变更操作 - 处理成功和错误情况
- 在成功后更新缓存
- 在表单提交时触发变更
- 显示加载状态
通过这些基本用法,你可以开始使用 TanStack Query 来处理大多数数据获取和变更的场景。
四、查询状态管理
在 TanStack Query 中,每个查询都有其状态,这些状态可以帮助我们更好地管理数据加载、错误处理和用户体验。让我们深入了解这些状态及其使用方法。
查询状态
TanStack Query 提供了多个状态标志来帮助我们了解查询的当前状态:
const {
// 返回的数据
data, // 默认为 undefined,查询最后一次成功解析的数据
dataUpdatedAt, // 查询最近一次返回 "success" 状态的时间戳
error, // 默认为 null,查询抛出的错误对象
errorUpdatedAt, // 查询最近一次返回 "error" 状态的时间戳
failureCount, // 查询失败的次数,每次失败递增,成功时重置为 0
failureReason, // 查询重试的失败原因,成功时重置为 null
fetchStatus, // 获取状态:'fetching'(正在执行) | 'paused'(已暂停) | 'idle'(空闲)
isError, // 从 status 派生的布尔值,表示是否发生错误
isFetched, // 查询是否已被获取过
isFetchedAfterMount, // 查询是否在组件挂载后被获取过,可用于不显示任何缓存的旧数据
isFetching, // 从 fetchStatus 派生的布尔值,表示是否正在获取数据
isInitialLoading, // 已废弃,将在下一个大版本中移除,是 isLoading 的别名
isLoading, // 查询首次获取是否正在进行中,等同于 isFetching && isPending
isLoadingError, // 查询首次获取时是否失败
isPaused, // 从 fetchStatus 派生的布尔值,表示查询是否被暂停
isPending, // 从 status 派生的布尔值,表示是否处于 pending 状态
isPlaceholderData, // 显示的数据是否为占位数据
isRefetchError, // 查询重新获取时是否失败
isRefetching, // 后台重新获取是否正在进行中,等同于 isFetching && !isPending
isStale, // 缓存中的数据是否已失效或超过 staleTime
isSuccess, // 从 status 派生的布尔值,表示是否成功获取数据
promise, // 一个稳定的 Promise,将解析为查询的数据(需要启用 experimental_prefetchInRender 特性)
refetch, // 手动重新获取查询的函数,可配置 throwOnError 和 cancelRefetch 选项
status, // 查询状态:'pending'(无缓存数据且查询未完成) | 'error'(查询出错) | 'success'(查询成功)
} = useQuery(
{
queryKey, // 查询的唯一键,用于缓存和重新获取
queryFn, // 用于请求数据的函数
gcTime, // 未使用/非活动缓存数据在内存中保留的时间(以毫秒为单位)
enabled, // 是否自动执行查询
networkMode, // 网络模式:'online' | 'always' | 'offlineFirst'
initialData, // 初始数据,在查询创建或缓存前使用,默认被视为过期数据
initialDataUpdatedAt, // 初始数据最后更新的时间戳(毫秒)
meta, // 可存储查询相关的额外信息,可在查询可用处访问
notifyOnChangeProps, // 指定哪些属性变化时触发重新渲染
placeholderData, // 查询处于 pending 状态时使用的占位数据,不会持久化到缓存
queryKeyHashFn, // 自定义查询键的哈希函数
refetchInterval, // 自动重新获取的时间间隔(毫秒)
refetchIntervalInBackground, // 在后台时是否继续自动重新获取
refetchOnMount, // 组件挂载时是否重新获取
refetchOnReconnect, // 网络重连时是否重新获取
refetchOnWindowFocus, // 窗口获得焦点时是否重新获取
retry, // 失败重试次数
retryOnMount, // 组件挂载时是否重试失败的查询
retryDelay, // 重试延迟时间(毫秒)
select, // 数据转换函数,用于在返回数据前转换数据
staleTime, // 数据保持新鲜的时间(毫秒)
structuralSharing, // 是否启用结构共享,默认为 true
subscribed, // 是否订阅缓存更新,默认为 true
throwOnError, // 是否在渲染阶段抛出错误并传播到最近的错误边界
},
queryClient, // 自定义 QueryClient 实例,否则使用最近上下文中的实例
)
上面这段代码就是 useQuery 的基本结构,也标注了详细的注释,这里就不再赘述了!
状态更新和缓存
TanStack Query 提供了多种方式来管理查询状态和缓存:
const queryClient = useQueryClient();
// 手动更新缓存
queryClient.setQueryData(['todos'], (oldData) => {
return oldData.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
})
// 使查询失效并重新获取
queryClient.invalidateQueries({ queryKey: ['todos'] })
// 预取数据
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
状态同步
当多个组件使用相同的查询时,它们会自动共享状态:
// ComponentA.tsx
function ComponentA() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
// ComponentB.tsx
function ComponentB() {
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// 自动共享 ComponentA 的数据和状态,如果不想使用 ComponentA 共享的数据和状态,可以继续添加 queryKey
}
通过合理使用这些状态管理特性,我们可以构建出更加健壮和用户友好的应用程序
🧚♀️ 数据预加载
预加载是一种 "提前加载" 数据的策略,通常用于用户即将访问的数据页面,以减少等待时间、提升交互体验。
为什么要预加载?
以一个典型的书籍列表和详情页为例:
- 用户访问列表页,加载所有书籍标题。
- 点击任意书名进入详情页。
- 详情页再次发起请求,加载书籍详情。
- 回到列表页时重新发起请求,确保数据最新。
- 用户再次点击书名,又重新加载详情。
存在的问题:
- 每次切换页面都需要等待数据加载。
- 用户快速点击,体验受阻。
- 已访问页面的数据重复请求,浪费资源。
优化策略:
- 如果对数据实时性要求没有特别高的话,请求过的详情页和列表页可以缓存起来,不需要每次都重新请求。
- 当用户鼠标悬浮在列表项上时,可以预先请求数据,等用户点击列表项时,数据已经请求回来并放到缓存中,这样就减少了用户的等待时间。
使用 prefetchQuery 实现预加载
React Query 提供 queryClient.prefetchQuery()
方法来执行预加载。
import { useQueryClient } from "@tanstack/react-query"
const queryClient = useQueryClient();
const handlePrefetch = (id: number) => {
queryClient.prefetchQuery({
queryKey: ["book", id],
queryFn: () => fetchBookDetail(id),
staleTime: 60 * 1000 // 缓存1分钟,避免频繁请求
})
}
将其绑定到鼠标事件中:
<li onMouseEnter={() => handlePrefetch(book.id)}>
{book.title}
</li>
- 通过
onMouseEnter
事件来触发预加载 - 通过
useQueryClient
来获取queryClient.prefetchQuery
方法来执行预加载。
注意
记得为 prefetchQuery
提供 staleTime
参数,它表示缓存数据的有效时间,能有效避免过于频繁的网络请求。
使用 placeholderData 占位数据
预加载虽好,但并不能保证用户点击前数据一定已返回。如果用户操作特别快,仍可能看到 loading 状态。
如果我们在列表页已加载过部分基础信息,比如书名、分类、出版社信息,那么详情页完全可以先显示这些内容作为 "占位符"。
示例:使用 getQueryData
设置占位数据
const useBookDetail = (id: number) => {
const queryClient = useQueryClient()
return useQuery({
queryKey: ["book", id],
queryFn: () => fetchBookDetail(id),
placeholderData: () => {
const list = queryClient.getQueryData<{ id: number }[]>(["book"])
return list?.find((item) => item.id === id)
}
})
}
isPlaceholderData
标识状态:
React Query 会返回 isPlaceholderData
标识当前数据是否为占位符,我们可以据此渲染不同 UI:
{
isPlaceholderData ?
<p>loading...</p>
:
<p>{data.description}</p>
}
五、数据缓存与更新
TanStack Query 的核心特性之一是其强大的缓存机制。它不仅能自动管理缓存,还提供了多种方式来手动控制缓存数据。
为什么需要缓存?
在实际业务开发中,频繁的网络请求不仅会增加服务器负担,还会影响用户体验。而很多场景下的数据其实并不需要实时更新,我们可以合理设置缓存时间来降低请求频率。
常见的例子包括:
- 📈 基金收益明细:通常每天只更新一次;
- 👨🏻💻 用户头像:只有用户手动修改时才会变化;
- 💬 评论列表:如果对实时性要求不高,半小时更新一次也是可以接受的。
缓存机制
TanStack Query 使用查询键(Query Key)来标识和存储缓存数据。当使用相同的查询键时,数据会被自动缓存和共享。
// 使用相同的查询键,数据会被共享
const { data: user1 } = useQuery({
queryKey: ['user', 1],
queryFn: () => fetchUser(1)
})
const { data: user2 } = useQuery({
queryKey: ['user', 1],
queryFn: () => fetchUser(1)
}) // user2 将使用 user1 的缓存数据
如何设置缓存的过期时间 ?
手动管理缓存的生命周期是非常复杂且容易出错的事情,在 TanStack Query 中,我们可以通过 staleTime
和 gcTime
轻松控制数据的缓存行为:
staleTime
表示数据多久之后会被视 "过期"。在这段时间内,如果再次触发该请求,会直接使用缓存数据,而不会重新发起请求。gcTime
表示数据在缓存中保留的时间。
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000, // 数据在 5 分钟内保持新鲜
gcTime: 10 * 60 * 1000, // 未使用的数据在 10 分钟后被垃圾回收
})
建议
- 默认情况下,
staleTime
为0
,意味着数据在获取后立即进入 "过期" 状态,每次都会重新请求。 - 缓存时间的设置应该根据业务实际情况与产品、后端、测试同学沟通确定,避免拍脑袋式的判断,否则可能导致缓存失效或数据不同步的问题。
手动更新缓存
TanStack Query 提供了多种方式来手动更新缓存数据:
- 使用
setQueryData
直接更新:
const queryClient = useQueryClient();
// 更新单个查询的缓存
queryClient.setQueryData(['todos'], (oldData) => {
return oldData.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
})
- 使用
invalidateQueries
使缓存失效:
// 使特定查询的缓存失效
queryClient.invalidateQueries({ queryKey: ['todos'] })
// 使多个相关查询的缓存失效
queryClient.invalidateQueries({ queryKey: ['todos', 'lists'] })
- 使用
prefetchQuery
预取数据:
// 预取数据到缓存
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
- 利用回调管理缓存
可以通过 onSuccess
、onError
等回调在变更完成后更新缓存:
const { mutate } = useMutation({
mutationFn: () => axios.post("/api/user", { name: "jack" })
onSuccess: (newUser) => {
toast.success("用户创建成功");
// 方法 1:创建用户成功后,更新缓存中的用户列表
queryClient.setQueryData(["users", newUser.id], newUser)
// 方法 2:创建用户成功后,让之前的缓存数据失效,并重新获取数据(即刷新缓存)
queryClient.invalidateQueries({ queryKey: ["users"] })
}
})
缓存什么时候会自动更新?
需要注意的是,即使缓存已经过期,react-query 也不会立即重新请求,只有在满足以下几种情况时才会触发自动更新:
queryKey
发生变化:例如分页列表中页码变化 (如:['todo', page]
);- 组件重新挂载并订阅了该 query: 例如一个引用了过期缓存的 Modal, Modal 重新渲染时会触发更新缓存;
- 浏览器窗口重新获取焦点:用户切换标签页返回后;
- 设备网络断开后重新连接
如何关闭自动更新行为?
TanStack Query 默认启用上述刷新机制,但在某些情况下你可能希望手动控制更新行为。
其中三种情况可以手动关闭:
useQuery({
queryKey: ['todo', sort],
queryFn: () => fetchTodo(sort),
staleTime: 30 * 1000, // 缓存30秒
refetchOnMount: false, // 组件挂载时不自动刷新
refetchOnWindowFocus: false, // 聚焦窗口时不刷新
refetchOnReconnect: false, // 网络重连时不刷新
})
- 你也可以通过将
staleTime
设置为Infinity
来永不过期,除非手动触发刷新。
示例:展示数据及更新时间,并手动刷新
下面是一个小示例,展示如何使用缓存和更新逻辑:
- 使用 useQuery 获取数据;
- 设置
staleTime
为 30 秒; - 显示当前数据和上次更新时间;
- 5 秒后出现 「刷新数据」 按钮,用户可以手动触发更新;
- 观察
isStale
(数据是否过期)与 isFetching(是否正在请求)状态。
'use client'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
interface Todo {
id: number
title: string
completed: boolean
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5')
if (!response.ok) {
throw new Error('网络请求失败')
}
return response.json()
}
/**
* 待办事项列表组件
*/
export default function TodoList() {
const {
data: todos,
isLoading,
isError,
error,
refetch,
isFetching,
isStale,
dataUpdatedAt,
} = useQuery<Todo[], Error>({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5000,
})
if (isLoading) {
return <div className="text-orange-500">加载中...</div>
}
if (isError) {
return (
<div className="text-red-500">
错误:
{error.message}
</div>
)
}
return (
<div>
<h3>TODO List</h3>
{/* 当数据过期且不在获取中时显示刷新按钮 */}
{isStale && !isFetching && (
<Button type="button" variant="outline" onClick={() => refetch()} style={{ marginBottom: '1rem' }}>
刷新数据
</Button>
)}
{/* 如果正在获取新数据,显示加载提示 */}
{isFetching && <div className="text-blue-500">正在更新数据...</div>}
<p className="text-xs">
数据更新于
{new Date(dataUpdatedAt).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</p>
<ul>
{todos?.map(todo => (
<li key={todo.id}>
<input type="checkbox" defaultChecked={todo.completed} />
<span className="ml-2">{todo.title}</span>
</li>
))}
</ul>
</div>
)
}
乐观更新
乐观更新是一种提升用户体验的技术,它假设更新会成功,立即更新 UI,然后在后台进行实际的更新操作:
以下这些场景非常适合使用乐观更新:
- 点赞 / 收藏
- 关注 / 取关
- 评论 / 删除
- 拖拽排序等即时 UI 响应操作
实现思路 onMutate + 回滚
onMutate: async ({ targetData }) => {
const snapshot = queryClient.getQueryData(queryKey)
// 乐观更新
queryClient.setQueryData(queryKey, (oldData) => {
return newData;
})
// 返回一个回滚函数
return () => queryClient.setQueryData(queryKey, snapshot)
},
onError: (err, variables, rollback) => {
// rollback 就是 onMutate 的返回值,也就是回滚方法。
rollback?.()
},
onSettled: () => {
// 无论接口成功与否,都会执行。我们可以在里面调用 `invalidateQueries`,刷新缓存。
queryClient.invalidateQueries({ queryKey })
}
示例
import { Button } from 'antd'
import { useMutation, useQueryClient } from '@tanstack/react-query'
interface Todo {
id: number
title: string
completed: boolean
}
const updateTodo = async (newTodo: Todo) => {
// Implement the logic to update a todo item
// For example, you can make a POST request to a server
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo)
})
return response.json()
};
export default function TodoList() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 取消任何传出的重新获取
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 保存之前的数据
const previousTodos = queryClient.getQueryData(['todos'])
// 乐观更新
queryClient.setQueryData(['todos'], (old: Todo[]) => [...old, newTodo])
// 返回上下文对象
return { previousTodos }
},
onError: (err, _, context) => {
console.log('🚀 ~ TodoList ~ err:', err)
// 如果发生错误,回滚到之前的数据
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
onSettled: () => {
// 无论成功或失败,都重新获取数据
queryClient.invalidateQueries({ queryKey: ['todos'] })
},
});
return (
<div>
{mutation.isPending ? (
'Adding todo...'
) : (
<>
{mutation.isError ? (
<div>An error occurred: {mutation.error.message}</div>
) : null}
{mutation.isSuccess ? <div>Todo added!</div> : null}
<Button
onClick={() => {
mutation.mutate({ id: Date.now(), title: 'New Todo', completed: false });
}}
>
Create Todo
</Button>
</>
)}
</div>
)
}
封装通用 Hook
若你项目中多次使用乐观更新,可以封装一个统一的 Hook。
但我觉得一个项目中涉及到乐观更新的地方其实并不多,基于
useMutate
调用onMutate
,onError
,onSettled
等方法,可以很方便的实现乐观更新。封装了 Hook 后,反而增加了代码的复杂度。
type UseOptimisticMutationParams<TData = unknown> = {
mutationFn: MutationFunction<TData, any>
queryKey: QueryKey
updater: (oldData: any) => any
invalidates?: QueryKey
};
export const useOptimisticMutation = <TData>({
mutationFn,
queryKey,
updater,
invalidates = queryKey,
}: UseOptimisticMutationParams<TData>) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn,
onMutate: async () => {
// 取消任何相关请求,避免覆盖结果
await queryClient.cancelQueries({ queryKey })
const snapshot = queryClient.getQueryData(queryKey)
queryClient.setQueryData(queryKey, updater)
return () => queryClient.setQueryData(queryKey, snapshot)
},
onError: (err, variables, rollback) => {
rollback?.()
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: invalidates })
}
})
}
缓存持久化
在某些情况下,我们可能需要将缓存数据持久化到本地存储:
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
// 创建持久化存储
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
// 配置持久化
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24 // 24 小时
});
缓存调试
TanStack Query 提供了开发者工具来帮助调试缓存,@tanstack/react-query-devtools
就是专门来做调试的,在配置之前,需要先安装:
pnpm add @tanstack/react-query-devtools
在入口文件的配置如下:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
function App() {
return (
<>
{/* 你的应用组件 */}
<ReactQueryDevtools initialIsOpen={false} />
</>
);
}
配置成功后,默认会在网页的右下角显示一个按钮,效果如下:
通过合理使用这些缓存和更新机制,我们可以:
- 减少不必要的网络请求
- 提供更好的用户体验
- 实现离线功能
- 优化应用性能
六、处理分页和无限滚动
在实际开发中,我们不会一次性把全部数据请求回来,而是进入不同页码时才请求对应的数据。在处理大量数据时,分页和无限滚动是两种常用的数据加载方式。TanStack Query 提供了专门的钩子来处理这些场景。
分页查询
在日常开发中,分页接口通常接受 page、limit 等参数,并返回如下结构的数据:
{
"cur_page": 1,
"total": 500,
"data": [...]
}
最直观的实现是为每一页使用不同的 queryKey
,例如:
useQuery({
queryKey: ['books', page],
queryFn: () => fetchBooks(page)
})
示例演示
'use client'
import type { TablePaginationConfig } from 'antd/es/table'
import type { PaginationParams, ProductsResponse } from './types'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Card, Space, Spin, Table, Tag } from 'antd'
import { useState } from 'react'
async function fetchProducts({ page, limit }: PaginationParams): Promise<ProductsResponse> {
const skip = (page - 1) * limit
const response = await fetch(
`https://dummyjson.com/products?limit=${limit}&skip=${skip}`,
)
if (!response.ok) {
throw new Error('Failed to fetch products')
}
return response.json()
}
export default function ProductList() {
const [pagination, setPagination] = useState<PaginationParams>({
page: 1,
limit: 10,
})
const { data, isPending, isError, error } = useQuery({
queryKey: ['products', pagination],
queryFn: () => fetchProducts(pagination),
placeholderData: keepPreviousData,
})
const handleTableChange = (newPagination: TablePaginationConfig) => {
setPagination({
page: newPagination.current || 1,
limit: newPagination.pageSize || 10,
})
}
if (isPending) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
)
}
if (isError) {
return (
<Alert
type="error"
message="Error"
description={error.message}
showIcon
/>
)
}
return (
<div className=" container mx-auto py-10">
<Card title="Product List">
<Table
columns={[
{
title: 'Thumbnail',
dataIndex: 'thumbnail',
key: 'thumbnail',
render: thumbnail => (
<img src={thumbnail} alt="product" style={{ width: 80, height: 80, objectFit: 'contain' }} />
),
},
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: title => (
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</div>
),
},
{
title: 'Price',
dataIndex: 'price',
key: 'price',
render: (price, record) => (
<Space direction="vertical" size={0}>
<span style={{ color: '#ff4d4f', textDecoration: 'line-through' }}>
$
{(price * (1 + record.discountPercentage / 100)).toFixed(2)}
</span>
<span style={{ fontWeight: 'bold' }}>
$
{price}
</span>
</Space>
),
},
{
title: 'Category',
dataIndex: 'category',
key: 'category',
render: category => (
<Tag color="blue">{category}</Tag>
),
},
]}
dataSource={data?.products}
rowKey="id"
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: data?.total,
showSizeChanger: true,
showTotal: total => `Total ${total} items`,
}}
onChange={handleTableChange}
loading={isPending}
/>
</Card>
</div>
)
}
行上图的右侧 network
面板也能看到,每页请求 10
条数据,已经访问过的页码,不会再次重新请求,主要配置就是 placeholderData
和 keepPreviousData
!
无限滚动
无限滚动场景常用于用户滑动到底部时自动加载下一页,例如博客、商品列表、评论区等。
对于无限滚动,TanStack Query 提供了 useInfiniteQuery
钩子。这个钩子专门用于处理无限加载的数据。
基本用法
const { data, hasNextPage, fetchNextPage } = useInfiniteQuery({
queryKey: ['books', sort],
initialPageParam: 1,
queryFn: ({ pageParam = 1 }) => fetchBooks(sort, pageParam),
getNextPageParam: (lastPage) => {
return lastPage.cur_page < lastPage.total
? lastPage.cur_page + 1
: undefined
}
})
渲染分页数据:
<ul>
{data.pages.map((page) =>
page.data.map((item) => <li key={item.id}>{item.name}</li>)
)}
</ul>
<button onClick={() => fetchNextPage()} disabled={!hasNextPage}>
下一页
</button>
注意
上述定义是不准确的,因为还有一种可能是用户向上滚动时,自动加载上一页的数据。所以无限滚动需要支持双向获取数据(比如获取聊天记录)记得为 prefetchQuery
提供 staleTime
参数,它表示缓存数据的有效时间,能有效避免过于频繁的网络请求。
支持双向加载
需要同时实现 getPreviousPageParam
:
useInfiniteQuery({
queryKey: ["books", sort],
initialPageParam: { page: 1 },
queryFn: ({ pageParam = 1 }) => fetchBooks(sort, pageParam),
getNextPageParam: (lastPage) => {
const nextPage = lastPage.cur_page + 1
return nextPage <= lastPage.total ? { page: nextPage } : undefined
},
getPreviousPageParam: (firstPage) => {
const prevPage = firstPage.cur_page - 1
return prevPage >= 1 ? { page: prevPage } : undefined
}
})
注意
- 此时
useInfiniteQuery
返回值的data
不再不是queryFn
返回的数据,而是包含多个分页内容的pages
数组。 pages
表示目前已获取的所有页的数据的集合。useInfiniteQuery
返回的refetch
将会重新请求所有分页数据,而非仅刷新当前页。- 这是因为当某一页数据发生变更,可能后续(或前面)的每一页都随着发生变更。
示例演示
在实现无限滚动时,我们通常需要检测元素是否进入视口(viewport),这时就需要用到 react-intersection-observer
这个库。
react-intersection-observer
是一个 React 组件和钩子,用于检测元素是否进入视口。它基于 Intersection Observer API,提供了一种简单的方式来监控元素与视口的交叉状态。
主要特点:
- 使用简单:提供了
useInView
钩子,使用起来非常方便 - 性能好:基于原生的 Intersection Observer API,性能开销小
- 可配置:支持多种配置选项,如阈值、根元素等
- 跨浏览器兼容:自动处理浏览器兼容性问题
基本用法:
import { useInView } from 'react-intersection-observer';
function MyComponent() {
const { ref, inView } = useInView({
threshold: 0, // 触发阈值,0 表示元素刚进入视口就触发
triggerOnce: false, // 是否只触发一次
});
return (
<div ref={ref}>
{inView ? '元素在视口中' : '元素不在视口中'}
</div>
);
}
在我们的无限滚动实现中,react-intersection-observer
用于检测加载更多触发器是否进入视口,从而触发加载下一页数据的操作。
下面是一个使用 useInfiniteQuery
和 react-intersection-observer
实现的无限滚动商品列表:
'use client'
import type { ProductsResponse } from './types'
import { useInfiniteQuery } from '@tanstack/react-query'
import { Alert, Card, Image, List, Rate, Space, Spin, Tag, Typography } from 'antd'
import { useEffect } from 'react'
import { useInView } from 'react-intersection-observer'
const { Text, Paragraph } = Typography
async function fetchProducts({ pageParam = 0 }): Promise<ProductsResponse> {
const limit = 10
const skip = pageParam * limit
const response = await fetch(
`https://dummyjson.com/products?limit=${limit}&skip=${skip}`,
)
if (!response.ok) {
throw new Error('Failed to fetch products')
}
return response.json()
}
export default function InfiniteProductList() {
const { ref, inView } = useInView()
const {
data,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['products'],
queryFn: fetchProducts,
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const { skip, limit, total } = lastPage
const nextPage = skip + limit
return nextPage < total ? nextPage / limit : undefined
},
})
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage()
}
}, [inView, fetchNextPage, hasNextPage])
if (status === 'pending') {
return (
<div className="text-center p-12.5">
<Spin size="large" />
</div>
)
}
if (status === 'error') {
return <Alert type="error" message="Error" description={error.message} showIcon />
}
return (
<Card title="Infinite Product List">
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 3, xl: 4, xxl: 4 }}
dataSource={data.pages.flatMap(page => page.products)}
renderItem={product => (
<List.Item>
<Card
hoverable
cover={(
<div style={{ padding: '20px', textAlign: 'center', background: '#fafafa' }}>
<Image
alt={product.title}
src={product.thumbnail}
style={{ height: 200, objectFit: 'contain' }}
preview={{
src: product.images[0],
}}
/>
</div>
)}
>
<Card.Meta
title={(
<Paragraph
ellipsis={{ rows: 2 }}
style={{ marginBottom: 8 }}
>
{product.title}
</Paragraph>
)}
description={(
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Space>
<Tag color="blue">{product.brand}</Tag>
<Tag color="green">{product.category}</Tag>
</Space>
<Space direction="vertical" size={0}>
<Text type="secondary" delete>
$
{(product.price * (1 + product.discountPercentage / 100)).toFixed(2)}
</Text>
<Text strong style={{ fontSize: 16 }}>
$
{product.price}
</Text>
</Space>
<Space>
<Rate disabled defaultValue={product.rating} allowHalf />
<Text type="secondary">
(
{product.rating}
)
</Text>
</Space>
<Tag color={product.stock > 10 ? 'green' : product.stock > 0 ? 'orange' : 'red'}>
{product.stock > 0 ? `In Stock (${product.stock})` : 'Out of Stock'}
</Tag>
</Space>
)}
/>
</Card>
</List.Item>
)}
/>
<div ref={ref} className="mt-5 h-12 flex items-center justify-center">
{isFetchingNextPage
? <Spin />
: hasNextPage
? 'Load More'
: 'No More Products'}
</div>
</Card>
)
}
当滚动条划到底部的时就自动去获取下一页的数据,如下图:
分页与无限滚动的选择
选择使用分页还是无限滚动取决于具体的应用场景:
- 分页适用于:
- 需要精确控制每页显示数量的场景
- 需要快速跳转到特定页面的场景
- 数据量相对较小且结构化的场景
- 无限滚动适用于:
- 内容流式的场景(如社交媒体)
- 需要持续加载更多内容的场景
- 移动端应用
性能优化建议
1. 使用 placeholderData
保留上一页数据
似乎平常的 useQuery
就可以解决分页的需求,但每次翻页获取新数据都会因为 key 的改变,使 data
变成空数据,导致页面抖动。
placeholderData
支持接收上一次的数据作为参数,这使得我们可以在加载新页时显示上一页内容,从而避免空白闪动。
我们可以利用这一点:让加载新数据时不再直接返回空数据,而是保留上一页的数据,配合一个简单的半透明滤镜效果表明正在加载就可以在保证有响应的同时减少页面抖动:
const { data, isPlaceholderData } = useQuery({
queryKey: ['books', page],
queryFn: () => fetchBooks(page),
placeholderData: (prev) => prev
})
结合简单样式处理即可实现 「渐隐加载」 的效果:
<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data.data.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
2. 禁用切页按钮以避免异常行为
为了防止重复请求或越界请求,通常我们会想在新数据加载时禁用翻页按钮:
const { isPlaceholderData } = useBooksQuery(sort, page)
const prevDisabled = page === 1 || isPlaceholderData
const nextDisabled = page === data.total || isPlaceholderData
3. 使用 keepPreviousData
:
const { data } = useQuery({
queryKey: ['products', pagination],
queryFn: () => fetchProducts(pagination),
placeholderData: keepPreviousData, // 在加载新数据时保留旧数据
});
4. 配合 prefetchQuery
预取下一页数据:
我们还可以在进入当前页时,结合上一篇文章介绍的 prefetchQuery
,预先加载下一页数据,加快翻页响应速度:
const queryClient = useQueryClient();
// 预取下一页数据
queryClient.prefetchQuery({
queryKey: ['products', { page: pagination.page + 1, limit: pagination.limit }],
queryFn: () => fetchProducts({ page: pagination.page + 1, limit: pagination.limit }),
});
const getBooksQueryOptions = (sort: string, page: number) => ({
queryKey: ['books', sort, page],
queryFn: () => fetchBooks(sort, page),
staleTime: 5 * 60 * 1000 // 缓存 5 分钟
})
const useBooksQuery = (sort: string, page: number) => {
const queryClient = useQueryClient()
// 预加载下一页
useEffect(() => {
queryClient.prefetchQuery(getBooksQueryOptions(sort, page + 1))
}, [sort, page])
return useQuery(getBooksQueryOptions(sort, page))
}
5. 使用 staleTime
控制数据新鲜度:
const { data } = useQuery({
queryKey: ['products', pagination],
queryFn: () => fetchProducts(pagination),
staleTime: 5 * 60 * 1000, // 缓存5分钟数据
});
七、订阅与实时数据
在实时应用中,我们经常需要处理实时数据更新。TanStack Query 提供了多种方式来处理实时数据,包括轮询、WebSocket 订阅等。下面通过一个商品库存到货通知的例子来展示 TanStack Query 中如何使用 WebSocket。
WebSocket 服务器实现
下面以简易聊天系统为例,来看看怎么将 TanStack Query 跟 WS 结合起来,最终效果如下:
服务端代码实现如下:
/* eslint-disable node/prefer-global/buffer */
// pnpm ws: "ws": "npx tsx './src/app/(examples)/demo/websocket/websocket-server.ts'",
import { createServer } from 'node:http'
import { v4 as uuidv4 } from 'uuid'
import { WebSocket, WebSocketServer } from 'ws'
// 创建 HTTP 服务器
const server = createServer()
const wss = new WebSocketServer({ server })
// 存储所有连接的客户端
interface Client {
id: string
ws: WebSocket
username: string
lastHeartbeat: number
}
const clients = new Map<string, Client>()
// 心跳检测间隔(毫秒)
const HEARTBEAT_INTERVAL = 30000
// 心跳超时时间(毫秒)
const HEARTBEAT_TIMEOUT = 60000
// 广播消息给所有客户端
function broadcast(message: any, excludeClientId?: string) {
const messageStr = JSON.stringify(message)
clients.forEach((client) => {
if (client.id !== excludeClientId && client.ws.readyState === WebSocket.OPEN) {
client.ws.send(messageStr)
}
})
}
// 处理 WebSocket 连接
wss.on('connection', (ws: WebSocket) => {
const clientId = uuidv4()
console.log(`Client connected: ${clientId}`)
// 初始化客户端
const client: Client = {
id: clientId,
ws,
username: `User-${clientId.slice(0, 4)}`,
lastHeartbeat: Date.now(),
}
clients.set(clientId, client)
// 发送欢迎消息
ws.send(JSON.stringify({
type: 'WELCOME',
data: {
clientId,
username: client.username,
message: 'Welcome to the chat!',
},
}))
// 广播新用户加入
broadcast({
type: 'USER_JOINED',
data: {
username: client.username,
timestamp: Date.now(),
},
}, clientId)
// 处理消息
ws.on('message', (message: Buffer) => {
try {
const data = JSON.parse(message.toString())
switch (data.type) {
case 'CHAT_MESSAGE':
// 处理聊天消息
broadcast({
type: 'CHAT_MESSAGE',
data: {
username: client.username,
message: data.message,
timestamp: Date.now(),
},
})
break
case 'HEARTBEAT':
// 处理心跳消息
client.lastHeartbeat = Date.now()
ws.send(JSON.stringify({
type: 'HEARTBEAT_ACK',
data: { timestamp: Date.now() },
}))
break
case 'SET_USERNAME':
client.username = data.username
broadcast({
type: 'USERNAME_CHANGED',
data: {
oldUsername: client.username, // 处理用户名更改
newUsername: data.username,
timestamp: Date.now(),
},
})
break
}
}
catch (error) {
console.error('Error processing message:', error)
}
})
// 处理连接关闭
ws.on('close', () => {
console.log(`Client disconnected: ${clientId}`)
clients.delete(clientId)
broadcast({
type: 'USER_LEFT',
data: {
username: client.username,
timestamp: Date.now(),
},
})
})
// 处理错误
ws.on('error', (error: unknown) => {
console.error(`WebSocket error for client ${clientId}:`, error)
})
})
// 心跳检测
setInterval(() => {
const now = Date.now()
clients.forEach((client, clientId) => {
if (now - client.lastHeartbeat > HEARTBEAT_TIMEOUT) {
console.log(`Client ${clientId} timed out`)
client.ws.terminate()
clients.delete(clientId)
}
})
}, HEARTBEAT_INTERVAL)
// 启动服务器
const PORT = process.env.PORT || 8080
server.listen(PORT, () => {
console.log(`WebSocket server is running on port ${PORT}`)
})
使用命令 npx tsx ./server/websocket.ts
运行服务端代码如下:
前端实现
前端一共就两个点:连接 WS 和展示用户的信息,现在,让我们创建一个使用 chat 的 React 组件来展示聊天数据:
'use client'
import { useQuery } from '@tanstack/react-query'
import { Avatar, Button, Card, Input, List, message, Space, Tag, Typography } from 'antd'
import { Check, Edit, Send as SendIcon, UserRound, X } from 'lucide-react'
import React, { useState } from 'react'
import { chatKeys, useWebSocket } from './use-websocket'
const { Text, Title } = Typography
interface ChatMessage {
username: string
message: string
timestamp: number
}
type SystemMessage =
| { type: 'USER_JOINED', data: { username: string, timestamp: number } }
| { type: 'USER_LEFT', data: { username: string, timestamp: number } }
| { type: 'USERNAME_CHANGED', data: { oldUsername: string, newUsername: string, timestamp: number } }
export default function Chat() {
const [inputMessage, setInputMessage] = useState('')
const [newUsername, setNewUsername] = useState('')
const { isConnected, username, sendMessage, updateUsername } = useWebSocket('ws://localhost:8080')
// 使用 TanStack Query 获取消息
const { data: messages = [] } = useQuery<ChatMessage[]>({
queryKey: chatKeys.messages(),
initialData: [],
})
// 使用 TanStack Query 获取系统消息
const { data: systemMessages = [] } = useQuery<SystemMessage[]>({
queryKey: chatKeys.users(),
initialData: [],
})
const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault()
if (inputMessage.trim()) {
sendMessage(inputMessage)
setInputMessage('')
}
}
const handleUpdateUsername = (e: React.FormEvent) => {
e.preventDefault()
if (newUsername.trim()) {
updateUsername(newUsername)
setNewUsername('')
message.success('Username updated successfully!')
}
}
return (
<div>
<Card
title={(
<Space>
<Title level={4} style={{ margin: 0 }}>WebSocket Chat</Title>
<Tag className="!flex items-center" color={isConnected ? 'success' : 'error'} icon={isConnected ? <Check /> : <X />}>
{isConnected ? 'Connected' : 'Disconnected'}
</Tag>
</Space>
)}
>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 用户名设置 */}
<Card size="small">
<Space direction="vertical" style={{ width: '100%' }}>
<form onSubmit={handleUpdateUsername} style={{ display: 'flex', gap: '8px' }}>
<Input
prefix={<UserRound />}
placeholder="Enter new username"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<Edit />}
htmlType="submit"
>
Update Username
</Button>
</form>
<Text type="secondary">
Current username:
{username}
</Text>
</Space>
</Card>
{/* 消息列表 */}
<Card
styles={
{
body: { height: 400, overflow: 'auto', padding: '12px' },
}
}
size="small"
>
<List
dataSource={[...systemMessages, ...messages]}
renderItem={(msg) => {
if ('type' in msg) {
// 系统消息
return (
<List.Item>
<Card size="small" style={{ width: '100%', backgroundColor: '#e6f7ff' }}>
<Text type="secondary">
{msg.type === 'USER_JOINED' && (
<>
{msg.data.username}
{' '}
joined the chat
</>
)}
{msg.type === 'USER_LEFT' && (
<>
{msg.data.username}
left the chat
</>
)}
{msg.type === 'USERNAME_CHANGED' && (
<>
{msg.data.oldUsername}
{' '}
changed their name to
{' '}
{msg.data.newUsername}
</>
)}
<Text type="secondary" style={{ float: 'right' }}>
{new Date(msg.data.timestamp).toLocaleTimeString()}
</Text>
</Text>
</Card>
</List.Item>
)
}
else {
// 聊天消息
return (
<List.Item>
<Card size="small" style={{ width: '100%' }}>
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Space>
<Avatar icon={<UserRound />} />
<Text strong>{msg.username}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
{new Date(msg.timestamp).toLocaleTimeString()}
</Text>
</Space>
<Text style={{ marginLeft: 40 }}>{msg.message}</Text>
</Space>
</Card>
</List.Item>
)
}
}}
/>
</Card>
{/* 消息输入 */}
<form onSubmit={handleSendMessage} style={{ display: 'flex', gap: '8px' }}>
<Input
placeholder="Type a message..."
value={inputMessage}
onChange={e => setInputMessage(e.target.value)}
disabled={!isConnected}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<SendIcon />}
htmlType="submit"
disabled={!isConnected}
>
Send
</Button>
</form>
</Space>
</Card>
</div>
)
}
上面的组件中,引入了一个关键的 Hooks——useWebSocket
,用于建立一个 WebSocket 连接到指定的 URL 的服务器。这个钩子提供了一系列函数和状态变量来管理连接和与服务器通信。具体功能有:连接管理、状态管理、消息收发、TanStack Query 集成和组件卸载时清理 WebSocket 连接和间隔。代码实现如下:
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
interface Message {
type: string
data: any
}
interface ChatMessage {
username: string
message: string
timestamp: number
}
interface SystemMessage {
type: 'USER_JOINED' | 'USER_LEFT' | 'USERNAME_CHANGED'
data: {
username: string
oldUsername?: string
newUsername?: string
timestamp: number
}
}
// 查询键
export const chatKeys = {
all: ['chat'] as const,
messages: () => [...chatKeys.all, 'messages'] as const,
users: () => [...chatKeys.all, 'users'] as const,
}
export function useWebSocket(url: string) {
const [isConnected, setIsConnected] = useState(false)
const [username, setUsername] = useState('')
const wsRef = useRef<WebSocket | null>(null)
const heartbeatIntervalRef = useRef(0)
const queryClient = useQueryClient()
// 发送消息
const sendMessage = useCallback((message: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'CHAT_MESSAGE',
message,
}))
}
}, [])
// 更新用户名
const updateUsername = useCallback((newUsername: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'SET_USERNAME',
username: newUsername,
}))
setUsername(newUsername)
}
}, [])
// 发送心跳
const sendHeartbeat = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'HEARTBEAT',
timestamp: Date.now(),
}))
}
}, [])
// 处理接收到的消息
const handleMessage = useCallback((event: MessageEvent) => {
try {
const message: Message = JSON.parse(event.data)
switch (message.type) {
case 'WELCOME':
setUsername(message.data.username)
break
case 'CHAT_MESSAGE':
// 使用 TanStack Query 更新消息缓存
queryClient.setQueryData(chatKeys.messages(), (old: ChatMessage[] = []) => {
return [...old, message.data]
})
break
case 'USER_JOINED':
case 'USER_LEFT':
case 'USERNAME_CHANGED':
// 使用 TanStack Query 更新系统消息缓存
queryClient.setQueryData(chatKeys.users(), (old: SystemMessage[] = []) => {
return [...old, message as SystemMessage]
})
break
case 'HEARTBEAT_ACK':
// 心跳确认,可以在这里处理连接状态
break
}
}
catch (error) {
console.error('Error processing message:', error)
}
}, [queryClient])
// 初始化 WebSocket 连接
useEffect(() => {
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
setIsConnected(true)
// 启动心跳
heartbeatIntervalRef.current = setInterval(sendHeartbeat, 30000) as unknown as any
}
ws.onclose = () => {
setIsConnected(false)
// 清除心跳
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current)
}
}
ws.onmessage = handleMessage
return () => {
if (heartbeatIntervalRef.current) {
clearInterval(heartbeatIntervalRef.current)
}
ws.close()
}
}, [url, handleMessage, sendHeartbeat])
return {
isConnected,
username,
sendMessage,
updateUsername,
}
}
实现说明
- WebSocket 服务器:
- 使用
ws
库创建 WebSocket 服务器 - 维护连接的客户端列表
- 暂存客户端发送的信息
- 广播更新给所有连接的客户端
- 使用
- 前端实现:
- 使用
useQuery
获取初始数据 - 使用
useEffect
建立 WebSocket 连接 - 通过
queryClient.setQueryData
更新缓存数据 - 使用 Ant Design 的组件展示数据
- 使用
这个实现展示了如何使用 WebSocket 和 TanStack Query 来处理实时数据更新。通过这种方式,我们可以:
- 保持数据的实时性
- 减少不必要的轮询请求
- 提供更好的用户体验
- 优化服务器资源使用
八、总结
TanStack Query 是一个强大的数据获取和状态管理库,它通过以下特性显著提升了开发效率和用户体验:
- 自动缓存管理:智能处理数据缓存,减少重复请求,提升应用性能。
- 状态管理简化:自动处理加载、错误等状态,减少样板代码。
- 实时数据支持:通过 WebSocket 等机制支持实时数据更新。
- 分页与无限滚动:内置支持分页和无限滚动场景,简化复杂数据加载逻辑。
- 开发体验优化:提供 DevTools 等工具,方便调试和监控。
由于篇幅有限,没有概含所有 TanStack Query 的内容,只列举了一些高频的场景,如果感兴趣,可以自行阅读官网!
其它
竞态请求
竞态请求(Race Condition)是一个常见问题,尤其在组件快速重新请求数据(如搜索框、快速切换页面等)时,旧请求比新请求晚返回,却错误地覆盖了新请求的结果。
上述有点抽象,我们想象两个场景:
- 翻页交互:有一个支持翻页的表格,如果用户从第一页快速的翻到第十页。此时用户期望看到的是第十页的数据,但我们不能保证第十页的请求一定早于其他页码的请求返回,所以第十页的请求结果可能被其他页码的请求结果覆盖。
- 搜索功能:一个自动完成组件,用户输入一部分内容,组件会立即访问远程服务器获取智能补全的提示词。但因为用户输入变更很快,最终显示的提示词可能被延期返回的请求结果覆盖。
常见解决思路
一个简单的处理竞态问题的思路:通过闭包的 active
标记来让旧的请求在组件重渲染时不再执行后续逻辑。
useEffect(() => {
let active = true
const fetchData = async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`)
const newData = await response.json()
if (active) {
setFetchedId(props.id)
setData(newData)
}
}
fetchData()
return () => { active = false }
}, [props.id])
- 每次发起请求都有一个新的
active
被创建,只有当active
为true
才会执行网络请求的后续操作。 - 当组件重新渲染或者说页面状态过期的时候
active
变成false
,这个false
作为网络请求的闭包被缓存到了回调函数中,从而阻止了过期的后续操作。
参考链接 🔗
React Query 会根据 queryKey
缓存每一次请求。如果你能保证每一次请求的 queryKey
是唯一的,那么就不会发生竞态问题。
技巧篇
在实际项目中,我们常常需要根据不同场景对接口请求进行更细致的控制,比如:是否请求、请求顺序、是否轮询等。本文将深入介绍 useQuery 在这些高级场景下的使用方式。
条件性请求(Conditional Queries)
有时我们希望只有在满足某个条件时才发起请求,例如搜索关键词存在时才请求搜索结果。
⚠️ 错误示范(违反 Hook 规则):
if (keyword) {
useQuery({
queryKey: ['search', keyword],
queryFn
})
}
✅ 正确写法:通过 enabled 参数控制请求是否启用。
useQuery({
queryKey: ['search', keyword],
queryFn,
enabled: !!keyword // keyword 存在时才请求 即:仅当 `enabled` 为 `true` 时,query 才会被触发。
})
依赖性请求(Dependent Queries)
有些请求依赖于另一个请求的结果,比如根据书名获取书籍信息,再根据书籍 ID 获取评论。
❌ 不推荐:将多个请求写在一个 queryFn 中(耦合性高)
useQuery({
queryKey: ['book', 'comments', bookTitle],
queryFn: async () => {
const book = await fetchBook(bookTitle)
const comments = await fetchBookComments(book.data.id)
return { book, comments }
}
})
✅ 推荐方式:拆分为多个 query,并通过 enabled 控制请求时机
const useBookDetail = (bookTitle: string) =>
useQuery({
queryKey: ['book', bookTitle],
queryFn: () => fetchBook(bookTitle),
enabled: !!bookTitle
})
const useBookComments = (bookId?: string) =>
useQuery({
queryKey: ['comments', bookId],
queryFn: () => fetchBookComments(bookId!),
enabled: !!bookId
})
const useBookDetailAndComments = (bookTitle: string) => {
const book = useBookDetail(bookTitle)
const comments = useBookComments(book.data?.id)
return { book, comments }
}
这样不仅降低了耦合,还能分别缓存和复用每个请求的数据。
轮询(Polling)
如果你需要定时请求数据,比如检测支付是否完成,可以使用 refetchInterval
参数。
1. 简单轮询:
轮询通常用于那些需要实时性反馈的场景,比如查询用户是否完成支付。
useQuery({
queryKey: ['list', { sort }],
queryFn,
refetchInterval: 5000, // 每 5 秒请求一次
});
2. 条件轮询:仅在某些状态下持续轮询
refetchInterval
也可以是一个函数。
想象这样一个场景:前端通过轮询来得知用户是否完成支付,用户一旦完成支付轮询就该停止。
useQuery({
//...,
refetchInterval: (query) => {
if (query.state.data?.finished) {
return false; // 停止轮询
}
return 3000; // 每 3 秒请求一次
}
})
🚀 并发请求(Parallel Queries)
1. Promise.all 简单实现(注意失败会中断所有请求):
const queryFn = async (bookId: string) => {
const [book, comments] = await Promise.all([
fetchBookDetail(bookId),
fetchBookComments(bookId)
])
return { book, comments }
}
useQuery({
queryKey: ['bookWithComments', bookId],
queryFn
})
当一些业务场景需要前端并发若干请求时,我们可以在 queryFn
里基于 Promise.all
来并发请求。
这很好,但会让两个请求耦合在一起。任意请求的失败都会导致整个请求失败,且只有一个缓存 key,不利于细粒度的控制缓存。
2. 使用 useQueries
并发多个请求,支持更细粒度控制
const ids = [1, 2, 3] // // 基于 ids 并发请求,可以动态控制请求数量
const { data, pending } = useQueries({
queries: ids.map((id) => ({
queryKey: ['post', id],
queryFn: () => fetchPost(id),
})),
combine: (results) => ({
data: results.map((r) => r.data),
pending: results.some((r) => r.isPending)
})
})
上面的代码通过调用 useQuery
多次,这也很好,但缺点是不能动态控制请求并发的数量。
3. 多请求并聚合结果示例:
如果你明确并发的请求之间存在逻辑关联,或者需要动态控制并发数量,更推荐使用 useQueries
。
useQueries
的 queries
参数是一个数组,数组里的每一项都是一个 useQuery
的配置对象。
基于 combine
可以实现更复杂的聚合逻辑。
const { bookDetail, bookComments, isDetailAndCommentPending } = useQueries({
queries: [
{
queryKey: ['book', selectedBookId],
queryFn: () => fetchBookDetail(selectedBookId!),
enabled: !!selectedBookId
},
{
queryKey: ['bookComments', selectedBookId],
queryFn: () => fetchBookComments(selectedBookId!),
enabled: !!selectedBookId
}
],
combine: ([bookDetail, bookComments]) => ({
bookDetail,
bookComments,
isDetailAndCommentPending: bookDetail.isPending || bookComments.isPending
})
})
Last updated on