触摸劫持攻击原理与前端防御实战方案

Code°瑞君 安全 阅读 1,827
赞 25 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?

触摸劫持(Touch Hijacking)这玩意儿,说白了就是用户明明想滑动页面,结果手指一划,触发了你写的某个按钮、轮播图、抽屉菜单甚至整个页面都卡死了——不是 bug,是 feature(但没人想要这个 feature)。我在三个项目里被它坑过:一个电商 H5 的商品图左右滑失效;一个金融类 APP 的手势密码区域误触返回;还有一个内部管理后台的表格横向滚动直接被上层组件吞掉了 touchmove。折腾半天发现,问题不在逻辑,而在「谁在抢 touch 事件」。

触摸劫持攻击原理与前端防御实战方案

所以这次我拉了三个最常用方案来对比:CSS 的 touch-action、JS 的 preventDefault() + passive 控制、以及用 pointer-events: none 配合条件显隐。不讲原理,只说真实开发中哪个让我少改三次代码、哪个让我凌晨两点还在 console 里打 log。

方案一:touch-action —— 我现在默认开箱即用

这是我目前写新项目时第一反应加的 CSS。简单、声明式、不碰 JS,而且现代浏览器支持稳得一批(iOS 13.4+、Android Chrome 65+ 都 OK)。关键是:它不阻止事件传播,只告诉浏览器「别瞎管,这个区域你该干啥就干啥」。

比如轮播图容器,我要它只响应水平滑动,禁止缩放和双指操作:

.carousel {
  touch-action: pan-x pinch-zoom;
}

再比如一个不可拖拽的按钮区域,但下面有滚动列表:

.button-area {
  touch-action: none;
}
.scroll-container {
  touch-action: auto; /* 或者 pan-y */
}

优点:零 JS、无副作用、性能无损耗、兼容性查 MDN 就能放心用。我甚至把它加进项目的 reset.css 里,所有 .no-touch-interact 类统一设 touch-action: none

缺点:IE 完全不支持(但谁还在给 IE 写 H5?);Safari 在某些老版本 iOS 上对 pan-xpan-y 混用会抽风(亲测 iOS 12.1 里 pan-x pinch-zoom 会导致 vertical scroll 卡顿,换回 pan-x 就好了);还有——它不能动态开关。你想根据状态切 touch 行为?不行,得靠 JS 切 class,但 class 切换有渲染延迟,极端场景下仍有微小劫持窗口(不过我没在线上遇到过)。

方案二:preventDefault + passive —— 老派但够狠,我以前最爱用

这是最早期的“硬刚”方案:监听 touchstarttouchmove,手动 e.preventDefault(),把浏览器默认行为掐死。但后来发现,Chrome 加了 passive 默认 true,preventDefault 在 passive listener 里直接报错。于是变成这样:

// ❌ 错误写法(Chrome 报错)
el.addEventListener('touchmove', (e) => e.preventDefault(), { passive: true });

// ✅ 正确写法(但要分情况)
el.addEventListener('touchstart', (e) => {
  if (shouldPrevent(e)) {
    e.preventDefault();
  }
}, { passive: false });

el.addEventListener('touchmove', (e) => {
  if (shouldPrevent(e)) {
    e.preventDefault();
  }
}, { passive: false });

我之前在一个地图标记组件里这么干,效果确实猛:手指一按,绝对不滚页面。但代价是——滚动体验变差。iOS 下 passive: false 会让主线程等 JS 执行完才滚动,哪怕你只是做个 console.log,都会让滚动卡顿半拍。我们 QA 直接在 iPhone 上录屏对比,说「这个版本比上个版本滑起来像拖着砂纸」。

后来改成用 { passive: true } + touch-action 组合,只在必要时 fallback 到非 passive,才稳住体验。结论:这个方案适合「必须 100% 拦截」的场景(比如手势密码画布),但日常交互组件,我已经把它从工具箱里移出去了。

方案三:pointer-events: none —— 偷懒专用,我只在特定地方用

这招本质是“我不处理,你绕开我”。比如弹窗遮罩层后面有个固定定位的悬浮按钮,用户点按钮时可能误触遮罩导致关闭弹窗。我的解法是:遮罩层默认 pointer-events: none,只在需要拦截点击时才切回 auto;而按钮区域始终 pointer-events: auto,哪怕它在遮罩层 DOM 下面。

.overlay {
  pointer-events: none;
}
.overlay.active {
  pointer-events: auto;
}
.floating-btn {
  pointer-events: auto;
  z-index: 1001;
}

注意:这个方案对 touchmove 滚动无效!pointer-events: none 只影响 pointer/touch/click 事件,不影响 touchstart/touchmove 的默认滚动行为。所以如果你指望它解决「轮播图滑不动」的问题——没用。它只适合「拦截点击,但不管滑动」的场景。

我一般只用在模态框、Toast、Tooltip 这些临时 UI 上。好处是快、轻量、不污染事件流;坏处是:它不解决底层滚动冲突,纯属「掩耳盗铃」——表面不响应,但手指划过去依然可能触发父容器的 scroll,尤其当父容器没设 touch-action 时。

我的选型逻辑

一句话总结:优先用 touch-action,80% 场景覆盖;剩下 20%,能用 pointer-events 偷懒就偷懒;只有在「必须劫持且不能妥协」时,才上 preventDefault + 非 passive。

举个真实例子:我们有个数据看板,顶部是可横向滚动的 tab 栏,下面是纵向滚动的图表。Tab 栏用了 touch-action: pan-x,图表容器用了 touch-action: pan-y,中间没有任何 JS 干预,上线后零投诉。而那个曾经让我改了三天的手势密码模块?我保留了 preventDefault,但加了 touch-action: none 双保险,防止 Safari 偶尔漏判。

另外提醒一句:别忘了测试真机。模拟器里一切正常,到 iPhone 上发现 touch-action: pan-x 在微信内置浏览器(iOS)里偶尔失灵——最后加了个兜底:在 touchstart 里检测是否 horizontal move,是的话立刻 e.preventDefault()。这不是优雅,是现实。

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

  • 不要给 <body><html>touch-action: none —— 整个页面变废,连下拉刷新都没了
  • touch-action 不继承,子元素不会自动获得父级设置,每个需要控制的区域都得单独写
  • 在 Vue/React 里动态切 touch-action 时,别用内联 style(:style="{ 'touch-action': action }"),某些 Android WebView 会忽略;改用 class 切换更稳

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 动态启停 touch 行为,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

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

暂无评论