1. 探讨类型声明
TanStack Query 在项目中并非只是管理请求发送的库,它更核心的职责是管理服务端状态,解决数据跨组件共享、持久化、错误处理等问题。
但引入之后,项目很快会碰到一个常见问题:类型到底该写在哪一层?以我项目中的一个 Hook 为例:
/** * 获取审批实例分页数据 */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,但这一点本质上是互通的:
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<A, B, C, D>(...)把多余泛型去掉后,省时省力:
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,官方给的例子已经很明确:
// A list of todosuseQuery({ queryKey: ["todos"], ... })
// Something else, whatever!useQuery({ queryKey: ["something", "special"], ... })// An individual todouseQuery({ queryKey: ["todo", 5], ... })
// An individual todo in a "preview" formatuseQuery({ queryKey: ["todo", 5, { preview: true }], ... })
// A list of todos that are "done"useQuery({ queryKey: ["todos", { type: "done" }], ... })需要注意的是,对于数组中的对象来说,字段顺序甚至字段本身是否显式写出 undefined,都不会影响唯一性。也就是说下面三个 QueryKey,在 TanStack Query 中会被视为同一个 Key,可参考官方文档:
useQuery({ queryKey: ["todos", { status, page }], ... })useQuery({ queryKey: ["todos", { page, status }], ... })useQuery({ queryKey: ["todos", { page, status, other: undefined }], ... })QueryKey 更适合按链式结构来设计,例如:
["approval"]["approval", "detail", id]["approval", "history", taskId]这样做的好处是:
- 前缀失效更自然
- 统一管理更容易
- 类型约束更清晰
当然,如果你不想自己设计,也可以直接使用这种思路衍生出的社区库 query-key-factory。
2.1 QueryKey 的排序方式
如果不想引入 query-key-factory 这种额外的库,我更推荐按下面的顺序组织 queryKey:
- 域 / 命名空间
- 资源集合
- 视图 / 操作语义
- 资源身份或查询参数
这不是形式问题,而是资源归属的表达方式。本质上它是一棵树,这样更方便做失效、命中与调试定位。
比如我有一个审批业务,对应审批流程、审批实例、审批任务、审批历史四张表。对于审批实例,可以这样定义:
["approval", "instances", "detail", 101]["approval", "instances", "page", { page: 1, size: 10 }]对于关系型表结构的业务,则要看你希望从哪个资源视角来管理缓存。
如果某段关系是父资源下的附属数据,并且希望它跟随父资源一起失效,比如角色详情下的权限:
["role", "detail", roleId, "permissions"]但如果这段关系会被多个页面独立读取,并且需要独立失效,那么更适合定义为关系本身:
["userRole", "assignedUsers", { roleId }]["userRole", "assignedRoles", { userId }]["membership", "detail", { userId, roleId }]还有一种情况是资源身份或查询参数本身包含数组,例如:
["file", "get", { ids: [...ids] }]如果这里的数组是无序关系,那么 [1, 2, 3] 和 [3, 2, 1] 在语义上其实是同一批资源,但缓存未必会命中,因为 TanStack Query 只会对对象做稳定哈希,数组本身仍然区分顺序。
因此这种场景需要手动归一化:
["file", "get", { ids: [a, b].sort((x, y) => x - y) }]但如果顺序本身有业务含义,就不能这样做。
2.2 缓存持久化与失效策略
2.2.1 持久化
默认情况下,缓存只存在于内存中,并不会自动持久化。
如果你需要持久化,当前需要显式接入 persistQueryClient,再配一个 persister。
对于 persister,官方常见示例是 localStorage / sessionStorage,详见文档。
我项目里因为确实有持久化需求,所以接入了 dexie。这个功能目前仍偏实验性质,相关文档可参考 createSyncStoragePersister 与 experimental_createQueryPersister。
考虑到登录用户会变化,我使用 accessToken 做了缓存隔离:
query-client-cache:${token ?? 'anonymous'}
我这里的持久化缓存主要用在:
- OSS 返回图片按过期时间设置缓存,失效后再重新请求,避免用户看到裂图,也避免无意义重复请求
- 信息聚合页通过版本对比决定是否重新获取
- 短 TTL 的列表页缓存
注意:我这里本身就有
dexie的其它用途,并不是建议你为了缓存持久化单独引入一个库。如果没有额外需求,默认的
localStorage或sessionStorage就已经足够。
2.2.2 失效
请求成功后,缓存到底该怎么更新?
如果这里没有约束,最后很容易出现两类问题:
- 该失效的不失效,页面数据不一致
- 不该全量失效的全量失效,缓存等于白做
2.2.2.1 invalidate 的范围
在项目初期,最常见的一种写法是:
queryClient.invalidateQueries({ queryKey: ["xxx"] })这种做法颗粒度太粗。
正如前面所说,invalidateQueries 本质上是按前缀树去匹配的,所以 QueryKey 结构越清晰,失效范围就越容易控制。如果 QueryKey 本身设计混乱,那 invalidation 最后也只能越来越粗。
比如说我们有个 access 模块,里面有权限、角色、用户-角色、角色-权限这四个 DTO。
如果继续用一个非常粗的模块级前缀:
queryClient.invalidateQueries({ queryKey: ["access"] })看上去很省事,但问题在于,并不是删了权限就需要把用户-角色以及角色列表的缓存一起失效。
同理,如果你删除了角色,那么前端这边,通常会受到影响的是角色列表、用户-角色、角色-权限这些相关资源,而不是整个 access 命名空间里的所有缓存。
想要做到更合理的设计,可以先把这些资源拆成四个稳定的子树:
["access", "roles"]["access", "permissions"]["access", "userRoles"]["access", "rolePermissions"]进一步抽成统一的 key factory 后,可读性会更好:
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,}回到上面删除角色的问题上,需要给对应资源做缓存失效的话:
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
- 当前前端拿到的数据已经可以作为最新真值
例如编辑角色后,如果后端直接返回最新角色详情,那么可以直接更新:
queryClient.setQueryData(roleKeys.detail(role.id), role)但如果这次变更会影响:
- 列表结构
- 分页总数
- 排序结果
- 关系型资源
- 服务端聚合字段
- 权限派生结果
那么通常不应该强行在前端 patch 多份缓存,而应该选择 invalidateQueries,让后端重新计算并返回结果。
总的来说:
单实体、完整返回、影响范围明确时,用
setQueryData。涉及列表、关系、聚合与派生结果时,优先
invalidateQueries。
3. 复用查询配置
官方文档提到,如果同一份 query 配置要复用在:
- Hook
- Prefetch
queryClient.getQueryData- SSR / loader
这些地方,就可以把配置提成 queryOptions(...)。
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 本身提供了非常完善的状态管理能力,例如:
isLoadingisErrorerrorretryonSuccess/onErrorQueryCache/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. 配置示例
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徒增没必要的请求。
const MAX_QUERY_RETRY_COUNT = 2const 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 解析
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 处理
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 成功处理
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 的清理
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))}