本文由 Huifer 撰写,TanStack Ship 独立开发者和维护者。 我今天使用的 TanStack Query 最佳实践,并非我最初就开始用的那些。我的第一个 TanStack Query 实现里,query key 像
['getUser', userId]、['fetchProduct']、['products']这样的命名——这个约定在有 30 个查询时就失控了,我根本不知道哪些是过期的。我在三个应用里花了一天时间统一了 query key 规范,这是我全年做过的杠杆效应最高的重构。本文就是我现在每个新应用都用的这套规范。验证来源:TanStack Query 官方文档 · TanStack Query 最佳实践 · TanStack Query GitHub 最后更新:2026-09-18 · 更新日志
TL;DR: 三个杠杆效应最高的 TanStack Query 实践:(1) query key 使用点号分隔约定——['users', userId, 'profile'];(2) staleTime 设置为数据的自然刷新间隔,而非 0;(3) 关键路由在链接悬停时预取。做好这三点之后,其他都是优化。
实践 1:Query Key 约定
TanStack Query 应用中最重要的约定就是 query key。Query key 决定缓存身份——两个拥有相同 key 的查询共享同一份缓存数据。不好的约定让缓存失效难以预测,好的约定则一目了然。
点号分隔约定
// 每个 query key 遵循:[entity, id?, subResource?]
// 这样失效行为可预测,key 结构也可预测
// 按 ID 获取单个资源
['users', userId]
['products', productId]
['orders', orderId]
// 某实体的子资源
['users', userId, 'profile']
['users', userId, 'orders']
['products', productId, 'reviews']
// 集合(无 ID)
['users']
['products']
['orders']
// 过滤后的集合
['products', 'category', categorySlug]
['orders', 'status', 'pending']
这个约定只有一条规则:实体名用复数,ID 用单数,子资源用单数。['users', '123'] 是一个用户。['users', '123', 'orders'] 是那个用户的订单列表。
为什么这直接影响失效
有了这个约定,失效行为就完全可预测了:
// 使某用户的所有查询失效
queryClient.invalidateQueries({ queryKey: ['users', userId] })
// 失效范围:['users', userId]、['users', userId, 'profile']、['users', userId, 'orders']
// 使某产品评论的所有查询失效
queryClient.invalidateQueries({ queryKey: ['products', productId, 'reviews'] })
// 使某资源类型的所有查询失效
queryClient.invalidateQueries({ queryKey: ['products'] })
// 失效范围:['products']、['products', 'category', ...]、['products', productId, ...]
没有约定,你永远不知道一次失效会影响哪些查询。有了约定,query key 结构本身就能告诉你精确答案。
实践 2:staleTime,设为非 0
默认的 staleTime: 0 意味着每次挂载都重新获取数据。对于频繁变化的数据(通知、实时指标),这没问题。对于变化稀少的数据(用户档案、产品目录),这就是性能税。
// ❌ 默认行为:每次挂载都重新获取——开销大
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
// staleTime 默认为 0
})
// ✅ 将 staleTime 设置为数据的自然刷新间隔
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60 * 5, // 5 分钟——用户档案很少变化
})
// ✅ 静态参考数据:除非显式失效,否则永不重新获取
const { data: countries } = useQuery({
queryKey: ['reference', 'countries'],
queryFn: () => fetchCountries(),
staleTime: 1000 * 60 * 60 * 24, // 24 小时
})
心智模型:staleTime 是你愿意提供过期数据的时间长度。用户档案数据,5 分钟没问题。股价,0 秒。国家列表,无穷大。
实践 3:链接悬停时预取
TanStack Query 中影响最大的 UX 模式:用户在链接上悬停时就预取数据。数据在点击前就已经进入缓存了。
import { useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
function ProductList({ products }) {
const queryClient = useQueryClient()
return (
<div className="product-grid">
{products.map(product => (
<Link
key={product.id}
to={`/products/${product.id}`}
onMouseEnter={() => {
// 用户悬停时预取——数据在点击前就绪
queryClient.prefetchQuery({
queryKey: ['products', product.id],
queryFn: () => fetchProduct(product.id),
staleTime: 1000 * 60 * 5,
})
}}
>
<ProductCard product={product} />
</Link>
))}
</div>
)
}
效果:点击产品卡片后详情页即时渲染——查询数据已经在缓存里。onMouseEnter 在导航前触发,给你 50–200ms 的预取时间。
实践 4:全局 QueryClient 默认值
设置与你的数据刷新特征匹配的全局默认值,而非边界情况:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 默认:2 分钟过期时间
// 这对大多数 SaaS 数据是正确的默认值
staleTime: 1000 * 60 * 2,
// gcTime:10 分钟
// 最后订阅者离开后数据在内存中保留 10 分钟
gcTime: 1000 * 60 * 10,
// 指数退避重试 3 次
retry: 3,
retryDelay: (attemptIndex) =>
Math.min(1000 * 2 ** attemptIndex, 30000),
// 已认证用户窗口获焦时重新获取
// (标签页不活跃期间数据可能已变更)
refetchOnWindowFocus: true,
// 已在加载中则不再重新获取
refetchOnMount: false,
},
mutations: {
// 变更默认不重试
// 变更应该失败得响亮,而非静默重试
retry: 0,
},
},
})
实践 5:v5 的 SSR 水合
对于 SSR 应用,v5 中水合模式有变化。需确保服务端和客户端状态对齐:
// 服务端:使用一致的 staleTime 预取
//(必须与客户端的 staleTime 匹配,v5 水合才能正常工作)
const dehydratedState = dehydrate(
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
},
},
})
)
// 客户端:在 QueryClient 配置中与服务端 staleTime 对齐
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// 必须与服务端 staleTime 匹配,水合才正确
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
},
},
})
)
v5 的改进:如果服务端数据是新鲜的(在 staleTime 内),客户端直接使用,不重新获取。服务端与客户端 staleTime 不匹配会导致水合时静默重新获取。
实践 6:后台重新获取策略
不要在每次焦点事件时重新获取所有内容。以 staleTime 为主要重新获取控制,后台重新获取只在必要时使用:
const { data, isFetching, isStale } = useQuery({
queryKey: ['dashboard-metrics'],
queryFn: () => fetchDashboardMetrics(),
staleTime: 1000 * 60, // 1 分钟
// 仅在查询当前已过期 AND 窗口有焦点时才重新获取
refetchOnWindowFocus: (query) => query.state.data !== undefined,
})
refetchOnWindowFocus 函数形式:只在已有数据时才重新获取。这避免了"每次切换标签页都出现加载状态"这一 UX 反模式。
五点生产检查清单
- Query key 约定已文档化 — 每个团队成员使用相同结构
- staleTime 每个查询单独设置 — 0 是例外,不是默认
- 关键路由已预取 — 产品详情页和用户档案页在链接悬停时预取
- SSR staleTime 与客户端对齐 — 每次 SSR 变更后跑水合冒烟测试
- QueryClient gcTime 为 10 分钟以上 — 设太低会导致后退导航缓慢
TanStack Ship 基于 TanStack Query v5 构建,query key 约定和 SSR 水合均已预配置。 参见 TanStack Query v5 指南 和完整功能列表。