首页 / TanStack 生态入门教程 / 查询函数与请求策略

TanStack 生态入门教程

查询函数与请求策略

本教程共 38 篇 · 第 7 篇 · 更新于 2026-07-27 · 约 9 分钟阅读

TanStackTanStack 生态入门教程queryFn错误处理retry重试AbortSignal查询取消

7. 查询函数与请求策略

本节目标:搞懂 queryFn 的返回值和错误处理规则、重试怎么配、查询怎么取消,学完你能掌控请求层各种细节,让请求更健壮。

7.1 queryFn 的返回值

queryFn 是真正发请求的函数,必须返回一个 Promise。这个 Promise 有两条路:

  • resolve 数据 — 查询成功
  • reject 或 throw — 查询失败

有个容易忽略的规则:resolve 的值不能是 undefined。如果你 resolve 了 undefined,Query 会把它当成失败处理。

为什么这么设计?因为 undefined 在缓存里没法和「没数据」区分。要表示「确实没东西」,用 null

// 错误:resolve undefined 会被当失败
queryFn: async () => {
  const data = await fetchMaybeEmpty()
  if (!data) return undefined  // 这会让查询失败
  return data
}

// 正确:用 null 表示空
queryFn: async () => {
  const data = await fetchMaybeEmpty()
  if (!data) return null
  return data
}
Warning

这个坑我踩过。有个接口在某些情况下返回空响应,我图省事直接 return undefined,结果查询一直显示 loading,查了半天才发现是 undefined 的锅。

7.2 错误处理:必须 throw

Query 怎么知道查询失败了?靠 queryFn 抛错或返回 rejected Promise。不抛错的失败,Query 察觉不到。

const { error } = useQuery({
  queryKey: ['todos', todoId],
  queryFn: async () => {
    if (somethingGoesWrong) {
      throw new Error('出错了!')
    }
    if (somethingElseGoesWrong) {
      return Promise.reject(new Error('也出错了!'))
    }
    return data
  },
})

throw 和 reject 效果一样,都会被 Query 捕获,存到 error 状态里。

fetch 不会自动抛错

这是最坑新手的点。原生 fetch 在 HTTP 状态码 4xx、5xx 时不会 throw,只有网络错误才 throw。你得手动判断 response.ok

useQuery({
  queryKey: ['todos', todoId],
  queryFn: async () => {
    const response = await fetch('/todos/' + todoId)
    if (!response.ok) {
      throw new Error('网络响应异常:' + response.status)
    }
    return response.json()
  },
})

不判断的话,404 返回的 HTML 错误页会被当成数据存进缓存,页面就渲染一堆乱码。

Tip

axios 会自动抛错,省心。用 fetch 的话,建议封装个统一函数处理 response.ok 判断,别每个 queryFn 都写一遍。

7.3 QueryFunctionContext

queryFn 接收一个上下文对象,叫 QueryFunctionContext,包含这些字段:

  • queryKey — 查询键,能从这里取参数
  • client — QueryClient 实例
  • signal — AbortSignal 实例,用于取消请求
  • meta — 可选的元信息

无限查询(Infinite Query)还会多两个:

  • pageParam — 当前页的参数
  • direction — 方向(已废弃,建议把方向信息放进 pageParam)

最常用的是 queryKeysignal。前面讲过从 queryKey 取参数,这里看 signal 的用法。

7.4 重试策略:retry

请求失败时,Query 默认会自动重试 3 次。这个行为可以配置:

// 全局配置
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 3,  // 默认就是 3
    },
  },
})

// 单个查询覆盖
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  retry: 10,  // 这个查询重试 10 次
})

retry 的几种写法:

  • retry: false — 不重试
  • retry: 3 — 重试 3 次
  • retry: true — 无限重试(慎用)
  • retry: (failureCount, error) => ... — 自定义逻辑

自定义逻辑很有用,比如根据错误类型决定要不要重试:

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  retry: (failureCount, error) => {
    // 404 不重试,没意义
    if (error.status === 404) return false
    // 其他错误最多重试 3 次
    return failureCount < 3
  },
})
Note

failureCount 从 0 开始,第一次重试时是 0。重试过程中的错误会存在 failureReason 字段里,只有最后一次重试还失败,错误才会进 error 字段。

服务端默认不重试

v5 有个变更:在服务端(SSR)默认 retry: 0。因为服务端渲染要快,重试会拖慢响应。如果你在 SSR 场景发现查询不重试,这是正常的。

7.5 重试延迟:retryDelay

重试不是立即重试,默认有指数退避:第一次等 1 秒,第二次 2 秒,第三次 4 秒,最多不超过 30 秒。

默认逻辑等价于:

retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)

可以改:

// 固定延迟
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  retryDelay: 1000,  // 每次都等 1 秒
})

// 自定义函数
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
})

指数退避是好东西,别随便改成固定延迟。服务短暂抖动时,退避能让服务有时间恢复,避免重试风暴压垮服务器。

7.6 查询取消:AbortSignal

Query 给每个 queryFn 传一个 AbortSignal。当查询过期或失活时,这个 signal 会被 abort,你可以借它取消正在进行的请求。

好处是:用正常的 async/await 写法,就能享受自动取消。

配合 fetch

fetch 原生支持 signal,直接传进去:

useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    const response = await fetch('/todos', { signal })
    if (!response.ok) throw new Error('请求失败')
    return response.json()
  },
})

组件卸载或查询失效时,signal 被 abort,fetch 自动取消,不会浪费网络请求。

配合 axios

axios 0.22.0+ 也支持 signal

useQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) =>
    axios.get('/todos', { signal }),
})

多个请求共用一个 signal

一个 queryFn 里发多个请求时,把同一个 signal 传给所有请求,一起取消:

useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    const todosResponse = await fetch('/todos', { signal })
    const todos = await todosResponse.json()

    // 用同一个 signal 请求每个 todo 的详情
    const details = todos.map(({ detailsUrl }) =>
      fetch(detailsUrl, { signal }).then((r) => r.json())
    )

    return Promise.all(details)
  },
})

7.7 手动取消查询

除了自动取消,也能手动取消。比如请求很慢,用户想点「取消」按钮:

const query = useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    const resp = await fetch('/todos', { signal })
    return resp.json()
  },
})

const queryClient = useQueryClient()

return (
  <button
    onClick={() => {
      queryClient.cancelQueries({ queryKey: ['todos'] })
    }}
  >
    取消请求
  </button>
)

cancelQueries 会 abort 对应的 signal,并让查询状态回退到之前的状态。

cancelQueries 的选项

// 静默取消,不触发错误回调
await queryClient.cancelQueries(
  { queryKey: ['posts'] },
  { silent: true }
)

两个选项:

  • silent: true — 不传播 CancelledError 给观察者,默认 false
  • revert: true — 回退到请求前的状态,默认 true
Warning

取消功能在 Suspense 相关的 hook(useSuspenseQuery 等)上不工作。如果你的查询用了 Suspense,别指望取消能生效。

7.8 默认取消行为

补充一个细节:默认情况下,组件卸载时已经发出的请求不会被取消。请求完成后数据会进缓存,下次挂载还能用。

只有你消费了 signal(比如传给 fetch),请求才会被真正取消。这个设计是权衡过的:不消费 signal 时保留数据方便下次用,消费了 signal 就认为你接受取消语义。

7.9 小结

请求层控制就这几样:

  • 返回值:resolve 数据别用 undefined,用 null
  • 错误:必须 throw 或 reject,fetch 要手动判断 response.ok
  • 重试:默认 3 次带指数退避,可按错误类型自定义
  • 取消:用 signal 配合 fetch/axios,或手动 cancelQueries

下一篇讲缓存机制,staleTime 和 gcTime 这两个最容易混的概念。