学完这篇你能得到一个可以直接抄进项目的虚拟列表实现:不管列表有一万条还是十万条,DOM 里始终只有几十个节点,滚动照样跟手。
第一步:先搞清楚卡在哪
长列表卡顿基本都源于同一个原因:DOM 节点太多。一万条数据渲染一万个 <div>,浏览器要计算一万次布局、绘制一万次,内存和主线程都会被占满,滚动时的重排就会直接掉帧。
关键认知是:用户一次只能看见屏幕里的那十来行。剩下的九千多行就算渲染了,也只是白白消耗性能,用户根本看不见。虚拟列表要做的就是:只渲染视口内那几十行,其余用一块「空高度」占位,让滚动条看起来仍然是完整长度。
第二步:固定行高,算出三个数
假设每行 40px,容器高 600px,数据共 10000 条:
const rowH = 40;
const viewH = 600;
const total = 10000;
const visibleCount = Math.ceil(viewH / rowH); // 15,视口能放几行
const startIndex = Math.floor(scrollTop / rowH); // 当前第一行是第几条
const offsetY = startIndex * rowH; // 这 15 行整体往下偏移多少
startIndex 和 offsetY 就是虚拟列表的全部核心。全程只有取整和乘法,开销可以忽略。
第三步:搭出三层结构
<div class="viewport" style="height:600px;overflow-y:auto">
<!-- 撑高度:高度 = 总条数 × 行高 -->
<div class="phantom" style="height:400000px;position:relative">
<!-- 真正渲染的那一小段,整体位移到正确位置 -->
<div class="list" style="transform:translateY(0px)">
<div class="row" style="height:40px">第 0 条</div>
<!-- …只渲染 15 条左右 -->
</div>
</div>
</div>
phantom 负责生成正确的滚动条长度,list 用 transform 把自己挪到当前位置。用 transform 而不是 top,因为 transform 不会触发重排,滚动时更稳。
第四步:滚动时只更新索引
const viewport = document.querySelector('.viewport');
const list = document.querySelector('.list');
let ticking = false;
viewport.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const scrollTop = viewport.scrollTop;
const start = Math.floor(scrollTop / rowH);
const end = Math.min(start + visibleCount, total);
render(data.slice(start, end), start * rowH);
ticking = false;
});
});
render 里更新 list.style.transform 和列表内容即可。用 requestAnimationFrame 合并高频 scroll 事件,避免一帧内重复渲染。
注意:transform: translateY(N px) 里的 N 必须是整数像素。用小数会让文字边缘发虚,而且不同浏览器取整策略不一致,会导致滚动时轻微抖动。
第五步:加上缓冲区,避免滚动时出现白屏
只渲染可视区时,快速滑动的那一瞬间新节点还没来得及绘制,会看到一片白。解决办法是在上下各多渲染几行:
const overscan = 5;
const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan);
const end = Math.min(total, start + visibleCount + overscan * 2);
overscan 取 3~8 行足够,取太大就失去了虚拟列表的意义。
注意:加上缓冲区后,offsetY 必须用 start * rowH 重新计算,不能沿用 Math.floor(scrollTop / rowH) * rowH,否则渲染内容和位置会错开,表现为「滚动时内容往上跳」。
第六步:行高不固定怎么办
内容长短不一,行高就不能靠算。常见做法是「先估后测」:
- 用
estimatedRowH(比如 60px)先算出总高度和位置;
- 每行渲染完后用
getBoundingClientRect().height 实测,写进 heights[index];
- 维护一个前缀和数组
offsets[i] = offsets[i-1] + heights[i-1],总高度取 offsets[total];
- 查找
startIndex 时在前缀和上做二分,复杂度 O(log n)。
这样滚动过程中高度会不断被修正,滚动条位置偶尔会有轻微跳动,属于正常现象。
注意:ResizeObserver 监听行高变化时要做防抖,否则图片加载完成的瞬间会引发密集回调,反而更卡。
第七步:这些坑要提前处理
- 滚动条位置重置:列表数据整体替换后要手动把
scrollTop 置 0,否则会出现「滚到了空白区域」。
- 容器高度变化:手机横竖屏切换、浏览器窗口缩放,都要重新计算
visibleCount,用 ResizeObserver 监听容器而不是 window.resize。
- 键盘导航与锚点:被虚拟掉的元素不存在于 DOM,
scrollIntoView 和 Tab 键都够不着。需要跳转到某一条时,先算出它的 offset,手动设置 scrollTop。
- 行内不要放重计算:每一行都调用一次格式化时间、拼字符串,滚动时还是会卡。把处理放到数据层,或者给行组件加缓存。
- 别用
v-for 一层套一层:框架里虚拟列表和深层组件嵌套叠加,开销会比手写还大。
小结
虚拟列表的成本只有三件事:一块撑高度的占位、一个随滚动变化的起始索引、一段被位移的渲染区。核心公式就是 startIndex = scrollTop / rowH,剩下的都是围绕它的工程细节——overscan 防白屏、rAF 防重复渲染、前缀和与二分解决不定高、ResizeObserver 应对尺寸变化。先把固定行高版本跑通,再去做不定高,顺序不要反。