首页
/ TanStack Query Angular 的 queryOptions:以类型安全的方式共享与复用查询配置

TanStack Query Angular 的 queryOptions:以类型安全的方式共享与复用查询配置

2026-09-07 15:33:20作者:宣海椒Queenly

本文面向在 Angular 项目中使用 @tanstack/angular-query-experimental 的开发者,围绕 queryOptions 官方参考文档 展开,结合其仓库实现(query-options.ts)讲解这一类型辅助函数的用途、三组重载签名、类型标注机制与实战用法。读完本文你将理解 queryOptions 为什么是「零运行时成本」的纯类型工具,掌握如何用它把 queryKey 与 queryFn 的数据类型绑定在一起,让 injectQuery、getQueryData 等 API 获得精确的类型推断。

一、queryOptions 是什么,解决了什么问题

queryOptions@tanstack/angular-query-experimental 导出的一个类型辅助函数(官方文档原话:Allows sharing and re-using query options in a type-safe way),核心能力可概括为两句话:

  • 允许把一份 query 配置(queryKey、queryFn、以及各类选项)抽出来,在多个地方安全地共享与复用;
  • 它会用 queryFn 的返回类型去「标记(tag)」配置中的 queryKey,让 queryKey 本身携带数据类型的元信息。

这一机制解决的是 Angular 应用中很常见的一类痛点:当配置被抽离到 Service 或常量文件后,queryKeyqueryFn 往往失去绑定关系,导致在调用 queryClient.getQueryData(queryKey)injectQuery 等接口时,TypeScript 无法再推导出查询数据的精确类型,只能退回到 unknown 或手动 as 断言。

官方文档给出的最小示例即直观展示了这一能力:

const { queryKey } = queryOptions({
  queryKey: ['key'],
  queryFn: () => Promise.resolve(5),
  //  ^?  Promise<number>
})

const queryClient = new QueryClient()
const data = queryClient.getQueryData(queryKey)
//    ^?  number | undefined

可以看到:queryKey 从 queryOptions 取出后,getQueryData 无需额外传泛型就能推断出数据为 number | undefined,而不是笼统的 unknown

二、核心机制:为什么 queryKey 会「记得」数据的类型

queryOptions 本身不是运行时逻辑。翻看它的实现(query-options.ts:169),函数体只有一行:

export function queryOptions(options: unknown) {
  return options
}

也就是说它在运行时原封不动地返回传入对象。其类型标记能力完全建立在 TypeScript 类型层之上,依据如下(均为仓库内可查证的源码事实):

  1. query-options.ts:61-63,query-core 导入了 dataTagSymboldataTagErrorSymbol 两个用于标记的 symbol;
  2. query-core/src/types.ts:71-80 定义了 DataTag<TType, TValue, TError>,通过 TType & { [dataTagSymbol]: TValue; [dataTagErrorSymbol]: TError } 把值与错误类型「打」在类型上;
  3. query-core/src/types.ts:82-88QueryKeyWithDataTag 将标记作用到 queryKey 字段:{ queryKey: DataTag<TQueryKey, TQueryFnData, TError> }
  4. 反过来,query-core/src/types.ts:90-100InferDataFromTag / InferErrorFromTag 会从已标记的 queryKey 中反解出数据与错误类型,供 getQueryData 等 API 使用。

因此可以这样理解「标记」的完整链路:queryOptions 返回类型里将 queryKey 与被 queryFn 推导出的 TQueryFnData/TError 绑定;当 queryKey 被传入 getQueryData/injectQuery 等消费端时,消费端类型通过 DataTag 反解出绑定类型。两个 symbol 都是普通的全局唯一 Symbol 常量,并不参与真实对象键值,所以不会在运行时污染 queryKey。

三、调用签名与三组重载的取舍

与 React Query 等适配层不同,Angular 版 queryOptions 在 query-options.ts 中声明了三个重载(Call Signature),分别对应三类不同的 initialData / queryFn 形态:

签名一:DefinedInitialDataOptions(initialData 必填且非 undefined)

query-options.ts:76-84

function queryOptions<TQueryFnData, TError, TData, TQueryKey>(
  options: DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey>,
): DefinedInitialDataOptions<TQueryFnData, TError, TData, TQueryKey> &
  QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>

签名二:UnusedSkipTokenOptions(queryFn 排除了 skipToken)

query-options.ts:107-115:该分支移除 queryFn 中使用 skipToken 的可能,用于配置明确会发起请求、queryKey 必须「有用」的场景。

签名三:UndefinedInitialDataOptions(通用形态,initialData 允许为 undefined)

query-options.ts:138-146:普通、无特殊约束的 query 配置,数据可能为 undefined

三个分支的类型实体同样定义在本文件中:

  • UndefinedInitialDataOptionsquery-options.ts:13-23):initialData 可缺省,也可以是 InitialDataFunction<NonUndefinedGuard<TQueryFnData>> 或非 undefined 的值;
  • UnusedSkipTokenOptionsquery-options.ts:25-38):通过 OmitKeyof<CreateQueryOptions, 'queryFn'> 去掉原 queryFn 类型,再收窄为排除了 SkipToken | undefined 的版本;
  • DefinedInitialDataOptionsquery-options.ts:40-53):initialData 为必填,其取值必须是 NonUndefinedGuard<TQueryFnData>(保证类型不为 undefined),使查询结果可被推断为「已定义」。

泛型参数的默认值

四个泛型参数在三个签名中保持一致(query-options.ts:76-80):

泛型 默认值 含义
TQueryFnData unknown queryFn 返回的原始数据
TError DefaultError 默认错误类型,未自定义 Register 时为 Error(见 query-core/src/types.ts:45-49
TData TQueryFnData 经过 select 等变换后的最终数据
TQueryKey readonly unknown[] 要求 queryKey 是只读的 unknown 数组(extends readonly unknown[])

底层 options 类型来自 CreateQueryOptions,它继承自 query-core 的 QueryObserverOptions,并剔除了 'suspense' 字段,覆盖 queryKey、queryFn、staleTime、gcTime、refetchInterval、enabled、retry、placeholderData 等常规选项,具体字段与默认值可进一步查阅 query-core 源码中的类型定义

四、返回值:被标记的 queryKey

无论走哪个重载,返回值都满足同一条规律——在传入 options 的基础上叠加 QueryKeyWithDataTag

  • 签名一返回 DefinedInitialDataOptions & QueryKeyWithDataTag
  • 签名二返回 UnusedSkipTokenOptions & QueryKeyWithDataTag
  • 签名三返回 UndefinedInitialDataOptions & QueryKeyWithDataTag

因此返回对象里能被立即安全解构的核心字段是 queryKey(解构出的 queryKey 已带类型标签)。对返回结果的其他字段(如 queryFn、staleTime 等)原样保留,可继续传给 injectQueryinjectQueries 消费。

五、运行时行为:零开销的恒等函数

尽管文档与类型层面讨论繁多,运行时行为却极为朴素。仓库测试文件 query-options.test.ts:5-13 中明确定义并验证了这一契约:

describe('queryOptions', () => {
  it('should return the object received as a parameter without any modification.', () => {
    const object: CreateQueryOptions = {
      queryKey: ['key'],
      queryFn: () => Promise.resolve(5),
    } as const

    expect(queryOptions(object)).toBe(object)
  })
})

测试名称即契约:queryOptions 原封不动返回传入对象(恒等),不产生任何包装或副本。这意味着:

  • 没有额外内存/对象创建开销,可放心用于「每次渲染重建 options」的写法;
  • 它纯靠类型标注实现能力,同一测试目录下还有配套的类型级断言(.test-d.ts 测试族),从编译期锁定推断结果;
  • injectQuery 这类依赖 Angular 注入的运行时代理不同,queryOptions 是一个无副作用的纯类型辅助函数,可在模块顶层、Service、class 属性等任意位置安全定义。

从源码结构看,这也解释了它为什么在包导出中被独立列出:index.ts:8-13queryOptions 连同 DefinedInitialDataOptionsUndefinedInitialDataOptionsUnusedSkipTokenOptions 三个类型一并对外导出,作为「预定义 options」基础设施的一部分。同族的兄弟函数还有面向数据变更的 mutationOptions 与面向无限查询的 infiniteQueryOptions,二者在官方参考文档中均有对应条目(mutationOptionsinfiniteQueryOptions)。

六、典型实战用法

1. 在 Service 中集中定义并共享 options

将 queryOptions 与「从 Service 提取 options」的模式结合(对应仓库示例目录 examples/angular/query-options-from-a-service):

// todos.options.ts
import { queryOptions } from '@tanstack/angular-query-experimental'

export const todoOptions = {
  list: () =>
    queryOptions({
      queryKey: ['todos'],
      queryFn: () => fetchTodos(), // 假定返回 Promise<Todo[]>
    }),
  detail: (id: number) =>
    queryOptions({
      queryKey: ['todos', id],
      queryFn: () => fetchTodo(id),
    }),
}

2. 共享给 injectQuery / getQueryData 消费

配置在组件里被 injectQuery 消费,同时可被其它位置用同一份 queryKey 读取缓存:

import { Component, inject } from '@angular/core'
import { injectQuery, injectQueryClient } from '@tanstack/angular-query-experimental'
import { todoOptions } from './todos.options'

@Component({ ... })
export class TodosComponent {
  private queryClient = injectQueryClient()

  readonly todosQuery = injectQuery(() => todoOptions.list())

  // 无需重复写泛型,queryKey 携带了数据与错误类型,
  // 非响应式读取路径同样能获得精确推断
  private debugRead() {
    const data = this.queryClient.getQueryData(todoOptions.list().queryKey)
    //    ^? Todo[] | undefined
  }
}

这里的要点是:injectQuery 要求传工厂函数(每次调用重建 options 以保持响应性),而 queryOptions 的恒等特性恰好保证了工厂每次返回的对象即我们集中定义的同一份配置,类型与运行时都能保持同步。

3. 与 as const / 数组 key 结合

TQueryKey extends readonly unknown[] 意味着推荐使用 as const 或字面量数组来写 key,例如 queryKey: ['todo', id] as const,这能进一步提升 key 结构在类型层的精确程度,并在 key 拼写变化时获得编译期提示。

七、使用注意事项

  • 只做类型标记,不做默认值合并:queryOptions 不负责把 staleTime、retry 等选项与全局默认值合并,那是 QueryClient 初始化时的职责;它只是让「一份配置可以被安全地共享与复用」这一模式在类型层变得可用。
  • 三个重载由 TypeScript 依据 options 形态自动选择:不必手动指定走哪一分支;当配置含必填的 initialData 时命中 DefinedInitialDataOptions,会获得数据不为 undefined 的更精确推断,反之(数据可能为 undefined)则命中 UndefinedInitialDataOptions 分支。
  • Angular 版与 React 版机制一致,但使用形态有差异:本仓库中对应包为 angular-query-experimental,其框架文档见 docs/framework/angular,配合 injectQuery、injectQueries 等相关 API 使用即可发挥完整价值。

结语

queryOptions 是一个把「类型安全」贯彻到查询配置共享场景的轻量设计:运行时零成本(恒等返回原对象),类型层用 DataTag 符号体系把 queryKey 与 queryFn 的数据/错误类型牢牢绑定,让 Angular 项目中的 Service 层、组件层与 QueryClient 工具方法共享同一份类型可信的查询配置。在大型 Angular 应用中,它是组织查询配置、消除 unknown、减少手工断言的有效抓手。

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