触摸劫持攻击原理与前端防御实战方案
谁更灵活?谁更省事?
触摸劫持(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-x 和 pan-y 混用会抽风(亲测 iOS 12.1 里 pan-x pinch-zoom 会导致 vertical scroll 卡顿,换回 pan-x 就好了);还有——它不能动态开关。你想根据状态切 touch 行为?不行,得靠 JS 切 class,但 class 切换有渲染延迟,极端场景下仍有微小劫持窗口(不过我没在线上遇到过)。
方案二:preventDefault + passive —— 老派但够狠,我以前最爱用
这是最早期的“硬刚”方案:监听 touchstart 或 touchmove,手动 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 行为,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论