TanStack Query Lit 指南:掌握 createQueryController 与服务端数据查询(Query Basics、状态机与响应式刷新)
本文围绕 docs/framework/lit/guides/queries.md 展开,系统讲解在 Lit 框架中如何使用 @tanstack/lit-query 的 createQueryController 控制器订阅单个查询:从最小可运行的 queryKey + queryFn 用法,到 status/fetchStatus 双状态机的准确区分、依赖宿主状态的响应式 options,以及命令式 refetch。读完你可以把任意 Lit 自定义元素与 TanStack Query 缓存无缝对接,并理解其背后的 Lit Reactive Controller 生命周期实现。
阅读前提
Queries 指南假设你已经完成了基本接线。若从未使用过 Lit Query,请先依次阅读 Installation 与 Quick Start,了解 QueryClientProvider 的挂载方式和运行环境要求,再回到本文把查询控制器接入元素。
Query 基础:把异步数据源声明式地接入元素
一个 query(查询)是对某个异步数据源的声明式依赖,它通过一个唯一的 key 与数据源绑定。在 Lit 中,query 天生用于读取服务端状态;如果一个函数会创建、更新或删除服务端数据,应当改用 mutation,而不是 query。
在 Lit 中订阅查询,使用 createQueryController 创建控制器并传入宿主 this:
import { LitElement, html } from 'lit'
import { createQueryController } from '@tanstack/lit-query'
async function fetchTodos(): Promise<{ id: number; title: string }[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('Failed to fetch todos')
return res.json()
}
class TodosView extends LitElement {
private readonly todos = createQueryController(this, {
queryKey: ['todos'],
queryFn: fetchTodos,
})
render() {
const query = this.todos()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error: ${query.error.message}`
return html`
<ul>
${query.data.map((todo) => html`<li>${todo.title}</li>`)}
</ul>
`
}
}
customElements.define('todos-view', TodosView)
控制器创建时需要三样东西:
| 必需项 | 说明 |
|---|---|
ReactiveControllerHost |
通常是元素内部的 this(一个 LitElement),控制器借此接入 Lit 生命周期并触发重渲染 |
唯一的 queryKey |
用于缓存、失效、去重与跨控制器共享数据(详见 Query Keys) |
queryFn |
返回 Promise 的取数函数;出错时抛异常即可,queryFn 规范见 Query Functions |
从源码看 createQueryController 的函数签名
参考文档 createQueryController 给出了完整签名:
function createQueryController<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
host,
options,
queryClient?,
): QueryResultAccessor<TData, TError>;
其中各泛型与参数(实现位于 packages/lit-query/src/createQueryController.ts):
host:拥有该查询订阅的 Lit 响应式控制器宿主。options:类型为Accessor<CreateQueryOptions<...>>,既可以直接传对象,也可以传一个返回 options 的函数(函数形式会在宿主更新期间被重新求值,使 query key 与配置跟随响应式宿主状态)。queryClient(可选):显式传入的QueryClient。省略时,控制器会从最近的已连接QueryClientProvider解析客户端;QueryClientProvider通过 context.ts 中定义的queryClientContext在 DOM 树中共享客户端。- 返回
QueryResultAccessor<TData, TError>:对最新查询结果的可调用访问器,同时附带refetch、suspense、destroy方法(见下方实现摘录的返回对象 createQueryController.ts#L370-L377)。
读取查询结果:调用访问器,或读取 .current
控制器返回的 accessor 暴露的是当前 QueryObserverResult。在 render 中直接调用它即可取到最新结果;current 属性是等价读取方式:
const query = this.todos()
const sameQuery = this.todos.current
两者为何等价?从 accessor.ts 可以看到 ValueAccessor<T> 的类型定义:它是 (() => T) & { readonly current: T } 的交叉类型。createValueAccessor 用一个内部 getter 构造可调用函数,并用 Object.defineProperty 把 current 绑定为同一个 getter。每次调用 this.todos() 时,底层会走到 createQueryController.ts#L201-L207 的 readCurrent():若已存在 observer,先以 observer.getCurrentResult() 同步一次结果再返回,保证读到的总是最新快照。
Query 状态:isPending / isError / isSuccess 与 status
一个 query 在同一时刻只会处于以下三种主状态之一:
isPending(或status === 'pending'):尚无可用的数据;isError(或status === 'error'):查询失败,此时error可用;isSuccess(或status === 'success'):已有数据可用。
结果上还带有 isFetching,它在初次加载或后台重新拉取(background refetch)期间都可能为 true。官方指南推荐优先使用 status 做分支判断:
render() {
const query = this.todos()
if (query.status === 'pending') {
return html`<span>Loading...</span>`
}
if (query.status === 'error') {
return html`<span>Error: ${query.error.message}</span>`
}
return html`<todo-list .items=${query.data}></todo-list>`
}
TypeScript 会在你先检查完 pending 与 error 之后再读取 query.data,从而把 data 的类型收窄为"确定有值"。这是把 UI 分支写成 if (status === 'pending') / if (status === 'error') 顺序结构的最大收益:既保证渲染安全,又避免大量非空断言。
补充一点可验证性:即便控制器尚未拿到真正的 QueryClient,它也会先用 createPendingQueryResult 生成一个初始占位结果——其中 status: 'pending'、data: undefined、error: null、fetchStatus: 'idle',所有布尔标记均按"尚未请求"的状态预设好,因此元素首帧渲染也符合上述状态机。
Fetch Status:把"有没有数据"与"是否在请求"分开
status 描述的是"有没有可用数据",fetchStatus 描述的是 queryFn 此刻正在做什么:
fetchStatus === 'fetching':查询正在拉取数据;fetchStatus === 'paused':查询想拉取,但被暂停了(例如当前处于离线状态,涉及 onlineManager 的网络监听);fetchStatus === 'idle':查询当前没有在拉取。
这两个维度刻意相互独立。由于后台重拉取与 stale-while-revalidate(数据过期时先用缓存渲染、后台再刷新)机制,二者会组合出一些看起来"矛盾"但实际合理的状态:
- 已有缓存数据的成功查询,在后台重拉取进行时,表现为
status === 'success'且fetchStatus === 'fetching'; - 还没有数据、且拉取暂时无法开始的查询,表现为
status === 'pending'且fetchStatus === 'paused'。
因此选判断依据的标准是:决定能否渲染数据时看 status;决定是否展示网络活动指示器时看 fetchStatus / isFetching:
render() {
const query = this.todos()
if (query.isPending) return html`Loading...`
if (query.isError) return html`Error: ${query.error.message}`
return html`
${query.fetchStatus === 'fetching'
? html`<span>Refreshing...</span>`
: null}
<todo-list .items=${query.data}></todo-list>
`
}
这样数据首次加载显示全屏 "Loading...",而后续后台刷新只显示轻量的 "Refreshing..." 提示,不影响已渲染列表。
Reactive Query Options:让 key 与 queryFn 跟随宿主状态
当 query key 或 query function 依赖宿主(元素实例)的响应式状态时,createQueryController 的 options 参数应传一个 getter(返回 options 的函数):
class UserTodos extends LitElement {
static properties = {
userId: { type: String },
}
userId = ''
private readonly todos = createQueryController(this, () => ({
queryKey: ['todos', this.userId],
queryFn: () => fetchTodos(this.userId),
enabled: this.userId.length > 0,
}))
}
其中的要点:
queryKey中带有this.userId,缓存与失效都以它为准:不同用户各自拥有独立缓存条目,宿主状态一变,key 变化会触发符合旧 key 的查询被取消/清理、并按新 key 拉取。enabled: this.userId.length > 0控制查询开关:userId为空串时不发起请求。测试用例M6: does not fetch when enabled=false and fetches after enabling(见 query-controller.test.ts)验证了这一行为。- getter 之所以是响应式的,是因为
options的函数形式会在宿主更新期间被重新读取。对照 BaseController.ts:Lit 每次hostUpdate()都会触发onHostUpdate(),随后QueryController通过refreshOptions()重新执行 getter、应用新 options(createQueryController.ts#L153-L159)。底层的求值入口就是 accessor.ts#L15-L17 的readAccessor——函数则调用、非函数则原样返回。
query key 本身还承担缓存、重新拉取与控制器之间数据共享的职责:只要两个控制器使用相同的 key,它们共享同一条缓存与同一个后台请求,重复请求会被去重合并。
Refetching:命令式重新拉取
accessor 上直接带有 refetch 方法,可在事件处理器中调用:
html`<button @click=${() => this.todos.refetch()}>Refetch</button>`
调用后该查询会立即重新拉取并刷新响应式结果,UI 依据 fetchStatus === 'fetching' 显示刷新中状态。从实现上看,accessor 的 refetch 是对内部 QueryController.refetch 的委托(createQueryController.ts#L178-L184),它先确保 options 已应用到 observer,再转发给 observer.refetch;若此时尚无可用 QueryClient,则按 createMissingQueryClientError 的约定以 rejected Promise 结束。参考文档中的 suspense 方法也值得一提:当你想以 Promise 形式拿到一个乐观查询结果(必要时先 fetch)时,可用 await this.todos.suspense()。
如需同时管理多个并行执行的查询,或需要"多查询同启同停"的编排,参考 Parallel Queries。
生命周期与缓存协作:控制器背后的实现纵深
Lit Query 的查询控制器本质是一个实现 ReactiveController 接口的宿主控制器,生命周期映射定义在 BaseController.ts:
hostConnected()(L42):宿主元素接入 DOM。若未显式传 QueryClient,控制器会派发ContextEvent(queryClientContext, ...)向 DOM 树上层请求客户端(由 QueryClientProvider.ts 响应);随后在微任务中建立QueryObserver并subscribe。hostUpdate():宿主每次更新都让 options getter 重新求值并同步结果,实现上文"响应式 options"。hostDisconnected():宿主移出 DOM 时取消订阅;若依赖 context 客户端,会同时解除 context 绑定,重新挂载后再走一次解析流程。- 结果更新时,
setResult通过queueMicrotask合并调用host.requestUpdate(),保证一次事件风暴只触发一次重渲染。
在 QueryController 内部,所有查询行为最终都收敛到 @tanstack/query-core 的 QueryObserver 上(createQueryController.ts#L128-L133),这也解释了为什么本文描述的 status/fetchStatus 语义与所有 TanStack Query 框架保持一致。仓库测试 query-controller.test.ts 进一步印证了以下能力:失效触发重拉取(QSEM-03: invalidation triggers refetch and updates result state)、key 切换时旧请求通过 AbortSignal 取消(CANCEL-01: queryFn receives AbortSignal and prior request is aborted on key switch)、key 过渡期保留旧数据(S5: keepPreviousData preserves prior data during key transitions),以及跨 Provider 切换时重建单个活跃 observer(M3)。
下一步
- 数据读取只是第一步:写操作请阅读 mutations 指南;
- 需要列表分页/无限滚动时,查阅 infinite-queries 中
createInfiniteQueryController的用法; - 想调整取数时机(如 focus 重新拉取、失败重试),见 query-functions 与 query-keys;
- 若需在服务端预取数据再水合客户端缓存,参考 SSR 指南;
- 仓库中的 basic 示例 提供了可运行的最小工程,适合对照本文逐步调试。
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 StartedRust0627
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