虚拟列表大厂方案调研:各场景的选型、量级判断与 fallback 策略
一万条订单直接 map 渲染,浏览器会创建一万多个 DOM 节点——滚动掉帧、内存暴涨,甚至直接卡死。虚拟列表(Virtual List / Windowed Rendering)就是为了解决这个问题:不管数据有多少,始终只渲染视口附近的那几十个节点。但"上虚拟列表"只是第一步,真正难的是选型——不同场景(表格、feed、聊天、分组)该用哪个方案,不同数据量级该不该用、用什么级别的虚拟化,以及各种边界情况下的 fallback。
本文把主流方案和大厂实践完整调研一遍:原理 → 方案全景 → 场景选型 → 量级判断 → fallback。
1. 虚拟列表是什么:先看清问题的量级
不虚拟化的问题有多严重?一个直观对比:
| 直接渲染 | 虚拟列表 | |
|---|---|---|
| 10 万条数据 | 10 万 + 个 DOM 节点 | 约 50~80 个节点 |
| 首次渲染 | 可能冻结 / 卡死 | 毫秒级 |
| 滚动 | 大量布局 + 绘制 | 只重算视口窗口 |
为什么 DOM 多就慢:浏览器对每个节点都要维护数据结构、做布局计算、参与绘制。节点多 → 布局(Layout)慢、绘制(Paint)慢、内存占用高。50 万行直接渲染,浏览器基本会冻结甚至崩溃。
虚拟列表的核心思路三件事:
- 总高度占位:用一个"撑高"的容器模拟全部内容的高度,让滚动条长度正确;
- 只渲染窗口内:根据
scrollTop算出当前视口应该显示哪些项(startIndex~endIndex),只渲染这些; - 绝对定位 /
translateY偏移:把每一项按它在整条列表中的位置放好,视觉上无缝衔接。
overscan(缓冲区):视口外再多渲染几个,防止快速滚动时出现白屏——一般 5~10 个为宜。
2. 核心分水岭:定高 vs 不定高
这是选型的第一道分岔,也决定实现的复杂度和用哪个库。
| 维度 | 定高(Fixed Height) | 不定高(Dynamic Height) |
|---|---|---|
| 每项高度 | 固定,index * height 直接算位置 | 不一致,需要测量 / 预估 |
| 定位 | O(1):index * itemHeight | 需位置缓存:测量可见项 + 预估其余 |
| 实现成本 | 低,几乎不踩坑 | 高:高度缓存 + 预估修正 + ResizeObserver |
| 滚动稳定性 | 最稳 | 测量偏差会导致滚动条跳动,需补偿 |
| 典型场景 | 后台表格、结构规整的列表 | 评论、feed 流、混合复杂布局 |
决策逻辑一句话:内容可控(表格、纯文本行)→ 定高;内容不可控(feed、评论、图文混排)→ 不定高。定高永远是最优解,不定高是业务逼出来的复杂实现。
不定高是怎么做的:只测量渲染过的,其余靠预估
所有主流实现用的都是同一套策略(react-window 的 VariableSizeList、TanStack 的 measureElement、react-virtuoso 内置的都是):
- 给每项一个预估高度
estimateSize(通常 100px 起步,其实不精确也没关系); - 只在视口内实际渲染的项,用
ResizeObserver/getBoundingClientRect测量真实高度,存进缓存; - 位置由"已测量的精确值 + 未测量的预估值"累加得到——随着滚动,越来越多的项被测量,预估会被真实值逐步替换,位置越来越准;
- 当某项尺寸变化(图片加载、行展开)导致视口上方的项高度改变时,做滚动补偿:
scrollTop += 变化量,让用户看到的视觉位置不跳动。
// 量级化的实现思路:位置 = 已测量项的精确高度 + 未测量项的预估高度
function getItemOffset(index) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += measuredHeights.get(i) ?? estimateSize(i); // 测过的用真实值,没测的用预估
}
return offset;
}
这就是"只测量渲染过的,其余全靠预估,用多少测多少"的 fallback 思想——它在精度和性能之间取平衡,也是后面"各种量级 fallback"的雏形。
3. 主流方案全景(大厂 / 开源)
| 方案 | 定位 | 体积 | 定高/不定高 | 擅长 |
|---|---|---|---|---|
| react-window | 轻量基础组件 | ~4-7KB | 定高为主(不定高要手写测量) | 定高列表/表格,性能极致 |
| react-virtuoso | 开箱即用全能方案 | ~30KB+ | 动态高度内置(ResizeObserver 自动测量) | 聊天/feed/动态内容 |
| @tanstack/react-virtual | headless 底层 hook | ~15-18KB | 定高/不定高都支持,测量可控 | 企业表格、跨框架、自定义渲染 |
| rc-virtual-list | Ant Design 内部组件 | ~148KB | 定高为主 | antd 系表格/列表 |
| ahooks useVirtualList | React Hook | 轻量 | 支持动态高度 | 蚂蚁生态快速接入 |
| vue-virtual-scroller / Element Plus | Vue 生态 | — | 支持动态高度 | Vue 项目 |
3.1 react-window:定高场景的性能标杆
- 优点:包体积最小、纯组件性能最好、避免布局抖动(layout thrashing),是 2019-2022 年的行业标准,存量项目极多;
- 缺点:动态高度需要自己维护测量逻辑(
VariableSizeList只给机制不给自动测量);不支持分组、sticky header、反向滚动、嵌套滚动;官方不建议新项目再用,基本停止新功能开发; - 适用:数据看板、定高表格这类"高度可预测 + 极致性能"的场景。
3.2 react-virtuoso:动态场景的开箱即用之王
- 优点:动态高度全自动(ResizeObserver 测量 + 高度缓存,零手动维护);内置
TableVirtuoso(表格)、VirtuosoGrid(网格/瀑布流)、GroupedVirtuoso(分组 + sticky 组头);反向滚动 + followOutput(聊天消息流跟随最新消息);TypeScript 完整; - 生产案例:Rocket.Chat 的消息列表就是用它——同时用"双向加载 + 动态高度 + 日期分隔符",是业界最严苛的虚拟化场景;
- 适用:聊天、feed、评论这类"动态高度 + 内容不可控"的场景,中小团队起步首选。
3.3 @tanstack/react-virtual:headless 的底层基础设施
- 优点:框架无关(React/Vue/Solid 都行)、不绑定 DOM(可接 Canvas/WebGL)、TypeScript-first;底层用
Float64Array连续数组做位置缓存(O(1) 访问、少 GC),能扛几十万条;与@tanstack/react-table组合是企业级数据表格的推荐模式; - 缺点:headless,要自己处理 ref、resize、scroll 同步,不是开箱即用;
- 适用:企业数据表格、内部工具平台、需要自定义渲染管线的复杂布局。
3.4 蚂蚁系:rc-virtual-list + ahooks useVirtualList
- rc-virtual-list:Ant Design(antd Table、Select 的虚拟滚动)底层的虚拟列表组件,周下载百万级;
- ahooks
useVirtualList:一行 hook 接入,支持动态高度,适合蚂蚁生态 / 快速原型。
4. 各场景的大厂选型
场景 1:后台数据表格(大量定高行)
固定列 + 海量行、每行高度基本一致 → 定高虚拟化。React 侧用 react-window 或 @tanstack/react-virtual + @tanstack/react-table;antd 系直接用 rc-virtual-list 或 Table 的虚拟滚动。
真实案例(企业后台 10 万行 × 20 列表格):初始渲染 12s → 0.5s,CPU 80% → 15%。
场景 2:feed / 评论流(动态高度)
图文混排、高度不可控 → 动态高度虚拟化,选 react-virtuoso(自动测量)或 @tanstack/react-virtual(自己接测量)。
真实案例(电商商品列表):首屏 3.2s → 0.8s,内存 400MB → 100MB,帧率 20 → 60 FPS。
场景 3:聊天消息流(反向 + 动态 + 跟随)
这是最苛刻的场景:消息从底部往上排(反向滚动)、每条高度不同、还要跟随最新消息。react-virtuoso 是事实标准(Rocket.Chat 生产案例),followOutput 平滑滚动到最新。
场景 4:分组列表 / 树形(sticky 组头)
分组 + 吸顶组头 → react-virtuoso 的 GroupedVirtuoso;树形展开/收起会改高度,属于"动态高度 + 增量测量"的复合场景。
场景 5:网格 / 瀑布流
VirtuosoGrid(多列)+ 每格动态高度。
场景 6:虚拟化 + 分页 / 无限滚动组合
服务端分页管"数据边界",客户端虚拟列表管"渲染边界"。注意一个坑:接口返回 total_count 和实际数据可能不一致,会导致滚动条过长/过短——用"已知高度 + 估算未加载部分"来对齐滚动条。
场景 7:Hybrid H5(移动端)
真实案例(Hybrid H5 饰品列表 1000+ 行):迁移前 DOM 节点 >20000、滚动 30fps 以下;虚拟化后显著改善。移动端选型优先 react-window(轻量)或动态场景的 react-virtuoso。
5. 各种量级的 fallback:什么时候该虚拟化,用什么级别
"上不上虚拟列表"和"上哪种虚拟化",本质取决于数据量级。这一步做对,能省掉大量无谓的复杂度。
5.1 量级判断表
| 数据量级 | 策略 | 说明 |
|---|---|---|
| < 1000 | 不虚拟化,直接全渲染 | 虚拟列表本身有实现复杂度 + 测量开销,这个量级收益趋近于零,别为了"显得专业"而上 |
| 1000 ~ 5000 | 优先分页 / 无限滚动;或简单定高虚拟 | 开始有卡顿风险(经验阈值),但还没到非虚拟不可 |
| 5000 ~ 10 万 | 正式虚拟列表 | 定高优先;动态高度必须上"测量 + 预估 + 补偿" |
| 10 万 ~ 100 万 | 高性能虚拟化 | 二分定位(O(log n))、位置缓存、增量重算,避免 O(n) 扫描;注意浏览器像素高度上限 |
| 100 万 + | 特殊架构 | 单容器几乎到极限,考虑数据聚合/分批、服务端、Canvas/WebGL 渲染 |
5.2 量级对应的实现强度
5k~10 万(普通虚拟化):线性累加位置可接受,overscan 5~10,定高走 O(1) 公式。
10 万~100 万(高性能虚拟化):这一步要跨过三道坎——
- 定位别用线性扫描:从
scrollTop找起始项,用二分查找 O(log n),否则每次滚动都 O(n); - 位置数据别用对象数组:TanStack 用连续
Float64Array存 start/size,避免几十万个对象的内存与 GC 压力; - 测量别全量做:只测视口内的项,其余用预估;增量地重算受影响位置,别每次滚动全量重算。
浏览器硬边界:DOM 元素/滚动容器高度有物理上限(Chrome 约 2^24 ≈ 1600 万 px,实用上滚动到百万像素级已是极限)。这意味着"百万条 × 每条 20px = 2000 万 px"这种组合,单靠一个滚动容器是放不下的——必须拆(分批加载、按需扩展高度)或换渲染载体。
5.3 fallback 的几种形态
"fallback"在这类系统里至少指三件事:
① 数据量级的降级(上面那张表):数据小到不值得虚拟化 → 直接全渲染。虚拟化不是免费的,它有测量开销、高度缓存维护、定位计算——数据只有几百条时,直接渲染更快更简单。
② 测量机制的降级:
| 异常 / 边界 | fallback |
|---|---|
| 某项还没被渲染 / 没测到 | 用 estimateSize 预估值(测量后自动替换) |
| 图片异步加载导致高度变化 | ResizeObserver 监听 + 增量重算受影响区间 |
| 视口上方高度变化导致跳动 | 滚动补偿:scrollTop += 高度差值 保持视觉稳定 |
不支持 ResizeObserver(老环境) | 降级为滚动事件 + getBoundingClientRect 采样 |
| 快速滚动出现白屏 | 加大 overscan 缓冲区(5~10 个) |
③ 需求不满足的降级:定高方案在遇到动态内容时,如果不想引入大库,可以退化为"预估高度 + 出现内容后再补测修正"(react-window VariableSizeList 就是让你自己接这套);极端情况(无法测量、必须逐条真实高度)甚至放弃虚拟化、退化为分批渲染 + 懒加载。
6. 工程实践:性能归因与常见坑
6.1 先做性能归因,别盲目上虚拟列表
虚拟列表只解决"渲染性能(DOM 规模)"问题,不解决数据获取、状态管理问题。先用 Chrome Performance 归因:
- DOM 数量过多(经验阈值:<1000 通常没问题;1000~5000 有卡顿风险;>5000 基本要虚拟化)→ 虚拟列表是核心解法;
- Layout 时间长(不定高元素、图片、复杂 CSS)→ 重点是控制高度、降低布局复杂度,虚拟列表帮不上;
- Scripting 时间长(频繁 render/diff)→ 应做
memo、分层渲染、状态拆分。
6.2 五个高频坑
key={index}:滚动后状态漂移(第 100 行展开,滚动后变第 98 行)——用 item 的业务 id 做 key;- 状态存在 item 组件内部:虚拟列表的项会反复卸载/重建,内部 state 会丢——状态提到父级或全局 store,用
Map<itemId, state>缓存; - overscan 太小:快速滚动出现白屏——缓冲区放到 5~10 个;
- 滚动回调里做复杂计算:应使用
requestAnimationFrame或防抖,别阻塞主线程; - 服务端分页 + 虚拟列表混用:
total_count与实际数据不一致会导致滚动条失真——用"已知部分精确高度 + 未加载部分估算高度"对齐。
6.3 一个性能对照
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 10 万行 × 20 列表格 | 初始渲染 12s,CPU 80% | 0.5s,CPU 15% |
| 电商商品列表 | 首屏 3.2s,内存 400MB,20 FPS | 0.8s,内存 100MB,60 FPS |
| Hybrid H5 饰品列表 | DOM >20000,滚动 <30fps | 显著改善 |
7. 一句话总结
虚拟列表的本质是"总高度占位 + 只渲染视口窗口 + 定位偏移",它只解决 DOM 规模问题,不背数据获取和状态的锅。选型的第一分水岭是定高 vs 不定高:内容可控走定高(react-window / TanStack + Table,O(1) 定位、最稳),内容不可控走动态高度(react-virtuoso 自动测量,或 TanStack 自接测量),聊天/feed/分组等特殊场景用对应内置能力。而上不上虚拟、上到什么强度,看数据量级:千条以内直接渲染、万级上正式虚拟、十万到百万上二分 + 位置缓存 + 增量重算、百万以上得换架构。各种 fallback(预估高度、滚动补偿、overscan 加大、ResizeObserver 降级)本质都是"在精度、性能、稳定性之间取平衡"——先归因性能,再决定手段,永远别为了"用上虚拟列表"而虚拟化。
参考
- react-window(Brian Vaughn,轻量定高虚拟列表)
- react-virtuoso(动态高度 / Table / Grid / Grouped / 反向滚动,Rocket.Chat 生产案例)
- @tanstack/react-virtual(headless、框架无关、
Float64Array位置缓存) - TanStack Virtual 测量与尺寸系统(预估 + 测量 + 滚动补偿 + 增量重算)
- react-virtualized issue #309:只测量可见行,其余用预估高度
- 虚拟列表实战:如何定位性能瓶颈并做出方案决策(语雀)
- rc-virtual-list(Ant Design 虚拟列表组件) / ahooks useVirtualList
