tanstack-virtual
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseOverview
概述
TanStack Virtual provides virtualization logic for rendering only visible items in large lists, grids, and tables. It calculates which items are in the viewport and positions them with absolute positioning, keeping DOM node count minimal regardless of dataset size.
Package:
Core: (framework-agnostic)
@tanstack/react-virtual@tanstack/virtual-coreTanStack Virtual 提供虚拟化逻辑,用于在大型列表、网格和表格中仅渲染可见项。它会计算哪些项位于视口内,并通过绝对定位来放置这些项,无论数据集多大,都能将DOM节点数量保持在最低水平。
包:
核心: (与框架无关)
@tanstack/react-virtual@tanstack/virtual-coreInstallation
安装
bash
npm install @tanstack/react-virtualbash
npm install @tanstack/react-virtualCore Pattern
核心模式
tsx
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>
)
}tsx
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList() {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // 预估行高(单位: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)`,
}}
>
行 {virtualItem.index}
</div>
))}
</div>
</div>
)
}Virtualizer Options
虚拟化器选项
Required
必填项
| Option | Type | Description |
|---|---|---|
| | Total number of items |
| | Returns scroll container |
| | Estimated item size (overestimate recommended) |
| 选项 | 类型 | 描述 |
|---|---|---|
| | 项的总数 |
| | 返回滚动容器 |
| | 预估项尺寸(建议高估) |
Optional
可选项
| Option | Type | Default | Description |
|---|---|---|---|
| | | Extra items rendered beyond viewport |
| | | Horizontal virtualization |
| | | Gap between items (px) |
| | | Number of lanes (masonry/grid) |
| | | Padding before first item |
| | | Padding after last item |
| | | Offset for scrollTo positioning |
| | | Offset for scrollTo positioning |
| | | Starting scroll position |
| | - | Initial dimensions (SSR) |
| | | Enable/disable |
| | | Stable key for items |
| | default | Custom visible indices |
| | default | Custom scroll behavior |
| | default | Custom measurement |
| | - | State change callback |
| | | Delay before scroll complete |
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| | | 视口外额外渲染的项数量 |
| | | 是否启用水平虚拟化 |
| | | 项之间的间距(单位:px) |
| | | 列数(用于瀑布流/网格) |
| | | 第一项之前的内边距 |
| | | 最后一项之后的内边距 |
| | | 滚动定位的偏移量 |
| | | 滚动定位的偏移量 |
| | | 初始滚动位置 |
| | - | 初始尺寸(用于SSR) |
| | | 启用/禁用虚拟化 |
| | | 项的稳定键值 |
| | 默认 | 自定义可见索引范围 |
| | 默认 | 自定义滚动行为 |
| | 默认 | 自定义尺寸测量 |
| | - | 状态变化回调函数 |
| | | 滚动结束前的延迟时间 |
Virtualizer API
虚拟化器API
typescript
// 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()typescript
// 获取可见项
virtualizer.getVirtualItems(): VirtualItem[]
// 获取总滚动尺寸
virtualizer.getTotalSize(): number
// 滚动到指定索引
virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' })
// 滚动到指定偏移量
virtualizer.scrollToOffset(offset, options)
// 强制重新计算
virtualizer.measure()VirtualItem Properties
VirtualItem 属性
typescript
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)
}typescript
interface VirtualItem {
key: Key // 唯一键值
index: number // 在源数据中的索引
start: number // 像素偏移量(用于transform)
end: number // 结束像素偏移量
size: number // 项的尺寸
lane: number // 列索引(多列布局)
}Dynamic/Variable Heights
动态/可变高度
Use ref for items with unknown heights:
measureElementtsx
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>
))}对于高度未知的项,使用 ref:
measureElementtsx
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 高估尺寸
})
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index} // 测量时必填
ref={virtualizer.measureElement} // 附加用于动态测量
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
// 不要设置固定高度 - 让内容决定高度
}}
>
{items[virtualItem.index].content}
</div>
))}Horizontal Virtualization
水平虚拟化
tsx
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>tsx
const virtualizer = useVirtualizer({
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
horizontal: true,
})
// 容器使用width,定位使用translateX
<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)`,
}}>
列 {item.index}
</div>
))}
</div>Grid Virtualization (Two Virtualizers)
网格虚拟化(双虚拟化器)
tsx
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>
)
}tsx
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)`,
}}
>
单元格 {virtualRow.index},{virtualColumn.index}
</div>
))}
</Fragment>
))}
</div>
</div>
)
}Window Scrolling
窗口滚动
tsx
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>
)
}tsx
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)`,
}}
>
行 {item.index}
</div>
))}
</div>
</div>
)
}Infinite Scrolling
无限滚动
tsx
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
}tsx
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])
// 渲染虚拟项,加载时在最后一项显示加载器
}Sticky Items
粘性项
tsx
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: 1tsx
import { defaultRangeExtractor, Range } from '@tanstack/react-virtual'
const stickyIndexes = [0, 10, 20, 30] // 标题索引
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]),
})
// 渲染粘性项时使用position: sticky; top: 0; zIndex: 1Smooth Scrolling
平滑滚动
tsx
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' })tsx
const virtualizer = useVirtualizer({
scrollToFn: (offset, { behavior }, instance) => {
if (behavior === 'smooth') {
// 自定义缓动动画
instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' })
} else {
instance.scrollElement?.scrollTo({ top: offset })
}
},
})
// 使用示例
virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' })Best Practices
最佳实践
- Overestimate - prevents scroll jumps (items shrinking causes issues)
estimateSize - Increase (3-5) to reduce blank flashing during fast scrolling
overscan - Use over
transform: translateY()for GPU-composited positioningtop - Add attribute when using
data-indexfor dynamic sizingmeasureElement - Don't set fixed height on dynamically measured items
- Use for stable keys when items can reorder
getItemKey - Use option instead of margins (margins interfere with measurement)
gap - Use instead of CSS padding on the container
paddingStart/End - Use to pause when the list is hidden
enabled: false - Memoize callbacks (,
estimateSize,getItemKey)rangeExtractor - Use CSS on items for GPU acceleration
will-change: transform
- 高估- 防止滚动跳跃(项尺寸缩小会导致问题)
estimateSize - 增大值(3-5)以减少快速滚动时的空白闪烁
overscan - 使用替代
transform: translateY()实现GPU合成定位top - 使用时添加
measureElement属性 用于动态尺寸测量data-index - 不为动态测量的项设置固定高度
- 当项可能重新排序时使用生成稳定键值
getItemKey - 使用选项替代外边距(外边距会干扰尺寸测量)
gap - 使用替代容器的CSS内边距
paddingStart/End - 列表隐藏时设置暂停虚拟化
enabled: false - 回调函数使用memo化(、
estimateSize、getItemKey)rangeExtractor - 为项添加CSS属性 启用GPU加速
will-change: transform
Common Pitfalls
常见陷阱
- Setting fixed height on dynamically measured items
- Using CSS margins instead of the option
gap - Forgetting with
data-indexmeasureElement - Not providing on the inner container
position: relative - Underestimating (causes scroll jumps)
estimateSize - Setting too low for fast scrolling (blank items)
overscan - Forgetting to subtract from
scrollMarginin window scrollingtranslateY - Not memoizing the function (causes re-renders)
estimateSize
- 为动态测量的项设置固定高度
- 使用CSS外边距替代选项
gap - 使用时忘记添加
measureElementdata-index - 内部容器未设置
position: relative - 低估(导致滚动跳跃)
estimateSize - 值设置过低,快速滚动时出现空白项
overscan - 窗口滚动时忘记从中减去
translateYscrollMargin - 未对函数进行memo化(导致重复渲染)
estimateSize