上拉加载、下拉刷新这两个交互,在移动端列表页里几乎成了标配。很多入门开发者以为这只是调两个现成组件、写两个回调函数的事,直到自己动手实现一次,才发现里面有手势识别、状态管理、边界条件、并发控制一堆坑,任何一个处理不好,页面就会表现为“卡一下”“重复请求”或者“松手没反应”。这篇文章我想把这套交互背后的原理拆开聊一聊,然后给出一份可以在原生Web环境直接跑起来的实现代码,再附上我在实际项目中踩过的几个典型问题。内容适合正在做H5列表页、小程序页面、跨端应用,并且不想只做“调包侠”的朋友。
1. 交互模型与整体设计思路
1.1 两个交互的触发边界
上拉加载和下拉刷新是两个方向相反但逻辑上对称的交互。先明确它们的触发边界,否则后面实现会乱套。
下拉刷新发生在列表处于顶部时。用户把手指从屏幕上方往下方拖动,列表顶部会跟着出现一个不断展开的指示区,当这个区域被拉长到一定阈值之后松手,就触发一次刷新请求。这里的关键是:它需要一个“完整的手势过程”,包括手指按下、移动、松开。光靠滚动事件是无法区分“用户在顶部继续往下拖”和“页面本来就滚到顶了”这两种情况的,因为滚动事件只在滚动停止或滚动过程中触发,而无法感知用户正在主动拖拽并产生了位移。
上拉加载则发生在列表滚动接近底部时。用户持续向上滑动,列表内容向上移动,当可视区域的底部快要接触到内容底部时,页面自动触发加载下一页数据。它不依赖某一个具体手势的完成,而是依赖滚动位置的连续变化。也就是说,即使手指没有明显的“拉”动作,只要滚动条触底了,也应该触发加载。
所以,一个靠“手势状态”驱动,一个靠“滚动位置”驱动。这两者的触发机制不同,不能简单用同一个监听函数写一套判断逻辑。最合理的方式是分开处理:touch 事件负责下拉手势,scroll 事件负责触底判断,中间再用一个状态机把两者协调起来。
1.2 状态机设计
列表页不能只有一个“正在加载”标志位。我见过很多初版实现,用一个布尔值isLoading控制所有请求,结果刷新和加载更多同时触发,列表数据被覆盖。正确的做法是维护一个明确的状态机,至少包含四种状态:
| 状态 | 含义 | 触发条件 |
|---|---|---|
normal | 空闲,列表可滚动,无请求 | 初始状态,或者所有请求结束后 |
pull | 下拉手势进行中,尚未达到刷新阈值 | 列表在顶部,手指下移,位移大于0 |
refreshing | 正在执行刷新请求 | 下拉位移超过阈值,松手后进入 |
loadingMore | 正在加载下一页 | 滚动位置达到触底条件时进入 |
有经验的同学可能还会加一个pullUp状态,表示手指正在上拉但还没到加载条件,用于显示“松手加载更多”的提示。这个状态不是必须的,但能提升交互细节。
状态机的作用是防止并发和逻辑混乱。比如refreshing状态下,用户再怎么上拉,也不能进入loadingMore;loadingMore状态下,用户下拉松手,也不能立即执行刷新,只能把手势记录下来,等当前请求结束后再补上。状态转换最好是单向合法流动,下面给出一个常用的转换关系:
normal -> pull:顶部下拉开始pull -> normal:位移归零,松手取消pull -> refreshing:达到阈值,松手确认刷新refreshing -> normal:刷新请求完成,复位normal -> loadingMore:触底且不在其他状态loadingMore -> normal:加载请求完成,复位refreshing -> loadingMore:禁止,需要排队loadingMore -> refreshing:禁止,需要排队
状态机的价值在于,当你排查问题时,只需要盯着当前状态,就能判断某个事件该不该响应。而不是靠一大堆if (isRefreshing && !isLoadingMore)的临时条件堆叠。
1.3 为什么不能简单用 scroll 事件判断触底
很多人第一次实现上拉加载时,会写出这样的代码:
window.addEventListener('scroll', () => { if (window.scrollY + window.innerHeight >= document.body.scrollHeight) { loadMore(); } });这在桌面端可能奏效,但在移动端会遇到三个问题:
第一,scroll事件触发频率非常高,在部分低端 Android 设备上每帧触发多次,直接在里面做 DOM 操作和网络请求会卡顿。现代浏览器虽然把scroll标记为 passive 以提升滚动性能,但这也意味着你无法在监听器里preventDefault去阻止一些默认行为。
第二,scrollY + innerHeight >= scrollHeight这个公式只适用于滚动容器是window的场景。如果你的列表在一个固定高度的div内滚动,那window的尺寸完全不对,必须换成容器的scrollTop、clientHeight、scrollHeight来计算。还要考虑容器底部的 padding、边框、以及元素是否为border-box,差几个像素就会导致触底永远不触发或者提前触发。
第三,当内容高度不足一屏时,scrollHeight等于clientHeight,scrollTop永远为 0,公式永远成立,结果一进入页面就疯狂加载下一页,直到把所有数据都加载完。这种场景必须额外判断:如果内容不足以撑出滚动条,应该要么显示“没有更多”,要么直接加载到填满一屏。后面我会给出具体处理方案。
2. 核心原理拆解:手势识别与滚动位置
2.1 下拉刷新手势识别
下拉刷新的底层原理并不神秘,它依赖移动端的触摸事件序列:touchstart、touchmove、touchend。整个过程可以拆成三步:
第一步,在touchstart时记录手指的初始纵坐标startY,同时记录当前是否位于列表顶部。这个“是否顶部”的判断很关键,因为只有顶部下拉才应该进入pull状态。判断方式很简单:容器的scrollTop === 0。但实际中有个坑,iOS 的橡皮筋效果会导致scrollTop出现负值,所以更严谨的判断应该是scrollTop <= 0。
第二步,在touchmove中计算手指当前的纵坐标与startY的差值deltaY = currentY - startY。当deltaY > 0表示手指向下移动,结合之前记录的“顶部”标志,就可以确定用户是在下拉。此时需要调用event.preventDefault()阻止页面继续跟着手指滚动,否则会出现列表和指示器一起滚动的串台现象。同时,不能直接把deltaY当作指示器的位移去更新 UI,否则手感会太“硬”。通常要对位移做阻尼处理,让页面移动距离小于手指移动距离。比较常见的系数是 0.4 到 0.6,具体取决于想要的效果。
第三步,在touchend时判断最终的位移是否超过了刷新阈值,比如threshold = 70px。如果超过,就把状态从pull切换到refreshing,并调用刷新回调;如果没有超过,就把指示器动画回原位,状态回到normal。
这里特别要说明一下为什么不用scroll事件实现下拉刷新。因为在移动端列表顶部继续下拉会触发浏览器原生行为,比如 Android 的页面刷新或 iOS 的橡皮筋回弹。原生行为是操作系统层面的,scroll事件在位移发生时已经太晚了。只有touch事件发生在默认滚动之前,才有可能通过preventDefault拦截。
2.2 上拉加载的触发条件
上拉加载的核心就是计算滚动位置。我们先明确三个高度概念:
scrollTop:滚动容器中,内容顶部被滚出视口的高度。clientHeight:容器可视区域的高度。scrollHeight:容器内容的总高度。
当scrollTop + clientHeight等于scrollHeight时,说明滚动到了最底部。但实际体验中,如果等到完全触底才请求加载,用户会看到底部空白一闪而过,等待感很强。所以通常设置一个提前量threshold,比如 100 到 200px,让请求在距离底部还有一段距离时就发出:
if (scrollTop + clientHeight >= scrollHeight - threshold) { // 触发加载更多 }这个threshold怎么取值?我一般习惯用 150px。设置太大会导致用户还没看到底部内容就开始加载,设置太小则会有明显的停顿感。具体可以根据列表项高度估算:如果列表项平均 100px,提前 1.5 屏是比较舒适的。另外还要考虑设备性能,网络请求发起后需要时间返回,所以稍微提前反而能制造“无缝加载”的错觉。
还有一个容易被忽略的点:触底判断要在滚动停止后才检测,还是滚动过程中实时检测?我推荐实时检测,但是要通过状态锁保证同一时间只触发一次请求。滚动过程中scroll事件多次触发,只要都命中条件,就判断当前状态,如果不是loadingMore且列表还有下一页,就进入加载态。如果已经处于加载态,后面触发的scroll事件直接忽略,这就是状态锁的基本用法。
2.3 动画与反馈
好的下拉刷新不是让指示器直接瞬移,而是要有“拉扯感”和“回弹感”。这里面有两个核心技术点:阻尼运动和 CSS transform。
阻尼运动在我前面已经提过,核心是让元素位移不等于手指位移。除了线性系数,还可以用更平滑的曲线,比如当位移小于某个值时,阻力小一点,超过之后阻力变大,模拟真实弹簧的感觉。一个常见的分段阻尼逻辑是:
function applyDamping(distance, maxDistance = 100) { return maxDistance * Math.tanh(distance / maxDistance); }tanh函数会让位移的增长逐渐趋于平缓,手指拉得再快,指示器也不会冲出屏幕。
CSS transform 则负责把位移应用到指示器上。为什么用transform: translateY()而不是修改top或margin-top?因为transform不会触发布局和重排,浏览器可以把它交给 GPU 合成,性能远高于布局属性。在移动端,帧率能稳定在 60fps,就是靠这条底线护身。
指示器的反馈内容通常包括三块:位移未到阈值时显示“下拉刷新”,到了阈值显示“松手刷新”,刷新中显示旋转的 loading 动画。这些文案和动画的切换都是由状态机驱动的,也就是说,不应该在 UI 事件里直接改文字,而应该在状态切换时统一更新 UI。这样可以避免多个事件源竞争修改同一个 DOM。动画循环也建议使用requestAnimationFrame,而不是在touchmove里高频写值。
3. 完整实操示例:一个原生 Web 端可复用的实现
3.1 基础 HTML 结构与 CSS 样式
直接讲理论比较抽象,我们用一个具体的例子说明。假设页面是一个简单的消息列表,外层有一个固定高度的滚动容器,内部包含三个部分:下拉指示器、列表内容、上拉加载 footer。
先看 HTML:
<div id="listContainer" class="container"> <div class="refreshIndicator" id="refreshIndicator"> <span id="refreshText">下拉刷新</span> </div> <ul class="list" id="list"></ul> <div class="loadMore" id="loadMore"> <span>上拉加载更多</span> </div> </div>这里把滚动容器设计成#listContainer,而不是让整个 body 滚动。这样更容易控制,也方便在需要使用position: fixed锁定时保持页面稳定。刷新指示器放在列表上方,默认用transform: translateY(-100%)藏起来,只有下拉时才会出现。
CSS 关键部分:
.container { overflow-y: auto; -webkit-overflow-scrolling: touch; height: 100vh; position: relative; background: #f5f5f5; } .refreshIndicator { height: 60px; line-height: 60px; text-align: center; color: #999; position: relative; z-index: 10; transform: translateY(-100%); transition: transform 0.2s ease-out; } .list { margin: 0; padding: 0; list-style: none; } .loadMore { height: 50px; line-height: 50px; text-align: center; color: #999; display: none; }注意container使用-webkit-overflow-scrolling: touch让 iOS 的滚动更接近原生。不过在较新版本 iOS 上这个属性已经没有太大作用,保留也没有坏处。
3.2 JavaScript 核心逻辑实现
下面用原生 JavaScript 写一个可运行的类。它负责监听touchstart、touchmove、touchend和scroll,并维护状态机。为了便于理解,我模拟了一个分页请求函数,实际项目中替换成真实的接口调用即可。
class PullAndLoad { constructor(container, list, options = {}) { this.container = container; this.list = list; this.refreshIndicator = document.getElementById('refreshIndicator'); this.refreshText = document.getElementById('refreshText'); this.loadMoreEl = document.getElementById('loadMore'); this.threshold = options.threshold || 70; // 下拉刷新阈值 this.damping = options.damping || 0.4; // 下拉阻尼系数 this.loadOffset = options.loadOffset || 150; // 上拉加载提前量 this.state = 'normal'; // normal | pull | refreshing | loadingMore this.startY = 0; this.pullDistance = 0; this.pageIndex = 1; this.pageSize = 10; this.totalItems = 0; this.lock = false; this.bindEvents(); } bindEvents() { let touchStartY = 0; let isAtTop = false; this.container.addEventListener('touchstart', (e) => { if (this.state === 'refreshing' || this.state === 'loadingMore') return; touchStartY = e.touches[0].clientY; isAtTop = this.container.scrollTop <= 0; this.pullDistance = 0; }, { passive: true }); this.container.addEventListener('touchmove', (e) => { if (this.state === 'refreshing' || this.state === 'loadingMore') return; if (!isAtTop) return; const deltaY = e.touches[0].clientY - touchStartY; if (deltaY <= 0) return; e.preventDefault(); this.pullDistance = deltaY * this.damping; this.state = 'pull'; this.renderPullState(this.pullDistance); }, { passive: false }); this.container.addEventListener('touchend', (e) => { if (this.state !== 'pull') return; if (this.pullDistance >= this.threshold) { this.startRefresh(); } else { this.resetPullState(); } }); this.container.addEventListener('scroll', () => { this.checkLoadMore(); }); } renderPullState(distance) { this.refreshIndicator.style.transform = `translateY(${distance - 60}px)`; if (distance >= this.threshold) { this.refreshText.textContent = '松手刷新'; } else { this.refreshText.textContent = '下拉刷新'; } } resetPullState() { this.state = 'normal'; this.refreshIndicator.style.transform = 'translateY(-100%)'; this.refreshText.textContent = '下拉刷新'; } startRefresh() { this.state = 'refreshing'; this.refreshText.textContent = '刷新中...'; this.refreshIndicator.style.transform = 'translateY(0px)'; this.refreshData().then(() => { this.pageIndex = 1; this.totalItems = 0; // 模拟请求后重新渲染列表 this.list.innerHTML = ''; return this.fetchPage(this.pageIndex); }).then((data) => { this.renderList(data); this.totalItems = data.length; this.state = 'normal'; this.resetPullState(); this.updateLoadMore(); }).catch(() => { this.state = 'normal'; this.resetPullState(); }); } checkLoadMore() { if (this.state !== 'normal') return; if (this.hasMore() === false) return; const { scrollTop, clientHeight, scrollHeight } = this.container; if (scrollTop + clientHeight >= scrollHeight - this.loadOffset) { this.loadNextPage(); } } async loadNextPage() { this.state = 'loadingMore'; this.loadMoreEl.style.display = 'block'; const nextPage = this.pageIndex + 1; try { const data = await this.fetchPage(nextPage); if (data.length > 0) { this.pageIndex = nextPage; this.renderList(data, true); this.totalItems += data.length; } this.state = 'normal'; } catch (e) { this.state = 'normal'; } this.updateLoadMore(); } hasMore() { if (this.pageIndex >= 3) return false; // 模拟只有 3 页数据 return this.totalItems >= this.pageSize; } updateLoadMore() { if (this.hasMore()) { this.loadMoreEl.style.display = 'block'; } else { this.loadMoreEl.style.display = 'none'; } } fetchPage(page) { return new Promise((resolve) => { setTimeout(() => { const start = (page - 1) * this.pageSize; const data = Array.from({ length: this.pageSize }, (_, i) => `第 ${start + i + 1} 条消息`); resolve(data); }, 300); }); } renderList(data, append = false) { const html = data.map((text) => `<li>${text}</li>`).join(''); if (append) { this.list.insertAdjacentHTML('beforeend', html); } else { this.list.innerHTML = html; } } } const container = document.getElementById('listContainer'); const list = document.getElementById('list'); new PullAndLoad(container, list);这段代码里我故意省略了一些边界处理,因为接下来我会在常见问题部分逐个讲清楚。先看几个关键设计:
touchmove监听的passive: false是必须的,否则preventDefault()无效。- 状态为
refreshing或loadingMore时直接忽略所有手势,从源头防止并发。 refreshData()方法和fetchPage()是分离的,实际业务里刷新通常要拉最新第一页,加载更多拉下一页。- 模拟数据最多 3 页,
hasMore()根据页码和已有数量判断是否还有下一页。
3.3 加载更多与刷新完成后的状态复位
很多人在请求完成后忘记恢复状态,导致第二次点击没有反应。正确的复位流程分三步:
第一步,请求回来之后,先把网络状态从loadingMore或refreshing改回normal。改完状态再更新 UI,否则 UI 更新过程中用户可能又触发了新的手势,产生竞态。
第二步,如果是刷新,要重置分页参数。页码回到第一页,列表清空,再填充新数据。否则刷新后列表会继续从第三页开始加载,出现数据错乱。
第三步,复位滚动位置。刷新完成后,通常要把滚动容器回滚到顶部,让用户看到新数据的第一条。上拉加载完成后则不需要修改滚动位置,因为加载的数据在底部,用户本来就滚动在底部附近。
另外,请求失败时的复位也必须考虑。我习惯在 catch 里把状态复位,并提示用户失败。如果复位太晚,用户会一直看到 loading 动画却没有任何反馈。
3.4 如何封装为可在项目里复用的组件
上面的类已经是一个雏形,但真实项目里还要做更多封装。我总结几个实用的方向:
第一,把状态机和请求逻辑分离。比如这个PullAndLoad类只负责交互和滚动,不管数据请求。HTTP 请求通过外部传入的回调注入,这样就能在 Vue、React、小程序中通过不同方式复用了。
第二,对外暴露最小化 API。只需要refresh()、loadMore()、updateState()三个方法。框架层调外部回调,交互层调内部方法,两边不直接耦合。
第三,在 Vue 里可以封装成自定义指令v-pull-load,指令绑定回调,组件销毁时自动解绑事件。在 React 里可以封装成usePullLoadHook,返回容器 ref 和回调函数。核心逻辑还是一套,只是外壳不同。
封装时要注意事件监听的销毁。原生类在单测或页面跳转时,如果不移除监听,会造成内存泄漏。构造函数里bindEvents,最好配套一个destroy()方法,负责移除所有监听器。这个细节虽然简单,但很多同学会忽略,结果页面切换几次后触底回调被调了好几次。
4. 常见问题与排查技巧实录
4.1 下拉刷新时页面整体被拖动
最常见的现象是:用户下拉刷新时,指示器确实出现了,但整个页面也跟着往下滚,松手后页面停在一个诡异的位置。原因是没有阻止浏览器的默认滚动行为。移动端浏览器在touchmove事件默认允许滚动,即使你的列表在顶部,浏览器也会把手指的拖动解释为页面滚动。
解决办法有两个层面。第一,必须在touchmove监听器中使用event.preventDefault(),并且确保添加监听时passive: false。有些同学写了 preventDefault 但没加 passive 选项,浏览器为了性能会忽略 preventDefault,照样滚动。
第二,如果容器不是视口的一部分,比如页面本身可以滚动,列表容器只是页面中间一块区域,那么下拉时还需要锁住 body 的滚动。常规做法是在进入pull状态时,给 body 加一个overflow: hidden和position: fixed,但这样会导致页面跳动。更好的办法是在touchmove中判断条件后只阻止默认行为,因为手指是从列表容器上触摸的,阻止了默认滚动,页面其他区域自然也不会被拖动。
我实际排查过一个案例,preventDefault 写了,但按下时容器不可滚动,手指最终被浏览器判定为“横滑”,下拉距离被吞掉了。最后发现是touchstart上没有判断是否只按在一个位置,有多个 touch 点时事件被浏览器当作手势缩放处理了。所以对于这种场景,判断e.touches.length === 1是必要的。
4.2 滚动事件频繁触发导致重复请求
上拉加载的重复请求简直是重灾区。我见过最夸张的是,一个滚动事件在 Android 上连续触发了将近二十次,每次触底条件都成立,结果同时发出了二十个请求。解决重复请求最有效的不是防抖,而是状态锁。
状态锁的思路是:只有当状态是normal时才允许进入loadingMore,进入后所有后续scroll事件都直接 return。我给出的示例代码里已经体现了这一点。但这里还有一个细节:状态复位后,如果滚动仍然停留在触底位置,scroll事件不会自动再次触发,所以不会继续请求。然而如果用户在加载完成瞬间又滚动了几个像素,触发了新的scroll事件,条件又满足,会立刻加载下一页。这在逻辑上没错,但用户体验上可能造成“连刷”现象。如果产品希望每次上拉加载之间有间隔,可以在loadNextPage里加一个最小间隔判断,比如距离上一次请求结束至少 300ms 才能再次触发。
另外,如果触底判断里直接调用了异步请求,而没有先改变状态,那么在await等待期间,状态依然是normal,后续滚动会再次进入loadNextPage。所以记住:进入函数体后,立即设置状态,不要等请求发出去才设置。
4.3 数据量小不足以滚动时无法触底加载
当列表数据只有一两条时,内容高度小于容器可视高度,scrollTop恒为 0,触底条件永远成立。如果直接按照“触底就加载”,页面会不停请求,直到加载到足够多的数据填满视口。如果按照“不加载下一页”,用户就看不到任何加载发生。
我的处理习惯是:在首次渲染完成后,检查内容是否不足以撑起滚动。如果是,且接口还有下一页,就自动调用一次loadNextPage。为了不形成无限循环,每次追加数据后都重新检查,直到内容高度超过容器高度,或者没有更多数据为止。代码很简单:
ensureFillScreen() { if (this.state !== 'normal') return; if (!this.hasMore()) return; const { scrollHeight, clientHeight } = this.container; if (scrollHeight < clientHeight) { this.loadNextPage(); } }这个方法不依赖任何事件,在首次渲染后调用一次即可。但是要注意,如果每次加载回来的数据量也很少,比如一页只有两条,那可能会连续触发很多次加载。这时候要设置一个最大加载次数或手动兜底逻辑,避免数据还没加载完,用户先看到十几条 loading 动画。
4.4 iOS 橡皮筋效果与位置计算偏差
iOS 上的滚动容器默认支持橡皮筋效果,也就是内容滚动到顶或到底后继续拖拽,会看到一个空白区域回弹。这个效果会导致两个问题:
第一,在顶部判断scrollTop === 0会失效。因为橡皮筋状态下scrollTop可能是负值,比如 -20。虽然回弹很快,但scroll事件会在此期间触发。处理方法是把判断条件改成scrollTop <= 0,并且在发现负值时,将指示器的位移理论上应该加上这个负值,否则下拉位移会被“吞掉”一部分。不过现代浏览器在 overscroll-behavior 属性支持后,可以在 CSS 中设置overscroll-behavior: none禁用橡皮筋。如果不想禁用,也可以通过touchmove阻止默认滚动来绕开,因为一旦preventDefault生效,iOS 的橡皮筋就不会触发。
第二,-webkit-overflow-scrolling: touch会创建独立的滚动层,在极端情况下,滚动层内的scrollTop读取会有延迟,导致触底判断不准确。遇到这种情况,试着监听touchend后延迟 50 到 100 毫秒再读取位置,或者改用 IntersectionObserver 监听底部哨兵元素。IntersectionObserver 是另一个思路:在列表底部放置一个不可见的哨兵 div,观察它是否进入容器可视区域。这个方案避免了 scroll 事件的节流和滚动位置计算,但兼容性不如直接计算,而且在复杂布局中需要小心嵌套容器。
4.5 组合场景:状态互斥
在真实项目中,下拉刷新和上拉加载通常同时存在。除了我前面讲述的状态机互斥外,还有一些组合场景容易踩坑。
场景一:刷新过程中,用户快速上拉。状态机阻挡了loadingMore,但用户已经上拉了很多。刷新完成后,容器还停留在底部附近,这时如果不处理,用户会看到一个静止的列表,直到再次滚动才触发加载。我的建议是刷新完成后,手动检查一次触底条件,如果满足就继续加载。示例代码里updateLoadMore之后可以加上this.checkLoadMore(),不过要注意必须在状态复位后再调用。
场景二:加载更多过程中,用户下拉。同样被状态机阻挡,但用户下拉的手势可能被浏览器当成普通滚动处理,导致列表位置意外上移。这里可以在touchstart时判断状态,如果处于loadingMore,可以阻止下拉,或者干脆忽略触摸。但更好的体验是允许用户继续滚动列表,只是让刷新指示器不出现。这个细节取决于产品设计。
场景三:网络请求乱序返回。假设用户先触发了刷新,又在很短时间内触发了加载更多(虽然状态机理论上能拦截,但总有些边界情况)。两个请求同时发出,刷新请求返回慢,加载请求返回快,列表先被追加了新的一页,然后刷新返回清空了列表,最终数据只剩下第一页。这种问题需要在请求层规避,比如记录请求序号,每次刷新使之前的请求序号失效,只接受最新请求。或者使用 AbortController 取消过期请求。类里加一个requestSeq自增变量即可:
fetchData(isRefresh) { const seq = ++this.requestSeq; fetch(...).then(res => { if (seq !== this.requestSeq) return; // 更新数据 }); }这个小技巧简单有效,建议每个列表页都加上。
我个人在实际操作中的体会是:上拉加载和下拉刷新技术难度不在于事件监听,而在于如何把状态流转想清楚。只要你把状态机画出来,所有边界问题都变成了“当前状态能不能进入下一个状态”的判断。先动手写一版最简单的实现,再慢慢打磨手感和并发控制,比一上来就接第三方框架学得更深。这个功能看起来不起眼,但它涉及触摸事件、滚动性能、异步状态管理、视觉反馈,几乎覆盖了移动端交互开发的全部核心要点。把这一套吃透了,再去看各种现成组件的源码,会发现不过就是同一套思路在不同壳子里的变体罢了。