Virtual 进阶:动态尺寸与网格
本教程共 38 篇 · 第 36 篇 · 更新于 2026-07-27 · 约 11 分钟阅读
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>
)
}
关键改动:
ref={virtualizer.measureElement}— 绑到条目的外层 div 上。data-index={virtualItem.index}— 必须设,虚拟化器靠它把测量结果和正确的条目关联。- 不要设
height— 让内容撑开高度,虚拟化器来测量。
Note动态测量时,
style里不要写height: virtualItem.size。因为virtualItem.size初始是预估值,设了会限制元素高度,测不到真实值。让内容自然撑开高度即可。
36.1.2 测量的工作流程
- 条目首次渲染,用预估高度定位。
measureElement(内部用ResizeObserver)测量实际高度。- 实际高度和预估不同时,更新该条目的尺寸缓存。
- 重新计算总高度和后续条目的位置。
- 条目在正确位置上稳定下来。
这个过程中你可能会看到短暂的位置跳动—预估位置和实际位置有偏差,测量后修正。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 集成的关键点
- 不用
<table>标签:用<div>+ flex 布局。原生表格的布局规则和绝对定位冲突。 - 表头和表体分离:表头固定在滚动容器外,不参与虚拟化。表体在滚动容器内虚拟化。
- 列宽一致:表头和表体的列宽必须一致,否则对不齐。用统一变量管理列宽。
- 行高统一:虚拟化表格通常用固定行高(
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 生态底层的响应式状态管理库。