无限滚动实现的那些坑我替你踩过了
几个方案选下来,还是Intersection Observer最好用
最近做了一个长列表项目,需要无限滚动加载数据。之前零零散散用过几种方案,这次正好系统对比一下。说实话,对比下来我更倾向于用Intersection Observer了,性能和体验都比传统方案好太多。
我常用的就这几种:传统的scroll事件监听、Intersection Observer API、还有Vue/React的第三方组件。各有各的坑,也各有各的优点。
传统scroll事件:简单粗暴但容易卡顿
先说最经典的scroll事件方案,我估计90%的人都用过这个。代码很简单:
function initInfiniteScroll() {
let loading = false;
let page = 1;
window.addEventListener('scroll', 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));
async function loadMoreData() {
loading = true;
try {
const response = await fetch(https://jztheme.com/api/list?page=${page});
const data = await response.json();
// 渲染新数据
renderData(data.items);
page++;
} catch (error) {
console.error('加载失败:', error);
} finally {
loading = false;
}
}
}
// 节流函数
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 = Date.now();
}, delay - (currentTime - lastExecTime));
}
};
}
这套代码我用了好几年,优点是兼容性好,逻辑简单。但问题是性能真的很差,scroll事件触发频率太高,即使加了节流也容易卡顿。特别是移动端,滑动体验一言难尽。
这里注意我踩过好几次坑:window.scrollY在某些浏览器兼容性有问题,所以要用documentElement.scrollTop兼容一下。还有就是边界判断要留点余量,不然可能拉到底部了还在loading。
Intersection Observer:现代浏览器的救星
这是我现在主力用的方案,Chrome51+、Firefox55+都支持,基本够用了。代码比scroll事件复杂点,但性能好太多了:
class InfiniteScrollObserver {
constructor(options = {}) {
this.options = {
root: null,
rootMargin: options.rootMargin || '100px',
threshold: options.threshold || 0.1,
...options
};
this.page = 1;
this.loading = false;
this.hasMore = true;
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
this.options
);
this.setupTrigger();
}
setupTrigger() {
// 创建观察元素
this.triggerElement = document.createElement('div');
this.triggerElement.style.height = '1px';
this.triggerElement.style.width = '100%';
document.body.appendChild(this.triggerElement);
this.observer.observe(this.triggerElement);
}
async handleIntersection(entries) {
entries.forEach(async (entry) => {
if (entry.isIntersecting && !this.loading && this.hasMore) {
await this.loadMore();
}
});
}
async loadMore() {
this.loading = true;
this.showLoading();
try {
const response = await fetch(https://jztheme.com/api/list?page=${this.page});
const data = await response.json();
if (data.items.length === 0) {
this.hasMore = false;
this.showNoMore();
return;
}
this.renderData(data.items);
this.page++;
} catch (error) {
console.error('加载失败:', error);
this.showError();
} finally {
this.loading = false;
this.hideLoading();
}
}
showLoading() {
// 显示loading状态
let loadingEl = document.querySelector('.infinite-loading');
if (!loadingEl) {
loadingEl = document.createElement('div');
loadingEl.className = 'infinite-loading';
loadingEl.textContent = '加载中...';
document.body.appendChild(loadingEl);
}
}
hideLoading() {
const loadingEl = document.querySelector('.infinite-loading');
if (loadingEl) {
loadingEl.remove();
}
}
renderData(items) {
const container = document.querySelector('.list-container');
items.forEach(item => {
const itemEl = document.createElement('div');
itemEl.className = 'list-item';
itemEl.innerHTML =
<h3>${item.title}</h3>
<p>${item.content}</p>
;
container.appendChild(itemEl);
});
}
destroy() {
this.observer.disconnect();
if (this.triggerElement) {
this.triggerElement.remove();
}
}
}
// 使用
const infiniteScroll = new InfiniteScrollObserver({
rootMargin: '200px'
});
这套方案的核心优势是性能好,Intersection Observer只有在目标元素进入视窗时才触发回调,不像scroll事件那样高频执行。而且触发时机更精确,不容易出现误触发的问题。
不过也有个小坑需要注意:创建的triggerElement如果样式设置不当,可能会影响布局。我一般设置height为1px,position固定在底部。还有就是老版本Safari对Intersection Observer的支持有问题,需要用polyfill。
框架组件:开箱即用但不够灵活
Vue和React生态里有现成的无限滚动组件,比如vue-virtual-scroller、react-window-infinite-loader这些。优点是开箱即用,基本不用写多少代码:
// Vue示例
import { RecycleScroller } from 'vue-virtual-scroller'
export default {
components: {
RecycleScroller
},
data() {
return {
items: [],
page: 1,
loading: false
}
},
mounted() {
this.loadData();
},
methods: {
async handleBottom() {
if (this.loading) return;
await this.loadMore();
},
async loadMore() {
this.loading = true;
try {
const response = await fetch(https://jztheme.com/api/list?page=${this.page});
const data = await response.json();
this.items.push(...data.items);
this.page++;
} finally {
this.loading = false;
}
}
}
}
这类组件封装得比较好,通常内置了虚拟滚动优化,长列表渲染性能很优秀。但缺点也很明显:不够灵活,遇到特殊需求经常需要魔改源码,或者干脆重新自己实现一套。
性能对比:差距确实比我想象的大
实际测试了一下,在同一个页面加载200条数据的情况下,scroll事件方案的CPU占用率能到15-20%,Intersection Observer只有5%左右。特别是在低端设备上,差距更明显。
内存方面Intersection Observer也更占优势,因为它不会持续监听,只在需要的时候才触发。scroll事件方案因为持续监听,内存占用会一直保持在较高水平。
我的选型逻辑:日常开发就选Intersection Observer
综合对比下来,我的选型逻辑是这样的:
- 日常开发优先用Intersection Observer,性能好、体验佳
- 需要兼容老浏览器的项目才考虑scroll事件方案
- 复杂长列表场景下用框架组件配合虚拟滚动
Intersection Observer的API虽然看起来复杂一点,但实际用起来其实不难。而且现在大部分项目都不需要支持太老的浏览器了,可以直接用现代方案。老方案虽然简单,但性能问题越来越明显,特别是移动端体验差别很大。
以上是我的对比总结,有不同看法欢迎评论区交流
这些方案我都在线上项目用过,各有各的适用场景。不过现在的项目我基本都是直接上Intersection Observer,除非有特殊的兼容性要求。这个技术对比希望能给还在纠结选择的同学一些参考。

暂无评论