关于 tanstack-virtual
TanStack Virtual 为大型列表、网格和表格提供虚拟化逻辑,面向需要渲染大量数据的界面开发者。它只计算视口内的项目并进行定位,以减少 DOM 节点数量;核心包不依赖特定框架。
主要功能
- 01仅渲染视口可见项目
- 02支持列表、网格和表格
- 03支持动态尺寸测量
- 04支持横向与窗口滚动
- 05可控制滚动定位与超额渲染
适用场景
- 01展示包含大量项目的列表
- 02创建虚拟化网格或表格
- 03处理高度不固定的列表项
- 04为长列表添加无限滚动
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ tanstack-skills/ tanstack-skills --skill tanstack-virtual安装后在 Claude Code 中输入/tanstack-virtual确认已加载。
详细介绍
概述
TanStack Virtual 提供虚拟化逻辑,用于在大型列表、网格和表格中仅渲染可见项。它会计算视口中的项,并通过绝对定位放置它们,无论数据集大小如何,都能将 DOM 节点数量保持在最低水平。
软件包: @tanstack/react-virtual
核心: @tanstack/virtual-core(与框架无关)
安装
npm install @tanstack/react-virtual
核心模式
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList() {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // estimated row height in px
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
Row {virtualItem.index}
</div>
))}
</div>
</div>
)
}
Virtualizer 选项
必需
| 选项 | 类型 | 描述 |
|---|---|---|
count | number | 项目总数 |
getScrollElement | () => Element | null | 返回滚动容器 |
estimateSize | (index) => number | 估算项目大小(建议高估) |
可选
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
overscan | number | 1 | 超出视口额外渲染的项目数 |
horizontal | boolean | false | 水平虚拟化 |
gap | number | 0 | 项目间距(px) |
lanes | number | 1 | 轨道数(瀑布流/网格) |
paddingStart | number | 0 | 第一项之前的内边距 |
paddingEnd | number | 0 | 最后一项之后的内边距 |
scrollPaddingStart | number | 0 | scrollTo 定位的偏移量 |
scrollPaddingEnd | number | 0 | scrollTo 定位的偏移量 |
initialOffset | number | 0 | 初始滚动位置 |
initialRect | Rect | - | 初始尺寸(SSR) |
enabled | boolean | true | 启用/禁用 |
getItemKey | (index) => Key | (i) => i | 项目的稳定键 |
rangeExtractor | (range) => number[] | 默认值 | 自定义可见索引 |
scrollToFn | (offset, options, instance) => void | 默认值 | 自定义滚动行为 |
measureElement | (el, entry, instance) => number | 默认值 | 自定义测量 |
onChange | (instance, sync) => void | - | 状态变化回调 |
isScrollingResetDelay | number | 150 | 滚动完成前的延迟 |
Virtualizer API
// Get visible items
virtualizer.getVirtualItems(): VirtualItem[]
// Get total scrollable size
virtualizer.getTotalSize(): number
// Scroll to specific index
virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' })
// Scroll to offset
virtualizer.scrollToOffset(offset, options)
// Force recalculation
virtualizer.measure()
VirtualItem 属性
interface VirtualItem {
key: Key // Unique key
index: number // Index in source data
start: number // Pixel offset (use for transform)
end: number // End pixel offset
size: number // Item dimension
lane: number // Lane index (multi-column)
}
动态/可变高度
对于高度未知的项目,使用 measureElement ref:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // overestimate
})
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index} // REQUIRED for measurement
ref={virtualizer.measureElement} // Attach for dynamic measurement
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
// Do NOT set fixed height - let content determine it
}}
>
{items[virtualItem.index].content}
</div>
))}
水平虚拟化
const virtualizer = useVirtualizer({
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
horizontal: true,
})
// Use width for container, translateX for positioning
<div style={{ width: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((item) => (
<div style={{
position: 'absolute',
height: '100%',
width: `${item.size}px`,
transform: `translateX(${item.start}px)`,
}}>
Column {item.index}
</div>
))}
</div>
网格虚拟化(两个 Virtualizer)
function VirtualGrid() {
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
})
const columnVirtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
horizontal: true,
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: '500px', width: '500px', overflow: 'auto' }}>
<div style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative',
}}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
}}
>
Cell {virtualRow.index},{virtualColumn.index}
</div>
))}
</Fragment>
))}
</div>
</div>
)
}
窗口滚动
import { useWindowVirtualizer } from '@tanstack/react-virtual'
function WindowList() {
const listRef = useRef<HTMLDivElement>(null)
const virtualizer = useWindowVirtualizer({
count: 10000,
estimateSize: () => 45,
overscan: 5,
scrollMargin: listRef.current?.offsetTop ?? 0,
})
return (
<div ref={listRef}>
<div style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{
position: 'absolute',
height: `${item.size}px`,
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
}}
>
Row {item.index}
</div>
))}
</div>
</div>
)
}
无限滚动
import { useVirtualizer } from '@tanstack/react-virtual'
import { useInfiniteQuery } from '@tanstack/react-query'
function InfiniteList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
const allItems = data?.pages.flatMap((page) => page.items) ?? []
const virtualizer = useVirtualizer({
count: hasNextPage ? allItems.length + 1 : allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
})
useEffect(() => {
const items = virtualizer.getVirtualItems()
const lastItem = items[items.length - 1]
if (lastItem && lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allItems.length])
// Render virtual items, show loader row for last item if loading
}
粘性项目
import { defaultRangeExtractor, Range } from '@tanstack/react-virtual'
const stickyIndexes = [0, 10, 20, 30] // Header indices
const virtualizer = useVirtualizer({
count: 1000,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
rangeExtractor: useCallback((range: Range) => {
const next = new Set([...stickyIndexes, ...defaultRangeExtractor(range)])
return [...next].sort((a, b) => a - b)
}, [stickyIndexes]),
})
// Render sticky items with position: sticky; top: 0; zIndex: 1
平滑滚动
const virtualizer = useVirtualizer({
scrollToFn: (offset, { behavior }, instance) => {
if (behavior === 'smooth') {
// Custom easing animation
instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' })
} else {
instance.scrollElement?.scrollTo({ top: offset })
}
},
})
// Usage
virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' })
最佳实践
- 高估
estimateSize- 防止滚动跳动(项目变小会导致问题) - 增大
overscan(3-5),减少快速滚动时的空白闪烁 - 使用
transform: translateY()替代top,以便通过 GPU 合成定位 - 使用
measureElement进行动态尺寸测量时,添加data-index属性 - 不要为动态测量的项目设置固定高度
- 项目可能重新排序时,使用
getItemKey获取稳定键 - 使用
gap选项而不是外边距(外边距会干扰测量) - 使用
paddingStart/End,而不是在容器上设置 CSS 内边距 - 列表隐藏时,使用
enabled: false暂停 - 记忆化回调(
estimateSize、getItemKey、rangeExtractor) - 在项目上使用
will-change: transformCSS,以加速 GPU 渲染
常见问题
- 为动态测量的项目设置固定高度
- 使用 CSS 外边距而不是
gap选项 - 使用
measureElement时忘记添加data-index - 未在内部容器上设置
position: relative - 低估
estimateSize(会导致滚动跳动) - 快速滚动时将
overscan设得过低(出现空白项目) - 窗口滚动时忘记从
translateY中减去scrollMargin - 未对
estimateSize函数进行记忆化(会导致重新渲染)
评论
支持 Markdown