@tanstack/vue-query 使用说明

@tanstack/vue-query 使用说明

Tags
Javascript
Published
Feb 28, 2025

@tanstack/vue-query 使用说明

是什么

@tanstack/vue-query一个请求(XHR 或 Fetch,下文统称 Request)控制库服务端状态管理库

请求控制

所谓的请求控制,主要目标是控制反转,从手动调用 Request 到自动化调用 Request。
这样做带来了如下优势:
  1. 封装了构建应用所需的常用功能,开箱即用。
  1. 在 axios 层上建立了一个 useQuery 层。如果 axios 层抽象的是「Request」,那 useQuery 层抽象的就是「渲染时获取的 Request」。
请看如下代码:
// --- 使用前 --- <template> <div v-if="userInfoLoading$">loading...</div> <div v-else> {{ userInfo$?.username }} </div> </template> <script> import { onMounted } from 'vue' const userInfo$ = ref() const userInfoLoading$ = ref(false) function onSuccess() { console.log('success') } function onError() { console.log('error') } onMounted(async () => { try { userInfoLoading$.value = true const res = await commonAPIs.getUserInfo() userInfo$.value = res onSuccess() } catch (e) { onError() } finally { userInfoLoading$.value = false } }) </script>
// --- 使用后 --- <template> <div v-if="userInfoLoading$">loading...</div> <div v-else> {{ userInfo$?.username }} </div> </template> <script> import { computed } from 'vue' import { useQuery } from './useQuery' const getUserInfoQuery = useQuery({ queryKey: ['commonAPIs.getUserInfo'], queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), }) const userInfo$ = computed(() => { return getUserInfoQuery.data.value }) const userInfoLoading$ = computed(() => { return getUserInfoQuery.isFetching.value }) function onSuccess() { console.log('success') } function onError() { console.log('error') } watch( getUserInfoQuery.status, (status) => { if (status === 'success') { onSuccess() } if (status === 'error') { onError() } }, { immediate: true, }, ) </script>
仔细看不难看出,使用 useQuery 后的写法,所有数据的数据源都来自 useQuery 的返回值。
这种写法定义 userInfo$userInfoLoading$ 的目的仅仅是为了缩短冗长变量名称,因为 Vue 只会编译在 top level 的 Ref。

服务端状态管理

@tanstack/vue-query 的另一个重要特性是服务端状态管理。
在传统的写法中,如果需要跨组件共享 Request 的 Response,一般在请求返回后,将其手动存储至状态管理容器(如 pinia)中,然后在需要时从状态管理容器中取出。
@tanstack/vue-query 集成了缓存控制,能够自动化的控制 Request 的响应,并在缓存数据陈旧或不可靠时,自动向服务端获取最新的数据。
// 组件 1 <script> import { useQuery } from '@tanstack/vue-query' import { onMounted } from 'vue' const userInfoQuery = useQuery({ queryKey: ['commonAPIs.getUserInfo'], queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), gcTime: Infinity, staleTime: Infinity, }) onMounted(() => { // print: undefined console.log(userInfoQuery.data.value) }) </script>
// 组件 2 <script> import { useQuery } from '@tanstack/vue-query' const userInfoQuery = useQuery({ queryKey: ['commonAPIs.getUserInfo'], queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), gcTime: Infinity, staleTime: Infinity, }) onMounted(() => { // print: { username: 'xxx' } console.log(userInfoQuery.data.value) }) </script>
我们在第一个组件使用 useQuery,并指定两个关键的参数 gcTime: InfinitystaleTime: Infinity
然后我们使用 router 跳转到组件 2,组件 2 可以立即获取到组件 1 的返回数据,而不会重新发起请求。
那么,如果组件 1 和 组件 2 同时渲染,会重复发起请求吗?
答案是不会。useQuery 根据 queryKey 作为定位一个缓存的唯一标识 key,相同 key 的任意 useQuery 仅会请求一次。

使用仍必须的知识

参数变更时重新请求

下面是一个简单的列表组件,使用 useQuery 获取数据:
// --- 使用前,命令式 --- <script> import { onMounted } from 'vue' const userInfo$ = ref() const handlePageChange = async (pageNo, pageSize) => { const res = await commonAPIs.getUserInfo({ pageNo, pageSize, }) userInfo$.value = res } onMounted(() => { handlePageChange(1, 10) }) </script>
// --- 使用前,状态驱动 --- <script> const userInfo$ = ref() const tablePagination$ = ref({ pageNo: 1, pageSize: 10, }) const handlePageChange = async (pageNo, pageSize) => { tablePagination$.value = { pageNo, pageSize, } } watch( tablePagination$, async (tablePagination) => { const { pageNo, pageSize } = tablePagination const res = await commonAPIs.getUserInfo({ pageNo, pageSize, }) userInfo$.value = res }, { immediate: true, }, ) </script>
// --- 使用后 --- <script> const tablePagination$ = ref({ pageNo: 1, pageSize: 10, }) const getUserInfoQuery = useQuery({ queryKey: ['commonAPIs.getUserInfo', tablePagination$], queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), }) const handlePageChange = async (pageNo, pageSize) => { tablePagination$.value = { pageNo, pageSize, } } </script>
如果以前经常使用的「命令式」的重新获取,看到「状态驱动式」重新获取可能会有些迷惑。
我们在这里不讨论两种方式的优劣,但是要知道的是,本项目内使用的大部分都是「状态驱动式」的重新获取。
如果要简单的比较,答案是,状态驱动的方式有更高的内聚
举一个简单的例子,一样是用传统的列表页来做示例。对于列表来说,一个常见的需求,是在其他任意参数变更后,将 pageNo 重置为 1,以避免错误的请求。
对于「命令式」,由于代码过于分散,我们只能在每个 “命令” 的地方来调用函数来重置。
<script> import { onMounted } from 'vue' const userInfo$ = ref() const tableParams$ = ref({ pageNo: 1, pageSize: 10, }) const fetchData = async () => { const res = await commonAPIs.getUserInfo(tableParams$) userInfo$.value = res } const handlePageChange = async (pageNo, pageSize) => { tableParams$.pageNo = pageNo tableParams$.pageSize = pageSize fetchData() } const handleIdCardChange = async (idCardNumber) => { tableParams$.pageNo = 1 tableParams$.idCardNumber = idCardNumber fetchData() } const handleGenderChange = async (gender) => { tableParams$.pageNo = 1 tableParams$.gender = gender fetchData() } // ... onMounted(() => { fetchData() }) </script>
而「状态驱动式」的数据获取可以很轻松的进行封装,来自动重置 pageSize。
<script> import { useResetPageNo } from './useResetPageNo' const userInfo$ = ref() const tablePagination$ = ref({ pageNo: 1, pageSize: 10, }) // --- new --- // 具体的代码可在 repo 中寻找 useResetPageNo(tablePagination$, 'pageNo') const handlePageChange = async (pageNo, pageSize) => { tablePagination$.value = { pageNo, pageSize, } } watch( tablePagination$, async (tablePagination) => { const { pageNo, pageSize } = tablePagination const res = await commonAPIs.getUserInfo({ pageNo, pageSize, }) userInfo$.value = res }, { immediate: true, }, ) </script>
当然,上面的「命令式」的例子是一种极端情况,不代表命令式真的一无是处。
但是一旦使用「状态驱动式」代替命令式,你就会体验到这种细微的差别,和高内聚带来的好处。
某些时候,你可能仍然需要「命令式」的数据获取,当你依赖请求的执行序的时候。我们依然可以 useQuery 来托管,而无需直接调用 Request。
但是可能稍微有点复杂。
<script> import { useQueryClient } from '@tanstack/vue-query' import { computed } from 'vue' const queryClient = useQueryClient() const getUserInfoQueryKeys$ = computed(() => { return ['commonAPIs.getUserInfo'] }) // 可选 const getUserInfoQuery = useQuery({ queryKey: getUserInfoQueryKeys$, queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), enabled: false, }) const handleFetch = async () => { try { const res = await queryClient.fetchQuery({ queryKey: getUserInfoQueryKeys$, queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), }) // do anything } catch (e) { // ... } } </script>
你可以使用 queryClient.fetchQuery 来「命令式」的获取,它接受和 useQuery 一样的参数。
上面的 useQuery 是可选的,我们仍然可以声明一个 useQuery,来使用其内部的 datastatus, loading 等内置功能。
enabled 表示是否开始请求数据。指定 enabled: false 时,我们甚至可以省略 queryFn 的声明,但是仍建议声明,以便获取 data 对应的类型推断。
上面的 useQuery 代码,也可用于从其他 useQuery 缓存处获取数据,而不自己请求数据。

依赖查询

依赖查询可以简单的使用 enabled 来实现
<script> import { computed } from 'vue' const getUserInfoQuery = useQuery({ queryKey: ['commonAPIs.getUserInfo'], queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), }) const getMessageQuery = useQuery({ queryKey: ['commonAPIs.getMessage', getUserInfoQuery.data], queryFn: ({ queryKey }) => commonAPIs.getMessage(queryKey[1]), enabled: computed(() => { return !!getUserInfoQuery.data.value }), }) </script>
getMessageQuery 会在 getUserInfoQuery 的数据存在时,再安全的发起请求。

Query 状态 和 回调

@tanstack/vue-query 的一个 useQuery 和一个实体请求绑定,对应的每个 Query 都有一个 status 属性。
我们可以在 watch 中监听 status 的变化,来执行对应状态下的回调函数。
status 有三种状态 successerrorpending
successerror 分别表示请求成功和失败,即 queryFn 的返回值是 Promise.resolve 还是 Promise.reject。
pending 可以简单的理解为加载中,但是实际的准确描述并不是这么简单,下面会详细介绍。
对应状态的回调逻辑,可以简单的使用 watch 来监听 status 的变化,来执行对应的逻辑。
<script> import { watch } from 'vue' const getUserInfoQuery = useQuery({ queryKey: ['commonAPIs.getUserInfo'], queryFn: ({ queryKey }) => commonAPIs.getUserInfo(queryKey[1]), }) watch( getUserInfoQuery.status, (status) => { if (status === 'success') { onSuccess() } if (status === 'error') { onError() } if (status === 'pending') { onPending() } }, { immediate: true, }, ) </script>
需要注意的是 { immediate: true },由于缓存的原因,如果开启缓存,则 status 的值也会被缓存,可能不会产生变更导致 watch 执行,从而导致错误。
最佳实践是始终设置 immediate: true,因为 immediate: true 同时兼容了有缓存无缓存两种情况,而 immediate: false 仅适用于无缓存的情况,也就是 status 始终会出现 pending -> success 变更的情况。

Loading 和 缓存

当一个 Vue 组件卸载时,它内部的状态会被销毁。更进一步的讲,它的内部对象会被 Javascript 引擎的 GC 回收。
如果两个 Vue 组件想要共享相同的状态,两种的传统方式是: 「状态提升」和「状态管理容器」。
「状态提升」就是在父组件维护这个状态,然后通过 Props 传递给两个组件。
「状态管理容器」则是在 App 全局生命周期上,创建一个数据仓库(data storage),所有的组件直接从数据仓库获取数据,从而规避单向数据流的限制。
@tanstack/vue-query 则是一个集成了缓存控制服务端状态管理容器
对于缓存,一个必要的逻辑是确定过期时间,否则用户永远无法从服务获取到新的数据。
用另一种说法来说,就是要确定数据的新鲜度,如果一个数据不新鲜(stale)了,我们就需要和服务端通信,确定数据是否真的是不新鲜了,HTTP 的协商缓存就是这样一个例子。
useQuery 提供了一个简单的参数 staleTime 来指定数据过期时间,超过时间限制的数据,会变的不新鲜,useQuery 会尝试从服务端获取新的数据,这个操作叫验证(validate)。
验证并不会到导致数据被立即丢弃,useQuery 会先返回缓存中不新鲜的数据,而如果有数据变更,再更新这个数据。这种思路叫乐观更新(optimistic updates),是 UI 设计中的一种思想。
如果指定了 staleTime: Infinity 则表示数据永远新鲜,相当于禁用了 useQuery 的 auto validation 这个特性。
默认情况下是 staleTime: 0,表示数据始终不新鲜,任意的 useQuery 调用都会触发验证。
另一个必须的参数是 gcTime
默认情况下,useQuery 的数据销毁逻辑和 Vue Component 一样,在组件卸载的时候 GC。
gcTime 打破了这个规则。
指定了 gcTime 则表示在对应的时间范围内,数据不会被 GC,只有超出了对应的时间才会 GC。
没被 GC 的数据可以在其他组件中通过相同的 queryKey 来获取,也就是通用意义上的「使用缓存数据」。
staleTime 的乐观更新不同,超过 gcTime 的数据会立即被垃圾回收,query.data 会返回 undefined。
指定 gcTime: Infinity,则表示数据永远不会被垃圾回收,永远存在在内存中,可以像传统状态管理容器那样在 App 生命周期内任意访问。
staleTimegcTime@tanstack/vue-query 缓存控制功能的仅有的两个参数。
你可以简单的配置 gcTime: InfinitystaleTime: Infinity 来实现传统状态管理容器的功能,也可以根据需求,更细粒度的控制缓存逻辑。
另一个要强调的是,@tanstack/vue-query 是一个服务端状态管理容器。它不是一个客户端状态管理容器。
只有数据源来自服务端,和一个 Request 强关联的数据,才应该使用 @tanstack/vue-query 管理。
举个例子,一个侧边 Menu 是否展开,不是一个服务端状态,而用户信息,则是服务端状态。
而由于 @tanstack/vue-query 集成了缓存控制,pending 则是一个比较特殊的状态。
pending if there’s no cached data and no query attempt was finished yet.
没有 Cache 数据,且尚未完成查询的尝试, pending 就是 true。
这里的 Cache 不是上文提到的缓存,上文提到的缓存是一个通用概念,即「无需重复请求数据源,从本地获取之前请求的结果数据」。
这里的 Cache 表示,我们已经成功请求了数据源,或者通过其他方式(如从 localStorage 手动设置),把数据存储到了内存中,这就是有 Cache,否则,则是 no Cache。
<script> import { onMounted } from 'vue' const data$ = ref() onMounted(async () => { const res = await commomAPIs.getUserInfo() // data 已经被成功 set,有 Cache data$.value = res.data }) </script>
「且尚未完成查询的尝试」则是 Promise 的那个 settled 的概念,不管是成功,还是失败,只要状态敲定,就是完成(finished)。
这是一个且逻辑。
也就是说两个条件任意一个不满足,则脱离 pending 状态。
以下两种情况都会导致脱离 pending。
  1. 请求没回来,但是通过 QueryClient.setQueryData() 设置了 data。
  1. 请求回来了,无论是 error,还是没返回 data。
@tanstack/vue-query 提供了多个 loading 状态,以便和缓存控制的状态对应。
isPending 表示 status 处于 pending 状态。
isFetching 表示一个 Request 被 @tanstack/vue-query 发出,正在 devtool 里面展示 XHR 的 pending。
isRefetching === isFetching && !isPending,当前的 useQuery 不是 pending 状态,且 Request 被 useQuery 发起。
isLoading === isFetching && isPending,当前的 useQuery 是 pending 状态,且 Request 被 useQuery 发起。
脱离 pending 的概念,isLoading 可以理解为常规概念的「硬加载」,也就是没有 Cache,第一次从服务端获取数据。
isRefetching 则是之前请求过,或者从缓存中读取出了数据,再次请求服务端,如有参数变更或 validation。

Queries 和 Mutations

另一个重要的概念是 查询 Queries变更 Mutations
Queries 表示从服务端获取数据,不会导致服务端的状态改变。这类请求一般「无副作用」和「幂等」的。
Mutations 表示向服务端发送数据,会导致服务端的状态改变,如创建、更新、删除等操作。这类请求一般「有副作用」和「不幂等」的。
对于 Queries,应该使用 useQuery
对于 Mutations,应该使用 useMutation
下面是一个简单的使用 useMutation 的示例。
<script> import { useMutation } from '@tanstack/vue-query' const createUserMutation = useMutation({ mutationFn: commonAPIs.createUser, }) const handleClick = async (data) => { await createUserMutation.mutateAsync(data) } </script>
对于 Mutations, 我们无需启用缓存控制和状态管理,也就不需要指定 mutationKey,仅将 useMutation 当做一个获取 loading 的 hook 即可。
@tanstack/vue-query 并不关心实际发起的 HTTP 请求方法是 GET 还是 POST,这部分信息已经被 Request Function 隐藏掉了。

总结

这里介绍了 @tanstack/vue-query 的基本用法,这些用法已经涵盖了 90% 的用例。
大部分情况,无需进阶的用法。
但是如果有更多的需求,可以去浏览官网,官网介绍了更多的内容,比如设计这个 API 的想法,这个库的思维模型,概念上的一些细节,等等。
感谢阅读。

Loading Comments...