实现一个无限滚动列表

无限滚动(Infinite Scroll)是现代内容类产品的标配交互——微博、抖音、朋友圈无一例外。区别于传统的分页(Pagination),无限滚动让用户无需主动翻页,数据会随着滚动悄无声息地追加进来。

但在实现上,无限滚动藏着很多"反直觉"的工程坑:

  • scroll 事件 + 计算 scrollTop 来判断是否触底?——高频触发 + 强制同步布局,性能灾难。
  • 数据拉够之后不做处理?——DOM 节点疯狂堆积,内存泄漏,最终页面卡死。

本文将系统地探讨正确实现无限滚动的两种主流方案及其核心原理。

方案一:传统 scroll 事件监听(了解缺陷)

最直觉的做法:监听容器的 scroll 事件,判断 scrollTop + clientHeight >= scrollHeight,满足条件时请求下一页数据。

const container = document.querySelector('.list-container');

container.addEventListener('scroll', throttle(() => {
  const { scrollTop, clientHeight, scrollHeight } = container;
  // 距离底部 50px 时提前触发加载(提前量,改善体验)
  if (scrollTop + clientHeight >= scrollHeight - 50) {
    loadMoreData();
  }
}, 200)); // 必须配合节流!

缺陷

  • 即便加了节流,依然在 scroll 回调中读取了 scrollTopclientHeightscrollHeight 等布局属性,每次都有强制回流的风险。
  • 在 DOM 节点越来越多时,计算量持续上涨。

方案二:现代银弹 —IntersectionObserver

这是目前业界最推荐的方案。

你不需要自己去算 scrollTop,只需要在列表末尾放一个"哨兵元素(Sentinel)",然后用 IntersectionObserver 去监听这个哨兵是否进入了视口。一旦哨兵进入视口,就说明用户滚到了底部,此时加载下一页数据并追加进列表。

浏览器底层使用的是异步的 Intersection 检测,完全不会触发强制回流,性能极高。

// 1. 创建观察者
const observer = new IntersectionObserver((entries) => {
  const sentinel = entries[0];

  // 哨兵进入视口 = 触底
  if (sentinel.isIntersecting) {
    loadMoreData(); // 加载更多
  }
}, {
  // threshold: 0 表示哨兵有任意像素进入视口就触发
  threshold: 0,
  // 可选:rootMargin 让加载比实际触底提前 100px 触发,改善体验
  rootMargin: '0px 0px 100px 0px'
});

// 2. 开始观察列表底部的哨兵元素
const sentinel = document.querySelector('.sentinel');
observer.observe(sentinel);

完整 React 实现 Demo

基于 IntersectionObserver 的无限滚动
import React, { useState, useEffect, useRef, useCallback } from "react";

// 模拟异步接口
const fetchData = (page: number): Promise<string[]> =>
  new Promise((resolve) =>
    setTimeout(() => resolve(Array.from({ length: 10 }, (_, i) => `${page} 页 - 条目 ${i + 1}`)), 800)
  );

export default () => {
  const [items, setItems] = useState<string[]>([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef<HTMLDivElement>(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);
    const newItems = await fetchData(page);
    setItems((prev) => [...prev, ...newItems]);
    setPage((p) => p + 1);
    if (page >= 4) setHasMore(false); // 模拟最多 4 页
    setLoading(false);
  }, [loading, hasMore, page]);

  // 初始加载
  useEffect(() => { loadMore(); }, []);

  // 设置 IntersectionObserver 观察哨兵
  useEffect(() => {
    if (!sentinelRef.current) return;
    const observer = new IntersectionObserver(
      (entries) => { if (entries[0].isIntersecting) loadMore(); },
      { rootMargin: "0px 0px 50px 0px" }
    );
    observer.observe(sentinelRef.current);
    return () => observer.disconnect();
  }, [loadMore]);

  return (
    <div style={{ height: "300px", overflowY: "auto", border: "1px solid #eee", borderRadius: "8px" }}>
      {items.map((item, i) => (
        <div key={i} style={{ padding: "12px 16px", borderBottom: "1px solid #f5f5f5", fontSize: "14px" }}>
          {item}
        </div>
      ))}

      {/* 哨兵元素:放在列表末尾 */}
      <div ref={sentinelRef} style={{ height: "1px" }} />

      {loading && (
        <div style={{ textAlign: "center", padding: "16px", color: "#999", fontSize: "13px" }}>
          ⏳ 加载中...
        </div>
      )}
      {!hasMore && (
        <div style={{ textAlign: "center", padding: "16px", color: "#ccc", fontSize: "13px" }}>
          — 没有更多了 —
        </div>
      )}
    </div>
  );
};

进阶优化:虚拟列表(DOM 节点回收)

当数据量极大(比如已加载了 10000 条记录),如果仍然把所有 DOM 节点全留在页面上,内存消耗和渲染压力会让页面非常缓慢,这时就需要引入**虚拟列表(Virtual List)**技术:

  • 只渲染视口内可见的几十条数据对应的 DOM 节点("窗口")。
  • 当用户滚动时,动态替换"窗口"内渲染的内容,销毁滚出视口的节点,生成新的进入视口的节点。
  • 整个列表的 DOM 节点数量始终维持在一个固定的低值(如 30 ~ 50 个)。

这在工程上通常直接使用现成的库,如 react-windowreact-virtual、Vue 的 vue-virtual-scroller

import { FixedSizeList } from 'react-window';

// 每行固定高度为 50px,只渲染可视区域内的条目
<FixedSizeList height={400} itemCount={10000} itemSize={50} width="100%">
  {({ index, style }) => (
    <div style={style}>Row {index}</div>
  )}
</FixedSizeList>

高频面试题剖析

Q:如何实现一个无限滚动功能?当列表数据量非常大时如何优化?

回答思路:

  1. 方案选择:指出两种方案。传统的 scroll 事件监听(需要结合节流)虽然可行,但因为需要读取布局属性有性能风险。推荐使用 IntersectionObserver 监听列表末尾的"哨兵元素",该 API 是浏览器原生的异步检测机制,完全不触发强制回流,且节省了人工节流的成本。
  2. 解释核心实现逻辑:哨兵元素放在列表最末尾;IntersectionObserver 监听哨兵进入视口事件 → 触发数据加载 → 新数据追加到列表末尾 → 哨兵元素被重新推到新的底部。
  3. 展开进阶优化(拿高分):提到当数据量极大(万级以上)时,即便用了懒加载,DOM 节点的持续堆积仍然对内存和渲染性能造成极大压力。此时需要引入虚拟列表(Virtual Scrolling)技术,只渲染可视窗口内的少量节点,滚动时动态替换内容,是大数据列表的终极解法。实际开发中推荐直接使用 react-windowreact-virtual 等成熟库。