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 的标准原语,如 useQueryuseMutationuseInfiniteQuery

这种方式让你完全控制 TanStack Query 的特性,同时保持端到端的类型安全。查询键(query key)会根据你的路由名称和参数自动生成,缓存失效变得简单且类型安全。该集成专一地使用路由名称,确保你的 API 调用与 URL 结构解耦。

两个适配器共享相同的 API 表面。唯一的区别是框架特定的导入和组件语法。

前提条件

在使用 TanStack Query 集成之前,你必须在应用中安装并配置好 Tuyau。请先按照 Tuyau 安装指南 设置你的 Tuyau 客户端。

你应当熟悉:

安装

在你的前端应用中安装 TanStack Query 集成包。

npm install @tanstack/react-query @tuyau/react-query
npm install @tanstack/vue-query @tuyau/vue-query
Note

确保 @tuyau/react-query(或 @tuyau/vue-query)和 @tuyau/core 处于兼容的版本。如果安装后出现类型错误,请检查两个包是否共享相同的大版本和预发布标签(例如都在 1.x 上,或都在 next 上)。

设置

创建带有 TanStack Query 集成的 Tuyau 客户端。api 对象提供对所有路由的访问,并带有类型安全的查询和变更选项。client 对象是核心 Tuyau 客户端,queryClient 是标准的 TanStack Query 客户端,用于缓存管理和失效。

src/lib/client.ts
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 })
src/lib/client.ts
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 无需任何手动类型标注就能知道数据的确切形状。

src/pages/posts.tsx
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>
  )
}
src/pages/PostsList.vue
<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 选项,如 staleTimeenabledrefetchInterval

src/pages/post.tsx
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>
}
src/pages/PostDetail.vue
<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 会完全跳过该查询。一旦值变为真值,查询就会自动获取。

src/pages/user.tsx
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>
}
src/pages/UserProfile.vue
<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 变更选项,如 onSuccessonErroronSettled。所有变更参数(paramsbody)都根据你的后端验证器完全类型化。

src/pages/posts/create.tsx
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>
  )
}
src/pages/posts/CreatePost.vue
<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 选项指定哪个查询参数持有页码。这必须与你的后端验证器中的参数名称匹配。

src/pages/posts.tsx
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>
  )
}
src/pages/InfinitePosts.vue
<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 参数传递。

app/validators/post.ts
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

app/controllers/posts_controller.ts
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: 1initialPageParam)调用你的 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>
Note

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 会抛出一个来自 KyHTTPError,TanStack Query 会捕获它并通过其标准错误状态暴露出来。

在查询和变更中访问错误

TanStack Query 通过结果对象上的 isErrorerror 暴露错误。

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。

src/lib/client.ts
export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error instanceof HTTPError && error.response.status === 401) {
          return false // 不要在 401 时重试
        }
        return failureCount < 3
      },
    },
  },
})