Antd Table虚拟滚动详解

当表格需要展示成千上万行数据时,如果一次性把所有 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 的内容高度。浏览器需要:

  1. 创建 10,000 个 DOM 节点
  2. 对每个节点做布局计算
  3. 在滚动时触发大量重绘

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 行,需要:

  1. 外层容器设置 height: scrollY(视口高度)
  2. 内层 Filler 撑出 scrollHeight(所有行的总高度)
  3. 可见行通过 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
/>

注意事项

  1. scroll.y 必须是数字------没有固定视口高度,就无法计算可见区间
  2. scroll.x 建议设为数字------横向滚动需要明确的总宽度
  3. 每列设置 width------虚拟模式用 flex 布局,没有 width 列宽会对不齐
  4. 行高尽量统一------行高差异越大,测量修正越频繁,滚动越可能抖动
  5. 复杂 rowSpan 需谨慎------虽然有 extraRender 兜底,但极端情况仍可能有问题
  6. 与分页的关系------虚拟滚动适合"不分页的全量展示";如果数据量可控(几百行),普通模式 + 分页可能更简单

九、总结

Ant Design Table 的虚拟滚动可以用一张图概括:

sql 复制代码
用户滚动
   │
   ▼
VirtualList 读取 scrollTop
   │
   ▼
遍历数据,累加行高(预估 or 缓存)
   │
   ▼
计算出可见区间 [start, end]
   │
   ▼
只渲染 start~end 的 BodyLine(div 行)
   │
   ▼
Filler 占位总高度 + translateY 偏移对齐
   │
   ▼
ResizeObserver 测量真实行高 → 写入缓存 → 修正 scrollTop
   │
   ▼
用户看到流畅的大数据表格 ✓

本质上,虚拟滚动就是用 O(可见行数) 的 DOM 复杂度,替代 O(总行数) 的全量渲染,在数据量与渲染性能之间取得平衡。理解这套"窗口化 + 占位 + 测量"的机制,不仅有助于正确使用 antd Table,也能迁移到其他需要处理大量列表的场景------无论是 Select 下拉、Tree 树形控件,还是自研的长列表组件。


参考

相关推荐
颜进强1 小时前
Calude Code - 23 用 MCP 把 Jenkins 变成 AI 队友:一次对话完成自动化发布
前端·后端
wangjialelele1 小时前
Selenium4 + Java Web自动化测试入门指南:从环境搭建到常用操作详解
java·开发语言·前端·测试工具·自动化
J船长2 小时前
Spec Kit 入门:AI Agent 时代的规格驱动开发实践
前端
程序员黑豆2 小时前
鸿蒙应用开发 @Builder 使用教程:从入门到精通
前端·harmonyos
CCYe、2 小时前
限流与配额:企业AI网关的工程实践
前端·人工智能
看谷秀2 小时前
arkts- 6 手势/沉浸式/深浅色/性能/组件补充/访问/js交互/通知/Native交互
前端·arkts
划船不慎掉水顺便摸鱼2 小时前
TipTap 不是编辑器,是编辑器构造器:ProseMirror 模型驱动的富文本架构
前端
小高0073 小时前
🔥🔥🔥TypeScript 7 正式版来了:别只看 10 倍速度,这 4 个迁移坑更值得注意
前端·javascript·面试
凉茶社3 小时前
shadcn/ui 默认改用 Base UI,Radix 被放弃了吗?
前端