首页 / TanStack 生态入门教程 / Virtual 进阶:动态尺寸与网格

TanStack 生态入门教程

Virtual 进阶:动态尺寸与网格

本教程共 38 篇 · 第 36 篇 · 更新于 2026-07-27 · 约 11 分钟阅读

TanStackTanStack 生态入门教程TanStack Virtual动态尺寸measureElement网格虚拟化水平虚拟化Table 集成

36. Virtual 进阶:动态尺寸与网格

本节目标:掌握动态尺寸测量、水平虚拟化、网格虚拟化,以及与 TanStack Table 集成做大数据虚拟表格。学完你能虚拟化内容高度不固定的列表、水平滚动的列表、多列网格布局,以及万行级数据表格。

36.1 动态尺寸测量

上一章的列表,每条高度都是预估的固定值。但实际场景里,条目内容多少不一,高度各不相同。预估不准会导致滚动条跳变、条目错位。

动态尺寸测量(Dynamic Measurement) 在条目渲染后测量实际高度,用实际值替代预估值。

36.1.1 用 measureElement

virtualizer.measureElement 是一个 ref 回调函数,绑到条目的 DOM 元素上,虚拟化器会自动测量它的实际高度:

function DynamicHeightList({ items }: { items: { id: number; text: string }[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50, // 预估值,实际会用测量值替代
    getItemKey: (index) => items[index].id,
  })

  return (
    <div ref={parentRef} style={{ height: 500, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            ref={virtualizer.measureElement} // 测量实际高度
            data-index={virtualItem.index}    // 必须设 data-index
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {/* 内容高度不固定 */}
            <div style={{ padding: '12px', borderBottom: '1px solid #e5e7eb' }}>
              {items[virtualItem.index].text}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

关键改动:

  1. ref={virtualizer.measureElement} — 绑到条目的外层 div 上。
  2. data-index={virtualItem.index} — 必须设,虚拟化器靠它把测量结果和正确的条目关联。
  3. 不要设 height — 让内容撑开高度,虚拟化器来测量。
Note

动态测量时,style 里不要写 height: virtualItem.size。因为 virtualItem.size 初始是预估值,设了会限制元素高度,测不到真实值。让内容自然撑开高度即可。

36.1.2 测量的工作流程

  1. 条目首次渲染,用预估高度定位。
  2. measureElement(内部用 ResizeObserver)测量实际高度。
  3. 实际高度和预估不同时,更新该条目的尺寸缓存。
  4. 重新计算总高度和后续条目的位置。
  5. 条目在正确位置上稳定下来。

这个过程中你可能会看到短暂的位置跳动—预估位置和实际位置有偏差,测量后修正。estimateSize 越接近实际,跳动越不明显。

Tip

如果条目内容差异大(有的 1 行,有的 10 行),estimateSize 设成最常见的那个高度。比如大部分是 2 行约 60px,就设 estimateSize: () => 60

36.1.3 测量的性能考量

每个条目首次渲染都会触发一次测量,快速滚动时可能频繁测量。如果条目内容简单(高度差异小),可以不用动态测量,用固定预估高度就好。

条目内容复杂时才需要动态测量。如果测量导致性能问题,考虑用 useAnimationFrameWithResizeObserver: true 选项把测量推迟到下一帧:

const virtualizer = useVirtualizer({
  count: items.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 50,
  useAnimationFrameWithResizeObserver: true, // 推迟到下一帧测量
})

36.2 水平虚拟化

前面的例子都是垂直滚动的列表。水平滚动的列表(如轮播、横向时间轴)也能虚拟化。

36.2.1 开启水平虚拟化

horizontal: true

function HorizontalList({ items }: { items: { id: number; title: string }[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200, // 每条预估宽度
    horizontal: true,        // 水平方向
    getItemKey: (index) => items[index].id,
  })

  return (
    <div ref={parentRef} style={{ width: '100%', height: 100, overflowX: 'auto' }}>
      <div
        style={{
          width: virtualizer.getTotalSize(), // 总宽度撑开横向滚动条
          height: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              height: '100%',
              width: virtualItem.size,
              transform: `translateX(${virtualItem.start}px)`, // 横向定位
            }}
          >
            {items[virtualItem.index].title}
          </div>
        ))}
      </div>
    </div>
  )
}

水平虚拟化和垂直虚拟化的区别:

  • 滚动容器设 overflowX: 'auto'(不是 overflowY)。
  • 内层容器用 width 撑开(不是 height)。
  • 条目用 transform: translateX() 定位(不是 translateY)。
  • estimateSize 预估的是宽度(不是高度)。

36.3 网格虚拟化

网格(Grid)是多行多列的布局。虚拟化网格需要同时虚拟化行和列。

36.3.1 用 lanes 实现多列布局

最简单的网格虚拟化方式是 lanes 选项。它把列表分成多个「通道」(列),条目自动分配到最短的通道:

function GridLayout({ items }: { items: { id: number; name: string }[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 150,
    lanes: 3, // 3 列
    getItemKey: (index) => items[index].id,
  })

  return (
    <div ref={parentRef} style={{ height: 500, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: `${(virtualItem.lane * 100) / 3}%`, // 按 lane 分列
              width: `${100 / 3}%`,
              height: virtualItem.size,
              transform: `translateY(${virtualItem.start}px)`,
              padding: '4px',
              boxSizing: 'border-box',
            }}
          >
            <div style={{ border: '1px solid #e5e7eb', padding: '12px', height: '100%' }}>
              {items[virtualItem.index].name}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

lanes: 3 表示 3 列。virtualItem.lane 告诉你这条目在第几列(0/1/2),用来计算横向位置。条目的纵向位置还是用 start

Note

lanes 模式是「瀑布流」布局—条目按高度自动分配到最短的列,不等高也能对齐。适合卡片墙、图片列表等场景。

36.3.2 行列双虚拟化

如果网格的行和列都需要虚拟化(比如 1000 行 x 1000 列的大表格),需要创建两个虚拟化器—一个管行,一个管列:

function VirtualGrid({ rowCount, columnCount }) {
  const parentRef = useRef<HTMLDivElement>(null)

  // 行虚拟化器
  const rowVirtualizer = useVirtualizer({
    count: rowCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
  })

  // 列虚拟化器
  const columnVirtualizer = useVirtualizer({
    count: columnCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 150,
    horizontal: true,
  })

  return (
    <div
      ref={parentRef}
      style={{ height: 500, overflow: 'auto', position: 'relative' }}
    >
      <div
        style={{
          width: columnVirtualizer.getTotalSize(),
          height: rowVirtualizer.getTotalSize(),
          position: 'relative',
        }}
      >
        {/* 只渲染可视行 */}
        {rowVirtualizer.getVirtualItems().map((virtualRow) => (
          <div key={virtualRow.key}>
            {/* 只渲染可视列 */}
            {columnVirtualizer.getVirtualItems().map((virtualCol) => (
              <div
                key={virtualCol.key}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: virtualCol.size,
                  height: virtualRow.size,
                  transform: `translate(${virtualCol.start}px, ${virtualRow.start}px)`,
                  border: '1px solid #e5e7eb',
                  padding: '8px',
                  boxSizing: 'border-box',
                }}
              >
                行 {virtualRow.index}, 列 {virtualCol.index}
              </div>
            ))}
          </div>
        ))}
      </div>
    </div>
  )
}

这个方案同时虚拟化行和列,只有可视区域内的单元格被渲染。1000x1000 的网格也只渲染几十个单元格。

36.4 滚动到指定位置

虚拟化器提供了滚动控制方法:

// 滚动到指定像素偏移
virtualizer.scrollToOffset(500)

// 滚动到指定索引(带对齐方式)
virtualizer.scrollToIndex(50, { align: 'start' })  // 对齐到顶部
virtualizer.scrollToIndex(50, { align: 'center' }) // 对齐到中间
virtualizer.scrollToIndex(50, { align: 'end' })    // 对齐到底部
virtualizer.scrollToIndex(50, { align: 'auto' })   // 自动选择最近的方向

// 平滑滚动
virtualizer.scrollToIndex(50, { behavior: 'smooth' })

// 相对滚动
virtualizer.scrollBy(100) // 向下滚动 100px

// 滚动到底部
virtualizer.scrollToEnd()

常见场景:点击按钮滚动到第 100 条:

<button onClick={() => virtualizer.scrollToIndex(99, { align: 'center' })}>
  跳到第 100 条
</button>
Warning

动态测量模式下,scrollToIndex 可能不完全准确—未测量过的条目用预估值计算位置。滚动到目标位置后,虚拟化器会测量新出现的条目并自动修正。如果要精确滚动,先确保目标条目附近已被测量过。

36.5 与 TanStack Table 集成

第 31 章简单展示了 Table + Virtual 的集成。这里详细讲一下完整方案。

36.5.1 基本集成

核心思路:用 Table 管理数据(排序、过滤),用 Virtual 管理渲染(只渲染可视行)。

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  flexRender,
  createColumnHelper,
} from '@tanstack/react-table'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef, useState } from 'react'

type Person = { id: number; name: string; age: number; city: string }

const columnHelper = createColumnHelper<Person>()

function VirtualTable({ data }: { data: Person[] }) {
  const [sorting, setSorting] = useState([])
  const parentRef = useRef<HTMLDivElement>(null)

  const columns = [
    columnHelper.accessor('name', { header: '姓名' }),
    columnHelper.accessor('age', { header: '年龄' }),
    columnHelper.accessor('city', { header: '城市' }),
  ]

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    state: { sorting },
    onSortingChange: setSorting,
  })

  const { rows } = table.getRowModel()

  const rowVirtualizer = useVirtualizer({
    count: rows.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,
    overscan: 10,
  })

  // 列宽(用列定义的 size 或默认值)
  const colWidths = columns.map((_, i) => 150)

  return (
    <div>
      {/* 表头(固定不滚动) */}
      <div style={{ display: 'flex' }}>
        {table.getHeaderGroups()[0].headers.map((header, i) => (
          <div
            key={header.id}
            onClick={header.column.getToggleSortingHandler()}
            style={{ width: colWidths[i], padding: '8px', fontWeight: 'bold', borderBottom: '2px solid #d1d5db', cursor: 'pointer' }}
          >
            {flexRender(header.column.columnDef.header, header.getContext())}
            {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
          </div>
        ))}
      </div>

      {/* 可滚动的表体(虚拟化) */}
      <div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
        <div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
          {rowVirtualizer.getVirtualItems().map((virtualRow) => {
            const row = rows[virtualRow.index]
            return (
              <div
                key={row.id}
                style={{
                  position: 'absolute',
                  top: 0,
                  left: 0,
                  width: '100%',
                  height: virtualRow.size,
                  transform: `translateY(${virtualRow.start}px)`,
                  display: 'flex',
                }}
              >
                {row.getVisibleCells().map((cell, i) => (
                  <div
                    key={cell.id}
                    style={{ width: colWidths[i], padding: '8px', borderBottom: '1px solid #e5e7eb' }}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </div>
                ))}
              </div>
            )
          })}
        </div>
      </div>

      <div className="text-sm text-gray-500 mt-2">共 {rows.length} 行</div>
    </div>
  )
}

36.5.2 集成的关键点

  1. 不用 <table> 标签:用 <div> + flex 布局。原生表格的布局规则和绝对定位冲突。
  2. 表头和表体分离:表头固定在滚动容器外,不参与虚拟化。表体在滚动容器内虚拟化。
  3. 列宽一致:表头和表体的列宽必须一致,否则对不齐。用统一变量管理列宽。
  4. 行高统一:虚拟化表格通常用固定行高(estimateSize 准确),不用动态测量。动态测量会让行高不一致,和列宽对齐更麻烦。
Tip

如果要同时虚拟化行和列(超宽超长的表格),在表体里再加一个列虚拟化器。但这会让交互复杂度大增,建议先用分页控制列数,实在不行再上双虚拟化。

36.6 常见坑

坑一:动态测量时设了 height 设了 height: virtualItem.size 会导致元素高度被限制在预估值,测不到真实高度。动态测量时不要设 height,让内容撑开。

坑二:measureElement 没设 data-index 不设 data-index,虚拟化器无法把测量结果和条目对应,尺寸更新乱套。

坑三:水平虚拟化忘改 overflow 方向。 水平列表容器要设 overflowX: 'auto',不是 overflowY。设反了滚动条不出现。

坑四:网格布局 left 计算错误。 lanes 模式下 left 要按 lane 索引算百分比:left: (lane * 100) / lanes%。直接写 left: lane * 150 用固定像素,窗口缩放时会错位。

坑五:Table 集成后排序/过滤行数不对。 虚拟化器的 count 要用 table.getRowModel().rows.length(排序过滤后的行数),不是 data.length(原始数据行数)。排序过滤后行数变了,虚拟化器要跟着更新。

36.7 小结

这一章讲了 TanStack Virtual 的进阶用法:

  • 动态尺寸测量ref={virtualizer.measureElement} + data-index,自动测量实际高度。不设 height,让内容撑开。
  • 水平虚拟化horizontal: true,容器 overflowX: auto,条目用 translateX 定位。
  • 网格虚拟化lanes 选项做多列瀑布流,virtualItem.lane 决定横向位置。行列双虚拟化用两个虚拟化器。
  • 滚动控制scrollToIndex 跳到指定条目,scrollToOffset 跳到指定像素,scrollToEnd 跳到底部。
  • Table 集成:Table 管数据逻辑,Virtual 管渲染。用 <div> + flex 替代 <table>,表头固定表体虚拟化,列宽统一管理。

Virtual 两章到此结束。下一章讲 TanStack Store—整个 TanStack 生态底层的响应式状态管理库。