Sentry 前端数据获取实战:TanStack Query 与 apiOptions 的完整使用指南
【免费下载链接】sentryDeveloper-first error tracking and performance monitoring项目地址: https://gitcode.com/GitHub_Trending/sen/sentry
在 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 QueryqueryOptions,结构上有四个关键点:
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。同文件中的useFetchAllPageshook(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 中。
【免费下载链接】sentryDeveloper-first error tracking and performance monitoring项目地址: https://gitcode.com/GitHub_Trending/sen/sentry
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考