触摸劫持攻击原理与前端防御实战指南

打工人子伯 安全 阅读 629
赞 19 收藏
二维码
手机扫码查看
反馈

又踩坑了,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 —— 我的首选

现在我基本都用这套组合拳:

  1. 保持 touchmove 监听器 passive(默认)
  2. 用 CSS 的 overscroll-behavior: contain 隔离滚动传播
  3. 只在必要时通过其他方式模拟“阻止滚动”

具体代码:

.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 虐过几次呢?

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

暂无评论