- 前端
- UI组件
【免费下载链接】virtual
🤖 Headless UI for Virtualizing Large Element Lists in JS/TS, React, Solid, Vue and Svelte
本文基于本仓库中的 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> ... ` } }该组件的关键设计可拆解为四层结构:
- 滚动容器:一个带
overflow: auto的普通<div>,通过 Lit 的ref指令(ref(this.scrollElementRef))把 DOM 引用交给getScrollElement。在本例中滚动容器高度为 200px(见.scroll-container样式)。 - 撑高容器(sizer):内层的空
<div>使用position: relative,其高度设置为virtualizer.getTotalSize()。getTotalSize()返回所有虚拟化项的总像素高度,它决定了滚动条的实际长度。垂直列表算高度(height: ${totalSize}px),水平列表则算宽度。 - 虚拟项渲染:通过
repeat指令按virtualRow.key渲染当前可见项。每一项使用position: absolute+translateY(${virtualRow.start}px)定位,高度固定为virtualRow.size。 - 虚拟器控制器:
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。固定尺寸示例用到的核心选项及其默认值如下:
| 选项 | 类型 | 默认值 | 本例取值 | 说明 |
|---|---|---|---|---|
count | number | 必填 | 10000 /sentences.length | 虚拟化项的总数 |
getScrollElement | () => TScrollElement \| null | 必填 | 返回scrollElementRef.value | 返回滚动容器,未挂载时可返回 null |
estimateSize | (index) => number | 必填 | () => 35/() => 100 | 每项尺寸。固定尺寸下直接返回常量 |
overscan | number | 1 | 5 | 视口上下额外渲染的项数,越大越不易出现滚动白屏,但渲染开销越高 |
horizontal | boolean | false | 列/网格的列控制器为 true | 是否水平滚动 |
paddingStart/paddingEnd | number | 0 | 未用 | 内容首尾的内边距 |
scrollPaddingStart/scrollPaddingEnd | number | 0 | 未用 | scrollToIndex等滚动定位时预留的边距 |
gap | number | 0 | 未用 | 项与项之间的间距(像素) |
scrollMargin | number | 0 | 未用 | 列表起始点与滚动元素起点之间的偏移,常用于页头或同页多虚拟器场景 |
lanes | number | 1 | 未用 | 多列瀑布流场景的通道数 |
isScrollingResetDelay | number | 150 | 未用 | 最后一次滚动事件后重置isScrolling的等待毫秒数 |
useScrollendEvent | boolean | false | 未用 | 是否改用原生scrollend事件判定滚动结束 |
debug | boolean | false | 未用 | 开启调试日志 |
initialOffset | number | (() => 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 的渲染写法,可以总结出固定尺寸虚拟化的“渲染契约”:
- 外层容器负责滚动(
overflow: auto)并用ref暴露给getScrollElement; - 撑高容器持有
position: relative与总尺寸(getTotalSize()),为绝对定位的子项建立坐标基准; - 每个虚拟项使用
position: absolute+transform: translateY/translateX(start)定位,尺寸取virtualItem.size; 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引擎,因此本文学到的选项语义同样适用于其他框架适配器。
小结
通过本示例可以确认:
- 运行即所得:
npm install && npm run dev即可在本地看到行、列、网格三个固定尺寸虚拟化列表(README 中的npm run start与 package.json 实际脚本存在出入,运行时以dev为准)。 - 固定尺寸的核心是常量估算:
estimateSize返回固定值,配合position: absolute+translateY/X即可精确布局,无需 DOM 测量。 - 一维虚拟器组合出多维结构:行、列是同一个 API 的
horizontal开关,网格是两个控制器共享滚动容器嵌套渲染。 - 适配器是生命周期桥接:
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
相关推荐
Angular 固定尺寸虚拟滚动实战:基于 @tanstack/angular-virtual 的行、列与网格示例
Angular 固定尺寸虚拟滚动实战:基于 @tanstack/angular virtual 的行、列与网格示例 导读 本指南以仓库中的 Angular fi
前端UI组件用 TanStack Virtual 在 Lit 中实现动态尺寸虚拟化列表:从行、列到网格的完整实战
用 TanStack Virtual 在 Lit 中实现动态尺寸虚拟化列表:从行、列到网格的完整实战 导读 在 Web 前端处理上万条数据时,一次性把全部 DO
前端UI组件Angular 动态尺寸列表虚拟化实战:@tanstack/angular-virtual 官方示例的运行、构建与源码剖析
Angular 动态尺寸列表虚拟化实战:@tanstack/angular virtual 官方示例的运行、构建与源码剖析 本文围绕仓库中的 Angular 动
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考