首页
/ TanStack Query Angular 实战:用 queryOptions 集中管理并类型安全地复用查询配置

TanStack Query Angular 实战:用 queryOptions 集中管理并类型安全地复用查询配置

2026-09-06 11:55:33作者:邬祺芯Juliet

本文围绕 TanStack Query 仓库中的 Angular 指南 Query Options 展开,讲解如何在 @tanstack/angular-query-experimental 中用 queryOptions 把查询配置(queryKey + queryFn 等)抽取为可复用、类型安全的共享对象,并深入源码说明其重载类型设计、queryKey 数据标签机制,以及如何与 injectQueryQueryClient 配合完成组件内外的一致消费。读完本文,你将掌握在 Angular 应用中建立"查询配置服务层"的完整模式:从服务内集中定义,到组件响应式消费,再到组件外通过 QueryClient 读写缓存,全部保持编译期类型安全。

一、queryOptions 解决什么问题

在没有 queryOptions 之前,同一个查询的 queryKeyqueryFn 往往要在多个组件中重复书写;而一旦你在某处手写 ['post', postId] 这个 key,TypeScript 无法知道它对应的是什么数据结构,getQueryDatasetQueryData 等操作也就退化为 unknown

queryOptions 的定位就是:允许以类型安全的方式共享和复用查询配置,并且会把 queryKey 打上来自 queryFn 返回值的类型标签(源码 JSDoc 原文:"The queryKey will be tagged with the type from queryFn",见 query-options.ts)。

值得注意的是,从源码看它的运行时实现极其简单:

// packages/angular-query-experimental/src/query-options.ts
export function queryOptions(options: unknown) {
  return options
}

它是一个纯粹的类型层函数,运行时原样返回传入对象(对应测试断言 expect(queryOptions(object)).toBe(object),见 query-options.test.ts),没有任何运行时开销。它的价值全部体现在 TypeScript 的类型重载与 queryKey 标签上,下文第四节展开。

二、服务模式:在 QueriesService 中集中定义查询配置

指南给出的核心示例是把查询配置集中到一个 @Injectable 服务中,仓库中的示例工程 query-options-from-a-service 完整实现了该模式(运行方式见其 READMEpnpm installpnpm start)。服务实现见 queries-service.ts

import { Injectable, inject } from '@angular/core'
import { lastValueFrom } from 'rxjs'
import { queryOptions } from '@tanstack/angular-query-experimental'
import { HttpClient } from '@angular/common/http'

export interface Post {
  id: number
  title: string
  body: string
}

@Injectable({
  providedIn: 'root',
})
export class QueriesService {
  private readonly http = inject(HttpClient)

  post(postId: number) {
    return queryOptions({
      queryKey: ['post', postId],
      queryFn: () => {
        return lastValueFrom(
          this.http.get<Post>(
            `https://jsonplaceholder.typicode.com/posts/${postId}`,
          ),
        )
      },
    })
  }

  posts() {
    return queryOptions({
      queryKey: ['posts'],
      queryFn: () =>
        lastValueFrom(
          this.http.get<Array<Post>>(
            'https://jsonplaceholder.typicode.com/posts',
          ),
        ),
    })
  }
}

几个要点:

  • 参数化 keypost(postId) 以方法参数构造 queryKey,同一个配置工厂可以服务任意文章 ID,key 的粒度与参数一一对应,天然获得按 ID 的缓存隔离。
  • RxJS 到 Promise 的桥接HttpClient 返回 ObservablelastValueFrom 将其转换为 Promise<Post>,符合 queryFn 的约定。
  • 类型推断自动完成http.get<Post>(...) 的泛型参数经 queryFn 一路推断到 queryOptions 的返回类型,post(1) 返回的配置对象中 queryKey 已被标注为"数据结构为 Post",调用方无需任何显式类型标注。

三、组件内消费:input.required + injectQuery

指南示例的第二部分展示了组件如何消费服务中的配置,并顺带演示了与 QueryClient 的交互:

// 文档示例(docs/framework/angular/guides/query-options.md)
postId = input.required({
  transform: numberAttribute,
})
queries = inject(QueriesService)

postQuery = injectQuery(() => this.queries.post(this.postId()))

queryClient.query(this.queries.post(23)).catch(noop)
queryClient.setQueryData(this.queries.post(42).queryKey, newPost)

仓库示例中 post.component.ts 是这份代码的完整落地:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'post',
  templateUrl: './post.component.html',
  imports: [RouterLink],
})
export default class PostComponent {
  private readonly queries = inject(QueriesService)

  readonly postId = input.required({
    transform: numberAttribute,
  })

  readonly postQuery = injectQuery(() => this.queries.post(this.postId()))
}

这里有三个值得展开的细节:

  1. injectQuery 接收的是函数() => this.queries.post(this.postId())。从 inject-query.ts 的 JSDoc 可以看到,传入的函数"会在响应式上下文中运行(will be run in the reactive context)",类似于 computed:当 postId 信号变化时,工厂函数重新求值,queryKey 随之变化,查询自动切换到新 key 并复用缓存。因此 input 信号必须带 () 调用(文档示例中的 this.postId 缺调用,请以示例工程为准)。
  2. input.required({ transform: numberAttribute }):模板传入的是字符串属性,numberAttribute 转换器把它变成数字后进入 queryKey,避免 ['post', '1']['post', 1] 这类 key 不一致问题。
  3. 结果全是信号injectQuery 返回的 CreateQueryResultdataisLoadingerror 等字段均为 Signal(由 types.ts 中的 MapToSignals 映射实现),模板可直接写 postQuery.data(),配合 OnPush 变更检测策略。

列表组件 posts.component.ts 则展示了最简单的消费形态——injectQuery(() => this.queries.posts()),一行即完成订阅,同时 inject(QueryClient) 表明缓存客户端本身也可注入,供组件外场景使用。

四、源码剖析:三重载与 queryKey 数据标签

queryOptions 之所以能做到"零运行时成本却全程类型安全",关键在于 query-options.ts 中基于 initialDataqueryFn 形态拆分的三个重载:

重载类型 适用条件 关键约束
DefinedInitialDataOptions 提供了非 undefinedinitialData queryFn 变为可选;调用方拿到"defined"结果,data 信号不会是 undefined
UnusedSkipTokenOptions queryFn 使用了 skipToken(禁用查询) queryFn 被收窄为排除 SkipToken 的真实函数;结果 dataunknown
UndefinedInitialDataOptions 常规场景 queryFn 必填;initialData 可以是值、函数或 undefined(函数形式允许返回 undefined,用于"条件性初始数据")

三个重载的返回值都叠加了同一个标签类型:

DefinedInitialDataOptions<...> & QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>

QueryKeyWithDataTag 通过 query-coredataTagSymbol 把一个"不可见"的符号属性挂在 queryKey 上,其值就是 queryFn 的返回数据结构。类型测试文件 query-options.test-d.ts 直接验证了这一机制:

const { queryKey: tagged } = queryOptions({
  queryKey: key,
  queryFn: () => Promise.resolve(5),
})
assertType<number>(tagged[dataTagSymbol])

正是这个标签让第二节的"组件外读写缓存"成为可能:

const { queryKey } = queryOptions({
  queryKey: ['key'],
  queryFn: () => Promise.resolve(5),
})
const data = queryClient.getQueryData(queryKey) // ^? number | undefined
queryClient.setQueryData(queryKey, (prev) => prev) // prev: number | undefined

类型测试进一步确认:getQueryData 返回 number | undefinedL163-L174);setQueryData 传入错误类型的值(如字符串 '5')会直接触发编译错误(L192-L209)。此外,若配置中没有 queryFn,标签会退化为 unknownL143-L150),即未声明数据来源的 key 只能以 unknown 访问缓存,这是有意为之的保守设计。

queryOptions 返回的配置对象还可以直接喂给 QueryClient 的实例方法,类型测试覆盖了这些用法(见 query-options.test-d.ts):

  • new QueryClient().fetchQuery(options)Promise<number>
  • new QueryClient().query(options)Promise<number>,且 select 生效(select: (data) => data.toString() 时返回 Promise<string>
  • 配合 enabled: falsequeryFn: skipToken 时类型行为正确(skipToken 场景下结果为 Promise<unknown>

这说明"配置即对象"的抽象在组件(injectQuery)与组件外(fetchQuery/query/getQueryData/setQueryData)是统一且可互换的。

五、类型推断与配置覆盖:在共享配置上扩展 select

指南的第二个示例展示了共享配置的一个重要特性:调用点可以展开并覆盖字段,且类型推断仍然成立

// Type inference still works, so query.data will be the return type of select instead of queryFn
queries = inject(QueriesService)

query = injectQuery(() => ({
  ...groupOptions(1),
  select: (data) => data.title,
}))

由于 queryOptions 返回的就是普通选项对象(运行时 return options),在调用点用对象展开合并再传入 injectQuery 是完全合法的;select 的类型会沿着重载传导——query.data 信号的类型是 select 的返回类型(data.titlestring),而不是 queryFn 的原始类型。这一推断链有类型测试佐证:

const options = queryOptions({
  queryKey: ['key'],
  queryFn: () => Promise.resolve(5),
  select: (data) => data.toString(),
})
const data = new QueryClient().query(options)
assertType<Promise<string>>(data)

(见 query-options.test-d.ts

需要注意的边界是:queryKey 上的数据标签仍然基于 queryFn 的返回类型而非 select 的类型,类型测试 should tag the queryKey with the result type of the QueryFn if select is usedL152-L161)确认标签值为 number 而非 string——即缓存层存的始终是原始数据,select 只影响观察层。

另外,CreateQueryOptions 的基础类型继承自 QueryObserverOptions(去掉 suspense 字段,见 types.ts),因此 staleTimerefetchIntervalenabledretryplaceholderData 等所有观察者选项都可以在服务配置中或调用点覆盖时直接使用;同时类型测试 should not allow excess properties 确认了选项对象会进行精确的属性检查,不会出现拼写错误的静默忽略。

六、实践建议与相关文件

结合上述源码与测试,给出几条可直接落地的实践建议:

  • 一个服务按实体域聚合配置:如 QueriesService.post(postId) / posts() 的写法,key 构造与请求逻辑同址维护,避免 key 漂移。
  • 组件内用 injectQuery(() => service.method(deps)):把输入信号作为工厂参数传入,保证响应式重取;组件外需要"触发一次查询并拿到 Promise"时用 queryClient.query(options)fetchQuery(options),并记得 .catch(noop) 之类的错误处理(文档示例即演示了 queryClient.query(this.queries.post(23)).catch(noop))。
  • 预热缓存走 setQueryData(options.queryKey, ...):带标签的 queryKey 保证写入值与 queryFn 返回类型一致(文档示例 queryClient.setQueryData(this.queries.post(42).queryKey, newPost))。
  • 适用前提:本文基于 @tanstack/angular-query-experimental 包的当前仓库实现,示例工程使用 Angular 的 injectinput 等新 API;具体版本能力以仓库中 packages/angular-query-experimental/package.json 声明为准。

本文涉及的关键文件:

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