前端知库

无限滚动和虚拟列表怎样选型?怎样触发加载并恢复滚动位置?

无限滚动解决"何时加载下一页",虚拟列表解决"已加载的数据太多时只渲染可见部分",数据量大时两者组合;用 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 适合做什么,能否证明用户看到了内容?》。

恢复滚动位置

用户从详情页返回时,列表若重新从第一页请求,原位置的数据根本不存在,滚动无从恢复。做法:

  1. 离开前把已加载数据、页码和滚动位置保存在状态管理或 sessionStorage。虚拟列表记录首个可见条目的 ID 和偏移,比记录像素值更稳定。
  2. 返回时先用缓存渲染,再滚动到记录位置,之后按需后台刷新。
  3. history.scrollRestoration 默认为 auto,浏览器可能在异步内容渲染前尝试恢复而失败;设为 manual 后由应用在渲染完成后自行滚动。

整页导航命中 bfcache 时,页面状态包括滚动位置会完整保留,不需要额外处理。

无障碍与体验

  • 加载中和加载失败要有可见提示,失败时提供重试按钮,而不是静默停止。
  • 用 aria-live 区域播报"已加载 N 条",读屏用户才能感知内容变化。
  • 键盘用户需要能跳过列表到达后续内容;无限滚动会让页脚永远够不到,必要时把页脚链接移到其他位置。

常见追问

  • 为什么不用 scroll 事件?需要节流且每次都要读取布局信息,IntersectionObserver 由浏览器异步计算交叉状态。
  • 向上加载历史消息怎么保持位置?插入前记录 scrollHeight,插入后把差值加到 scrollTop,或使用 CSS 滚动锚定。
  • 新数据插入到列表头部导致重复怎么办?用游标而不是页码分页,按 ID 去重。