TanStack Query 实战指南:前端数据获取与缓存的终极方案

深度解析 TanStack Query(React Query)核心机制:stale-while-revalidate 缓存策略、乐观更新、无限滚动、离线支持。附完整 TypeScript 代码示例与性能对比数据,帮你彻底告别 useEffect + fetch 的手写时代。

前端开发 2026-05-29 16 分钟

2026 年,npm 周下载量突破 1200 万的 TanStack Query(前身 React Query)已经成为 React、Vue、Solid、Svelte 生态中数据获取的事实标准。它不是又一个 Axios 封装,而是一套完整的异步状态管理系统——自动缓存、后台刷新、乐观更新、离线支持、分页预取,这些过去需要数百行手写代码才能实现的功能,现在只需一个 useQuery Hook 就能搞定。如果你还在用 useEffect + useState + fetch 的老三样管理服务端数据,这篇文章将彻底改变你的前端数据层架构。

💡 提示:TanStack Query 管理的是服务端状态(Server State)——来自 API 的、可能过期的、需要同步的异步数据。它和 Zustand、Jotai 等客户端状态管理库不是竞争关系,而是互补关系。

🔧 一、核心机制:为什么 useQuery 不只是 fetch 的封装

1.1 传统方案的三大痛点

在没有 TanStack Query 的年代,一个简单的数据获取组件通常长这样:

// ❌ 传统方案:useEffect + fetch 的典型写法
import { useState, useEffect } from 'react'

function UserProfile({ userId }) {
  const [data, setData] = useState(null)
  const [isLoading, setIsLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setIsLoading(true)

    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error('请求失败')
        return res.json()
      })
      .then(data => {
        if (!cancelled) {
          setData(data)
          setIsLoading(false)
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err)
          setIsLoading(false)
        }
      })

    return () => { cancelled = true }
  }, [userId])

  if (isLoading) return <div>加载中...</div>
  if (error) return <div>出错了:{error.message}</div>
  return <div>{data.name}</div>
}

这段代码有三个致命问题:

  • 没有缓存:同一个用户数据,切换页面再回来要重新请求
  • 没有后台刷新:数据不会自动更新,用户看到的永远是旧数据
  • 竞态条件:快速切换 userId 时,后发的请求可能先返回导致数据错乱

1.2 TanStack Query 的解决方案

// ✅ TanStack Query:同样的功能,3 行搞定
import { useQuery } from '@tanstack/react-query'

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  })

  if (isLoading) return <div>加载中...</div>
  if (error) return <div>出错了:{error.message}</div>
  return <div>{data.name}</div>
}

看起来只是少了几行代码?实际上背后发生了质变:

特性 useEffect + fetch TanStack Query
自动缓存 ❌ 无 ✅ 按 queryKey 缓存
后台刷新 ❌ 无 ✅ stale-while-revalidate
竞态处理 ⚠️ 手动 cancelled ✅ 自动处理
窗口聚焦刷新 ❌ 无 ✅ 默认开启
重试机制 ⚠️ 手动实现 ✅ 默认重试 3 次
分页预取 ❌ 无 ✅ 自动预取下一页
离线支持 ❌ 无 ✅ 内置持久化
代码量(中型项目) ~500 行 ~50 行

📌 **记住:**TanStack Query 的 queryKey 不只是标识符——它是缓存系统的核心。['user', userId] 意味着每个 userId 都有独立的缓存条目,切换 userId 时自动使用对应缓存,同时在后台检查数据是否过期。

1.3 Stale-While-Revalidate 策略解析

TanStack Query 的缓存策略来自 HTTP 的 stale-while-revalidate 模型:

用户请求数据 → 有缓存且未过期 → 直接返回缓存(无网络请求)
用户请求数据 → 有缓存但已过期 → 先返回缓存,后台静默刷新
用户请求数据 → 无缓存 → loading 状态,发起网络请求

这个策略的核心思想是:用户永远不需要等待网络请求完成才能看到内容。对于内容型应用,这意味着页面切换瞬间完成,同时数据保持最新。

// TypeScript 完整配置示例
import { useQuery } from '@tanstack/react-query'

interface User {
  id: number
  name: string
  email: string
  avatar: string
}

function useUser(userId: number) {
  return useQuery<User>({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}`)
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      return res.json()
    },
    staleTime: 5 * 60 * 1000,      // 5 分钟内认为数据新鲜,不触发后台刷新
    gcTime: 30 * 60 * 1000,         // 缓存保留 30 分钟(旧称 cacheTime)
    retry: 2,                        // 失败重试 2 次
    refetchOnWindowFocus: true,      // 窗口聚焦时刷新
  })
}

⚠️ 警告:staleTimegcTime 是两个完全不同的概念。staleTime 控制"数据何时过期"(默认 0,即立即过期),gcTime 控制"未使用的缓存何时被垃圾回收"(默认 5 分钟)。很多开发者把这两个搞混,导致缓存行为不符合预期。

🚀 二、进阶模式:Mutation、乐观更新与无限滚动

2.1 useMutation:写操作的标准范式

useQuery 负责读,useMutation 负责写。但 Mutation 的核心价值不在"发送请求",而在请求成功后自动更新缓存

// ✅ useMutation 标准用法:创建用户后自动刷新列表
import { useMutation, useQueryClient } from '@tanstack/react-query'

function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (newUser: Omit<User, 'id'>) => {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser),
      })
      if (!res.ok) throw new Error('创建失败')
      return res.json()
    },
    // ✅ 成功后自动刷新用户列表缓存
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

// 使用方式
function CreateUserForm() {
  const createUser = useCreateUser()

  const handleSubmit = (formData: FormData) => {
    createUser.mutate(
      { name: formData.get('name') as string, email: formData.get('email') as string },
      {
        onSuccess: () => {
          // 表单重置、提示成功等
        },
        onError: (error) => {
          // 错误处理
        },
      }
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit" disabled={createUser.isPending}>
        {createUser.isPending ? '创建中...' : '创建用户'}
      </button>
    </form>
  )
}

2.2 乐观更新:让 UI 快过网络

乐观更新(Optimistic Update)是用户体验的杀手级特性——在服务器响应之前就更新 UI,如果请求失败则回滚。点赞、收藏、拖拽排序等场景必备。

// ✅ 乐观更新实战:点赞功能
import { useMutation, useQueryClient } from '@tanstack/react-query'

function useLikePost(postId: number) {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async () => {
      const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
      if (!res.ok) throw new Error('点赞失败')
      return res.json()
    },

    // 🚀 在 mutation 执行前,立即更新缓存(乐观更新)
    onMutate: async () => {
      // 1. 取消正在进行的查询,防止覆盖我们的乐观更新
      await queryClient.cancelQueries({ queryKey: ['post', postId] })

      // 2. 保存当前值,用于失败时回滚
      const previousPost = queryClient.getQueryData(['post', postId])

      // 3. 乐观地更新缓存
      queryClient.setQueryData(['post', postId], (old: any) => ({
        ...old,
        likes: old.likes + 1,
        isLiked: true,
      }))

      return { previousPost }
    },

    // ❌ 失败时回滚到之前的值
    onError: (_err, _variables, context) => {
      if (context?.previousPost) {
        queryClient.setQueryData(['post', postId], context.previousPost)
      }
    },

    // ✅ 无论成功或失败,最终都重新获取最新数据
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] })
    },
  })
}

⚠️ **警告:**乐观更新的 onMutate 中必须调用 cancelQueries,否则正在进行的后台刷新可能覆盖你的乐观更新,导致 UI 闪烁。这是最常见的乐观更新 Bug。

2.3 useInfiniteQuery:无限滚动的优雅实现

无限滚动(Infinite Scroll)是现代应用的标配,但手动实现分页状态管理极其繁琐。useInfiniteQuery 把这一切封装好了:

// ✅ 无限滚动:自动管理分页、预取下一页
import { useInfiniteQuery } from '@tanstack/react-query'

interface Post {
  id: number
  title: string
  content: string
}

interface PostPage {
  data: Post[]
  nextCursor: string | null  // 游标分页
}

function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam }) => {
      const res = await fetch(`/api/posts?cursor=${pageParam}&limit=20`)
      if (!res.ok) throw new Error('加载失败')
      return res.json() as Promise<PostPage>
    },
    initialPageParam: '',
    getNextPageParam: (lastPage) => lastPage.nextCursor,  // 返回 null 时停止预取
  })
}

// 组件中使用
function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfinitePosts()

  return (
    <div>
      {data?.pages.flatMap(page => page.data).map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content}</p>
        </article>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? '加载中...' : hasNextPage ? '加载更多' : '没有更多了'}
      </button>
    </div>
  )
}

💡 **提示:**TanStack Query 会自动预取下一页数据——当用户看到"加载更多"按钮时,下一页数据可能已经在后台请求完成了。这个行为可以通过 prefetchInfiniteQuery 手动控制。

💡 三、实战架构:与框架集成的最佳实践

3.1 查询客户端配置:全局统一管理

// ✅ 推荐的项目结构
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query'

export function createQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,        // 全局默认 1 分钟过期
        gcTime: 5 * 60 * 1000,       // 缓存保留 5 分钟
        retry: 1,                     // 默认重试 1 次
        refetchOnWindowFocus: false,  // 生产环境建议关闭,避免不必要的请求
      },
      mutations: {
        retry: 0,                     // 写操作不重试,避免重复提交
      },
    },
  })
}

3.2 与 Axios 搭配:统一错误处理

// ✅ 推荐:用 Axios 作为 queryFn 底层
import axios from 'axios'
import { useQuery } from '@tanstack/react-query'

const api = axios.create({ baseURL: '/api' })

// 全局拦截器处理 401、网络错误等
api.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      // 跳转登录页
      window.location.href = '/login'
    }
    return Promise.reject(err)
  }
)

// queryFn 直接用 axios
function useUser(userId: number) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: async ({ signal }) => {
      const { data } = await api.get(`/users/${userId}`, { signal })
      return data
    },
  })
}

📌 **记住:**一定要在 queryFn 中解构出 signal 并传给 axios/fetch,这样 TanStack Query 才能在组件卸载时自动取消请求,避免内存泄漏和状态更新错误。

3.3 开发者工具:调试利器

// ✅ 安装 React Query Devtools(仅开发环境)
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
    </QueryClientProvider>
  )
}

Devtools 可以实时查看:每个查询的缓存状态(fresh / stale / fetching)、缓存数据内容、请求时间线、活跃观察者数量。在排查"数据不更新"或"请求重复发送"问题时,Devtools 比 console.log 高效 10 倍。

📊 四、TanStack Query vs SWR vs RTK Query 选型对比

维度 TanStack Query SWR RTK Query
框架支持 React/Vue/Solid/Svelte/Angular React/Vue/Svelte React/Angular
缓存策略 stale-while-revalidate stale-while-revalidate 基于标签失效
乐观更新 ✅ 原生支持 ⚠️ 需手动实现 ✅ 原生支持
无限滚动 ✅ useInfiniteQuery ⚠️ 社区方案 ❌ 不支持
离线持久化 ✅ 插件支持 ❌ 不支持 ❌ 不支持
DevTools ✅ 功能丰富 ✅ 基础 ✅ RTK DevTools
包大小 ~13KB (gzip) ~4KB (gzip) ~15KB (gzip)
学习曲线 中等 高(需 Redux)
TypeScript ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐
社区生态 最活跃 活跃 Redux 生态
GitHub Stars 43K+ 30K+ Redux Toolkit 统一

⚡ **关键结论:**如果你的项目不限制技术栈,TanStack Query 是 2026 年的默认选择。SWR 适合小项目或只需要简单数据获取的场景,RTK Query 适合已经在用 Redux 的项目。

⚠️ 五、常见陷阱与避坑指南

5.1 queryKey 设计错误

// ❌ 错误:queryKey 包含对象引用(每次渲染都不同,导致无限请求)
useQuery({ queryKey: ['user', { id: userId }], queryFn: ... })

// ✅ 正确:queryKey 使用原始值
useQuery({ queryKey: ['user', userId], queryFn: ... })

// ✅ 复杂场景:使用数组展平
useQuery({ queryKey: ['posts', { page, limit, sort }], queryFn: ... })

5.2 忘记处理 loading 和 error 状态

// ❌ 错误:只处理了 data,没有处理 loading 和 error
const { data } = useQuery(...)
return <div>{data.name}</div>  // data 可能是 undefined!

// ✅ 正确:使用 status 判断
const { data, status } = useQuery(...)
if (status === 'pending') return <Skeleton />
if (status === 'error') return <ErrorMessage />
return <div>{data.name}</div>

5.3 Mutation 后忘记失效缓存

// ❌ 错误:Mutation 成功后没有刷新相关查询
useMutation({ mutationFn: deleteUser })

// ✅ 正确:失效相关缓存
useMutation({
  mutationFn: deleteUser,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['users'] })
    // 或者精确失效
    queryClient.invalidateQueries({ queryKey: ['users', 'list'] })
  },
})

5.4 组件内创建 QueryClient

// ❌ 错误:每次渲染都创建新的 QueryClient
function App() {
  const queryClient = new QueryClient()  // 每次渲染都重建缓存!
  return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}

// ✅ 正确:使用 useMemo 或在组件外创建
const queryClient = new QueryClient()
function App() {
  return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}

🎯 六、性能优化与最佳实践

**查询依赖(Dependent Queries):**当一个查询的参数依赖另一个查询的结果时,使用 enabled 选项:

// ✅ 先获取用户,再获取该用户的仓库
function useUserRepos(userId: number) {
  const user = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  const repos = useQuery({
    queryKey: ['repos', userId],
    queryFn: () => fetchRepos(userId),
    enabled: !!user.data,  // user 数据加载完成后才触发
  })

  return { user, repos }
}

**预取(Prefetching):**在用户即将访问某个页面前,提前加载数据:

// ✅ 鼠标悬停时预取详情页数据
function PostLink({ postId }: { postId: number }) {
  const queryClient = useQueryClient()

  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: ['post', postId],
      queryFn: () => fetchPost(postId),
      staleTime: 60 * 1000,  // 1 分钟内不重复预取
    })
  }

  return <Link to={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>查看</Link>
}

**全局错误处理:**设置全局的错误回调,统一处理 401、网络断开等场景:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      throwOnError: (error) => {
        // 对于严重错误(如 401),抛出让 Error Boundary 捕获
        if (error instanceof ApiError && error.status === 401) {
          return true
        }
        return false
      },
    },
  },
})

✅ 总结

TanStack Query 不是一个"可选的第三方库"——在 2026 年的前端开发中,它和 TypeScript 一样,已经成为项目基础设施的一部分。它的核心价值在于:把服务端状态管理从"手动同步"变成"声明式描述"。你只需要告诉 TanStack Query"我要什么数据",它会自动处理"什么时候获取、什么时候刷新、什么时候缓存、什么时候失效"。

起步建议:

  • ✅ 新项目直接用 TanStack Query v5(当前最新稳定版)
  • ✅ 从 useQuery 开始,逐步引入 useMutation 和乐观更新
  • ✅ 安装 DevTools,养成用 DevTools 调试缓存的习惯
  • ❌ 不要和 SWR 混用——选一个,坚持用
  • ❌ 不要在 queryFn 中做业务逻辑——它只负责获取数据

⚡ **关键结论:**如果你今天只能学一个新的前端库,选 TanStack Query。它解决的不是"怎么发请求"的问题,而是"怎么让服务端数据和 UI 保持同步"的问题——这是每个前端应用都必须面对的核心挑战。

📚 相关文章