无限滚动时多次触发请求该怎么解决?

Good“悦洋 阅读 39

我用window.scroll监听做无限滚动,设置的防抖函数也没问题,但滚动到底部时还是会触发多次请求。比如快速滚动时甚至会连续请求三次,怎么排查这个问题?

我的逻辑是监听scroll事件,当scrollTop + clientHeight >= documentHeight时调用fetchData:


const handleScroll = debounce(() => {
  const { scrollTop, clientHeight } = document.documentElement;
  const documentHeight = document.body.scrollHeight;
  if (scrollTop + clientHeight >= documentHeight - 200) {
    fetchData(); // 这里会多次调用
  }
}, 300);

尝试过加isLoading标记但没起作用,请求回来后还能继续触发。是不是防抖没生效?或者有其他边界条件没考虑到?

我来解答 赞 7 收藏
二维码
手机扫码查看
2 条解答
打工人雨诺
根本原因是防抖函数的调用时机和滚动事件的触发频率之间存在“竞态”,加上滚动事件本身是高频触发的,即使你加了防抖,如果防抖函数内部的逻辑没有状态锁,还是会在防抖等待期间被多次触发。

先说下为什么你加的 isLoading 标记没起作用:
你可能是在 fetchData 开始时设置 isLoading = true,结束时设为 false,但问题在于——防抖函数本身每次都会重新计时,如果在防抖延迟时间内又触发了新的滚动事件,防抖函数会重新排队,等延迟时间一到,它会执行最后一次调用,而之前被“跳过”的调用根本没机会执行,但你可能在 fetchData 里又没加锁判断,导致多个 fetchData 实例在并发执行(尤其是网络慢的时候更容易暴露这个问题)。

而且,更致命的是:防抖不能解决“请求还没回来就再次触发”的问题,它只解决“短时间内多次触发只执行最后一次”,但如果你滚动到底部后快速抖动几次,或者用户手抖多滚了几次,防抖可能只保留最后一次,但如果最后一次执行时请求还没回来,它又会发新请求。

正确做法是三层防护:

1. 用“节流 + 状态锁”代替纯防抖:防抖适合“输入框实时搜索”这种场景,但无限滚动更适合节流,因为你要确保“只要用户滚到底,就一定要发一次请求”,而不是只发最后一次。节流能保证在一定时间间隔内最多执行一次,同时配合锁机制避免并发请求。

2. 加请求状态锁,且锁必须是“滚动感知型”的:不能只靠一个全局 isLoading,因为滚动事件可能在请求发出后又触发了,导致重复判断。应该在触发请求前加锁,请求结束后立即解锁,且锁的检查和设置必须是原子操作(避免竞态)。

3. 用 IntersectionObserver 代替 scroll 监听(推荐):scroll 事件太底层,性能差、边界条件多,比如滚动到末尾时 document.body.scrollHeight 可能还没更新(异步渲染导致),或者滚动事件触发时 DOM 还没渲染完,导致判断不准。IntersectionObserver 是专门干这个的,它会在元素进入视口时触发,更可靠。

先给你一个最小改动、能立刻用的方案(基于 scroll 监听):

// 全局锁:必须用 let 而不是 const,因为要改值
let isLoading = false;
// 用于记录上一次触发的 scroll 时间,节流用
let lastScrollTime = 0;

const handleScroll = () => {
const now = Date.now();
const throttleInterval = 300; // 节流时间,别用防抖!

// 节流:间隔太短直接返回
if (now - lastScrollTime < throttleInterval) return;
lastScrollTime = now;

// 已经在请求了,直接 return
if (isLoading) return;

const { scrollTop, clientHeight } = document.documentElement;
const documentHeight = document.body.scrollHeight;

// 注意:这里用 >= 100 会更稳妥,因为滚动到末尾时可能还有几像素误差
if (scrollTop + clientHeight >= documentHeight - 100) {
// 锁住!在发请求前立刻设为 true
isLoading = true;

fetchData()
.then(() => {
// 请求成功后解锁
isLoading = false;
})
.catch(() => {
// 请求失败也要解锁,不然会卡死
isLoading = false;
});
}
};

// 用 requestAnimationFrame 优化 scroll 性能(可选但推荐)
window.addEventListener('scroll', () => {
requestAnimationFrame(handleScroll);
});


这个方案里,关键点是:

- 用 节流 而不是防抖,因为无限滚动需要“只要滚到底就发一次”,不是“只发最后一次”
- isLoading 锁必须在判断滚动条件之后、发请求之前立刻设为 true,避免中间有空档被其他事件抢占
- fetchDatathencatch 都要解锁,不然请求失败就卡死了
- 用 requestAnimationFrame 包裹 handleScroll 能减少 scroll 事件的触发频率(浏览器一帧只执行一次),避免滚动太猛时事件堆积

如果你愿意花点时间重构,IntersectionObserver 是更优雅的解法:

const observerTarget = document.querySelector('#load-more-trigger'); // 一个放在列表末尾的空 div

const observer = new IntersectionObserver((entries) => {
// 只关注第一个 entry(一般只有一个)
const entry = entries[0];
if (entry.isIntersecting && !isLoading) {
isLoading = true;
fetchData()
.then(() => { isLoading = false; })
.catch(() => { isLoading = false; });
}
}, {
rootMargin: '0px 0px 200px 0px', // 提前 200px 触发,避免滚动到底才触发
threshold: 0, // 一进入视口就触发
});

if (observerTarget) {
observer.observe(observerTarget);
}


HTML 里在列表最后加个:

<div id="load-more-trigger" style="height: 1px;"></div>


这个方案的优势是:

- 不依赖滚动位置计算,避免 scrollHeight 赋值延迟、滚动边界抖动等问题
- 浏览器原生支持,性能更好
- 不会因为用户手抖滚多次而重复触发,因为 IntersectionObserver 的回调是“状态驱动”的,只有当目标元素从“不可见”变成“可见”时才触发一次

最后说个容易被忽略的细节:如果你的 fetchData 是用 async/await 写的,别忘了在函数开头就设锁,否则 async 函数的调用是同步的,但执行是异步的,锁可能没及时生效:

const fetchData = async () => {
// 立刻锁住!
isLoading = true;
try {
const res = await fetch('/api/list');
// ...
} finally {
isLoading = false;
}
};


你之前的问题,大概率是:
1. 用了防抖(应该用节流)
2. 锁没加在正确时机(应该在判断条件后立刻锁)
3. 没处理请求失败的情况(导致锁卡死)

按上面改完,基本不会再出现“滚一下发三枪”的情况了。
点赞 5
2026-02-26 15:08
欧阳弯弯
你这个问题是防抖没控制住连续触发,加个 loading 状态锁就行了。

let isLoading = false;
const handleScroll = debounce(() => {
if (isLoading) return;
const { scrollTop, clientHeight } = document.documentElement;
const documentHeight = document.body.scrollHeight;
if (scrollTop + clientHeight >= documentHeight - 200) {
isLoading = true;
fetchData().finally(() => {
isLoading = false;
});
}
}, 300);
点赞 5
2026-02-11 09:01