TanStack Query 与服务端状态管理 / TanStack Query and Server State Management
📅 创建时间:2026-07-28 🏷️ 标签:#TanStackQuery #ServerState #Caching #useQuery #useMutation 📚 前置知识:[[02-hooks-state-and-effects]]
📋 本章目标
- 区分服务端状态与客户端状态,理解为何不能把二者塞进同一个 Store
- 掌握
useQuery的完整生命周期:fetching、fresh、stale、inactive、garbage collected - 精通
staleTime与gcTime的语义区别,按业务场景选择合理值 - 设计可维护的 queryKey 层级体系与工厂函数
- 实现完整的乐观更新流程:快照保存、UI 立即响应、失败回滚、最终重验证
- 使用
useInfiniteQuery实现游标分页与双向无限滚动 - 通过预取、selector 与持久化优化感知性能与缓存命中率
第1部分:为什么需要 TanStack Query
服务端状态与客户端状态的本质区别
客户端状态由你的应用拥有,是同步的、确定性的、不会在你不知情时被修改。服务端状态由远端系统拥有,具有异步、可过期、可并发修改和可能失败等特征。
┌─────────────────────────────────────────────────────────────┐
│ 客户端状态 vs 服务端状态 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 客户端状态 服务端状态 │
│ ┌───────────────────────┐ ┌───────────────────────────┐ │
│ │ 由前端拥有和控制 │ │ 由远端服务拥有 │ │
│ │ 同步可用 │ │ 异步获取(Promise) │ │
│ │ 不会自主变化 │ │ 随时可能过期 │ │
│ │ 无需处理加载/错误态 │ │ 必须处理 loading/error │ │
│ │ 例:选中的tab、表单草稿│ │ 例:用户列表、订单详情 │ │
│ └───────────────────────┘ └───────────────────────────┘ │
│ │
│ ⚠️ 把服务端数据复制进 Redux/Zustand 会引入双重真实源 │
│ (dual source of truth),你需要自己处理: │
│ - 何时重新获取? - 何时标记为过期? │
│ - 如何去重请求? - 失败后如何重试? │
│ - 如何在多个组件间共享但只请求一次? │
│ │
└─────────────────────────────────────────────────────────────┘手动管理的典型痛点
不使用专用库时,开发者通常用 useEffect + useState 组合自己实现:
// ❌ 手动管理服务端数据的反模式
function ProjectList() {
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
api.projects.list()
.then(data => { if (!cancelled) { setProjects(data); setLoading(false) } })
.catch(err => { if (!cancelled) { setError(err); setLoading(false) } })
return () => { cancelled = true }
}, [])
if (loading) return <Spinner />
if (error) return <ErrorBanner error={error} />
return <ProjectTable data={projects} />
}手动代码需要处理竞态条件(cleanup flag)、去重、缓存共享、窗口聚焦重取、重试、乐观更新等。每一个都是独立的 bug 源头。
缓存失效的固有复杂性
缓存失效是计算机科学中最难的两个问题之一(另一个是命名)。你需要回答:
- 这份数据在屏幕上停留了多久?
- 服务端的数据已经被其他用户修改了吗?
- 用户刚提交了一个 mutation —— 哪些缓存的查询会受到影响?
- 如果网络断开又恢复,哪些数据需要重新获取?
TanStack Query 提供了一套声明式框架来回答这些问题。
第2部分:Query 核心
useQuery 的完整生命周期
┌─────────────────────────────────────────────────────────────┐
│ useQuery 生命周期状态机 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 组件挂载 / queryClient.fetchQuery() │
│ │ │
│ ▼ │
│ ┌──────────┐ 请求完成 ┌──────────┐ │
│ │ fetching │──────────────▶│ fresh │ │
│ │ │ │ │ │
│ │ status: │ │ 数据在 │ │
│ │ loading │ │ staleTime│ │
│ └──────────┘ │ 内 │ │
│ └────┬─────┘ │
│ │ │
│ staleTime 到期(或触发事件) │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ stale │ │
│ │ │ │
│ │ 有订阅者 │ │
│ │ 仍可使用 │ │
│ │ 但下次 │ │
│ │ 触发会 │ │
│ │ 重新获取 │ │
│ └────┬─────┘ │
│ │ │
│ 所有订阅者卸载(gcTime 计时开始) │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ inactive │ │
│ │ │ │
│ │ 无活跃 │ │
│ │ 观察者 │ │
│ │ 数据仍 │ │
│ │ 在缓存中 │ │
│ └────┬─────┘ │
│ │ │
│ gcTime 到期,无新订阅者挂载 │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ garbage │ │
│ │ collected│ │
│ │ │ │
│ │ 数据被 │ │
│ │ 清除 │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘staleTime vs gcTime 的精确语义
两个参数经常被混淆,但实际上控制的是完全不同的维度。
staleTime 控制的是"数据多久内被视为新鲜"——即从数据获取成功那一刻起,多长时间内 TanStack Query 不会主动重新请求。staleTime 之内触发的重新获取(如组件重新挂载、窗口聚焦)会被跳过。当 staleTime 到期后,数据变为 stale(陈旧但仍可用),下一次触发事件会导致后台重新获取。
gcTime(旧称 cacheTime)控制的是"没有订阅者后缓存保留多久"——即当所有使用该查询的组件都卸载后,数据在内存中保留多长时间。在这段时间内如果有新组件挂载,可以直接使用缓存数据(如果 stale 则会后台重取)。gcTime 到期后数据被垃圾回收,下次挂载相当于全新查询。
┌─────────────────────────────────────────────────────────────┐
│ staleTime vs gcTime 时间线对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 时间轴 ──────────────────────────────────────────────▶ │
│ │
│ staleTime (默认 0ms): │
│ ┌────────────┐ │
│ │ fresh │········ stale ························· │
│ │ (不重取) │ (可后台重取) │
│ └────────────┘ │
│ 0s staleTime │
│ │
│ gcTime (默认 5min): │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 缓存保留(有或没有订阅者) │ │
│ │ │ │
│ │ 所有订阅者卸载 ↓ gcTime 到期 ↓ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ inactive(仍可被重新订阅) │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ → 被清除 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 典型配置策略: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 静态资源(国家列表、配置):staleTime: Infinity │ │
│ │ 用户数据(个人资料): staleTime: 5 * 60 * 1000 │ │
│ │ 列表数据(项目列表): staleTime: 30 * 1000 │ │
│ │ 实时数据(通知计数): staleTime: 0 │ │
│ │ gcTime 通常设为 staleTime 的 5-10 倍 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘queryKey 设计原则
queryKey 是缓存的唯一标识。它的设计直接影响缓存命中率、失效精确度和代码可维护性。
// 原则1:从通用到具体,数组形式
// ['资源类型', '操作', ...具体参数]
useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
useQuery({ queryKey: ['projects', id], queryFn: () => fetchProject(id) })
useQuery({ queryKey: ['projects', id, 'tasks'], queryFn: () => fetchTasks(id) })
// 原则2:包含查询函数依赖的所有变量
function useFilteredProjects(filters: ProjectFilters) {
return useQuery({
// ✅ filters 的每个字段都包含在 key 中
queryKey: ['projects', 'list', filters],
queryFn: () => api.projects.list(filters),
})
}
// 原则3:键需要可序列化(能被 JSON.stringify 稳定处理)
// ❌ 不要把函数、DOM 节点、Symbol 放进 queryKey
// ✅ 使用简单值:string、number、boolean、plain object、arrayQuery Key Factory 模式——中大型项目推荐将 key 集中管理:
// src/lib/query-keys.ts
export const projectKeys = {
all: ['projects'] as const,
lists: () => [...projectKeys.all, 'list'] as const,
list: (filters: ProjectFilters) => [...projectKeys.lists(), filters] as const,
details: () => [...projectKeys.all, 'detail'] as const,
detail: (id: string) => [...projectKeys.details(), id] as const,
tasks: (id: string) => [...projectKeys.detail(id), 'tasks'] as const,
}
// 使用
useQuery({ queryKey: projectKeys.list(filters), queryFn: ... })
// 失效时可以精确命中
queryClient.invalidateQueries({ queryKey: projectKeys.lists() })
// 这会失效所有 ['projects', 'list', ...] 的查询// queryFn 的类型安全写法
interface Project {
id: string
name: string
status: 'active' | 'archived'
}
function useProject(id: string) {
return useQuery<Project, Error>({
queryKey: projectKeys.detail(id),
queryFn: async ({ queryKey }) => {
const [, , projectId] = queryKey // 从 queryKey 解构参数
const res = await fetch(`/api/projects/${projectId}`)
if (!res.ok) {
throw new Error(`Failed to fetch project: ${res.status}`)
}
return res.json() as Promise<Project>
},
})
}第3部分:缓存与重新获取策略
自动重新获取的触发机制
TanStack Query 提供多个"时机钩子",在检测到特定事件时自动后台重新获取 stale 数据。这些是可配置的开关,全部默认启用。
┌─────────────────────────────────────────────────────────────┐
│ 自动重新获取的触发条件 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 触发条件 配置项 默认值 │
│ ─────────────────────────────────────────────────────── │
│ 组件挂载(首次或重新挂载) refetchOnMount true │
│ 浏览器窗口重新聚焦 refetchOnWindowFocus true │
│ 网络断开后重新连接 refetchOnReconnect true │
│ │
│ ⚠️ 以上只会重新获取标记为 stale 的查询 │
│ fresh 数据直接使用缓存,不发请求 │
│ │
│ 全局配置示例: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const queryClient = new QueryClient({ │ │
│ │ defaultOptions: { │ │
│ │ queries: { │ │
│ │ staleTime: 30_000, // 30s 内不过期 │ │
│ │ gcTime: 5 * 60_000, // 5min 后 GC │ │
│ │ retry: 3, // 失败重试 3 次 │ │
│ │ refetchOnWindowFocus: true, │ │
│ │ refetchOnReconnect: true, │ │
│ │ }, │ │
│ │ }, │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘手动失效:invalidateQueries 的精确打击与范围轰炸
Mutation 之后,你需要让受影响的查询重新获取。invalidateQueries 通过 queryKey 前缀匹配来决定哪些查询被标记为 stale 并触发重取。
const queryClient = useQueryClient()
// 精确失效:只影响 ['projects', projectId] 这一个查询
queryClient.invalidateQueries({
queryKey: projectKeys.detail(projectId),
})
// 范围失效:影响所有以 ['projects'] 开头的查询
// 包括 projects 列表、各 project 详情等
queryClient.invalidateQueries({
queryKey: projectKeys.all,
})
// 组合策略:精确更新详情 + 宽泛失效列表
const updateProject = useMutation({
mutationFn: api.projects.update,
onSuccess: (updated, variables) => {
// 1. 立即更新缓存的详情,让当前页面秒级响应
queryClient.setQueryData(
projectKeys.detail(variables.id),
updated,
)
// 2. 失效所有列表,让它们在后台静默重取
queryClient.invalidateQueries({
queryKey: projectKeys.lists(),
})
},
})// 高级:条件失效 —— 只失效匹配特定条件的结果
queryClient.invalidateQueries({
predicate: (query) => {
// 只失效包含特定项目 ID 的查询缓存
const key = query.queryKey
return (
Array.isArray(key) &&
key[0] === 'projects' &&
key.includes('list')
)
},
})┌─────────────────────────────────────────────────────────────┐
│ 失效策略对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 策略 操作 网络请求 UI响应速度 │
│ ──────────────────────────────────────────────────────── │
│ invalidateQueries 标记stale 多(所有匹配 慢(等网络) │
│ (宽泛) →后台重取 的查询都重取) │
│ │
│ setQueryData 直接写入缓存 零 即时 │
│ + invalidateQueries 详情秒更 列表重取 详情即时/列表慢 │
│ │
│ setQueryData only 只写缓存 零 即时 │
│ ⚠️ 其他客户端的修改不会被感知 │
│ │
│ 推荐组合:setQueryData 更新已知受影响的缓存 + │
│ invalidateQueries 让所有相关查询最终与服务端一致 │
│ │
└─────────────────────────────────────────────────────────────┘手动重新获取 vs 手动失效
// refetch:强制立即重新获取(无视 staleTime)
const { refetch } = useQuery({ ... })
await refetch()
// invalidate:标记 stale,由下一次触发事件驱动重取
queryClient.invalidateQueries({ queryKey: ['projects'] })
// 如果你需要立即获取但不想写缓存:
// refetch 适合用户点击"刷新"按钮
// invalidateQueries 适合 mutation 后"脏标记"第4部分:Mutation 与乐观更新
useMutation API 概览
与 useQuery 不同,useMutation 不自动执行、不共享结果、不与缓存关联。它是"手动点火"的命令式模型。
const mutation = useMutation({
mutationFn: (variables: TVariables) => apiEndpoint(variables),
// 四个生命周期回调
onMutate: async (variables) => { /* 发起前 */ },
onSuccess: (data, variables, context) => { /* 成功 */ },
onError: (error, variables, context) => { /* 失败 */ },
onSettled: (data, error, variables, context) => { /* 无论如何 */ },
// 重试策略(仅对瞬时错误重试)
retry: 0, // mutation 默认不重试(与 query 相反,合理)
retryDelay: (attemptIndex, error) => Math.min(1000 * 2 ** attemptIndex, 30000),
})
// 调用
mutation.mutate({ name: 'New Project' })
mutation.mutateAsync({ name: 'New Project' }) // 返回 PromiseonMutate 的 context 机制是乐观更新的核心。onMutate 返回的值会作为 context 参数传递给 onSuccess、onError 和 onSettled——通常用于传递回滚所需的快照。
乐观更新完整流程
┌─────────────────────────────────────────────────────────────┐
│ 乐观更新完整协议 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 用户点击 "完成" ──▶ 预期成功率极高 ──▶ 不想等网络 │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ Step 1: onMutate │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ ① cancelQueries —— 取消正在进行的相关请求 │ │ │
│ │ │ 防止旧请求返回后覆盖乐观更新 │ │ │
│ │ │ │ │ │
│ │ │ ② getQueryData —— 保存当前缓存快照 │ │ │
│ │ │ 这是回滚的"保险" │ │ │
│ │ │ │ │ │
│ │ │ ③ setQueryData —— 立即写入新数据 │ │ │
│ │ │ UI 秒级更新,用户感觉不到延迟 │ │ │
│ │ │ │ │ │
│ │ │ ④ return { previous } —— 传递快照给回调 │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Step 2: 发起网络请求 mutationFn │ │
│ │ │ │ │
│ │ ┌────┴────┐ │ │
│ │ ▼ ▼ │ │
│ │ 成功 失败 │ │
│ │ onSuccess onError │ │
│ │ ┌──────┐ ┌──────────────────────────────┐ │ │
│ │ │ 可选 │ │ setQueryData(previous) │ │ │
│ │ │ 精细 │ │ → 恢复快照,UI 回退到操作前 │ │ │
│ │ │ 更新 │ │ → 展示错误提示 │ │ │
│ │ └──────┘ └──────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ └──────────┬───────────────────┘ │ │
│ │ ▼ │ │
│ │ Step 3: onSettled —— 无论如何都执行 │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ invalidateQueries —— 确保缓存与服务端一致 │ │ │
│ │ │ 即使乐观更新正确,也通过重取确认最终数据 │ │ │
│ │ │ 服务端可能有额外的计算(时间戳、自动字段) │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘完整代码实现
interface Todo {
id: string
title: string
completed: boolean
}
function useToggleTodo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (todo: Todo) =>
api.todos.update(todo.id, { completed: !todo.completed }),
onMutate: async (toggledTodo) => {
// ═══ Step 1: 取消进行中的查询 ═══
await queryClient.cancelQueries({
queryKey: todoKeys.lists(),
})
// ═══ Step 2: 保存当前快照 ═══
const previousTodos = queryClient.getQueryData<Todo[]>(
todoKeys.list('all'),
)
// ═══ Step 3: 乐观更新 ═══
queryClient.setQueryData<Todo[]>(
todoKeys.list('all'),
(old) =>
old?.map((t) =>
t.id === toggledTodo.id
? { ...t, completed: !t.completed }
: t,
) ?? [],
)
// ═══ Step 4: 返回回滚上下文 ═══
return { previousTodos }
},
onError: (_error, _todo, context) => {
// ═══ 回滚:恢复快照 ═══
if (context?.previousTodos) {
queryClient.setQueryData(
todoKeys.list('all'),
context.previousTodos,
)
}
toast.error('操作失败,已回滚')
},
onSettled: () => {
// ═══ 最终一致性:重取确认 ═══
queryClient.invalidateQueries({
queryKey: todoKeys.lists(),
})
},
})
}cancelQueries vs setQueryData 的分工
cancelQueries 取消的是正在飞行中的 query 请求 —— 如果不在 onMutate 中 cancel,旧的列表请求可能在 optimistic 更新写入后返回,覆盖掉乐观更新的结果。
setQueryData 直接写入缓存。它不触发网络请求,不改变 freshness 状态 —— 数据被标记为什么状态取决于你同时调用了什么。
// 典型错误:直接 setQueryData 但不 cancel
// 时序问题:
// t0: 用户滚动,触发列表请求(需要 500ms)
// t2: 用户点击删除
// t2: onMutate → setQueryData(乐观删除)
// t4: t0 的请求返回 → 覆盖了 t2 的乐观删除!
// 用户看到被删除的项又出现了
// 正确做法:onMutate 中先 cancel
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 现在安全了 —— 没有飞行中的请求会覆盖我们
const previous = queryClient.getQueryData(['todos'])
queryClient.setQueryData(['todos'], (old) =>
old?.filter((t) => t.id !== id),
)
return { previous }
},第5部分:分页与无限滚动
useInfiniteQuery 的工作原理
┌─────────────────────────────────────────────────────────────┐
│ useInfiniteQuery 数据模型 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 与 useQuery 的核心区别: │
│ useQuery: data = 最后一次查询的结果 │
│ useInfiniteQuery: data.pages = 所有页码的数组 │
│ data.pageParams = 每页的游标/偏移量 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ data: { │ │
│ │ pages: [ │ │
│ │ [{ id:1 }, { id:2 }, { id:3 }], // 第1页 │ │
│ │ [{ id:4 }, { id:5 }, { id:6 }], // 第2页 │ │
│ │ [{ id:7 }, { id:8 }, { id:9 }], // 第3页 │ │
│ │ ], │ │
│ │ pageParams: [undefined, 'cursor_3', 'cursor_6'], │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键方法: │
│ fetchNextPage() → 请求下一页 │
│ fetchPreviousPage() → 请求上一页(双向) │
│ hasNextPage → getNextPageParam 的返回值是否为非空 │
│ isFetchingNextPage → 正在获取下一页(用于底部加载指示器) │
│ │
└─────────────────────────────────────────────────────────────┘// 游标分页实现
interface PaginatedResponse<T> {
data: T[]
nextCursor: string | null
}
function useInfiniteProjects(pageSize: number = 20) {
return useInfiniteQuery<Project[], Error>({
queryKey: ['projects', 'infinite', pageSize],
queryFn: async ({ pageParam }) => {
const cursor = pageParam as string | undefined
const params = new URLSearchParams({ limit: String(pageSize) })
if (cursor) params.set('cursor', cursor)
const res = await fetch(`/api/projects?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
})
}
// 在组件中使用
function InfiniteProjectList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteProjects(20)
if (status === 'pending') return <Spinner />
if (status === 'error') return <ErrorBanner />
return (
<div>
{data.pages.map((page, i) => (
<Fragment key={i}>
{page.data.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? '加载中...'
: hasNextPage
? '加载更多'
: '已加载全部'}
</button>
</div>
)
}游标分页 vs Offset 分页
┌─────────────────────────────────────────────────────────────┐
│ 游标分页 vs Offset 分页 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Offset 分页(page=3&limit=20 → 跳过前 40 条) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ 实现简单,前端可以跳转到任意页 │ │
│ │ ❌ 分页期间插入/删除会导致数据重复或丢失 │ │
│ │ 例:正在读第2页时有人删了第1页的一条, │ │
│ │ 第2页的所有数据会整体前移,出现重叠 │ │
│ │ ❌ 大偏移量的性能差(OFFSET 10000 仍需扫描前10000行)│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 游标分页(cursor=eyJpZCI6IjQyIn0=&limit=20 → id>42 的20条)│
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ 不受并发修改影响(基于不变的数据位置) │ │
│ │ ✅ 性能稳定(WHERE id > ? LIMIT 20 走索引) │ │
│ │ ❌ 不能跳页(只能"上一页""下一页") │ │
│ │ ❌ 服务端需要额外实现游标的编码/解码逻辑 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 推荐:对数据频繁变化的场景(消息、通知、活动日志)使用游标; │
│ 对管理后台(数据相对稳定,需要跳页)可用 offset │
│ │
└─────────────────────────────────────────────────────────────┘双向无限滚动
聊天应用和日志查看器经常需要双向滚动(向上加载更早消息,向下加载更新消息)。
function useChatMessages(conversationId: string) {
return useInfiniteQuery({
queryKey: ['messages', conversationId],
queryFn: async ({ pageParam }) => {
const res = await fetch(
`/api/conversations/${conversationId}/messages?cursor=${pageParam ?? ''}`,
)
return res.json()
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
getPreviousPageParam: (firstPage) => firstPage.prevCursor ?? undefined,
// 从"最新"开始,允许向上翻
maxPages: 10, // 限制内存占用,防止无限累积
})
}
// 配合 IntersectionObserver 实现自动加载
function ChatView({ conversationId }: { conversationId: string }) {
const { data, fetchNextPage, fetchPreviousPage } =
useChatMessages(conversationId)
const topRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
// 向上滚动 → 加载更早的消息
useIntersectionObserver(topRef, fetchPreviousPage)
// 向下滚动 → 加载更新的消息
useIntersectionObserver(bottomRef, fetchNextPage)
// 渲染所有 page 中的消息...
}第6部分:预取策略
预取的时机与方式
┌─────────────────────────────────────────────────────────────┐
│ 预取策略矩阵 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 策略 触发时机 适用场景 │
│ ─────────────────────────────────────────────────────── │
│ 鼠标悬停预取 用户光标停在链接上 导航菜单、卡片 │
│ (hover prefetch) 或按钮上 │
│ │
│ 路由级预取 路由匹配即将进入时 列表→详情页导航 │
│ (route prefetch) │
│ │
│ SSR 预取 服务端渲染时 首屏关键数据 │
│ (server prefetch) │
│ │
│ 主动预取 特定用户行为后 表单提交后的 │
│ (eager prefetch) (如搜索前) 下一步数据 │
│ │
└─────────────────────────────────────────────────────────────┘鼠标悬停预取
function ProjectLink({ project }: { project: ProjectSummary }) {
const queryClient = useQueryClient()
const prefetchProject = () => {
queryClient.prefetchQuery({
queryKey: projectKeys.detail(project.id),
queryFn: () => api.projects.get(project.id),
staleTime: 60_000, // 预取的数据 60s 内保持新鲜
})
}
return (
<Link
href={`/projects/${project.id}`}
onMouseEnter={prefetchProject}
onFocus={prefetchProject} // 键盘导航也触发
>
{project.name}
</Link>
)
}SSR 预取:Next.js App Router 集成
// app/projects/page.tsx —— 服务端组件
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
export default async function ProjectsPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 服务端预取的数据:服务端不设 staleTime,客户端由 hydration 接管
staleTime: 30_000,
},
},
})
// 在服务端预取首屏数据
await queryClient.prefetchQuery({
queryKey: projectKeys.list({ page: 1 }),
queryFn: () => api.projects.list({ page: 1 }),
})
// 也可以并行预取多个查询
await Promise.all([
queryClient.prefetchQuery({
queryKey: ['user', 'preferences'],
queryFn: fetchUserPreferences,
}),
queryClient.prefetchQuery({
queryKey: ['notifications', 'count'],
queryFn: fetchNotificationCount,
}),
])
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectsClient />
</HydrationBoundary>
)
}// app/projects/ProjectsClient.tsx —— 客户端组件
'use client'
function ProjectsClient() {
// useQuery 会立即命中服务端预取的缓存,不发请求
const { data } = useQuery({
queryKey: projectKeys.list({ page: 1 }),
queryFn: () => api.projects.list({ page: 1 }),
})
return <ProjectTable data={data} />
}┌─────────────────────────────────────────────────────────────┐
│ SSR Hydration 陷阱 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 陷阱1:将用户 A 的缓存脱水给用户 B │
│ 服务端的 QueryClient 必须是请求级别的(每个请求 new) │
│ 不能复用模块级单例 —— 数据会跨用户泄露 │
│ │
│ ❌ 陷阱2:staleTime 不合理导致 hydration 后立即重复请求 │
│ 服务端预取的数据默认 staleTime = 0(立即 stale) │
│ 客户端挂载后立即触发重取 → 首屏闪烁,SSR 白做 │
│ 解决:为预取查询设合理 staleTime,或使用 initialData │
│ │
│ ❌ 陷阱3:服务端和客户端 queryKey 不一致 │
│ hydration 匹配靠的是 queryKey,不匹配则缓存未命中 │
│ 使用共享的 queryKey factory 确保一致性 │
│ │
│ ❌ 陷阱4:把不可序列化字段放进脱水数据 │
│ dehydrate 使用 JSON.stringify,Date → 字符串 │
│ 在查询函数中做反序列化,或使用 select 转换 │
│ │
└─────────────────────────────────────────────────────────────┘路由级预取
// 在 Next.js 中使用 Link 的 prefetch 配合 TanStack Query
// Next.js 默认在视口中 prefetch <Link> 的目标页面 JS bundle
// 但业务数据需要我们自己处理
function ProjectList() {
const queryClient = useQueryClient()
const projects = useProjects()
// 当列表数据加载完成后,预热所有可见项的详情
useEffect(() => {
if (!projects.data) return
for (const project of projects.data.slice(0, 10)) {
queryClient.prefetchQuery({
queryKey: projectKeys.detail(project.id),
queryFn: () => api.projects.get(project.id),
staleTime: 60_000,
})
}
}, [projects.data, queryClient])
return (
<div>
{projects.data?.map((p) => (
<ProjectRow key={p.id} project={p} />
))}
</div>
)
}第7部分:性能优化
选择器 (select):数据派生与渲染优化
select 让你从查询结果中提取或转换数据。关键优势:只有当 select 的输出改变时,组件才会重渲染 —— 即使原始 data 对象变化了。
// 原始数据是大而全的 Project 对象
function useProjectName(projectId: string) {
return useQuery({
queryKey: projectKeys.detail(projectId),
queryFn: () => api.projects.get(projectId),
select: (project) => project.name, // 只提取名称
})
// data 的类型推断为 string,不再是 Project
}
// 适合场景:组件只需要一个字段,但查询返回整个对象
function ProjectStatusBadge({ projectId }: { projectId: string }) {
const { data: status } = useQuery({
queryKey: projectKeys.detail(projectId),
queryFn: () => api.projects.get(projectId),
select: (project) => project.status,
})
// 只有 status 字段变化时才重渲染
return <Badge variant={status}>{status}</Badge>
}
// 复杂派生:汇总计算
function ProjectStats() {
const { data: stats } = useQuery({
queryKey: projectKeys.list({}),
queryFn: () => api.projects.list({}),
select: (projects) => ({
total: projects.length,
active: projects.filter((p) => p.status === 'active').length,
archived: projects.filter((p) => p.status === 'archived').length,
}),
})
return (
<div>
<Stat label="总项目数" value={stats.total} />
<Stat label="活跃" value={stats.active} />
<Stat label="已归档" value={stats.archived} />
</div>
)
}Structural Sharing(结构共享 / 引用稳定性)
这是 TanStack Query 默认启用的关键优化。当你执行 setQueryData 或查询重取时,如果新旧数据在值上相等(deep equal),TanStack Query 会保留旧对象的引用。
// 原理示意(TanStack Query 内部使用 replaceEqualDeep)
// 如果旧数据 = [{ id: 1, name: 'A' }]
// 新数据 = [{ id: 1, name: 'A' }](内容相同,引用不同)
// 则返回旧引用 → React.memo 的浅比较不会触发重渲染
// 例外:对于频繁变化的数据(如股票行情),
// 你可以关闭 structural sharing:
useQuery({
queryKey: ['stock-price', symbol],
queryFn: () => api.stocks.getPrice(symbol),
structuralSharing: false, // 每次都返回新引用
})queryClient 单例与测试
┌─────────────────────────────────────────────────────────────┐
│ QueryClient 生命周期管理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 生产环境:一个应用一个 QueryClient 实例 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ import { QueryClient, QueryClientProvider } │ │
│ │ from '@tanstack/react-query' │ │
│ │ │ │
│ │ const queryClient = new QueryClient({ │ │
│ │ defaultOptions: { /* 全局默认配置 */ }, │ │
│ │ }) │ │
│ │ │ │
│ │ function App() { │ │
│ │ return ( │ │
│ │ <QueryClientProvider client={queryClient}> │ │
│ │ <Router /> │ │
│ │ </QueryClientProvider> │ │
│ │ ) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 测试环境:每个测试用例一个独立的 QueryClient │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ function createTestQueryClient() { │ │
│ │ return new QueryClient({ │ │
│ │ defaultOptions: { │ │
│ │ queries: { │ │
│ │ retry: false, // 测试中不重试 │ │
│ │ gcTime: Infinity, // 不自动 GC │ │
│ │ }, │ │
│ │ mutations: { │ │
│ │ retry: false, │ │
│ │ }, │ │
│ │ }, │ │
│ │ }) │ │
│ │ } │ │
│ │ │ │
│ │ function TestWrapper({ children }) { │ │
│ │ const [queryClient] = useState(createTestQueryClient)│ │
│ │ return ( │ │
│ │ <QueryClientProvider client={queryClient}> │ │
│ │ {children} │ │
│ │ </QueryClientProvider> │ │
│ │ ) │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘持久化:persistQueryClient
对于需要离线缓存的应用(PWA、移动端 WebView),可以将缓存持久化到 localStorage / IndexedDB。
import { persistQueryClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24h —— 持久化场景下给足够长的时间
},
},
})
const persister = createSyncStoragePersister({
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
throttleTime: 1000, // 最多每秒写入一次
})
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 持久化数据最长保留 24h
buster: process.env.NEXT_PUBLIC_APP_VERSION ?? 'v1', // 版本变更时清空缓存
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// 只持久化有价值的查询(跳过实时数据、敏感数据)
const key = query.queryKey
if (key[0] === 'stock-price') return false
if (key[0] === 'auth') return false
return true
},
},
})┌─────────────────────────────────────────────────────────────┐
│ window focus refetch 与用户体验 │
├─────────────────────────────────────────────────────────────┤
│ │
│ refetchOnWindowFocus = true 是默认行为,但有代价: │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✅ 用户在另一个标签页做了操作 → 切回来自动看到最新 │ │
│ │ ✅ 长时间闲置后回到页面 → 数据不会"陈旧" │ │
│ │ │ │
│ │ ❌ 频繁切换标签页 → 大量不必要的网络请求 │ │
│ │ ❌ 移动端 App 切换 → 可能触发大量并发请求 │ │
│ │ │ │
│ │ 精细化控制: │ │
│ │ refetchOnWindowFocus: 'always' // 即使 fresh 也取 │ │
│ │ refetchOnWindowFocus: true // 只取 stale 的 │ │
│ │ refetchOnWindowFocus: false // 完全不取 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 建议:协作应用保持开启;内容站点可以关闭。 │
│ 也可按查询级别覆盖全局策略。 │
│ │
└─────────────────────────────────────────────────────────────┘核心总结
| 概念 | 一句话总结 |
|---|---|
| 服务端 vs 客户端状态 | 服务端状态是异步、可过期、远端拥有的;不要把它复制进客户端 Store |
| queryKey | 缓存的唯一地址,从通用到具体,包含所有依赖参数,使用工厂函数管理 |
| staleTime | 数据被视为"新鲜"的时长:在此期间跳过自动重取 |
| gcTime | 无订阅者后内存中保留的时长:给用户返回留出窗口 |
| useQuery 生命周期 | fetching → fresh → stale → inactive → garbage collected |
| invalidateQueries | 标记查询为 stale 并触发后台重取,靠 queryKey 前缀匹配 |
| 乐观更新协议 | cancelQueries → 保存快照 → setQueryData → 失败回滚 → onSettled 重验证 |
| useInfiniteQuery | 累积多页数据到 data.pages[],通过 getNextPageParam 驱动游标 |
| select | 数据转换 + 渲染优化:只有 select 输出变化时才重渲染 |
| SSR Hydration | 每个请求 new QueryClient → prefetch → dehydrate → HydrationBoundary |
章节测试
选择题:一个查询组件卸载 2 分钟后重新挂载。
staleTime = 30s,gcTime = 5min。重新挂载时会发生什么? A) 展示缓存数据,不发起网络请求 B) 展示缓存数据,并在后台重新获取 C) 展示 loading 状态,重新获取数据 D) 抛出错误,缓存已被清除填空题:在乐观更新中,
onMutate回调返回的对象被称为______,它会被传递给onError用于回滚。判断题:
invalidateQueries会立即发起网络请求,与refetch行为相同。简答题:为什么在
onMutate中需要先调用cancelQueries再调用setQueryData?如果颠倒顺序会发生什么?设计题:你有一个项目列表页面(支持筛选和分页),点击进入项目详情页(可编辑)。请设计:
- 查询键层级结构
- 详情页编辑保存后的失效策略
- 如何利用预取优化从列表到详情的导航体验
参考答案
B。数据在 gcTime 内(5min > 2min),缓存仍存在,组件挂载时可以立即展示缓存。但由于 staleTime(30s)已过期,数据是 stale 的,会在后台重新获取。
context。
onMutate的返回值作为 context 参数传递给onSuccess/onError/onSettled,通常用于传递回滚所需的快照数据。错误。
invalidateQueries只是将匹配的查询标记为 stale,实际的网络请求由这些查询的观察者(活跃的 useQuery 钩子)在下一个渲染周期触发。而refetch是立即发起的。如果不在
onMutate中 cancel,可能存在飞行中的旧请求在乐观更新写入缓存后返回,覆盖掉乐观更新的结果(race condition)。Cancel 必须在 setQueryData 之前,因为 cancel 是异步的 —— 你需要确保在写入前所有旧请求都已取消。颠倒顺序会导致:先写了新数据,旧请求返回后覆盖了新数据。参考方案:
tsx// 键层级 const projectKeys = { all: ['projects'] as const, lists: () => [...projectKeys.all, 'list'] as const, list: (filters: ProjectFilters) => [...projectKeys.lists(), filters] as const, details: () => [...projectKeys.all, 'detail'] as const, detail: (id: string) => [...projectKeys.details(), id] as const, } // 编辑保存后的失效策略 const updateProject = useMutation({ mutationFn: api.projects.update, onSuccess: (updated, variables) => { // 精确更新详情缓存(用户正在看这个页面) queryClient.setQueryData( projectKeys.detail(variables.id), updated, ) // 失效所有列表(筛选条件未知,宽泛失效让全部列表重取) queryClient.invalidateQueries({ queryKey: projectKeys.lists(), }) }, }) // 预取策略:列表页面中,对可见项目的详情做 hover 预取 // 或列表加载完成后立即预取前 N 项的详情
相关笔记
- [[01-react-components-and-rendering]] —— 理解渲染与重渲染,是性能优化的基础
- [[02-hooks-state-and-effects]] —— useEffect 与状态管理,服务端状态手动管理的对比
- [[04-redux-zustand-state-management]] —— 客户端状态管理,与 TanStack Query 的职责边界
- [[06-nextjs-data-cache-mutations]] —— Next.js 服务端数据缓存与 Server Actions
- [[08-testing-performance-production]] —— 测试 Query 和 Mutation 的模式
下一步学习
学习状态:🟡 开始学习