无限滚动加载是内容类应用中非常常见的交互模式,用户向下滚动页面时,当到达内容底部会自动触发新数据的加载,无需手动点击分页按钮,能够提升用户的使用体验。实现这个功能需要解决两个核心问题,一是如何准确检测用户是否已经滚动到页面底部,二是如何合理处理分页请求,避免重复请求、请求冲突等问题。

触底检测的实现方式
触底检测的核心逻辑是比较当前滚动位置与页面可滚动总高度的关系,常见的检测方式有两种,分别是基于文档对象的检测和基于容器元素的检测。
基于文档的全局触底检测
如果无限滚动的内容是铺满整个页面文档的,可以直接通过document.documentElement或者document.body的相关属性来计算滚动位置。判断逻辑为:当前滚动距离 + 可视区域高度 >= 文档总高度 - 阈值,就认为已经触底。
这里可以设置一个阈值,比如50px,当距离底部还有50px的时候就提前触发加载,避免用户看到空白区域。
// 全局触底检测函数
function checkReachBottom(threshold = 50) {
// 获取文档总高度,兼容不同浏览器的写法
const scrollHeight = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight
);
// 获取当前滚动距离
const scrollTop = Math.max(
document.documentElement.scrollTop,
document.body.scrollTop
);
// 获取可视区域高度
const clientHeight = window.innerHeight || document.documentElement.clientHeight;
// 判断是否触底
return scrollTop + clientHeight >= scrollHeight - threshold;
}
基于容器的局部触底检测
如果无限滚动的内容是放在某个固定高度的容器内部的,比如一个div容器设置了overflow-y: auto,那么需要基于这个容器的属性来计算。判断逻辑为:容器的滚动距离 + 容器的可视高度 >= 容器的总内容高度 - 阈值。
// 容器触底检测函数,container是目标容器DOM元素
function checkContainerReachBottom(container, threshold = 50) {
const scrollHeight = container.scrollHeight;
const scrollTop = container.scrollTop;
const clientHeight = container.clientHeight;
return scrollTop + clientHeight >= scrollHeight - threshold;
}
分页请求的处理逻辑
触底检测只是触发加载的时机,分页请求需要处理更多的状态,避免出现问题。常见的需要处理的状态包括:当前页码、每页条数、是否正在加载、是否还有更多数据。
核心状态定义
我们需要维护以下几个核心状态来控制分页请求:
- currentPage:当前已经加载到的页码,初始值为1
- pageSize:每页请求的条数,根据业务需求设置,比如10条、20条
- isLoading:是否正在请求数据,防止重复触发请求
- hasMore:是否还有更多数据可以加载,当接口返回的数据条数小于pageSize时,说明没有更多数据了
完整的加载流程
完整的无限滚动加载流程如下:
- 监听滚动事件,触发触底检测
- 如果触底,且当前没有正在加载,且还有更多数据,才发起分页请求
- 请求开始时设置isLoading为true,避免重复请求
- 请求成功后,将新数据拼接到已有数据列表中,currentPage加1,判断返回数据长度是否小于pageSize,如果是则设置hasMore为false
- 请求失败后,可以给出提示,不影响后续加载
- 请求结束后,无论成功失败,都设置isLoading为false
完整代码示例
下面是一个基于原生JavaScript实现的全局无限滚动加载的完整示例,模拟请求后端分页数据的过程:
// 模拟的后端请求函数,实际项目中替换为真实的接口请求
function fetchData(page, pageSize) {
return new Promise((resolve) => {
// 模拟网络延迟
setTimeout(() => {
// 模拟返回数据,最多返回5页数据
if (page > 5) {
resolve({
list: [],
total: 50
});
return;
}
const list = [];
for (let i = 0; i < pageSize; i++) {
list.push({
id: (page - 1) * pageSize + i + 1,
content: `这是第${page}页的第${i + 1}条内容`
});
}
resolve({
list: list,
total: 50
});
}, 1000);
});
}
// 核心状态
let currentPage = 1;
const pageSize = 10;
let isLoading = false;
let hasMore = true;
// 存储所有加载的内容
const contentList = [];
// 触底检测函数
function checkReachBottom(threshold = 50) {
const scrollHeight = Math.max(
document.documentElement.scrollHeight,
document.body.scrollHeight
);
const scrollTop = Math.max(
document.documentElement.scrollTop,
document.body.scrollTop
);
const clientHeight = window.innerHeight || document.documentElement.clientHeight;
return scrollTop + clientHeight >= scrollHeight - threshold;
}
// 渲染内容到页面
function renderContent() {
const container = document.getElementById('content-container');
contentList.forEach(item => {
const div = document.createElement('div');
div.className = 'content-item';
div.textContent = item.content;
container.appendChild(div);
});
}
// 加载数据函数
async function loadMoreData() {
// 如果正在加载或者没有更多数据,直接返回
if (isLoading || !hasMore) {
return;
}
isLoading = true;
// 显示加载提示
const loadingEl = document.getElementById('loading');
loadingEl.style.display = 'block';
try {
const res = await fetchData(currentPage, pageSize);
if (res.list.length > 0) {
contentList.push(...res.list);
renderContent();
currentPage++;
// 如果返回的数据长度小于pageSize,说明没有更多数据了
if (res.list.length < pageSize) {
hasMore = false;
document.getElementById('no-more').style.display = 'block';
}
} else {
hasMore = false;
document.getElementById('no-more').style.display = 'block';
}
} catch (error) {
console.error('加载数据失败', error);
alert('加载数据失败,请稍后重试');
} finally {
isLoading = false;
loadingEl.style.display = 'none';
}
}
// 监听滚动事件,使用节流优化性能,避免频繁触发
let timer = null;
window.addEventListener('scroll', () => {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
if (checkReachBottom()) {
loadMoreData();
}
}, 200);
});
// 初始加载第一页数据
loadMoreData();
对应的HTML结构如下:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>无限滚动加载示例</title>
<style>
.content-item {
height: 100px;
line-height: 100px;
text-align: center;
border-bottom: 1px solid #eee;
font-size: 16px;
}
#loading, #no-more {
text-align: center;
height: 50px;
line-height: 50px;
color: #999;
display: none;
}
</style>
</head>
<body>
<div id="content-container"></div>
<div id="loading">加载中...</div>
<div id="no-more">没有更多内容了</div>
<script src="index.js"></script>
</body>
</html>
注意事项
在实际开发中,还需要注意以下几点来优化体验:
- 滚动事件需要添加节流处理,避免滚动时频繁触发检测逻辑,浪费性能
- 加载状态要有明确的提示,让用户知道当前正在加载数据
- 当没有更多数据时,要给出明确的提示,避免用户一直等待
- 如果页面有动态改变高度的内容,比如图片懒加载,需要在内容高度变化后重新计算触底状态,避免漏加载
- 分页请求的参数要和后端约定好,比如页码是从0开始还是从1开始,避免参数错误导致数据加载异常