PullToRefresh 下拉刷新组件的实现原理与实战优化技巧

玉杰的笔记 交互 阅读 2,321
赞 10 收藏
二维码
手机扫码查看
反馈

又踩坑了,下拉刷新把页面滚动搞崩了

昨天在给一个移动端列表页加下拉刷新功能,本来以为就是个简单活儿,结果折腾了大半天,差点把键盘砸了。问题出在:我加完下拉刷新后,页面的正常滚动突然失效了,手指一滑,页面卡住不动,或者干脆整个页面抖一下就回弹回去,根本没法往下看内容。这体验谁受得了?

PullToRefresh 下拉刷新组件的实现原理与实战优化技巧

一开始我以为是 CSS 冲突,比如 overflow: hidden 之类的,但查了一圈没发现。后来才意识到,问题出在事件监听上——我用了 touchmove 来监听下拉动作,但没处理好默认行为,导致浏览器以为我在“阻止滚动”,于是直接把整个页面的滚动给禁了。

三种方案对比,我选了最简单的

其实实现下拉刷新的思路挺多的,我试了三种:

  • 方案一:用原生 scroll 事件配合顶部负偏移(比如 transform: translateY(-50px)),但这个在 iOS 上特别卡,而且判断“是否在顶部”容易出错,尤其当页面有 sticky header 的时候。
  • 方案二:用第三方库,比如 pulltorefreshjs,但项目里已经有一堆依赖了,我不想再加一个只用一次的库,而且它对容器结构有要求,得改现有布局。
  • 方案三:自己写一套基于 touchstarttouchmovetouchend 的轻量逻辑,只监听顶部区域,不影响其他滚动。

最后我选了方案三,虽然要自己处理细节,但可控性高,而且代码量其实不大。关键是,我终于搞明白了为什么之前会把滚动搞崩——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:否则用户在页面中间随便一拉,也会触发下拉逻辑,而且会阻止滚动。
  • touchmovepassive 必须设为 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 问题?或者如何兼容桌面端的鼠标拖拽?这些我还没细究,但感觉值得挖一挖。

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

暂无评论