TanStack Query 集成
本指南介绍 Tuyau 的 TanStack Query 集成。你将学习如何:
- 安装并配置
@tuyau/react-query或@tuyau/vue-query - 生成类型安全的查询和变更选项
- 通过分页实现无限滚动
- 在不同粒度级别上管理缓存失效
- 处理来自失败 API 调用的错误
概述
@tuyau/react-query 和 @tuyau/vue-query 包提供了 Tuyau 与
TanStack Query
之间的无缝集成。你无需创建自定义 hooks 或组合式函数(composables),Tuyau 会生成类型安全的选项对象,你可以直接将其传给 TanStack Query 的标准原语,如 useQuery、useMutation 和 useInfiniteQuery。
这种方式让你完全控制 TanStack Query 的特性,同时保持端到端的类型安全。查询键(query key)会根据你的路由名称和参数自动生成,缓存失效变得简单且类型安全。该集成专一地使用路由名称,确保你的 API 调用与 URL 结构解耦。
两个适配器共享相同的 API 表面。唯一的区别是框架特定的导入和组件语法。
前提条件
在使用 TanStack Query 集成之前,你必须在应用中安装并配置好 Tuyau。请先按照 Tuyau 安装指南 设置你的 Tuyau 客户端。
你应当熟悉:
- TanStack Query 基础 (理解查询、变更和缓存管理)
- Tuyau 路由名称和 API 调用
安装
在你的前端应用中安装 TanStack Query 集成包。
npm install @tanstack/react-query @tuyau/react-query
npm install @tanstack/vue-query @tuyau/vue-query
确保 @tuyau/react-query(或 @tuyau/vue-query)和 @tuyau/core 处于兼容的版本。如果安装后出现类型错误,请检查两个包是否共享相同的大版本和预发布标签(例如都在 1.x 上,或都在 next 上)。
设置
创建带有 TanStack Query 集成的 Tuyau 客户端。api 对象提供对所有路由的访问,并带有类型安全的查询和变更选项。client 对象是核心 Tuyau 客户端,queryClient 是标准的 TanStack Query 客户端,用于缓存管理和失效。
import { registry } from '~registry'
import { createTuyau } from '@tuyau/core/client'
import { QueryClient } from '@tanstack/react-query'
import { createTuyauReactQueryClient } from '@tuyau/react-query'
export const queryClient = new QueryClient()
export const client = createTuyau({ baseUrl: import.meta.env.VITE_API_URL, registry })
export const api = createTuyauReactQueryClient({ client })
import { registry } from '~registry'
import { createTuyau } from '@tuyau/core/client'
import { QueryClient } from '@tanstack/vue-query'
import { createTuyauVueQueryClient } from '@tuyau/vue-query'
export const queryClient = new QueryClient()
export const client = createTuyau({ baseUrl: import.meta.env.VITE_API_URL, registry })
export const api = createTuyauVueQueryClient({ client })
重试行为
Tuyau 构建于 Ky 之上,Ky 默认对失败请求启用自动重试。使用 TanStack Query 集成时,Ky 的重试机制会被自动禁用,以便让 TanStack Query 代为处理重试,因为它也内置了重试功能。
这可以防止双重重试(Ky 先重试,然后 TanStack Query 在其之上再次重试),并让你通过 TanStack Query 的配置完全控制重试行为。
const postsQuery = useQuery(
api.posts.index.queryOptions(
{},
{
retry: 3, // TanStack Query 处理重试
}
)
)
基础查询
使用 queryOptions() 为 TanStack Query 的 useQuery hook 生成选项。所有查询都使用路由名称而非 URL。响应数据根据你的后端控制器返回值完全类型化,因此 TypeScript 无需任何手动类型标注就能知道数据的确切形状。
import { useQuery } from '@tanstack/react-query'
import { api } from '~/lib/client'
export default function PostsList() {
const postsQuery = useQuery(
api.posts.index.queryOptions()
)
if (postsQuery.isLoading) return <div>加载中...</div>
if (postsQuery.isError) return <div>加载文章出错</div>
return (
<div>
{postsQuery.data?.posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
)
}
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { api } from '~/lib/client'
const { data, isLoading, isError } = useQuery(
api.posts.index.queryOptions()
)
</script>
<template>
<div v-if="isLoading">加载中...</div>
<div v-else-if="isError">加载文章出错</div>
<div v-else>
<article v-for="post in data?.posts" :key="post.id">
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
</article>
</div>
</template>
带参数的查询
将路由参数和查询参数作为第一个参数传给 queryOptions()。第二个参数接受任何标准的 TanStack Query 选项,如 staleTime、enabled 或 refetchInterval。
import { useQuery } from '@tanstack/react-query'
import { api } from '~/lib/client'
export default function PostDetail({ postId }: { postId: string }) {
const postQuery = useQuery(
api.posts.show.queryOptions(
{
params: { id: postId },
query: { include: 'comments' }
},
{
staleTime: 5000,
refetchOnWindowFocus: false
}
)
)
return <div>{postQuery.data?.post.title}</div>
}
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { api } from '~/lib/client'
const props = defineProps<{ postId: string }>()
const { data } = useQuery(
api.posts.show.queryOptions(
{
params: { id: props.postId },
query: { include: 'comments' }
},
{
staleTime: 5000,
refetchOnWindowFocus: false
}
)
)
</script>
<template>
<div>{{ data?.post.title }}</div>
</template>
使用 skipToken 的条件查询
使用 skipToken 有条件地禁用查询,同时保持类型安全。这比使用 enabled 选项进行条件获取更干净,因为无论查询是否处于活动状态,查询函数签名都保持不变。当传入 skipToken 时,TanStack Query 会完全跳过该查询。一旦值变为真值,查询就会自动获取。
import { useQuery, skipToken } from '@tanstack/react-query'
import { api } from '~/lib/client'
export default function UserProfile({ userId }: { userId: string | null }) {
const userQuery = useQuery(
api.users.show.queryOptions(
userId ? { params: { id: userId } } : skipToken
)
)
return <div>{userQuery.data?.user.name}</div>
}
<script setup lang="ts">
import { useQuery, skipToken } from '@tanstack/vue-query'
import { api } from '~/lib/client'
const props = defineProps<{ userId: string | null }>()
const { data } = useQuery(
api.users.show.queryOptions(
props.userId ? { params: { id: props.userId } } : skipToken
)
)
</script>
<template>
<div>{{ data?.user.name }}</div>
</template>
变更
使用 mutationOptions() 为 TanStack Query 的 useMutation hook 生成选项。该方法接受标准的 TanStack Query 变更选项,如 onSuccess、onError 和 onSettled。所有变更参数(params、body)都根据你的后端验证器完全类型化。
import { useMutation } from '@tanstack/react-query'
import { api, queryClient } from '~/lib/client'
export default function CreatePost() {
const createPost = useMutation(
api.posts.store.mutationOptions({
onSuccess: () => {
/**
* 创建文章后使文章列表查询失效。
* 这会导致列表重新获取,包含新文章。
*/
queryClient.invalidateQueries({
queryKey: api.posts.list.pathKey()
})
}
})
)
const handleSubmit = (data: { title: string; content: string }) => {
createPost.mutate({
body: {
title: data.title,
content: data.content,
authorId: 1
}
})
}
return (
<form onSubmit={(e) => {
e.preventDefault()
handleSubmit({ title: '我的文章', content: '内容在此' })
}}>
<input name="title" placeholder="标题" />
<textarea name="content" placeholder="内容" />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? '创建中...' : '创建文章'}
</button>
{createPost.isError && (
<p>错误:{createPost.error.message}</p>
)}
</form>
)
}
<script setup lang="ts">
import { useMutation } from '@tanstack/vue-query'
import { api, queryClient } from '~/lib/client'
const createPost = useMutation(
api.posts.store.mutationOptions({
onSuccess: () => {
/**
* 创建文章后使文章列表查询失效。
* 这会导致列表重新获取,包含新文章。
*/
queryClient.invalidateQueries({
queryKey: api.posts.list.pathKey()
})
}
})
)
function handleSubmit(data: { title: string; content: string }) {
createPost.mutate({
body: {
title: data.title,
content: data.content,
authorId: 1
}
})
}
</script>
<template>
<form @submit.prevent="handleSubmit({ title: '我的文章', content: '内容在此' })">
<input name="title" placeholder="标题" />
<textarea name="content" placeholder="内容" />
<button type="submit" :disabled="createPost.isPending">
{{ createPost.isPending ? '创建中...' : '创建文章' }}
</button>
<p v-if="createPost.isError">
错误:{{ createPost.error?.message }}
</p>
</form>
</template>
无限查询
为了分页和无限滚动,请将 infiniteQueryOptions() 与 TanStack Query 的 useInfiniteQuery 一起使用。这需要你的前端查询配置和后端验证之间进行协调。
前端配置
pageParamKey 选项指定哪个查询参数持有页码。这必须与你的后端验证器中的参数名称匹配。
import { useInfiniteQuery } from '@tanstack/react-query'
import { api } from '~/lib/client'
export default function InfinitePosts() {
const postsQuery = useInfiniteQuery(
api.posts.list.infiniteQueryOptions(
{
query: {
limit: 10,
search: 'typescript'
}
},
{
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.meta.nextPage,
pageParamKey: 'page',
}
)
)
const allPosts = postsQuery.data?.pages.flatMap(page => page.posts) || []
return (
<div>
{allPosts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
{postsQuery.hasNextPage && (
<button
onClick={() => postsQuery.fetchNextPage()}
disabled={postsQuery.isFetchingNextPage}
>
{postsQuery.isFetchingNextPage ? '加载中...' : '加载更多'}
</button>
)}
</div>
)
}
<script setup lang="ts">
import { computed } from 'vue'
import { useInfiniteQuery } from '@tanstack/vue-query'
import { api } from '~/lib/client'
const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery(
api.posts.list.infiniteQueryOptions(
{
query: {
limit: 10,
search: 'typescript'
}
},
{
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.meta.nextPage,
pageParamKey: 'page',
}
)
)
const allPosts = computed(() => data.value?.pages.flatMap(page => page.posts) || [])
</script>
<template>
<div>
<article v-for="post in allPosts" :key="post.id">
<h2>{{ post.title }}</h2>
<p>{{ post.content }}</p>
</article>
<button
v-if="hasNextPage"
:disabled="isFetchingNextPage"
@click="fetchNextPage()"
>
{{ isFetchingNextPage ? '加载中...' : '加载更多' }}
</button>
</div>
</template>
后端验证
定义一个包含 pageParamKey 所引用的分页参数的验证器。验证器中的 page 参数必须与前端配置中的 pageParamKey 值匹配。Tuyau 会自动处理从 TanStack Query 的分页系统到后端的 page 参数传递。
import vine from '@vinejs/vine'
export const listPostsValidator = vine.create({
page: vine.number().optional(),
limit: vine.number().optional(),
search: vine.string().optional(),
})
后端控制器
使用经过验证的参数在你的控制器中实现分页。getNextPageParam 函数会检查 lastPage.meta.nextPage 来确定是否还有更多页面。当没有更多页面可加载时返回 null。
import type { HttpContext } from '@adonisjs/core/http'
import { listPostsValidator } from '#validators/post'
export default class PostsController {
async list({ request, serialize }: HttpContext) {
const { page = 1, limit = 10, search } = await request.validateUsing(listPostsValidator)
const posts = await Post.query()
.if(search, (query) => query.where('title', 'like', `%${search}%`))
.paginate(page, limit)
return {
posts: await serialize(PostTransformer.transform(posts.all())),
meta: {
currentPage: posts.currentPage,
lastPage: posts.lastPage,
nextPage: posts.hasNextPage ? posts.currentPage + 1 : null
}
}
}
}
无限查询是如何工作的
当组件挂载时,TanStack Query 以 page: 1(initialPageParam)调用你的 API。响应同时包含关于分页的数据和元数据。getNextPageParam 函数检查此元数据以确定接下来要获取哪一页。
当用户点击"加载更多"时,TanStack Query 会自动再次调用你的 API,传入下一页的页码,将结果追加到现有数据中。Tuyau 会透明地处理将 page 参数注入到你的查询字符串中。
响应式查询(Vue)
在 Vue 中,你经常需要查询在响应式状态改变时重新获取。将你的 queryOptions() 调用包裹在一个 getter 函数中,以便 TanStack Query 自动跟踪依赖项并在它们改变时重新求值。
这能工作是因为 Vue Query 的 useQuery 接受 MaybeRefOrGetter<Options>。当你传入一个 getter 函数时,TanStack Query 会在 computed 内部调用它,这会跟踪求值期间访问的所有响应式依赖项。当 search.value 改变时,computed 会重新求值,生成带有更新后的查询键和查询函数的新查询选项。
<script setup lang="ts">
import { ref } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { api } from '~/lib/client'
const search = ref('')
const { data } = useQuery(
() => api.posts.index.queryOptions({ query: { search: search.value } })
)
</script>
<template>
<input v-model="search" placeholder="搜索文章..." />
<article v-for="post in data?.posts" :key="post.id">
<h2>{{ post.title }}</h2>
</article>
</template>
React 中不需要这种模式,因为组件的重新渲染自然会使用新值再次调用 queryOptions()。
缓存失效
Tuyau 提供了多种不同粒度级别的缓存失效方法。这些方法在 React 和 Vue 中的工作方式完全相同。
queryKey() - 精确匹配
当你确切知道需要失效的是哪个查询,并且你拥有它的所有参数时,使用 queryKey()。
const updatePost = useMutation(
api.posts.update.mutationOptions({
onSuccess: (data, variables) => {
/**
* 仅使被更新的特定文章失效。
* 这是最精确的失效策略。
*/
queryClient.invalidateQueries({
queryKey: api.posts.show.queryKey({
params: { id: variables.params.id }
})
})
}
})
)
pathKey() - 基础路径
当你想使一个不带参数的特定端点失效时,例如不依赖于路由参数的列表查询,使用 pathKey()。
const deletePost = useMutation(
api.posts.delete.mutationOptions({
onSuccess: () => {
/**
* 使这个确切路径的所有查询失效。
* 这会使 posts.list 失效,但不会影响 posts.show。
*/
queryClient.invalidateQueries({
queryKey: api.posts.list.pathKey()
})
}
})
)
pathFilter() - 子树匹配
当你有一个变更可能影响多个相关查询,并想一次性使它们全部失效时,pathFilter() 方法特别有用。
const createProduct = useMutation(
api.products.store.mutationOptions({
onSuccess: () => {
/**
* 使任何路由下的所有产品相关查询失效。
* 这会捕获 products.search、products.list、products.show、
* products.byCategory 以及任何其他产品路由。
*/
queryClient.invalidateQueries(
api.products.pathFilter()
)
}
})
)
queryFilter() - 自定义过滤
使用带有谓词(predicate)函数的 queryFilter() 来细粒度控制要使哪些查询失效。谓词函数接收查询状态,并可以检查缓存数据来做出失效决策。
const archivePost = useMutation(
api.posts.archive.mutationOptions({
onSuccess: () => {
/**
* 仅使文章被标记为 active 的查询失效。
* 使用谓词来检查缓存数据,并根据
* 自定义逻辑决定是否失效。
*/
const filter = api.posts.pathFilter({
predicate: (query) => {
const data = query.state.data
return data?.post?.status === 'active'
},
})
queryClient.invalidateQueries(filter)
}
})
)
错误处理
当 API 调用失败时,Tuyau 会抛出一个来自
Ky
的 HTTPError,TanStack Query 会捕获它并通过其标准错误状态暴露出来。
在查询和变更中访问错误
TanStack Query 通过结果对象上的 isError 和 error 暴露错误。
const postsQuery = useQuery(api.posts.index.queryOptions())
if (postsQuery.isError) {
console.log(postsQuery.error.message)
}
检查 HTTP 状态码
error 对象是一个带有 response 属性的 Ky HTTPError。用它来检查状态码。
import { HTTPError } from 'ky'
const postsQuery = useQuery(api.posts.index.queryOptions())
if (postsQuery.isError && postsQuery.error instanceof HTTPError) {
const status = postsQuery.error.response.status
if (status === 401) {
window.location.href = '/login'
}
}
全局错误处理
与其在每个组件中检查状态码,不如使用 queryClient 默认选项配置一个全局处理器。这可以防止对未授权请求进行重试,并让你在整个应用中一致地处理 401。
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof HTTPError && error.response.status === 401) {
return false // 不要在 401 时重试
}
return failureCount < 3
},
},
},
})