TanStack Query Angular 实战:用 queryOptions 集中管理并类型安全地复用查询配置
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
本文围绕 TanStack Query 仓库中的 Angular 指南 Query Options 展开,讲解如何在@tanstack/angular-query-experimental中用queryOptions把查询配置(queryKey+queryFn等)抽取为可复用、类型安全的共享对象,并深入源码说明其重载类型设计、queryKey数据标签机制,以及如何与injectQuery、QueryClient配合完成组件内外的一致消费。读完本文,你将掌握在 Angular 应用中建立"查询配置服务层"的完整模式:从服务内集中定义,到组件响应式消费,再到组件外通过QueryClient读写缓存,全部保持编译期类型安全。
一、queryOptions 解决什么问题
在没有queryOptions之前,同一个查询的queryKey和queryFn往往要在多个组件中重复书写;而一旦你在某处手写['post', postId]这个 key,TypeScript 无法知道它对应的是什么数据结构,getQueryData、setQueryData等操作也就退化为unknown。
queryOptions的定位就是:允许以类型安全的方式共享和复用查询配置,并且会把queryKey打上来自queryFn返回值的类型标签(源码 JSDoc 原文:"ThequeryKeywill be tagged with the type fromqueryFn",见 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 完整实现了该模式(运行方式见其 README:pnpm install后pnpm 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', ), ), }) } }几个要点:
- 参数化 key:
post(postId)以方法参数构造queryKey,同一个配置工厂可以服务任意文章 ID,key 的粒度与参数一一对应,天然获得按 ID 的缓存隔离。 - RxJS 到 Promise 的桥接:
HttpClient返回Observable,lastValueFrom将其转换为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())) }这里有三个值得展开的细节:
injectQuery接收的是函数:() => this.queries.post(this.postId())。从 inject-query.ts 的 JSDoc 可以看到,传入的函数"会在响应式上下文中运行(will be run in the reactive context)",类似于computed:当postId信号变化时,工厂函数重新求值,queryKey随之变化,查询自动切换到新 key 并复用缓存。因此input信号必须带()调用(文档示例中的this.postId缺调用,请以示例工程为准)。input.required({ transform: numberAttribute }):模板传入的是字符串属性,numberAttribute转换器把它变成数字后进入queryKey,避免['post', '1']与['post', 1]这类 key 不一致问题。- 结果全是信号:
injectQuery返回的CreateQueryResult中data、isLoading、error等字段均为Signal(由 types.ts 中的MapToSignals映射实现),模板可直接写postQuery.data(),配合OnPush变更检测策略。
列表组件 posts.component.ts 则展示了最简单的消费形态——injectQuery(() => this.queries.posts()),一行即完成订阅,同时inject(QueryClient)表明缓存客户端本身也可注入,供组件外场景使用。
四、源码剖析:三重载与 queryKey 数据标签
queryOptions之所以能做到"零运行时成本却全程类型安全",关键在于 query-options.ts 中基于initialData与queryFn形态拆分的三个重载:
| 重载类型 | 适用条件 | 关键约束 |
|---|---|---|
DefinedInitialDataOptions | 提供了非 undefined的initialData | queryFn变为可选;调用方拿到"defined"结果,data信号不会是undefined |
UnusedSkipTokenOptions | queryFn使用了skipToken(禁用查询) | queryFn被收窄为排除SkipToken的真实函数;结果data为unknown |
UndefinedInitialDataOptions | 常规场景 | queryFn必填;initialData可以是值、函数或undefined(函数形式允许返回undefined,用于"条件性初始数据") |
三个重载的返回值都叠加了同一个标签类型:
DefinedInitialDataOptions<...> & QueryKeyWithDataTag<TQueryKey, TQueryFnData, TError>QueryKeyWithDataTag通过query-core的dataTagSymbol把一个"不可见"的符号属性挂在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 | undefined(L163-L174);setQueryData传入错误类型的值(如字符串'5')会直接触发编译错误(L192-L209)。此外,若配置中没有queryFn,标签会退化为unknown(L143-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: false或queryFn: 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.title即string),而不是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 used(L152-L161)确认标签值为number而非string——即缓存层存的始终是原始数据,select只影响观察层。
另外,CreateQueryOptions的基础类型继承自QueryObserverOptions(去掉suspense字段,见 types.ts),因此staleTime、refetchInterval、enabled、retry、placeholderData等所有观察者选项都可以在服务配置中或调用点覆盖时直接使用;同时类型测试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 的inject、input等新 API;具体版本能力以仓库中 packages/angular-query-experimental/package.json 声明为准。
本文涉及的关键文件:
- 指南原文:docs/framework/angular/guides/query-options.md
- 核心实现:packages/angular-query-experimental/src/query-options.ts、packages/angular-query-experimental/src/types.ts、packages/angular-query-experimental/src/inject-query.ts、导出入口 packages/angular-query-experimental/src/index.ts
- 测试:packages/angular-query-experimental/src/tests/query-options.test.ts、packages/angular-query-experimental/src/tests/query-options.test-d.ts
- 示例工程:examples/angular/query-options-from-a-service(含 queries-service.ts、post.component.ts、posts.component.ts)
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考