mcpField
T
tanstack-virtual

tanstack-virtual

github.com/tanstack-skills开发362.5k 次安装39 周前更新
代码仓库

关于 tanstack-virtual

TanStack Virtual 为大型列表、网格和表格提供虚拟化逻辑,面向需要渲染大量数据的界面开发者。它只计算视口内的项目并进行定位,以减少 DOM 节点数量;核心包不依赖特定框架。

主要功能

  1. 01仅渲染视口可见项目
  2. 02支持列表、网格和表格
  3. 03支持动态尺寸测量
  4. 04支持横向与窗口滚动
  5. 05可控制滚动定位与超额渲染

适用场景

  1. 01展示包含大量项目的列表
  2. 02创建虚拟化网格或表格
  3. 03处理高度不固定的列表项
  4. 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 选项

必需
选项类型描述
countnumber项目总数
getScrollElement() => Element | null返回滚动容器
estimateSize(index) => number估算项目大小(建议高估)
可选
选项类型默认值描述
overscannumber1超出视口额外渲染的项目数
horizontalbooleanfalse水平虚拟化
gapnumber0项目间距(px)
lanesnumber1轨道数(瀑布流/网格)
paddingStartnumber0第一项之前的内边距
paddingEndnumber0最后一项之后的内边距
scrollPaddingStartnumber0scrollTo 定位的偏移量
scrollPaddingEndnumber0scrollTo 定位的偏移量
initialOffsetnumber0初始滚动位置
initialRectRect-初始尺寸(SSR)
enabledbooleantrue启用/禁用
getItemKey(index) => Key(i) => i项目的稳定键
rangeExtractor(range) => number[]默认值自定义可见索引
scrollToFn(offset, options, instance) => void默认值自定义滚动行为
measureElement(el, entry, instance) => number默认值自定义测量
onChange(instance, sync) => void-状态变化回调
isScrollingResetDelaynumber150滚动完成前的延迟

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' })

最佳实践

  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. 记忆化回调(estimateSize、getItemKey、rangeExtractor)
  11. 在项目上使用 will-change: transform CSS,以加速 GPU 渲染

常见问题

  • 为动态测量的项目设置固定高度
  • 使用 CSS 外边距而不是 gap 选项
  • 使用 measureElement 时忘记添加 data-index
  • 未在内部容器上设置 position: relative
  • 低估 estimateSize(会导致滚动跳动)
  • 快速滚动时将 overscan 设得过低(出现空白项目)
  • 窗口滚动时忘记从 translateY 中减去 scrollMargin
  • 未对 estimateSize 函数进行记忆化(会导致重新渲染)

评论

支持 Markdown