无限滚动加载
实现页面滚动到底部自动加载更多内容
问题
实现页面滚动到底部时自动加载更多内容(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 方式,必须做节流处理
目录