在 Lit 组件中拥抱 TanStack Query:createQueryController 单查询控制器完全指南
导读
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 生命周期——元素连接时开始观察并订阅查询、宿主更新时刷新配置、元素断开连接时退订并清理。返回的访问器既可以直接调用以读取最新结果,也暴露 current、refetch、suspense、destroy 四个能力点。
从源码实现看,整个控制器体系被封装为 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 的前提下通过 select 让 TData 指向 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(含 addController、removeController、requestUpdate 与生命周期钩子)。因此任何符合 Lit 控制器宿主契约的对象都能使用本 API。
options:Accessor<CreateQueryOptions<...>>
options 接收的是 TanStack Query 的 QueryObserverOptions 在 Lit 下的适配形态 CreateQueryOptions(见 createQueryController.ts),即 queryKey、queryFn、enabled、staleTime、gcTime、select、retry、refetchOnMount、placeholderData 等 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.ts 的 hostUpdate() 中可以看到,当 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-02 与 CANCEL-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 并触发更新。
对应地,QueryClientProvider 在 connectedCallback 中会调用 client.mount() 并注册为默认客户端,断开时执行 client.unmount() 与反注册(QueryClientProvider.ts)。因此:
- 显式传入
queryClient:跳过 Context 全过程,初始解析状态直接为bound,适合测试、SSR、无 Provider 的孤岛组件; - 依赖 Context:必须在祖先链中放置已连接且赋好
.client的QueryClientProvider,否则查询永远停留在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: false时queryFn完全不执行;把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.ts、infiniteQueryOptions.ts),可以在组件外集中定义类型安全的查询配置,再通过函数 Accessor 在组件内做键的响应式组合。
十、小结
createQueryController 的精妙之处在于用一个可调用的访问器统一了"读结果"与"操作查询"两种语义,并把 Lit 的生命周期、Query Core 的观察器与 context 化的客户端解析无缝缝合。把握三个要点即可熟练运用:静态 options 适合一次性拉取;函数 options 让查询键与开关跟随响应式宿主状态;queryClient 第三参数用于脱离 Provider 的显式场景。若需深入调试或定制,可从 createQueryController.ts、controllers/BaseController.ts 与 query-controller.test.ts 三份文件入手,其注释与测试覆盖了本文所述的绝大多数边界行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00