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

司空亚会 安全 阅读 2,728
赞 21 收藏
二维码
手机扫码查看
反馈

又踩坑了,touchmove被劫持导致滚动失效

最近在做移动端的一个滑动组件,本来以为就是监听个 touchmove 事件,结果上线后一堆用户反馈“页面卡住不能滑动”。折腾了半天才发现,是我在某个子元素上加了 preventDefault(),结果把整个页面的滚动给干掉了。这种问题叫“触摸劫持”(Touch Hijacking),说白了就是你本意只想处理局部滑动,却意外吃掉了原生滚动行为。

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

为了解决这个问题,我试了三种主流方案:暴力全局放行、细粒度事件委托、以及用 CSS 的 touch-action。下面直接说结论:我更推荐用 touch-action,简单、干净、不依赖 JS,除非你有非常复杂的交互逻辑。

方案一:JS 里手动判断要不要 preventDefault

这是最直觉的做法——在 touchmove 里加个条件判断,只在需要的时候调用 preventDefault()。比如你只想让某个区域横向滑动,那就判断滑动方向是不是 X 轴为主。

let startX, startY;

element.addEventListener('touchstart', (e) => {
  startX = e.touches[0].clientX;
  startY = e.touches[0].clientY;
});

element.addEventListener('touchmove', (e) => {
  const dx = e.touches[0].clientX - startX;
  const dy = e.touches[0].clientY - startY;
  
  // 如果横向滑动幅度大于纵向,就阻止默认行为(比如防止页面上下滚动)
  if (Math.abs(dx) > Math.abs(dy)) {
    e.preventDefault();
    // 执行你的滑动逻辑
    handleHorizontalSwipe(dx);
  }
  // 否则放行,让页面正常滚动
});

这个方案听起来很合理,但实际用起来坑不少。首先,你得自己算方向,还得处理边界情况(比如刚开始滑动时 dx/dy 都很小,容易误判)。其次,在 iOS 上,即使你没调 preventDefault(),只要监听了 touchmove,Safari 也会延迟滚动,俗称“300ms 延迟”的变种。我之前在一个项目里就这么干,结果用户反馈“滑动卡顿”,查了好久才定位到是监听器本身的问题。

另外,如果你的组件嵌套很深,每个层级都可能加了类似的逻辑,调试起来简直是噩梦。我曾经在一个轮播图组件里这么写,后来发现它父容器也有类似逻辑,两个 preventDefault() 叠加,直接把整个页面锁死。

方案二:用 passive: true 显式声明不阻止默认行为

现代浏览器其实提供了一个更优雅的方式:通过 passive: true 告诉浏览器“我不会调用 preventDefault()”,这样浏览器就能提前优化滚动性能,也不会因为监听了事件就卡住。

// 注意:这里必须显式设置 passive: true
element.addEventListener('touchmove', (e) => {
  // 这里不能调用 e.preventDefault()!否则会报错
  handleSwipe(e);
}, { passive: true });

这个方案在性能上确实有优势,尤其在低端安卓机上能明显感觉到滚动更流畅。但问题在于:**一旦你设置了 passive: true,就完全不能调用 preventDefault() 了**。也就是说,如果你的组件既需要偶尔阻止滚动(比如横向滑动时),又需要放行(比如纵向滑动时),这个方案就玩不转。

我试过在运行时动态切换监听器(先加 passive 的,再根据条件移除并加非 passive 的),但代码变得极其复杂,而且容易内存泄漏。最后放弃了。

方案三:用 CSS 的 touch-action,一劳永逸

这个才是我现在的首选。CSS 的 touch-action 属性可以直接告诉浏览器:“在这个元素上,哪些触摸行为由我接管,哪些交给原生滚动”。比如:

.swipe-area {
  touch-action: pan-y; /* 只允许垂直滚动,水平滑动由 JS 处理 */
}

配合 JS,你甚至可以完全不用 preventDefault()

const swipeArea = document.querySelector('.swipe-area');
swipeArea.addEventListener('touchmove', (e) => {
  // 因为 CSS 已经限制了 touch-action,这里不需要 preventDefault
  // 浏览器会自动放行 pan-y(垂直滚动),而水平滑动事件会正常触发
  const dx = e.touches[0].clientX - startX;
  handleHorizontalSwipe(dx);
}, { passive: true }); // 这里可以安全地设为 passive

这方案最大的好处是:逻辑清晰,性能好,且不依赖复杂的 JS 判断。 浏览器在底层就处理了事件分发,你不用再担心误杀滚动。而且代码量少,维护成本低。

我之前在一个商品详情页的图片轮播区域用了这个方案,效果非常好。用户上下滑动页面时流畅如丝,左右滑动图片时也响应迅速。关键是,再也不用担心因为某个子组件的 bug 导致整个页面卡死。

当然,它也有局限:比如你需要非常精细的控制(比如“只在特定速度下阻止滚动”),那 CSS 就不够用了。但这种情况极少,95% 的场景 touch-action 都能搞定。

我的选型逻辑

现在我拿到一个新需求,第一反应就是看能不能用 touch-action 解决。如果交互逻辑是“某个方向由我处理,其他方向放行”,那直接上 CSS。代码干净,性能好,还不容易出错。

只有在以下情况,我才会考虑回退到 JS 方案:

  • 需要动态改变行为(比如根据数据状态切换是否允许滑动)
  • 目标浏览器不支持 touch-action(主要是老版本 UC 浏览器,但现在基本可以忽略)
  • 交互逻辑极其复杂,比如“斜向滑动要同时触发两个动作”

但即便如此,我也会尽量避免在 touchmove 里直接调 preventDefault(),而是用状态标记 + 异步判断,减少对主线程的阻塞。

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

1. 别在 document 或 window 上监听 touchmove。很多教程为了“全局捕获”会这么做,但一旦你在这里调了 preventDefault(),整个页面滚动就废了。我见过不止一个项目因为这个原因线上事故。

2. iOS 的 Safari 对 passive 事件支持有坑。在 iOS 13 之前,即使你设了 passive: true,只要监听了 touchmove,滚动就会有延迟。所以老项目还是要测试真机。

3. touch-action 的值要写全。比如你想禁止所有原生行为,应该写 touch-action: none,而不是只写 pan-x。否则浏览器可能会默认开启其他操作(比如缩放)。

核心代码就这几行

最后贴一个我目前项目里用的完整示例,亲测有效:

<div class="slider" id="slider">
  <!-- 轮播内容 -->
</div>
.slider {
  touch-action: pan-y pinch-zoom; /* 允许垂直滚动和缩放,水平滑动由 JS 处理 */
  overflow: hidden;
}
const slider = document.getElementById('slider');
let startX;

slider.addEventListener('touchstart', (e) => {
  startX = e.touches[0].clientX;
});

slider.addEventListener('touchmove', (e) => {
  const currentX = e.touches[0].clientX;
  const diff = currentX - startX;
  // 直接处理滑动,无需 preventDefault
  updateSliderPosition(diff);
}, { passive: true }); // 安全设为 passive

就这么简单,再也没出现过滚动卡死的问题。

以上是我个人对触摸劫持问题的完整踩坑总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多(比如结合 IntersectionObserver 做懒加载区域的滑动优化),后续会继续分享这类实战博客。

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

暂无评论