无限滚动实现的性能优化与实践技巧

长孙倩影 优化 阅读 1,858
赞 20 收藏
二维码
手机扫码查看
反馈

又踩坑了,touchmove滚动失效

今天早上上线前最后测一遍功能,发现列表页的无限滚动在 iOS 上完全不工作。安卓上好好的,PC 上也没问题,就 iPhone 微信和 Safari 里,滚到页面底部,死活不触发加载下一页。我第一反应是监听 scroll 事件没触发,但用 console.log 打了一下,发现 scroll 事件压根没被触发——不是代码逻辑的问题,而是页面根本就没滚动。

无限滚动实现的性能优化与实践技巧

这里我踩了个大坑:我们项目用了某个第三方弹窗库,它在弹窗打开时会禁用 body 滚动。怎么禁用?简单粗暴,在 body 上加 position: fixed。这招平时没啥问题,但一旦你页面里有内容需要靠用户滚动来触发动态加载,那就完蛋了。

更恶心的是,这个弹窗关闭后,它并没有把样式还原。也就是说,position: fixed 还留在那,导致整个页面“看起来能滑”,但实际上 document.documentElement 和 document.body 的 scrollTop 根本不会变。所以你的 IntersectionObserver 或 window.onscroll 回调,全都收不到更新。

折腾了半天,试了三种方案:

  • 手动恢复 body 样式(太脆弱,容易漏)
  • 改用虚拟滚动(成本太高,不现实)
  • 换一种方式监听“用户快到底部了”

后来试了下发现,其实不用依赖 scroll 事件也可以做无限滚动。只要能知道用户当前是不是接近容器底部就行。于是我改用 IntersectionObserver 监听一个哨兵元素(sentinel),哪怕父容器不能滚动,只要这个哨兵能出现在视口中,就能触发加载。

核心代码就这几行

我现在用的是基于 DOM 节点的哨兵模式。页面底部放一个空 div,用 IntersectionObserver 去观察它。当它进入视口时,就加载下一页数据。

<div id="news-list">
  <!-- 新闻列表 -->
  <article class="news-item">...</article>
  <article class="news-item">...</article>

  <!-- 哨兵元素 -->
  <div id="sentinel"></div>
</div>
let isLoading = false;
const sentinel = document.getElementById('sentinel');
const observer = new IntersectionObserver(
  (entries) => {
    const entry = entries[0];
    if (entry.isIntersecting && !isLoading) {
      loadMore();
    }
  },
  { threshold: 0.1 }
);

function loadMore() {
  isLoading = true;
  // 显示 loading 状态
  sentinel.innerHTML = '加载中...';

  fetch('https://jztheme.com/api/news?page=' + (currentPage + 1))
    .then(res => res.json())
    .then(data => {
      if (data.list.length === 0) {
        sentinel.innerHTML = '没有更多内容了';
        observer.unobserve(sentinel);
        return;
      }

      const container = document.getElementById('news-list');
      data.list.forEach(item => {
        const article = document.createElement('article');
        article.className = 'news-item';
        article.innerHTML = &lt;h3&gt;${item.title}&lt;/h3&gt;&lt;p&gt;${item.summary}&lt;/p&gt;;
        container.insertBefore(article, sentinel);
      });

      currentPage++;
      // 重置状态
      sentinel.innerHTML = '';
    })
    .catch(err => {
      console.error('加载失败:', err);
      sentinel.innerHTML = '加载失败,点击重试';
      sentinel.onclick = () => {
        sentinel.onclick = null;
        loadMore();
      };
    })
    .finally(() => {
      isLoading = false;
    });
}

// 开始监听
observer.observe(sentinel);

就这么几行,反而比原来绑在 window.onscroll 上稳定多了。关键是它不依赖页面是否真的在滚动,哪怕 body 被 fixed 住了,只要那个哨兵元素出现在屏幕上,就能触发回调。

谁更灵活?谁更省事?

其实还有两种常见做法:一种是监听 window.scroll,计算距离底部的距离;另一种是用 requestAnimationFrame 不断检查位置。

我之前就是用的第一种:

window.addEventListener('scroll', () => {
  const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
  if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoading) {
    loadMore();
  }
});

看着很直观,但在 iOS 上经常不准。尤其是键盘弹出、地址栏隐藏的时候,clientHeight 会动态变化,导致你算出来的阈值偏移。而且像前面说的,如果 body 被 fixed 了,scrollTop 根本不会变,这个判断直接失效。

第二种 requestAnimationFrame 轮询,虽然精度高,但太耗性能。本来就是个边缘触发的功能,没必要每帧都算一次。而且一旦页面复杂起来,卡顿就会导致检测延迟,用户体验反而更差。

所以综合来看,IntersectionObserver 是目前最优解。它是浏览器原生支持的,性能好,兼容性也还行(iOS 12.2+ 都支持)。而且它本身就是为“元素可见性”设计的,语义清晰,不容易出错。

踩坑提醒:这三点一定注意

虽然现在功能跑通了,但中间还是有几个细节差点把我绕进去:

  1. 哨兵元素必须在文档流中。如果你把它设成 position: absolute 或 display: none,Observer 就检测不到它了。哪怕 visibility: hidden 都不行。必须是真实存在于布局中的元素。
  2. 避免重复请求。一定要用 isLoading 标志位锁住。否则在慢网络下,用户稍微多滑两下,就会发出多个请求,后端压力大不说,数据还可能乱序插入。
  3. 加载失败要可恢复。我一开始写完就撤了 observer,结果网络差的时候加载失败,用户就卡住了。后来改成保留哨兵,并绑定点击重试,体验好很多。

还有一个小问题到现在还没完美解决:当用户快速上滑时,哨兵刚进入视口就被移除或替换,偶尔会出现“加载了一半停住”的情况。目前的 workaround 是在 loadMore 开始时先把哨兵 innerHTML 设为空字符串,防止视觉干扰,但这治标不治本。

不过这个场景出现概率很低,暂时先放着了。毕竟上线优先。

可以再优化的地方

其实还可以进一步提升体验。比如:

  • 预加载下一页数据,在用户滚到 70% 的时候就开始请求,减少等待时间
  • 对图片做懒加载,配合 IO 一起管理
  • 在低网速环境下自动降级为“点击加载更多”

但现在项目排期紧,这些都只能记在 TODO 里了。先保证主流程稳了再说。

另外提醒一下,如果你的列表项高度差异很大,比如有的卡片很长,有的很短,那 threshold 设成 0.1 可能不够准。可以考虑动态调整 rootMargin,比如 { rootMargin: '0px 0px 200px 0px' },提前 200px 触发,给请求留出缓冲时间。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流

说实话,这种看似简单的功能,真做起来细节一堆。尤其是移动端各种奇葩行为,光靠理论推导根本搞不定,必须实机测。

这次最大的教训是:别轻易动 body 的定位和溢出属性。一旦你用了 position: fixed 来锁屏,就得配套一套完整的恢复机制,不然很容易波及到其他依赖滚动的组件。

而用 IntersectionObserver 做无限滚动,算是目前最省心的方案了。虽然 IE 不支持,但现在谁还 cares IE 呢。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论