@tanstack/vue-query 使用说明
是什么
@tanstack/vue-query
是一个请求(XHR 或 Fetch,下文统称 Request)控制库和服务端状态管理库。请求控制
所谓的请求控制,主要目标是控制反转,从手动调用 Request 到自动化调用 Request。
这样做带来了如下优势:
- 封装了构建应用所需的常用功能,开箱即用。
- 在 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: Infinity
和 staleTime: 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,来使用其内部的
data
,status
, 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
有三种状态 success
、error
和 pending
。success
、error
分别表示请求成功和失败,即 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 生命周期内任意访问。staleTime
和 gcTime
是 @tanstack/vue-query
缓存控制功能的仅有的两个参数。你可以简单的配置
gcTime: Infinity
和 staleTime: 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。
- 请求没回来,但是通过 QueryClient.setQueryData() 设置了 data。
- 请求回来了,无论是 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...