触摸劫持攻击原理与前端防御实战指南
又踩坑了,touchmove滚动失效
上周上线一个移动端滑动菜单,结果 QA 一测就说“iOS 上滑不动”,我一开始还以为是惯性滚动的问题,后来才发现是被 touchmove 的默认行为搞的鬼。更麻烦的是,我们还在页面里嵌了第三方地图组件,用户一滑地图就乱飘——典型的触摸劫持(Touch Hijacking)问题。
其实所谓“触摸劫持”说白了就是:某个元素监听了 touchstart/touchmove,但没处理好事件冒泡或默认行为,导致父级或兄弟组件收不到触摸事件,或者页面滚动被意外阻止。这在混合内容(比如内嵌 iframe、canvas、自定义手势组件)的页面里特别常见。
为了解决这个问题,我试过三种主流方案,今天就来唠唠它们各自的坑和爽点。
三种主流 touchmove 处理方案
先说结论:我现在基本只用 passive + 条件 preventDefault 的组合。下面一个个拆开讲。
方案一:无脑 preventDefault() —— 别这么干
这是新手最容易踩的坑。代码长这样:
element.addEventListener('touchmove', (e) => {
e.preventDefault(); // 阻止默认滚动
// 自己处理滑动手势
});
看起来没问题?错。一旦你这么写,整个页面的原生滚动就废了。更糟的是,在 iOS Safari 和新版 Chrome 上,这种写法还会触发控制台警告:“Unable to preventDefault inside passive event listener due to target being treated as passive.”
为啥?因为现代浏览器为了提升滚动性能,默认把 touchmove 监听器设为 passive(被动),意思是“你别想阻止默认行为了,先让我滚起来再说”。所以 e.preventDefault() 根本不起作用,还可能引发兼容性问题。
我之前在一个老项目里这么写过,结果用户反馈“页面卡成 PPT”,查了半天才发现是这里阻塞了合成线程。血泪教训:除非你 100% 确定这个区域不需要任何原生滚动(比如全屏游戏画布),否则别这么干。
方案二:passive: false 强制可阻止 —— 能用但慎用
既然 passive 是默认行为,那我显式关掉不就行了?于是有了这个写法:
element.addEventListener('touchmove', (e) => {
if (shouldBlockScroll) {
e.preventDefault();
}
}, { passive: false });
这样确实能让你的 preventDefault() 生效。我在处理一个横向滑动的轮播图时就这么干过,效果立竿见影。
但问题来了:关掉 passive 会直接影响滚动性能。浏览器没法提前知道你是否要阻止滚动,只能等 JS 执行完才能决定要不要滚,这就破坏了“异步滚动”的优化机制。实测在低端安卓机上,页面滚动会有明显卡顿。
而且,如果你在同一个页面多个地方都这么写,性能损耗会叠加。有一次我同时用了两个自定义滑动手势组件,都设了 passive: false,结果整页滚动帧率直接掉到 30fps 以下。
所以我的建议是:只在必须精确控制滚动(比如需要完全禁用滚动的模态弹窗)时才用这个方案,并且尽量缩小监听范围。
方案三:passive + 条件判断 + CSS overscroll-behavior —— 我的首选
现在我基本都用这套组合拳:
- 保持
touchmove监听器 passive(默认) - 用 CSS 的
overscroll-behavior: contain隔离滚动传播 - 只在必要时通过其他方式模拟“阻止滚动”
具体代码:
.scroll-container {
overscroll-behavior: contain;
/* 如果还需要禁止内部滚动,加 overflow: hidden */
}
// 注意:这里不调用 preventDefault()
element.addEventListener('touchmove', (e) => {
// 直接处理你的手势逻辑
handleSwipe(e);
// 不阻止默认行为,让浏览器自由滚动
});
overscroll-behavior: contain 这个属性简直是救星。它的作用是:当容器滚动到边界时,不再把滚动事件传递给父级。这样你就能在局部区域实现“独立滚动”,而不会影响整个页面。
比如我在做一个侧滑抽屉菜单时,给抽屉加了这个样式,用户上下滑动抽屉内容不会导致背景页面跟着滚,体验干净利落。而且它完全由浏览器原生实现,零 JS 开销,性能拉满。
当然,它也有局限:不能完全禁止滚动(比如你想做个不可滚动的拖拽区域)。这时候我会配合 overflow: hidden + 手动计算位移来模拟“固定”效果,而不是硬生生阻止 touchmove。
另外,对于需要精确阻止滚动的场景(比如全屏编辑器),我会用一个 trick:在 touchstart 时动态给 body 加 style="overflow: hidden",touchend 再移除。虽然有点 hack,但比全局 passive: false 安全多了。
谁更灵活?谁更省事?
从灵活性看,方案三赢麻了。CSS 控制滚动传播,JS 专注手势逻辑,职责分明。而且 overscroll-behavior 在现代移动端浏览器支持度已经很高(iOS 16+、Android Chrome 全支持)。
方案二虽然功能强大,但属于“杀敌一千自损八百”,不到万不得已我不碰。
方案一……就当是个反面教材吧。
至于兼容性?如果你还要支持 iOS 15 以下的老设备,那可能得回退到方案二。但我现在接手的项目基本都放弃 iOS 14 以下了,所以直接冲方案三。
踩坑提醒:这三点一定注意
- 别在 touchmove 里做重计算:即使 passive,频繁读取 layout 属性(比如
offsetTop)也会导致强制同步布局,卡死滚动。用getBoundingClientRect()缓存初始值,后续只算相对位移。 - iframe 里的触摸事件会穿透:如果页面嵌了第三方 iframe(比如地图),它的触摸事件可能绕过你的处理。这时候得在 iframe 外层包一层 div,加上
pointer-events: none动态控制。 - 测试真机!测试真机!:Chrome DevTools 的设备模拟对 passive 行为模拟不全,很多问题只在真机上暴露。尤其是 iOS 的 rubber-band 效果,模拟器根本看不出问题。
我的选型逻辑
总结一下我的决策树:
- 如果只是隔离滚动(比如弹窗、抽屉)→ 优先用
overscroll-behavior: contain - 如果需要完全禁止滚动(比如全屏游戏)→ 用
body { overflow: hidden }+ 方案三的手势处理 - 如果必须动态阻止滚动且无法用 CSS 解决 → 才考虑
{ passive: false },但要严格限制作用域
说到底,能用 CSS 解决的,就别动 JS;能用 passive 的,就别关 passive。浏览器的原生滚动优化是无数工程师调出来的,别轻易挑战它。
核心代码就这几行
最后贴个我常用的模板,亲测有效:
<div class="gesture-area" id="swipeArea">
<!-- 你的内容 -->
</div>
.gesture-area {
overscroll-behavior: contain;
touch-action: pan-y; /* 允许垂直滚动,禁止水平 */
}
const el = document.getElementById('swipeArea');
el.addEventListener('touchmove', (e) => {
// 这里处理你的横向滑动手势
const dx = e.touches[0].clientX - startX;
if (Math.abs(dx) > 10) {
// 触发滑动逻辑
}
// 注意:不调用 preventDefault()
});
搞定。既不影响页面滚动,又能精准捕获手势,还不掉帧。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,或者在特殊场景下有更好的 trick,欢迎评论区交流——毕竟前端这行,谁还没被 touchmove 虐过几次呢?

暂无评论