服务器状态管理 Vue Query

服务器状态管理是"把原本只活在远程接口里的数据,当成前端「可缓存、可同步、可复用」的状态来管理"

一、前言

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个核心回调的触发顺序

  1. onMutate(variables)网络请求前 同步执行; 返回的任何值都会塞进 context,供后续 onError、onSuccess 回滚用
  2. 请求真正发出 → 成功或失败
  3. onSuccess(data, variables, context) 请求成功后调用;context 就是 onMutate 返回的对象。一般在这里 invalidateQueries 让列表重新拉取
  4. onError(error, variables, context) 请求失败后调用;利用 context 回滚乐观更新
  5. 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 的大脑,是一个全局单例对象,负责:

  1. 管理所有缓存(QueryCache + MutationCache
  2. 提供对缓存的增删改查API(getQueryData / setQueryData / invalidateQueries ...)
  3. 调度后台请求、重试、失效、垃圾回收
  4. 最为依赖注入的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 的响应式系统

核心只做了四件事

  1. 拿到全局 QueryClient(或手动传入)
  2. 创建/复用 QueryObserver 实例
  3. 把 observer 的"快照"包成 Vue 响应式对象reactive + computed
  4. 在组件生命周期内自动订阅 & 取消订阅,保证数据变化驱动视图更新,且不会内存泄漏
  • 入参规整 & 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>
}
相关推荐
weixin_405023373 小时前
webpack 学习
前端·学习·webpack
云中雾丽3 小时前
flutter中 Future 详细介绍
前端
在下Z.4 小时前
前端基础--css(1)
前端·css
常在士心4 小时前
Flutter项目支持鸿蒙环境
前端
重生之我要当java大帝4 小时前
java微服务-尚医通-管理平台前端搭建-医院设置管理-4
java·开发语言·前端
用户59561957545234 小时前
Vue-i18n踩坑记录
前端
WindrunnerMax4 小时前
从零实现富文本编辑器#8-浏览器输入模式的非受控DOM行为
前端·前端框架·github
我是日安4 小时前
从零到一打造 Vue3 响应式系统 Day 27 - toRef、toRefs、ProxyRef、unref
前端·javascript·vue.js
sjin4 小时前
React源码 - 关键数据结构
前端·react.js