npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qiaopeng/tanstack-query-plus

v0.5.10

Published

Enhanced TanStack Query toolkit: defaults, hooks, persistence, offline, data guard, utils

Readme

@qiaopeng/tanstack-query-plus 完整使用教程

本教程将带你从零开始,循序渐进地学习如何使用 @qiaopeng/tanstack-query-plus。每个章节都会自然地引出下一个概念,帮助你建立完整的知识体系。

目录

  1. 前言:为什么需要这个库?
  2. 安装与环境准备
  3. 第一步:配置 Provider
  4. 第二步:发起你的第一个查询
  5. 第三步:使用增强查询追踪性能
  6. 第四步:管理 Query Key
  7. 第五步:数据变更与乐观更新
  8. 第六步:无限滚动与分页
  9. 第七步:全局状态与 Mutation 监控
  10. 第八步:批量查询与仪表盘
  11. 第九步:智能预取
  12. 第十步:Suspense 模式与 SSR
  13. 第十一步:离线支持与持久化
  14. 第十二步:数据防护与安全
  15. 第十三步:焦点管理
  16. 第十四步:工具函数与选择器
  17. 最佳实践与常见问题
  18. API 索引

1. 前言:为什么需要这个库?

在使用 TanStack Query(原 React Query)时,你可能会遇到以下问题:

  • 配置繁琐:每次新项目都要重新配置 staleTime、gcTime、重试策略等
  • 缺乏最佳实践:不确定什么样的配置才是最优的
  • 重复代码:乐观更新、错误处理、性能追踪等逻辑需要反复编写
  • 离线支持复杂:实现离线队列和数据持久化需要大量代码

@qiaopeng/tanstack-query-plus 就是为了解决这些问题而生的。它在 TanStack Query v5 的基础上,提供了:

  • 🚀 开箱即用的最佳实践配置
  • 🔄 增强的 Hooks(性能追踪、慢查询检测、错误日志)
  • 💾 一键启用的持久化
  • 📡 完整的离线支持
  • 多种智能预取策略
  • 🎯 内置乐观更新

接下来,让我们一步步学习如何使用这些功能。

1.1 设计初衷与原则

  • 保持与 TanStack Query v5 完全兼容,不改变其核心行为,只做“安全增强”。
  • 提供开箱即用的最佳实践配置,减少重复劳动与认知负担。
  • 以“安全”为首要前提:数据防护、持久化安全、离线队列的稳健性、错误处理的可控性。
  • API 设计坚持渐进增强:原生用法不变,增强能力按需启用,便于迁移和学习。
  • TypeScript 友好:导出类型与范型参数与 TanStack 保持一致,避免类型陷阱。

1.2 适用场景

  • 中大型前端应用,需要统一的查询管理与最佳实践配置。
  • 有离线需求(电商、文档编辑、移动端 Web)或需要缓存持久化与恢复的场景。
  • 需要更强的乐观更新、并发冲突处理、数据一致性保障。
  • 希望最小化自定义基础设施代码,将精力聚焦在业务逻辑。

1.3 非目标与边界

  • 不替代后端的并发控制与数据一致性保障;前端 Data Guard 仅作为“最后防线”。
  • 不内置与具体后端协议的强绑定(如 GraphQL/REST 的特定实现);保持通用。
  • 不存储任何敏感凭据;持久化仅针对查询缓存,且可配置与可关闭。

1.4 安全与合规

  • 持久化默认仅保存可序列化且成功的查询数据,避免异常对象导致恢复失败(参见 createPersistOptions)。
  • 建议不要在 queryKey 中包含敏感信息(如 token、身份证号、手机号原文)。
  • DevTools 仅在开发环境启用,避免在生产泄露内部状态(参见 isDevToolsEnabled)。
  • 离线队列持久化时去除了函数体,仅存操作元数据;实际执行函数需通过注册表安全绑定。

1.5 术语速览

  • queryKey:查询的唯一标识;必须是稳定、可序列化的值(通常为数组)
  • queryFn:实际获取数据的异步函数;返回 Promise
  • staleTime:数据保持“新鲜”的时间窗口;新鲜期内不会重复请求
  • gcTime:缓存保留时间;超过后缓存会被清理
  • invalidate:标记查询为过期;下一次渲染或焦点恢复时会重新请求
  • refetch:主动重新请求数据
  • persist:将查询缓存持久化到存储(localStorage/IndexedDB)并在刷新后恢复
  • offline:网络不可用时的状态;本库提供队列与自动恢复机制
  • optimistic update:先更新 UI,再与服务端同步;失败时需回滚
  • Data Guard:防止旧数据覆盖新数据的前端机制(版本/时间戳/哈希比对)

2. 安装与环境准备

2.1 安装核心依赖

首先,安装必需的包:

npm install @qiaopeng/tanstack-query-plus @tanstack/react-query @tanstack/react-query-persist-client

这三个包的作用分别是:

  • @qiaopeng/tanstack-query-plus:本库,提供增强功能
  • @tanstack/react-query:TanStack Query 核心库
  • @tanstack/react-query-persist-client:持久化支持

2.2 安装可选依赖

根据你的需求,可以选择安装以下可选依赖:

# 开发调试工具(强烈推荐在开发环境使用)
npm install @tanstack/react-query-devtools

# 视口预取功能(如果需要 useInViewPrefetch)
npm install react-intersection-observer

# 路由预取示例所需(如果使用 useRoutePrefetch 示例中的 Link/useNavigate)
npm install react-router-dom

2.3 环境要求

确保你的项目满足以下要求:

  • Node.js >= 16
  • React >= 18
  • TypeScript(推荐,但非必需)

现在环境准备好了,让我们开始配置应用。

2.4 学习路径与检查清单

严格建议按照以下顺序学习与落地,并在每一步完成后进行自检:

  1. 安装依赖:确保安装本库及 peer 依赖(@tanstack/react-queryreactreact-dom),按需安装 devtoolsreact-intersection-observerreact-router-dom
  2. 创建 QueryClient:使用 GLOBAL_QUERY_CONFIG,避免随意调整 retrystaleTime 造成请求风暴
  3. 包裹应用:使用 PersistQueryClientProvider 开启持久化与离线支持(生产环境建议保留持久化)
  4. 添加 DevTools(开发环境):isDevToolsEnabled() 控制显示,严禁在生产强制开启
  5. 发起首个查询:优先使用 useEnhancedQuery,在慢查询或错误场景验证日志输出
  6. 增强 Mutation:在列表 CRUD 场景启用乐观更新,并验证回滚路径与错误处理
  7. 离线与持久化:断网测试页面行为;验证缓存恢复与离线队列的稳健性
  8. 数据防护(可选但推荐):开启 Data Guard 的版本/时间戳/哈希策略,防止旧数据覆盖
  9. 焦点管理:按照业务需要控制窗口聚焦时的刷新频率,避免抖动
  10. 预取:根据网络情况与页面流量,按需启用悬停/视口/路由/空闲预取,避免过度预取

完成以上 10 点后,再进入“最佳实践与常见问题”章节进行整体检查与性能、安全优化。

2.5 五分钟上手示例

目标:用 5 分钟完成“配置 Provider + 首个查询 + DevTools 调试”。

  1. 安装依赖:
npm install @qiaopeng/tanstack-query-plus @tanstack/react-query @tanstack/react-query-persist-client
npm install @tanstack/react-query-devtools --save-dev
  1. 创建 Provider:
// main.tsx
import { QueryClient, PersistQueryClientProvider } from '@qiaopeng/tanstack-query-plus'
import { GLOBAL_QUERY_CONFIG } from '@qiaopeng/tanstack-query-plus/core'
import { ReactQueryDevtools, isDevToolsEnabled } from '@qiaopeng/tanstack-query-plus/core/devtools'

const queryClient = new QueryClient({ defaultOptions: GLOBAL_QUERY_CONFIG })

function Providers({ children }) {
  return (
    <PersistQueryClientProvider client={queryClient}>
      {children}
      {isDevToolsEnabled() && <ReactQueryDevtools initialIsOpen={false} />}
    </PersistQueryClientProvider>
  )
}
  1. 发起首个查询:
// App.tsx
import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'

export default function App() {
  const { data, isLoading, isError } = useEnhancedQuery({
    queryKey: ['hello'],
    queryFn: async () => ({ message: 'Hello Query Plus' }),
  })
  if (isLoading) return <div>加载中...</div>
  if (isError) return <div>加载失败</div>
  return <div>{data.message}</div>
}
  1. 跑起来:在浏览器中打开 DevTools 面板,查看 ['hello'] 查询状态。

2.6 TypeScript 配置建议

以下 tsconfig.json 选项可以帮助初学者避免常见类型问题:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Node",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "useUnknownInCatchVariables": true
  }
}

说明:

  • strict: true 有助于暴露隐含的 any 与未处理的 undefined
  • skipLibCheck: true 可避免第三方库类型检查的噪音(对本库安全)
  • useUnknownInCatchVariables 提醒你显式处理错误类型

3. 第一步:配置 Provider

任何使用 TanStack Query 的应用都需要一个 Provider 来提供 QueryClient 实例。本库提供了一个增强版的 Provider,让配置变得更简单。

3.1 最简配置

最简单的配置只需要几行代码:

// App.tsx
import { QueryClient, PersistQueryClientProvider } from '@qiaopeng/tanstack-query-plus'
import { GLOBAL_QUERY_CONFIG } from '@qiaopeng/tanstack-query-plus/core'

// 创建 QueryClient,使用预配置的最佳实践
const queryClient = new QueryClient({
  defaultOptions: GLOBAL_QUERY_CONFIG
})

function App() {
  return (
    <PersistQueryClientProvider client={queryClient}>
      <YourApp />
    </PersistQueryClientProvider>
  )
}

这段代码做了什么?

  1. 创建 QueryClient:使用 GLOBAL_QUERY_CONFIG 预配置,包含了经过优化的默认值
  2. 包裹应用PersistQueryClientProvider 让所有子组件都能访问 QueryClient

3.2 启用持久化和离线支持

PersistQueryClientProvider 默认就启用了持久化和离线支持(enablePersistenceenableOfflineSupport 默认都是 true)。如果你想显式配置或禁用某些功能:

<PersistQueryClientProvider 
  client={queryClient}
  enablePersistence={true}    // 启用 localStorage 持久化(默认 true)
  enableOfflineSupport={true} // 启用离线状态监听(默认 true)
  cacheKey="my-app-cache"     // 自定义缓存 key(默认 'tanstack-query-cache')
  onPersistRestore={() => console.log('缓存已恢复')}  // 缓存恢复回调
  onPersistError={(err) => console.error('持久化错误', err)}
>
  <YourApp />
</PersistQueryClientProvider>

enablePersistence 的作用:

  • 自动将查询缓存保存到 localStorage
  • 页面刷新后自动恢复缓存数据
  • 用户可以立即看到上次的数据,无需等待网络请求
  • 设为 false 可禁用持久化

enableOfflineSupport 的作用:

  • 监听网络状态变化
  • 离线时暂停请求,在线时自动恢复
  • 配合离线队列管理器使用
  • 设为 false 可禁用离线支持

3.3 理解预配置

GLOBAL_QUERY_CONFIG 包含了以下默认值:

{
  queries: {
    staleTime: 30000,
    gcTime: 600000,
    retry: defaultQueryRetryStrategy,  // 智能重试:4XX不重试,5XX最多1次
    retryDelay: exponentialBackoff,
    refetchOnWindowFocus: true,
    refetchOnReconnect: true,
    refetchOnMount: true,
  },
  mutations: {
    retry: 0,  // Mutation 默认不重试
    retryDelay: exponentialBackoff,
    gcTime: 600000,
  }
}

重试策略说明:

  • Query 重试defaultQueryRetryStrategy):
    • 4XX 客户端错误:不重试(客户端问题,重试无意义)
    • 5XX 服务端错误:最多重试 1 次(避免过度重试)
    • 网络错误:最多重试 2 次
  • Mutation 重试defaultMutationRetryStrategy):
    • 4XX 客户端错误:不重试
    • 5XX 服务端错误:不重试(避免重复操作)
    • 网络错误:最多重试 1 次

这些值是经过实践验证的最佳实践,适合大多数应用场景。

3.4 根据环境选择配置

本库还提供了针对不同环境的预配置:

import { getConfigByEnvironment } from '@qiaopeng/tanstack-query-plus/core'

// 根据环境自动选择配置
const env =
  process.env.NODE_ENV === 'production'
    ? 'production'
    : process.env.NODE_ENV === 'test'
      ? 'test'
      : 'development'
const config = getConfigByEnvironment(env)
const queryClient = new QueryClient({ defaultOptions: config })

不同环境的配置差异:

| 配置项 | development | production | test | |--------|-------------|------------|------| | staleTime | 0 | 10 分钟 | 0 | | retry (Query) | 智能重试* | 智能重试* | 0 | | retry (Mutation) | 0 | 0 | 0 | | refetchOnWindowFocus | true | true | false |

*智能重试:4XX 不重试,5XX 最多 1 次,网络错误最多 2 次

补充:还支持 getConfigByEnvironment('longCache')getConfigByEnvironment('realtime') 两种预设,分别适用于“长缓存”与“高实时”场景。

3.5 自定义重试策略

如果默认的重试策略不满足你的需求,可以使用 createSafeRetryStrategycreateErrorSafeConfig 来自定义:

import { 
  createSafeRetryStrategy, 
  createErrorSafeConfig 
} from '@qiaopeng/tanstack-query-plus/core'

// 方式一:创建自定义重试策略
const customRetry = createSafeRetryStrategy(
  0,  // 4XX 错误重试次数
  1,  // 5XX 错误重试次数
  2   // 其他错误重试次数
)

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: customRetry,
    }
  }
})

// 方式二:使用错误安全配置(推荐)
const errorSafeConfig = createErrorSafeConfig({
  maxRetries4xx: 0,       // 4XX 不重试
  maxRetries5xx: 0,       // 5XX 不重试(严格模式)
  maxRetriesOther: 1,     // 网络错误最多 1 次
  disableFocus: false,    // 是否禁用窗口聚焦时 refetch
  disableReconnect: false // 是否禁用重连时 refetch
})

const queryClient = new QueryClient({
  defaultOptions: errorSafeConfig
})

使用场景:

  1. 严格模式:完全禁用 4XX/5XX 重试,避免不必要的请求
  2. 宽松模式:增加重试次数,适合网络不稳定的环境
  3. 自定义场景:根据业务需求精确控制重试行为

3.6 添加 DevTools(开发环境)

在开发环境中,强烈建议添加 DevTools 来调试查询状态:

import { ReactQueryDevtools, isDevToolsEnabled } from '@qiaopeng/tanstack-query-plus/core/devtools'

function App() {
  return (
    <PersistQueryClientProvider client={queryClient}>
      <YourApp />
      {isDevToolsEnabled() && <ReactQueryDevtools initialIsOpen={false} />}
    </PersistQueryClientProvider>
  )
}

DevTools 可以让你:

  • 查看所有查询的状态
  • 手动触发 refetch
  • 查看缓存数据
  • 调试查询问题

现在 Provider 配置好了,让我们开始发起第一个查询!


4. 第二步:发起你的第一个查询

配置好 Provider 后,我们就可以在组件中使用查询了。

4.1 基础查询

最基本的查询可以使用 TanStack Query 原生的 useQuery,或者本库提供的增强版 useEnhancedQuery

// 方式一:使用 TanStack Query 原生 useQuery
import { useQuery } from '@tanstack/react-query'

// 方式二:使用本库的增强版(推荐,支持性能追踪等功能)
import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'

function UserProfile({ userId }) {
  // 两者用法相同,useEnhancedQuery 额外支持性能追踪
  const { data, isLoading, isError, error } = useEnhancedQuery({
    queryKey: ['user', userId],  // 查询的唯一标识
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),  // 获取数据的函数
  })

  if (isLoading) return <div>加载中...</div>
  if (isError) return <div>错误: {error.message}</div>
  
  return <div>用户名: {data.name}</div>
}

关键概念解释:

  1. queryKey:查询的唯一标识符,是一个数组。TanStack Query 用它来:

    • 缓存数据
    • 判断是否需要重新请求
    • 在多个组件间共享数据
  2. queryFn:实际获取数据的异步函数。可以是 fetch、axios 或任何返回 Promise 的函数。

  3. 返回值

    • data:查询成功后的数据
    • isLoading:首次加载中
    • isError:是否出错
    • error:错误对象

4.2 条件查询

有时候我们需要在满足某些条件时才发起查询:

import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'

function UserProfile({ userId }) {
  const { data } = useEnhancedQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    enabled: !!userId,  // 只有 userId 存在时才查询
  })
  
  // ...
}

4.3 使用 skipToken 禁用查询

另一种禁用查询的方式是使用 skipToken

import { useEnhancedQuery, skipToken } from '@qiaopeng/tanstack-query-plus/hooks'

function UserProfile({ userId }) {
  const { data } = useEnhancedQuery({
    queryKey: ['user', userId],
    queryFn: userId ? () => fetchUser(userId) : skipToken,
  })
  
  // ...
}

注意skipToken 也可以从 @qiaopeng/tanstack-query-plus 主包导入,或者从 @tanstack/react-query 导入。

skipToken 的好处是 TypeScript 类型推断更准确。

4.4 自定义缓存时间

你可以为特定查询设置不同的缓存策略:

import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'

const { data } = useEnhancedQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 5 * 60 * 1000,  // 5 分钟内数据视为新鲜
  gcTime: 30 * 60 * 1000,    // 缓存保留 30 分钟
})

staleTime vs gcTime 的区别:

  • staleTime:数据被认为是"新鲜"的时间。在这段时间内,即使组件重新挂载,也不会重新请求。
  • gcTime:数据在缓存中保留的时间。超过这个时间,数据会被垃圾回收。

现在你已经会发起基本查询了。但在实际项目中,我们往往需要追踪查询性能、检测慢查询。这就是增强查询的用武之地。


5. 第三步:使用增强查询追踪性能

useEnhancedQuery 是本库的核心 Hook 之一,它在原生 useQuery 的基础上增加了性能追踪、慢查询检测和错误日志功能。

5.1 基本使用

import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'

function UserProfile({ userId }) {
  const { 
    data, 
    isLoading, 
    isError, 
    error,
    // 增强的返回值
    refetchCount,       // 重新获取次数
    lastQueryDuration   // 最后一次查询耗时(毫秒)
  } = useEnhancedQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  if (isLoading) return <div>加载中...</div>
  if (isError) return <div>错误: {error.message}</div>
  
  return (
    <div>
      <h1>{data.name}</h1>
      <p className="text-sm text-gray-500">
        查询耗时: {lastQueryDuration}ms | 刷新次数: {refetchCount}
      </p>
    </div>
  )
}

5.2 启用性能追踪

要追踪查询性能,需要显式启用 trackPerformance

const { data, lastQueryDuration } = useEnhancedQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  trackPerformance: true,  // 启用性能追踪
})

启用后,lastQueryDuration 会记录每次查询的耗时。

5.3 检测慢查询

在生产环境中,检测慢查询对于性能优化至关重要:

const { data } = useEnhancedQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  trackPerformance: true,
  slowQueryThreshold: 2000,  // 超过 2 秒视为慢查询
  onSlowQuery: (duration, queryKey) => {
    // 上报到监控系统
    analytics.track('slow_query', {
      queryKey: JSON.stringify(queryKey),
      duration,
    })
    console.warn(`慢查询警告: ${JSON.stringify(queryKey)} 耗时 ${duration}ms`)
  },
})

实际应用场景:

  1. 性能监控:将慢查询上报到 APM 系统(如 Sentry、DataDog)
  2. 开发调试:在开发环境中快速发现性能问题
  3. 用户体验优化:识别需要优化的 API 接口

5.4 错误日志

useEnhancedQuery 默认在开发环境自动记录错误:

const { data } = useEnhancedQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  logErrors: true,  // 默认在开发环境为 true
})

当查询出错时,控制台会输出:

[useEnhancedQuery Error] ["user","123"]: Error: Network request failed

如果你想在生产环境禁用错误日志:

logErrors: process.env.NODE_ENV === 'development'

5.5 完整示例:带监控的用户详情页

import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'

function UserDetailPage({ userId }) {
  const { 
    data: user, 
    isLoading, 
    isError, 
    error,
    refetchCount,
    lastQueryDuration,
    refetch
  } = useEnhancedQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`/api/users/${userId}`)
      if (!response.ok) throw new Error('获取用户失败')
      return response.json()
    },
    trackPerformance: true,
    slowQueryThreshold: 3000,
    onSlowQuery: (duration, queryKey) => {
      // 发送到监控系统
      reportSlowQuery({ queryKey, duration })
    },
  })

  if (isLoading) {
    return <LoadingSkeleton />
  }

  if (isError) {
    return (
      <ErrorDisplay 
        message={error.message} 
        onRetry={() => refetch()} 
      />
    )
  }

  return (
    <div>
      <UserCard user={user} />
      
      {/* 开发环境显示调试信息 */}
      {process.env.NODE_ENV === 'development' && (
        <div className="mt-4 p-2 bg-gray-100 text-xs">
          <p>查询耗时: {lastQueryDuration}ms</p>
          <p>刷新次数: {refetchCount}</p>
        </div>
      )}
    </div>
  )
}

现在你已经掌握了增强查询的使用。但你可能注意到,我们一直在手写 queryKey,比如 ['user', userId]。随着项目变大,管理这些 key 会变得困难。接下来,让我们学习如何优雅地管理 Query Key。


6. 第四步:管理 Query Key

Query Key 是 TanStack Query 的核心概念。好的 Key 管理策略可以让你的代码更易维护、更不容易出错。

6.1 为什么需要管理 Query Key?

考虑以下场景:

// 组件 A
useQuery({ queryKey: ['user', userId], ... })

// 组件 B
useQuery({ queryKey: ['users', userId], ... })  // 拼写错误!

// 组件 C - 需要失效用户缓存
queryClient.invalidateQueries({ queryKey: ['user', userId] })

问题:

  1. 拼写错误导致缓存不共享
  2. 修改 key 结构时需要全局搜索替换
  3. 没有类型提示

6.2 使用内置的 Key 工厂

本库提供了一套预定义的 Key 工厂:

import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'

// 用户相关
queryKeys.users()              // ['tanstack-query', 'users']
queryKeys.user('123')          // ['tanstack-query', 'users', '123']
queryKeys.userProfile('123')   // ['tanstack-query', 'users', '123', 'profile']
queryKeys.userSettings('123')  // ['tanstack-query', 'users', '123', 'settings']
queryKeys.usersByRole('admin') // ['tanstack-query', 'users', 'by-role', 'admin']

// 文章相关
queryKeys.posts()              // ['tanstack-query', 'posts']
queryKeys.post('456')          // ['tanstack-query', 'posts', '456']
queryKeys.postsByUser('123')   // ['tanstack-query', 'posts', 'by-user', '123']
queryKeys.postComments('456')  // ['tanstack-query', 'posts', '456', 'comments']

// 搜索
queryKeys.search('react', 'posts')  // ['tanstack-query', 'search', { query: 'react', type: 'posts' }]

// 通知
queryKeys.notifications()           // ['tanstack-query', 'notifications']
queryKeys.unreadNotifications()     // ['tanstack-query', 'notifications', 'unread']

使用示例:

import { queryKeys } from '@qiaopeng/tanstack-query-plus/core'
import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'

function UserProfile({ userId }) {
  const { data } = useEnhancedQuery({
    queryKey: queryKeys.user(userId),  // 类型安全,不会拼错
    queryFn: () => fetchUser(userId),
  })
  
  // ...
}

// 失效缓存时也使用同样的 key
function useUpdateUser() {
  const queryClient = useQueryClient()
  
  return useMutation({
    mutationFn: updateUser,
    onSuccess: (_, { userId }) => {
      // 失效该用户的所有相关缓存
      queryClient.invalidateQueries({ queryKey: queryKeys.user(userId) })
    }
  })
}

6.3 创建自定义域 Key 工厂

对于内置 Key 工厂没有覆盖的业务领域,可以创建自定义工厂:

import { createDomainKeyFactory } from '@qiaopeng/tanstack-query-plus/core'

// 创建产品域的 Key 工厂
const productKeys = createDomainKeyFactory('products')

productKeys.all()              // ['tanstack-query', 'products']
productKeys.lists()            // ['tanstack-query', 'products', 'list']
productKeys.list({ page: 1 })  // ['tanstack-query', 'products', 'list', { page: 1 }]
productKeys.details()          // ['tanstack-query', 'products', 'detail']
productKeys.detail('abc')      // ['tanstack-query', 'products', 'detail', 'abc']
productKeys.subResource('abc', 'reviews')  // ['tanstack-query', 'products', 'detail', 'abc', 'reviews']
productKeys.byRelation('category', 'electronics')  // ['tanstack-query', 'products', 'by-category', 'electronics']

实际项目中的组织方式:

// src/queries/keys.ts
import { createDomainKeyFactory } from '@qiaopeng/tanstack-query-plus/core'

export const productKeys = createDomainKeyFactory('products')
export const orderKeys = createDomainKeyFactory('orders')
export const cartKeys = createDomainKeyFactory('cart')
export const reviewKeys = createDomainKeyFactory('reviews')

// 使用
import { productKeys } from '@/queries/keys'

useQuery({
  queryKey: productKeys.detail(productId),
  queryFn: () => fetchProduct(productId),
})

6.4 高级 Key 工具函数

本库还提供了一些高级的 Key 工具函数:

import { 
  createFilteredKey,
  createPaginatedKey,
  createSortedKey,
  createSearchKey,
  createComplexKey,
  matchesKeyPattern,
  areKeysEqual
} from '@qiaopeng/tanstack-query-plus/core'

// 带筛选的 Key
const filteredKey = createFilteredKey(
  productKeys.lists(), 
  { category: 'electronics', inStock: true }
)
// ['tanstack-query', 'products', 'list', 'filtered', { category: 'electronics', inStock: true }]

// 带分页的 Key
const paginatedKey = createPaginatedKey(productKeys.lists(), 1, 20)
// ['tanstack-query', 'products', 'list', 'paginated', { page: 1, pageSize: 20 }]

// 带排序的 Key
const sortedKey = createSortedKey(productKeys.lists(), 'price', 'desc')
// ['tanstack-query', 'products', 'list', 'sorted', { sortBy: 'price', sortOrder: 'desc' }]

// 复杂查询 Key(组合多个条件)
const complexKey = createComplexKey(productKeys.lists(), {
  page: 1,
  pageSize: 20,
  filters: { category: 'electronics' },
  sortBy: 'price',
  sortOrder: 'desc',
  search: 'phone'
})

// 检查 Key 是否匹配模式
const matches = matchesKeyPattern(
  ['tanstack-query', 'products', 'detail', '123'],
  ['tanstack-query', 'products']  // 模式
)
// true - 可用于批量失效

// 比较两个 Key 是否相等
const equal = areKeysEqual(key1, key2)

6.5 Mutation Key 工厂

除了查询 Key,mutation 也可以有 Key(用于去重、追踪等):

import { createMutationKeyFactory } from '@qiaopeng/tanstack-query-plus/core'

const productMutations = createMutationKeyFactory('products')

productMutations.create()        // ['products', 'create']
productMutations.update('123')   // ['products', 'update', '123']
productMutations.delete('123')   // ['products', 'delete', '123']
productMutations.batch('archive') // ['products', 'batch', 'archive']

现在你已经掌握了 Query Key 的管理。接下来,让我们学习如何进行数据变更(Mutation)以及如何实现乐观更新。


7. 第五步:数据变更与乐观更新

查询(Query)用于获取数据,而变更(Mutation)用于创建、更新或删除数据。本库的 useMutation 提供了内置的乐观更新支持,让用户体验更流畅。

7.1 基础 Mutation

最基本的 mutation 使用:

import { useMutation, useQueryClient } from '@qiaopeng/tanstack-query-plus'

function UpdateUserButton({ userId }) {
  const queryClient = useQueryClient()
  
  const mutation = useMutation({
    mutationFn: (newName) => 
      fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        body: JSON.stringify({ name: newName })
      }).then(r => r.json()),
    onSuccess: () => {
      // 成功后刷新用户数据
      queryClient.invalidateQueries({ queryKey: ['user', userId] })
    },
    onError: (error) => {
      alert(`更新失败: ${error.message}`)
    }
  })

  return (
    <button 
      onClick={() => mutation.mutate('新名字')}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? '更新中...' : '更新名字'}
    </button>
  )
}

7.2 什么是乐观更新?

传统流程:

  1. 用户点击"更新"
  2. 显示 loading
  3. 等待服务器响应
  4. 更新 UI

乐观更新流程:

  1. 用户点击"更新"
  2. 立即更新 UI(假设会成功)
  3. 后台发送请求
  4. 如果失败,回滚到之前的状态

乐观更新让用户感觉应用响应更快,体验更好。

7.3 使用内置乐观更新

本库的 useMutation 内置了乐观更新支持,无需手写复杂的 onMutate/onError 逻辑:

import { useMutation } from '@qiaopeng/tanstack-query-plus/hooks'

function UpdateUserName({ userId, currentName }) {
  const mutation = useMutation({
    mutationFn: (newName) => updateUserAPI(userId, { name: newName }),
    
    // 乐观更新配置
    optimistic: {
      queryKey: ['user', userId],  // 要更新的缓存 key
      
      // 更新函数:接收旧数据和变量,返回新数据
      updater: (oldData, newName) => ({
        ...oldData,
        name: newName
      }),
      
      // 回滚回调(可选):失败时执行
      rollback: (previousData, error) => {
        console.error('更新失败,已回滚:', error.message)
        toast.error(`更新失败: ${error.message}`)
      }
    },
    
    // 标准回调仍然可用
    onSuccess: () => {
      toast.success('更新成功')
    }
  })

  return (
    <button onClick={() => mutation.mutate('新名字')}>
      更新名字
    </button>
  )
}

工作原理:

  1. 调用 mutation.mutate('新名字') 时:

    • 取消该 queryKey 的进行中请求
    • 保存当前缓存数据(用于回滚)
    • 调用 updater 立即更新缓存
    • 发送实际请求
  2. 如果请求成功:

    • 自动失效该 queryKey,触发重新获取最新数据
    • 调用 onSuccess 回调
  3. 如果请求失败:

    • 自动回滚到之前的数据
    • 调用 rollback 回调
    • 调用 onError 回调

7.4 字段映射

有时候 mutation 的变量名和缓存数据的字段名不一致,可以使用字段映射:

const mutation = useMutation({
  mutationFn: ({ newTitle }) => updateTodo(todoId, { title: newTitle }),
  
  optimistic: {
    queryKey: ['todo', todoId],
    updater: (oldData, variables) => ({
      ...oldData,
      ...variables  // 映射后的变量会自动应用
    }),
    // 将 mutation 变量的 newTitle 映射到缓存数据的 title
    fieldMapping: {
      'newTitle': 'title'
    }
  }
})

// 调用时
mutation.mutate({ newTitle: '新标题' })
// 缓存会更新 title 字段

7.5 条件性乐观更新

本库未提供单独的 useConditionalOptimisticMutation。如需按条件启用乐观更新,使用以下两种安全模式:

  1. 使用两个 mutation,根据条件选择调用哪个(最清晰、类型安全):
import { useMutation } from '@qiaopeng/tanstack-query-plus/hooks'

const optimisticUpdate = useMutation({
  mutationFn: updateTodo,
  optimistic: {
    queryKey: ['todos'],
    updater: (oldTodos, updatedTodo) => oldTodos?.map(t => t.id === updatedTodo.id ? { ...t, ...updatedTodo } : t)
  }
})

const plainUpdate = useMutation({ mutationFn: updateTodo })

function save(todo) {
  const shouldOptimistic = todo.priority === 'high'
  const runner = shouldOptimistic ? optimisticUpdate : plainUpdate
  runner.mutate(todo)
}
  1. 基于状态切换 optimistic.enabled(适合全局开关):
// 以应用自身配置或组件状态为准(此处仅示例)
const enableOptimistic = true
const mutation = useMutation({
  mutationFn: updateTodo,
  optimistic: {
    queryKey: ['todos'],
    enabled: enableOptimistic,
    updater: (oldTodos, updatedTodo) => oldTodos?.map(t => t.id === updatedTodo.id ? { ...t, ...updatedTodo } : t)
  }
})

7.6 列表操作的简化 Mutation

对于常见的列表 CRUD 操作,可以使用 useListMutation

import { useListMutation } from '@qiaopeng/tanstack-query-plus/hooks'

function TodoList() {
  const mutation = useListMutation(
    async ({ operation, data }) => {
      switch (operation) {
        case 'create':
          return api.createTodo(data)
        case 'update':
          return api.updateTodo(data.id, data)
        case 'delete':
          return api.deleteTodo(data.id)
      }
    },
    ['todos']  // 操作完成后自动失效这个 queryKey
  )

  const handleCreate = () => {
    mutation.mutate({ 
      operation: 'create', 
      data: { title: '新任务', done: false } 
    })
  }

  const handleUpdate = (todo) => {
    mutation.mutate({ 
      operation: 'update', 
      data: { ...todo, done: !todo.done } 
    })
  }

  const handleDelete = (todoId) => {
    mutation.mutate({ 
      operation: 'delete', 
      data: { id: todoId } 
    })
  }

  // ...
}

7.7 批量 Mutation

本库未提供 useBatchMutation。进行批量操作时,推荐两种模式:

  1. 在一个 mutation 中封装批量逻辑(一次请求或并发 Promise):
import { useMutation } from '@qiaopeng/tanstack-query-plus/hooks'

const batchDelete = useMutation({
  mutationFn: async (ids: string[]) => {
    return Promise.all(ids.map(id => api.deleteTodo(id)))
  },
  optimistic: {
    queryKey: ['todos'],
    updater: (old, ids: string[]) => old?.filter(t => !ids.includes(String(t.id)))
  }
})

// 使用
batchDelete.mutate(['id1', 'id2', 'id3'])
  1. 使用离线队列在恢复网络后批量执行(稳健且可持久化):
import { createOfflineQueueManager, mutationRegistry, serializeMutationKey } from '@qiaopeng/tanstack-query-plus/features'
import { MutationOperationType } from '@qiaopeng/tanstack-query-plus/types'

const queue = createOfflineQueueManager({ storageKey: 'todo-ops', concurrency: 3 })

async function registerDelete(id: string) {
  const key = serializeMutationKey(['todos', 'delete', id])
  mutationRegistry.register(key, () => api.deleteTodo(id))
  await queue.add({
    type: MutationOperationType.DELETE,
    mutationKey: ['todos', 'delete', id],
    variables: { id },
    mutationFn: () => api.deleteTodo(id),
    priority: 1
  })
}

7.8 乐观更新工具函数

本库还提供了一些工具函数来简化列表的乐观更新:

import { 
  listUpdater,
  createAddItemConfig,
  createUpdateItemConfig,
  createRemoveItemConfig,
  batchUpdateItems,
  batchRemoveItems,
  reorderItems,
  conditionalUpdateItems
} from '@qiaopeng/tanstack-query-plus/utils'

// 列表更新器(要求列表项有 id 字段)
const list1 = listUpdater.add(items, newItem)      // 添加到头部(如果 id 已存在则更新)
const list2 = listUpdater.update(items, { id: '1', title: '新标题' })  // 更新项
const list3 = listUpdater.remove(items, '1')       // 按 id 移除项

// 创建预配置的乐观更新配置(返回 { queryKey, updater, rollback?, enabled } 对象)
const addConfig = createAddItemConfig(['todos'], { 
  addToTop: true,  // 默认 true,添加到头部
  onRollback: (error) => console.error('添加失败:', error)
})
const updateConfig = createUpdateItemConfig(['todos'])
const removeConfig = createRemoveItemConfig(['todos'])

// 在 mutation 中使用这些配置
const addMutation = useMutation({
  mutationFn: createTodo,
  optimistic: addConfig,  // 直接使用预配置
})

// 批量更新(每个更新对象必须包含 id)
const list4 = batchUpdateItems(items, [
  { id: '1', done: true },
  { id: '2', done: true }
])

// 批量移除
const list5 = batchRemoveItems(items, ['1', '2', '3'])

// 重新排序(将 fromIndex 位置的项移动到 toIndex)
const list6 = reorderItems(items, 0, 2)  // 将第一项移到第三位

// 条件更新(满足条件的项才更新)
const list7 = conditionalUpdateItems(
  items,
  (item) => item.status === 'pending',  // 条件
  (item) => ({ status: 'completed' })   // 更新内容
)

7.9 完整示例:Todo 应用

import { useEnhancedQuery, useMutation } from '@qiaopeng/tanstack-query-plus/hooks'
import { listUpdater } from '@qiaopeng/tanstack-query-plus/utils'

function TodoApp() {
  // 查询 todos
  const { data: todos, isLoading } = useEnhancedQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  // 添加 todo(乐观更新)
  const addMutation = useMutation({
    mutationFn: (title) => api.createTodo({ title, done: false }),
    optimistic: {
      queryKey: ['todos'],
      updater: (oldTodos, title) => [
        { id: `temp-${Date.now()}`, title, done: false },
        ...(oldTodos || [])
      ],
      rollback: (_, error) => toast.error(`添加失败: ${error.message}`)
    }
  })

  // 切换完成状态(乐观更新)
  const toggleMutation = useMutation({
    mutationFn: (todo) => api.updateTodo(todo.id, { done: !todo.done }),
    optimistic: {
      queryKey: ['todos'],
      updater: (oldTodos, todo) => 
        oldTodos?.map(t => t.id === todo.id ? { ...t, done: !t.done } : t),
    }
  })

  // 删除 todo(乐观更新)
  const deleteMutation = useMutation({
    mutationFn: (todoId) => api.deleteTodo(todoId),
    optimistic: {
      queryKey: ['todos'],
      updater: (oldTodos, todoId) => oldTodos?.filter(t => t.id !== todoId),
    }
  })

  if (isLoading) return <div>加载中...</div>

  return (
    <div>
      <AddTodoForm onAdd={(title) => addMutation.mutate(title)} />
      
      <ul>
        {todos?.map(todo => (
          <li key={todo.id}>
            <input 
              type="checkbox" 
              checked={todo.done}
              onChange={() => toggleMutation.mutate(todo)}
            />
            <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
              {todo.title}
            </span>
            <button onClick={() => deleteMutation.mutate(todo.id)}>
              删除
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
  }
}

7.10 安全提示

  • 明确回滚路径:在 onErrorrollback 中恢复缓存或触发重新拉取
  • 稳定的 queryKey:使用 Key 工厂,避免结构漂移导致更新不到位
  • 变量安全:Mutation 变量不包含敏感信息(如 token),错误上报需脱敏
  • 冲突处理:对 409 触发家族失效与 UI 提示;对 500 展示兜底提示并记录错误

现在你已经掌握了数据变更和乐观更新。接下来,让我们学习如何处理无限滚动和分页场景。


8. 第六步:无限滚动与分页

无限滚动是现代应用中常见的交互模式。本库提供了 useEnhancedInfiniteQuery 和多种分页模式的工厂函数,让实现变得简单。

8.1 理解三种分页模式

在实际项目中,后端 API 通常采用以下三种分页方式之一:

  1. 游标分页(Cursor Pagination)

    • 使用游标(通常是最后一条记录的 ID)来获取下一页
    • 适合:社交媒体 feed、聊天记录
    • 示例:/api/posts?cursor=abc123
  2. 偏移分页(Offset Pagination)

    • 使用 offset 和 limit 来获取数据
    • 适合:传统列表、搜索结果
    • 示例:/api/posts?offset=20&limit=10
  3. 页码分页(Page Number Pagination)

    • 使用页码来获取数据
    • 适合:传统分页 UI
    • 示例:/api/posts?page=2

8.2 游标分页

import { 
  useEnhancedInfiniteQuery, 
  createCursorPaginationOptions 
} from '@qiaopeng/tanstack-query-plus/hooks'

// 假设 API 返回格式:
// { items: [...], cursor: 'next-cursor' | null }

function PostFeed() {
  // 创建游标分页配置
  const options = createCursorPaginationOptions({
    queryKey: ['posts', 'feed'],
    queryFn: async (cursor) => {
      const url = cursor 
        ? `/api/posts?cursor=${cursor}` 
        : '/api/posts'
      const response = await fetch(url)
      return response.json()
      // 返回 { items: Post[], cursor: string | null }
    },
    initialCursor: null,  // 初始游标
    staleTime: 30000,
  })

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useEnhancedInfiniteQuery(options)

  if (isLoading) return <div>加载中...</div>

  return (
    <div>
      {/* 展平所有页的数据 */}
      {data?.pages.map((page, pageIndex) => (
        <div key={pageIndex}></div>      {page.items.map(post => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}

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

8.3 偏移分页

import { 
  useEnhancedInfiniteQuery, 
  createOffsetPaginationOptions 
} from '@qiaopeng/tanstack-query-plus/hooks'

// 假设 API 返回格式:
// { items: [...], total: 100, hasMore: true }

function ProductList() {
  const options = createOffsetPaginationOptions({
    queryKey: ['products'],
    queryFn: async (offset, limit) => {
      const response = await fetch(
        `/api/products?offset=${offset}&limit=${limit}`
      )
      return response.json()
      // 返回 { items: Product[], total: number, hasMore: boolean }
    },
    limit: 20,  // 每页数量
  })

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useEnhancedInfiniteQuery(options)

  // 计算已加载的总数
  const loadedCount = data?.pages.reduce(
    (sum, page) => sum + page.items.length, 
    0
  ) || 0

  return (
    <div>
      <div className="grid grid-cols-4 gap-4">
        {data?.pages.flatMap(page => page.items).map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>

      <div className="mt-4 text-center">
        <p>已加载 {loadedCount} / {data?.pages[0]?.total || 0} 个商品</p>
        
        {hasNextPage && (
          <button 
            onClick={() => fetchNextPage()}
            disabled={isFetchingNextPage}
            className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
          >
            {isFetchingNextPage ? '加载中...' : '加载更多'}
          </button>
        )}
      </div>
    </div>
  )
}

8.4 页码分页

import { 
  useEnhancedInfiniteQuery, 
  createPageNumberPaginationOptions 
} from '@qiaopeng/tanstack-query-plus/hooks'

// 假设 API 返回格式:
// { items: [...], page: 1, totalPages: 10 }

function ArticleList() {
  const options = createPageNumberPaginationOptions({
    queryKey: ['articles'],
    queryFn: async (page) => {
      const response = await fetch(`/api/articles?page=${page}`)
      return response.json()
      // 返回 { items: Article[], page: number, totalPages: number }
    },
  })

  const {
    data,
    fetchNextPage,
    fetchPreviousPage,
    hasNextPage,
    hasPreviousPage,
    isFetchingNextPage,
  } = useEnhancedInfiniteQuery(options)

  const currentPage = data?.pages.length || 0
  const totalPages = data?.pages[0]?.totalPages || 0

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.items.map(article => (
            <ArticleCard key={article.id} article={article} />
          ))}
        </div>
      ))}

      <div className="flex justify-between mt-4">
        <button 
          onClick={() => fetchPreviousPage()}
          disabled={!hasPreviousPage}
        >
          上一页
        </button>
        
        <span>第 {currentPage} / {totalPages} 页</span>
        
        <button 
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
        >
          {isFetchingNextPage ? '加载中...' : '下一页'}
        </button>
      </div>
    </div>
  )
}

8.5 无限滚动(自动加载)

结合 Intersection Observer 实现滚动到底部自动加载:

import { useRef, useEffect } from 'react'
import { useEnhancedInfiniteQuery, createOffsetPaginationOptions } from '@qiaopeng/tanstack-query-plus/hooks'

function InfiniteScrollList() {
  const loadMoreRef = useRef(null)
  
  const options = createOffsetPaginationOptions({
    queryKey: ['items'],
    queryFn: (offset, limit) => fetchItems(offset, limit),
    limit: 20,
  })

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useEnhancedInfiniteQuery(options)

  // 监听滚动到底部
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage()
        }
      },
      { threshold: 0.1 }
    )

    if (loadMoreRef.current) {
      observer.observe(loadMoreRef.current)
    }

    return () => observer.disconnect()
  }, [hasNextPage, isFetchingNextPage, fetchNextPage])

  return (
    <div>
      {data?.pages.flatMap(page => page.items).map(item => (
        <ItemCard key={item.id} item={item} />
      ))}

      {/* 触发加载的哨兵元素 */}
      <div ref={loadMoreRef} className="h-10">
        {isFetchingNextPage && <div>加载中...</div>}
        {!hasNextPage && <div>已经到底了</div>}
      </div>
    </div>
  )
}

8.6 自定义无限查询配置

如果预设的分页模式不满足需求,可以使用 createInfiniteQueryOptions 创建自定义配置:

import { createInfiniteQueryOptions, useEnhancedInfiniteQuery } from '@qiaopeng/tanstack-query-plus/hooks'

// 使用 createInfiniteQueryOptions 创建自定义分页配置
const customOptions = createInfiniteQueryOptions({
  queryKey: ['custom-list'],
  queryFn: ({ pageParam }) => fetchCustomData(pageParam),
  initialPageParam: { page: 1, filter: 'active' },
  getNextPageParam: (lastPage, allPages, lastPageParam) => {
    if (lastPage.hasMore) {
      return { ...lastPageParam, page: lastPageParam.page + 1 }
    }
    return undefined  // 没有更多数据
  },
  getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
    if (firstPageParam.page > 1) {
      return { ...firstPageParam, page: firstPageParam.page - 1 }
    }
    return undefined
  },
  staleTime: 60000,
  gcTime: 300000,
})

const result = useEnhancedInfiniteQuery(customOptions)

方式二:也可以直接传递配置给 useEnhancedInfiniteQuery

const result = useEnhancedInfiniteQuery({
  queryKey: ['custom-list'],
  queryFn: ({ pageParam }) => fetchCustomData(pageParam),
  initialPageParam: { page: 1, filter: 'active' },
  getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
})

方式三:使用 TanStack Query 的 infiniteQueryOptions(如果你需要与原生 API 保持一致):

import { infiniteQueryOptions } from '@tanstack/react-query'

const customOptions = infiniteQueryOptions({
  queryKey: ['custom-list'],
  queryFn: ({ pageParam }) => fetchCustomData(pageParam),
  initialPageParam: { page: 1, filter: 'active' },
  getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
})

const result = useEnhancedInfiniteQuery(customOptions)

现在你已经掌握了无限滚动和分页。在复杂的应用中,我们经常需要同时发起多个查询。接下来,让我们学习批量查询。


9. 第七步:全局状态与 Mutation 监控

在复杂的应用中,你可能需要监控全局的加载状态,或者获取正在进行的 Mutation 进度。本库补全了 v5 的状态监控 Hooks。

9.1 监控全局加载状态

使用 useIsFetchinguseIsMutating 可以实时感知后台活动:

import { useIsFetching, useIsMutating } from '@qiaopeng/tanstack-query-plus/hooks'

function GlobalLoadingIndicator() {
  const isFetching = useIsFetching() // 正在进行的 Query 数量
  const isMutating = useIsMutating() // 正在进行的 Mutation 数量

  if (!isFetching && !isMutating) return null

  return (
    <div className="fixed top-0 right-0 p-4">
      {isFetching > 0 && <span>数据加载中...</span>}
      {isMutating > 0 && <span>后台同步中...</span>}
    </div>
  )
}

9.2 监控 Mutation 详细状态

useMutationState 允许你订阅 Mutation 缓存,获取特定任务的进度或结果:

import { useMutationState } from '@qiaopeng/tanstack-query-plus/hooks'

function UploadManager() {
  // 获取所有 ['upload'] 相关的 mutation 状态
  const uploads = useMutationState({
    filters: { mutationKey: ['upload'], status: 'pending' },
    select: (mutation) => mutation.state.variables,
  })

  return (
    <div>
      <h3>正在上传 ({uploads.length})</h3>
      <ul>
        {uploads.map((file, i) => (
          <li key={i}>{file.name} 正在传输...</li>
        ))}
      </ul>
    </div>
  )
}

10. 第八步:批量查询与仪表盘

在仪表盘、数据概览等场景中,我们经常需要同时发起多个查询。本库提供了强大的批量查询功能,包括统计信息、批量操作和错误聚合。

9.1 基础批量查询

使用 useEnhancedQueries 同时发起多个查询:

import { useEnhancedQueries, batchQueryUtils } from '@qiaopeng/tanstack-query-plus/hooks'

function Dashboard() {
  const { data: results, stats, operations } = useEnhancedQueries([
    { queryKey: ['users'], queryFn: fetchUsers },
    { queryKey: ['posts'], queryFn: fetchPosts },
    { queryKey: ['comments'], queryFn: fetchComments },
    { queryKey: ['analytics'], queryFn: fetchAnalytics },
  ])

  // stats 包含聚合统计信息
  // {
  //   total: 4,        // 总查询数
  //   loading: 1,      // 加载中的数量
  //   success: 2,      // 成功的数量
  //   error: 1,        // 失败的数量
  //   stale: 0,        // 过期的数量
  //   successRate: 50, // 成功率 (%)
  //   errorRate: 25,   // 错误率 (%)
  // }

  return (
    <div>
      {/* 显示加载状态 */}
      <div className="mb-4 p-4 bg-gray-100 rounded">
        <p>加载进度: {stats.success}/{stats.total}</p>
        <p>成功率: {stats.successRate.toFixed(1)}%</p>
        {stats.loading > 0 && <p>正在加载 {stats.loading} 个查询...</p>}
      </div>

      {/* 批量操作按钮 */}
      <div className="space-x-2 mb-4">
        <button onClick={() => operations.refetchAll()}>
          刷新全部
        </button>
        <button onClick={() => operations.invalidateAll()}>
          失效全部
        </button>
        <button onClick={() => operations.cancelAll()}>
          取消全部
        </button>
      </div>

      {/* 错误处理 */}
      {batchQueryUtils.hasError(results) && (
        <div className="p-4 bg-red-100 rounded mb-4">
          <p>部分查询失败</p>
          <button onClick={() => operations.retryFailed()}>
            重试失败的查询
          </button>
        </div>
      )}

      {/* 数据展示 */}
      {batchQueryUtils.isAllSuccess(results) && (
        <div className="grid grid-cols-2 gap-4">
          <UserStats data={results[0].data} />
          <PostStats data={results[1].data} />
          <CommentStats data={results[2].data} />
          <AnalyticsChart data={results[3].data} />
        </div>
      )}
    </div>
  )
}

9.2 批量查询工具函数

batchQueryUtils 提供了丰富的工具函数:

import { batchQueryUtils } from '@qiaopeng/tanstack-query-plus/hooks'

// 状态检查
batchQueryUtils.isAllLoading(results)   // 是否全部加载中
batchQueryUtils.isAllSuccess(results)   // 是否全部成功
batchQueryUtils.isAllPending(results)   // 是否全部待处理
batchQueryUtils.hasError(results)       // 是否有错误
batchQueryUtils.hasStale(results)       // 是否有过期数据
batchQueryUtils.isAnyFetching(results)  // 是否有正在获取的

// 数据提取
batchQueryUtils.getAllData(results)     // 获取所有成功的数据
batchQueryUtils.getSuccessData(results) // 获取成功数据(带类型)
batchQueryUtils.getAllErrors(results)   // 获取所有错误
batchQueryUtils.getFirstError(results)  // 获取第一个错误

// 高级功能
batchQueryUtils.createErrorAggregate(results, queries)  // 创建错误聚合
batchQueryUtils.createOperationReport(results, queries, startTime)  // 创建操作报告

9.3 仪表盘查询(命名数据)

useDashboardQueries 让你可以用对象形式定义查询,返回命名的数据:

import { useDashboardQueries } from '@qiaopeng/tanstack-query-plus/hooks'

function AdminDashboard() {
  const { 
    data,      // 命名的数据对象
    isLoading, // 任一查询加载中
    isError,   // 任一查询出错
    isSuccess, // 全部成功
    stats,     // 统计信息
    results    // 原始结果数组
  } = useDashboardQueries({
    users: { 
      queryKey: ['dashboard', 'users'], 
      queryFn: fetchUserStats 
    },
    revenue: { 
      queryKey: ['dashboard', 'revenue'], 
      queryFn: fetchRevenueStats 
    },
    orders: { 
      queryKey: ['dashboard', 'orders'], 
      queryFn: fetchOrderStats 
    },
    traffic: { 
      queryKey: ['dashboard', 'traffic'], 
      queryFn: fetchTrafficStats 
    },
  })

  if (isLoading) return <DashboardSkeleton />
  if (isError) return <DashboardError />

  // 直接通过名称访问数据
  return (
    <div className="grid grid-cols-2 gap-6">
      <StatCard title="用户" value={data.users?.total} />
      <StatCard title="收入" value={data.revenue?.total} />
      <StatCard title="订单" value={data.orders?.count} />
      <TrafficChart data={data.traffic} />
    </div>
  )
}

9.4 依赖查询链

有时候后续查询依赖于前一个查询的结果。使用 useDependentBatchQueries

import { useDependentBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'

function UserDashboard({ userId }) {
  const { 
    primaryResult,  // 主查询结果
    results,        // 从查询结果数组
    stats,          // 统计信息
    operations      // 批量操作
  } = useDependentBatchQueries({
    // 主查询:获取用户信息
    primaryQuery: {
      queryKey: ['user', userId],
      queryFn: () => fetchUser(userId),
    },
    // 从查询:基于用户信息获取相关数据
    dependentQueries: (user) => [
      { 
        queryKey: ['posts', user.id], 
        queryFn: () => fetchUserPosts(user.id) 
      },
      { 
        queryKey: ['followers', user.id], 
        queryFn: () => fetchFollowers(user.id) 
      },
      { 
        queryKey: ['following', user.id], 
        queryFn: () => fetchFollowing(user.id) 
      },
      // 可以使用用户数据中的任何信息
      ...(user.isAdmin ? [
        { 
          queryKey: ['admin-stats'], 
          queryFn: fetchAdminStats 
        }
      ] : [])
    ],
  })

  if (primaryResult.isLoading) return <div>加载用户信息...</div>
  if (primaryResult.isError) return <div>加载失败</div>

  const user = primaryResult.data
  const [postsResult, followersResult, followingResult] = results

  return (
    <div>
      <UserHeader user={user} />
      
      <div className="grid grid-cols-3 gap-4">
        <PostList 
          posts={postsResult?.data} 
          isLoading={postsResult?.isLoading} 
        />
        <FollowerList 
          followers={followersResult?.data}
          isLoading={followersResult?.isLoading}
        />
        <FollowingList 
          following={followingResult?.data}
          isLoading={followingResult?.isLoading}
        />
      </div>
    </div>
  )
}

9.5 动态批量查询

当查询数量是动态的(比如基于一个 ID 列表):

import { useDynamicBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'

function ProductComparison({ productIds }) {
  const { data: results, stats } = useDynamicBatchQueries({
    items: productIds,  // 动态的 ID 列表
    queryKeyPrefix: ['product'],
    queryFn: (productId) => fetchProduct(productId),
    enabled: productIds.length > 0,
    staleTime: 60000,
  })

  if (stats.loading > 0) {
    return <div>加载中... ({stats.success}/{stats.total})</div>
  }

  const products = batchQueryUtils.getSuccessData(results)

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

9.6 自动刷新批量查询

对于需要定期刷新的仪表盘:

import { useAutoRefreshBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'

function LiveDashboard() {
  const { data: results, stats } = useAutoRefreshBatchQueries({
    queries: [
      { queryKey: ['live-users'], queryFn: fetchLiveUsers },
      { queryKey: ['live-orders'], queryFn: fetchLiveOrders },
      { queryKey: ['live-revenue'], queryFn: fetchLiveRevenue },
    ],
    refreshInterval: 30000,  // 每 30 秒刷新
    enabled: true,
  })

  // ...
}

9.7 条件批量查询

只执行满足条件的查询:

import { useConditionalBatchQueries } from '@qiaopeng/tanstack-query-plus/hooks'

function ConditionalDashboard({ userRole }) {
  const { data: results } = useConditionalBatchQueries([
    { 
      queryKey: ['basic-stats'], 
      queryFn: fetchBasicStats,
      enabled: true  // 总是执行
    },
    { 
      queryKey: ['admin-stats'], 
      queryFn: fetchAdminStats,
      enabled: userRole === 'admin'  // 只有管理员执行
    },
    { 
      queryKey: ['premium-stats'], 
      queryFn: fetchPremiumStats,
      enabled: userRole === 'premium' || userRole === 'admin'
    },
  ])

  // ...
}

现在你已经掌握了批量查询。为了提升用户体验,我们可以在用户需要数据之前就预先获取。接下来,让我们学习智能预取。


10. 第八步:智能预取

预取(Prefetch)是指在用户实际需要数据之前就提前获取。这可以显著提升用户体验,让页面切换感觉更快。本库提供了多种预取策略。

10.1 悬停预取

当用户将鼠标悬停在链接上时预取数据:

import { useHoverPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function UserLink({ userId, userName }) {
  // 返回需要绑定到元素的事件处理器
  const hoverProps = useHoverPrefetch(
    ['user', userId],           // queryKey
    () => fetchUser(userId),    // queryFn
    {
      hoverDelay: 200,    // 悬停 200ms 后开始预取(避免快速划过触发)
      minInterval: 1000,  // 同一个 key 最小预取间隔
      staleTime: 30000,   // 数据新鲜时不预取
    }
  )

  return (
    <a 
      href={`/user/${userId}`} 
      {...hoverProps}  // 绑定 onMouseEnter, onMouseLeave, onFocus
    >
      {userName}
    </a>
  )
}

工作原理:

  1. 用户鼠标移入元素
  2. 等待 hoverDelay 毫秒
  3. 检查数据是否已缓存且新鲜
  4. 如果需要,发起预取请求
  5. 用户点击链接时,数据已经准备好了

10.2 智能预取

useSmartPrefetch 会自动检测网络状态,在慢网络时跳过预取:

import { useSmartPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function ProductCard({ productId }) {
  const { prefetch, shouldPrefetch, clearPrefetchHistory } = useSmartPrefetch()

  const handleMouseEnter = () => {
    // 自动检测网络状态,慢网络时不预取
    prefetch(
      ['product', productId],
      () => fetchProduct(productId),
      { staleTime: 60000 }
    )
  }

  return (
    <div 
      onMouseEnter={handleMouseEnter}
      className="product-card"
    >
      <ProductImage id={productId} />
      <ProductInfo id={productId} />
      
      {/* 可选:显示网络状态 */}
      {!shouldPrefetch && (
        <span className="text-xs text-gray-400">
          慢网络,已禁用预取
        </span>
      )}
    </div>
  )
}

10.3 视口预取

当元素进入视口时预取(需要安装 react-intersection-observer):

import { useInViewPrefetch } from '@qiaopeng/tanstack-query-plus/hooks/inview'

function LazySection({ sectionId }) {
  // 返回一个 ref,绑定到需要监听的元素
  const ref = useInViewPrefetch(
    ['section', sectionId],
    () => fetchSectionData(sectionId),
    {
      threshold: 0.1,      // 10% 可见时触发
      rootMargin: '100px', // 提前 100px 触发(元素还没完全进入视口)
      triggerOnce: true,   // 只触发一次
    }
  )

  return (
    <section ref={ref}>
      <SectionContent id={sectionId} />
    </section>
  )
}

使用场景:

  • 长页面的各个区块
  • 图片懒加载
  • 无限滚动列表的下一批数据

10.4 路由预取

在路由切换前预取下一个页面的数据:

import { useRoutePrefetch } from '@qiaopeng/tanstack-query-plus/hooks'
import { Link, useNavigate } from 'react-router-dom'

function Navigation() {
  const prefetch = useRoutePrefetch()
  const navigate = useNavigate()

  const handlePrefetchUser = (userId) => {
    prefetch(
      ['user', userId],
      () => fetchUser(userId),
      { staleTime: 30000 }
    )
  }

  return (
    <nav>
      <Link 
        to="/user/123"
        onMouseEnter={() => handlePrefetchUser('123')}
      >
        用户 123
      </Link>
      
      {/* 或者在按钮点击前预取 */}
      <button
        onMouseEnter={() => handlePrefetchUser('456')}
        onClick={() => navigate('/user/456')}
      >
        查看用户 456
      </button>
    </nav>
  )
}

10.5 条件预取

只在满足条件时预取:

import { useConditionalPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function SearchResults({ query, isHovered }) {
  // 当 isHovered 为 true 时预取
  useConditionalPrefetch(
    ['search', query],
    () => fetchSearchResults(query),
    isHovered,  // 条件
    { delay: 300 }  // 延迟 300ms
  )

  // ...
}

10.6 空闲时预取

利用浏览器空闲时间预取:

import { useIdlePrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function App() {
  // 在浏览器空闲时预取常用数据
  useIdlePrefetch(
    ['common-data'],
    fetchCommonData,
    { 
      timeout: 2000,  // 最多等待 2 秒进入空闲
      enabled: true 
    }
  )

  return <MainContent />
}

工作原理:

  • 使用 requestIdleCallback API
  • 在浏览器空闲时执行预取
  • 不影响主线程性能

10.7 周期预取

定期预取数据,保持缓存新鲜:

import { usePeriodicPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function Dashboard() {
  // 每分钟预取一次
  usePeriodicPrefetch(
    ['dashboard-stats'],
    fetchDashboardStats,
    { 
      interval: 60000,  // 60 秒
      enabled: true 
    }
  )

  // ...
}

10.8 批量预取

一次预取多个查询:

import { useBatchPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function HomePage() {
  const batchPrefetch = useBatchPrefetch()

  useEffect(() => {
    // 页面加载后预取常用数据
    batchPrefetch([
      { queryKey: ['featured-products'], queryFn: fetchFeaturedProducts },
      { queryKey: ['categories'], queryFn: fetchCategories },
      { queryKey: ['promotions'], queryFn: fetchPromotions },
    ])
  }, [batchPrefetch])

  // ...
}

10.9 优先级预取

按优先级执行预取任务:

import { usePriorityPrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function App() {
  const { addPrefetchTask, processTasks, taskCount } = usePriorityPrefetch()

  useEffect(() => {
    // 添加不同优先级的预取任务
    addPrefetchTask(['critical-data'], fetchCriticalData, 'high')
    addPrefetchTask(['important-data'], fetchImportantData, 'medium')
    addPrefetchTask(['optional-data'], fetchOptionalData, 'low')

    // 按优先级顺序执行
    processTasks()
  }, [])

  return (
    <div>
      {taskCount > 0 && <span>预取中... ({taskCount} 个任务)</span>}
      <MainContent />
    </div>
  )
}

10.10 预测性预取

基于用户行为预测并预取:

import { usePredictivePrefetch } from '@qiaopeng/tanstack-query-plus/hooks'

function ProductBrowser() {
  const { 
    recordInteraction, 
    getPredictions, 
    prefetchPredicted 
  } = usePredictivePrefetch()

  const handleProductClick = (productId) => {
    // 记录用户交互
    recordInteraction('click', productId)
    navigate(`/product/${productId}`)
  }

  const handleProductHover = (productId) => {
    recordInteraction('hover', productId)
  }

  // 基于历史行为预取
  useEffect(() => {
    prefetchPredicted((target) => ({
      queryKey: ['product', target],
      queryFn: () => fetchProduct(target)
    }))
  }, [prefetchPredicted])

  return (
    <div>
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onClick={() => handleProductClick(product.id)}
          onMouseEnter={() => handleProductHover(product.id)}
        />
      ))}
    </div>
  )
}

10.11 预取最佳实践

  1. 不要过度预取:只预取用户很可能需要的数据
  2. 设置合理的 staleTime:避免重复预取新鲜数据
  3. 考虑网络状况:使用 useSmartPrefetch 在慢网络时禁用
  4. 使用延迟:悬停预取应该有延迟,避免快速划过触发
  5. 优先级管理:关键数据优先预取

现在你已经掌握了预取策略。接下来,让我们学习 Suspense 模式,它可以让你的代码更简洁。

10.12 安全提示

  • 结合网络状况:使用 useSmartPrefetch 在慢网络禁用预取,避免拥塞
  • 控制频率与间隔:为悬停/路由预取设置 minInterval,避免重复请求
  • 严格限定 Key:预取目标必须是稳定且可序列化的 queryKey
  • 避免敏感信息:不要将敏感数据拼入 queryKey
  • 可回收:在复杂页面中适时清理预取历史(clearPrefetchHistory)以避免状态膨胀

12. 第十步:Suspense 模式与 SSR

React Suspense 提供了声明式的加载处理。本库不仅支持增强的 Suspense Hooks,还提供了完善的 SSR 水合支持。

12.1 基础 Suspense 查询

useEnhancedSuspenseQuery 保证 data 始终存在,省去非空判断:

import { useEnhancedSuspenseQuery } from '@qiaopeng/tanstack-query-plus/hooks'

function UserProfile({ userId }) {
  const { data } = useEnhancedSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })
  
  return <div>{data.name}</div>
}

12.2 SSR 水合支持

在 Next.js (App Router) 或其他 SSR 框架中,使用 HydrationBoundary 进行注水:

// Server Component
import { dehydrate, QueryClient } from '@qiaopeng/tanstack-query-plus'
import { HydrationBoundary } from '@qiaopeng/tanstack-query-plus/components'

export default async function Page() {
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts })

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostsList />
    </HydrationBoundary>
  )
}

12.3 使用 SuspenseWrapper 组件

本库提供了 SuspenseWrapperQuerySuspenseWrapper 组件,它们组合了 Suspense 和 ErrorBoundary:

import { SuspenseWrapper, QuerySuspenseWrapper } from '@qiaopeng/tanstack-query-plus/components'

function UserPage({ userId }) {
  return (
    <SuspenseWrapper
      fallback={<UserSkeleton />}
      errorFallback={(error, reset) => (
        <div className="error-container">
          <p>加载失败: {error.message}</p>
          <button onClick={reset}>重试</button>
        </div>
      )}
      onError={(error, info) => {
        // 上报错误到监控系统
        reportError(error, info)
      }}
      resetKeys={[userId]}  // userId 变化时重置错误状态
    >
      <UserProfile userId={userId} />
    </SuspenseWrapper>
  )
}

// QuerySuspenseWrapper 是 SuspenseWrapper 的别名,语义更清晰
function DataPage() {
  return (
    <QuerySuspenseWrapper
      fallback={<DataSkeleton />}
      errorFallback={(error, reset) => (
        <ErrorDisplay error={error} onRetry={reset} />
      )}
    >
      <DataComponent />
    </QuerySuspenseWrapper>
  )
}

注意QuerySuspenseWrapperSuspenseWrapper 功能完全相同,只是名称不同。使用 QuerySuspenseWrapper 可以让代码语义更清晰,表明这是用于查询的 Suspense 包装器。

11.4 QueryErrorBoundary

专门为查询设计的错误边界,集成了 React Query 的错误重置:

import { QueryErrorBoundary } from '@qiaopeng/tanstack-query-plus/components'

function DataSection() {
  return (
    <QueryErrorBoundary
      fallback={(error, reset) => (
        <div>
          <p>查询失败: {error.message}</p>
          <button onClick={reset}>重新加载</button>
        </div>
      )}
      resetKeys={['data-key']}
    >
      <Suspense fallback={<Loading />}>
        <DataComponent />
      </Suspense>
    </QueryErrorBoundary>
  )
}

11.5 Loading 组件库

本库提供了多种预设的 Loading 组件:

import {
  DefaultLoadingFallback,  // 默认加载指示器
  SmallLoadingIndicator,   // 小型加载指示器
  FullScreenLoading,       // 全屏加载
  TextSkeletonFallback,    // 文本骨架屏
  CardSkeletonFallback,    // 卡片骨架屏
  ListSkeletonFallback,    // 列表骨架屏
  PageSkeletonFallback,    // 页面骨架屏
} from '@qiaopeng/tanstack-query-plus/components'

// 使用示例
<SuspenseWrapper fallback={<DefaultLoadingFallback />}>
  <Content />
</SuspenseWrapper>

<SuspenseWrapper fallback={<ListSkeletonFallback items={5} />}>
  <UserList />
</SuspenseWrapper>

<SuspenseWrapper fallback={<CardSkeletonFallback />}>
  <ProductCard />
</SuspenseWrapper>

// 小型加载指示器(用于按钮等)
<SmallLoadingIndicator size="sm" />  // sm | md | lg

// 全屏加载(用于页面切换)
<FullScreenLoading message="正在加载页面..." />

// 文本骨架屏
<TextSkeletonFallback lines={3} />

11.6 Suspense 无限查询

import { useEnhancedSuspenseInfiniteQuery } from '@qiaopeng/tanstack-query-plus/hooks'

function PostList() {
  const { data, fetchNextPage, hasNextPage } = useEnhancedSuspenseInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  })

  return (
    <div>
      {data.pages.flatMap(page => page.items).map(post => (
        <PostCard key={post.id} post={post} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>加载更多</button>
      )}
    </div>
  )
}

// 使用
<SuspenseWrapper fallback={<PostListSkeleton />}>
  <PostList />
</SuspenseWrapper>

11.7 创建可复用的 Suspense 查询

使用工厂函数创建可复用的 Suspense 查询:

import { createSuspenseQuery } from '@qiaopeng/tanstack-query-plus/hooks'

// 创建一个可复用的用户查询 hook
// 参数1: queryKey 生成函数,接收变量返回 queryKey
// 参数2: queryFn,接收 QueryFunctionContext(包含 queryKey, signal 等)
// 参数3: 可选的默认配置
const useUserSuspense = createSuspenseQuery(
  (userId: string) => ['user', userId],
  async (context) => {
    // context.queryKey 是 ['user', userId]
    // context.signal 可用于取消请求
    const [, userId] = context.queryKey
    return fetchUser(userId as string)
  },
  { staleTime: 30000 }
)

// 使用:传入变量,返回 Suspense 查询结果
function UserProfile({ userId }) {
  const { data } = useUserSuspense(userId)
  return <div>{data.name}</div>
}

11.8 嵌套 Suspense

对于复杂页面,可以使用嵌套的 Suspense 来实现渐进式加载:

function UserDashboard({ userId }) {
  return (
    <div>
      {/* 用户信息先加载 */}
      <SuspenseWrapper fallback={<UserHeaderSkeleton />}>
        <UserHeader userId={userId} />
      </SuspenseWrapper>

      <div className="grid grid-cols-2 gap-4">
        {/* 文章列表独立加载 */}
        <SuspenseWrapper fallback={<PostListSkeleton />}>
          <UserPosts userId={userId} />
        </SuspenseWrapper>

        {/* 统计信息独立加载 */}
        <SuspenseWrapper fallback={<StatsSkeleton />}>
          <UserStats userId={userId} />
        </SuspenseWrapper>
      </div>
    </div>
  )
}

这样,各个区块可以独立加载,用户能更快看到部分内容。

11.9 Suspense 最佳实践

  1. 合理划分 Suspense 边界:不要把整个页面包在一个 Suspense 里
  2. 使用骨架屏:比简单的 "加载中..." 体验更好
  3. 处理错误:始终配合 ErrorBoundary 使用
  4. 设置 resetKeys:确保参数变化时能正确重置状态
  5. 考虑 SSR:Suspense 在服务端渲染时有特殊行为

现在你已经掌握了 Suspense 模式。接下来,让我们学习如何实现离线支持和数据持久化。


12. 第十步:离线支持与持久化

现代 Web 应用需要在网络不稳定甚至离线时也能正常工作。本库提供了完整的离线支持和数据持久化功能。

12.1 启用持久化

在第 3 章我们已经介绍了如何启用持久化:

<PersistQueryClientProvider 
  client={queryClient}
  enablePersistence    // 启用 localStorage 持久化
  enableOfflineSupport // 启用离线状态监听
>
  <App />
</PersistQueryClientProvider>

启用后:

  • 查询缓存会自动保存到 localStorage
  • 页面刷新后自动恢复
  • 网络状态变化会自动处理

12.2 监听网络状态

使用 usePersistenceStatus hook 可以方便地监听网络状态:

import { usePersistenceStatus } from '@qiaopeng/tanstack-query-plus'

function NetworkIndicator() {
  const { isOnline, isOffline } = usePersistenceStatus()

  return (
    <div className={`network-status ${isOffline ? 'offline' : 'online'}`}>
      {isOffline ? (
        <span>📴 离线模式 - 数据可能不是最新的</span>
      ) : (
        <span>🌐 在线</span>
      )}
    </div>
  )
}

底层 API:如果你需要更细粒度的控制,也可以直接使用底层 API:

import { useState, useEffect } from 'react'
import { isOnline, subscribeToOnlineStatus } from '@qiaopeng/tanstack-query-plus/features'

function NetworkIndicator() {
  const [online, setOnline] = useState(isOnline())

  useEffect(() => {
    const unsubscribe = subscribeToOnlineStatus(setOnline)
    return unsubscribe
  }, [])

  return <div>{online ? '在线' : '离线'}</div>
}

12.3 手动管理持久化

使用 usePersistenceManager hook 可以方便地管理缓存:

import { usePersistenceManager } from '@qiaopeng/tanstack-query-plus'

function SettingsPage() {
  const { clearCache, getOnlineStatus } = usePersistenceManager()

  const handleClearCache = () => {
    clearCache()  // 清除默认缓存
    // 或指定 key: clearCache('my-cache-key')
    alert('缓存已清除')
  }

  return (
    <div>
      <p>网络状态: {getOnlineStatus() ? '在线' : '离线'}</p>
      <button onClick={handleClearCache}>清除缓存</button>
    </div>
  )
}

底层 API:也可以直接使用底层函数:

import { clearCache, isOnline } from '@qiaopeng/tanstack-query-plus/features'

function SettingsPage() {
  const handleClearCache = () => {
    clearCache()  // 清除默认缓存
    alert('缓存已清除')
  }

  return (
    <div>
      <p>网络状态: {isOnline() ? '在线' : '离线'}</p>
      <button onClick={handleClearCache}>清除缓存</button>
    </div>
  )
}

12.4 离线功能 API

本库提供了丰富的离线功能 API:

import {
  isOnline,
  subscribeToOnlineStatus,
  clearCache,
  clearExpiredCache,
  checkStorageSize,
  getStorageStats,
} from '@qiaopeng/tanstack-query-plus/features'

// 检查网络状态
const online = isOnline()

// 订阅网络状态变化
const unsubscribe = subscribeToOnlineStatus((online) => {
  console.log('网络状态:', online ? '在线' : '离线')
  if (online) {
    // 网络恢复,可以同步数据
    syncPendingChanges()
  }
})

// 清除缓存
clearCache()  // 清除所有缓存
clearCache('my-cache-key')  // 清除指定缓存

// 清除过期缓存
clearExpiredCache('tanstack-query-cache', 24 * 60 * 60 * 1000)  // 清除超过 24 小时的缓存

// 检查存储大小
const sizeInfo = checkStorageSize()
console.log(`缓存大小: ${sizeInfo.sizeInMB}MB`)
if (sizeInfo.shouldMigrate) {
  console.log('建议迁移到 IndexedDB')
}

// 获取存储统计
const stats = getStorageStats()
console.log({
  exists: stats.exists,
  age: stats.age,  // 缓存年龄(毫秒)
  queriesCount: stats.queriesCount,
  mutationsCount: stats.mutationsCount,
  sizeInfo: stats.sizeInfo,
})

12.5 离线队列管理器

对于需要在离线时也能操作的应用,可以使用离线队列管理器:

import { createOfflineQueueManager, isOnline, mutationRegistry, serializeMutationKey } from '@qiaopeng/tanstack-query-plus/features'
import { MutationOperationType } from '@qiaopeng/tanstack-query-plus/types'

// 创建队列管理器
const queueManager = createOfflineQueueManager({
  maxSize: 100,              // 最大队列大小
  autoExecuteInterval: 5000, // 自动执行间隔(毫秒)
  executeOnReconnect: true,  // 网络恢复时自动执行
  operationTimeout: 30000,   // 操作超时时间
  concurrency: 3,            // 并发执行数
})

// 注册 mutation 函数(用于恢复队列时执行)
// 注册函数签名为 () => Promise<unknown>,如需变量请使用闭包或在入队项的 mutationFn 捕获
mutationRegistry.register(serializeMutationKey(['updateUser']), () => updateUserAPI(savedUserData))
mutationRegistry.register(serializeMutationKey(['createPost']), () => createPostAPI(savedPostData))

// 添加操作到队列
async function handleUpdateUser(userData) {
  if (!isOnline()) {
    // 离线时添加到队列
    await queueManager.add({
      type: MutationOperationType.UPDATE,
      mutationKey: ['updateUser'],
      variables: userData,
      mutationFn: () => updateUserAPI(userData),
      priority: 1,  // 优先级(数字越大越优先)
    })
    toast.info('已保存到离线队列,网络恢复后将自动同步')
  } else {
    // 在线时直接执行
    await updateUserAPI(userData)
 }
}

### 队列管理器操作参考

// 获取队列状态
const state = queueManager.getState()
console.log({
  isOffline: state.isOffline,
  queuedOperations: state.queuedOperations,
  failedQueries: state.failedQueries,
  isRecovering: state.isRecovering,
})

// 手动执行队列
const result = await queueManager.execute()
console.log(`成功: ${result.success}, 失败: ${result.failed}, 跳过: ${result.skipped}`)

// 获取队列中的操作
const operations = queueManager.getOperations()

// 清空队列
await queueManager.clear()

// 销毁管理器(清理定时器和监听器)
queueManager.destroy()

12.6 完整的离线应用示例

import { useState, useEffect } from 'react'
import { createOfflineQueueManager } from '@qiaopeng/tanstack-query-plus/features'
import { MutationOperationType } from '@qiaopeng/tanstack-query-plus/types'
import { useEnhancedQuery } from '@qiaopeng/tanstack-query-plus/hooks'
import { useQueryClient, usePersistenceStatus } from '@qiaopeng/tanstack-query-plus'

// 创建全局队列管理器
const offlineQueue = createOfflineQueueManager({
  executeOnReconnect: true,
  autoExecuteInterval: 10000,
})

function TodoApp() {
  const queryClient = useQueryClient()
  const { isOnline: networkStatus } = usePersistenceStatus()  // 使用 hook 监听网络状态
  const [pendingCount, setPendingCount] = useState(0)

  // 网络状态变化时显示提示
  useEffect(() => {
    if (networkStatus) {
      toast.success('网络已恢复,正在同步数据...')
    } else {
      toast.warning('网络已断开,操作将在恢复后同步')
    }
  }, [networkStatus])

  // 更新待处理数量
  useEffect(() => {
    const interval = setInterval(() => {
      setPendingCount(offlineQueue.getState().queuedOperations)
    }, 1000)
    return () => clearInterval(interval)
  }, [])

  // 查询 todos(离线时使用缓存)
  const { data: todos } = useEnhanc