news 2026/9/29 5:59:28

TanStack Virtual 的 Lit 适配器实战:固定尺寸行/列/网格虚拟化示例深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TanStack Virtual 的 Lit 适配器实战:固定尺寸行/列/网格虚拟化示例深度解析
  • 前端
  • UI组件

【免费下载链接】virtual

🤖 Headless UI for Virtualizing Large Element Lists in JS/TS, React, Solid, Vue and Svelte

项目地址:https://gitcode.com/gh_mirrors/vi/virtual
点击查看免费下载

本文基于本仓库中的 Lit 固定尺寸(fixed)示例,讲解如何使用@tanstack/lit-virtual的VirtualizerController在 Web Components(Lit)中实现行、列与网格三种固定尺寸虚拟化列表。读完本文,你将掌握示例的运行方式、三种虚拟化组件的完整实现、VirtualizerController的底层响应式机制,以及@tanstack/virtual-core中与固定尺寸场景相关的核心选项与默认值。

示例概况与运行方式

本示例位于 examples/lit/fixed,其官方说明(README.md)给出的运行步骤非常简短:

  • 安装依赖:npm install
  • 启动开发服务器:npm run start

不过对照该目录下的 package.json 可以发现,实际定义的脚本为dev(vite)、build(tsc && vite build)与serve(vite preview),并未定义start脚本。因此在实际运行时,请以 package.json 为准:

npm install npm run dev

开发服务器启动后,浏览器加载 index.html:

<body> <div id="root"></div> <script type="module" src="/src/main.ts"></script> <my-app></my-app> </body>

页面通过<script type="module">引入 src/main.ts,然后挂载一个名为my-app的自定义元素。该元素内部依次渲染三个自定义元素:<row-virtualizer-fixed>(行)、<column-virtualizer-fixed>(列)和<grid-virtualizer-fixed>(网格),并在顶部给出说明文字——这些组件使用固定尺寸,即每个元素的尺寸被硬编码为同一个常量且永不变化。

示例的依赖与版本(见 package.json):

  • @tanstack/lit-virtual(^3.14.2):Lit 适配器,提供VirtualizerController/WindowVirtualizerController
  • @tanstack/virtual-core(^3.17.11):框架无关的核心虚拟化引擎
  • lit(^3.3.0):组件基类与响应式控制器
  • @faker-js/faker(^8.4.1):生成测试数据
  • vite(^6.4.2)与typescript(5.9.3):构建工具链

固定尺寸行虚拟化:RowVirtualizerFixed

行虚拟化是最典型的垂直列表场景。核心实现如下(节选自 src/main.ts):

@customElement('row-virtualizer-fixed') class RowVirtualizerFixed extends LitElement { private scrollElementRef: Ref<HTMLDivElement> = createRef() private virtualizerController: VirtualizerController<HTMLDivElement, Element> constructor() { super() this.virtualizerController = new VirtualizerController(this, { getScrollElement: () => this.scrollElementRef.value, count: 10000, estimateSize: () => 35, overscan: 5, }) } render() { const virtualizer = this.virtualizerController.getVirtualizer() const virtualRows = virtualizer.getVirtualItems() return html` <div> <div class="list scroll-container" ${ref(this.scrollElementRef)}> <div style="position: relative; height: ${virtualizer.getTotalSize()}px; width: 100%;" > ${repeat( virtualRows, (virtualRow) => virtualRow.key, (virtualRow) => html` <div class="${virtualRow.index % 2 === 0 ? 'list-item-even' : 'list-item-odd'}" style="position: absolute; left: 0; top: 0; width: 100%; height: ${virtualRow.size}px; transform: translateY(${virtualRow.start}px)" > Row ${virtualRow.index} </div>`, )} </div> </div> </div> ... ` } }

该组件的关键设计可拆解为四层结构:

  1. 滚动容器:一个带overflow: auto的普通<div>,通过 Lit 的ref指令(ref(this.scrollElementRef))把 DOM 引用交给getScrollElement。在本例中滚动容器高度为 200px(见.scroll-container样式)。
  2. 撑高容器(sizer):内层的空<div>使用position: relative,其高度设置为virtualizer.getTotalSize()。getTotalSize()返回所有虚拟化项的总像素高度,它决定了滚动条的实际长度。垂直列表算高度(height: ${totalSize}px),水平列表则算宽度。
  3. 虚拟项渲染:通过repeat指令按virtualRow.key渲染当前可见项。每一项使用position: absolute+translateY(${virtualRow.start}px)定位,高度固定为virtualRow.size。
  4. 虚拟器控制器:new VirtualizerController(this, {...})将虚拟器绑定到 Lit 组件的生命周期上,getVirtualizer()在render()中取出Virtualizer实例,getVirtualItems()返回当前需要渲染的项数组。

这里体现了固定尺寸场景的核心思路:每一项都拥有确定的size与start(起始位置),因此可以用纯 CSS 变换精确摆放,无需在渲染后测量真实 DOM 尺寸。

固定尺寸列虚拟化:ColumnVirtualizerFixed

列虚拟化与行虚拟化几乎对称,区别仅在于开启horizontal: true:

@customElement('column-virtualizer-fixed') class ColumnVirtualizerFixed extends LitElement { private scrollElementRef: Ref<HTMLDivElement> = createRef() private virtualizerController: VirtualizerController<HTMLDivElement, Element> constructor() { super() this.virtualizerController = new VirtualizerController(this, { getScrollElement: () => this.scrollElementRef.value, count: sentences.length, estimateSize: () => 100, horizontal: true, }) } render() { const virtualizer = this.virtualizerController.getVirtualizer() const virtualColumns = virtualizer.getVirtualItems() return html` <div> <div class="list scroll-container" ${ref(this.scrollElementRef)}> <div style="position: relative; height: 100%; width: ${virtualizer.getTotalSize()}px;" > ${repeat( virtualColumns, (virtualColumn) => virtualColumn.key, (virtualColumn) => html` <div class="${virtualColumn.index % 2 === 0 ? 'list-item-even' : 'list-item-odd'}" style="position: absolute; left: 0; top: 0; height: 100%; width: ${virtualColumn.size}px; transform: translateX(${virtualColumn.start}px)" > Column ${virtualColumn.index} </div>`, )} </div> </div> </div> ... ` } }

与行版本的三处对照:

维度行虚拟化列虚拟化
选项horizontal缺省(false)horizontal: true
撑高容器height: ${totalSize}px,宽度 100%width: ${totalSize}px,高度 100%
项定位translateY(${start}px)translateX(${start}px)
项尺寸高度 =virtualRow.size宽度 =virtualColumn.size
滚动轴scrollTop(垂直)scrollLeft(水平)

数据方面,列的数量取自sentences.length(即预先生成的 10000 条 faker 句子),每条句子 20~70 个单词(faker.lorem.sentence(randomNumber(20, 70))),但每列宽度仍固定为estimateSize: () => 100。这正体现了“固定尺寸”的含义——内容数据可以各不相同,但虚拟化层使用的尺寸始终是常量。

固定尺寸网格虚拟化:GridVirtualizerFixed

网格示例演示了一个强大的特性:同一个滚动容器上同时挂载两个独立控制器,一个负责行、一个负责列:

@customElement('grid-virtualizer-fixed') class GridVirtualizerFixed extends LitElement { private scrollElementRef: Ref<HTMLDivElement> = createRef() private rowVirtualizerController: VirtualizerController<HTMLDivElement, Element> private columnVirtualizerController: VirtualizerController<HTMLDivElement, Element> constructor() { super() this.rowVirtualizerController = new VirtualizerController(this, { getScrollElement: () => this.scrollElementRef.value, count: sentences.length, estimateSize: () => 35, overscan: 5, }) this.columnVirtualizerController = new VirtualizerController(this, { getScrollElement: () => this.scrollElementRef.value, count: sentences.length, estimateSize: () => 100, horizontal: true, overscan: 5, }) } render() { const rowVirtualizer = this.rowVirtualizerController.getVirtualizer() const columnVirtualizer = this.columnVirtualizerController.getVirtualizer() return html` <div> <div class="list scroll-container" ${ref(this.scrollElementRef)}> <div style="position: relative; height: ${rowVirtualizer.getTotalSize()}px; width: ${columnVirtualizer.getTotalSize()}px;" > ${repeat( rowVirtualizer.getVirtualItems(), (virtualRow) => virtualRow.key, (virtualRow) => repeat( columnVirtualizer.getVirtualItems(), (virtualColumn) => virtualColumn.key, (virtualColumn) => html` <div class="..." style="position: absolute;left: 0; top: 0; width: ${virtualColumn.size}px; height: ${virtualRow.size}px; transform: translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)" > Cell ${virtualRow.index}, ${virtualColumn.index} </div> `, ), )} </div> </div> </div> ... ` } }

网格的撑高容器需要同时写入height与width:高度由行控制器决定(rowVirtualizer.getTotalSize()),宽度由列控制器决定(columnVirtualizer.getTotalSize())。单元格用repeat嵌套:外层遍历可见行,内层遍历可见列,每个单元格的宽高分别取virtualColumn.size与virtualRow.size,定位则同时使用translateX与translateY。

这种“双控制器”模式是 TanStack Virtual 实现网格虚拟化的官方范式——网格并不是一个独立的虚拟器类型,而是由两个正交的一维虚拟器组合而成,二者共享同一个滚动元素。

VirtualizerController:Lit 响应式控制器适配层

固定尺寸示例中反复出现的VirtualizerController定义在 packages/lit-virtual/src/index.ts,它是本仓库为 Lit 提供的一层薄封装(官方文档见 docs/framework/lit/lit-virtual.md)。其核心实现如下:

class VirtualizerControllerBase< TScrollElement extends Element | Window, TItemElement extends Element, > implements ReactiveController { host: ReactiveControllerHost private readonly virtualizer: Virtualizer<TScrollElement, TItemElement> private cleanup: () => void = () => {} constructor( host: ReactiveControllerHost, options: VirtualizerOptions<TScrollElement, TItemElement>, ) { const resolvedOptions = { ...options, onChange: (instance, sync) => { this.host.updateComplete.then(() => this.host.requestUpdate()) options.onChange?.(instance, sync) }, } this.virtualizer = new Virtualizer(resolvedOptions) ;(this.host = host).addController(this) } public getVirtualizer() { return this.virtualizer } hostConnected() { this.cleanup = this.virtualizer._didMount() } hostUpdated() { this.virtualizer._willUpdate() } hostDisconnected() { this.cleanup() } }

这段代码揭示了适配器与 Lit 生命周期钩子的映射关系,从源码结构看:

  • hostConnected()→virtualizer._didMount():当 Lit 元素被连接到文档时,虚拟器开始安装,内部会建立滚动监听与ResizeObserver,返回值是一个用于卸载的清理函数。
  • hostUpdated()→virtualizer._willUpdate():每次组件更新后,虚拟器同步读取最新的scrollElement、scrollRect 与 scrollOffset,并触发可见范围重算。
  • hostDisconnected()→cleanup():元素被移除时取消全部订阅,避免内存泄漏。
  • onChange桥接:虚拟器内部状态变化时,先等待host.updateComplete再调用host.requestUpdate(),把虚拟化计算的结果安全地送进 Lit 的渲染批次,避免在渲染中途写入 DOM。

在此基础上,VirtualizerController(元素滚动)与WindowVirtualizerController(窗口滚动)分别注入了不同的底层实现:

export class VirtualizerController<...> extends VirtualizerControllerBase<...> { constructor(host, options) { super(host, { observeElementRect: observeElementRect, observeElementOffset: observeElementOffset, scrollToFn: elementScroll, ...options, }) } } export class WindowVirtualizerController<...> extends VirtualizerControllerBase<Window, ...> { constructor(host, options) { super(host, { getScrollElement: () => (typeof document !== 'undefined' ? window : null), observeElementRect: observeWindowRect, observeElementOffset: observeWindowOffset, scrollToFn: windowScroll, initialOffset: () => (typeof document !== 'undefined' ? window.scrollY : 0), ...options, }) } }

从 packages/virtual-core/src/index.ts 的源码可以看出这四种内置实现的职责:

  • observeElementRect:用ResizeObserver(border-box)持续观测滚动容器的宽高,并支持useAnimationFrameWithResizeObserver选项决定是否延迟到下一帧处理;
  • observeElementOffset:监听滚动容器的scroll(及可选的原生scrollend)事件,按horizontal/isRtl选项读取scrollLeft或scrollTop;
  • elementScroll/windowScroll:基于scrollTo({ top/left, behavior })实现程序化滚动;
  • WindowVirtualizerController额外把getScrollElement默认指向window,initialOffset默认读取window.scrollY,适合整页滚动场景。

固定示例使用的是元素滚动控制器;仓库中的 dynamic 示例 也演示了同一 API 在动态尺寸场景下的用法,可作为对比参考。

固定尺寸选项速查:以 virtual-core 为准

虚拟器的全部选项定义于 packages/virtual-core/src/index.ts 的VirtualizerOptions接口,详细说明见 docs/api/virtualizer.md。固定尺寸示例用到的核心选项及其默认值如下:

选项类型默认值本例取值说明
countnumber必填10000 /sentences.length虚拟化项的总数
getScrollElement() => TScrollElement \| null必填返回scrollElementRef.value返回滚动容器,未挂载时可返回 null
estimateSize(index) => number必填() => 35/() => 100每项尺寸。固定尺寸下直接返回常量
overscannumber15视口上下额外渲染的项数,越大越不易出现滚动白屏,但渲染开销越高
horizontalbooleanfalse列/网格的列控制器为 true是否水平滚动
paddingStart/paddingEndnumber0未用内容首尾的内边距
scrollPaddingStart/scrollPaddingEndnumber0未用scrollToIndex等滚动定位时预留的边距
gapnumber0未用项与项之间的间距(像素)
scrollMarginnumber0未用列表起始点与滚动元素起点之间的偏移,常用于页头或同页多虚拟器场景
lanesnumber1未用多列瀑布流场景的通道数
isScrollingResetDelaynumber150未用最后一次滚动事件后重置isScrolling的等待毫秒数
useScrollendEventbooleanfalse未用是否改用原生scrollend事件判定滚动结束
debugbooleanfalse未用开启调试日志
initialOffsetnumber | (() => number)0未用首次渲染时的初始滚动位置,适合 SSR 与条件渲染

从setOptions的实现可以确认上述默认值(overscan: 1、paddingStart/End: 0、gap: 0、lanes: 1、isScrollingResetDelay: 150、useScrollendEvent: false等均在此处合并)。

虚拟项的形态与渲染契约

getVirtualItems()返回的每一项是VirtualItem(定义于 packages/virtual-core/src/index.ts,类型说明见 docs/api/virtual-item.md):

export interface VirtualItem { key: Key // number | string | bigint index: number // 在数据数组中的下标 start: number // 距列表起点的像素偏移 end: number // start + size size: number // 该项尺寸(垂直=高度,水平=宽度) lane: number // 所在通道(lanes > 1 时使用) }

配合 src/main.ts 的渲染写法,可以总结出固定尺寸虚拟化的“渲染契约”:

  1. 外层容器负责滚动(overflow: auto)并用ref暴露给getScrollElement;
  2. 撑高容器持有position: relative与总尺寸(getTotalSize()),为绝对定位的子项建立坐标基准;
  3. 每个虚拟项使用position: absolute+transform: translateY/translateX(start)定位,尺寸取virtualItem.size;
  4. repeat的 key 使用virtualItem.key(默认即 index,可用getItemKey覆盖为业务主键)。

为什么固定尺寸下可以采用“纯估算、不测量”的策略?因为estimateSize返回的常量就是真实尺寸,虚拟器无需在渲染后调用measureElement回读 DOM。这也解释了为何示例中getVirtualItems()返回的size与start可以直接用于布局——它们基于 10000 个等宽/等高元素的总和精确计算得出,滚动过程中元素数量巨大但 DOM 中始终只保留可见窗口附近的一小部分(本例overscan: 5,即视口外各多渲染 5 项)。

与动态尺寸、窗口虚拟化的关系

理解固定示例后,可以把它放入整个仓库示例体系里定位:

  • 固定 vs 动态:固定示例中estimateSize返回常量,元素尺寸永不改变;dynamic 示例 则让各项尺寸随内容变化,需要配合virtualizer.measureElement与ResizeObserver动态测量。两种模式在 API 层面完全一致,区别只在estimateSize的实现与是否启用测量。
  • 元素 vs 窗口:本示例使用VirtualizerController,滚动发生在容器元素上;若要让整个页面滚动,则改用WindowVirtualizerController(见 packages/lit-virtual/src/index.ts),其接口与用法相同,仅底层滚动目标不同。
  • 与其他框架示例的关系:本仓库为 React、Vue、Svelte、Solid、Angular、Marko 等提供了同构的 fixed/dynamic 示例(见 examples 目录),@tanstack/lit-virtual作为其中之一,遵循统一的@tanstack/virtual-core引擎,因此本文学到的选项语义同样适用于其他框架适配器。

小结

通过本示例可以确认:

  1. 运行即所得:npm install && npm run dev即可在本地看到行、列、网格三个固定尺寸虚拟化列表(README 中的npm run start与 package.json 实际脚本存在出入,运行时以dev为准)。
  2. 固定尺寸的核心是常量估算:estimateSize返回固定值,配合position: absolute+translateY/X即可精确布局,无需 DOM 测量。
  3. 一维虚拟器组合出多维结构:行、列是同一个 API 的horizontal开关,网格是两个控制器共享滚动容器嵌套渲染。
  4. 适配器是生命周期桥接:VirtualizerController通过hostConnected/hostUpdated/hostDisconnected与 Lit 响应式系统对接,把 @tanstack/virtual-core 的安装、更新与清理接入组件生命周期。

进一步阅读:适配器完整说明见 docs/framework/lit/lit-virtual.md,全部选项与实例方法见 docs/api/virtualizer.md,虚拟项结构见 docs/api/virtual-item.md。

  • 前端
  • UI组件

【免费下载链接】virtual

🤖 Headless UI for Virtualizing Large Element Lists in JS/TS, React, Solid, Vue and Svelte

项目地址:https://gitcode.com/gh_mirrors/vi/virtual
点击查看免费下载

相关推荐

上一篇:CANN ops-nn 算子指南:aclnnIndexFill 与 aclnnInplaceIndexFill 接口详解
下一篇:pymoo多目标决策实战指南:从Pareto前沿到最优方案选择的完整流程

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 5:58:12

PSI5-S 协议解析:TC264 两线制电流接口与时间槽解码

手上有块 TC264 的板子&#xff0c;又刚好要接一颗气压式碰撞传感器&#xff0c;翻规格书的时候第一次撞上 PSI5-S 这个词——Peripheral Sensor Interface with Serial PHY。我一开始以为它就是个换了个名字的 SPI&#xff0c;两根线同时管供电和通信&#xff0c;听起来又很像…

作者头像 李华
网站建设 2026/9/29 5:55:47

身体在“去繁就简”?解读中年7个变化信号,别误读为衰老

天还没亮&#xff0c;大概五点出头&#xff0c;你又醒了。翻来覆去睡不着&#xff0c;手机屏幕的光刺得眼睛发酸。身边人还在打呼&#xff0c;你却清醒得像被什么东西叫醒了一样。以前周末能睡到十一点&#xff0c;现在六点准时睁眼&#xff0c;连闹钟都成了摆设。再看一眼日程…

作者头像 李华