TanStack Query ESLint 规则详解:prefer-query-options 如何强制 queryKey 与 queryFn 同处定义
在 TanStack Query 生态中,queryKey 与 queryFn 分离书写是常见的历史写法,但同一 key 被不同 fetch 函数复用时会引发难以察觉的运行时问题。本文基于仓库中 prefer-query-options 规则文档 展开,结合 eslint-plugin-query 规则实现源码 与 测试用例,完整讲解该规则的检查范围、违规与修复写法、queryKey 复用检查以及底层 AST 判定原理。读完你可以将该规则接入项目、理解它覆盖哪些 Hook 与 QueryClient 方法,并判断它是否适合你的代码库。
规则动机:为什么 queryKey 和 queryFn 应该放在一起
规则文档开宗明义:把 queryKey 和 queryFn 分开写在 Hook 参数里,当同一个 query key 被意外地与多个不同的 queryFn 一起使用时,会导致不可预期的运行时行为——例如某个组件用 key ['get', id] 取到了另一个组件 queryFn 写入的缓存数据。用 queryOptions(无限滚动场景用 infiniteQueryOptions)把 key 和函数包裹在一起,可以让二者在物理位置上绑定(co-locate),查询因此更安全、也更容易被多个组件复用。
规则元数据在源码中被声明为:
// packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts
meta: {
type: 'problem',
docs: {
description:
'Prefer using queryOptions() to co-locate queryKey and queryFn',
recommended: 'strict',
},
messages: {
preferQueryOptions:
'Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn.',
preferQueryOptionsQueryKey:
'Prefer referencing a queryKey from a queryOptions() result instead of typing it manually.',
},
schema: [],
}
两个关键信息:
- 规则类型为
problem,且recommended: 'strict',即它只在recommended-strict预设中默认开启,而不是普通recommended预设; schema: []表示该规则不接受任何选项配置,开启即全量生效。
在插件入口 index.ts 中,预设的差异也印证了这一点:recommendedRules 里不包含该规则,只有 recommendedStrictRules 显式追加了 '@tanstack/query/prefer-query-options': 'error',并且对应的 flat/recommended-strict Flat Config 同步暴露给 Flat Config 用户。
违规写法与正确写法
在查询 Hook 中内联 queryKey / queryFn(违规)
文档给出的两类错误示例如下。第一类是直接在组件里写:
/* eslint "@tanstack/query/prefer-query-options": "error" */
function Component({ id }) {
const query = useQuery({
queryKey: ['get', id],
queryFn: () => Api.get(`/foo/${id}`),
})
// ...
}
第二类是把参数化选项封装成自定义 Hook,仍然违规:
/* eslint "@tanstack/query/prefer-query-options": "error" */
function useFooQuery(id) {
return useQuery({
queryKey: ['get', id],
queryFn: () => Api.get(`/foo/${id}`),
})
}
修复方式是引入 queryOptions 构建函数,让 key 与 fetch 逻辑定义在一起,Hook 只消费其结果:
/* eslint "@tanstack/query/prefer-query-options": "error" */
function getFooOptions(id) {
return queryOptions({
queryKey: ['get', id],
queryFn: () => Api.get(`/foo/${id}`),
})
}
function Component({ id }) {
const query = useQuery(getFooOptions(id))
// ...
}
也允许在消费侧展开结果并追加选项(如 select),同样不报错:
/* eslint "@tanstack/query/prefer-query-options": "error" */
function getFooOptions(id) {
return queryOptions({
queryKey: ['get', id],
queryFn: () => Api.get(`/foo/${id}`),
})
}
function useFooQuery(id) {
return useQuery({ ...getFooOptions(id), select: (data) => data.foo })
}
这里值得注意的是:文档示例中 Component 直接传入 useQuery(getFooOptions(id)) 这类函数调用结果也能通过检查。这一点在测试文件 prefer-query-options.test.ts 的 "hooks consuming queryOptions result" 分组中被逐条验证,包括传入 queryOptions 常量、传入函数调用结果、展开后追加 select 等合法形态。
QueryClient 方法中手写 queryKey(违规)
规则的第二重检查针对 QueryClient 的方法与过滤器:应当复用 queryOptions 结果上的 queryKey,而不是在调用处再次手写一遍数组。错误示例:
/* eslint "@tanstack/query/prefer-query-options": "error" */
function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}
function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.getQueryData(['todo', id])
}
/* eslint "@tanstack/query/prefer-query-options": "error" */
function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}
function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.invalidateQueries({ queryKey: ['todo', id] })
}
正确的写法是引用 options 结果上的 queryKey 属性:
/* eslint "@tanstack/query/prefer-query-options": "error" */
function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}
function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.getQueryData(todoOptions(id).queryKey)
}
/* eslint "@tanstack/query/prefer-query-options": "error" */
function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}
function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.invalidateQueries({ queryKey: todoOptions(id).queryKey })
}
这样 key 只有一个事实来源:改 todoOptions 里的 key 时,所有 QueryClient 调用点自动跟随,不会出现"改了定义处、漏改 invalidate 处"这类 key 漂移。
规则的检查范围:从源码看它覆盖了哪些调用点
规则文档只展示了 useQuery 与 QueryClient 的部分方法,而 规则源码 顶部的常量数组完整列出了检查面,可以据此判断哪些写法会被扫到:
- 直接触发
preferQueryOptions报告的查询 Hook(queryHooks):useQuery、useInfiniteQuery、useSuspenseQuery、useSuspenseInfiniteQuery、usePrefetchQuery、usePrefetchInfiniteQuery; - 批量查询 Hook(
queriesHooks):useQueries、useSuspenseQueries——规则会读取queries数组中的每个 query 对象(支持数组字面量与.map(...)映射),逐一检查是否内联了queryKey或queryFn; - 过滤器 Hook(
filterHooks):useIsFetching,其选项对象中出现内联queryKey数组时报告preferQueryOptionsQueryKey; QueryClient选项类方法(queryClientOptionMethods):fetchQuery、prefetchQuery、fetchInfiniteQuery、prefetchInfiniteQuery、ensureQueryData、ensureInfiniteQueryData,参数对象内联queryKey/queryFn时报preferQueryOptions;QueryClient纯 key 类方法(queryClientQueryKeyMethods):getQueryData、setQueryData、getQueryState、setQueryDefaults、getQueryDefaults,第一个参数是内联数组字面量时直接报preferQueryOptionsQueryKey;QueryClient过滤器类方法(queryClientFilterMethods):invalidateQueries、cancelQueries、refetchQueries、removeQueries、resetQueries、isFetching、getQueriesData、setQueriesData,其queryKey字段为内联数组时报preferQueryOptionsQueryKey;queryOptionsBuilders:queryOptions与infiniteQueryOptions的调用本身永远放行。
此外,判定"参数对象是否内联"的逻辑很宽松:只要对象里同时或单独出现 queryKey 或 queryFn 任一属性就报告(见 hasInlineQueryOptions 函数),测试用例也验证了仅写 useQuery({ queryKey: ['todos'] }) 而不写 queryFn 同样违规。而 useQuery(todosOptions)、useQuery({ ...todosOptions, select: ... })、invalidateQueries({ queryKey: todosOptions.queryKey, exact: true }) 等形态均被测试确认为合法。
识别机制:只有 @tanstack/*-query 的导入才会被检查
该规则不会盲扫所有 useQuery 调用。它由通用包装器 detectTanstackQueryImports 包裹:
- 只有当
ImportDeclaration的模块名startsWith('@tanstack/')且endsWith('-query')(如@tanstack/react-query、@tanstack/vue-query、@tanstack/solid-query等)时,该声明的所有具名导入才会进入识别集合; - 规则内部再通过
getTanstackImportName用作用域解析把调用点标识符映射回导入的原始名称(imported.name),因此重命名导入(import { useQuery as useQ })也能被正确识别; - 对
QueryClient方法,isTanstackQueryClient还会解析调用对象的来源:来自useQueryClient()或new QueryClient()的实例才被检查,测试用例专门验证了"被局部参数遮蔽的 queryClient"和"非 TanStack 对象的 fetchQuery"均被忽略。
测试文件中还有一条明确的边界用例:从 other-library 导入的同名 useQuery 完全不受该规则影响。这一点在跨框架使用(如项目中同时存在自研数据层)时很重要——规则的作用域严格限定于 TanStack Query 官方包。
如何接入:配置与适用前提
在 Flat Config 项目中,推荐直接复用插件内置预设。由于该规则属于 strict 档,需要选择 flat/recommended-strict 预设(或在 flat/recommended 基础上手动加一条规则):
import query from '@tanstack/eslint-plugin-query'
export default [
{
plugins: {
'@tanstack/query': query,
},
rules: {
'@tanstack/query/prefer-query-options': 'error',
},
},
]
在 Legacy(eslintrc)配置中则对应 query.configs.recommendedStrict。
适用前提与限制:
- 该规则依赖运行 ESLint 的文件中显式存在来自
@tanstack/*-query包的import语句,通过 import 识别确定作用域; - 规则不接受选项(
schema: []),无法按文件粒度豁免某个 Hook 时只能借助// eslint-disable注释或配置块级覆盖; - 从
type: 'problem'与文档的 "When Not To Use It" 部分看,如果你不希望代码库强制queryOptions风格(例如大量既有代码内联选项、迁移成本过高),就不需要开启这条规则; - 文档 Attributes 标注:✅ Recommended (strict) 勾选、🔧 Fixable 未勾选,即当前没有自动修复能力,报告后需要手动将内联选项抽取到
queryOptions/infiniteQueryOptions中。
小结
prefer-query-options 是 TanStack Query ESLint 插件 strict 预设中最具架构导向的一条规则:它通过 preferQueryOptions 与 preferQueryOptionsQueryKey 两个报告消息,把"key 与 fetch 函数同处定义"和"key 单一事实来源"变成可静态检查的约束。其检查面覆盖全部查询/预取 Hook、批量 Hook、useIsFetching 过滤器以及 QueryClient 的选项类、key 类、过滤器类方法,识别机制严格限定在 @tanstack/*-query 导入之上。对于以 queryOptions 为中心组织数据层的项目,开启它能在代码评审之前就把 key 漂移这类运行时隐患挡在 lint 阶段。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00