4263 字
21 分钟
TanStack Query在项目中的实践总结

1. 探讨类型声明#

TanStack Query 在项目中并非只是管理请求发送的库,它更核心的职责是管理服务端状态,解决数据跨组件共享、持久化、错误处理等问题。

但引入之后,项目很快会碰到一个常见问题:类型到底该写在哪一层?以我项目中的一个 Hook 为例:

useApprovalInstancePage.before.ts
/**
* 获取审批实例分页数据
*/
export const useApprovalInstancePage = (
params: Ref<ApprovalInstancesPageRequest> | ApprovalInstancesPageRequest,
options?: {
enabled?: Ref<boolean> | boolean
refetchInterval?: number
},
) => {
return useQuery<unknown, unknown, unknown, unknown>({
queryKey: computed(() => approvalInstanceKeys.INSTANCE_PAGE(unref(params))),
queryFn: () => approvalService.getInstancePage(unref(params)),
enabled: computed(() => unref(options?.enabled ?? true)),
refetchInterval: options?.refetchInterval,
staleTime: 30 * 1000,
placeholderData: (previousData) => previousData,
})
}

对于每个调用的 Hook,其数据类型与错误类型,其实已经在定义 API 的地方,也就是 approvalService.getInstancePage(unref(params)) 里面声明过一次。为了在 Hook 层继续获得“看起来更完整”的类型提示,又不得不再重复写一次泛型。

这其实是很没必要的。为了应付 TypeScript,无论是 Mutation 还是 Query,只要 API 类型稍有改动,Hook 层也得跟着改,结构会非常脆弱。

类型推断的关键其实只在 approvalService.getInstancePage(unref(params)) 这一层。早期我甚至会直接把类型写成 unknown 来糊弄过去。

1.1 官方推荐与社区常见做法#

官方文档 在演示 React 中的 useQuery 时提到:

Types in React Query generally flow through very well so that you don’t have to provide type annotations for yourself

总的来说,React Query 的类型推导做得非常好,没有必要自己去写类型声明。

当然,我项目是 Vue,但这一点本质上是互通的:

type-inference.ts
const { data } = useQuery({
// ^ 这里会自动推断为 number | undefined
queryKey: ["test"],
queryFn: () => Promise.resolve(5),
})

queryFn / mutationFn 已经能提供大部分类型信息。

官方文档 也明确强调:想让推导效果好,关键是把 queryFn 写成返回类型明确的函数,而不是在 useQuery 的调用点堆泛型。

This works best if your queryFn has a well-defined returned type. Keep in mind that most data fetching libraries return any per default, so make sure to extract it to a properly typed function

实际上也是如此。像下面这种显式堆泛型的写法,一旦 API 类型有小改动,维护成本就会上来:

useMutation.ts
useMutation<A, B, C, D>(...)

把多余泛型去掉后,省时省力:

useApprovalInstancePage.after.ts
export const useApprovalInstancePage = (
params: Ref<ApprovalInstancesPageRequest> | ApprovalInstancesPageRequest,
options?: {
enabled?: Ref<boolean> | boolean
refetchInterval?: number
},
) => {
return useQuery({
queryKey: computed(() => approvalInstanceKeys.INSTANCE_PAGE(unref(params))),
queryFn: () => approvalService.getInstancePage(unref(params)),
enabled: computed(() => unref(options?.enabled ?? true)),
refetchInterval: options?.refetchInterval,
staleTime: 30 * 1000,
placeholderData: (previousData) => previousData,
})
}

只要 queryFn 返回类型明确,Query / Mutation 不需要在调用点重复写一长串泛型。

2. QueryKey 的定义#

如何使用 QueryKey,官方给的例子已经很明确:

simple-query-key.ts
// A list of todos
useQuery({ queryKey: ["todos"], ... })
// Something else, whatever!
useQuery({ queryKey: ["something", "special"], ... })
todo-query-key.ts
// An individual todo
useQuery({ queryKey: ["todo", 5], ... })
// An individual todo in a "preview" format
useQuery({ queryKey: ["todo", 5, { preview: true }], ... })
// A list of todos that are "done"
useQuery({ queryKey: ["todos", { type: "done" }], ... })

需要注意的是,对于数组中的对象来说,字段顺序甚至字段本身是否显式写出 undefined,都不会影响唯一性。也就是说下面三个 QueryKey,在 TanStack Query 中会被视为同一个 Key,可参考官方文档

deterministic-query-key.ts
useQuery({ queryKey: ["todos", { status, page }], ... })
useQuery({ queryKey: ["todos", { page, status }], ... })
useQuery({ queryKey: ["todos", { page, status, other: undefined }], ... })

QueryKey 更适合按链式结构来设计,例如:

approval.query-key.ts
["approval"]
["approval", "detail", id]
["approval", "history", taskId]

这样做的好处是:

  • 前缀失效更自然
  • 统一管理更容易
  • 类型约束更清晰

当然,如果你不想自己设计,也可以直接使用这种思路衍生出的社区库 query-key-factory

2.1 QueryKey 的排序方式#

如果不想引入 query-key-factory 这种额外的库,我更推荐按下面的顺序组织 queryKey

  1. 域 / 命名空间
  2. 资源集合
  3. 视图 / 操作语义
  4. 资源身份或查询参数

这不是形式问题,而是资源归属的表达方式。本质上它是一棵树,这样更方便做失效、命中与调试定位。

比如我有一个审批业务,对应审批流程、审批实例、审批任务、审批历史四张表。对于审批实例,可以这样定义:

approval-instances.query-key.ts
["approval", "instances", "detail", 101]
["approval", "instances", "page", { page: 1, size: 10 }]

对于关系型表结构的业务,则要看你希望从哪个资源视角来管理缓存。

如果某段关系是父资源下的附属数据,并且希望它跟随父资源一起失效,比如角色详情下的权限:

role-permissions.query-key.ts
["role", "detail", roleId, "permissions"]

但如果这段关系会被多个页面独立读取,并且需要独立失效,那么更适合定义为关系本身:

membership.query-key.ts
["userRole", "assignedUsers", { roleId }]
["userRole", "assignedRoles", { userId }]
["membership", "detail", { userId, roleId }]

还有一种情况是资源身份或查询参数本身包含数组,例如:

file.query-key.ts
["file", "get", { ids: [...ids] }]

如果这里的数组是无序关系,那么 [1, 2, 3][3, 2, 1] 在语义上其实是同一批资源,但缓存未必会命中,因为 TanStack Query 只会对对象做稳定哈希,数组本身仍然区分顺序。

因此这种场景需要手动归一化:

normalized-file.query-key.ts
["file", "get", { ids: [a, b].sort((x, y) => x - y) }]

但如果顺序本身有业务含义,就不能这样做。

2.2 缓存持久化与失效策略#

2.2.1 持久化#

默认情况下,缓存只存在于内存中,并不会自动持久化。

如果你需要持久化,当前需要显式接入 persistQueryClient,再配一个 persister

对于 persister,官方常见示例是 localStorage / sessionStorage,详见文档

我项目里因为确实有持久化需求,所以接入了 dexie。这个功能目前仍偏实验性质,相关文档可参考 createSyncStoragePersisterexperimental_createQueryPersister

考虑到登录用户会变化,我使用 accessToken 做了缓存隔离:

query-client-cache:${token ?? 'anonymous'}

我这里的持久化缓存主要用在:

  • OSS 返回图片按过期时间设置缓存,失效后再重新请求,避免用户看到裂图,也避免无意义重复请求
  • 信息聚合页通过版本对比决定是否重新获取
  • 短 TTL 的列表页缓存

注意:我这里本身就有 dexie 的其它用途,并不是建议你为了缓存持久化单独引入一个库。

如果没有额外需求,默认的 localStoragesessionStorage 就已经足够。

2.2.2 失效#

请求成功后,缓存到底该怎么更新?

如果这里没有约束,最后很容易出现两类问题:

  • 该失效的不失效,页面数据不一致
  • 不该全量失效的全量失效,缓存等于白做

2.2.2.1 invalidate 的范围#

在项目初期,最常见的一种写法是:

too-broad-invalidation.ts
queryClient.invalidateQueries({ queryKey: ["xxx"] })

这种做法颗粒度太粗。

正如前面所说,invalidateQueries 本质上是按前缀树去匹配的,所以 QueryKey 结构越清晰,失效范围就越容易控制。如果 QueryKey 本身设计混乱,那 invalidation 最后也只能越来越粗。

比如说我们有个 access 模块,里面有权限、角色、用户-角色、角色-权限这四个 DTO。

如果继续用一个非常粗的模块级前缀:

too-broad-access-invalidation.ts
queryClient.invalidateQueries({ queryKey: ["access"] })

看上去很省事,但问题在于,并不是删了权限就需要把用户-角色以及角色列表的缓存一起失效。

同理,如果你删除了角色,那么前端这边,通常会受到影响的是角色列表用户-角色角色-权限这些相关资源,而不是整个 access 命名空间里的所有缓存。

想要做到更合理的设计,可以先把这些资源拆成四个稳定的子树:

access-subtrees.ts
["access", "roles"]
["access", "permissions"]
["access", "userRoles"]
["access", "rolePermissions"]

进一步抽成统一的 key factory 后,可读性会更好:

access.keys.ts
export const accessKeys = {
all: ["access"] as const,
roles: () => [...accessKeys.all, "roles"] as const,
roleLists: () => [...accessKeys.roles(), "list"] as const,
roleList: (params: RolePageQuery) => [...accessKeys.roleLists(), params] as const,
roleDetails: () => [...accessKeys.roles(), "detail"] as const,
roleDetail: (roleId: number) => [...accessKeys.roleDetails(), roleId] as const,
permissions: () => [...accessKeys.all, "permissions"] as const,
permissionLists: () => [...accessKeys.permissions(), "list"] as const,
permissionList: (params: PermissionPageQuery) =>
16 collapsed lines
[...accessKeys.permissionLists(), params] as const,
permissionDetails: () => [...accessKeys.permissions(), "detail"] as const,
permissionDetail: (permissionId: number) =>
[...accessKeys.permissionDetails(), permissionId] as const,
userRoles: () => [...accessKeys.all, "userRoles"] as const,
userRolesByUser: (userId: number | null) =>
[...accessKeys.userRoles(), "byUser", { userId }] as const,
userRolesByRole: (roleId: number | null) =>
[...accessKeys.userRoles(), "byRole", { roleId }] as const,
rolePermissions: () => [...accessKeys.all, "rolePermissions"] as const,
rolePermissionsByRole: (roleId: number | null) =>
[...accessKeys.rolePermissions(), "byRole", { roleId }] as const,
rolePermissionsByPermission: (permissionId: number | null) =>
[...accessKeys.rolePermissions(), "byPermission", { permissionId }] as const,
}

回到上面删除角色的问题上,需要给对应资源做缓存失效的话:

delete-role.invalidation.ts
queryClient.invalidateQueries({ queryKey: accessKeys.roles() })
queryClient.invalidateQueries({ queryKey: accessKeys.userRolesByRole(roleId) })
queryClient.invalidateQueries({ queryKey: accessKeys.rolePermissionsByRole(roleId) })
// 既然角色已经删除,对应 detail 也应该直接移除。
queryClient.removeQueries({ queryKey: accessKeys.roleDetail(roleId), exact: true })

总的来说,主动失效缓存是最方便的办法,但代价是下次访问相关资源时,仍需要重新向后端获取结果。因此它更适合这些场景:

  • 影响范围不确定
  • 会影响列表结构
  • 会影响关系型资源
  • Mutation 返回结果不完整
  • 需要以后端重新计算的结果为准

2.3 setQueryData#

除了主动失效缓存,我们还可以通过 setQueryData 直接写入缓存去代替旧缓存。

它和 invalidateQueries 的区别在于:前者表示前端已经明确知道最新结果是什么;后者表示前端只知道旧数据不可信了,需要让后端重新给出最新状态。

setQueryData 适合这些场景:

  • Mutation 成功后返回了完整的新实体
  • 影响范围明确且单一
  • 只需要更新某个 detail cache
  • 当前前端拿到的数据已经可以作为最新真值

例如编辑角色后,如果后端直接返回最新角色详情,那么可以直接更新:

update-role-detail.ts
queryClient.setQueryData(roleKeys.detail(role.id), role)

但如果这次变更会影响:

  • 列表结构
  • 分页总数
  • 排序结果
  • 关系型资源
  • 服务端聚合字段
  • 权限派生结果

那么通常不应该强行在前端 patch 多份缓存,而应该选择 invalidateQueries,让后端重新计算并返回结果。

总的来说:

单实体、完整返回、影响范围明确时,用 setQueryData

涉及列表、关系、聚合与派生结果时,优先 invalidateQueries

3. 复用查询配置#

官方文档提到,如果同一份 query 配置要复用在:

  • Hook
  • Prefetch
  • queryClient.getQueryData
  • SSR / loader

这些地方,就可以把配置提成 queryOptions(...)

approval-instance-detail.query.ts
export const approvalInstanceDetailQuery = (id: number) =>
queryOptions({
queryKey: ["approval", "instances", "detail", id],
queryFn: () => approvalService.getDetail(id),
staleTime: 5 * 60 * 1000,
})
useQuery(approvalInstanceDetailQuery(1))
queryClient.prefetchQuery(approvalInstanceDetailQuery(1))

其实就是一种非常基础的代码复用范式,不多展开。

4. 错误处理边界#

TanStack Query 本身提供了非常完善的状态管理能力,例如:

  • isLoading
  • isError
  • error
  • retry
  • onSuccess / onError
  • QueryCache / MutationCache 的全局回调

但真正落到项目中,如果没有边界意识,通常会慢慢演变成:

  • axios interceptor 里处理一部分
  • axios 封装里处理一部分
  • useQuery / useMutation 里处理一部分
  • 页面组件里再处理一部分
  • 再加上全局 toast、局部 message、业务错误弹窗混在一起

这样不仅看起来混乱,维护上也会有很大的麻烦。

4.1 职责分离#

所以得再强调一次,TanStack Query 更适合负责:

  • Query / Mutation 的状态流转
  • Retry 策略
  • 请求成功/失败后的缓存更新,以及一些状态清理,比如 requestId
  • 统一的全局 success/error 生命周期钩子(也就是通用处理)
  • 基于 meta 的行为控制(success/error 时的额外行为)

这里清理的并不是单次 HTTP 请求头中的 requestId,而是 queryKey -> requestId 的生命周期绑定关系。

它应当随着 Query settled 被清理,而不是随着某次 request 结束被清理,否则会打断 retry / replay 期间的 requestId 关联。

简单来说,当请求开启 retry 时,TanStack Query 可能会发起多次 Axios 请求。我们应该让这组重试请求共享同一个业务链路 ID,而不是每次 Axios 报错就粗暴地把它删掉。

比如说 token 注入、requestId 注入、RFC7807 / BusinessError 映射、401/403 这类与业务无关的异常,本身都不应该在 TanStack Query 中处理,而应放在请求层。

也就是说,下面这些行为更适合交给请求层:

  • 网络错误
  • 超时
  • Token 失效
  • Refresh / replay
  • 401 最终失败后的 session 清理
  • 403 这类鉴权/权限级反馈

因为这些问题本质上并不关心当前业务 Query 是什么,它们只关心当前请求有没有被正常完成。

而页面层则更适合处理和当前交互 context 强相关的反馈,比如:

  • 表单保存后关闭弹窗(如需)
  • 列表更新后不要重复 toast(Query 层已经做过的事情,页面层就不要再做一遍)
  • 局部 UI 提示,例如流程走完后更适合用页面状态呈现,而不是简单 toast

toast 本身也不应该被滥用。对于下面这些场景,请求成功后选择静默:

  • useQuery 自动请求成功
  • useMutation 成功后页面本身已经有明显 UI 变化
  • 高频操作(比如列表page切换)
  • 跳转后结果页已经能自证成功

简而言之:

  • Query success toast 默认关闭
  • Mutation success toast 默认关闭
  • 通过 meta.toastOnSuccess 显式控制

至于失败提示,则要看业务影响。

在多数列表 / 表单型查询场景下,useQuery 失败往往已经有显式 UI 状态,因此再额外 toast 会很吵,看着很乱。

useMutation 失败通常发生在用户主动操作之后,如果没有特殊页面级UI需求,则需要通过toast之类的形式给出明确反馈。

5. 配置示例#

query-client.ts
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: shouldRetryQueryError,
},
},
queryCache: new QueryCache({
onError: globalBaseErrorHandler,
onSuccess(data, query) {
const result = data as SuccessPayload
globalSuccessHandler(result, null, null, query, undefined)
},
onSettled(_data, _error, query) {
clearQueryRequestId(query.queryKey)
},
}),
mutationCache: new MutationCache({
onError: globalMutationErrorHandler,
onSuccess(data, _variables, _context, mutation) {
const result = data as SuccessPayload
globalSuccessHandler(result, null, null, undefined, mutation)
},
}),
})

如果对里面的具体处理感兴趣,可以继续往下看。

5.1 Retry 策略#

开启 retry 后,并不是所有错误都需要重试。有些错误本身就是明显的客户端错误,retry徒增没必要的请求。

retry-policy.ts
const MAX_QUERY_RETRY_COUNT = 2
const RETRYABLE_CLIENT_STATUS_CODES = new Set([408, 429])
function isRetryableHttpStatus(status: number): boolean {
return status >= 500 || RETRYABLE_CLIENT_STATUS_CODES.has(status)
}
function resolveErrorStatus(error: unknown): number | undefined {
if (error instanceof BusinessError) {
return error.status
}
if (axios.isAxiosError(error)) {
const problemDetails = error.response?.data as RFC7807Response<unknown> | undefined
return error.response?.status ?? problemDetails?.status
}
return undefined
}
function isRetryableNetworkError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false
}
if (error.request && !error.response) {
return true
}
return error.code === "ECONNABORTED"
}
function shouldRetryQueryError(failureCount: number, error: unknown): boolean {
if (error instanceof Error && error.name === "CanceledError") {
return false
}
const status = resolveErrorStatus(error)
const retryableError =
typeof status === "number"
? isRetryableHttpStatus(status)
: isRetryableNetworkError(error)
return retryableError && failureCount < MAX_QUERY_RETRY_COUNT
}

5.2 错误解析与处理#

5.2.1 解析#

process-api-error.ts
function processApiError(error: Error | undefined): ProcessedError | undefined {
const originalError = error
if (error?.hasOwnProperty("isBusinessError")) {
const bizError = error as BusinessError
const traceIdSuffix = bizError.traceId ? `\n\nTraceId: ${bizError.traceId}` : ""
const requestIdSuffix = bizError.requestId ? `\nRequestId: ${bizError.requestId}` : ""
return {
title: $t("common.error.businessFail"),
content:
(bizError.message === "error" ? $t("common.status.error") : bizError.message) +
traceIdSuffix +
requestIdSuffix,
originalError,
}
}
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<RFC7807Response<unknown>>
if (axiosError.response) {
const problemDetails = axiosError.response.data
const serverMessage = problemDetails?.detail || problemDetails?.title
const traceIdSuffix = problemDetails?.traceId ? `\n\nTraceId: ${problemDetails.traceId}` : ""
const requestId =
readRequestIdFromBody(problemDetails) ??
readRequestIdFromHeaders(axiosError.response.headers)
const requestIdSuffix = requestId ? `\nRequestId: ${requestId}` : ""
return {
title: $t("common.error.timeout"),
content:
(serverMessage || $t("common.error.timeoutMeta")) +
traceIdSuffix +
requestIdSuffix,
originalError,
}
}
if (axiosError.request) {
return {
title: $t("common.error.networkUnavailable"),
content: $t("common.error.networkUnavailableMeta"),
originalError,
}
}
}
if (error instanceof Error) {
if (error.name === "CanceledError") {
return undefined
}
return {
title: $t("common.error.unexpected"),
content: error.message || $t("common.error.contactAdmin"),
originalError,
}
}
return {
title: $t("common.error.unknown"),
content: $t("common.error.reported"),
originalError,
}
}

5.2.2 处理#

global-error-handler.ts
const globalBaseErrorHandler = (
error: unknown,
query?: AppQuery,
mutation?: AppMutation,
) => {
const target = query ?? mutation
if (!target) {
return
}
if (shouldSkipGlobalErrorHandler(target)) {
return
}
const isDefaultToastOnError = query ? shouldShowDefaultQueryErrorToast(query) : true
const isExecute =
target.meta?.toastOnError === undefined
? isDefaultToastOnError
: !!target.meta?.toastOnError
if (!isExecute) {
return
}
if (error instanceof Error) {
console.error("A global request error was caught:", error)
}
const errorKey = buildGlobalErrorKey(error, query, mutation)
const toastOnErrorConfig = target.meta?.toastOnError ?? true
match(typeof toastOnErrorConfig)
.with("function", () => {
if (query) {
const handler = toastOnErrorConfig as QueryErrorToastHandler
showUniqueErrorNotification(errorKey, handler(error, query))
return
}
if (mutation) {
const handler = toastOnErrorConfig as MutationErrorToastHandler
showUniqueErrorNotification(errorKey, handler(error, mutation))
}
})
.with("object", () => {
const handler = toastOnErrorConfig as NaiveNotificationOptions
showUniqueErrorNotification(errorKey, handler)
})
.otherwise(() => {
const result = processApiError(error as Error | undefined)
if (!result) {
return
}
showUniqueErrorNotification(errorKey, {
title: result.title,
content: result.content,
duration: 5000,
keepAliveOnHover: true,
})
})
}
const globalMutationErrorHandler = (
error: unknown,
_context: unknown,
_variables: unknown,
mutation: AppMutation,
) => {
globalBaseErrorHandler(error, undefined, mutation)
}

5.3 成功处理#

global-success-handler.ts
const globalSuccessHandler = (
data: SuccessPayload,
_variables: unknown,
_context: unknown,
query?: AppQuery,
mutation?: AppMutation,
) => {
const target = query ?? mutation
if (!target) {
return
}
const toastOnSuccessConfig = target.meta?.toastOnSuccess
// best practice: success toast 默认关闭,仅显式开启才提示。
if (toastOnSuccessConfig === undefined || toastOnSuccessConfig === false) {
return
}
if (typeof toastOnSuccessConfig === "function") {
if (query) {
const handler = toastOnSuccessConfig as QuerySuccessToastHandler
notification.success(handler(data, query))
return
}
if (mutation) {
const handler = toastOnSuccessConfig as MutationSuccessToastHandler
notification.success(handler(data, mutation))
}
return
}
if (typeof toastOnSuccessConfig === "object" && toastOnSuccessConfig) {
notification.success(toastOnSuccessConfig as NaiveNotificationOptions)
return
}
if (toastOnSuccessConfig === true) {
const successDetail = (() => {
if (!data || typeof data !== "object") {
return undefined
}
if ("config" in data && "data" in data) {
return (data as AxiosResponse<RFC7807Response<unknown>>).data?.detail
}
if ("detail" in data) {
return (data as RFC7807Response<unknown>).detail
}
return undefined
})()
notification.success({
title: $t("common.status.success"),
content: successDetail ?? $t("common.status.success"),
duration: 5000,
keepAliveOnHover: true,
})
}
}

5.4 RequestId 的清理#

query-request-id.ts
import { hashKey, type QueryKey } from "@tanstack/query-core"
import { createRequestId } from "@/app/infrastructure/request/request-id"
const queryRequestIdPool = new Map<string, string>()
const toQueryHash = (queryKey: QueryKey): string => hashKey(queryKey)
export const getOrCreateQueryRequestId = (queryKey: QueryKey): string => {
const queryHash = toQueryHash(queryKey)
const cached = queryRequestIdPool.get(queryHash)
if (cached) {
return cached
}
const requestId = createRequestId()
queryRequestIdPool.set(queryHash, requestId)
return requestId
}
export const clearQueryRequestId = (queryKey: QueryKey): void => {
queryRequestIdPool.delete(toQueryHash(queryKey))
}
TanStack Query在项目中的实践总结
https://blog.astro777.cfd/posts/guide/tanstack-query-best-practices/
作者
ASTRO
发布于
2026-03-25
许可协议
CC BY-NC-SA 4.0