移动端触摸交互优化的实战经验与关键技巧

Zz万华 移动 阅读 1,274
赞 12 收藏
二维码
手机扫码查看
反馈

又踩坑了,touchmove滚动失效

今天上线前测手指滑动,发现列表在 iOS 上死活不滚动——touchstart 触发了,touchmove 也进了,但 scrollTop 死活不更新,手一松直接回弹。安卓倒是正常。我盯着 DevTools 里那个卡住的 scrollTop 值看了三分钟,心想:这破事怎么又来了。

移动端触摸交互优化的实战经验与关键技巧

折腾了半天发现,是 passive 默认值搞的鬼

去年就遇到过一次,当时以为记牢了,结果换了个项目又忘了加 { passive: false }。这次更绝,我甚至在 addEventListener 里写了,但写在了错的地方——给父容器绑了,却没给真正需要拦截 touchmove 的那个滚动区域绑。还顺手加了 preventDefault() 在 touchmove 里,结果 Chrome 控制台直接报黄字警告:Unable to preventDefault inside passive event listener。我第一反应是“靠,又来?”然后翻 MDN 确认:对,iOS Safari 和新版 Chrome 都把 touchmove 默认设为 passive: true,你再调 preventDefault() 就直接被 ignore,连 warning 都是事后才吐的。

我还试过用 CSS 强制开启原生滚动:overflow-y: scroll; -webkit-overflow-scrolling: touch;。没用。iOS 15+ 已经基本无视 -webkit-overflow-scrolling 了,加上去反而让某些机型更卡。后来查 CanIUse,确认这个属性早就标成 “Deprecated” 了,但我项目里还留着,删掉后居然稍微快了一丢丢(可能心理作用)。

接着试了 touch-action: none,本意是禁掉浏览器默认手势,自己全权接管。结果发现:禁是禁掉了,但手指一划,整个页面都跟着晃(body 滚动了),而且 touchend 后还残留个微小位移,像是系统偷偷补了一帧。这里我踩了个坑:只在滚动容器上加 touch-action: none 是不够的,还得确保它的所有父级(包括 body)没有意外触发 pan-y 或 pinch-zoom。最后我在根容器上加了 touch-action: pan-y;,子容器加 touch-action: none;,才算稳住。

核心代码就这几行

最终方案很朴素:不用任何第三方库,纯原生,就靠三个事件 + 一个标志位 + 被动监听开关。关键点不是多炫酷,而是顺序和时机——touchstart 记位置,touchmove 算差值并手动改 scrollToptouchend 收尾。注意:必须在 touchstart 里就绑定 touchmovetouchend,且都带 { passive: false },否则 move 里 preventDefault() 还是无效。

下面是完整可运行的片段(已上线跑了一周,iOS 14–17 全覆盖):

const scrollContainer = document.querySelector('.js-scroll-container');
let startY = 0;
let startScrollTop = 0;
let isDragging = false;

function handleTouchStart(e) {
  const touch = e.touches[0];
  startY = touch.pageY;
  startScrollTop = scrollContainer.scrollTop;
  isDragging = true;

  // ⚠️ 关键:这里必须立刻绑定,且 passive: false
  scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
  scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: false });
}

function handleTouchMove(e) {
  if (!isDragging) return;
  
  const touch = e.touches[0];
  const deltaY = touch.pageY - startY;
  const newScrollTop = startScrollTop - deltaY;

  // 手动设置 scrollTop(兼容 iOS)
  scrollContainer.scrollTop = newScrollTop;

  // 阻止默认行为,避免页面整体滚动或缩放干扰
  e.preventDefault();
}

function handleTouchEnd() {
  isDragging = false;
  
  // 解绑,防内存泄漏
  scrollContainer.removeEventListener('touchmove', handleTouchMove, { passive: false });
  scrollContainer.removeEventListener('touchend', handleTouchEnd, { passive: false });
}

// 绑定入口
scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true });

HTML 结构就普通得很:

<div class="js-scroll-container" style="height: 400px; overflow-y: auto;">
  <div>item 1</div>
  <div>item 2</div>
  <!-- ... -->
</div>

CSS 就一行关键:

.js-scroll-container {
  -webkit-overflow-scrolling: touch; /* 保留兼容老 iOS,虽 deprecated 但无害 */
}

踩坑提醒:这三点一定注意

  • 不要在 touchstart 外部提前绑定 touchmove——我一开始把三个事件全写在初始化里,结果 touchmove 总是晚一拍,导致第一次滑动卡顿;必须在 touchstart 里动态绑定,保证上下文精准。
  • passive: false 必须写在 addEventListener 第三个参数,不能只写在 touchstart 里——我曾只给 touchstart 加了 { passive: true },以为够了,结果 move 里 preventDefault 还是被静音。
  • scrollTop 赋值后别依赖 scroll 事件去监听变化——iOS 下手动设 scrollTop,scroll 事件不一定触发,尤其快速滑动时。如果你有吸顶、联动状态等逻辑,得在 handleTouchMove 里直接算、直接更新,别等事件。

顺带提一句:改完之后,Android 上偶尔还有轻微“粘滞感”,比如快速甩动后惯性偏弱。查了下是 Android WebView 对 scrollTop 设置的渲染延迟,加了个 requestAnimationFrame 包一层能缓解,但收益不大,我就没上——毕竟主力用户是 iOS,先保主流程稳定。

谁更灵活?谁更省事?

其实我也试过 hammer.jsinteract.js,API 是好,但体积太大(hammer.min.js 60KB+),而且它们内部对 passive 的处理也不统一,有些版本要手动传 recognizers 配置,文档还不全。还有人推 CSS scroll-snap,但那玩意儿只适合分页式滚动,我们这是无限加载列表,snap 会打断懒加载节奏,果断放弃。

所以最后还是回归原生。不是因为多高大上,纯粹是因为:它最小、最可控、出问题我能一眼看懂哪行错了。有时候最土的方案,就是最靠谱的方案。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如用 PointerEvent 替代 TouchEvent 的实践,或者解决了 Android 惯性衰减问题,欢迎评论区交流。

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

暂无评论