纯前端实现虚拟滚动:高度缓存、锚点保持与滚动抖动治理
虚拟滚动的基本思路不复杂:只渲染视口附近的若干条,其余用上下两个占位块撑起滚动高度。真正难的是列表项高度不固定的时候——高度怎么量、起点怎么找、数据变了怎么不跳。这篇文章按这三个问题展开。
一、先量高度,再谈渲染
固定高度只需要乘法,可变高度就必须知道每一项的实际高度。最稳妥的方式是渲染后测量,并用 ResizeObserver 持续更新:
const heights = new Map();
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const id = e.target.dataset.id;
const h = e.target.getBoundingClientRect().height;
if (heights.get(id) !== h) {
heights.set(id, h);
scheduleUpdate();
}
}
});
没有量过的项先用一个估算值占位,等真实高度回来再修正。注意不要在每次更新时清空缓存,否则列表滚动到中间时会反复跳变。
二、前缀和 + 二分,快速定位起点
知道每项高度后,把所有高度累加成前缀和数组。给定滚动距离,用二分找到第一个累计高度大于它的下标,就是当前视口的起始项:
function findStart(prefix, scrollTop) {
let lo = 0, hi = prefix.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (prefix[mid] < scrollTop) lo = mid + 1;
else hi = mid;
}
return lo;
}
前缀和要在高度变化时增量更新。如果列表很长,逐项累加会很慢,可以按块(比如每 100 项)维护块内高度和,定位时先找块再找项,把更新成本摊薄。
三、锚点保持:数据变化时视口别跳
虚拟滚动最容易被忽略的场景是「列表上方插入或删除了数据」。这时所有下标都会平移,如果还按原来的 scrollTop 渲染,用户眼前的内容就会突然换掉。
做法是记录「锚点」:当前视口第一项的 id 和它距离视口顶部的偏移。数据更新后,重新计算这个 id 的新位置,再把滚动位置调到「新位置 − 偏移」:
const offset = scrollTop - prefix[anchorIndex];
const newIndex = indexOfId(anchorId);
scrollTop = prefix[newIndex] + offset;
这样无论上方插入多少条,用户正在看的那一项都会停在原来的位置,体验上就像什么都没发生。
四、滚动抖动:一帧只更新一次
滚动事件触发频率远高于屏幕刷新率,直接在监听里改 DOM 很容易掉帧。标准做法是用 requestAnimationFrame 合并同一帧里的多次事件:
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
render(window.scrollY);
});
}
另一个细节是上下占位块的高度。给占位块设置高度会触发重排,频繁修改时可以用 transform 撑开,或者把占位块放在独立图层里,减少对其他内容的影响。
五、几个容易踩的坑
图片加载后高度变化。没有写死宽高比的图片,加载完成会把后面的项整体推下去。给图片容器设置 aspect-ratio,或者预先占位,能避免大部分跳动。
快速拖动滚动条。一帧内跨过几百项时,用二分定位依然很快,但不要把中间项都渲染出来,只渲染最终视口附近的部分即可。
展开/收起。折叠面板展开会改变高度,必须重新测量并触发前缀和更新,否则下面的滚动位置会错位。
键盘导航。用上下键移动焦点时,如果目标项不在渲染范围内,需要先滚动到它,等它渲染出来再 focus,不能直接对未挂载的节点调用 focus。
六、小结
虚拟滚动真正的复杂度在「高度不确定」和「数据会变」这两件事上。把测量、前缀和、锚点保持拆成三个独立模块,每个模块都能单独测试;渲染层只负责根据计算结果输出可见项,逻辑会清楚很多。
- 点赞
- 收藏
- 关注作者
评论(0)