PullToRefresh 下拉刷新组件的实现原理与实战优化技巧
又踩坑了,下拉刷新把页面滚动搞崩了
昨天在给一个移动端列表页加下拉刷新功能,本来以为就是个简单活儿,结果折腾了大半天,差点把键盘砸了。问题出在:我加完下拉刷新后,页面的正常滚动突然失效了,手指一滑,页面卡住不动,或者干脆整个页面抖一下就回弹回去,根本没法往下看内容。这体验谁受得了?
一开始我以为是 CSS 冲突,比如 overflow: hidden 之类的,但查了一圈没发现。后来才意识到,问题出在事件监听上——我用了 touchmove 来监听下拉动作,但没处理好默认行为,导致浏览器以为我在“阻止滚动”,于是直接把整个页面的滚动给禁了。
三种方案对比,我选了最简单的
其实实现下拉刷新的思路挺多的,我试了三种:
- 方案一:用原生
scroll事件配合顶部负偏移(比如transform: translateY(-50px)),但这个在 iOS 上特别卡,而且判断“是否在顶部”容易出错,尤其当页面有 sticky header 的时候。 - 方案二:用第三方库,比如
pulltorefreshjs,但项目里已经有一堆依赖了,我不想再加一个只用一次的库,而且它对容器结构有要求,得改现有布局。 - 方案三:自己写一套基于
touchstart、touchmove、touchend的轻量逻辑,只监听顶部区域,不影响其他滚动。
最后我选了方案三,虽然要自己处理细节,但可控性高,而且代码量其实不大。关键是,我终于搞明白了为什么之前会把滚动搞崩——在 touchmove 里无脑调了 preventDefault(),而没加条件判断。
核心代码就这几行
关键在于:只有在用户从页面顶部开始下拉、且方向是向下的时候,才阻止默认滚动;其他情况一律放行。否则,一旦你阻止了 touchmove,浏览器就会认为“这个手势被消费了”,就不会触发原生滚动。
下面是我最终的实现,亲测有效(iOS Safari 和 Android Chrome 都跑通了):
let startY = 0;
let currentY = 0;
let isPulling = false;
const refreshThreshold = 60; // 下拉超过60px触发刷新
const container = document.querySelector('.list-container'); // 你的滚动容器
// 注意:这里监听的是 document,不是容器本身,因为 touchstart 可能发生在任何地方
document.addEventListener('touchstart', (e) => {
// 只有在滚动容器处于顶部时才允许下拉
if (container.scrollTop === 0) {
startY = e.touches[0].clientY;
isPulling = true;
} else {
isPulling = false;
}
}, { passive: true });
document.addEventListener('touchmove', (e) => {
if (!isPulling) return;
currentY = e.touches[0].clientY;
const diff = currentY - startY;
// 只有向下拉(diff > 0)才处理
if (diff > 0) {
// 阻止默认滚动,但仅限于下拉动作
e.preventDefault();
// 这里可以更新 loading 提示的位置或透明度
const pullDistance = Math.min(diff, refreshThreshold * 1.5); // 限制最大拉伸距离
// 比如:refreshIndicator.style.transform = translateY(${pullDistance}px);
} else {
// 向上滑,说明用户想正常滚动,放行
isPulling = false;
}
}, { passive: false }); // 注意:这里必须设 passive: false,否则 preventDefault 无效
document.addEventListener('touchend', () => {
if (!isPulling) return;
const diff = currentY - startY;
if (diff >= refreshThreshold) {
// 触发刷新
triggerRefresh();
}
// 重置状态
isPulling = false;
// 动画回弹
// refreshIndicator.style.transform = 'translateY(0)';
});
function triggerRefresh() {
console.log('开始刷新...');
// 比如:fetch('https://jztheme.com/api/data')
// .then(res => res.json())
// .then(data => updateList(data));
}
这里有几个关键点我踩过坑,必须强调:
- 必须检查
container.scrollTop === 0:否则用户在页面中间随便一拉,也会触发下拉逻辑,而且会阻止滚动。 touchmove的passive必须设为false:现代浏览器默认passive: true以提升滚动性能,但这也意味着你调preventDefault()会报错或无效。所以这里必须显式关掉。- 只在
diff > 0时阻止默认行为:如果用户手指向上滑(想正常滚动),就别拦着,赶紧return并重置状态。
踩坑提醒:这三点一定注意
第一,别在 touchmove 里无条件 preventDefault()。我一开始就是这么干的,结果页面完全滚不动,还以为是 CSS 的问题,白白浪费半小时。
第二,scrollTop === 0 在某些情况下可能不准确。比如容器有 padding-top,或者用了 scrollIntoView 之后,实际滚动位置可能不是 0。这时候你可以加个容差值,比如 scrollTop < 5,避免因为浮点误差判断失败。
第三,iOS Safari 对 touchmove 的处理特别敏感。如果你在 touchmove 里做了太多计算(比如频繁读取 DOM 尺寸),可能会卡顿甚至崩溃。所以尽量把计算移到 requestAnimationFrame 里,或者缓存变量。
另外,我这个方案目前还有一个小问题:如果用户快速下拉然后松手,有时会触发两次刷新(因为 touchend 判断和动画回弹有竞态)。不过概率很低,而且加个防抖就能解决,暂时没动,毕竟“能跑就行”(狗头)。
其实原理没那么玄乎
说白了,下拉刷新就是监听“从顶部开始的向下拖拽手势”,然后在达到阈值后触发回调。难点不在逻辑,而在**如何不干扰原生滚动**。浏览器的滚动机制很复杂,尤其是移动端,各种手势(缩放、滚动、回弹)交织在一起,你一不小心就会打断它的节奏。
所以核心原则是:只在必要时拦截事件,其他时候乖乖放行。这也是为什么很多第三方库要你指定一个“可下拉的区域”——它们通过限制监听范围来避免冲突。
如果你用的是框架(比如 React、Vue),记得在组件卸载时移除事件监听,不然内存泄漏等着你。我这次是纯 JS 实现,所以手动管理了监听器。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有更优雅的方式处理 passive 问题?或者如何兼容桌面端的鼠标拖拽?这些我还没细究,但感觉值得挖一挖。

暂无评论