无限滚动加载

实现页面滚动到底部自动加载更多内容

问题

实现页面滚动到底部时自动加载更多内容(Infinite Scroll)。

解答

方式一:Intersection Observer(推荐)

class InfiniteScroll {
  constructor(options) {
    this.container = options.container;
    this.loadMore = options.loadMore;
    this.loading = false;
    this.hasMore = true;
    
    // 创建哨兵元素,放在列表底部
    this.sentinel = document.createElement('div');
    this.sentinel.className = 'scroll-sentinel';
    this.container.appendChild(this.sentinel);
    
    this.init();
  }
  
  init() {
    // 创建观察器
    this.observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        // 哨兵进入视口时触发加载
        if (entry.isIntersecting && !this.loading && this.hasMore) {
          this.load();
        }
      },
      {
        root: null, // 视口作为根
        rootMargin: '100px', // 提前 100px 触发
        threshold: 0
      }
    );
    
    this.observer.observe(this.sentinel);
  }
  
  async load() {
    this.loading = true;
    
    try {
      // 调用加载函数,返回是否还有更多数据
      this.hasMore = await this.loadMore();
    } finally {
      this.loading = false;
    }
    
    // 没有更多数据时停止观察
    if (!this.hasMore) {
      this.observer.disconnect();
      this.sentinel.remove();
    }
  }
  
  destroy() {
    this.observer.disconnect();
    this.sentinel.remove();
  }
}

// 使用示例
const list = document.getElementById('list');
let page = 1;

const scroller = new InfiniteScroll({
  container: list,
  loadMore: async () => {
    const response = await fetch(`/api/items?page=${page}`);
    const data = await response.json();
    
    // 渲染数据
    data.items.forEach(item => {
      const div = document.createElement('div');
      div.textContent = item.name;
      list.appendChild(div);
    });
    
    page++;
    return data.hasMore; // 返回是否还有更多
  }
});

方式二:Scroll 事件监听

class ScrollLoader {
  constructor(options) {
    this.container = options.container;
    this.loadMore = options.loadMore;
    this.threshold = options.threshold || 100; // 距底部多少像素触发
    this.loading = false;
    this.hasMore = true;
    
    this.handleScroll = this.throttle(this.checkScroll.bind(this), 200);
    this.container.addEventListener('scroll', this.handleScroll);
  }
  
  checkScroll() {
    if (this.loading || !this.hasMore) return;
    
    const { scrollTop, scrollHeight, clientHeight } = this.container;
    // 判断是否滚动到底部附近
    const distanceToBottom = scrollHeight - scrollTop - clientHeight;
    
    if (distanceToBottom < this.threshold) {
      this.load();
    }
  }
  
  async load() {
    this.loading = true;
    
    try {
      this.hasMore = await this.loadMore();
    } finally {
      this.loading = false;
    }
  }
  
  // 节流函数
  throttle(fn, delay) {
    let timer = null;
    return function(...args) {
      if (timer) return;
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, delay);
    };
  }
  
  destroy() {
    this.container.removeEventListener('scroll', this.handleScroll);
  }
}

React Hook 实现

import { useEffect, useRef, useState, useCallback } from 'react';

function useInfiniteScroll(fetchData) {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef(null);
  const pageRef = useRef(1);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;
    
    setLoading(true);
    try {
      const { data, hasMore: more } = await fetchData(pageRef.current);
      setItems(prev => [...prev, ...data]);
      setHasMore(more);
      pageRef.current++;
    } finally {
      setLoading(false);
    }
  }, [fetchData, loading, hasMore]);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          loadMore();
        }
      },
      { rootMargin: '100px' }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loadMore]);

  return { items, loading, hasMore, sentinelRef };
}

// 使用
function List() {
  const { items, loading, hasMore, sentinelRef } = useInfiniteScroll(
    async (page) => {
      const res = await fetch(`/api/items?page=${page}`);
      return res.json();
    }
  );

  return (
    <div>
      {items.map(item => <div key={item.id}>{item.name}</div>)}
      {hasMore && <div ref={sentinelRef}>加载中...</div>}
    </div>
  );
}

关键点

  • Intersection Observer 优于 scroll 事件:不阻塞主线程,性能更好
  • 哨兵元素:在列表底部放置一个空元素作为观察目标
  • rootMargin 提前触发:设置正值可以提前加载,体验更流畅
  • 防止重复加载:用 loading 标志位控制,避免并发请求
  • scroll 事件需要节流:如果用 scroll 方式,必须做节流处理