- 前端
- UI组件
【免费下载链接】table
🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Table, Svelte-Table
subscribe()是 @tanstack/lit-table 提供的一个核心 Lit 指令(Directive),它让组件能够订阅 Store 或 Atom 状态源,并仅在状态或所选状态切片变化时高效地更新被包裹的模板片段。本文基于仓库中的官方参考文档(docs/framework/lit/reference/variables/subscribe.md)展开,结合 packages/lit-table/src/subscribe-directive.ts 的源码实现与 examples/lit/basic-subscribe 完整示例,深入讲解该指令的两种调用签名、底层更新机制以及在大表格场景下的实战用法。读完本文,你将掌握如何在 Lit 表格中实现"哪块状态变,就只重渲染哪块 DOM"的细粒度响应式渲染。
subscribe() 是什么
subscribe是一个函数形式的变量,其类型签名如下(摘自参考文档):
const subscribe: { <TSource>(source, template): DirectiveResult<typeof SubscribeDirective>; <TSource, TSelected>(source, selector, template): DirectiveResult<typeof SubscribeDirective>; };它的核心职责是:订阅一个状态源(Store 或 Atom),并在状态或所选切片发生变化时,只高效更新被它包裹的模板部分。它定义于 packages/lit-table/src/subscribe-directive.ts:191,通过 Lit 的directive()工厂包装SubscribeDirective类导出:
export const subscribe = directive(SubscribeDirective) as { <TSource>( source: SelectionSource<TSource>, template: TemplateFunction<TSource>, ): DirectiveResult<typeof SubscribeDirective> <TSource, TSelected>( source: SelectionSource<TSource>, selector: Selector<TSource, TSelected>, template: TemplateFunction<TSelected>, ): DirectiveResult<typeof SubscribeDirective> }它与TableController上暴露的table.subscribe是同一个函数(TableController.ts 中直接以subscribe作为实例方法返回),因此既能独立使用,也能通过表格实例调用。
两种调用签名
签名一:订阅完整状态(无 selector)
<TSource>(source, template): DirectiveResult<typeof SubscribeDirective>这种形式不加过滤地订阅整个源状态。只要源状态发生任何变化,模板就会重新渲染。
source:类型为SelectionSource<TSource>,即 Store 或 Atom(详见下文"状态源类型")。template:类型为TemplateFunction<TSource>,接收完整状态并返回渲染内容(通常是TemplateResult)。
签名二:通过 selector 订阅状态切片(有 selector)
<TSource, TSelected>(source, selector, template): DirectiveResult<typeof SubscribeDirective>这种形式通过 selector 订阅源状态的特定切片,当状态的其它部分变化时不会触发不必要的重渲染。
source:SelectionSource<TSource>,同上。selector:Selector<TSource, TSelected>,从完整状态中提取或派生所需切片。template:TemplateFunction<TSelected>,只接收被选中的状态切片。
参考文档给出的两个典型示例:
// Without a selector (subscribes to entire state) html`<div>${subscribe(myStore, (state) => html`<span>${state.count}</span>`)}</div>` // With a selector (only updates when `count` changes) html`<div>${subscribe(myStore, state => state.count, (count) => html`<span>${count}</span>`)}</div>`第一个例子中,myStore的任何字段变化都会触发模板更新;第二个例子中,只有count切片变化才会更新模板,其它字段变化时模板保持不动——这正是细粒度订阅的价值所在。
核心类型解读
SelectionSource:可订阅的状态源
SelectionSource定义在 packages/lit-table/src/subscribe-directive.ts:13,是 subscribe 第一参数可接受的类型联合:
export type SelectionSource<TValue> = Atom<TValue> | ReadonlyAtom<TValue> | Store<TValue> | ReadonlyStore<TValue>即来自@tanstack/lit-store的四种响应式原语都可以作为订阅源。在表格场景中,最常见的用法是传入table.store(整表状态 Store)或table.atoms.<slice>(某个具体状态切片的 Atom,如table.atoms.rowSelection、table.atoms.columnFilters)。对应 API 文档见 docs/framework/lit/reference/type-aliases/SelectionSource.md。
Selector:状态切片函数
type Selector<TSource, TSelected> = (state: TSource) => TSelected一个从完整状态中提取或派生状态切片的普通函数。选择器越"窄",模板受无关状态变化的影响就越小。
TemplateFunction:渲染函数
type TemplateFunction<TSelected> = (value: TSelected) => unknown接收被选中的状态,返回内容(通常是TemplateResult)供 Lit 渲染。返回值不限于TemplateResult,也可以是其它 Lit 可渲染内容。
底层实现原理:SubscribeDirective 是如何工作的
subscribe的底层是SubscribeDirective类(packages/lit-table/src/subscribe-directive.ts:43),它继承自 Lit 的AsyncDirective,内部用TanStackStoreSelector控制器(来自@tanstack/lit-store)管理对 Store/Atom 的订阅。
用"伪 ReactiveControllerHost"桥接两个生命周期
Lit 的AsyncDirective生命周期(render/update/disconnected/reconnected)与 TanStack 控制器依赖的ReactiveControllerHost并不相同。源码通过createFakeHost()构造了一个模拟宿主来桥接二者(subscribe-directive.ts:163):
private createFakeHost(): ReactiveControllerHost { return { addController: () => {}, removeController: () => {}, requestUpdate: () => { if (this.resolvedTemplate && this.controller) { this.setValue(this.resolvedTemplate(this.controller.value)) } }, get updateComplete() { return Promise.resolve(true) }, } }关键点在于requestUpdate():当 TanStack 状态发生变化并通知控制器时,它调用AsyncDirective的setValue()将最新渲染结果直接写入指令所在位置,从而只更新被包裹的那一小段 DOM,而不会触发宿主组件整体重渲染。
实际渲染发生在 update 而不是 render
render()方法统一返回noChange(subscribe-directive.ts:78-85),真正的渲染逻辑在update()中完成,以确保模板只在必要时被求值。update()的核心流程如下:
- 参数归一化:通过
template === undefined判断是"无 selector"形式还是"有 selector"形式。无 selector 时用identitySelector作为默认选择器(subscribe-directive.ts:35),有 selector 时使用传入的选择器。 - 判断是否需要重新建立订阅:当
source或selector引用发生变化时(shouldReinitialize),会先断开旧控制器,再创建新的TanStackStoreSelector并调用hostUpdate()(subscribe-directive.ts:104-134)。这也解释了为什么示例代码中会强调"保持 selector 引用稳定"——引用稳定即可跳过重建,避免重复订阅。 - 总是采用最新的模板闭包:源码注释明确指出(subscribe-directive.ts:109-113),模板闭包每次宿主渲染都会被重新创建并捕获外层渲染作用域(如表格包装器、行模型)中的值,因此必须始终采用最新闭包,否则订阅驱动的更新会继续沿用上一次渲染捕获的陈旧值。
- 立即渲染当前值:返回
resolvedTemplate(latestSelector(latestSource.get())),保证宿主驱动渲染时也能拿到最新状态。
断开与重连
disconnected():指令从 DOM 移除时调用controller.hostDisconnected()清理订阅(subscribe-directive.ts:147-149)。reconnected():指令重新挂载时调用controller.hostUpdate()恢复订阅,并立即用当前值重新渲染一次(subscribe-directive.ts:152-157)。
实战:在 Lit 表格中实现细粒度重渲染
仓库中的 examples/lit/basic-subscribe/src/main.ts 是该指令的完整实战范例。这个示例镜像了 React 版的basic-subscribe:UI 的每个部分只订阅它所需的状态切片,因此"切换一行选择、在过滤框打字、翻页"都只重渲染受影响的区域,而不是整张表格。示例注释还特别提醒:只有在真正遇到性能问题时才需要采用这些模式。
表格初始化与"默认不订阅任何状态"
示例通过TableController创建表格,并在第二个参数传入() => null作为选择器,使宿主组件默认不订阅任何表格状态,把细粒度响应式完全交给table.subscribe的各个"孤岛"(basic-subscribe/src/main.ts:138-141):
private table = this.tableController.table( this.tableOptions(), () => null, // subscribe to no table state by default; use table.subscribe below )从 TableController.ts:232-248 可以看到其原理:_setupSubscriptions()中,当存在 selector 时用shallow浅比较判断选中状态是否真的变化,若未变化则跳过host.requestUpdate()。() => null每次比较都相等,于是宿主级更新被完全"关掉",更新压力被推给各订阅孤岛。
场景一:全局过滤框——只在 globalFilter 变化时重渲染
${this.table.subscribe( this.table.store, (state) => state.globalFilter, (globalFilter) => html` <input type="text" .value=${globalFilter ?? ''} @input=${(e: InputEvent) => this.table.setGlobalFilter((e.currentTarget as HTMLInputElement).value)} ... /> `, )}场景二:表体——只在过滤/分页变化时重渲染
表体是数据量大、渲染代价最高的区域。示例将其绑定到"列过滤 + 全局过滤 + 分页"三个切片(basic-subscribe/src/main.ts:145-149),并保持 selector 引用稳定以便指令跳过重建:
private getBodyState = (state: ReturnType<typeof this.table.store.get>) => ({ columnFilters: state.columnFilters, globalFilter: state.globalFilter, pagination: state.pagination, })然后在模板中使用:
${this.table.subscribe( this.table.store, this.getBodyState, () => html` <tbody> ${repeat( this.table.getRowModel().rows, (row) => row.id, (row) => html`<tr>...</tr>`, )} </tbody> ... `, )}这样,当用户输入过滤条件或翻页时只有<tbody>重渲染,表头、分页控件、选择摘要各自保持不动。
场景三:行选择——每行只订阅自己的选择值
这是最能体现"细粒度"的用法(basic-subscribe/src/main.ts:73-85):每一行的复选框只订阅table.atoms.rowSelection中对应自己row.id的那个布尔值,因此切换某一行时,只有该行的复选框重渲染:
cell: ({ row, table }) => subscribe( table.atoms.rowSelection, (rowSelection) => rowSelection[row.id], (isRowSelected) => html` <input type="checkbox" .checked=${!!isRowSelected} ?disabled=${!row.getCanSelect()} @click=${row.getToggleSelectedHandler()} /> `, ),注意这里使用的是独立的rowSelectionAtom(通过createAtom从@tanstack/lit-store创建,见 basic-subscribe/src/main.ts:112),并通过atoms: { rowSelection: rowSelectionAtom }选项注入表格,把行选择切片提升为外部 Atom 以完全掌控其更新范围。
场景四:外部 Atom 直接订阅
subscribe同样可以直接订阅外部创建的 Atom。示例中"全页选择"复选框和"选中行数摘要"都直接订阅rowSelectionAtom:
${this.table.subscribe( rowSelectionAtom, (rowSelection) => html` <div> ${Object.keys(rowSelection).length.toLocaleString()} of ... Total Rows Selected </div> `, )}场景五:调试——订阅完整状态
用于调试时,可以不加 selector 订阅完整状态,任何状态变化都会刷新该区域(basic-subscribe/src/main.ts:430-434):
${this.table.subscribe( this.table.store, (state) => state, (state) => html` <pre>${JSON.stringify(state, null, 2)}</pre> `, )}最佳实践与注意事项
综合参考文档、源码注释与示例代码,可以归纳出以下实践要点:
- 选择器越窄越好:selector 只挑选模板真正依赖的状态切片,其它切片变化时该模板不会重渲染,这是控制渲染范围的核心手段。
- 保持 selector 引用稳定:从 subscribe-directive.ts:104-107 可见,指令会在 source 或 selector 引用变化时销毁并重建订阅。把 selector 提取为类字段(如示例中的
getBodyState)可以避免宿主每次渲染都重建订阅。 - 优先订阅 Atom 而非整表 Store:对于行选择这类热点状态,直接订阅
table.atoms.rowSelection的对应切片,能让更新范围精确到单个复选框。 - 只在有真实性能问题时使用:示例源码的头部注释(basic-subscribe/src/main.ts:24-30)明确提示这些模式应"在有真实性能问题时才使用",避免不必要的复杂度。
- 宿主级与指令级订阅可以分层:
TableController.table(options, selector)的第二参数控制宿主组件的更新粒度,而table.subscribe控制模板片段的更新粒度,两者配合可以实现从"整组件重渲染"到"单指令重渲染"的完整分层控制。 - 默认不订阅的"孤岛"模式:当数据量大时,可让宿主不订阅任何表格状态(
() => null),把所有响应式更新都收敛到具体的订阅孤岛上,从而获得接近手工 DOM 更新的性能表现。
与 TableController 的协作关系
subscribe虽然可以脱离表格独立使用(直接传入任意SelectionSource),但在 Lit 表格生态中它通常是 TableController 工作流的一部分:
TableController.table()每次渲染返回的LitTable实例上挂着subscribe、state、FlexRender三个增强属性(TableController.ts:26-94);- 表格通过 reactivity.ts 中的
litReactivity()绑定@tanstack/table-core的renderPhaseReactivity,并复用@tanstack/lit-store的createAtom与batch,保证table.store、table.atoms.*与用户创建的外部 Atom 共享同一套响应式实例(reactivity.ts:18-20); subscribe与LitTable的类型信息统一由 packages/lit-table/src/index.ts 导出。
此外,SubscribeDirective类的完整 API 文档见 docs/framework/lit/reference/classes/SubscribeDirective.md。想快速运行验证,可以在 examples/lit/basic-subscribe 目录下安装依赖并启动示例(其package.json配置了标准的 Vite + Lit 开发环境,tests/e2e/smoke.spec.ts中的 Playwright 冒烟测试会验证表格渲染、数据重新生成等基本行为)。当表格数据量增长到十万级甚至百万级时,这套"按切片订阅"的机制就是保持界面流畅的关键。
- 前端
- UI组件
【免费下载链接】table
🤖 Headless UI for building powerful tables & datagrids for TS/JS - React-Table, Vue-Table, Solid-Table, Svelte-Table
相关推荐
TanStack Table Lit 适配器 SelectionSource 类型解析:Atom 与 Store 驱动的细粒度订阅机制
TanStack Table Lit 适配器 SelectionSource 类型解析:Atom 与 Store 驱动的细粒度订阅机制 本篇技术指南围绕 Tan
前端UI组件TanStack Table Lit 适配器的 SubscribeDirective:基于 lit-store 的模板级细粒度状态订阅
TanStack Table Lit 适配器的 SubscribeDirective:基于 lit store 的模板级细粒度状态订阅 导读 Subscribe
前端UI组件基于 TanStack Table Preact 适配器的 API 参考指南:useTable、createTableHook 与 Subscribe 细粒度订阅机制深度解析
基于 TanStack Table Preact 适配器的 API 参考指南:useTable、createTableHook 与 Subscribe 细粒度订
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考