滚动加载实现的几种方案对比及性能优化实践

一东旭 交互 阅读 2,128
赞 14 收藏
二维码
手机扫码查看
反馈

核心代码就这几行

先说个实在话,滚动加载这玩意儿看着挺复杂,其实核心就那么几行代码。我之前也觉得这是什么高深技术,折腾半天发现,就是监听个滚动事件,判断是否触底就完事了。

滚动加载实现的几种方案对比及性能优化实践

这里直接上最基础的实现:

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展示等。亲测有效,在好几个项目里都用得很稳定。

以上是我踩坑后的总结,希望对你有帮助。滚动加载这玩意儿看似简单,实际做起来还是有很多细节需要注意的。这个技巧的拓展用法还有很多,后续会继续分享这类博客。

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

暂无评论