Sentry 前端数据获取实战:TanStack Query 与 apiOptions 的完整使用指南
在 Sentry 的前端代码库(static/ 目录)中,所有 React 组件调用后端 API 的标准方式是:用 apiOptions 工厂构造 TanStack Query 的 options 对象,再交给 useQuery / useInfiniteQuery 消费;写操作则配合 fetchMutation 与 useMutation。读完本文,你将掌握这套数据获取模式的基本用法、条件请求(skipToken)、响应头读取(分页 / 命中计数)、TanStack Query 的类型推断规则,以及 apiOptions 在源码层的 queryKey 构造、缓存结构与测试验证细节。
为什么是 apiOptions 而不是 useQuery 裸写
Sentry 的 API 端点路径是受类型系统约束的:apiOptions 的第二个参数只能是 KnownSentryApiUrls 或 KnownGetsentryApiUrls 联合类型中已知的 URL 模板(类型定义见 knownSentryApiUrls.generated.ts 与 knownGetsentryApiUrls.ts)。这意味着端点写错、路径参数缺漏都会在编译期被拦住,而不是等到运行时才 404。
规范文档(SKILL.md)开篇就明确了迁移基线:
使用
apiOptions搭配 TanStack Query 的useQuery。不要使用useApiQuery、getApiQueryData、setApiQueryData—— 它们已被废弃。
基本用法与条件请求
最简用法:apiOptions.as<ResponseType>() 指定响应体类型,第一个参数是 API 路径模板,第二个参数必须包含 staleTime:
import {skipToken, useQuery} from '@tanstack/react-query';
import {apiOptions} from 'sentry/utils/api/apiOptions';
// Basic usage
const query = useQuery(
apiOptions.as<ResponseType>()('/organizations/$organizationIdOrSlug/endpoint/', {
path: {organizationIdOrSlug: organization.slug},
staleTime: 30_000,
})
);
当请求所依赖的 ID 尚未确定时(例如路由参数还在加载中),不需要手写 enabled 判断——直接把 skipToken 作为 path 传入即可整体禁用该查询:
// Conditional fetching — pass skipToken as path to disable the query
const query = useQuery(
apiOptions.as<ResponseType>()('/organizations/$organizationIdOrSlug/items/$itemId/', {
path: itemId ? {organizationIdOrSlug: organization.slug, itemId} : skipToken,
staleTime: 30_000,
})
);
从源码看(apiOptions.ts),skipToken 的作用机制是:queryFn 本身被替换为 skipToken,同时 enabled: pathParams !== skipToken 被设为 false,查询既不发请求也不进入缓存污染。测试用例 apiOptions.spec.tsx 验证了这一行为:path 为 skipToken 时,生成的 queryKey 中 URL 仍保留未替换的 $tokenId 占位符,而 queryFn 就是 skipToken 本身。
四条硬性规则
规范文档将以下规则列为必须遵守的 Key rules:
staleTime是必填项——必须显式选择一个值:0、毫秒数字、Infinity或'static'。源码中Options类型定义为QueryKeyEndpointOptions & {staleTime: number | 'static'}(apiOptions.ts),漏掉会在编译期报错;apiOptions.spec.tsx 中有专门的@ts-expect-error测试固化了这个约束。- 基于
apiOptions做抽象,而不是基于useQuery做抽象。上层封装应返回 options 对象,让调用方自行选择传给useQuery、useQueries、prefetchQuery等任意入口——这是该 API 设计为“options 工厂”而非 hook 的根本原因。 - 缓存里存的是
{json, headers},而不只是响应体。apiOptions默认用select抽取.json,但getQueryData、setQueryData、retry函数与predicate回调拿到的都是原始ApiResponse<T>结构。 - 永远不要用
api.requestPromise作为 Query 的 queryFn——它返回的结构不对。如果必须手写queryFn,请使用apiFetch。
类型推断铁律:绝不在调用点传泛型
规范文档用一整节强调:永远不要给 useQuery、useMutation、mutationOptions、queryOptions 等 TanStack Query 函数在调用点传类型参数。类型应当从你的 queryFn / mutationFn 及其回调自然推断出来。传调用点泛型会让推断失效、掩盖真实 bug,并带来额外维护成本:
// ❌ NEVER pass generics to useQuery, useMutation, mutationOptions, etc.
useMutation<ResponseType, RequestError, Variables, Context>({...})
mutationOptions<ResponseType, RequestError, Variables, Context>({...})
useQuery<ResponseType, RequestError>({...})
// ✅ Let types be inferred — annotate the mutationFn/queryFn instead
useMutation({
mutationFn: (variables: MyVariables) =>
fetchMutation<MyResponse>({...}),
})
具体展开为五条细则:
- 给
mutationFn的参数写类型,而不是给 hook 本身传泛型。variables 类型会从mutationFn的签名流出。 - 用
fetchMutation<T>标注返回值——fetchMutation的泛型是正确的,因为它标注的是 API 响应。对照实现(queryClient.tsx),fetchMutation接受{method, url, data?, options?}形式的 variables,内部调用QUERY_API_CLIENT.requestPromise并返回Promise<TResponseData>,可以直接作为mutationFn使用。 - 永远不要把 error 泛型写成
RequestError——那是一种伪装的类型断言。错误默认就是Error,需要RequestError专有属性时用运行时收窄(if (error instanceof RequestError))。 - 永远不要显式标注 context 类型——它由
onMutate的返回值推断。单独造一个type FooContext = {...}再当泛型传入是多余动作。 - query 侧同理——
useQuery、queryOptions、useInfiniteQuery的类型都从queryFn和select流出。
对比示例,左边是错误写法,右边是全部推断的正确写法:
// ❌ Explicit context type + error assertion
type MyContext = {previousData: Item[]};
mutationOptions<Item, RequestError, UpdateItemVars, MyContext>({
mutationFn: variables => fetchMutation({...}),
onMutate: async () => {
const previousData = queryClient.getQueryData(itemQueryOptions);
return {previousData};
},
onError: (_error, _variables, context) => {
queryClient.setQueryData(key, context?.previousData);
},
})
// ✅ Everything is inferred
mutationOptions({
mutationFn: (variables: UpdateItemVars) =>
fetchMutation<Item>({...}),
onMutate: async () => {
const previousData = queryClient.getQueryData(itemQueryOptions);
return {previousData};
},
onError: (_error, _variables, context) => {
// context type is inferred from onMutate return
queryClient.setQueryData(key, context?.previousData);
},
})
读取响应头:分页 Link 与 X-Hits 计数
默认情况下 apiOptions 的 select 只从响应中抽取 JSON body。如果 UI 需要响应头——比如 Link 头做游标分页、X-Hits / X-Max-Hits 显示总命中数——用 selectJsonWithHeaders 覆盖 select:
import {useQuery} from '@tanstack/react-query';
import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions';
const {data} = useQuery({
...apiOptions.as<Item[]>()('/organizations/$organizationIdOrSlug/items/', {
path: {organizationIdOrSlug: organization.slug},
query: {cursor, per_page: 25},
staleTime: 0,
}),
select: selectJsonWithHeaders,
});
// data is ApiResponse<Item[]> — an object with `json` and `headers`
const items = data?.json ?? [];
const pageLinks = data?.headers.Link; // string | undefined
const totalHits = data?.headers['X-Hits']; // number | undefined
const maxHits = data?.headers['X-Max-Hits']; // number | undefined
注意 X-Hits 和 X-Max-Hits 已经是解析好的 number | undefined,不需要再 parseInt。这一点可以在取数层源码中验证:apiFetch.tsx 里 apiFetch 调用 QUERY_API_CLIENT.requestPromise(includeAllArgs: true 以拿到原始 Response 对象),随后对响应头做了类型收敛:
'X-Hits': typeof hits === 'string' ? Number(hits) : undefined,
'X-Max-Hits': typeof maxHits === 'string' ? Number(maxHits) : undefined,
对应的 ApiResponse<T> 类型也在这里定义,headers 只暴露 Link、X-Hits、X-Max-Hits、X-Sentry-Direct-Hit 四个字段(apiFetch.tsx)。apiOptions.spec.tsx 中的 “should extract headers” 测试进一步用 expectTypeOf 固化了这一点:mock 响应头 'X-Hits': '14' 经查询后 data.headers['X-Hits'] 是数值 14。
源码透视:apiOptions 到底做了什么
apiOptions.ts 的核心是 _apiOptions 工厂,它返回标准 TanStack Query queryOptions,结构上有四个关键点:
return queryOptions({
queryKey: [url, strippedOptions, {infinite: false}] as const,
queryFn: pathParams === skipToken ? skipToken : apiFetch<TActualData>,
enabled: pathParams !== skipToken,
staleTime,
select: selectJson,
});
- queryKey 是三段式:
[最终 URL, 去除 undefined 后的请求选项, {infinite: false}]。URL 由getApiUrl(getApiUrl.ts)从路径模板 +path参数渲染而来。{infinite: false}标志让同一端点的普通查询与无限查询天然拥有不同 key,互不污染缓存。 stripUndefinedValues递归剔除 undefined(apiOptions.ts)。这样query: {cursor: undefined, per_page: 25}与query: {per_page: 25}会命中同一个缓存槽位——选项里没有定义过的字段不会改变 queryKey 身份。apiOptions.spec.tsx 的 “strips undefined top-level/deep values” 测试覆盖了顶层和深层两种情形。- 默认
select: selectJson即(data: ApiResponse<TData>) => data.json,所以useQuery拿到的data直接是响应体;而缓存本体始终保留完整ApiResponse<T>,这正是 Key rule 第 3 条的来源。 as<TManualData>是手动数据类型逃生口。源码注释里有一条todo: infer the actual data type from the ApiMapping,说明类型目前由as<>手动指定(TActualData = TManualData),这也是为什么文档要求调用时总是写apiOptions.as<ResponseType>()。
无限滚动:asInfinite 变体
对于游标分页列表,apiOptions 提供了 asInfinite 入口(apiOptions.ts),它返回 infiniteQueryOptions,且分页方向完全由响应头驱动:
getPreviousPageParam: parsePageParam('previous'),
getNextPageParam: parsePageParam('next'),
initialPageParam: undefined,
parsePageParam 从 ApiResponse.headers.Link 里解析 RFC 5988 风格的 Link 头,取 previous/next 对应的 results 作为下一页游标,取不到就返回 null 停止翻页。取数则由 apiFetch.tsx 的 apiFetchInfinite 完成,它会把 context.pageParam?.cursor 注入 query.cursor。同文件中的 useFetchAllPages hook(apiFetch.tsx)则封装了“hasNextPage 时自动 fetchNextPage()”的拉全量场景。
测试用例给出的行为边界
apiOptions.spec.tsx 除了上文提到的用例外,还固化了若干容易踩坑的行为:
- 路径参数编码:
version: 'v 1.0.0'会渲染为/organizations/my-org/releases/v%201.0.0/(URL 编码自动完成); - 数字参数自动字符串化:
path: {tokenId: 123}得到/api-tokens/123/; - 不做意外前缀替换:模板
'$id1/$id'传入{id: '123', id1: '456'}时只替换完整的$id1与$id,不会把$id1误当成$id的前缀; - 无参端点不允许传
path:/api-tokens/上写path: {}会触发类型错误({path?: never}约束,见 apiOptions.ts); - 未知端点默认
never:apiOptions.as<never>()('/unknown/$param/')会因路径不在KnownApiUrls联合中而报编译错误——这是“端点拼写错误即刻暴露”的实现基础。
实践清单
在 Sentry 前端新增或修改 API 调用时,可按以下清单自检:
| 场景 | 正确做法 | 反模式 |
|---|---|---|
| 读数据 | useQuery(apiOptions.as<T>()(path, {path, staleTime})) |
直接 api.requestPromise 塞进 queryFn |
| 依赖未就绪 | path: id ? {...} : skipToken |
手写 enabled: !!id |
需要 Link / X-Hits |
覆盖 select: selectJsonWithHeaders |
对 header 手动 parseInt |
| 游标无限滚动 | apiOptions.asInfinite<T>() + useInfiniteQuery |
手动维护 cursor 状态 |
| 写数据 | useMutation({mutationFn: (v: Vars) => fetchMutation<T>(...)}) |
给 useMutation 传调用点泛型 |
| 复用 | 返回 options 对象,基于 apiOptions 建抽象 |
基于 useQuery 包一层 hook |
| 错误收窄 | if (error instanceof RequestError) |
error 泛型写死 RequestError |
所有规则的第一手依据可对照 SKILL.md,实现与行为验证分别在 apiOptions.ts、apiFetch.tsx、queryClient.tsx 与 apiOptions.spec.tsx 中。
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