首页
/ Sentry 前端数据获取实战:TanStack Query 与 apiOptions 的完整使用指南

Sentry 前端数据获取实战:TanStack Query 与 apiOptions 的完整使用指南

2026-09-05 20:33:53作者:齐冠琰

在 Sentry 的前端代码库(static/ 目录)中,所有 React 组件调用后端 API 的标准方式是:用 apiOptions 工厂构造 TanStack Query 的 options 对象,再交给 useQuery / useInfiniteQuery 消费;写操作则配合 fetchMutationuseMutation。读完本文,你将掌握这套数据获取模式的基本用法、条件请求(skipToken)、响应头读取(分页 / 命中计数)、TanStack Query 的类型推断规则,以及 apiOptions 在源码层的 queryKey 构造、缓存结构与测试验证细节。

为什么是 apiOptions 而不是 useQuery 裸写

Sentry 的 API 端点路径是受类型系统约束的:apiOptions 的第二个参数只能是 KnownSentryApiUrlsKnownGetsentryApiUrls 联合类型中已知的 URL 模板(类型定义见 knownSentryApiUrls.generated.tsknownGetsentryApiUrls.ts)。这意味着端点写错、路径参数缺漏都会在编译期被拦住,而不是等到运行时才 404。

规范文档(SKILL.md)开篇就明确了迁移基线:

使用 apiOptions 搭配 TanStack Query 的 useQuery不要使用 useApiQuerygetApiQueryDatasetApiQueryData —— 它们已被废弃。

基本用法与条件请求

最简用法: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 验证了这一行为:pathskipToken 时,生成的 queryKey 中 URL 仍保留未替换的 $tokenId 占位符,而 queryFn 就是 skipToken 本身。

四条硬性规则

规范文档将以下规则列为必须遵守的 Key rules:

  1. staleTime 是必填项——必须显式选择一个值:0、毫秒数字、Infinity'static'。源码中 Options 类型定义为 QueryKeyEndpointOptions & {staleTime: number | 'static'}apiOptions.ts),漏掉会在编译期报错;apiOptions.spec.tsx 中有专门的 @ts-expect-error 测试固化了这个约束。
  2. 基于 apiOptions 做抽象,而不是基于 useQuery 做抽象。上层封装应返回 options 对象,让调用方自行选择传给 useQueryuseQueriesprefetchQuery 等任意入口——这是该 API 设计为“options 工厂”而非 hook 的根本原因。
  3. 缓存里存的是 {json, headers},而不只是响应体apiOptions 默认用 select 抽取 .json,但 getQueryDatasetQueryDataretry 函数与 predicate 回调拿到的都是原始 ApiResponse<T> 结构。
  4. 永远不要用 api.requestPromise 作为 Query 的 queryFn——它返回的结构不对。如果必须手写 queryFn,请使用 apiFetch

类型推断铁律:绝不在调用点传泛型

规范文档用一整节强调:永远不要useQueryuseMutationmutationOptionsqueryOptions 等 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>({...}),
})

具体展开为五条细则:

  1. mutationFn 的参数写类型,而不是给 hook 本身传泛型。variables 类型会从 mutationFn 的签名流出。
  2. fetchMutation<T> 标注返回值——fetchMutation 的泛型是正确的,因为它标注的是 API 响应。对照实现(queryClient.tsx),fetchMutation 接受 {method, url, data?, options?} 形式的 variables,内部调用 QUERY_API_CLIENT.requestPromise 并返回 Promise<TResponseData>,可以直接作为 mutationFn 使用。
  3. 永远不要把 error 泛型写成 RequestError——那是一种伪装的类型断言。错误默认就是 Error,需要 RequestError 专有属性时用运行时收窄(if (error instanceof RequestError))。
  4. 永远不要显式标注 context 类型——它由 onMutate 的返回值推断。单独造一个 type FooContext = {...} 再当泛型传入是多余动作。
  5. query 侧同理——useQueryqueryOptionsuseInfiniteQuery 的类型都从 queryFnselect 流出。

对比示例,左边是错误写法,右边是全部推断的正确写法:

// ❌ 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 计数

默认情况下 apiOptionsselect 只从响应中抽取 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-HitsX-Max-Hits 已经是解析好的 number | undefined不需要再 parseInt。这一点可以在取数层源码中验证:apiFetch.tsxapiFetch 调用 QUERY_API_CLIENT.requestPromiseincludeAllArgs: true 以拿到原始 Response 对象),随后对响应头做了类型收敛:

'X-Hits': typeof hits === 'string' ? Number(hits) : undefined,
'X-Max-Hits': typeof maxHits === 'string' ? Number(maxHits) : undefined,

对应的 ApiResponse<T> 类型也在这里定义,headers 只暴露 LinkX-HitsX-Max-HitsX-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 由 getApiUrlgetApiUrl.ts)从路径模板 + path 参数渲染而来。{infinite: false} 标志让同一端点的普通查询与无限查询天然拥有不同 key,互不污染缓存。
  • stripUndefinedValues 递归剔除 undefinedapiOptions.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,

parsePageParamApiResponse.headers.Link 里解析 RFC 5988 风格的 Link 头,取 previous/next 对应的 results 作为下一页游标,取不到就返回 null 停止翻页。取数则由 apiFetch.tsxapiFetchInfinite 完成,它会把 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);
  • 未知端点默认 neverapiOptions.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.tsapiFetch.tsxqueryClient.tsxapiOptions.spec.tsx 中。

登录后查看全文
热门项目推荐
相关项目推荐