性能优化
TanStack Query 默认已经做了大量性能优化。本章讲解如何利用其内置机制并避免常见的性能陷阱。
内置优化机制
TanStack Query 开箱即用的性能特性:
| 机制 | 说明 | 默认 |
|---|---|---|
| 请求去重 | 相同查询键同时只发一次请求 | ✅ |
| 缓存共享 | 多个 observer 共享同一份数据 | ✅ |
| 结构共享(Structural Sharing) | 数据引用稳定化——只有实际变化的部分创建新引用 | ✅ |
| React 18 批量更新 | 多个状态更新合并为一次重渲染 | ✅(需 React 18) |
| 后台重取 | stale 数据先返回缓存,后台更新 | ✅ |
| AbortSignal 取消 | 组件卸载时自动取消请求 | ✅ |
select — 减少重渲染
select 是性能优化的首要工具。它让你只订阅数据的子集:
// ❌ 组件订阅整个 todos 数组 — 任何 todo 变化都重渲染
function TodoCount() {
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
return <div>总数:{todos?.length}</div>
}
// ✅ 只订阅数量(原始值)— title 修改不重渲染
function TodoCount() {
const { data: count } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: (todos) => todos.length, // 返回原始值
})
return <div>总数:{count}</div>
}select + 引用稳定性
select 返回对象/数组时,只有引用变化才触发重渲染:
// ❌ 每次重取都产生新数组 → 每次都重渲染
select: (todos) => todos.filter(t => t.completed)
// ✅ 提取为稳定引用
const selectCompleted = (todos: Todo[]) => todos.filter(t => t.completed)
// ✅ 配合 queryOptions 工厂
export function doneTodosOptions() {
return queryOptions({
queryKey: todoKeys.lists({ status: 'done' }),
queryFn: fetchDoneTodos,
})
}
// 然后调用 useQuery(doneTodosOptions())
🔬 深入原理:
select使用useSyncExternalStore配合selector机制。TanStack Query 执行select后,将结果与上一次的结果做Object.is比较——同值(原始类型)或同引用(对象)则跳过重渲染。
结构共享(Structural Sharing)
结构共享是默认开启的核心优化。它在更新缓存数据时,尽量保持未变化部分的引用不变:
// 假设缓存的 data 是:
const oldData = {
todos: [
{ id: 1, title: 'A', done: false },
{ id: 2, title: 'B', done: false },
]
}
// 服务端返回新数据(只改了第二条的 done):
const newData = {
todos: [
{ id: 1, title: 'A', done: false }, // ← 未变化,引用保持!
{ id: 2, title: 'B', done: true }, // ← 变化了,新引用
]
}
// 结果:
oldData.todos[0] === newData.todos[0] // true(引用相等!)
oldData.todos === newData.todos // false(数组引用变了)
这对 React.memo 和 useMemo 非常关键——未变化的子项不会触发依赖它们的重渲染。
自定义结构共享
// 默认使用 JSON.parse(JSON.stringify()) 做深拷贝
// 对于特殊数据结构(如 Map、Set),需自定义
import { replaceEqualDeep } from '@tanstack/react-query'
useQuery({
queryKey: ['data'],
queryFn: fetchData,
structuralSharing: (oldData, newData) => {
// 返回 newData 的深拷贝,同时保持未变化部分的引用
return replaceEqualDeep(oldData, newData)
},
})禁用结构共享
// 场景:数据永远是全新的(如随机数),不需要结构共享
useQuery({
queryKey: ['random-number'],
queryFn: fetchRandomNumber,
structuralSharing: false,
})查询键稳定引用
查询键每次渲染都会被重新创建。如果它包含动态生成的对象,会导致不必要的缓存失效:
// ❌ 对象字面量每次渲染都是新引用
useQuery({
queryKey: ['todos', { status, sort: 'date' }],
queryFn: fetchTodos,
})
// 虽然值可能相同,但每次都是新对象 → 查询键变化 → 新请求
// ✅ 对频繁变化的场景,使用 useMemo
const queryParams = useMemo(
() => ({ status, sort: 'date' }),
[status]
)
useQuery({
queryKey: ['todos', queryParams],
queryFn: fetchTodos,
})⚠️ 实际上,TanStack Query v5 判断查询键相等使用的是深度比较(deep equal),而非引用比较。所以大多数情况下上面的代码不会有问题。但保持键的稳定能减少深度比较的开销,且对 DevTools 中的缓存键可读性有帮助。
queryOptions 工厂模式 — 避免重复创建
// ❌ 每次渲染都创建新的 options 对象
function TodoList() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 30_000,
select: (todos) => todos.filter(t => !t.completed),
// 每次渲染都是新对象 + 新 select 函数
})
}
// ✅ queryOptions 工厂 — 创建一次,到处复用
const todoListOptions = queryOptions({
queryKey: ['todos'] as const,
queryFn: fetchTodos,
staleTime: 30_000,
})
function TodoList() {
const { data } = useQuery({
...todoListOptions,
select: activeTodosSelector, // 提取的稳定函数
})
}Window Focus 与网络请求优化
默认配置下,用户切回标签页时所有 stale 查询都会重取。大型应用可能产生大量并发请求:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 谨慎关闭:权衡数据新鲜度和带宽
refetchOnWindowFocus: false,
// 或者只对特定查询开启
refetchOnWindowFocus: (query) => {
// 只有活跃的、重要的查询才重取
return query.queryKey[0] === 'notifications'
},
},
},
})⚡ 性能提示:对于列表/详情这类数据,保持
refetchOnWindowFocus: true带来的数据新鲜度收益通常大于带宽开销。对配置数据、字典数据等基本不变的内容,设置staleTime: Infinity比关闭refetchOnWindowFocus更好。
useMemo / useCallback 在 Query 场景中的使用
// queryFn 不需要 useCallback — TanStack Query 只在 queryKey 变化时调用它
// 但 select 需要稳定引用(如果依赖外部变量)
function useFilteredTodos(status: string) {
const selectFn = useCallback(
(todos: Todo[]) => todos.filter(t => t.status === status),
[status]
)
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
select: selectFn,
})
}减少不必要的 data 解构
// ❌ 即使组件不用这些字段,解构也会建立订阅
const { data, isPending, isFetching, error, isStale, isFetched } = useQuery({...})
// ✅ 只取需要的
const { data, isPending, error } = useQuery({...})🔬 深入原理:TanStack Query 的
useQuery返回值是一个对象,解构多少字段不影响性能。真正影响性能的是组件如何使用data——如果整个组件订阅data而只有一小部分需要它,应该拆分子组件或用select。
组件拆分策略
// ❌ 一个组件同时处理加载/错误/数据显示
function TodoPage() {
const { data, isPending, isFetching, error } = useQuery({...})
if (isPending) return <Spinner />
if (error) return <Error />
return (
<div>
<Header isFetching={isFetching} />
<TodoList todos={data} />
</div>
)
}
// ✅ 拆分:每个子组件只取自己需要的
function TodoPage() {
return (
<div>
<Suspense fallback={<Spinner />}>
<TodoHeader />
<TodoList />
</Suspense>
</div>
)
}
function TodoHeader() {
const { isFetching } = useQuery({...}) // 只触发重渲染当 isFetching 变化
return <Header isFetching={isFetching} />
}
function TodoList() {
const { data } = useQuery({...})
return <List items={data} />
}性能诊断工具
React Query Devtools
内置的 DevTools 可以帮助识别性能问题:
- 查看查询状态(fresh / stale / fetching)
- 查看 observer 数量(了解哪些组件在使用查询)
- 手动 invalidate / refetch 测试行为
React DevTools Profiler
- 检查哪些组件因为 useQuery 数据变化而重渲染
- 确认
select是否有效减少了重渲染
常见性能症状与排查
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 数据返回后大量组件重渲染 | 未使用 select / 整个 data 被传递 |
用 select 缩减订阅 |
| 切换标签页后卡顿 | refetchOnWindowFocus 触发大量请求 |
增加 staleTime 或选择性关闭 |
| 内存持续增长 | 无限查询未设 maxPages |
设置 maxPages: 5~10 |
| 相同 API 被调用多次 | 查询键不一致 / 对象字面量键 | 检查查询键是否相同、使用查询键工厂 |