阻止默认行为在前端开发中的常见误区与正确处理方式

❤丹丹 移动 阅读 2,351
赞 18 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年下半年在做一个纯前端的移动端活动页,需求很典型:左右滑动切换卡片、上拉加载更多、下拉刷新、中间区域支持 pinch-zoom(双指缩放)、顶部固定导航栏要随滚动隐藏/显示。整个页面是单页应用,没用任何框架,就原生 JS + Tailwind CSS 搞定。

阻止默认行为在前端开发中的常见误区与正确处理方式

一开始我直接用 scroll 事件监听滚动,结果发现 iOS Safari 下卡顿得离谱,Android 倒还行。后来一查文档,意识到:不是 scroll 不够快,而是「滚动过程中频繁触发 JS 回调」+「默认滚动行为被干扰」导致了渲染抖动。于是决定切到 touch 系列事件 —— 这也是为什么我们这次必须认真对待 preventDefault()

最大的坑:touchmove 一加 preventDefault 就卡死

项目第二周,我写了第一版 touch 处理逻辑:

let startY = 0;
let isDragging = false;

el.addEventListener('touchstart', (e) => {
  startY = e.touches[0].clientY;
  isDragging = true;
});

el.addEventListener('touchmove', (e) => {
  if (!isDragging) return;
  const deltaY = e.touches[0].clientY - startY;
  // 更新 translateY
  el.style.transform = translateY(${deltaY}px);
  e.preventDefault(); // ← 这里加了
});

el.addEventListener('touchend', () => {
  isDragging = false;
});

结果?安卓真机上一切正常,iOS 上……滑不动了。手指一碰,页面就“冻住”,连最基本的原生滚动都失效了。当时以为是 CSS 的 touch-action 没配,加了 touch-action: none,反而更糟 —— 整个页面彻底不能上下滚了。

折腾了半天翻 MDN,才发现:iOS Safari 对 touchmove.preventDefault() 的限制比想象中严格得多。只要你在某个元素上阻止了默认行为,浏览器就会直接禁用该元素及其父级的**所有滚动能力**,哪怕你只拦了一次,它也记一辈子(直到 touchend 触发)。更坑的是,这个行为不报错、不警告,就是默默卡住。

绕不开的真相:不是要不要 preventDefault,而是什么时候、在哪一层拦

后来我把整个触摸流拆开看,发现真正需要阻止默认的,其实只是「我正在处理的那个手势」,而不是所有 touchmove。比如:用户双指 zoom 时,我不该拦;用户在顶部导航栏里拖拽按钮时,也不该拦;但当用户在卡片区域做横向滑动时,我就得拦掉 vertical scroll,否则会误触发页面滚动。

所以核心思路变了:不全局拦,而是在判断出当前手势意图之后,再精准拦截。我用了最朴素的办法 —— 记录初始方向,并在前 3 个 touchmove 中观察位移向量角度:

let startX = 0;
let startY = 0;
let gestureType = null; // 'horizontal' | 'vertical' | 'pinch'

el.addEventListener('touchstart', (e) => {
  if (e.touches.length === 1) {
    startX = e.touches[0].clientX;
    startY = e.touches[0].clientY;
  }
  gestureType = null;
});

el.addEventListener('touchmove', (e) => {
  if (e.touches.length !== 1 || !startX) return;

  const dx = Math.abs(e.touches[0].clientX - startX);
  const dy = Math.abs(e.touches[0].clientY - startY);

  // 判断主方向:超过 30px 才开始决策,避免误判
  if (dx < 5 && dy  dy ? 'horizontal' : 'vertical';
  }

  // 只有明确是 horizontal 手势时,才阻止垂直滚动
  if (gestureType === 'horizontal') {
    e.preventDefault();
  }
});

这里注意我踩过好几次坑:一是没加 dx/dy > 5 的阈值,导致手指刚落就误判;二是没区分 touches.length,双指操作时也去算 dx/dy,结果 NaN 直接崩逻辑;三是忘了重置 startX/startY,导致第二次滑动方向判断全乱。

最终的解决方案

最终上线版本用了三层防御:

  • CSS 层:给可滑动容器加 touch-action: pan-y(允许纵向滚动),横向滑动区域加 touch-action: pan-x
  • JS 层:按上面的方向判断逻辑,在确认是目标手势后再 preventDefault
  • 兜底层:在 touchend 里强制恢复 body { overscroll-behavior: auto }(之前为防回弹设成了 contain)

另外,为了兼容某些老安卓机型对 touch-action 的支持问题,我在初始化时做了个检测:

function supportsTouchAction() {
  return CSS.supports('touch-action', 'pan-x');
}

if (!supportsTouchAction()) {
  document.body.classList.add('no-touch-action');
}

然后在 CSS 里写:

.no-touch-action .swipe-area {
  /* 降级方案:用 JS 完全接管 */
}

这套组合拳跑下来,iOS 和安卓主流机型(包括 iOS 15.7 和 Android 12)都稳了。唯一遗留的小问题是:在部分 iPad 上双指缩放时,偶尔第一次 pinch 会触发一次轻微的页面滚动(大概 2px),但不影响功能,PM 说“用户感知不到”,我们就先上线了。

回顾与反思

这次最大的收获不是学会了怎么写 preventDefault,而是终于理解了浏览器底层的“手势仲裁机制” —— 它不是非黑即白的开关,而是一套带优先级、有缓存、还会跨事件生命周期的记忆系统。

做得好的地方:方向判断逻辑足够轻量,没引入任何第三方库;CSS + JS 分层控制,维护成本低;错误兜底完整,没出现白屏或锁死。

还能优化的地方:目前的 gestureType 是单次判定,如果用户中途转向(比如先横后竖),就无法动态切换。不过这种场景极少,而且强行支持反而增加复杂度,现阶段没必要。

还有个小细节值得提:我试过用 passive: false 注册 touchmove,结果发现 Chrome 最新版已经不认这个 flag 了 —— 即使你写了 { passive: false },它照样给你当成 passive 处理,除非你在 touchstart 里明确调用 e.preventDefault()。这点文档没写清楚,我是抓包对比 behavior 才发现的。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 getCoalescedEvents() 做高精度拖拽,或者用 PointerEvent 统一处理 touch/mouse,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

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

暂无评论