首页
/ 在 Lit 组件中拥抱 TanStack Query:createQueryController 单查询控制器完全指南

在 Lit 组件中拥抱 TanStack Query:createQueryController 单查询控制器完全指南

2026-09-07 15:56:21作者:昌雅子Ethen

导读

createQueryController@tanstack/lit-query 为 Lit 开发者提供的核心入口:它以 Lit Reactive Controller 的形式把单个 TanStack Query 查询"挂载"到组件宿主上,自动管理订阅、生命周期与重渲染,并返回一个既能取结果又带辅助方法的访问器。本文基于 createQueryController 参考文档 展开,并结合 @tanstack/lit-query 的源码实现与测试用例,讲清其签名、类型参数、响应式 options 机制、返回访问器用法、QueryClient 解析链路以及底层生命周期原理。读完后你将能直接在 Lit Element 中编写声明式、可取消、可自动重取的查询代码。

一、函数速览:签名与定位

createQueryController 定义于 packages/lit-query/src/createQueryController.ts,并从 packages/lit-query/src/index.ts 统一导出,随包使用只需:

import { createQueryController } from '@tanstack/lit-query'

其完整函数签名如下:

function createQueryController<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
  host: ReactiveControllerHost,
  options: Accessor<CreateQueryOptions<TQueryFnData, TError, TData, TQueryData, TQueryKey>>,
  queryClient?: QueryClient,
): QueryResultAccessor<TData, TError>

它的职责非常聚焦:创建一个 Lit 响应式控制器(Reactive Controller),让宿主(host)订阅单个查询。控制器被附加到宿主后,会跟随 Lit 生命周期——元素连接时开始观察并订阅查询、宿主更新时刷新配置、元素断开连接时退订并清理。返回的访问器既可以直接调用以读取最新结果,也暴露 currentrefetchsuspensedestroy 四个能力点。

从源码实现看,整个控制器体系被封装为 QueryController 类,它继承自 controllers/BaseController.ts 中的 BaseController<TResult>,内部真正干活的观察器是 TanStack Query Core 的 QueryObserver(见 createQueryController.ts 中的 observer 字段)。也就是说,Lit 适配层解决的是"如何把 Query Observer 的结果与 Lit 渲染周期对齐",而查询语义(缓存、重试、去重)完全复用 Query Core。

二、类型参数:五段式泛型

QueryObserverOptions 保持一致,createQueryController 暴露五个泛型参数,且全部带默认值:

类型参数 默认值 含义
TQueryFnData unknown queryFn 的原始返回类型,即服务端数据形态
TError Error(源码内为 DefaultError 查询失败时的错误类型
TData TQueryFnData select 转换后的最终数据形态
TQueryData TQueryFnData 存入查询缓存的数据形态(升级/持久化场景可与之分离)
TQueryKey readonly unknown[] 查询键类型,默认任何只读数组

日常使用中泛型可全部省略,TypeScript 会根据 options 自动推断。只有当你想精细化约束查询键、错误类型或区分"缓存数据"与"展示数据"时才需要显式声明,例如在 TData = TQueryFnData 的前提下通过 selectTData 指向 string 而缓存里存的是对象。

三、参数详解

host:ReactiveControllerHost

host 是持有该查询订阅的 Lit 响应式控制器宿主。Lit 的 LitElement 本身即实现了 ReactiveControllerHost,所以组件中直接把 this 传入即可,正如 Quick Start 文档所述:"控制器用 this 创建,因为 LitElement 就是 ReactiveControllerHost,Lit Query 借此使用宿主生命周期来订阅、请求更新并在元素断开时清理"(见 docs/framework/lit/quick-start.md)。

值得注意:宿主并不要求必须是 LitElement。参考测试 query-controller.test.ts,测试中就用一个纯 HTMLElement 实现了 ReactiveControllerHost(含 addControllerremoveControllerrequestUpdate 与生命周期钩子)。因此任何符合 Lit 控制器宿主契约的对象都能使用本 API。

options:Accessor<CreateQueryOptions<...>>

options 接收的是 TanStack Query 的 QueryObserverOptions 在 Lit 下的适配形态 CreateQueryOptions(见 createQueryController.ts),即 queryKeyqueryFnenabledstaleTimegcTimeselectretryrefetchOnMountplaceholderData 等 Query Core 选项都可直接使用,并额外享受 Lit Query 的默认值合并(内部经 client.defaultQueryOptions(...) 处理,见 createQueryController.ts)。

关键能力在于 options 的类型是一个 Accessor

// packages/lit-query/src/accessor.ts 中定义
export type Accessor<T> = T | (() => T)

也就是说,options 既可以传静态对象,也可以传一个无参 getter 函数。当传入函数时,它会在宿主更新(host update)期间被重新读取,从而让 queryKey 与其他选项跟随响应式宿主状态变化——例如跟随组件的属性或内部状态生成查询键。这是 Lit Query 处理"查询跟随响应式状态"的官方姿势,对应源码中的 readAccessor 实现(见 accessor.ts):

export function readAccessor<T>(value: Accessor<T>): T {
  return typeof value === 'function' ? (value as () => T)() : value
}

BaseController.tshostUpdate() 中可以看到,当 options 是函数时,控制器会在 onHostUpdate 钩子里执行 refreshOptions()——重新读取 getter、把最新选项同步给 QueryObserver 并刷新结果(见 createQueryController.ts)。

queryClient?:显式注入客户端

queryClient 是可选参数。省略时,控制器会通过 Lit Context 从最近的已连接 QueryClientProvider 解析客户端;传入时则将该控制器"绑定"到指定客户端,适合那些不应从 Context 解析客户端的场景(例如库作者、测试或独立子树)。

BaseController.ts 可以看到:构造函数里若 queryClient 存在,解析状态直接置为 'bound';否则置为 'pre-connect',等宿主连接后再发起 Context 请求。

四、返回值:QueryResultAccessor 访问器

返回类型 QueryResultAccessor<TData, TError> 是核心亮点,定义如下(见 createQueryController.ts):

type QueryResultAccessor<TData, TError> = ValueAccessor<QueryObserverResult<TData, TError>> & {
  refetch: QueryObserverResult<TData, TError>['refetch']
  suspense: () => Promise<QueryObserverResult<TData, TError>>
  destroy: () => void
}

其中 ValueAccessor<T> = (() => T) & { readonly current: T }(见 accessor.ts),因此访问器是"可调用 + 带属性"的复合体:

  • 直接调用 this.todos():读取最新 QueryObserverResult,等价于 current
  • current:同一结果的属性读取形式,供模板或非渲染逻辑读取;
  • refetch(...):转发给当前 QueryObserver 的 refetch;若客户端尚未就绪则 reject(返回缺少客户端的错误),见 createQueryController.ts
  • suspense():返回一个 Promise,必要时先发起拉取再 resolve 乐观查询结果,可配合 Suspense 风格的挂起渲染;当 enabled !== false 且结果已 stale 时执行 observer.fetchOptimistic(...),否则直接返回乐观结果(见 createQueryController.ts);
  • destroy():把控制器从宿主移除并退订所有 observer,等价于 controller.destroy()

函数组合发生在工厂方法的最后(createQueryController.ts):工厂先 new QueryController(host, options, queryClient),再用 Object.assign 把可调用访问器与四个方法绑定返回。

五、开箱即用:最小可运行示例

原文档给出的 Example 展示了最核心的用法——把控制器作为字段声明、在 render() 中调用并依据状态分支渲染。下面的完整示例进一步补齐了 Provider 挂载,可直接照搬到 Lit 项目:

import { LitElement, html } from 'lit'
import { customElement } from 'lit/decorators.js'
import {
  createQueryController,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/lit-query'

const queryClient = new QueryClient()

// QueryClientProvider 不会自动注册,需要应用侧定义一个子类或直接注册
class AppQueryProvider extends QueryClientProvider {
  constructor() {
    super()
    this.client = queryClient
  }
}
customElements.define('app-query-provider', AppQueryProvider)

@customElement('todos-view')
class TodosView extends LitElement {
  private readonly todos = createQueryController(this, {
    queryKey: ['todos'],
    queryFn: async () => fetch('/api/todos').then((r) => r.json()),
  })

  render() {
    const query = this.todos()

    if (query.isPending) return html`Loading...`
    if (query.isError) return html`Error`

    return html`
      <ul>${query.data.map((todo) => html`<li>${todo.title}</li>`)}</ul>
    `
  }
}

HTML 中把 Provider 包在组件外层即可让控制器通过 Context 拿到客户端:

<app-query-provider>
  <todos-view></todos-view>
</app-query-provider>

关于 Provider 有两点重要约束(见 QueryClientProvider.ts):第一,client属性而非 attribute,模板里必须用属性绑定 .client=${queryClient}QueryClientProvider.ts);第二,Provider 在已连接状态下若没有 client 会直接抛出缺少客户端的错误(QueryClientProvider.ts)。

六、让查询跟随响应式状态:函数式 options

静态 options 只够应付"挂载即拉一次"的场景。真实应用里,查询键往往取决于组件属性(如当前页号、筛选条件)。这时把第二个参数换成 getter,Query 就会随宿主更新自动迁移到新键:

class UserProfileView extends LitElement {
  @property({ type: Number })
  userId = 1

  private readonly profile = createQueryController(this, () => ({
    queryKey: ['user', this.userId],
    queryFn: async ({ queryKey }) => {
      const [, id] = queryKey
      return fetch(`/api/users/${id}`).then((r) => r.json())
    },
  }))

  render() {
    const query = this.profile()
    // ...
  }
}

每当 userId 变化触发宿主更新时,控制器重新执行 getter,QueryObserver 切换到底层新查询键,并触发对新数据的拉取;同步变化期间旧结果不会被新的键覆盖(参见测试 CANCEL-02CANCEL-01:旧请求会在键切换时收到 AbortSignal 的 abort,见 query-controller.test.ts)。

同样的 getter 机制也支持响应式地控制查询开关,例如 enabled

private readonly search = createQueryController(this, () => ({
  queryKey: ['search', this.keyword],
  enabled: this.keyword.trim().length > 0,
  queryFn: () => search(this.keyword),
}))

七、QueryClient 的解析链路:Context 与显式绑定

理解"省略第三个参数时客户端从哪来",对排查问题很关键。Lit Query 用 @lit/context 封装了一个 queryClientContext,解析状态机定义在 BaseController 中(BaseController.ts):

  • pre-connect:尚未创建/未提供显式客户端;
  • awaiting-context:已发起 Context 请求,等待 Provider 响应;
  • bound:已拿到客户端(显式或来自 Provider);
  • missing:确认缺少客户端,读取 current 会抛错。

流程大致是:hostConnected() 时若无显式客户端,就调用 beginContextResolution() 置为 awaiting-context,随后在微任务里 dispatchEvent 一个 ContextEvent(queryClientContext, ...) 向上冒泡(BaseController.ts);Provider 通过其内部 ContextProvider 响应后,回调把 contextClient 绑定下来并触发更新;若最终无人响应则落为 missing 并触发更新。

对应地,QueryClientProviderconnectedCallback 中会调用 client.mount() 并注册为默认客户端,断开时执行 client.unmount() 与反注册(QueryClientProvider.ts)。因此:

  • 显式传入 queryClient:跳过 Context 全过程,初始解析状态直接为 bound,适合测试、SSR、无 Provider 的孤岛组件;
  • 依赖 Context:必须在祖先链中放置已连接且赋好 .clientQueryClientProvider,否则查询永远停留在 pending,并抛出 createMissingQueryClientError() 创建的缺失客户端错误。

客户端在已连接状态下发生切换(例如测试 M3 中把 provider.client 从 clientA 换成 clientB)时,BaseController 会触发 onQueryClientChanged:先退订旧 observer,再为新客户端创建全新 QueryObserver 并写入乐观结果,确保同一时刻只有一个活跃 observer(见 createQueryController.ts)。

八、底层生命周期与结果流转

把这套 API 用明白,最好也看清它背后"如何驱动 Lit 更新"。从源码可梳理出四条主线:

1. 挂载前的 pending 占位

构造 QueryController 时,如果还没有客户端或 options 是函数(无法立即确定最终选项),会先以 createPendingQueryResult() 作为初始结果(createQueryController.ts),它代表一个 status: 'pending'fetchStatus: 'idle' 的"空结果",从而保证在真正拿到 observer 之前访问器读取始终安全——这也是测试 M17 所验证的"无显式客户端构造路径在 Provider 解析前安全"的前提(query-controller.test.ts)。

2. 连接的微任务延迟

hostConnected() 不会同步执行订阅逻辑,而是用 queueMicrotask 延迟执行 onConnected()BaseController.ts)。注释表明这是为了确保"子类构造函数先于生命周期回调完成"——处理在 willUpdate 中对已连接宿主调用 addController、从而同步触发 hostConnected 的情况。onConnected 内部会完成 syncClient()refreshOptions()subscribe() 与结果同步(createQueryController.ts)。

3. 观察与结果推送

subscribe()observer.subscribe((next) => this.setObserverResult(next)) 建立订阅(createQueryController.ts)。每次 observer 有新结果,setObserverResult 更新内部 result,再通过 queueUpdate() 在微任务里调用 host.requestUpdate(),从而把异步数据变更引入 Lit 渲染周期(BaseController.ts)。

4. 乐观结果与精确追踪

两处细节显著降低无效渲染:

  • defaultOptions 会给默认化后的选项打上 _optimisticResults = 'optimistic'createQueryController.ts),让 getOptimisticResult 在拉取尚未完成时也能立即给出乐观结果;
  • queryObserverResultTracker.ts未显式配置 notifyOnChangeProps 时调用 observer.trackResult(...),把结果收敛到渲染实际读过的字段上。测试验证了这一收益:当宿主只读取了 data,即使发生 refetch 引起的状态翻转也不会触发额外更新(query-controller.test.ts);当函数 options 解析后结果未变化时也不会重复请求更新(query-controller.test.ts)。

5. 断开与销毁

宿主断开时,hostDisconnected 触发退订并尝试清理 context(BaseController.ts);显式调用 destroy() 则更进一步,把控制器从宿主的控制器集合中移除(host.removeController,见 BaseController.ts),此后微任务队列中的迟到更新不会再触发宿主刷新(测试 M1 验证了 destroy 后的零更新,query-controller.test.ts)。

九、值得注意的行为契约与进阶场景

测试文件 query-controller.test.ts 为上述契约提供了大量可运行佐证,以下几条对实际开发最有用:

  • pending → success 的状态契约:连接前读取结果为 status: 'pending'isSuccess: false,拉取完成后转为 success(M4)。
  • 禁用即不请求enabled: falsequeryFn 完全不执行;把 enabled 翻转为 true 并触发宿主更新后才开始拉取(M6)。
  • 按键切换使用最新选项:函数 options 携带键更新、以及调用 refetch() 时都会应用最新的键与配置(M8);稳定的函数 options 在多次宿主更新下不会重复请求(多次更新后 queryFn 仅执行一次)。
  • 缓存与 observer 计数不泄漏:经历 100 次"连接—更新—断开—销毁"循环后 observer 数回到基线;gcTime: 0 时卸载后缓存条目可被立即回收,重挂载会重新拉取(M2、M7)。
  • 旧键响应不覆盖新键:快速切换键(连续 20 次键变化)最终稳定在最后一个键且不产生重复 observer(S6);键切换后迟到的旧响应不会污染最新结果(CANCEL-01/02)。
  • 连接与挂起期间的安全性:请求进行中断开连接,迟到结果不会触发多余更新(LIFE-01);在飞行中重连后能拿到正确的最终快照(LIFE-02)。

若需要一次订阅多个查询,可在 createQueriesController.ts 找到 createQueriesController;分页/无限滚动场景对应 createInfiniteQueryController.ts;写操作对应 createMutationController.ts。配合 queryOptions()infiniteQueryOptions() 类型助手(见 queryOptions.tsinfiniteQueryOptions.ts),可以在组件外集中定义类型安全的查询配置,再通过函数 Accessor 在组件内做键的响应式组合。

十、小结

createQueryController 的精妙之处在于用一个可调用的访问器统一了"读结果"与"操作查询"两种语义,并把 Lit 的生命周期、Query Core 的观察器与 context 化的客户端解析无缝缝合。把握三个要点即可熟练运用:静态 options 适合一次性拉取;函数 options 让查询键与开关跟随响应式宿主状态;queryClient 第三参数用于脱离 Provider 的显式场景。若需深入调试或定制,可从 createQueryController.tscontrollers/BaseController.tsquery-controller.test.ts 三份文件入手,其注释与测试覆盖了本文所述的绝大多数边界行为。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388