Lit Query 并行查询(Parallel Queries)实战指南:从手动多查询到动态合并
TanStack Query 的 Lit 适配层 @tanstack/lit-query 提供了一组以 Lit ReactiveController 为核心的查询控制器,其中**并行查询(Parallel Queries)**用于解决"多个互相独立的请求同时发起、UI 不必等待某个请求先完成再发下一个"的问题。本文围绕 parallel-queries.md 指南,讲解固定数量查询的手动并行写法、随宿主状态变化的动态并行写法,以及用 combine 将结果数组归一为单一派生值的技巧,并结合本仓库 lit-query 包源码解释其底层订阅与触发更新机制。
什么是并行查询
并行查询指在同一时刻并行运行的多个查询。与"串行/依赖查询"(前一个请求的结果作为下一个请求的参数)相对,并行查询之间没有数据依赖,因此可以同时发起,缩短首屏总等待时间。
在 @tanstack/lit-query 中,并行能力来自 Lit 的控制器(Controller)生命周期:每个查询控制器都会在宿主元素(Host)连接(connected)时通过 hostConnected → onConnected 注册订阅,多个控制器挂在同一个 Host 上即天然并行、互不阻塞。根据查询数量是否固定,官方指南给出了两种模式:
- 手动并行查询(Manual Parallel Queries):查询数量固定时,在同一宿主上创建多个
createQueryController。 - 动态并行查询(Dynamic Parallel Queries):查询数量随宿主状态变化时,使用
createQueriesController维护一个查询数组。
手动并行查询:数量固定时创建多个控制器
当查询数量在编译期就可确定时,推荐的做法是在同一个 LitElement 宿主上创建多个查询控制器,例如一个仪表盘同时加载用户、团队与项目三类数据:
import { LitElement, html } from 'lit'
import { createQueryController } from '@tanstack/lit-query'
class DashboardView extends LitElement {
private readonly users = createQueryController(this, {
queryKey: ['users'],
queryFn: fetchUsers,
})
private readonly teams = createQueryController(this, {
queryKey: ['teams'],
queryFn: fetchTeams,
})
private readonly projects = createQueryController(this, {
queryKey: ['projects'],
queryFn: fetchProjects,
})
render() {
const users = this.users()
const teams = this.teams()
const projects = this.projects()
if (users.isPending || teams.isPending || projects.isPending) {
return html`Loading...`
}
if (users.isError || teams.isError || projects.isError) {
return html`Unable to load dashboard`
}
return html`
<dashboard-summary
.users=${users.data}
.teams=${teams.data}
.projects=${projects.data}
></dashboard-summary>
`
}
}
关键行为与实现依据
从 createQueryController.ts 源码可以看出该模式的几个要点:
- 共享宿主、并行订阅:
createQueryController的第一个参数是ReactiveControllerHost,创建时会执行host.addController(this)(见 BaseController.ts)。三个控制器挂到同一个DashboardView上,当宿主连接时各自走onConnected():先syncClient()解析QueryClient,再refreshOptions()应用默认选项,最后subscribe()把观察者订阅到 QueryObserver。三个观察者彼此独立,fetch 请求并行发出,谁先完成谁先触发host.requestUpdate()。 - 访问器(Accessor)语义:返回对象是可调用函数,同时也暴露
current、refetch、suspense、destroy(见 createQueryController.ts)。文档中的this.users()等价于读取最新一次QueryObserverResult。 - 初次渲染的安全结果:尚未拿到数据的阶段,控制器内置一个 pending 占位结果(
createPendingQueryResult,status: 'pending'、isPending: true),这正是users.isPending分支可用的前提,见 createQueryController.ts。 - QueryClient 解析规则:文档强调"如果未显式传入
QueryClient,每个控制器都会解析最近连接的QueryClientProvider"。机制上,控制器会通过dispatchContextRequest派发ContextEvent(queryClientContext, ...)向上查找 Provider(见 BaseController.ts 与 context.ts),因此并行查询的多个控制器默认共享同一份缓存。也可以在第三个参数显式传入QueryClient(见 createQueryController.ts 的类型说明)。
动态并行查询:使用 createQueriesController
当查询的数量会随宿主状态变化时(例如根据一组用户 id 渲染每个用户的详情),逐个声明控制器不再可行,应改用 createQueriesController。它接受一个 queries 数组,并返回一个访问器,读取结果为查询结果数组。
当查询列表依赖响应式宿主字段时,把 options 写成**getter(options getter)**即可让列表跟随宿主状态刷新:
import { LitElement, html } from 'lit'
import { createQueriesController } from '@tanstack/lit-query'
class UsersDetails extends LitElement {
static properties = {
userIds: { attribute: false },
}
userIds: Array<string> = []
private readonly users = createQueriesController(this, () => ({
queries: this.userIds.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUserById(id),
})),
}))
render() {
const userQueries = this.users()
return html`
<ul>
${userQueries.map((query, index) => {
if (query.isPending) return html`<li>Loading...</li>`
if (query.isError) return html`<li>Error loading user</li>`
return html`<li>${this.userIds[index]}: ${query.data.name}</li>`
})}
</ul>
`
}
}
注意几个要点:
- 结果顺序与输入顺序一致。
createQueriesController会按queries数组的元素位置映射每个查询结果,因此渲染时可以放心用userQueries[index]与userIds[index]一一对应,无需额外排序。 - 单个元素的渲染模式与单查询控制器完全一致:读取
query.isPending、query.isError、query.data等字段。
底层实现细节
从 createQueriesController.ts 与 BaseController.ts 可以还原它的运行机制:
- getter 触发刷新:控制器会在构造时判断"
options本身是函数,或options.queries是函数"(shouldRefreshOnHostUpdate,见 createQueriesController.ts)。由于示例中传入的是函数,每次宿主更新(hostUpdate)都会走onHostUpdate()重新调用 getter、执行refreshOptions(),再通过observer.setQueries(...)把新的查询集合推给底层的 QueriesObserver。 - 多个查询共享一个观察者:动态模式下不是"每个查询一个 QueryObserver",而是由一个来自
@tanstack/query-core的QueriesObserver统一协调多个子查询,控制器收到其订阅回调后更新结果(见 createQueriesController.ts)。createQueriesController的完整函数签名可查阅 createQueriesController.md。 - 默认选项统一应用:每个子查询在进入观察者前都会经过
client.defaultQueryOptions(...)补全全局默认值,并设置_optimisticResults: 'optimistic'以支持乐观结果(见 createQueriesController.ts)。 - 类型安全的元组推导:源码中
CreateQueriesResults/GetCreateQueriesResult等类型会对queries数组逐元素做查询输入与结果的一一映射(见 createQueriesController.ts),这正是示例里query.data.name能拿到精确类型的原因。
用 combine 合并多个查询结果
当组件希望拿到的不是查询结果数组,而是由它们派生出的单一值(例如一个仪表盘模型)时,可使用 combine 选项。combine 接收查询结果数组,返回任意派生值,控制器会把该派生值作为访问器的返回值:
private readonly dashboard = createQueriesController(this, {
queries: [
{ queryKey: ['stats'], queryFn: fetchStats },
{ queryKey: ['projects'], queryFn: fetchProjects },
],
combine: ([stats, projects]) => ({
activeUsers: stats.data?.activeUsers ?? 0,
projects: projects.data ?? [],
isPending: stats.isPending || projects.isPending,
isError: stats.isError || projects.isError,
}),
})
render() {
const dashboard = this.dashboard()
if (dashboard.isPending) return html`Loading...`
if (dashboard.isError) return html`Unable to load dashboard`
return html`
<p>Total projects: ${dashboard.projects.length}</p>
<p>Active users: ${dashboard.activeUsers}</p>
`
}
从源码看,combine 的执行包含一次关键的引用稳定化:控制器用 replaceEqualDeep 对比上一次的合并结果,若两次 combine 产出结构相等,则继续复用旧引用、不再触发宿主更新(见 createQueriesController.ts)。这意味着只要派生对象的内容没有实质变化,就不会引起额外的 requestUpdate 与重复渲染,这在仪表盘这类高频刷新场景中尤为重要。
无 QueryClient 时的占位 combine
源码还处理了一个边界:当 Provider 尚未连接、客户端不可用且 combine 提前被读取时,控制器会先用占位(placeholder)查询结果构造并应用一次 combine(createPlaceholderResult,见 createQueriesController.ts)。如果某个子查询带有 initialData,占位结果会优先物化该初始数据,使 combine 在首帧就能拿到真实数据形状(相关用例见 queries-controller.test.ts 中 "placeholder combine materializes defined initialData" 等测试)。
注意事项:重复的 queryKey 会共享缓存
官方指南给出了一条明确的警告:queries 数组中若出现同一个 queryKey 多次,这些条目会共享同一份缓存数据。因为 TanStack Query 以 [queryKey, queryClient] 作为缓存与观察的标识,两个相同 key 的查询会命中同一个 cache entry。如果每行都需要相互独立的查询状态(例如同一批数据的多行分别展示各自的加载/错误状态,或参数即将变化前做差异化处理),应先对重复的 key 去重后再生成查询集合,例如先对 userIds 去重:
const ids = [...new Set(this.userIds)]
queries: ids.map((id) => ({
queryKey: ['user', id],
queryFn: () => fetchUserById(id),
}))
与此相关的最佳实践还有:使用可序列化、结构清晰的查询键(见 query-keys.md),以及在需要加载更多等"分页式并行"场景参考 infinite-queries.md。
小结:三种并行形态的选型对照
| 场景 | 推荐 API | 结果形态 | 结果顺序 |
|---|---|---|---|
| 查询数量固定(如仪表盘 3 块数据) | 多个 createQueryController |
各自独立的 QueryObserverResult |
不适用(按需取名) |
| 数量随状态变化(如按 id 列表渲染详情) | createQueriesController + options getter |
QueryObserverResult[] |
与 queries 输入一一对应 |
| 需要把多个结果合成一个派生值 | createQueriesController + combine |
任意自定义对象 | 在 combine 解构时保持输入顺序 |
三种形态共享同一套原则:控制器挂载在 Lit 宿主上并随生命周期注册/销毁订阅(subscribe/unsubscribeObserver,见 BaseController.ts),未显式传 QueryClient 时统一从最近的 QueryClientProvider 解析客户端,从而保证并行查询天然复用同一缓存与全局默认配置。
若想验证上述行为或深入了解状态机细节,可直接阅读本仓库的单元测试 queries-controller.test.ts 与 query-controller.test.ts,其中覆盖了并行合并、动态增删查询、combine 稳定引用以及 Provider 切换等多类用例。
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