大家好,我是长林啊!一个全栈开发者和 AI 探索者;致力于终身学习和技术分享。
本文首发在我的微信公众号【长林啊】,欢迎大家关注、分享、点赞!
你是不是曾经跟我一样,为了处理数据加载状态,写了一大堆 isLoading
和 error
的判断;为了实现数据缓存,不得不手动管理 useState
和 useEffect
;为了处理实时数据更新,写了很多复杂的轮询逻辑...这些重复性的工作不仅降低了开发效率,还增加了代码维护的难度,每次迭代都需要小心翼翼地处理这些状态,生怕一不小心就引入了新的 bug。
直到我遇到了 TanStack Query(原 React Query),它彻底改变了我的开发方式。现在,我可以轻松处理:
- 自动的数据缓存和状态管理
- 优雅的实时数据更新
- 简单的分页和无限滚动
- 便捷的开发调试工具
- ......
如果你也正在为 React 应用中的数据管理而烦恼,不妨跟着我一起探索 TanStack Query 的魅力,让数据管理变得简单高效!
TanStack Query 是一个用于在 web 应用中获取、缓存、同步和更新服务器状态的库。它简化了数据获取过程,使开发者能够专注于业务逻辑,而无需处理繁琐的状态管理。它自动管理请求状态,包括加载、错误处理和数据缓存,极大提高了开发效率。内置的缓存机制不仅减少了网络请求,还提升了应用性能和用户体验。还支持复杂用例,如分页和实时数据获取。另外,它能与现代框架(如 React 和 Vue)及其他状态管理库(如 Redux 和 Zustand)无缝集成,增强了灵活性。
TanStack Query 通过使用查询键来标识不同接口返回的数据,而查询函数就是我们请求后端接口的函数。TanStack Query 中的查询是对异步数据源的声明性依赖,它与唯一键绑定。查询可以与任何基于 Promise 的方法一起使用(包括 GET 和 POST 方法)来从服务器获取数据。
一、什么是 TanStack Query?
背景
按照官方的说法:大多数核心的 Web 框架缺乏统一的方式来获取或更新数据;在此背景下,就有了 React Query 的雏形。
主要功能和特点
React 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 中打开。核心代码如下:
import React from 'react'
import ReactDOM from 'react-dom/client'
import {
QueryClient,
QueryClientProvider,
useQuery,
} from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
// 创建实例
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>
)
}
const rootElement = document.getElementById('root') as HTMLElement
ReactDOM.createRoot(rootElement).render(<App />)
效果如下:
在上面的例子中,给我们展示了 TanStack Query 最核心的几个 API:
-
QueryClient
用于管理和配置查询的行为。 -
QueryClientProvider
是使用 TanStack Query 的起点,也就是第一步,我们必须要通过QueryClient
创建一个实例并传入到QueryClientProvider
中。 -
useQuery
获取数据,当加载数据时,我们可以通过isPending
属性来判断是否数据正在加载中,从而去展示加载时的 UI。其中,我们向useQuery
中传入了queryKey
和queryFn
,queryKey
用来作为该查询的标识,而queryFn
对应为获取数据的函数。
下面我们来用一个电商应用来快速入门!用TanStack Query 分别对商品做请求和创建的处理。
2.1 创建一个 Vite + react +TS 项目
创建项目
pnpm create vite
创建过程如下图:
在 vs code 中打开后如下图:
安装依赖:
pnpm install
启动项目:
pnpm dev
安装相关依赖
pnpm add @tanstack/react-query@5.59.0
为了跟本文中的演示效果的一致性,建议统一使用文中的版本号!大多数场景下,使用最新版本也没啥问题。
- 配置 TanStack Query
- 在 App.tsx 文件中初始化
QueryClient
、在组件根部添加QueryClientProvider
。
完整代码如下:
import { useState } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
const queryClient = new QueryClient()
function App() {
const [count, setCount] = useState(0)
return (
<QueryClientProvider client={queryClient}>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</QueryClientProvider>
)
}
export default App
对接电商应用的接口(fakestoreapi products)
为了统一管理客户端的 API 请求,我们在 src
目录下创建一个 api/
文件夹,并在其中添加一个 products.ts
文件。这个文件中包含:分页获取、单个产品获取、所有产品获取和创建产品等逻辑。
const BASE_URL = 'https://fakestoreapi.com/products'
export interface Product {
id: number
title: string
price: number
description: string
category: string
image: string
rating?: Rating
}
export interface Rating {
rate: number
count: number
}
export interface CreateProduct {
title: string
price: number
description: string
image: string
category: string
}
// 获取所有商品
export async function getProducts(): Promise<Product[]> {
const response = await fetch(BASE_URL)
return await response.json()
}
// 获取单个商品
export async function getProductByID(id: number): Promise<Product> {
const response = await fetch(`${BASE_URL}/${id}`)
return await response.json()
}
// 分页获取商品
export async function getProductByPage(page: number): Promise<Product[]> {
// 因为产品一共只有 20 条,为了达到多页获取的效果,所以每页最多显示 5 条
if (page > 4) {
return []
}
const limit = 5;
const response = await fetch(`${BASE_URL}?limit=${page * limit}`)
return await response.json()
}
// 创建商品
export async function createProduct(product: CreateProduct): Promise<Product> {
const response = await fetch(BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(product)
})
return await response.json()
}
界面交互
上面我们把数据请求都已经做好了,接下来就是做界面交互了,为了提升效率和界面的美观,我这里就是用 Ant Design 做 UI 的搭建!
-
安装 Ant Design
pnpm install antd --save
-
使用 antd 搭建界面
src
层级的目录如下:├── src │ ├── App.tsx │ ├── api │ │ └── products.ts │ ├── assets │ │ └── react.svg │ ├── components │ │ └── product │ │ ├── all.tsx │ │ ├── index.tsx │ │ ├── load-more.tsx │ │ └── single-product.tsx │ ├── main.tsx │ └── vite-env.d.ts
components/product/index.tsx
是产品组件的入口文件,这个组件是用 antd 的 Tab 标签组件来搭建的,效果如下:完整代码如下:
// src/components/product/index.tsx import React, { useState } from 'react'; import { Flex, Radio, Segmented, Tabs } from 'antd'; import type { RadioChangeEvent, TabsProps } from 'antd'; import All from './all'; import SingleProduct from './single-product'; import LoadMore from './load-more'; const onChange = (key: string) => { console.log(key); }; const items: TabsProps['items'] = [ { key: '1', label: '全部', children: <All />, }, { key: '2', label: '单个产品', children: <SingleProduct />, }, { key: '3', label: '分页', children: <LoadMore />, }, ]; type Align = 'start' | 'center' | 'end'; type TabPosition = 'left' | 'right' | 'top' | 'bottom'; const Product: React.FC = () => { // 标签位置 const [mode, setMode] = useState<TabPosition>('top'); // 对齐方式 const [alignValue, setAlignValue] = React.useState<Align>('center'); return <> <Flex gap={8}> <Radio.Group onChange={e => setMode(e.target.value)} value={mode} style={{ marginBottom: 8 }}> <Radio.Button value="top">Horizontal</Radio.Button> <Radio.Button value="left">Vertical</Radio.Button> </Radio.Group> <Segmented defaultValue="center" style={{ marginBottom: 8 }} onChange={(value) => setAlignValue(value as Align)} options={['start', 'center', 'end']} /> </Flex> <Tabs defaultActiveKey="1" tabPosition={mode} items={items} onChange={onChange} indicator={{ size: (origin) => origin - 20, align: alignValue }} /> </> }; export default Product;
全部产品的展示组件:
// src/components/product/all.tsx const All = () => { return ( <div>All</div> ) } export default All
数据分页显示的组件:
// src/components/product/load-more.tsx const LoadMore = () => { return ( <div>LoadMore</div> ) } export default LoadMore
单个产品的获取:
const SingleProduct = () => { return ( <div>SingleProduct</div> ) } export default SingleProduct
数据处理
页面的模版搭建好了,接下来就是做数据处理了,我们从获取全部数据开始!
-
全部产品的展示组件
在写代码之前,我们要先想清楚这个组件是做什么的,需要做哪些操作!显然,这个组件是用来展示所有展品的,所以,所以就是获取数据和渲染数据,获取数据的 API 逻辑我们已经写好了,接下来就是处理 UI 层的相关逻辑。
使用
useQuery
来获取数据,根据不同的状态来处理对应的界面交互,代码如下:const { data, error, isPending } = useQuery<Product[]>({ queryKey: ['products'], queryFn: getProducts, }); if (isPending) { return <div>Loading...</div>; } if (error instanceof Error) { return <div>An error has occurred: {error.message}</div>; }
上面代码通过
useQuery
来获取数据,当加载数据时,通过isPending
属性来判断是否数据正在加载中,如果在加载中就去展示加载时的 UI。如果有error
则展示Error
的 UI。使用 antd 的
List
组件搭建 UI,完整代码代码如下:import { Card, List, Image } from 'antd'; import { useQuery } from '@tanstack/react-query'; import { getProducts, Product } from '../../api/products'; import "./style.css" const All = () => { const { data, error, isLoading } = useQuery<Product[]>({ queryKey: ['products'], queryFn: getProducts, }); if (isLoading) { return <div>Loading...</div>; } if (error instanceof Error) { return <div>An error has occurred: {error.message}</div>; } return <List grid={{ gutter: 12, xs: 2, sm: 2, md: 3, lg: 4, xl: 5, xxl: 8, }} rowKey={(product) => product.id} style={{ flexWrap: 'wrap' }} dataSource={data} renderItem={(product) => ( <List.Item style={{ border: '1px solid transparent' }}> <Card hoverable type='inner' style={{ width: 220 }} cover={ <p style={{ display: 'flex', alignContent: 'center', justifyContent: 'center' }}> <Image width={200} height={200} preview={false} src={product.image} /> </p> } > <List.Item.Meta title={<p className='ellipsis' style={{ '--ellipsis-line': 2, height: '48px' } as React.CSSProperties} >{product.title}</p>} description={<p className='ellipsis' style={{ '--ellipsis-line': 3 } as React.CSSProperties}>{product.description}</p>} /> </Card> </List.Item> )} /> } export default All
最终效果如下:
-
单个数据的展示
为了保持本文的纯粹,没有过多的集成第三方包,包括路由都没有集成,使用的 antd 库的
Tab
组件来做页面的切换,下面我们来看一下单个产品的获取及展示!我们先看一下效果,然后再分解功能:
其实一共可以分为两个部分,上面小图标块和下面产品详情展示,下面我们就来看看具体的实现步骤:
- 先请求所有产品,然后将所有产品的图片展示出来
- 默认选中第一个,点击上面的图片可以切换下面商品详情的展示(注意:这里使用 useQuery 就有一个先后的调用的关系了)
下面我们来一一实现,首先获取所有产品:
// 先获取所有产品的 id,然后根据 id 获取单个产品 const { data: allProducts, error: allProductsError } = useQuery<Product[]>({ queryKey: ['products'], queryFn: getProducts, });
然后根据获取到的产品的第一个 id 去获取详细信息:
这里的
enable
就是用来处理先后调用关系的,也就是这个请求需要依赖上一个请求结果的id
,才能发起,如果没有这个id
,则useQuery
不会发起请求。const { data, isPending } = useQuery<Product>({ queryKey: ['product', id], queryFn: () => getProductByID(id), enabled: !!id });
以上就是核心逻辑的实现,完整代码如下:
import { useQuery } from '@tanstack/react-query' import { Card, Flex, Image } from 'antd'; import { getProductByID, getProducts, Product } from '../../api/products' import { useState } from 'react'; const { Meta } = Card; const SingleProduct = () => { // 先获取所有产品的 id,然后根据 id 获取单个产品 const { data: allProducts, error: allProductsError } = useQuery<Product[]>({ queryKey: ['products'], queryFn: getProducts, }); const [id, setId] = useState(allProducts?.[0].id) if (!id || allProductsError instanceof Error) { return <div>no product</div> } const { data, isPending } = useQuery<Product>({ queryKey: ['product', id], queryFn: () => getProductByID(id), enabled: !!id }); return ( <> {id && <Flex gap={8} wrap> {allProducts?.map((product) => (<Image key={product.id} width={60} height={60} preview={false} src={product.image} className='image-box' onClick={() => setId(product.id)} />))} </Flex>} {isPending && <div>Loading...</div>} {!isPending && <Card style={{ width: 240, marginBlockStart: '10px' }} cover={<div style={{ display: 'flex', alignContent: 'center', justifyContent: 'center', padding: '10px' }}> <Image width={200} height={200} preview={false} src={data?.image} /> </div>} > <Meta title={data?.category} description={data?.title} /> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', textTransform: 'uppercase', fontWeight: '600' }}> <p>price: {data?.price}</p> <p>rate: {data?.rating?.rate}</p> </div> </Card>} </> ) } export default SingleProduct
最后我们看看切换效果:
-
分页功能
在业务开发过程中,我们通常不会一次性把所有数据都请求回来,而是根据不同的页码去请求对应的数据;我们接着上面的例子,来做一下上面的分页查询的功能。由于 fakestoreapi 的 product 的数据太少,这里就用 jsonplacehokder 的 todo 作为数据的承载。
const {/* ... */} = useInfiniteQuery( - ['users'], + ['users', debouncedKeyword], - ({ pageParam = { page: 1, size: 10 } }) => getUsers(pageParam), + ({ pageParam = { page: 1, size: 10 }, queryKey }) => { + return getUsers({ ...pageParam, keyword: queryKey[1] /* 即 debo+ uncedKeyword */ }) + } )
三、基本用法
创建第一个查询
在 TanStack Query 中,创建查询非常简单。我们使用 useQuery
钩子来发起数据请求。这个钩子接受一个配置对象,其中包含查询键(queryKey)和查询函数(queryFn)。
使用 useQuery 钩子
useQuery
钩子的基本结构如下:
const { data, isLoading, error } = useQuery({
queryKey: ['uniqueKey'],
queryFn: () => fetchData(),
});
主要参数说明:
queryKey
:查询的唯一标识符,用于缓存管理queryFn
:实际获取数据的异步函数enabled
:控制查询是否自动执行staleTime
:数据保持新鲜的时间cacheTime
:数据在缓存中保留的时间
示例代码
让我们看一个实际的例子,展示如何使用 useQuery
获取用户数据:
import { useQuery } from '@tanstack/react-query';
import { Card, Typography, Space, Spin, Alert } from 'antd';
const { Title, Text } = Typography;
interface User {
id: number;
email: string;
username: string;
password: string;
name: {
firstname: string;
lastname: string;
};
address: {
city: string;
street: string;
number: number;
zipcode: string;
geolocation: {
lat: string;
long: string;
};
};
phone: string;
}
async function fetchUser(id: number): 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();
}
export default function User({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <Spin size="large" />;
if (error) return <Alert type="error" message={error.message} />;
if (!data) return <Alert type="warning" message="No data available" />;
return (
<Card>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Title level={2}>{data.name.firstname} {data.name.lastname}</Title>
<Space direction="vertical">
<Text><Text strong>Email:</Text> {data.email}</Text>
<Text><Text strong>Phone:</Text> {data.phone}</Text>
<Text><Text strong>Address:</Text> {data.address.street} {data.address.number}, {data.address.city}</Text>
</Space>
</Space>
</Card>
);
}
创建变更请求
除了查询数据,我们经常需要修改数据。TanStack Query 提供了 useMutation
钩子来处理数据变更操作。
使用 useMutation 钩子
useMutation
钩子的基本结构如下:
const mutation = useMutation({
mutationFn: (newData) => updateData(newData),
onSuccess: () => {
// 处理成功后的操作
},
onError: (error) => {
// 处理错误
},
});
主要参数说明:
mutationFn
:执行数据变更的函数onSuccess
:变更成功后的回调onError
:发生错误时的回调onSettled
:无论成功失败都会执行的回调
下面是一个使用 useMutation
创建新用户的例子:
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Form, Input, Button, Card, message, Space } from 'antd';
interface NewUser {
email: string;
username: string;
password: string;
name: {
firstname: string;
lastname: string;
};
address: {
city: string;
street: string;
number: number;
zipcode: string;
geolocation: {
lat: string;
long: string;
};
};
phone: string;
}
async function createUser(user: NewUser): Promise<NewUser & { 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] = Form.useForm();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser: NewUser) => {
console.log('🚀 ~ CreateUserForm ~ newUser:', newUser)
queryClient.invalidateQueries({ queryKey: ['users'] });
message.success('User created successfully!');
form.resetFields();
},
onError: (error) => {
message.error('Failed to create user: ' + error.message);
},
});
const handleSubmit = (values: NewUser) => {
mutation.mutate(values);
};
return (
<Card title="Create New User" style={{ width: '600px' }}>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
disabled={mutation.isPending}
>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Form.Item
label="Email"
name={['email']}
rules={[{ required: true, type: 'email' }]}
>
<Input />
</Form.Item>
<Form.Item
label="Username"
name={['username']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Password"
name={['password']}
rules={[{ required: true }]}
>
<Input.Password />
</Form.Item>
<Space direction="horizontal" size="middle">
<Form.Item
label="First Name"
name={['name', 'firstname']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Last Name"
name={['name', 'lastname']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Space>
<Space direction="horizontal" size="middle">
<Form.Item
label="City"
name={['address', 'city']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Street"
name={['address', 'street']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Space>
<Space direction="horizontal" size="middle">
<Form.Item
label="Street Number"
name={['address', 'number']}
rules={[{ required: true, type: 'number' }]}
>
<Input type="number" />
</Form.Item>
<Form.Item
label="Zipcode"
name={['address', 'zipcode']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Space>
<Space direction="horizontal" size="middle">
<Form.Item
label="Latitude"
name={['address', 'geolocation', 'lat']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
label="Longitude"
name={['address', 'geolocation', 'long']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
</Space>
<Form.Item
label="Phone"
name={['phone']}
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={mutation.isPending}>
Create User
</Button>
</Form.Item>
</Space>
</Form>
</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 的基本结构,也标注了详细的注释,这里就不再赘述了!让我们通过一个实际的例子来展示如何处理不同的查询状态:
import { useQuery } from '@tanstack/react-query';
import { Card, List, Spin, Alert, Empty, Typography } from 'antd';
const { Title } = Typography;
interface Todo {
id: number;
title: string;
completed: boolean;
}
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('https://fakestoreapi.com/products?limit=5');
if (!response.ok) {
throw new Error('Failed to fetch todos');
}
return response.json();
}
export default function TodoList() {
const {
data: todos,
isPending,
isFetching,
isError,
error,
} = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// 处理加载状态
if (isPending) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
<p>Loading todos...</p>
</div>
);
}
// 处理错误状态
if (isError) {
return (
<Alert
type="error"
message="Error"
description={error.message}
showIcon
/>
);
}
// 处理空数据状态
if (!todos?.length) {
return <Empty description="No todos found" />;
}
return (
<Card>
{isFetching && (
<div style={{ position: 'absolute', top: 10, right: 10 }}>
<Spin size="small" />
</div>
)}
<Title level={4}>Todo List</Title>
<List
dataSource={todos}
renderItem={(todo) => (
<List.Item>
<List.Item.Meta
title={todo.title}
description={`Status: ${todo.completed ? 'Completed' : 'Pending'}`}
/>
</List.Item>
)}
/>
</Card>
);
}
状态更新和缓存
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
}
通过合理使用这些状态管理特性,我们可以构建出更加健壮和用户友好的应用程序
五、数据缓存与更新
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 的缓存数据
缓存时间控制
通过 staleTime
和 gcTime
可以控制数据的缓存行为:
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000, // 数据在 5 分钟内保持新鲜
gcTime: 10 * 60 * 1000, // 未使用的数据在 10 分钟后被垃圾回收
});
手动更新缓存
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,
});
乐观更新
乐观更新是一种提升用户体验的技术,它假设更新会成功,立即更新 UI,然后在后台进行实际的更新操作:
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>
);
}
缓存持久化
在某些情况下,我们可能需要将缓存数据持久化到本地存储:
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 提供了专门的钩子来处理这些场景。
分页查询
TanStack Query 提供了 useQuery
钩子来处理分页数据。让我们通过一个商品列表的例子来展示:
import { useState } from 'react';
import { Table, Card, Spin, Alert, Space, Tag } from 'antd';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import type { TablePaginationConfig } from 'antd/es/table';
interface Product {
id: number
title: string
description: string
category: string
price: number
discountPercentage: number
rating: number
stock: number
tags: string[]
brand: string
sku: string
weight: number
dimensions: Dimensions
warrantyInformation: string
shippingInformation: string
availabilityStatus: string
reviews: Review[]
returnPolicy: string
minimumOrderQuantity: number
meta: Meta
images: string[]
thumbnail: string
}
export interface Dimensions {
width: number
height: number
depth: number
}
export interface Review {
rating: number
comment: string
date: string
reviewerName: string
reviewerEmail: string
}
export interface Meta {
createdAt: string
updatedAt: string
barcode: string
qrCode: string
}
interface PaginationParams {
page: number;
limit: number;
}
interface ProductsResponse {
products: Product[];
total: number;
skip: number;
limit: number;
}
async function fetchProducts({ page, limit }: PaginationParams): Promise<ProductsResponse> {
const skip = (page - 1) * limit;
const response = await fetch(
`/api/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 (
<Card title="Product List">
<Table
columns={[
{
title: 'Thumbnail',
dataIndex: 'thumbnail',
key: 'thumbnail',
render: (thumbnail) => (
<img src={thumbnail} alt="product" style={{ width: 50, height: 50, 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>
);
}
因为接口请求比较慢,这里就截动图,可以把项目 clone 后运行;静态效果图下:
行上图的右侧 network 面板也能看到,每页请求 10 条数据,已经访问过的页码,不会再次重新请求,主要配置就是 placeholderData: keepPreviousData,
!
无限滚动
对于无限滚动,TanStack Query 提供了 useInfiniteQuery
钩子。这个钩子专门用于处理无限加载的数据。在实现无限滚动时,我们通常需要检测元素是否进入视口(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
实现的无限滚动商品列表:
import { useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useInfiniteQuery } from '@tanstack/react-query';
import { List, Card, Image, Typography, Spin, Alert, Space, Tag, Rate } from 'antd';
const { Text, Paragraph } = Typography;
interface Product {
id: number;
title: string;
description: string;
category: string;
price: number;
discountPercentage: number;
rating: number;
stock: number;
tags: string[];
brand: string;
thumbnail: string;
images: string[];
}
interface ProductsResponse {
products: Product[];
total: number;
skip: number;
limit: number;
}
async function fetchProducts({ pageParam = 0 }): Promise<ProductsResponse> {
const limit = 10;
const skip = pageParam * limit;
const response = await fetch(
`/api/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 style={{ textAlign: 'center', padding: '50px' }}>
<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}
style={{
textAlign: 'center',
marginTop: '20px',
height: '50px',
lineHeight: '50px',
}}
>
{isFetchingNextPage ? (
<Spin />
) : hasNextPage ? (
'Load More'
) : (
'No More Products'
)}
</div>
</Card>
);
}
当滚动条划到底部的时就自动去获取下一页的数据,如下图:
分页与无限滚动的选择
选择使用分页还是无限滚动取决于具体的应用场景:
-
分页适用于:
- 需要精确控制每页显示数量的场景
- 需要快速跳转到特定页面的场景
- 数据量相对较小且结构化的场景
-
无限滚动适用于:
- 内容流式的场景(如社交媒体)
- 需要持续加载更多内容的场景
- 移动端应用
性能优化建议
-
使用
keepPreviousData
:const { data } = useQuery({ queryKey: ['products', pagination], queryFn: () => fetchProducts(pagination), placeholderData: keepPreviousData, // 在加载新数据时保留旧数据 });
-
预取下一页数据:
const queryClient = useQueryClient(); // 预取下一页数据 queryClient.prefetchQuery({ queryKey: ['products', { page: pagination.page + 1, limit: pagination.limit }], queryFn: () => fetchProducts({ page: pagination.page + 1, limit: pagination.limit }), });
-
使用
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 结合起来,最终效果如下:
服务端代码实现如下:
// server/websocket.ts
import { createServer } from 'http';
import { v4 as uuidv4 } from 'uuid';
import { WebSocketServer, WebSocket } 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':
// 处理用户名更改
const oldUsername = client.username;
client.username = data.username;
broadcast({
type: 'USERNAME_CHANGED',
data: {
oldUsername,
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) => {
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 组件来展示聊天数据:
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Card,
Input,
Button,
List,
Typography,
Space,
Tag,
Avatar,
message,
} from 'antd';
import {
SendOutlined,
UserOutlined,
EditOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useWebSocket, chatKeys } from '../hooks/useWebSocket';
const { Text, Title } = Typography;
export 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({
queryKey: chatKeys.messages(),
initialData: [],
});
// 使用 TanStack Query 获取系统消息
const { data: systemMessages = [] } = useQuery({
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 style={{ maxWidth: 800, margin: '0 auto', padding: '20px' }}>
<Card
title={
<Space>
<Title level={4} style={{ margin: 0 }}>WebSocket Chat</Title>
<Tag color={isConnected ? 'success' : 'error'} icon={isConnected ? <CheckCircleOutlined /> : <CloseCircleOutlined />}>
{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={<UserOutlined />}
placeholder="Enter new username"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<EditOutlined />}
htmlType="submit"
>
Update Username
</Button>
</form>
<Text type="secondary">Current username: {username}</Text>
</Space>
</Card>
{/* 消息列表 */}
<Card
bodyStyle={{ height: 400, overflow: 'auto', padding: '12px' }}
size="small"
>
<List
dataSource={[...systemMessages, ...messages]}
renderItem={(msg, index) => {
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={<UserOutlined />} />
<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={<SendOutlined />}
htmlType="submit"
disabled={!isConnected}
>
Send
</Button>
</form>
</Space>
</Card>
</div>
);
}
上面的组件中,引入了一个关键的 Hooks——useWebSocket
,用于建立一个 WebSocket 连接到指定的 URL 的服务器。这个钩子提供了一系列函数和状态变量来管理连接和与服务器通信。具体功能有:连接管理、状态管理、消息收发、TanStack Query 集成和组件卸载时清理 WebSocket 连接和间隔。代码实现如下:
// /src/hooks/useWebSocket.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
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);
};
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 的内容,只列举了一些高频的场景,如果感兴趣,可以自行阅读官网或者跟我一起探讨!