tanstack-virtual

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Overview

概述

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:
@tanstack/react-virtual
Core:
@tanstack/virtual-core
(framework-agnostic)
TanStack Virtual 提供虚拟化逻辑,用于在大型列表、网格和表格中仅渲染可见项。它会计算哪些项位于视口内,并通过绝对定位来放置这些项,无论数据集多大,都能将DOM节点数量保持在最低水平。
包:
@tanstack/react-virtual
核心:
@tanstack/virtual-core
(与框架无关)

Installation

安装

bash
npm install @tanstack/react-virtual
bash
npm install @tanstack/react-virtual

Core 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

必填项

OptionTypeDescription
count
number
Total number of items
getScrollElement
() => Element | null
Returns scroll container
estimateSize
(index) => number
Estimated item size (overestimate recommended)
选项类型描述
count
number
项的总数
getScrollElement
() => Element | null
返回滚动容器
estimateSize
(index) => number
预估项尺寸(建议高估)

Optional

可选项

OptionTypeDefaultDescription
overscan
number
1
Extra items rendered beyond viewport
horizontal
boolean
false
Horizontal virtualization
gap
number
0
Gap between items (px)
lanes
number
1
Number of lanes (masonry/grid)
paddingStart
number
0
Padding before first item
paddingEnd
number
0
Padding after last item
scrollPaddingStart
number
0
Offset for scrollTo positioning
scrollPaddingEnd
number
0
Offset for scrollTo positioning
initialOffset
number
0
Starting scroll position
initialRect
Rect
-Initial dimensions (SSR)
enabled
boolean
true
Enable/disable
getItemKey
(index) => Key
(i) => i
Stable key for items
rangeExtractor
(range) => number[]
defaultCustom visible indices
scrollToFn
(offset, options, instance) => void
defaultCustom scroll behavior
measureElement
(el, entry, instance) => number
defaultCustom measurement
onChange
(instance, sync) => void
-State change callback
isScrollingResetDelay
number
150
Delay before scroll complete
选项类型默认值描述
overscan
number
1
视口外额外渲染的项数量
horizontal
boolean
false
是否启用水平虚拟化
gap
number
0
项之间的间距(单位:px)
lanes
number
1
列数(用于瀑布流/网格)
paddingStart
number
0
第一项之前的内边距
paddingEnd
number
0
最后一项之后的内边距
scrollPaddingStart
number
0
滚动定位的偏移量
scrollPaddingEnd
number
0
滚动定位的偏移量
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

虚拟化器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
measureElement
ref for items with unknown heights:
tsx
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>
))}
对于高度未知的项,使用
measureElement
ref:
tsx
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: 1
tsx
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: 1

Smooth 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

最佳实践

  1. Overestimate
    estimateSize
    - prevents scroll jumps (items shrinking causes issues)
  2. Increase
    overscan
    (3-5) to reduce blank flashing during fast scrolling
  3. Use
    transform: translateY()
    over
    top
    for GPU-composited positioning
  4. Add
    data-index
    attribute
    when using
    measureElement
    for dynamic sizing
  5. Don't set fixed height on dynamically measured items
  6. Use
    getItemKey
    for stable keys when items can reorder
  7. Use
    gap
    option
    instead of margins (margins interfere with measurement)
  8. Use
    paddingStart/End
    instead of CSS padding on the container
  9. Use
    enabled: false
    to pause when the list is hidden
  10. Memoize callbacks (
    estimateSize
    ,
    getItemKey
    ,
    rangeExtractor
    )
  11. Use
    will-change: transform
    CSS on items for GPU acceleration
  1. 高估
    estimateSize
    - 防止滚动跳跃(项尺寸缩小会导致问题)
  2. 增大
    overscan
    (3-5)以减少快速滚动时的空白闪烁
  3. 使用
    transform: translateY()
    替代
    top
    实现GPU合成定位
  4. 使用
    measureElement
    时添加
    data-index
    属性
    用于动态尺寸测量
  5. 不为动态测量的项设置固定高度
  6. 当项可能重新排序时使用
    getItemKey
    生成稳定键值
  7. 使用
    gap
    选项替代外边距
    (外边距会干扰尺寸测量)
  8. 使用
    paddingStart/End
    替代容器的CSS内边距
  9. 列表隐藏时设置
    enabled: false
    暂停虚拟化
  10. 回调函数使用memo化
    estimateSize
    getItemKey
    rangeExtractor
  11. 为项添加
    will-change: transform
    CSS属性
    启用GPU加速

Common Pitfalls

常见陷阱

  • Setting fixed height on dynamically measured items
  • Using CSS margins instead of the
    gap
    option
  • Forgetting
    data-index
    with
    measureElement
  • Not providing
    position: relative
    on the inner container
  • Underestimating
    estimateSize
    (causes scroll jumps)
  • Setting
    overscan
    too low for fast scrolling (blank items)
  • Forgetting to subtract
    scrollMargin
    from
    translateY
    in window scrolling
  • Not memoizing the
    estimateSize
    function (causes re-renders)
  • 为动态测量的项设置固定高度
  • 使用CSS外边距替代
    gap
    选项
  • 使用
    measureElement
    时忘记添加
    data-index
  • 内部容器未设置
    position: relative
  • 低估
    estimateSize
    (导致滚动跳跃)
  • overscan
    值设置过低,快速滚动时出现空白项
  • 窗口滚动时忘记从
    translateY
    中减去
    scrollMargin
  • 未对
    estimateSize
    函数进行memo化(导致重复渲染)