一个列表页有 5000 条数据,改成虚拟滚动(只渲染可见的 20 条)之后,首屏快了,但"滚动时还是会卡",而且"搜索筛选变得很慢"。团队以为是"虚拟滚动没配好",查下来是两个新问题:滚动时每帧都在算"哪 20 条可见"(对上万条做查找),以及"筛选时要重建整个数据集"(一次性 map 出 5000 个行对象)。
虚拟滚动解决的是"DOM 数量",这个"转移"是虚拟滚动的本质:它用 CPU 换 DOM。所以"上了虚拟滚动还卡"通常意味着"CPU 那侧没优化",而不是"虚拟滚动没用"。
而它把成本转移到了"计算可见区间"与"维护数据集"上。这两处不做优化,滚动仍然会卡——而卡的原因已经变了。
1. 虚拟滚动的三段成本
| 段 | 成本 |
|---|---|
| 计算可见区间 | 每帧一次(滚动时 60 次/秒) |
| 渲染可见行 | 20 个组件(可接受) |
| 维护位置/高度 | 不定高时需测量 |
第一项在"等行高"时可 O(1) 算出(floor(scrollTop / rowHeight))第一项的"等行高"是绝大多数虚拟滚动库的默认假设,而它在"文本长度不定"的列表里必然被打破——于是"要么截断文本保等高,要么接受测量复杂度"。
;而不定高时必须先知道每行高度,于是变成"前缀和 + 二分",或"边滚边测"。
第二项是虚拟滚动的主要收益:DOM 从 5000 个降到 20 个,于是"重排/重绘"的成本大幅下降。
2. "滚动卡"的三个真实原因
| 原因 | 表现 |
|---|---|
| 每帧做 O(n) 计算 | 滚动时掉帧 |
| 每帧触发 React 重渲染 | 组件树更新开销 |
| 不定高测量导致布局抖动 | 滚动条跳动 |
第一项的量化很直观:60 帧/秒意味着每帧只有 16ms,而"遍历 5000 条 + 一次 setState + React 渲染 20 个组件"很容易超过 16ms——于是掉帧。
第一项在"用 filter 找可见区间"时必然发生:每帧遍历 5000 条,即使只是"找下标",也会占满一帧的预算。
第二项的修法是"滚动位置用 ref/transform,不触发重渲染第二项的"细节"是"把滚动位置与渲染解耦":滚动事件只更新 ref(不触发渲染),而"渲染"由"可见区间变化"驱动——于是"滚动 100 次、区间变 3 次"就只渲染 3 次。
":只在"可见区间变化"时才 setState。
// 反例:每帧都 setState(触发整棵可见列表重渲染)
const onScroll = (e) => setScrollTop(e.target.scrollTop);
// 正解:节流 + 只在"区间变化"时更新
const onScroll = throttle((e) => {
const top = e.target.scrollTop;
const start = Math.floor(top / ROW_H);
if (start !== startRef.current) { // 只有区间变了才更新
startRef.current = start;
setStart(start);
}
}, 16);
3. 不定高的三种处理
| 方案 | 代价 |
|---|---|
| 全部等高(截断文本) | 视觉损失 |
| 预算 + 测量 + 修正 | 复杂,可能抖动 |
| 分页替代虚拟滚动 | 体验差异 |
第三项的优势还有"可被搜索与分享":分页的 URL 与页面内容都是确定的,而虚拟滚动列表"复制链接、Ctrl+F、打印"都可能失效。
第三项被低估:"每页 50 条 + 分页"在 5000 条量级完全够用,而它的实现成本接近零。虚拟滚动适合"无限滚动 + 条数不可预估"的场景。
第二项是"不定高"的标准方案:先给每行一个估算高度,渲染后测量真实高度并修正累计偏移。第二项的"锚定"很关键:修正高度时若不做锚定,用户会看到"内容跳一下"。做法是"修正前后保持"当前首个可见项"的位置不变"。
代价是"修正时会让滚动条跳动",需要"锚定当前可见项"来抵消。
4. 数据集的三种维护方式
| 方式 | 成本 |
|---|---|
| 一次性 map 出所有行 | 初次 O(n),筛选时重建 |
| 惰性构造(按需) | 滚动时构造 |
| 只存原始数据 + 索引 | 最省 |
第三项的"代价"是"渲染时每次要算高度":所以实践中会"按行缓存高度"(WeakMap 或数组),于是"首次算、之后读"。
第三项最优:列表里存原始对象数组,渲染时按索引取,于是"筛选"只是"换一个数组引用",不必"重建行对象"。
而"每行包一个 {data, height, offset} 的对象"是常见做法,它在"筛选时"要重建 5000 个对象——于是"搜索变慢"。
// 反例:筛选时重建 5000 个行对象
const rows = filtered.map(d => ({ data: d, height: estHeight(d) }));
// 正解:只保留数据,高度按需算/缓存
const rows = filtered; // 引用变化即可
const heightOf = memo((d) => measure(d)); // 按行缓存
"区间计算"要做到 O(1) 或 O(log n),而不是 O(n):
// 定高:O(1)
const start = Math.floor(scrollTop / ROW_H);
const end = start + Math.ceil(viewH / ROW_H) + 1;
// 不定高:用"前缀和 + 二分",O(log n)
const start = lowerBound(prefixSum, scrollTop); // 首个 offset >= scrollTop
const end = lowerBound(prefixSum, scrollTop + viewH);
// 关键:prefixSum 只在"行高度变化"时重建,不是每帧重建
5. 阈值:什么时候不该用虚拟滚动
| 数据量 | 建议 |
|---|---|
| < 200 条 | 直接渲染 |
| 200–1000 | 直接渲染 + 分页/折叠 |
| 1000–10000 | 虚拟滚动(定高) |
| > 10000 | 虚拟滚动 + 服务端分页 |
第一项与第二项的判据是"首屏渲染时间":200 个简单行通常 < 50ms,而虚拟滚动的引入成本(代码 + 调试 + 边界)高于收益。
第四项的关键是"不要一次把 10 万条都取到前端":虚拟滚动解决"渲染",不解决"数据量"。
"滚动性能"应当用浏览器工具量,而不是靠感觉:
// 三步定位(十分钟)
1 DevTools → Performance:录 3 秒滚动,看"帧率"与"Long Task"
2 找主线程热点:是"计算区间"还是"组件渲染"还是"布局"
3 对症:算区间 → 二分/索引;渲染 → memo + key 稳定;布局 → 固定行高容器
// 判据:单帧是否 > 16ms,热点函数是否在"每帧都调用"
6. 排错:五种"虚拟滚动相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 滚动掉帧 | 每帧 O(n) 计算 | 二分/索引 |
| 滚动条跳动 | 不定高测量修正 | 锚定 + 平滑 |
| 搜索变慢 | 筛选时重建对象 | 只换数组引用 |
| 白屏/空白行 | 容器高度不对 | 总高度 = 行数×行高 |
| 键盘/无障碍失效 | 只渲染可见 | 补 aria 与焦点管理 |
第五项还有"滚动位置恢复"的问题:从详情页返回列表时,虚拟滚动列表常常回到顶部,因为"位置"是"数据 + 高度"共同决定的——而高度可能还没测量。
第五项最容易被忽略:Ctrl+F 搜不到未渲染的行,屏幕阅读器也读不到——虚拟滚动会破坏"页面可搜索"这个基础能力。
最后一个"可测"的验收:滚动 3 秒的平均帧率 ≥ 55fps,且没有单帧 > 30ms。这个数能在 CI 里用无头浏览器跑(对固定数据集),于是"改坏了"能被自动发现。
// 三个可测指标
avg_fps ≥ 55 滚动 3 秒的平均
long_frames 0(> 30ms) 掉帧次数
first_render_ms < 200 首屏可交互
// 用法:把"数据集大小"固定为 5000,作为基线场景
7. 边界:什么情况虚拟滚动不是答案
| 场景 | 为什么 | 做法 |
|---|---|---|
| 需要全选/统计 | 需要全部数据 | 服务端统计 |
| 需要浏览器查找 | 只渲染可见 | 提供搜索框 |
| 表格需固定表头 + 合并单元格 | 虚拟化复杂 | 分页 |
| 行内有复杂交互 | 回收会丢状态 | 减少行数或分页 |
判断口径一句话:"用户是否需要在全部数据上做操作"。最后一条与"演进"有关的边界:先做"分页",等"数据量 + 交互需求"都确认了再上虚拟滚动。因为分页的实现成本是"一天",而虚拟滚动是"一周 + 持续的边界 bug"。
需要(全选、查找、统计),虚拟滚动只会带来麻烦;不需要(只是浏览),它是最有效的优化。
8. 自测
- 虚拟滚动的三段成本是什么?为什么"DOM 少了还是卡"?
- "每帧 setState"为什么会让滚动掉帧?正确做法是什么?
- 不定高的三种处理里,哪一种被低估?虚拟滚动适合什么场景?
- "筛选时重建行对象"为什么会变慢?"只存原始数据 + 索引"省了什么?
- 虚拟滚动会破坏哪两个基础能力?怎么缓解?
- 判断"该不该用虚拟滚动"的那个问题是什么?