首页 / TanStack 生态入门教程 / 查询键(QueryKey)设计

TanStack 生态入门教程

查询键(QueryKey)设计

本教程共 38 篇 · 第 6 篇 · 更新于 2026-07-27 · 约 8 分钟阅读

TanStackTanStack 生态入门教程QueryKey查询键序列化缓存默认查询函数defaultQueryFn

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 的细节和请求策略。