当表格需要展示成千上万行数据时,如果一次性把所有 DOM 节点都渲染出来,浏览器会在布局、绘制和内存上承受巨大压力,页面卡顿几乎不可避免。虚拟滚动(Virtual Scrolling)的核心思路很朴素:只渲染用户当前能看到的部分,其余用占位空间"假装"存在。
本文以 Ant Design Table 的 virtual 模式为例,从使用方式到底层源码,完整拆解虚拟滚动的实现原理。
一、从一个例子开始
tsx
import { Table } from 'antd'
const ROW_COUNT = 10_000
function App() {
const data = Array.from({ length: ROW_COUNT }, (_, i) => ({
key: i,
name: `User ${i + 1}`,
age: 18 + (i % 60),
email: `user${i + 1}@example.com`,
}))
const columns = [
{ title: 'Name', dataIndex: 'name', width: 160 },
{ title: 'Age', dataIndex: 'age', width: 80 },
{ title: 'Email', dataIndex: 'email', width: 240 },
]
return (
<Table
dataSource={data}
columns={columns}
pagination={false}
virtual
scroll={{ y: 500 }}
/>
)
}
这段代码背后发生了什么?
dataSource里有 10,000 条数据,但 DOM 里不会 出现 10,000 个<tr>scroll.y: 500固定了可视区域高度,虚拟列表据此计算"哪些行在视口内"virtual开关让 antd 切换到专门的虚拟 Table 实现
二、虚拟滚动的核心思想
2.1 问题:全量渲染的代价
假设每行高度 50px,10,000 行就是 500,000px 的内容高度。浏览器需要:
- 创建 10,000 个 DOM 节点
- 对每个节点做布局计算
- 在滚动时触发大量重绘
React 的 diff 也会随着节点数量线性增长。数据量一大,性能瓶颈非常明显。
2.2 解法:窗口化渲染
虚拟滚动的本质是 Windowing(窗口化):
sql
← 不可见区域(仅占位,不渲染,Row 0 ~ 40 )
┌─────────────────────────┐ ← 可视区域 (scroll.y = 500px)
│ Row 42 │
│ Row 43 │ ← 实际渲染的 DOM(Row 41 ~ 60)
│ Row 44 │
│ ... │
└─────────────────────────┘
← 不可见区域(仅占位,不渲染,Row 61 ~ 9999)
三个关键要素:
| 要素 | 作用 |
|---|---|
| 固定视口高度 | 确定"窗口"大小 |
| 预估/缓存行高 | 计算每行在滚动条上的位置 |
| 占位 + 偏移 | 让滚动条行为与全量渲染一致 |
三、Ant Design 的实现架构
3.1 行高预设
antd 会根据 Design Token 计算预估行高 listItemHeight:
js
// 根据 size(small / middle / large)和主题 token 计算
const fontHeight = Math.floor(fontSize * lineHeight)
listItemHeight = padding * 2 + fontHeight + lineWidth
这个值至关重要------在真实行高尚未测量之前,虚拟列表靠它估算每行的位置。
3.2 可见区间计算
这是整个虚拟滚动的引擎。核心逻辑如下:
Step 1:根据 scrollTop 找到可见区间 start, end
js
let itemTop = 0
for (let i = 0; i < dataLen; i++) {
const cacheHeight = heights.get(key) // 已测量的真实高度
const rowHeight = cacheHeight ?? itemHeight // 未测量则用预估值
const currentItemBottom = itemTop + rowHeight
if (currentItemBottom >= scrollTop && startIndex === undefined) {
startIndex = i
startOffset = itemTop
}
if (currentItemBottom > scrollTop + viewportHeight && endIndex === undefined) {
endIndex = i
}
itemTop += rowHeight
}
遍历数据,累加行高,找到第一个进入视口和最后一个离开视口的行索引。
Step 2:只渲染 start, end 范围内的行
js
data.slice(start, end + 1).map((item, index) => renderRow(item, start + index))
Step 3:Filler 占位 + translateY 偏移
为了让滚动条"感觉"像有 10,000 行,需要:
- 外层容器设置
height: scrollY(视口高度) - 内层 Filler 撑出
scrollHeight(所有行的总高度) - 可见行通过
transform: translateY(-startOffset)偏移到正确位置
arduino
┌─ 外层容器 (height: 500px, overflow: auto) ─────┐
│ ┌─ Filler (height: 500000px) ──────────────┐ │
│ │ ↑ 上方不可见区域的占位空间 │ │
│ │ ┌─ translateY(-2100px) ──────────────┐ │ │
│ │ │ Row 42 ← 实际 DOM │ │ │
│ │ │ Row 43 │ │ │
│ │ │ Row 44 │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ │ ↓ 下方不可见区域的占位空间 │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘
用户滚动时,scrollTop 变化 → 重新计算 [start, end] → 销毁旧行、渲染新行 → 更新 translateY 偏移。
四、动态行高:预估与测量的协作
真实业务中,行高并不总是固定的------展开行、多行文本、自定义 render 都会让行高变化。虚拟列表用 "预估 + 测量 + 缓存" 三步策略应对:
4.1 预估阶段
首次渲染时,所有行使用 listItemHeight(antd 根据主题 token 计算的值,通常 30~55px)。
4.2 测量阶段
行渲染到 DOM 后,useHeights hook 通过 ResizeObserver / offsetHeight 采集真实高度:
js
function collectHeight() {
instanceRef.current.forEach((element, key) => {
const totalHeight = element.offsetHeight + marginTop + marginBottom
if (heights.get(key) !== totalHeight) {
heights.set(key, totalHeight) // 写入缓存
}
})
}
offsetHeight定义如下:

4.3 修正阶段
如果当前视口第一行的真实高度与预估值不同,会同步修正 scrollTop,避免滚动跳动:
js
const diffHeight = realStartHeight - itemHeight
syncScrollTop(prev => prev + diffHeight)
这就是为什么 行高越稳定,虚拟滚动体验越好。如果每行高度差异很大,频繁的测量和修正会导致轻微抖动。
五、横向虚拟滚动
当列数较多时,Table 还需要横向虚拟滚动。实现方式类似:
scroll.x设为数字,表示表格总宽度- VirtualList 维护
offsetLeft,只渲染可见列 - VirtualCell 通过
columnsOffset(列宽前缀和数组)计算每列的flex-basis
表头和表体的横向滚动通过 onVirtualScroll 事件同步:
js
onVirtualScroll: ({ x }) => {
onScroll({ scrollLeft: x }) // 同步给表头
}
六、特殊场景处理
6.1 rowSpan / colSpan
标准虚拟列表按行切分,但 rowSpan > 1 的单元格会跨越多行。如果跨行区域不完全在可见区间内,合并单元格就会"断裂"。
rc-table 的解法是 extraRender:扫描可见区间前后,找出所有跨行的 rowSpan 行,用绝对定位额外渲染:
js
// 扩展 start/end 范围,找出 rowSpan 跨行的行
// 用 position: absolute + top 定位,补渲染这些行
style: { top: -offsetY + sizeInfo.top }
6.2 树形数据与展开行
- 树形数据通过
useFlattenRecords拍平为一维数组 - 展开的内容作为额外
div跟在行后面,增加行高并触发重新测量
6.3 固定列(Fixed Column)
固定列通过 position: sticky 或独立的固定列容器实现,与虚拟滚动的 div 布局配合工作。
七、性能优化手段
| 手段 | 说明 |
|---|---|
| Immutable 渲染 | makeImmutable / responseImmutable 做细粒度 props 比较,避免无效 re-render |
| 高度缓存 | 已测量的行高存入 CacheMap,滚动回去时无需重新测量 |
| 缓冲行 | 可见区间 end + 1,多渲染一行防止快速滚动白屏 |
| requestAnimationFrame | 滚轮事件通过 rAF 节流,避免一帧内多次计算 |
| fullHeight: false | 不把容器撑满父元素,适配 Table 的复杂布局 |
八、使用建议与限制
推荐用法
tsx
<Table
virtual
scroll={{ x: 1200, y: 500 }} // x、y 都设为数字
pagination={false} // 全量数据交给虚拟滚动
dataSource={largeDataset}
columns={columnsWithWidth} // 每列指定 width
/>
注意事项
scroll.y必须是数字------没有固定视口高度,就无法计算可见区间scroll.x建议设为数字------横向滚动需要明确的总宽度- 每列设置
width------虚拟模式用 flex 布局,没有 width 列宽会对不齐 - 行高尽量统一------行高差异越大,测量修正越频繁,滚动越可能抖动
- 复杂 rowSpan 需谨慎------虽然有 extraRender 兜底,但极端情况仍可能有问题
- 与分页的关系------虚拟滚动适合"不分页的全量展示";如果数据量可控(几百行),普通模式 + 分页可能更简单
九、总结
Ant Design Table 的虚拟滚动可以用一张图概括:
sql
用户滚动
│
▼
VirtualList 读取 scrollTop
│
▼
遍历数据,累加行高(预估 or 缓存)
│
▼
计算出可见区间 [start, end]
│
▼
只渲染 start~end 的 BodyLine(div 行)
│
▼
Filler 占位总高度 + translateY 偏移对齐
│
▼
ResizeObserver 测量真实行高 → 写入缓存 → 修正 scrollTop
│
▼
用户看到流畅的大数据表格 ✓
本质上,虚拟滚动就是用 O(可见行数) 的 DOM 复杂度,替代 O(总行数) 的全量渲染,在数据量与渲染性能之间取得平衡。理解这套"窗口化 + 占位 + 测量"的机制,不仅有助于正确使用 antd Table,也能迁移到其他需要处理大量列表的场景------无论是 Select 下拉、Tree 树形控件,还是自研的长列表组件。