下拉刷新与上拉加载

990 阅读11分钟

前端移动端下拉刷新与上拉加载详解

下拉刷新和上拉加载是移动端应用中非常常见的交互模式,用于提升用户体验,尤其是在列表数据展示的场景中。

  • 下拉刷新 (Pull-to-Refresh): 用户在列表顶部向下滑动一段距离后释放,触发数据刷新操作,通常用于获取最新的数据。
  • 上拉加载 (Infinite Scrolling / Pull-up-to-Load-More): 用户滚动到列表底部时,自动或手动触发加载更多历史数据的操作。

实现原理

这两种功能的实现都依赖于监听用户的触摸 (touch) 事件和滚动 (scroll) 事件。

下拉刷新核心逻辑:

  1. 监听触摸开始 (touchstart): 记录初始触摸位置。

  2. 监听触摸移动 (touchmove):

    • 计算手指滑动的垂直距离。
    • 判断滑动方向是否向下。
    • 判断列表是否滚动到顶部 ( scrollTop === 0 )。
    • 如果满足条件,则根据滑动距离显示“下拉刷新”的提示,并可能伴随一些动画效果(如元素跟随下拉)。
  3. 监听触摸结束 (touchend):

    • 判断下拉的距离是否达到触发刷新的阈值。
    • 如果达到阈值,则显示“加载中”状态,并执行数据刷新操作(通常是异步请求)。
    • 刷新完成后,隐藏加载状态,恢复列表。
    • 如果未达到阈值,则恢复到初始状态。

上拉加载核心逻辑:

  1. 监听滚动事件 (scroll):

    • 获取滚动容器的几个关键高度:

      • scrollHeight: 元素内容的总高度。
      • scrollTop: 元素滚动条在垂直方向上已滚动的距离。
      • clientHeight: 元素的可视区域高度。
    • 判断是否滚动到底部:通常条件是 scrollTop + clientHeight >= scrollHeight - threshold (threshold 是一个小的容差值,用于提前加载)。

  2. 触发加载:

    • 如果滚动到底部,并且当前没有正在加载数据,则显示“加载中”状态,并执行加载更多数据的操作(异步请求)。
    • 数据加载完成后,将新数据追加到列表中,并隐藏加载状态。
    • 如果所有数据都已加载完毕,可以显示“没有更多数据”的提示。

代码示例

下面是一个使用原生 JavaScript 实现的简单示例。在实际项目中,你可能会使用像 better-scroll 这样的库来简化实现并获得更平滑的体验,但理解原生实现有助于掌握核心原理。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>下拉刷新与上拉加载示例</title>
    <style>
        body, html {
            margin: 0;
            padding: 0;
            height: 100%;
            font-family: Arial, sans-serif;
            background-color: #f4f4f4;
            overscroll-behavior-y: contain; /* 防止在某些浏览器中下拉刷新时触发浏览器自身的刷新 */
        }

        .container {
            height: 100vh; /* 占满整个视窗高度 */
            overflow-y: auto; /* 允许内容垂直滚动 */
            -webkit-overflow-scrolling: touch; /* iOS 优化滚动体验 */
            background-color: #fff;
            position: relative; /* 用于定位下拉刷新提示 */
        }

        .pull-to-refresh-indicator {
            position: absolute;
            top: -50px; /* 初始隐藏在视窗上方 */
            left: 0;
            width: 100%;
            height: 50px;
            line-height: 50px;
            text-align: center;
            color: #666;
            background-color: #f0f0f0;
            z-index: 10;
            transition: transform 0.2s ease-out; /* 平滑过渡效果 */
        }

        .pull-to-refresh-indicator.visible {
            transform: translateY(50px); /* 下拉时显示 */
        }

        .content-list {
            list-style: none;
            padding: 0;
            margin: 0;
        }

        .content-list li {
            padding: 15px;
            border-bottom: 1px solid #eee;
            background-color: #fff;
        }

        .content-list li:last-child {
            border-bottom: none;
        }

        .load-more-indicator {
            padding: 15px;
            text-align: center;
            color: #666;
            background-color: #f9f9f9;
        }

        .loading-spinner {
            display: inline-block;
            width: 20px;
            height: 20px;
            border: 3px solid rgba(0,0,0,.1);
            border-radius: 50%;
            border-top-color: #3498db;
            animation: spin 1s ease-in-out infinite;
            margin-right: 8px;
            vertical-align: middle;
        }

        @keyframes spin {
            to { transform: rotate(360deg); }
        }
    </style>
</head>
<body>

<div class="container" id="scrollContainer">
    <div class="pull-to-refresh-indicator" id="pullRefreshIndicator">
        <span class="text">下拉刷新</span>
        <span class="spinner" style="display: none;"><div class="loading-spinner"></div>加载中...</span>
    </div>
    <ul class="content-list" id="contentList">
        <!-- 初始数据将在这里加载 -->
    </ul>
    <div class="load-more-indicator" id="loadMoreIndicator">
        <span class="text">上拉加载更多</span>
        <span class="spinner" style="display: none;"><div class="loading-spinner"></div>加载中...</span>
        <span class="no-more" style="display: none;">没有更多数据了</span>
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', () => {
        const scrollContainer = document.getElementById('scrollContainer');
        const contentList = document.getElementById('contentList');
        const pullRefreshIndicator = document.getElementById('pullRefreshIndicator');
        const pullRefreshText = pullRefreshIndicator.querySelector('.text');
        const pullRefreshSpinner = pullRefreshIndicator.querySelector('.spinner');
        const loadMoreIndicator = document.getElementById('loadMoreIndicator');
        const loadMoreText = loadMoreIndicator.querySelector('.text');
        const loadMoreSpinner = loadMoreIndicator.querySelector('.spinner');
        const noMoreText = loadMoreIndicator.querySelector('.no-more');

        let isRefreshing = false;
        let isLoadingMore = false;
        let currentPage = 1;
        const itemsPerPage = 15;
        let hasMoreData = true;

        let touchStartY = 0;
        let pullDistance = 0;
        const PULL_THRESHOLD = 70; // 下拉刷新的阈值

        // 模拟异步数据获取
        function fetchData(page, count, isRefresh = false) {
            return new Promise((resolve) => {
                setTimeout(() => {
                    const newData = [];
                    if (page > 5 && !isRefresh) { // 模拟没有更多数据的情况
                        resolve([]);
                        return;
                    }
                    const startItem = (page - 1) * count + 1;
                    for (let i = 0; i < count; i++) {
                        newData.push(`条目 ${startItem + i} (页 ${page})`);
                    }
                    resolve(newData);
                }, 1000); // 模拟网络延迟
            });
        }

        // 渲染列表项
        function renderItems(items, isRefresh = false) {
            if (isRefresh) {
                contentList.innerHTML = ''; // 清空现有列表
            }
            items.forEach(itemText => {
                const li = document.createElement('li');
                li.textContent = itemText;
                contentList.appendChild(li);
            });
        }

        // --- 下拉刷新逻辑 ---
        scrollContainer.addEventListener('touchstart', (e) => {
            if (isRefreshing || isLoadingMore || scrollContainer.scrollTop !== 0) {
                return;
            }
            touchStartY = e.touches[0].clientY;
            pullRefreshIndicator.style.transition = 'none'; // 拖拽时禁用过渡,释放时再启用
        }, { passive: true });

        scrollContainer.addEventListener('touchmove', (e) => {
            if (isRefreshing || isLoadingMore || scrollContainer.scrollTop !== 0 && touchStartY === 0) {
                // 如果在滚动过程中,scrollTop不为0,则重置touchStartY,避免从中间开始下拉也被错误判断
                touchStartY = 0; // 重置,确保只有在顶部才开始计算下拉
                return;
            }
            if (touchStartY === 0 && scrollContainer.scrollTop === 0) { // 确保是在顶部开始的下拉
                 touchStartY = e.touches[0].clientY; // 重新获取起始点
            }
            if (touchStartY === 0) return; // 如果依然没有有效的起始点,则返回

            const touchMoveY = e.touches[0].clientY;
            pullDistance = touchMoveY - touchStartY;

            if (pullDistance > 0 && scrollContainer.scrollTop === 0) {
                // 阻止页面默认滚动行为,尤其是在iOS上,当顶部元素被拉动时
                // e.preventDefault(); // 注意: passive: true 时不能使用 preventDefault,如果需要,需移除 passive

                pullRefreshIndicator.classList.add('visible');
                pullRefreshIndicator.style.transform = `translateY(${Math.min(pullDistance, PULL_THRESHOLD * 1.5)}px)`; // 给一些阻尼感

                if (pullDistance >= PULL_THRESHOLD) {
                    pullRefreshText.textContent = '释放立即刷新';
                } else {
                    pullRefreshText.textContent = '下拉刷新';
                }
            }
        }, { passive: false }); // passive: false 允许 preventDefault

        scrollContainer.addEventListener('touchend', async () => {
            if (isRefreshing || isLoadingMore || scrollContainer.scrollTop !== 0) {
                touchStartY = 0;
                pullDistance = 0;
                return;
            }

            pullRefreshIndicator.style.transition = 'transform 0.2s ease-out'; // 恢复过渡

            if (pullDistance >= PULL_THRESHOLD) {
                isRefreshing = true;
                pullRefreshText.style.display = 'none';
                pullRefreshSpinner.style.display = 'inline';
                pullRefreshIndicator.style.transform = `translateY(${PULL_THRESHOLD}px)`; // 固定在刷新位置

                console.log('开始刷新...');
                currentPage = 1; // 重置页码
                hasMoreData = true; // 重置是否有更多数据的状态
                noMoreText.style.display = 'none';
                loadMoreText.style.display = 'inline';

                try {
                    const newData = await fetchData(currentPage, itemsPerPage, true);
                    renderItems(newData, true);
                    if (newData.length < itemsPerPage) {
                        hasMoreData = false;
                        loadMoreText.style.display = 'none';
                        loadMoreSpinner.style.display = 'none';
                        noMoreText.style.display = 'inline';
                    }
                } catch (error) {
                    console.error('刷新失败:', error);
                    pullRefreshText.textContent = '刷新失败'; // 可以添加错误提示
                } finally {
                    isRefreshing = false;
                    pullRefreshText.style.display = 'inline';
                    pullRefreshSpinner.style.display = 'none';
                    pullRefreshIndicator.style.transform = 'translateY(0)';
                    setTimeout(() => { // 等待回弹动画结束后再隐藏
                         pullRefreshIndicator.classList.remove('visible');
                         pullRefreshText.textContent = '下拉刷新';
                    }, 200); // 对应过渡时间
                }
            } else if (pullDistance > 0) { // 下拉但未达到阈值,回弹
                pullRefreshIndicator.style.transform = 'translateY(0)';
                 setTimeout(() => {
                     pullRefreshIndicator.classList.remove('visible');
                     pullRefreshText.textContent = '下拉刷新';
                }, 200);
            }
            touchStartY = 0;
            pullDistance = 0;
        });


        // --- 上拉加载更多逻辑 ---
        scrollContainer.addEventListener('scroll', async () => {
            if (isLoadingMore || isRefreshing || !hasMoreData) {
                return;
            }

            // scrollTop: 滚动条卷去的高度
            // clientHeight: 容器的可视高度
            // scrollHeight: 容器内容的总高度
            const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
            const LOAD_MORE_THRESHOLD = 50; // 距离底部多少像素时开始加载

            if (scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD) {
                isLoadingMore = true;
                loadMoreText.style.display = 'none';
                loadMoreSpinner.style.display = 'inline';
                noMoreText.style.display = 'none';
                console.log('开始加载更多...');

                try {
                    currentPage++;
                    const newData = await fetchData(currentPage, itemsPerPage);
                    if (newData && newData.length > 0) {
                        renderItems(newData);
                        loadMoreText.style.display = 'inline';
                    } else {
                        hasMoreData = false;
                        noMoreText.style.display = 'inline';
                        console.log('没有更多数据了');
                    }
                } catch (error) {
                    console.error('加载更多失败:', error);
                    currentPage--; // 加载失败,页码回退
                    loadMoreText.style.display = 'inline'; // 显示“上拉加载更多”或错误提示
                    loadMoreText.textContent = '加载失败,请重试';
                } finally {
                    isLoadingMore = false;
                    loadMoreSpinner.style.display = 'none';
                    if (!hasMoreData) {
                        loadMoreText.style.display = 'none';
                    }
                }
            }
        });

        // --- 初始加载数据 ---
        async function initialLoad() {
            isLoadingMore = true; // 使用 isLoadingMore 状态锁,避免重复加载
            loadMoreSpinner.style.display = 'inline';
            loadMoreText.style.display = 'none';

            try {
                const initialData = await fetchData(currentPage, itemsPerPage);
                renderItems(initialData);
                if (initialData.length < itemsPerPage) {
                    hasMoreData = false;
                    noMoreText.style.display = 'inline';
                    loadMoreText.style.display = 'none';
                } else {
                    loadMoreText.style.display = 'inline';
                }
            } catch (error) {
                console.error('初始加载失败:', error);
                loadMoreText.textContent = '加载失败,请重试';
                loadMoreText.style.display = 'inline';
            } finally {
                isLoadingMore = false;
                loadMoreSpinner.style.display = 'none';
            }
        }

        initialLoad();
    });
</script>

</body>
</html>

代码讲解

HTML 结构:

  • .container: 作为滚动容器,设置了 overflow-y: autoheight: 100vh 使其可滚动并占满屏幕。overscroll-behavior-y: contain; 用于防止在某些浏览器(如 Chrome Android)中,当内容滚动到顶部或底部时,继续拖动会触发浏览器级别的下拉刷新或导航。

  • .pull-to-refresh-indicator: 下拉刷新提示元素,初始时通过 top: -50px 定位在容器外部,下拉时通过 transform: translateY() 控制其显示。

    • 包含 text (下拉刷新/释放刷新) 和 spinner (加载中动画)。
  • .content-list: 用于展示数据的列表。

  • .load-more-indicator: 上拉加载提示元素,位于列表底部。

    • 包含 text (上拉加载更多)、spinner (加载中动画) 和 no-more (没有更多数据)。

CSS 样式:

  • 基本的布局和美化。
  • .loading-spinner: 一个简单的 CSS 加载动画。
  • .pull-to-refresh-indicator.visible: 控制下拉刷新提示的显示。
  • 过渡效果 (transition) 用于平滑显示和隐藏下拉刷新提示。

JavaScript 逻辑:

  1. 变量定义:

    • scrollContainer, contentList, 等 DOM 元素引用。
    • isRefreshing, isLoadingMore: 状态锁,防止在一次操作未完成时重复触发。
    • currentPage: 当前加载的数据页码。
    • itemsPerPage: 每页加载的条目数。
    • hasMoreData: 标记是否还有更多数据可供加载。
    • touchStartY, pullDistance: 用于下拉刷新计算。
    • PULL_THRESHOLD: 触发下拉刷新的最小下拉距离。
  2. fetchData(page, count, isRefresh):

    • 模拟异步数据请求。实际项目中这里会是 fetchXMLHttpRequest
    • isRefresh 参数用于区分是刷新操作还是加载更多。
    • 模拟了当 page > 5 时没有更多数据的情况。
  3. renderItems(items, isRefresh):

    • 将获取到的数据渲染到列表中。
    • 如果是刷新操作 (isRefresh = true),则先清空列表。
  4. 下拉刷新逻辑 (touchstart, touchmove, touchend 事件监听器):

    • touchstart:

      • 检查是否正在刷新或加载更多,或者列表未滚动到顶部,如果是则不处理。
      • 记录触摸起始点的 Y 坐标 (touchStartY)。
      • 禁用下拉提示的 transition,以便拖拽时实时响应。
    • touchmove:

      • 同样进行状态检查和 scrollTop 检查。
      • 计算下拉距离 pullDistance
      • 只有当 pullDistance > 0 (向下滑动) 且 scrollContainer.scrollTop === 0 (列表在顶部) 时才处理下拉。
      • 通过 transform: translateY() 移动下拉提示元素,使其跟随手势。
      • 根据 pullDistancePULL_THRESHOLD 更新提示文本 (“下拉刷新” 或 “释放立即刷新”)。
      • e.preventDefault(): 在某些情况下,如果希望完全控制下拉行为并阻止浏览器默认的滚动(尤其是在iOS上,当顶部元素被拉动时可能触发页面回弹或导航),可以调用此方法。但要注意,如果事件监听器选项中设置了 passive: true,则 preventDefault() 会被忽略并可能产生警告。示例中对 touchmove 设置了 { passive: false }
    • touchend:

      • 恢复下拉提示的 transition

      • 如果 pullDistance 达到 PULL_THRESHOLD

        • 设置 isRefreshing = true
        • 更新提示为“加载中...”。
        • 将下拉提示固定在刷新位置。
        • 调用 fetchData 获取新数据 (第1页)。
        • 调用 renderItems 渲染新数据 (清空旧数据)。
        • 更新 hasMoreData 状态。
        • 操作完成后,重置状态,隐藏加载提示,将下拉提示元素移回原位。
      • 如果 pullDistance 未达到阈值但大于0 (说明有下拉但未松手释放刷新):

        • 将下拉提示元素平滑移回原位。
      • 重置 touchStartYpullDistance

  5. 上拉加载更多逻辑 (scroll 事件监听器):

    • 检查是否正在加载、刷新或已无更多数据。

    • 获取 scrollTop, clientHeight, scrollHeight

    • 判断条件 scrollTop + clientHeight >= scrollHeight - LOAD_MORE_THRESHOLD 是否满足。LOAD_MORE_THRESHOLD 是一个提前量,使得用户不需要精确滚动到最底部就能触发加载。

    • 如果满足条件:

      • 设置 isLoadingMore = true
      • 显示“加载中...”提示。
      • currentPage++
      • 调用 fetchData 获取下一页数据。
      • 如果获取到数据,则追加到列表;如果未获取到数据或数据为空,则设置 hasMoreData = false 并显示“没有更多数据了”。
      • 操作完成后,重置状态,更新提示。
  6. initialLoad():

    • 页面加载完成后首次加载数据。逻辑与上拉加载类似,但加载的是第一页数据。

关键点和优化建议

  1. 节流 (Throttling) scroll 事件: scroll 事件触发非常频繁。对于上拉加载,可以使用节流函数来限制其处理函数的执行频率,以提升性能。

    function throttle(func, delay) {
        let timeoutId = null;
        let lastExecTime = 0;
        return function(...args) {
            const currentTime = Date.now();
            const remainingTime = delay - (currentTime - lastExecTime);
            clearTimeout(timeoutId);
            if (remainingTime <= 0) {
                lastExecTime = currentTime;
                func.apply(this, args);
            } else {
                timeoutId = setTimeout(() => {
                    lastExecTime = Date.now();
                    func.apply(this, args);
                }, remainingTime);
            }
        };
    }
    // 使用:
    // scrollContainer.addEventListener('scroll', throttle(async () => { /* ... */ }, 200));
    
  2. 用户体验 (UX):

    • 明确的视觉反馈: 用户需要清楚地知道当前的状态(下拉中、释放刷新、加载中、没有更多数据)。
    • 合适的阈值: PULL_THRESHOLDLOAD_MORE_THRESHOLD 需要根据实际体验调整。
    • 加载动画: 使用平滑的加载动画。
    • 回弹效果: 下拉刷新释放后的回弹动画要自然。
    • 错误处理: 网络请求失败时,应给出明确提示,并允许用户重试。
  3. passive 事件监听器:

    • 对于 touchstarttouchmove 事件,如果不需要在处理函数中调用 event.preventDefault(),建议使用 { passive: true } 选项。这可以告诉浏览器该监听器不会阻止默认滚动行为,从而提高滚动性能。
    • 在我们的下拉刷新示例中,touchmove 可能需要 preventDefault 来阻止页面在顶部被拉动时的默认行为(尤其是在某些移动端浏览器上),所以我们为 touchmove 设置了 { passive: false }。而 touchstart 通常可以设为 passive: true
  4. 滚动容器的选择:

    • 通常是 window 或一个具有固定高度且 overflow: autodiv 元素。示例中使用的是后者。如果监听 window 的滚动,则判断条件会略有不同(例如,使用 document.documentElement.scrollTopdocument.body.scrollTop)。
  5. 处理空列表和初始加载:

    • 确保在列表为空或数据不足一屏时,上拉加载的逻辑不会被错误触发。
    • 初始加载数据后,判断是否需要显示“没有更多数据”。
  6. 使用现成库:

    • 对于复杂的场景或追求极致体验,可以考虑使用成熟的库,如:

      • BetterScroll: 功能强大,专门处理移动端滚动场景,支持下拉刷新、上拉加载等。
      • iScroll: 另一个经典的选择,虽然更新可能不如 BetterScroll 频繁。
      • 许多 UI 框架 (如 Vant, Ant Design Mobile) 也内置了这些组件。
    • 这些库通常处理了更多的边界情况和浏览器兼容性问题,并提供了更平滑的动画效果。

  7. 可访问性 (Accessibility):

    • 确保加载指示器和状态变化对辅助技术(如屏幕阅读器)是可感知的。可以使用 ARIA 属性来增强。