滚动加载实现的几种方案对比及性能优化实践
核心代码就这几行
先说个实在话,滚动加载这玩意儿看着挺复杂,其实核心就那么几行代码。我之前也觉得这是什么高深技术,折腾半天发现,就是监听个滚动事件,判断是否触底就完事了。
这里直接上最基础的实现:
function infiniteScroll() {
let loading = false;
let page = 1;
const container = document.querySelector('.list-container');
window.addEventListener('scroll', () => {
// 计算当前滚动位置距离底部的距离
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// 距离底部100px时开始加载
if (scrollTop + windowHeight >= documentHeight - 100 && !loading) {
loadMoreData();
}
});
async function loadMoreData() {
loading = true;
try {
const response = await fetch(https://jztheme.com/api/list?page=${page});
const data = await response.json();
if (data.list.length > 0) {
renderList(data.list);
page++;
} else {
// 没有更多数据了
console.log('没有更多数据');
}
} catch (error) {
console.error('加载失败:', error);
} finally {
loading = false;
}
}
}
这段代码的核心逻辑就是计算滚动位置,当用户快滚到底部时触发加载。其中有个关键点:scrollTop + windowHeight >= documentHeight - 100,这里减100是为了提前触发加载,用户体验更好。
性能优化别忘了节流
上面的代码有个大问题——scroll事件触发频率太高了。我曾经在某个项目里没加节流,结果页面滚动卡得要死,后来加上节流立马就好了。
建议这样改:
// 节流函数
function throttle(func, delay) {
let timeoutId;
let lastExecTime = 0;
return function (...args) {
const currentTime = Date.now();
if (currentTime - lastExecTime > delay) {
func.apply(this, args);
lastExecTime = currentTime;
} else {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
lastExecTime = currentTime;
}, delay - (currentTime - lastExecTime));
}
};
}
// 使用节流
const throttledScroll = throttle(() => {
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
if (scrollTop + windowHeight >= documentHeight - 100 && !loading) {
loadMoreData();
}
}, 100);
window.addEventListener('scroll', throttledScroll);
100ms的节流间隔基本够用了,既保证了响应速度,又不会影响性能。这个数值可以根据实际体验调整,50-200ms都可以。
React里的实现更优雅
如果用React的话,可以封装成Hook,这样复用起来方便多了:
import { useState, useEffect, useCallback } from 'react';
function useInfiniteScroll(apiUrl, initialPage = 1) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(initialPage);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const response = await fetch(${apiUrl}?page=${page});
const result = await response.json();
if (result.list && result.list.length > 0) {
setData(prev => [...prev, ...result.list]);
setPage(prev => prev + 1);
} else {
setHasMore(false);
}
} catch (error) {
console.error('加载失败:', error);
} finally {
setLoading(false);
}
}, [apiUrl, page, loading, hasMore]);
useEffect(() => {
const handleScroll = throttle(() => {
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
if (scrollTop + windowHeight >= documentHeight - 100) {
loadMore();
}
}, 100);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [loadMore]);
return { data, loading, hasMore, loadMore, refresh: () => setPage(initialPage) };
}
用起来就很简单了:
function MyList() {
const { data, loading, hasMore, loadMore } = useInfiniteScroll('/api/posts');
return (
<div className="list-container">
{data.map(item => (
<div key={item.id} className="list-item">
{item.title}
</div>
))}
{loading && <div className="loading">加载中...</div>}
{!hasMore && <div className="no-more">没有更多了</div>}
</div>
);
}
踩坑提醒:这三点一定注意
这里重点说说我踩过的几个坑:
- loading状态管理:一定要控制好loading状态,不然用户快速滚动会触发多次请求。我见过有人没控制loading,结果一页数据请求发了十几遍,服务器直接炸了。
- 移动端兼容性:iOS的Safari有个特性,页面高度不够时不会触发滚动事件。需要给body设置最小高度:
body { min-height: 100vh; } - 最后一页判断:后端返回的数据长度为0才代表真的没数据了,不要光靠页码判断。有时候数据总数正好是每页数量的整数倍,容易误判。
还有一个坑是关于容器滚动的。如果是某个div内部滚动而不是整个页面滚动,那么监听的对象就要改一下:
const container = document.querySelector('.scroll-container');
container.addEventListener('scroll', throttledScroll);
这时候计算距离的方法也要相应调整:
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const scrollHeight = container.scrollHeight;
if (scrollTop + containerHeight >= scrollHeight - 100) {
// 触发加载
}
Intersection Observer API更现代的方案
现在还有一种更现代的实现方式,用Intersection Observer API。这个API专门用来监听元素是否进入视口,比scroll事件更高效:
function useIntersectionObserver(target, callback, options = {}) {
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
callback();
}
});
},
{
rootMargin: '100px', // 提前100px触发
...options
}
);
if (target.current) {
observer.observe(target.current);
}
return () => {
if (target.current) {
observer.unobserve(target.current);
}
};
}, [target, callback, options]);
}
// 使用示例
function MyComponent() {
const sentinelRef = useRef();
const [data, setData] = useState([]);
const loadMore = useCallback(async () => {
// 加载数据逻辑
}, []);
useIntersectionObserver(sentinelRef, loadMore);
return (
<div>
{/* 数据列表 */}
<div ref={sentinelRef}></div>
</div>
);
}
这种方案的优点是性能更好,因为浏览器内部优化了相交检测,不需要频繁触发scroll事件。缺点是兼容性稍微差一点,IE11不支持,不过现在应该没人管IE了吧。
实际项目中的完整实现
最后贴一个我在实际项目中使用的完整版本,包含了错误重试、加载动画、防抖等细节:
class InfiniteScroll {
constructor(options) {
this.options = {
apiUrl: '',
container: document,
threshold: 100,
retryCount: 3,
...options
};
this.page = 1;
this.loading = false;
this.hasMore = true;
this.retryTimes = 0;
this.init();
}
init() {
this.bindEvents();
}
bindEvents() {
const handleScroll = throttle(() => {
if (!this.hasMore || this.loading) return;
const rect = this.options.container.getBoundingClientRect();
const isNearBottom = rect.bottom <= window.innerHeight + this.options.threshold;
if (isNearBottom) {
this.loadMore();
}
}, 100);
window.addEventListener('scroll', handleScroll);
}
async loadMore() {
this.loading = true;
this.showLoading();
try {
const response = await fetch(${this.options.apiUrl}?page=${this.page});
const data = await response.json();
if (data.list && data.list.length > 0) {
this.options.onLoadSuccess?.(data.list);
this.page++;
this.retryTimes = 0; // 重置重试次数
} else {
this.hasMore = false;
this.options.onNoMore?.();
}
} catch (error) {
console.error('加载失败:', error);
if (this.retryTimes < this.options.retryCount) {
this.retryTimes++;
setTimeout(() => this.loadMore(), 1000 * this.retryTimes); // 递增延迟重试
} else {
this.options.onError?.(error);
this.hasMore = false;
}
} finally {
this.loading = false;
this.hideLoading();
}
}
showLoading() {
// 显示加载动画
const loader = document.createElement('div');
loader.className = 'infinite-loader';
loader.innerHTML = '加载中...';
document.body.appendChild(loader);
}
hideLoading() {
// 隐藏加载动画
const loader = document.querySelector('.infinite-loader');
loader?.remove();
}
reset() {
this.page = 1;
this.hasMore = true;
this.retryTimes = 0;
}
}
用法也很简单:
const infiniteScroll = new InfiniteScroll({
apiUrl: '/api/data',
container: document.querySelector('.content'),
onLoadSuccess: (newData) => {
// 渲染新数据
renderData(newData);
},
onNoMore: () => {
console.log('没有更多数据了');
},
onError: (error) => {
console.error('加载出错:', error);
}
});
这个实现考虑了很多实际场景,比如网络不稳定时的重试机制、loading状态的UI展示等。亲测有效,在好几个项目里都用得很稳定。
以上是我踩坑后的总结,希望对你有帮助。滚动加载这玩意儿看似简单,实际做起来还是有很多细节需要注意的。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论