无限滚动和虚拟列表怎样选型?怎样触发加载并恢复滚动位置?
无限滚动解决"何时加载下一页",虚拟列表解决"已加载的数据太多时只渲染可见部分",数据量大时两者组合;用 IntersectionObserver 观察列表底部哨兵元素触发加载,返回列表时用缓存数据和记录的位置手动恢复滚动。
参考答案
简答
- 无限滚动决定何时加载数据,虚拟列表决定渲染多少 DOM;累计条目多到 DOM 数量拖慢滚动时,两者结合使用。
- 触发加载用
IntersectionObserver观察列表末尾的哨兵元素,通过rootMargin提前触发,比监听scroll事件并计算位置开销小。 - 加载要防重入:请求进行中或已无更多数据时不再触发,并处理加载失败后的重试入口。
- 恢复滚动位置的前提是数据还在:返回列表时先从缓存恢复已加载页,再滚动到记录的位置;SPA 往往需要
history.scrollRestoration = 'manual'自行控制时机。 - 无限滚动不适合需要页脚、按页定位或分享具体位置的场景,此时分页或"加载更多"按钮更合适。
选型
| 场景 | 方案 |
|---|---|
| 几十到几百条、结构简单 | 普通渲染 + 分页或无限滚动 |
| 数据持续追加、条目可能上千 | 无限滚动 + 虚拟列表 |
| 需要定位第 N 页、分享链接、访问页脚 | 分页或"加载更多"按钮 |
虚拟列表的原理见《虚拟列表通过什么方式减少渲染成本?》,条目高度不固定时的测量与锚点问题见《动态高度的虚拟列表怎样处理测量、滚动锚点和焦点?》。
触发加载
JavaScript
function observeSentinel(sentinel, loadMore) {
let loading = false;
let done = false;
const observer = new IntersectionObserver(
async ([entry]) => {
if (!entry.isIntersecting || loading || done) return;
loading = true;
try {
done = !(await loadMore()); // loadMore 返回是否还有下一页
} finally {
loading = false;
}
if (done) observer.disconnect();
},
{ rootMargin: '0px 0px 400px 0px' }, // 距底部 400px 时提前加载
);
observer.observe(sentinel);
return () => observer.disconnect();
}
回调在元素交叉状态变化时触发。如果加载的一页数据不足以把哨兵推出视口,交叉状态没有变化,回调不会再次触发;需要在加载完成后检查哨兵是否仍在可视范围内,必要时继续加载。列表在滚动容器内时,把 root 设为该容器。
IntersectionObserver 的能力边界见《IntersectionObserver 适合做什么,能否证明用户看到了内容?》。
恢复滚动位置
用户从详情页返回时,列表若重新从第一页请求,原位置的数据根本不存在,滚动无从恢复。做法:
- 离开前把已加载数据、页码和滚动位置保存在状态管理或
sessionStorage。虚拟列表记录首个可见条目的 ID 和偏移,比记录像素值更稳定。 - 返回时先用缓存渲染,再滚动到记录位置,之后按需后台刷新。
history.scrollRestoration默认为auto,浏览器可能在异步内容渲染前尝试恢复而失败;设为manual后由应用在渲染完成后自行滚动。
整页导航命中 bfcache 时,页面状态包括滚动位置会完整保留,不需要额外处理。
无障碍与体验
- 加载中和加载失败要有可见提示,失败时提供重试按钮,而不是静默停止。
- 用
aria-live区域播报"已加载 N 条",读屏用户才能感知内容变化。 - 键盘用户需要能跳过列表到达后续内容;无限滚动会让页脚永远够不到,必要时把页脚链接移到其他位置。
常见追问
- 为什么不用
scroll事件?需要节流且每次都要读取布局信息,IntersectionObserver由浏览器异步计算交叉状态。 - 向上加载历史消息怎么保持位置?插入前记录
scrollHeight,插入后把差值加到scrollTop,或使用 CSS 滚动锚定。 - 新数据插入到列表头部导致重复怎么办?用游标而不是页码分页,按 ID 去重。