查询键(QueryKey)设计
本教程共 38 篇 · 第 6 篇 · 更新于 2026-07-27 · 约 8 分钟阅读
6. 查询键(QueryKey)设计
本节目标:搞懂 QueryKey 的结构、层级、序列化规则,学会设计合理的键,并知道怎么用默认查询函数简化代码,学完你能把查询键组织得井井有条。
6.1 QueryKey 是什么
QueryKey 是个数组,用来唯一标识一份缓存数据。Query 内部靠它做缓存读写、请求去重、失效匹配。
要求就一条:能用 JSON.stringify 序列化,且对同一份数据是唯一的。
最简单的形式,单个字符串:
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
带变量时往数组里加:
useQuery({ queryKey: ['todo', 5], queryFn: () => fetchTodoById(5) })
6.2 键的层级设计
好的 QueryKey 应该有层级结构,从粗到细。这样后面做失效(invalidation)时能按前缀批量匹配。
推荐的层级:
// 第一层:资源类型
['todos']
// 第二层:具体 ID
['todos', 5]
// 第三层:视图变体或参数
['todos', 5, { preview: true }]
['todos', { type: 'done', page: 1 }]
这种「资源 → ID → 参数」的层级,让你可以:
- 失效所有 todo 相关查询:
invalidateQueries({ queryKey: ['todos'] }) - 只失效列表查询:
invalidateQueries({ queryKey: ['todos', { type: 'done' }] }) - 精确失效某一个:
invalidateQueries({ queryKey: ['todos', 5], exact: true })
Tip把第一项设成稳定的资源名(字符串),后面的项放变量。这样按前缀失效时,一个
['todos']就能命中所有 todo 查询。如果第一项就是变量,就没法按前缀批量失效了。
6.3 数组键 vs 对象键
参数可以放数组里,也可以包成对象。两者序列化结果不同,行为也不同。
对象键:键的顺序无关,undefined 会被忽略。下面三个完全相等:
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ... })
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })
数组键:顺序敏感,多一项少一项都不一样。下面三个互不相等:
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ... })
useQuery({ queryKey: ['todos', undefined, page, status], ... })
Note实测下来,参数多用对象包更省心。不用纠结顺序,加新参数也不影响旧 key 的匹配。数组键适合参数有明确先后语义的场景(比如分页的 page)。
6.4 序列化规则
Query 内部把 QueryKey 用 JSON.stringify 做确定性哈希。这意味着:
- 字符串、数字、布尔值、null 直接比较
- 对象按属性名排序后比较(所以顺序无关)
- 数组按索引顺序比较(所以顺序敏感)
undefined在对象里会被忽略,但在数组里会占位
理解这点很重要,因为它决定了「两个 key 算不算同一个」。
一个容易踩的坑:函数和 Symbol 不能序列化,别放进 key。日期对象会被转成字符串,能用但不推荐,转成时间戳更稳。
6.5 queryFn 用到的变量必须进 key
这条铁律再强调一次。看个反面教材:
// 错误:todoId 没进 key
function Todo({ todoId }) {
const { data } = useQuery({
queryKey: ['todo'],
queryFn: () => fetchTodoById(todoId),
})
}
todoId 从 5 变成 6,但 key 还是 ['todo'],Query 以为数据没变,不会重新请求,页面就显示旧的 todo。
正确写法:
function Todo({ todoId }) {
const { data } = useQuery({
queryKey: ['todo', todoId], // todoId 进 key
queryFn: () => fetchTodoById(todoId),
})
}
key 变了,Query 自动重新请求新数据。这和 React 的 useEffect 依赖数组是一个思路。
Warning官方 ESLint 插件有个
exhaustive-deps规则,能帮你抓这种「queryFn 用了变量但 key 里没有」的问题。强烈建议开着。
6.6 从 key 里取参数
前面提过,queryFn 能从上下文对象拿到 queryKey。配合层级设计,可以把请求函数抽出去复用:
// 抽出来的请求函数,不依赖组件变量
function fetchTodoList({ queryKey }) {
const [_key, { status, page }] = queryKey
return fetch(`/api/todos?status=${status}&page=${page}`).then((r) => r.json())
}
// 多个地方用,key 不同就请求不同数据
useQuery({ queryKey: ['todos', { status: 'done', page: 1 }], queryFn: fetchTodoList })
useQuery({ queryKey: ['todos', { status: 'todo', page: 2 }], queryFn: fetchTodoList })
这种写法把「查什么」和「怎么查」解耦了,key 描述查什么,函数描述怎么查。
6.7 默认查询函数
如果整个项目的请求逻辑都差不多(比如都是 REST API + axios),可以配个全局默认 queryFn,这样每个 useQuery 只用传 key 不用传 fn。
const defaultQueryFn = async ({ queryKey }) => {
const { data } = await axios.get(
`https://api.example.com${queryKey[0]}`
)
return data
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryFn: defaultQueryFn,
},
},
})
配好之后,组件里就省事了:
// 不用传 queryFn,用默认的
function Posts() {
const { data } = useQuery({ queryKey: ['/posts'] })
}
// 带参数也能用
function Post({ postId }) {
const { data } = useQuery({
queryKey: [`/posts/${postId}`],
enabled: !!postId,
})
}
想覆盖默认行为时,正常传 queryFn 就行,局部优先。
Tip这种模式特别适合 REST API 项目。把 URL 当 key,请求方法统一,代码量大幅减少。GraphQL 项目不太适合,因为 query 通常带 variables,用默认函数不如各自写清晰。
6.8 键的组织建议
项目大了,QueryKey 散落各处容易乱。几个实践建议:
1. 集中定义 key 工厂。 把 key 的生成逻辑收拢到一个文件:
// queryKeys.ts
export const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
用的时候:
useQuery({ queryKey: todoKeys.list({ status: 'done' }), queryFn: fetchTodos })
useQuery({ queryKey: todoKeys.detail(5), queryFn: () => fetchTodo(5) })
// 失效时也用工厂
queryClient.invalidateQueries({ queryKey: todoKeys.all })
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
这样 key 的结构改了,全项目跟着改,不用到处找散落的字符串。
2. 配合 queryOptions 类型穿透。 把 key 和 fn 绑一起:
export function todoDetailOptions(id: number) {
return queryOptions({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
})
}
useQuery(todoDetailOptions(5))
queryClient.prefetchQuery(todoDetailOptions(5))
queryOptions 让 key 和 fn 的类型关系保持住,getQueryData 拿数据时类型不会丢。
3. 用 Register 约束 key 结构。 对类型要求高的项目,全局注册 key 类型:
declare module '@tanstack/react-query' {
interface Register {
queryKey: ['todos' | 'posts' | 'users', ...ReadonlyArray<unknown>]
}
}
这样 key 第一项只能是你定义的资源名,写错就报错。
6.9 小结
QueryKey 设计的核心是层级化、可匹配。第一项放资源名,后面放 ID 和参数;参数多用对象包避免顺序敏感;queryFn 用到的变量必须进 key;大项目用 key 工厂集中管理。
下一篇讲 queryFn 的细节和请求策略。