移动端触摸交互优化的实战经验与关键技巧
又踩坑了,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 算差值并手动改 scrollTop,touchend 收尾。注意:必须在 touchstart 里就绑定 touchmove 和 touchend,且都带 { 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.js 和 interact.js,API 是好,但体积太大(hammer.min.js 60KB+),而且它们内部对 passive 的处理也不统一,有些版本要手动传 recognizers 配置,文档还不全。还有人推 CSS scroll-snap,但那玩意儿只适合分页式滚动,我们这是无限加载列表,snap 会打断懒加载节奏,果断放弃。
所以最后还是回归原生。不是因为多高大上,纯粹是因为:它最小、最可控、出问题我能一眼看懂哪行错了。有时候最土的方案,就是最靠谱的方案。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如用 PointerEvent 替代 TouchEvent 的实践,或者解决了 Android 惯性衰减问题,欢迎评论区交流。

暂无评论