@tanstack/lit-query 快速上手指南:用响应式控制器在 Lit 元素中接入 TanStack Query
【免费下载链接】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
本指南以 docs/framework/lit/quick-start.md 为骨架,讲解如何在 Lit 自定义元素中,借助@tanstack/lit-query(本仓库packages/lit-query包,即 TanStack Query 官方 Lit 适配层)完成服务端状态的数据查询(Query)、变更提交(Mutation)与缓存失效(Query Invalidation)三件事。读完本文你将能亲手搭建一个可运行的 Lit + Query 最小应用,并理解控制器生命周期、QueryClient上下文传递等底层机制。
三个核心概念:Queries、Mutations 与 Query Invalidation
Lit Query 把 TanStack Query 的使用体验带入 Lit 生态,核心抽象与 React Query 一一对应,只是接入点是 Lit 的ReactiveController而不是 Hooks。快速入门围绕三个概念展开:
- Queries(查询):声明式地依赖某个异步数据源,并用唯一
queryKey绑定它,用于读取服务端状态; - Mutations(变更):用于创建、更新、删除等"写"操作与服务端副作用;
- Query Invalidation(查询失效):写入成功后主动标记相关缓存为过期,从而刷新视图。
完整的可运行示例见仓库中的 examples/lit/basic、examples/lit/pagination 与 examples/lit/ssr。
一个 70 行的完整可运行示例
下面是 quick-start 提供的完整骨架,它同时演示了上述三个概念:读取 todo 列表、新增 todo,并在新增成功后使列表缓存失效从而自动刷新。由于代码量很小,建议把它作为你新项目或现有 Lit 组件的第一个接入模板。
第一步:创建 QueryClient 并派生 Provider 元素
import { LitElement, html } from 'lit' import { QueryClient, QueryClientProvider, createMutationController, createQueryController, } from '@tanstack/lit-query' import { addTodo, getTodos } from './api' const queryClient = new QueryClient() class AppQueryProvider extends QueryClientProvider { constructor() { super() this.client = queryClient } } customElements.define('app-query-provider', AppQueryProvider)QueryClient是缓存与请求调度的核心实例;QueryClientProvider是 Lit 自定义元素形式的 Provider,负责通过 Lit Context 把该实例传递给后代的控制器。需要特别注意的是:QueryClientProvider的client是属性(property)而非 attribute,因此在使用时要用属性绑定.client=${queryClient}。它的源码位于 packages/lit-query/src/QueryClientProvider.ts。
第二步:在 LitElement 中挂载查询与变更控制器
class TodosView extends LitElement { private readonly todos = createQueryController(this, { queryKey: ['todos'], queryFn: getTodos, }) private readonly createTodo = createMutationController(this, { mutationFn: addTodo, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['todos'] }) }, }) render() { const query = this.todos() const mutation = this.createTodo() 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> <button ?disabled=${mutation.isPending} @click=${() => this.createTodo.mutate({ title: 'Write Lit docs' })} > Add Todo </button> ` } } customElements.define('todos-view', TodosView)这段代码的关键信息量很大,拆开来看:
createQueryController(this, { queryKey, queryFn })返回一个可调用的 accessor(后续会讲);在render()中调用this.todos()即可获得当前QueryObserverResult,并用isPending/isError/data完成分支渲染与 TS 类型收窄;createMutationController(this, { mutationFn, onSuccess })的mutationFn负责真正执行写请求;按钮点击时通过this.createTodo.mutate(...)传入变量触发;- 一个非常典型的失效模式:
onSuccess里调用queryClient.invalidateQueries({ queryKey: ['todos'] }),让"已成功写入 → 列表必然过期"这一事实反映到缓存上; - 自定义元素注册(
customElements.define)永远是应用层职责,包内不会替你注册。
第三步:把 Provider 包在最外层
<app-query-provider> <todos-view></todos-view> </app-query-provider>将 Provider 挂在组件外层,后代的查询/变更控制器便能通过 Lit Context 解析到同一个QueryClient。
为什么用this?——控制器与宿主生命周期
原文档特别强调:控制器都以this作为第一个参数创建,因为LitElement本身就实现了ReactiveControllerHost接口。
查看 packages/lit-query/src/controllers/BaseController.ts 的实现,其内部就是一个标准的ReactiveController:构造函数中调用host.addController(this)(第 39 行),并借助宿主生命周期钩子完成三件事:
hostConnected():宿主元素连接到文档时订阅(subscribe),开始接收缓存变更;hostDisconnected():宿主元素断开连接时退订(unsubscribe),避免内存泄漏与无谓的网络活动;- 结果变化时通过
host.requestUpdate()触发宿主元素重新渲染。
源码中还用queueMicrotask推迟了onConnected的调用(第 62 行附近),以确保子类字段初始化完成后生命周期回调再访问子类状态,这保证了在willUpdate这类时机下addController也能安全工作。对使用者而言,你可以放心地把控制器定义为类字段:
private readonly todos = createQueryController(this, { ... })深入查询:createQueryController 的使用细节
详细指南见 docs/framework/lit/guides/queries.md。一个查询的本质是"绑定唯一queryKey的异步数据声明"。控制器要求三样东西:
- 一个
ReactiveControllerHost,通常是LitElement内部的this; - 一个唯一的
queryKey,用于缓存、共享与失效定位; - 一个
queryFn,返回 Promise,出错时抛出异常。
通过可调用 accessor 读取结果
控制器创建函数返回的 accessor 既可调用,也带有.current属性读取最新结果:
const query = this.todos() const sameQuery = this.todos.current从 packages/lit-query/src/createQueryController.ts 可以看到,返回对象是通过createValueAccessor(() => controller.readCurrent())构造,并额外合入了refetch、suspense、destroy三个方法(第 370-377 行)。
查询状态与抓取状态是两回事
查询结果上同时存在status与fetchStatus两组状态,必须分开理解:
status(数据可用性):pending(尚无数据)/error(失败,可读error)/success(有数据);fetchStatus(queryFn 在做什么):fetching(正在抓取)/paused(想抓取但被暂停)/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> ` }响应式选项:当 queryKey / queryFn 依赖宿主状态时
如果查询选项依赖宿主属性(例如userId),请传入选项 getter 函数,而不是静态对象。Lit Query 会在宿主更新期间重新读取函数型 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, })) }enabled: false可以阻止无意义请求,配合动态 key 可做到"参数到位才开始加载"。若 options 是静态的,直接传对象即可;注意:如果你自行修改了静态 options 对象,需要调用refetch之类的控制器方法让 observer 感知到新选项,更推荐的做法是对依赖状态变化的场景一律使用函数 getter。
手动重新抓取
accessor 自带refetch:
html`<button @click=${() => this.todos.refetch()}>Refetch</button>`多个查询同时并发执行的场景,参见 docs/framework/lit/guides/parallel-queries.md(相关控制器为createQueriesController)。
深入变更:createMutationController 的使用细节
详细指南见 docs/framework/lit/guides/mutations.md。查询负责读、变更负责写。变更控制器可以放在与查询控制器同一个元素中(例如上述 todos 示例)。
变更状态机
一次变更只能处于以下状态之一:
isIdle/status === 'idle':从未执行,或已被 reset;isPending/status === 'pending':正在执行;isError/status === 'error':执行失败,error可用;isSuccess/status === 'success':执行完成,data可用。
基于状态渲染 UI 的典型写法:
render() { const query = this.todos() const mutation = this.addTodo() return html` ${mutation.isError ? html`<p>${mutation.error.message}</p>` : null} ${mutation.isSuccess ? html`<p>Todo added</p>` : null} <button ?disabled=${mutation.isPending} @click=${() => this.addTodo.mutate({ title: 'Write mutation docs' })} > ${mutation.isPending ? 'Adding...' : 'Add Todo'} </button> ` }变量:mutate 与 mutateAsync
mutate的实参即传给mutationFn的 variables:
this.addTodo.mutate({ title: this.nextTitle, })需要 Promise 时使用mutateAsync(例如拿到返回数据后继续处理):
try { const created = await this.addTodo.mutateAsync({ title: this.nextTitle }) this.nextTitle = created.title } catch (error) { this.errorMessage = String(error) }一个值得了解的边界行为:如果控制器无法解析到QueryClient(元素不在已连接的QueryClientProvider之下,且未显式传入 client),mutate会同步抛出异常,而mutateAsync则表现为返回 rejected promise。
重置变更状态
accessor 提供reset,常用于"清除错误提示":
html` ${mutation.isError ? html`<button @click=${() => this.addTodo.reset()}>Clear error</button>` : null} `副作用与乐观更新
变更选项支持onMutate、onError、onSuccess、onSettled四个生命周期回调。指南用 examples/lit/pagination 展示了一个完整闭环:先传显式queryClient给控制器,在onMutate里取消进行中的查询并快照旧数据、就地更新 UI(乐观更新),失败时在onError中根据 context 回滚,最后在onSettled里失效刷新:
private readonly favoriteMutation = createMutationController( this, { mutationKey: ['toggle-project-favorite'], mutationFn: async (input) => { const response = await toggleProjectFavoriteOnServer(input) return response.project }, onMutate: async (variables) => { await queryClient.cancelQueries({ queryKey: ['projects'] }) const snapshots = queryClient.getQueriesData<ProjectsPageResponse>({ queryKey: ['projects'], }) for (const [key, existing] of snapshots) { if (!existing) continue queryClient.setQueryData<ProjectsPageResponse>(key, { ...existing, projects: existing.projects.map((project) => project.id === variables.id ? { ...project, isFavorite: variables.isFavorite } : project, ), }) } return { snapshots } }, onError: (_error, _variables, context) => { for (const [key, snapshot] of context?.snapshots ?? []) { queryClient.setQueryData(key, snapshot) } }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: ['projects'] }) }, }, queryClient, )深入失效:invalidateQueries 与手动缓存写入
详细指南见 docs/framework/lit/guides/query-invalidation.md。等待查询自然过期(stale)往往不够——写入成功后,你通常"已经知道"相关缓存过期了,此时应主动失效。
失效的基本语义
queryClient.invalidateQueries() // 全部 queryClient.invalidateQueries({ queryKey: ['todos'] }) // 按 key 匹配当查询被失效时会发生两件事:
- 该查询被标记为 stale,覆盖任何
staleTime; - 若匹配到的查询正被某控制器"激活"(active),它会在后台重新抓取。
最常用的场景就是把失效挂在变更成功回调里:
private readonly addTodo = createMutationController(this, { mutationFn: addTodo, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['todos'] }) }, })匹配规则:前缀匹配与 exact
默认按key 前缀匹配一组查询。下面两个 key 都会被['projects']命中:
const projectsListKey = ['projects'] const projectsPageKey = ['projects', 1, 250, false]只想让某一页失效时用更具体的 key:
queryClient.invalidateQueries({ queryKey: ['projects', this.page], })只精确匹配整棵 key 时加exact: true:
queryClient.invalidateQueries({ queryKey: ['projects'], exact: true, })手动写缓存:setQueryData + 失效
失效通常比规范化缓存更新简单,需要即时 UI 反馈时可组合"定向写缓存 + 失效":
queryClient.setQueryData<TodosResponse>(['todos'], (existing) => { if (!existing) return existing return { ...existing, items: [...existing.items, createdTodo], } }) await queryClient.invalidateQueries({ queryKey: ['todos'] })乐观更新中用于快照回滚的技术细节见变更指南与 examples/lit/pagination 完整流程。
Provider 的底层行为与 QueryClient 解析规则
结合 packages/lit-query/src/QueryClientProvider.ts 的源码,Provider 组件连接时会发生三件事(connectedCallback/mountClient):
- 校验
this.client非空,为空则抛createMissingQueryClientError(); - 通过
@lit/context的ContextProvider向所有后代控制器提供该 client(控制器侧由 controllers/BaseController.ts 派发ContextEvent消费); - 调用
client.mount()并把它注册进一个process-local 的默认客户端登记表(registerDefaultQueryClient),断开时对称地client.unmount()与注销。
因此存在两条 client 解析路径:上下文路径(宿主位于 Provider 之下)与显式传参(构造控制器时第三个参数传入,如上面分页例子的queryClient)。
useQueryClient/resolveQueryClient依赖的是那个 process-local 登记表,规则刻意保守:
- 没有 Provider 连接时:
useQueryClient()抛错; - 恰有一个不同 client 连接时:
useQueryClient()返回它; - 同一 JS 上下文中有多个不同 client 连接时:
useQueryClient()与resolveQueryClient()抛错,因为兜底会变得有歧义。
因此多根节点、微前端、共享模块的测试套件与嵌套应用应避免依赖全局兜底:要么让宿主控制器渲染在正确的 Provider 下,要么给控制器传显式QueryClient,要么在测试之间干净地断开 Provider。
给 React Query 用户的对照表:Reactive Controllers vs Hooks
详细对照见 docs/framework/lit/guides/reactive-controllers-vs-hooks.md。职责相似:把组件订阅到QueryClient、读取最新结果、缓存变化时更新组件。区别只在于接入点——Lit 用ReactiveControllerHost,React 用渲染/Hook 体系。
| React Query | Lit Query(@tanstack/lit-query) |
|---|---|
useQuery(options) | createQueryController(this, options) |
useQueries(options) | createQueriesController(this, options) |
useMutation(options) | createMutationController(this, options) |
useInfiniteQuery(options) | createInfiniteQueryController(this, options) |
useIsFetching(options) | useIsFetching(this, options) |
useIsMutating(options) | useIsMutating(this, options) |
useMutationState(options) | useMutationState(this, options) |
| Hook 返回的结果对象 | 可调用的结果 accessor |
| React Context Provider | QueryClientProvider自定义元素 |
| 组件重渲染 | host.requestUpdate() |
需要强调的是,所有与宿主绑定的 API 都以host作为第一个参数,并实现上述"连接即订阅、变化即请求更新、断开即退订"的生命周期契约。而useQueryClient是特例:它不是响应式控制器,不接受 host、不订阅,且在没有唯一默认 client 时同步抛错——只应在"恰好只有一个QueryClientProvider连接"的命令式代码中使用,宿主绑定 API 内部请优先走 Provider 上下文或显式传QueryClient。
包内所有公开 API 的导出统一收敛在 packages/lit-query/src/index.ts:除上述控制器外,还导出queryOptions、mutationOptions、infiniteQueryOptions以及dehydrate/hydrate等 SSR 相关能力,并且整体 re-export 了@tanstack/query-core。
安装、运行示例与下一步
先完成 docs/framework/lit/installation.md 中的包安装与工程配置(包名@tanstack/lit-query,当前仓库内对应 packages/lit-query/package.json 记录的版本为 0.2.20)。随后任选一个示例目录安装依赖并运行:
- 基础示例examples/lit/basic:与本文完全对应的查询/变更/失效最小闭环,含本地产物展示页;
- 分页示例examples/lit/pagination:包含乐观更新、回滚与失配失效的完整业务流;
- SSR 示例examples/lit/ssr:服务端预取 +
dehydrate/hydrate水合,详见 docs/framework/lit/guides/ssr.md。
如果你想对照控制器在元素断开、重新连接、切换 Provider client 等边界情况下的行为,仓库内的测试(如 packages/lit-query/src/tests/base-controller.test.ts、packages/lit-query/src/tests/query-controller.test.ts)也是很好的阅读入口。下一步建议直接进入 Queries 指南 系统学习全部状态与选项,或先阅读 Reactive Controllers vs Hooks 对照指南 建立与 React Query 的概念映射。
【免费下载链接】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),仅供参考