无限滚动实现的那些坑我替你踩过了

Designer°艳苹 交互 阅读 670
赞 14 收藏
二维码
手机扫码查看
反馈

几个方案选下来,还是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,除非有特殊的兼容性要求。这个技术对比希望能给还在纠结选择的同学一些参考。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论