一文掌握 TanStack Query:让 React 数据管理变得简单高效

526 阅读21分钟

大家好,我是长林啊!一个全栈开发者和 AI 探索者;致力于终身学习和技术分享。

本文首发在我的微信公众号【长林啊】,欢迎大家关注、分享、点赞!

你是不是曾经跟我一样,为了处理数据加载状态,写了一大堆 isLoadingerror 的判断;为了实现数据缓存,不得不手动管理 useStateuseEffect;为了处理实时数据更新,写了很多复杂的轮询逻辑...这些重复性的工作不仅降低了开发效率,还增加了代码维护的难度,每次迭代都需要小心翼翼地处理这些状态,生怕一不小心就引入了新的 bug。

直到我遇到了 TanStack Query(原 React Query),它彻底改变了我的开发方式。现在,我可以轻松处理:

  • 自动的数据缓存和状态管理
  • 优雅的实时数据更新
  • 简单的分页和无限滚动
  • 便捷的开发调试工具
  • ......

如果你也正在为 React 应用中的数据管理而烦恼,不妨跟着我一起探索 TanStack Query 的魅力,让数据管理变得简单高效!

截自官网 https://tanstack.com/query/latest

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 中传入了 queryKeyqueryFnqueryKey 用来作为该查询的标识,而 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

为了跟本文中的演示效果的一致性,建议统一使用文中的版本号!大多数场景下,使用最新版本也没啥问题。

  1. 配置 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 组件来做页面的切换,下面我们来看一下单个产品的获取及展示!

    我们先看一下效果,然后再分解功能:

    其实一共可以分为两个部分,上面小图标块和下面产品详情展示,下面我们就来看看具体的实现步骤:

    1. 先请求所有产品,然后将所有产品的图片展示出来
    2. 默认选中第一个,点击上面的图片可以切换下面商品详情的展示(注意:这里使用 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>
    );
}

这个例子展示中:

  1. 使用 useMutation 创建数据变更操作
  2. 处理成功和错误情况
  3. 在成功后更新缓存
  4. 在表单提交时触发变更
  5. 显示加载状态

通过这些基本用法,你可以开始使用 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 的缓存数据

缓存时间控制

通过 staleTimegcTime 可以控制数据的缓存行为:

const { data } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    staleTime: 5 * 60 * 1000, // 数据在 5 分钟内保持新鲜
    gcTime: 10 * 60 * 1000,   // 未使用的数据在 10 分钟后被垃圾回收
});

手动更新缓存

TanStack Query 提供了多种方式来手动更新缓存数据:

  1. 使用 setQueryData 直接更新
const queryClient = useQueryClient();

// 更新单个查询的缓存
queryClient.setQueryData(['todos'], (oldData) => {
    return oldData.map(todo => 
        todo.id === updatedTodo.id ? updatedTodo : todo
    );
});
  1. 使用 invalidateQueries 使缓存失效
// 使特定查询的缓存失效
queryClient.invalidateQueries({ queryKey: ['todos'] });

// 使多个相关查询的缓存失效
queryClient.invalidateQueries({ queryKey: ['todos', 'lists'] });
  1. 使用 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} />
        </>
    );
}

配置成功后,默认会在网页的右下角显示一个按钮,效果如下:

通过合理使用这些缓存和更新机制,我们可以:

  1. 减少不必要的网络请求
  2. 提供更好的用户体验
  3. 实现离线功能
  4. 优化应用性能

六、处理分页和无限滚动

在实际开发中,我们不会一次性把全部数据请求回来,而是进入不同页码时才请求对应的数据。在处理大量数据时,分页和无限滚动是两种常用的数据加载方式。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,提供了一种简单的方式来监控元素与视口的交叉状态。

主要特点:

  1. 使用简单:提供了 useInView 钩子,使用起来非常方便
  2. 性能好:基于原生的 Intersection Observer API,性能开销小
  3. 可配置:支持多种配置选项,如阈值、根元素等
  4. 跨浏览器兼容:自动处理浏览器兼容性问题

基本用法:

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 用于检测加载更多触发器是否进入视口,从而触发加载下一页数据的操作。

下面是一个使用 useInfiniteQueryreact-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>
    );
}

当滚动条划到底部的时就自动去获取下一页的数据,如下图:

分页与无限滚动的选择

选择使用分页还是无限滚动取决于具体的应用场景:

  1. 分页适用于

    • 需要精确控制每页显示数量的场景
    • 需要快速跳转到特定页面的场景
    • 数据量相对较小且结构化的场景
  2. 无限滚动适用于

    • 内容流式的场景(如社交媒体)
    • 需要持续加载更多内容的场景
    • 移动端应用

性能优化建议

  1. 使用 keepPreviousData

    const { data } = useQuery({
        queryKey: ['products', pagination],
        queryFn: () => fetchProducts(pagination),
        placeholderData: keepPreviousData, // 在加载新数据时保留旧数据
    });
    
  2. 预取下一页数据

    const queryClient = useQueryClient();
    
    // 预取下一页数据
    queryClient.prefetchQuery({
        queryKey: ['products', { page: pagination.page + 1, limit: pagination.limit }],
        queryFn: () => fetchProducts({ page: pagination.page + 1, limit: pagination.limit }),
    });
    
  3. 使用 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,
    };
}

实现说明

  1. WebSocket 服务器

    • 使用 ws 库创建 WebSocket 服务器
    • 维护连接的客户端列表
    • 暂存客户端发送的信息
    • 广播更新给所有连接的客户端
  2. 前端实现

    • 使用 useQuery 获取初始数据
    • 使用 useEffect 建立 WebSocket 连接
    • 通过 queryClient.setQueryData 更新缓存数据
    • 使用 Ant Design 的组件展示数据

这个实现展示了如何使用 WebSocket 和 TanStack Query 来处理实时数据更新。通过这种方式,我们可以:

  • 保持数据的实时性
  • 减少不必要的轮询请求
  • 提供更好的用户体验
  • 优化服务器资源使用

八、总结

TanStack Query 是一个强大的数据获取和状态管理库,它通过以下特性显著提升了开发效率和用户体验:

  1. 自动缓存管理:智能处理数据缓存,减少重复请求,提升应用性能。
  2. 状态管理简化:自动处理加载、错误等状态,减少样板代码。
  3. 实时数据支持:通过 WebSocket 等机制支持实时数据更新。
  4. 分页与无限滚动:内置支持分页和无限滚动场景,简化复杂数据加载逻辑。
  5. 开发体验优化:提供 DevTools 等工具,方便调试和监控。

由于篇幅有限,没有概含所有 TanStack Query 的内容,只列举了一些高频的场景,如果感兴趣,可以自行阅读官网或者跟我一起探讨!