2432 字
约 8 分钟
13
S14-A20 · 虚拟滚动:DOM 少了,为什么还是卡

一个列表页有 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. 自测

  1. 虚拟滚动的三段成本是什么?为什么"DOM 少了还是卡"?
  2. "每帧 setState"为什么会让滚动掉帧?正确做法是什么?
  3. 不定高的三种处理里,哪一种被低估?虚拟滚动适合什么场景?
  4. "筛选时重建行对象"为什么会变慢?"只存原始数据 + 索引"省了什么?
  5. 虚拟滚动会破坏哪两个基础能力?怎么缓解?
  6. 判断"该不该用虚拟滚动"的那个问题是什么?
S14-A20 · 虚拟滚动:DOM 少了,为什么还是卡
https://www.clxhxhhr.top/posts/6572/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。