Passive监听事件提升滚动性能的实战经验与避坑指南
又踩坑了,touchmove滚动失效
今天上线前测一个横向拖拽组件,结果在 iOS Safari 上死活不响应 touchmove —— 不是卡住,是压根没触发。我第一反应是:JS 报错了?赶紧打开 Safari 远程调试,console 一片干净。再加 console.log,发现 touchstart 能打出来,touchmove 就像被系统吞了一样,连 event.preventDefault() 都没机会执行。
折腾了半天发现,这根本不是业务逻辑问题,是浏览器偷偷把我的 touchmove 监听器给“静音”了。
Passive 监听器:浏览器的强制优化,也是我的定时炸弹
其实我早就知道 passive 这玩意儿,也记得 Chrome 56 就开始推了,但一直没当回事——毕竟平时写 scroll、resize 这类监听,加个 { passive: true } 也就是顺手的事,不加也跑得动。直到这次写自定义拖拽,才真真切切被它背刺了一次。
简单说:当你给 touchstart/touchmove/mousewheel 绑监听器时,如果没显式声明 passive: false,现代浏览器(尤其 iOS Safari 和新版 Chrome)会默认把它当成 passive: true。而 passive 为 true 的监听器里,event.preventDefault() 是直接被禁止调用的 —— 浏览器一看“你都声明 passive 了,那我默认你不会阻止默认行为”,于是提前把滚动/缩放/拖拽这些原生行为的控制权收走了。
所以我的代码长这样:
element.addEventListener('touchmove', handleTouchMove);
// 等价于:
element.addEventListener('touchmove', handleTouchMove, { passive: true });
然后在 handleTouchMove 里我写了 event.preventDefault(),结果 iOS Safari 直接报错:
Unable to preventDefault inside passive event listener due to target being treated as passive.
但奇怪的是,这个错误在 console 里并不总显示(尤其在某些 iOS 版本上),它就默默失效,让你以为是 JS 没跑、事件没绑定、甚至怀疑是不是真机调试失灵…… 我花了快一小时反复确认元素是否可点击、touch-action 是否被覆盖、有没有父级 stopPropagation,最后才想起来翻 MDN 查 touchmove 的 passive 行为。
三种写法我都试了,最终选了最稳的
方案一:全局加 { passive: false } —— 最直觉,也最容易漏
element.addEventListener('touchstart', handleTouchStart);
element.addEventListener('touchmove', handleTouchMove, { passive: false });
element.addEventListener('touchend', handleTouchEnd);
✅ 表面看没问题,但这里有个大坑:如果你的页面里还有其他 touchmove 监听器(比如某个第三方轮播图、或者全局的防抖滚动监听),它们没加 passive: false,就会被浏览器默认设成 true,而一旦有任意一个 passive: true 的监听器存在,整个 touch 事件流的“可取消性”就可能被影响(尤其 iOS 上对 touch-action 和 passive 的协同判断特别敏感)。我试过,哪怕只漏一个,横向拖拽在某些机型上还是抽风。
方案二:用 CSS 的 touch-action: none 配合 JS
这个我一开始很心动,因为看起来“更声明式”。在拖拽容器上加:
.drag-container {
touch-action: none;
}
理论上,这告诉浏览器“这个区域别管默认手势,全交给我 JS 处理”。但实测下来:它确实能让 touchmove 触发,但同时也干掉了所有原生交互 —— 比如双指缩放、三指切换 App(iOS)、甚至有时候连长按复制都失效。而且,一旦你在拖拽过程中临时想恢复 touch-action(比如拖到边界要松手),CSS 切换会有延迟或不生效,非常难控制。
方案三:用 touch-action: pan-x + passive: false 组合 —— 我最终落地的解法
核心思路是:**不一刀切禁用所有手势,只接管我需要的,其余交给浏览器**。比如横向拖拽,我就明确告诉浏览器:“你只负责处理 x 方向的滚动,y 方向别碰,我来管”。这样既保留了用户上下滑动页面的能力(避免体验断层),又能确保 touchmove 正常触发并允许 preventDefault。
代码就这么几行:
// 初始化时设置
element.style.touchAction = 'pan-x';
// 绑定事件(必须显式声明 passive: false)
element.addEventListener('touchstart', handleTouchStart, { passive: true }); // start 不需要 prevent,可以 passive
element.addEventListener('touchmove', handleTouchMove, { passive: false }); // move 必须 false
element.addEventListener('touchend', handleTouchEnd, { passive: true });
function handleTouchMove(e) {
// 这里可以安全调用 preventDefault,阻止默认横向滚动
e.preventDefault();
const touch = e.touches[0];
const deltaX = touch.clientX - startX;
// 更新 translateX...
}
⚠️ 注意:touch-action: pan-x 必须在事件绑定前就设置好,否则 iOS Safari 可能已经按默认策略初始化了事件流。我之前就是先绑事件再设 style,结果照样失效。
另外提一嘴:Chrome 开发者工具里有个隐藏彩蛋,在 Application → Rendering 面板勾上 “Scrolling performance issues”,它会在控制台标出所有 passive 冲突的监听器 —— 这个功能救了我两次,强烈建议开启。
踩坑提醒:这三点一定注意
- 不是所有 touchmove 都要
passive: false—— 只有你明确要调用preventDefault()的时候才需要;纯读取坐标、不干预原生行为的场景,passive: true反而是性能更好的选择。 - iOS Safari 对
touch-action的解析比文档写的更严格:比如写成pan-x pan-y,它可能直接回退到默认行为;写成none又太暴力。实测pan-x/pan-y单独用最稳。 - 别信“兼容性检测脚本”。网上有些判断是否支持 passive 的 try-catch 检测,在 iOS 上经常误判 —— 它可能返回支持,但实际执行时仍报错。最靠谱的方式:始终显式声明,该 false 就 false,别省那几个字。
改完后还有一两个小问题,但无大碍
现在横向拖拽在 iOS 15+、Android Chrome 90+ 都跑通了,但 iOS 14.8 上偶尔第一次 touch 会延迟一帧(大概 30ms),估计是 touch-action 渲染管线初始化的问题,不影响主流程。另外,如果用户快速连续触发多个 touchstart(比如误点两次),startX 可能没及时更新,我在 handleTouchStart 里加了个 e.touches.length === 1 的 guard,问题不大。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如配合 getCoalescedEvents() 做高精度拖拽,后续会继续分享这类博客。如果你有更好的方案,比如用 Pointer Events 替代 Touch Events 的完整实践,欢迎评论区交流。

暂无评论