Vue 基础体系 · 第 46/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Query 数据状态:缓存键、失效、乐观更新、分页和 SSR

在 Vue 应用中,数据通常来自两个来源:

  • 客户端状态:弹窗是否打开、当前选中的标签、表单草稿。
  • 服务器状态:用户列表、订单详情、权限、库存等由远程服务负责持久化的数据。

Pinia 更适合前一种状态;TanStack Query(Vue 中通常称为 Vue Query)解决的是后一种状态。它不仅封装请求,还维护请求数据的缓存、过期时间、加载状态、错误状态、并发请求和重新获取策略。

本文使用:

  • Vue 3
  • Composition API
  • TypeScript
  • Vite
  • @tanstack/vue-query v5 风格 API

安装依赖:

npm install @tanstack/vue-query

一、先建立数据状态模型

一个查询可以抽象为:

Q=(K,F,D,M)Q = (K, F, D, M)

其中:

  • KK:查询键(query key),表示“请求的身份”;
  • FF:查询函数(query function),根据请求参数获取数据;
  • DD:缓存中的数据;
  • MM:查询元状态,例如 pendingerrorsuccess、是否正在后台刷新。

例如:

const queryKey = ['users', { page: 1, size: 20 }]

这个键表达的是“用户资源的第 1 页,每页 20 条”。如果页码从 1 变成 2,它就是另一个查询。

Vue Query 的核心判断可以概括为:

同一个缓存项查询键经过哈希后相同\text{同一个缓存项} \Longleftrightarrow \text{查询键经过哈希后相同}

因此,查询键不是普通的标签,而是缓存身份的一部分。请求 URL 改变了而查询键没有改变,或者查询键改变了而请求函数仍然请求旧参数,都会造成数据错误。

1. 查询状态与数据状态不是一回事

一个查询通常至少包含这些可观察状态:

状态 含义
isPending 当前还没有可用数据,正在首次请求
isFetching 当前正在执行请求,包括后台刷新
isSuccess 最近一次请求成功
isError 最近一次请求失败
data 最近一次成功得到的数据
error 最近一次请求产生的错误
isPlaceholderData 当前展示的是占位数据,而不是当前键真正请求得到的数据

isPendingisFetching 不能混用。

例如,已有旧数据时后台刷新:

已有 data
   │
   ├── isPending = false
   └── isFetching = true

这时页面通常应该保留旧内容,同时显示一个较轻量的刷新指示器,而不是把整个页面替换成空白加载页。

二、缓存键:缓存正确性的基础

1. 查询键必须包含所有会影响结果的变量

假设接口为:

GET /api/users?page=1&size=20&keyword=alice

那么至少应该把 pagesizekeyword 放入查询键:

const query = useQuery({
  queryKey: ['users', { page, size, keyword }],
  queryFn: () =>
    fetchUsers({
      page: page.value,
      size: size.value,
      keyword: keyword.value,
    }),
})

这里的 pagesizekeyword 是 Vue 的 ref。Vue Query 会追踪响应式查询键;查询键中值发生变化时,会切换到新的缓存项并触发相应请求。

也可以显式使用 .value

const query = useQuery({
  queryKey: ['users', {
    page: page.value,
    size: size.value,
    keyword: keyword.value,
  }],
  queryFn: () =>
    fetchUsers({
      page: page.value,
      size: size.value,
      keyword: keyword.value,
    }),
})

第二种写法更容易从普通 TypeScript 代码中理解,但在 Vue Query 中,使用响应式值本身通常更方便。

关键条件是:

R=f(p1,p2,,pn)K 必须包含所有影响 R 的参数R = f(p_1, p_2, \ldots, p_n) \Rightarrow K \text{ 必须包含所有影响 } R \text{ 的参数}

如果结果 RR 依赖 keyword,但查询键不包含 keyword,就可能出现以下错误:

// 错误示例
const query = useQuery({
  queryKey: ['users', page],
  queryFn: () =>
    fetchUsers({
      page: page.value,
      keyword: keyword.value,
    }),
})

用户先搜索 alice,再搜索 bob。如果 page 没变,Vue Query 认为仍然是同一个查询;组件可能继续使用旧缓存,或者后台刷新时把不同搜索条件的数据写进同一个缓存项。

2. 查询键应有稳定的层级

常见的键结构是:

['users']
['users', { page: 1, size: 20 }]
['users', 'detail', 42]

列表和详情最好使用不同的语义层级:

const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (params: UserListParams) =>
    [...userKeys.lists(), params] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: number) =>
    [...userKeys.details(), id] as const,
}

使用时:

useQuery({
  queryKey: userKeys.list({
    page: page.value,
    size: size.value,
    keyword: keyword.value,
  }),
  queryFn: () => fetchUsers({
    page: page.value,
    size: size.value,
    keyword: keyword.value,
  }),
})

这样做的直接价值是避免手写字符串不一致,并且便于按层级失效:

await queryClient.invalidateQueries({
  queryKey: userKeys.all,
})

这会匹配以 ['users'] 开头的相关查询,例如用户列表和用户详情。若只想失效列表:

await queryClient.invalidateQueries({
  queryKey: userKeys.lists(),
})

3. 对象参数的序列化边界

查询键的顶层应是数组:

['users', { page: 1 }]

不要把不可稳定序列化的对象放入查询键,例如:

['users', new Map()]
['users', () => 'value']
['users', document.body]

函数、DOM 对象、类实例和循环引用都不适合作为缓存身份。

普通对象参数通常可以使用:

['users', { page: 1, keyword: 'alice' }]

TanStack Query 会对键进行稳定哈希;对象属性顺序通常不影响等价判断。但工程上仍应保持参数对象结构稳定,不要让同一语义在不同地方使用不同命名或不同嵌套层级。

例如下面两个键的语义不应被混用:

['users', { page: 1, size: 20 }]
['users', { pagination: { page: 1, size: 20 } }]

它们是不同的缓存键,因为程序无法假定这两个结构代表同一资源。

4. 查询键与请求函数必须使用同一组参数

一个可靠的关系是:

const params = computed(() => ({
  page: page.value,
  size: size.value,
  keyword: keyword.value.trim(),
}))

const query = useQuery({
  queryKey: computed(() => ['users', params.value]),
  queryFn: () => fetchUsers(params.value),
})

queryKeyqueryFn 都从同一个 params 派生,避免出现“键使用未裁剪的搜索词、请求使用裁剪后的搜索词”这类不一致。

查询键变化时,内部过程大致是:

page = 1
  │
  ├── key = ['users', { page: 1 }]
  ├── 查找 key=1 的缓存
  └── 必要时执行 queryFn

page = 2
  │
  ├── key = ['users', { page: 2 }]
  ├── 切换到另一个缓存项
  └── 执行 queryFn 获取第 2 页

这也是为什么不要把一个可变的响应式对象随意原地修改后又复用在多个语义不同的查询中。查询键应该清楚表达当前请求身份。

三、查询生命周期:新鲜、过期与垃圾回收

Vue Query 中至少要区分两个时间概念。

1. staleTime:数据多久被认为是新鲜的

const query = useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  staleTime: 60_000,
})

staleTime: 60_000 表示成功数据在 60 秒内视为 fresh。fresh 数据通常不会因为组件重新挂载、窗口重新获得焦点等行为而立即重新请求。

它不等于“缓存只保留 60 秒”。60 秒后数据只是 stale,仍然可以继续显示。

2. gcTime:非活跃缓存保留多久

v5 中使用 gcTime,旧版本曾使用 cacheTime

const query = useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  gcTime: 5 * 60_000,
})

当某个查询没有任何活跃观察者时,它成为 inactive。经过 gcTime 后,如果仍未重新使用,缓存才会被垃圾回收。

因此:

成功请求
  │
  ├── staleTime 内:fresh
  │
  ├── staleTime 后:stale,但仍可显示
  │
  ├── 没有组件使用:inactive
  │
  └── inactive 持续超过 gcTime:删除缓存

这两个时间的作用不同:

  • staleTime 控制“是否需要重新验证”;
  • gcTime 控制“无人使用时缓存保存多久”。

staleTime 设置为很大,并不会阻止缓存最终被垃圾回收;把 gcTime 设置为很大,也不会让数据永远 fresh。

四、失效:为什么不是简单地“清空缓存”

1. invalidateQueries 做了什么

await queryClient.invalidateQueries({
  queryKey: ['users'],
})

失效通常包含两个动作:

  1. 把匹配的查询标记为 stale;
  2. 对当前活跃的查询按配置重新获取。

默认情况下,活跃查询会重新获取;非活跃查询通常只被标记为 stale,不会为了一个当前无人使用的页面立即发请求。

这与删除缓存不同。失效后,旧数据可能仍然短暂存在,直到新请求成功或查询被重置。

2. 为什么 mutation 成功后需要失效

假设详情页已经缓存:

['users', 'detail', 42] => { id: 42, name: 'Alice' }

用户在编辑页把名字改成 Alicia,服务器返回成功。如果只修改编辑表单,不处理查询缓存,详情页仍可能继续显示 Alice

最稳妥的基础做法是:

const mutation = useMutation({
  mutationFn: updateUser,
  onSuccess: async (_data, variables) => {
    await queryClient.invalidateQueries({
      queryKey: userKeys.detail(variables.id),
    })

    await queryClient.invalidateQueries({
      queryKey: userKeys.lists(),
    })
  },
})

第一个失效处理详情缓存,第二个处理列表缓存。因为列表中的用户名称也可能已经改变。

onSuccess 中等待 invalidateQueries 的意义是:让 mutation 的生命周期在相关重新验证完成后再进入后续状态。是否必须等待,取决于页面交互;如果需要在刷新完成后再关闭保存状态,应该 await

3. 精确匹配与前缀匹配

// 前缀匹配:匹配所有 users 相关查询
queryClient.invalidateQueries({
  queryKey: ['users'],
})

// 精确匹配:只匹配完整键
queryClient.invalidateQueries({
  queryKey: ['users', 'detail', 42],
  exact: true,
})

前缀匹配适合资源级更新,精确匹配适合只影响一个缓存项的场景。

如果想根据参数进一步筛选,可以使用 predicate,但应先确认匹配范围,避免一次失效大量页面查询导致请求突增。

4. 失效不是“强制让所有组件同时刷新”

下面这段代码不会让所有历史分页都马上请求:

await queryClient.invalidateQueries({
  queryKey: userKeys.lists(),
})

它首先标记匹配项失效;当前正在显示的列表通常会后台刷新,未挂载的分页只保留 stale 状态,等以后被访问时再处理。这种设计避免了用户修改一条记录后,把所有已经访问过的分页全部重新请求。

五、完整的查询与错误处理示例

先定义 API 类型和请求函数:

// api/users.ts
export interface User {
  id: number
  name: string
  email: string
}

export interface UserListParams {
  page: number
  size: number
  keyword?: string
}

export interface UserListResult {
  items: User[]
  total: number
  page: number
  size: number
}

export async function fetchUsers(
  params: UserListParams,
): Promise<UserListResult> {
  const search = new URLSearchParams({
    page: String(params.page),
    size: String(params.size),
  })

  if (params.keyword) {
    search.set('keyword', params.keyword)
  }

  const response = await fetch(`/api/users?${search}`)

  if (!response.ok) {
    throw new Error(`获取用户失败:HTTP ${response.status}`)
  }

  return response.json() as Promise<UserListResult>
}

fetch 遇到 HTTP 404 或 500 时不会自动抛错,因此必须检查 response.ok。否则 Vue Query 会把一个错误响应当成成功结果处理。

在组件中使用:

<script setup lang="ts">
import { computed, ref } from 'vue'
import {
  keepPreviousData,
  useQuery,
} from '@tanstack/vue-query'
import {
  fetchUsers,
  type UserListParams,
} from '@/api/users'

const page = ref(1)
const size = ref(20)
const keyword = ref('')

const params = computed<UserListParams>(() => ({
  page: page.value,
  size: size.value,
  keyword: keyword.value.trim() || undefined,
}))

const query = useQuery({
  queryKey: computed(() => ['users', params.value]),
  queryFn: () => fetchUsers(params.value),
  placeholderData: keepPreviousData,
  staleTime: 30_000,
})
</script>

<template>
  <section>
    <input
      v-model="keyword"
      placeholder="搜索用户名"
      @input="page = 1"
    >

    <p v-if="query.isPending.value">首次加载中……</p>

    <p v-else-if="query.isError.value">
      {{ query.error.value instanceof Error
        ? query.error.value.message
        : '请求失败' }}
      <button @click="query.refetch()">重试</button>
    </p>

    <template v-else>
      <p v-if="query.isFetching.value">正在刷新……</p>

      <ul>
        <li v-for="user in query.data.value?.items" :key="user.id">
          {{ user.name }}({{ user.email }})
        </li>
      </ul>

      <button
        :disabled="page <= 1 || query.isFetching.value"
        @click="page--"
      >
        上一页
      </button>

      <button
        :disabled="
          !query.data.value ||
          page * size >= query.data.value.total ||
          query.isFetching.value
        "
        @click="page++"
      >
        下一页
      </button>
    </template>
  </section>
</template>

这里使用了 v5 的 placeholderData: keepPreviousData

页码从 1 变成 2 时:

旧查询:['users', { page: 1, ... }]
新查询:['users', { page: 2, ... }]

新键本质上对应新缓存项。如果没有占位策略,组件可能在新数据到达前暂时没有 datakeepPreviousData 允许继续展示第 1 页,同时请求第 2 页。

此时 isPlaceholderData 可用于阻止用户在第 2 页数据尚未真正到达时继续快速翻页:

<button
  :disabled="
    query.isFetching.value ||
    query.isPlaceholderData.value ||
    !query.data.value
  "
  @click="page++"
>
  下一页
</button>

这里的“保留上一页”不是把第 1 页写进第 2 页缓存,而是在新查询尚未成功时,为界面提供一个临时展示值。新请求成功后,第 2 页的真实数据会替换它。

六、分页中的边界与并发

1. 页码边界必须由服务端结果决定

如果接口返回:

{
  "items": [],
  "total": 45,
  "page": 3,
  "size": 20
}

最大页数为:

4520=3\left\lceil \frac{45}{20} \right\rceil = 3

因此下一页条件应是:

const hasNextPage = computed(() => {
  const data = query.data.value
  if (!data) return false

  return data.page * data.size < data.total
})

只根据当前数组长度判断并不可靠。例如最后一页可能恰好有 20 条,或者服务端使用了过滤、权限裁剪和游标分页。

2. 搜索词变化会产生请求竞争

用户快速输入:

a -> al -> ali -> alice

可能同时存在多个请求。Vue Query 会根据查询键分别管理这些请求;旧请求返回后,结果会写入旧的键,而不是自动覆盖 alice 对应的缓存。

但如果应用自己把所有结果都写入同一个 Pinia 字段,就容易出现旧请求覆盖新请求的问题。这是服务器状态缓存和手写全局状态管理的一个重要差异。

搜索场景通常还应加入防抖,否则每个字符都会产生查询键变化和请求:

const rawKeyword = ref('')
const keyword = ref('')

let timer: ReturnType<typeof setTimeout> | undefined

function onKeywordInput(value: string) {
  rawKeyword.value = value

  if (timer) clearTimeout(timer)

  timer = setTimeout(() => {
    keyword.value = value.trim()
    page.value = 1
  }, 300)
}

防抖不是 Vue Query 的缓存机制,而是对用户输入频率的控制。它不能替代查询键,也不能解决错误的缓存身份设计。

3. 偏移分页与游标分页不是同一种模型

偏移分页通常使用:

['messages', { page: 3, size: 50 }]

游标分页通常使用上一次响应返回的 nextCursor

['messages', { filter: 'unread' }]

游标不应被简单当作一个永久的“当前页码”。它是服务端根据某个数据版本生成的继续读取位置;数据发生插入或删除时,偏移量和游标的稳定性也不同。

如果需要连续加载列表,应考虑 useInfiniteQuery,而不是手动把多个页合并到一个普通 useQuerydata 中。无限查询的数据通常包含:

{
  pages: [...],
  pageParams: [...]
}

普通分页和无限滚动的缓存结构不同,二者不应共享完全相同的缓存键,避免一个查询把另一种数据结构写入同一缓存项。

七、乐观更新:先改界面,再等待服务器确认

乐观更新(optimistic update)假设 mutation 大概率成功:

用户点击完成
  │
  ├── 立即修改本地缓存,界面马上显示完成
  ├── 后台请求服务器
  ├── 成功:重新验证或保留服务器结果
  └── 失败:恢复旧缓存并提示错误

它降低感知延迟,但引入了回滚和并发一致性问题。因此乐观更新不是简单的“先改 data”。

1. 完整的 mutation 示例

假设接口如下:

export interface UpdateUserInput {
  id: number
  name: string
}

export async function updateUser(
  input: UpdateUserInput,
): Promise<User> {
  const response = await fetch(`/api/users/${input.id}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ name: input.name }),
  })

  if (!response.ok) {
    throw new Error(`更新用户失败:HTTP ${response.status}`)
  }

  return response.json() as Promise<User>
}

mutation 可以这样写:

import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { updateUser, type User } from '@/api/users'

const queryClient = useQueryClient()

const mutation = useMutation({
  mutationFn: updateUser,

  async onMutate(input) {
    // 1. 取消正在进行的相关查询,避免旧响应覆盖乐观结果
    await queryClient.cancelQueries({
      queryKey: userKeys.all,
    })

    // 2. 保存受影响缓存的旧值
    const previousDetail = queryClient.getQueryData<User>(
      userKeys.detail(input.id),
    )

    // 3. 立即修改详情缓存
    queryClient.setQueryData<User>(
      userKeys.detail(input.id),
      (old) => old
        ? { ...old, name: input.name }
        : old,
    )

    // 4. 将回滚所需数据返回给后续生命周期
    return {
      previousDetail,
    }
  },

  onError(_error, input, context) {
    if (context?.previousDetail) {
      queryClient.setQueryData(
        userKeys.detail(input.id),
        context.previousDetail,
      )
    }
  },

  async onSuccess(serverUser) {
    // 服务器可能做了规范化、权限裁剪或其他字段变更
    queryClient.setQueryData(
      userKeys.detail(serverUser.id),
      serverUser,
    )
  },

  async onSettled(_data, _error, input) {
    // 最终重新验证列表,修正排序、分页和聚合字段
    await queryClient.invalidateQueries({
      queryKey: userKeys.lists(),
    })
  },
})

调用:

mutation.mutate({
  id: 42,
  name: 'Alicia',
})

每一步都有必要:

  1. cancelQueries 防止已经发出的旧详情请求在乐观更新之后返回并覆盖本地结果。
  2. getQueryData 保存回滚快照。
  3. setQueryData 只修改已有的详情缓存,不发网络请求。
  4. onError 失败时恢复快照。
  5. onSuccess 使用服务器最终结果,因为服务器可能对名字做清洗、截断或权限处理。
  6. onSettled 重新验证列表,因为列表还涉及排序、总数和分页位置。

2. 为什么不能只在 onSuccess 修改缓存

下面的写法不是乐观更新:

const mutation = useMutation({
  mutationFn: updateUser,
  onSuccess(user) {
    queryClient.setQueryData(userKeys.detail(user.id), user)
  },
})

它在服务器响应后才更新界面。优点是逻辑简单,缺点是用户必须等待网络请求完成。

如果采用乐观更新,却忘记回滚:

onMutate(input) {
  queryClient.setQueryData(
    userKeys.detail(input.id),
    (old: User | undefined) =>
      old ? { ...old, name: input.name } : old,
  )
}

服务器失败后界面会永久显示一个实际上没有保存成功的名字,直到某次其他刷新碰巧纠正它。这是乐观更新最危险的失败表现。

3. 快照必须覆盖所有被修改的缓存

如果 mutation 同时修改了:

  • ['users', 'detail', 42]
  • 多个 ['users', 'list', ...]

那么只保存详情快照是不够的。失败时,列表中的乐观数据仍然可能残留。

对于复杂列表更新,可以保存多个快照:

type Snapshot = Array<{
  queryKey: readonly unknown[]
  data: unknown
}>

或者只对详情做乐观更新,列表在 mutation 完成后统一失效。后者延迟稍高,但一致性更容易维护。

4. 并发 mutation 的风险

用户连续两次修改同一条记录:

mutation A: name = "Alice"
mutation B: name = "Alicia"

如果 A 比 B 晚返回,简单的 onSuccess 可能把旧结果重新写回缓存。

解决方式取决于业务:

  • 禁止同一资源的重复提交;
  • 给 mutation 增加客户端版本号;
  • 让服务端使用条件更新,例如 If-Match 或资源版本;
  • 成功后统一重新获取服务器状态;
  • 在客户端仅对最后一次 mutation 的结果执行写入。

客户端的乐观更新不能单独保证并发一致性。最终顺序必须由服务端定义,尤其是多标签页、多设备同时编辑时。

八、查询缓存与 Pinia、Router 的边界

1. Pinia 不应重复存储所有服务器数据

如果同一份用户详情同时存在于:

Vue Query cache
Pinia store
组件本地 ref

每次更新都需要同步三份状态,失效、回滚和 SSR 也会变复杂。

更清晰的分工是:

  • Vue Query:服务器返回的数据、请求状态、重新验证;
  • Pinia:客户端工作流状态、跨组件 UI 状态、非请求派生状态;
  • Vue Router:可分享、可后退、适合进入 URL 的状态,例如页码、过滤器和排序字段。

例如列表页的页码可以放在路由查询参数中:

import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'

const route = useRoute()
const router = useRouter()

const page = computed(() => {
  const value = Number(route.query.page ?? 1)
  return Number.isInteger(value) && value > 0 ? value : 1
})

async function goToPage(nextPage: number) {
  await router.replace({
    query: {
      ...route.query,
      page: String(nextPage),
    },
  })
}

然后查询键直接使用 page.value

useQuery({
  queryKey: ['users', { page: page.value }],
  queryFn: () => fetchUsers({
    page: page.value,
    size: 20,
  }),
})

这样刷新页面、复制 URL 和浏览器后退都能恢复分页位置。Vue Router 的路由状态和 Vue Query 的服务器数据各自负责不同问题;不要把二者混成一个全局对象。

九、SSR:在服务端预取,在客户端复用

SSR 中最常见的错误是把服务端请求结果只写进 HTML,却没有交给 Vue Query 的客户端缓存。这样页面虽然服务端有内容,客户端挂载后仍可能再次请求一次。

正确流程是:

服务端请求
  │
  ├── 创建本次请求专属 QueryClient
  ├── prefetchQuery
  ├── renderToString
  ├── dehydrate(queryClient)
  └── 将脱水状态安全注入 HTML

浏览器
  │
  ├── 创建客户端 QueryClient
  ├── hydrate(queryClient, dehydratedState)
  ├── 安装 VueQueryPlugin
  └── 挂载应用

1. 创建 QueryClient

// query-client.ts
import { QueryClient } from '@tanstack/vue-query'

export function createQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 30_000,
        retry: 1,
      },
    },
  })
}

服务端每个 HTTP 请求都必须创建新的 QueryClient

const queryClient = createQueryClient()

不能在模块顶层创建一个服务端全局单例:

// 服务端 SSR 中的危险写法
export const queryClient = new QueryClient()

否则不同用户的查询缓存可能共用同一个进程内对象,造成数据泄露或跨请求污染。浏览器端通常可以在一次应用生命周期内复用一个客户端实例;服务端则必须按请求隔离。

2. 服务端预取

下面是简化的 SSR 服务端入口:

// entry-server.ts
import { renderToString } from 'vue/server-renderer'
import { dehydrate, VueQueryPlugin } from '@tanstack/vue-query'
import { createApp } from './app'
import { createQueryClient } from './query-client'
import { fetchUsers } from '@/api/users'

export async function render(url: string) {
  const queryClient = createQueryClient()
  const { app, router } = createApp({ queryClient })

  await router.push(url)
  await router.isReady()

  const route = router.currentRoute.value

  const page = Number(route.query.page ?? 1)
  const safePage = Number.isInteger(page) && page > 0 ? page : 1

  await queryClient.prefetchQuery({
    queryKey: ['users', { page: safePage, size: 20 }],
    queryFn: () => fetchUsers({
      page: safePage,
      size: 20,
    }),
  })

  const html = await renderToString(app)

  return {
    html,
    state: dehydrate(queryClient),
  }
}

prefetchQuery 会执行请求并把成功结果写入这个 QueryClient。之后 dehydrate 将可脱水的缓存转换为可传输状态。

实际应用中,路由组件可能有多个查询。可以在路由元信息或服务端数据加载层中集中声明预取任务,而不是只预取一个固定查询。关键要求不变:服务端预取使用的查询键必须和客户端组件使用的查询键完全一致。

3. 浏览器端 hydrate

// entry-client.ts
import { createSSRApp } from 'vue'
import {
  hydrate,
  VueQueryPlugin,
  type DehydratedState,
} from '@tanstack/vue-query'
import { createApp } from './app'
import { createQueryClient } from './query-client'

declare global {
  interface Window {
    __TANSTACK_QUERY_STATE__?: DehydratedState
  }
}

const queryClient = createQueryClient()
const { app } = createApp({ queryClient })

app.use(VueQueryPlugin, { queryClient })

if (window.__TANSTACK_QUERY_STATE__) {
  hydrate(
    queryClient,
    window.__TANSTACK_QUERY_STATE__,
  )
}

app.mount('#app')

服务端生成 HTML 时,需要把 state 注入页面。注入时不能直接拼接未经处理的 JSON:

// 风险示例:直接把用户可控字符串拼进 script
html += `<script>
  window.__TANSTACK_QUERY_STATE__ = ${JSON.stringify(state)}
</script>`

如果数据中包含 </script> 等内容,可能破坏脚本上下文并引入 XSS 风险。应使用框架提供的安全序列化方案,或对 <>& 等字符进行安全转义,并设置严格的 CSP。这里的安全序列化不是 Vue Query 特有要求,而是所有 SSR 状态注入都必须处理的问题。

4. SSR 的时间条件

服务端预取的数据并不天然永远有效。客户端 hydrate 后,Vue Query 仍会依据 staleTime 判断是否需要重新验证。

例如:

queries: {
  staleTime: 30_000,
}

如果 HTML 从服务端生成到浏览器 hydrate 已经超过这个时间,客户端可能立即发起刷新。这是正常行为:SSR 解决首屏数据传输和渲染,不等于数据永远新鲜。

5. SSR 中的请求地址和身份

浏览器端可以请求:

fetch('/api/users')

但服务端执行时没有浏览器当前地址这一语义。若 API 不在同一个 Node 进程中,服务端通常需要绝对地址:

const baseUrl = process.env.API_BASE_URL

fetch(`${baseUrl}/api/users`)

此外,服务端预取必须正确转发当前用户的认证上下文:

  • Cookie;
  • Authorization;
  • 租户 ID;
  • 地区或语言信息。

不能使用一个全局管理员凭证去预取所有用户都能看到的页面,也不能把服务端专用 Token 脱水到浏览器。

如果请求结果与用户身份有关,那么以下任一条件变化都必须影响查询结果的隔离:

用户身份
租户
权限
地区
语言

通常这些信息应由服务端请求上下文控制,而不是把敏感 Token 放进查询键或脱水状态。

6. SSR 错误的处理

默认情况下,脱水状态主要包含成功查询。服务端查询失败时,应用应明确决定:

  • 返回错误页;
  • 让客户端重新请求;
  • 展示部分页面;
  • 把非敏感错误信息注入页面。

不能把完整后端异常、堆栈、内部 URL 或凭证写进 HTML。客户端错误状态也应由 HTTP 状态码和页面边界处理,而不是依赖把所有异常对象序列化。

十、数据预取与路由切换

Vue Router 只负责路由匹配和导航,不会自动知道某个页面依赖哪些服务器数据。可以在导航前预取:

router.beforeResolve(async (to) => {
  if (to.name !== 'user-detail') return

  const id = Number(to.params.id)
  if (!Number.isInteger(id)) {
    throw new Error('非法用户 ID')
  }

  const queryClient = appQueryClient

  await queryClient.prefetchQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
  })
})

但预取也有取舍:

  • 导航前等待请求,页面切换更完整;
  • 请求失败可能阻塞导航;
  • 用户快速离开时,预取请求可能浪费;
  • 已有 fresh 缓存时,prefetchQuery 可以避免重复请求。

如果页面允许先进入再显示加载状态,组件内 useQuery 更简单。如果数据是路由不可缺少的前置条件,导航守卫或 SSR 预取更合适。

十一、常见错误与诊断方法

错误一:修改数据后页面不更新

检查是否直接修改了缓存对象:

// 不推荐
const user = queryClient.getQueryData<User>(userKeys.detail(42))
if (user) {
  user.name = 'Alicia'
}

这种写法没有通过 Vue Query 的缓存更新入口,也可能破坏不可变更新假设。

应使用:

queryClient.setQueryData<User>(
  userKeys.detail(42),
  (old) => old
    ? { ...old, name: 'Alicia' }
    : old,
)

错误二:失效了错误的键

如果实际键是:

['users', 'detail', 42]

却失效:

queryClient.invalidateQueries({
  queryKey: ['user', 42],
})

不会匹配到目标查询。

诊断方法是:

const queries = queryClient.getQueryCache().findAll()

for (const query of queries) {
  console.log(query.queryKey, query.state.status)
}

查看真实缓存键,而不是根据组件名称猜测键。

错误三:把 isFetching 当成首次加载

<div v-if="query.isFetching.value">
  加载中
</div>

这会在后台刷新时隐藏已经存在的内容。更合理的是:

<div v-if="query.isPending.value">
  首次加载中
</div>

<div v-else>
  <span v-if="query.isFetching.value">后台刷新中</span>
  <!-- 继续显示已有 data -->
</div>

错误四:请求函数捕获了旧参数

const page = ref(1)

const query = useQuery({
  queryKey: ['users', page],
  queryFn: () => fetchUsers({
    page: 1, // 错误:永远请求第 1 页
    size: 20,
  }),
})

查询键虽然会随着 page 变化,但请求函数永远请求第 1 页。结果可能是:

键:['users', 1] -> 第 1 页
键:['users', 2] -> 仍然请求第 1 页

键和请求函数必须从同一参数源读取。

错误五:mutation 成功但其他页面仍是旧数据

只更新详情缓存不会自动知道哪些列表也受影响:

queryClient.setQueryData(
  userKeys.detail(id),
  updatedUser,
)

如果列表缓存包含这个用户,还需要:

await queryClient.invalidateQueries({
  queryKey: userKeys.lists(),
})

或者明确更新所有受影响的列表缓存。列表有分页、排序和筛选时,统一失效通常比手工修改更不容易漏边界。

错误六:SSR 每个请求共享一个 QueryClient

表现可能包括:

  • 用户 A 偶尔看到用户 B 的首屏数据;
  • 服务端缓存随着请求数量持续增长;
  • 测试用例之间互相污染;
  • hydration 后出现难以复现的数据跳变。

服务端必须按请求创建并销毁 QueryClient,并检查脱水状态中是否包含不应暴露的数据。

十二、如何选择缓存、失效和乐观更新策略

可以按数据特征做判断:

读多写少、对短暂旧数据容忍

使用较长的 staleTime

useQuery({
  queryKey: ['public-config'],
  queryFn: fetchPublicConfig,
  staleTime: 5 * 60_000,
})

写操作会影响多个视图

优先使用:

mutation.onSuccess -> invalidateQueries

让服务器作为最终事实来源,避免遗漏列表、统计和排序数据。

修改简单、失败可明确回滚、用户需要即时反馈

使用乐观更新,但必须同时具备:

cancelQueries
旧值快照
setQueryData
失败回滚
最终重新验证

缺少任一环节,都可能导致旧响应覆盖、失败数据残留或不同视图不一致。

页码和筛选条件应可分享

把它们放入 Vue Router 的 query 参数,并把路由值纳入查询键。这样 URL 是页面状态的来源,Vue Query 是服务器数据的来源。

SSR 首屏需要数据

服务端使用 prefetchQuery,渲染后 dehydrate,客户端在挂载前 hydrate。同时保证:

服务端 queryKey = 客户端 queryKey
服务端请求身份 = 当前用户身份
服务端 QueryClient = 每请求独立
脱水状态 = 安全序列化

Vue Query 的价值不只是减少几行 fetch 代码,而是建立一套明确的数据流:

查询键决定身份
  ↓
QueryClient 查找或创建缓存
  ↓
queryFn 获取服务器结果
  ↓
组件订阅 data 与状态
  ↓
mutation 改变服务器数据
  ↓
乐观更新或失效
  ↓
重新验证,恢复服务器与界面的一致

只要查询键完整、缓存边界清楚、mutation 处理回滚与失效、分页区分数据模型,并在 SSR 中正确隔离和脱水,Vue 应用中的服务器状态就不必被重复搬运到多个全局存储中。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。