服务器状态管理是"把原本只活在远程接口里的数据,当成前端「可缓存、可同步、可复用」的状态来管理"
一、前言
1.1 为什么数据请求是前端的重难点
-
1、传统做法的痛点
- 每次组件挂载都
fetch
---> 请求爆炸、loading 闪烁 - 多个组件需要同一份数据 ---> 层层
props
或全局store
手动同步,维护困难 - 多个请求之间有依赖关系,必须在一堆 Promise 链中穿针引线 ---> 并发与依赖请求混乱
- 离线/弱网、窗口切回、轮询、乐观更新 ---> 逻辑散落在各处,越来越难以维护
- 每次组件挂载都
-
2、业务诉求
- 秒开二次访问
- 后台静默更新
- 多端复用,同一数据在列表、详情、弹窗里一致
- 快速实现分页、无限滚动、乐观更新、错误重试
1.2 状态管理的转折点:从本地到服务器状态
前端的状态(state)有两种:
- UI 状态:组件的展示状态(是否展开、选中、激活等)
- 服务器状态:来自后端的数据,比如用户列表、文章详情、分页数据等
以往一直在用 Vuex、Pinia 处理状态,但这类库本质是客户端状态管理工具,而服务器状态有几个根本不同的特性:
- 它是异步的,无法保证时效性
- 它会频繁变化,需要不断同步
- 它有缓存有效期,需要自动过期与刷新
- 它有可能在多个页面间共享和重复使用
这意味着,传统的 Vuex 根本不适合直接管理服务器状态。于是,服务器状态管理库(SWR、React Query、Vue Query等)诞生。
它们把服务器返回的数据抽象成一层可观察、可缓存、可后台更新的前端状态,提供:
- 请求去重、内存缓存、TTL失效
- 聚焦/重连自动刷新
- 分页、无限加载、乐观更新、错误重试
- 与框架生命周期深度集成的 Hooks、Composables
1.3 从 SWR 到 React Query,再到 Vue Query
在 React 中,这个问题在几年前就被 SWR、React Query 解决了:
- SWR(stale-while-revalidate):有 Vercel 团队推出,主打"先显示旧数据,再后台刷新"
- React Query:有 Tanner Linsley 团队开发,后来升级为 @tanstack/query 系列,成为事实标准
Vue 社区也迎来了自己的版本:@tanstack/vue-query,它是 Vue 生态下的 React Query 实现,有官方团队维护,与 React Query 同步核心逻辑。
Vue Query
自动帮你实现:
- 管理请求状态
- 缓存结果
- 处理并发
- 自动刷新
- 错误重试
- 后台数据同步
也因此不用在手动维护 loading,不用写 retry,不用写缓存逻辑
二、核心概念 ------ Query、Mutation、Cache
Vue Query的哲学非常清晰:所有从服务器拉取的数据,都是一个有生命周期的查询
每一个查询都经历类似这样的生命周期:
js
idle → loading → success (data cached) → stale → refetch → fresh
阶段 | 含义 |
---|---|
idle | 查询还未开始,或组件尚未挂载。 |
loading | 正在发送请求,等待响应。 |
success | 请求成功,数据已返回并缓存起来,供后续使用。 |
stale | 数据仍在缓存中,但已被标记为"过时",不会立即重新请求。 |
refetch | 当满足某些条件(如窗口聚焦、组件重新挂载)时,自动重新发起请求。 |
fresh | 新数据返回,缓存更新,数据重新变为"新鲜"状态。 |
框架通过内部状态机精细地管理每一个阶段,从而实现
- 数据缓存
- 状态跟踪
- 自动刷新
- 错误重试
- 跨组件共享
2.1 查询 Query
在 Vue query 中,任何一个接口请求,都对应一个 Query 实例 ,通过 useQuery()
创建
js
const { data: productsData, isLoading: loading, error, refetch: fetchProducts } = useQuery({
queryKey: ['products', limit],
queryFn: async () => {
const response = await fetch(`https://dummyjson.com/products?limit=${limit.value}`)
if (!response.ok) {
throw new Error(`HTTP 错误!状态: ${response.status}`)
}
return response.json()
},
// 可选配置
enabled: true, // 默认启用自动查询
refetchOnWindowFocus: false, // 窗口聚焦时不重新查询
staleTime: 5 * 60 * 1000, // 5分钟内数据被认为是新鲜的
})
2.1.1 两个核心参数
-
queryKey:查询的唯一标识,变化即重新执行查询
- 决定了缓存的共享和命中
- 相同的 querykey 会共用缓存
- 通常为数组,可包含参数
js
['products', limit]
['posts', { page: 1 }]
-
queryFn:真正执行请求的函数
- 必须返回一个 Promise
- 会自动处理它的状态、错误与缓存
js
queryFn: async () => {
const res = await axios.get(`/api/user/${userId}`)
return res.data
}
2.1.2 其他的可选参数
参数名 | 类型 | 默认值 | 作用速记 |
---|---|---|---|
enabled | boolean |
true |
置 false 可"手动开关"查询,常用于依赖其它数据就绪时再发请求。 |
staleTime | number (ms) |
0 |
数据"保鲜期"。期间再次访问直接读缓存,不重新请求。 |
gcTime (旧称 cacheTime) | number (ms) |
5 min |
缓存垃圾回收时间;组件卸载后若在这段时间内无订阅就清除缓存。 |
retry | boolean │ number │ (failCount, error) => boolean |
3 |
请求失败时的重试次数或自定义策略。 |
refetchOnWindowFocus | boolean |
true |
窗口重新聚焦时是否自动重新拉取(过期的)数据。 |
refetchOnReconnect | boolean |
true |
网络恢复后是否重新拉取。仅限过期了的数据 |
refetchOnMount | boolean │ "always" |
true |
组件重新挂载时是否重新拉取;"always" 不管是否过期都拉。 |
initialData | T │ () => T |
- | 给查询提供初始值,会立即进入 success 状态,不再 loading 。 |
placeholderData | T │ () => T |
- | 类似"骨架数据",只填充一次,不会写入缓存;适合列表页快速渲染。 |
select | data => derived |
- | 对返回数据做"转换/派生",可减少不必要的重渲染。 |
refetchInterval | number │ false |
false |
定时轮询间隔(毫秒)。 |
keepPreviousData | boolean |
false |
切页时保留上一笔数据,解决"闪白"问题。 |
2.1.3 Query 的状态机
内部每一条 Query 维护一个有限状态机 FSM,用来精确描述一条查询从"无数据"到"有数据"再到"失效、重新获取"的完整生命周期
状态机只包含 5 个核心状态:
状态值 | 含义 | 触发时机 |
---|---|---|
pending | 首次查询中,尚无缓存数据 | 组件首次挂载、queryKey 变化且没有 initialData |
loading | 有缓存数据但正在重新请求 | 后台 refetch、窗口聚焦、人工 refetch |
success | 最近一次请求成功,数据可用 | fetch 成功且未抛出异常 |
error | 最近一次请求失败 | fetch 抛出异常且重试策略已耗尽 |
idle | 查询被禁用(enabled: false )且从未运行过 |
初始即禁用,或手动设为禁用 |
2.1.4 useQuery() 返回对象
- 返回一个 Reactive 对象,可直接解构使用,以下是常用参数
名称 | 类型 | 一句话说明 |
---|---|---|
data |
`Ref<T | undefined>` |
error |
`Ref<Error | null>` |
isPending |
computed<boolean> |
首次加载中(无缓存)。 |
isLoading |
computed<boolean> |
后台重新加载中(有缓存)。 |
isError |
computed<boolean> |
是否处于错误状态。 |
isSuccess |
computed<boolean> |
是否已成功拿到数据。 |
isFetching |
computed<boolean> |
只要正在发请求(含轮询/后台)就为 true 。 |
isRefetching |
computed<boolean> |
后台刷新且不是首次(isFetching && !isPending )。 |
status |
`Ref<'pending' | 'loading' |
其他不常用的还有 dataUpdatedAt、errorUpdatedAt、failureCount
等等
2.2 变异 Mutation
如果说 Query 代表读取数据,那么 Mutation 就代表修改数据(POST、PUT、DELETE),并在成功后更新本地缓存
Vue Query 通过 useMutation()
提供一种声明式写法,有以下特点:
- 手动触发:不像查询那样自动执行,需要手动调用(如点击按钮)
- 乐观更新:可以在请求发送前更新 UI,失败后再回滚
- 自动失效:成功后可以自动让相关查询失效,触发重新获取
- 内置错误回滚机制
js
useMutation({
mutationFn, // 唯一必填:真正发请求的函数 (vars) => Promise
onMutate, // 请求前同步调用,做乐观更新 / 快照
onError, // 请求失败调用,可回滚
onSuccess, // 请求成功调用,可刷新列表
onSettled, // 无论成功失败都会调用
retry, // 失败重试次数,默认 3
retryDelay, // 重试间隔,默认指数退避
gcTime, // 缓存时间,默认 5 min(Vue Query 5 里 cacheTime 改名 gcTime)
networkMode, // 'online' | 'always' | 'offlineFirst'
})
4个核心回调的触发顺序
onMutate(variables)
在 网络请求前 同步执行; 返回的任何值都会塞进context
,供后续onError、onSuccess
回滚用- 请求真正发出 → 成功或失败
onSuccess(data, variables, context)
请求成功后调用;context
就是onMutate
返回的对象。一般在这里invalidateQueries
让列表重新拉取onError(error, variables, context)
请求失败后调用;利用context
回滚乐观更新onSettled(data | null, error, variables, context)
无论成功失败都会执行;适合做清理工作
useMutation
返回的是一个 组合式对象 (Composition API 风格),里面包含 运行时状态 + 手动触发器 + 辅助方法
js
const {
// 1. 触发器(唯一必须掌握的)
mutate, // (variables, options?) => void 异步触发,不返回 Promise
mutateAsync, // (variables, options?) => Promise<T> 可以 await,需自己 catch
// 2. 运行时状态(全是 Ref,需 .value 访问)
data, // T | undefined 成功后的返回值
error, // Error | null 失败后的错误
failureReason, // Error | null Vue Query 5 新名字,同 error
failureCount, // number 已经重试的次数
isIdle, // boolean 尚未开始
isPending, // boolean 正在请求(含重试)
isLoading, // boolean 同 isPending(兼容旧名)
isSuccess, // boolean 已成功
isError, // boolean 已失败
status, // 'idle' | 'pending' | 'success' | 'error'
submittedAt, // Date | undefined 最近一次点击 mutate 的时刻
reset, // () => void 一键回到 idle 状态,清空 data/error
// 3. 辅助方法(很少手动调)
context, // any onMutate 返回的现场快照
variables, // unknown 最近一次调用 mutate 时传的参数
} = useMutation(mutationFn, options?)
2.3 缓存 Cache
缓存是用于存储查询结果,避免重复请求,提高性能
特点:
- 基于 queryKey 缓存:每个查询通过 queryKey 唯一标识,相同 key 的查询共享缓存
- 自动失效与刷新:可以设置缓存时间(sraleTime、cacheTime),过期后自动标记为"过时",下次使用时重新获取
- 手动操作缓存:可以手动设置、更新或删除缓存数据
js
queryClient.setQueryData(['todos'], newTodos)
queryClient.invalidateQueries(['todos'])
2.3.1 QueryClient
QueryClient 是 Vue Query 的大脑,是一个全局单例对象,负责:
- 管理所有缓存(
QueryCache
+MutationCache
) - 提供对缓存的增删改查API(
getQueryData
/setQueryData
/invalidateQueries
...) - 调度后台请求、重试、失效、垃圾回收
- 最为依赖注入的key,让
useQuery
/useMutation
能拿到同一份缓存实例
全局创建
js
import { QueryClient } from '@tanstack/vue-query'
// ① 创建大脑
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 0, // 默认新鲜时间
gcTime: 1000 * 60 * 5, // 垃圾回收时间
retry: 3,
},
},
})
// ② 挂载到 Vue
app.use(VueQueryPlugin, { queryClient })
// ③ 组件里使用
const { data } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
也可以在组件内创建
js
import { useQueryClient } from '@tanstack/vue-query'
const queryClient = useQueryClient();
常用 API
js
queryClient.getQueryData(['user', 1]) // 读
queryClient.setQueryData(['user', 1], newData) // 写
queryClient.invalidateQueries(['todos']) // 标过期
queryClient.resetQueries(['todos']) // 清空并重新拉
queryClient.removeQueries(['todos']) // 直接删除
queryClient.prefetchQuery({ queryKey, queryFn }) // 提前拉
queryClient.cancelQueries(['todos']) // 取消进行中的请求
2.3.2 setQueryData
作用是直接把数据写进缓存,不触发网络请求
queryClient.setQueryData(['todos'], newTodos)
是把 newTodos 写进 key 为 ['todos'] 的缓存项里,假装服务器已经返回了这份数据,所有正在使用 useQuery(['todos']) 的组件会立刻渲染这份新数据,而不会再去请求后端
典型场景:
- 乐观更新
- 服务端时间推送(websocket、sse)
- 分页预加载:先把下一页数据塞进缓存,用户翻页时秒出
js
// 新增一条 todo 后,把新列表直接写进缓存
const addTodo = async (title: string) => {
await api.createTodo(title) // 先写库
const old = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) => [...old, { id: Date.now(), title }])
}
2.3.3 invalidateQueries
作用是把缓存标记为"过期",立即触发后台重新取数(如果当前组件有在使用)
queryClient.invalidateQueries(['todos'])
把 key 为 ['todos']
的缓存项标记为 stale(过期) 。如果当前有组件 useQuery(['todos'], ...)
处于 mounted 状态,会立即触发一次后台 refetch
典型场景:
- 写操作成功后,让列表刷新到最新状态(最常用)
- 跨路由 / 跨组件"通知"数据已失效
js
const mutation = useMutation({
mutationFn: api.createTodo,
onSuccess: () => {
// 告诉 vue-query:列表脏了,重新拉一次
queryClient.invalidateQueries(['todos'])
},
})
invalidateQueries(['todos'])
采用 "前缀匹配" 策略,例如
js
useQuery({ queryKey: ['todos', { filter: 'completed' }] })
useQuery({ queryKey: ['todos', { filter: 'all' }] })
// 当你调用
queryClient.invalidateQueries(['todos'])
会让所有 key 以 ['todos']
开头的查询全部刷新
只想让其中一条 失效,怎么做? 把 queryKey
写全,并加上 exact: true
(告诉 Vue Query 必须完全相等)
js
// 只失效 "已完成" 列表
queryClient.invalidateQueries({
queryKey: ['todos', { filter: 'completed' }],
exact: true, // 关键:关闭前缀匹配
})
// 若还想立即重新请求(不等组件挂载)
queryClient.invalidateQueries({
queryKey: ['todos', { filter: 'completed' }],
exact: true,
refetchType: 'active', // 或 'active'|'inactive'|'all' 按需要选
})
2.3.4 二者组合套路(乐观更新+回滚)
js
import { useMutation, useQueryClient } from '@tanstack/vue-query';
// 获取 queryClient 实例
const queryClient = useQueryClient();
const addTodo = useMutation({
mutationFn: api.createTodo,
onMutate: async (title) => {
await queryClient.cancelQueries(['todos']) // 1. 取消正在进行的请求
const prev = queryClient.getQueryData(['todos']) // 2. 快照旧数据
queryClient.setQueryData(['todos'], old => [...old, { id: Date.now(), title }])
return { prev } // 3. 返回回滚数据
},
onError: (err, vars, context) => {
queryClient.setQueryData(['todos'], context.prev) // 4. 出错回滚
},
onSettled: () => {
queryClient.invalidateQueries(['todos']) // 5. 最终一致性
},
})
2.4 Query Observer:驱动视图更新的中间层
Vue Query 内部有一个非常关键的角色 ------ QueryObserver。它负责:
- 监听 Query 的状态变化
- 把变化推送给使用它的组件
- 触发 Vue 的响应式更新
当多个组件使用相同 queryKey
时,它们共享同一个 Query,但各自拥有自己的 Observer。
这样既能共享缓存,又不会相互干扰
2.5 Vue Query 的数据流概览
js
组件 useQuery()
↓
创建 QueryObserver
↓
注册到 QueryClient
↓
检测 QueryCache 是否命中
↓
- 命中缓存 → 返回缓存数据
- 未命中 → 执行 queryFn
↓
Query 更新状态 (loading → success/error)
↓
Observer 通知组件更新视图
↓
缓存进入生命周期管理(stale、inactive、GC)
三、其他实战使用
3.1 依赖请求
- 当 user 数据还没加载时,posts 请求不会执行
js
const userId = ref(1)
const { data: firstData } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId.value),
})
const postsQuery = useQuery({
queryKey: ['posts', firstData?.id],
queryFn: () => fetchPostsByUser(firstData.id),
enabled: computed(() => !!userQuery.data), // 配置启动查询条件
})
3.2 预取数据(Prefetch)
- Vue Query 可以在页面跳转前提前请求数据,提高首屏性能。在路由守卫中使用效果极佳
js
queryClient.prefetchQuery({
queryKey: ['user', id],
queryFn: () => getUser(id),
})
3.3 缓存持久化(Persist Query)
- 配合
@tanstack/query-persist-client
可以持久化缓存至 localStorage
js
npm i @tanstack/query-sync-storage-persister @tanstack/query-persist-client-core
js
import { useQueryClient } from '@tanstack/vue-query'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { persistQueryClient } from '@tanstack/query-persist-client-core'
const queryClient = useQueryClient();
const localStoragePersister = createSyncStoragePersister({
storage: window.localStorage, // 也可换成 IndexedDB 或自定义 storage
key: 'REACT_QUERY_OFFLINE_CACHE', // 本地存储的 key
throttleTime: 1000, // 写盘节流,默认 1 s
})
// 核心:把 queryClient 和 persister 绑定
persistQueryClient({
queryClient,
persister: localStoragePersister,
maxAge: 1000 * 60 * 1, // 24 h 后恢复的数据视为过期,不再使用
})
- 这样即使刷新页面,缓存仍可复用
3.4 Devtools 调试
js
npm install @tanstack/vue-query-devtools
js
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
app.use(VueQueryDevtools)
- 然后在组件中引入
js
<script setup>
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
</script>
<template>
<div>
<!-- 你的应用其他内容 -->
<RouterView />
<!-- Vue Query Devtools -->
<VueQueryDevtools />
</div>
</template>
- 在浏览器面板即可查看缓存、状态、依赖等,非常直观
四、源码架构与内部机制
@tanstack/vue-query 的核心并非从零编写, 而是直接基于 TanStack Query 的跨框架内核实现(即 React Query v5 的通用内核)
Vue Query ≈ React Query 的内核 + Vue 的响应式封装层。

也就是说:
- 所有的 状态管理、缓存、调度、生命周期逻辑 都在
@tanstack/query-core
- Vue 层只是负责把
QueryObserver
的回调,转化为 Vue 的响应式数据
4.1 useQuery() 实现
useQuery 的实现采用了分层架构设计:
js
useQuery (入口层)
↓
useBaseQuery (核心逻辑层)
↓
QueryObserver (@tanstack/query-core)
4.1.1 入口层:useQuery.ts

- queryClient 这个参数是可选的,即当不想用全局的
QueryClient
是,可手动传一个实例进来。一般用于多 QueryClient 实例(微前端、服务端渲染隔离、测试用例) - useQuery 简单的将参数传递给 useBaseQuery,传入 QueryObserver 作为观察者类型
4.1.1 核心层:useBaseQuery.ts
这个文件是 Vue Query 的核心适配器,负责将 @tanstack/query-core 的功能桥接到 Vue 的响应式系统
核心只做了四件事
- 拿到全局
QueryClient
(或手动传入) - 创建/复用
QueryObserver
实例 - 把 observer 的"快照"包成 Vue 响应式对象 (
reactive
+computed
) - 在组件生命周期内自动订阅 & 取消订阅,保证数据变化驱动视图更新,且不会内存泄漏
- 入参规整 & client 解析
js
export function useBaseQuery(
Observer: typeof QueryObserver, // 传入 QueryObserver 类
options: UseQueryOptions,
queryClient?: QueryClient,
) {
// ① 解析实例:优先用参数传入的,其次 inject 全局
const client = queryClient ?? inject(QueryClientKey) as QueryClient
// ② 把可能传入的 ref/computed 展开成"值"
const defaultedOptions = client.defaultQueryOptions(options)
defaultedOptions._optimisticResults = 'default'
}
- 创建 Observer & 响应式状态容器
js
// ③ 创建观察者(每个组件实例一个)
const observer = new Observer(client, defaultedOptions)
// ④ 把 observer.getCurrentResult() 变成 Vue 响应式
const state = reactive(observer.getCurrentResult())
// ⑤ 比较函数:observer 通知时,只把"变化的字段"合并,减少触发
const updateState = (newResult: QueryObserverResult) => {
for (const key in newResult) {
if (state[key] !== newResult[key]) {
state[key] = newResult[key]
}
}
}
- 订阅 & 生命周期绑定
js
// ⑥ 立即订阅,回调里只做"合并差异"
let unsubscribe = observer.subscribe(updateState)
// ⑦ 组件卸载时自动取消订阅,防止内存泄漏
onScopeDispose(() => unsubscribe())
// ⑧ 响应式 key 变化 → 重新计算 options → observer.setOptions
watch(
() => getQueryKey(defaultedOptions.queryKey), // 可能 ref 依赖
() => observer.setOptions(client.defaultQueryOptions(options)),
{ flush: 'sync' }, // 同步触发,保证 suspense 正确
)
- 返回"Vue 风格"对象
js
// ⑨ 把 state 展开成大量 computed,保持只读
return toRefs(state) as UseQueryReturnType<TData, TError>
}