TouchMove事件拦截与手势处理的实战踩坑总结

IT人家轩 交互 阅读 2,861
赞 16 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

TouchMove 这玩意儿,我用过不下十个项目,从轮播图、拖拽排序、滑动删除,到自研的简易手势库,基本都绕不开它。但每次写,我都会重新翻一遍 MDN,再对着 Chrome DevTools 的 Events 面板反复调试——不是因为记不住,而是因为浏览器太“贴心”了。

TouchMove事件拦截与手势处理的实战踩坑总结

我现在的标准写法,是这么干的:

let isDragging = false;
let startX = 0;
let startY = 0;
let currentX = 0;
let currentY = 0;

const touchStartHandler = (e) => {
  if (e.touches.length !== 1) return;
  isDragging = true;
  startX = e.touches[0].clientX;
  startY = e.touches[0].clientY;
  currentX = startX;
  currentY = startY;
  e.preventDefault(); // 关键!防默认滚动干扰
};

const touchMoveHandler = (e) => {
  if (!isDragging || e.touches.length !== 1) return;

  const touch = e.touches[0];
  const dx = touch.clientX - startX;
  const dy = touch.clientY - startY;

  // 这里做你的逻辑:比如 translateX/Y、更新进度条、判断方向
  currentX = touch.clientX;
  currentY = touch.clientY;

  // 注意:这里不直接改 style,而是存状态,requestAnimationFrame 再更新
  // 避免频繁重排重绘
  requestAnimationFrame(() => {
    element.style.transform = translateX(${dx}px) translateY(${dy}px);
  });

  e.preventDefault(); // 必须加,否则 iOS 下容易被系统吞掉事件
};

const touchEndHandler = (e) => {
  if (!isDragging) return;
  isDragging = false;
  // 拖拽结束后的收尾逻辑,比如回弹、触发 drop、校验位置等
  const velocityX = (currentX - startX) / (e.timeStamp - touchStartTime);
  const velocityY = (currentY - startY) / (e.timeStamp - touchStartTime);
  // 根据速度和位移做后续判断(可选)
};

// 记录 touchstart 时间戳,用于算速度
let touchStartTime = 0;
const touchStartHandlerWithTime = (e) => {
  if (e.touches.length !== 1) return;
  isDragging = true;
  startX = e.touches[0].clientX;
  startY = e.touches[0].clientY;
  currentX = startX;
  currentY = startY;
  touchStartTime = e.timeStamp;
  e.preventDefault();
};

element.addEventListener('touchstart', touchStartHandlerWithTime, { passive: false });
element.addEventListener('touchmove', touchMoveHandler, { passive: false });
element.addEventListener('touchend', touchEndHandler, { passive: true });
element.addEventListener('touchcancel', touchEndHandler, { passive: true });

为什么这么写?三点最实在的理由:

  • passive: false 是底线 —— 不加这个,iOS Safari 和新版 Android Chrome 默认把 touchmove 设为 passive,一旦你调用了 preventDefault(),控制台会报 warning,而且事件根本不会触发回调(尤其在 scroll 容器里嵌套 draggable 元素时,百分百失效);
  • requestAnimationFrame 包一层 —— 我试过直接在 touchmove 里改 transform,手速快点就卡顿,特别是低端安卓机。RAF 把更新塞进下一帧,肉眼顺滑很多;
  • 只认 touches[0] —— 多指操作会搞乱坐标逻辑。如果你真需要多指,那得自己做 touches 映射和中心点计算,但 95% 的业务场景,只处理第一个触点就够用了,也省得后期维护踩坑。

这几种错误写法,别再踩坑了

下面这几个,都是我在项目上线前夜紧急 hotfix 过的……真的别信网上某些“简写教程”。

❌ 错误一:没加 preventDefault,只靠 CSS touch-action

有人觉得加个 touch-action: pan-x 就万事大吉。错。它只能告诉浏览器“我来接管水平滑动”,但如果你的元素本身在可滚动容器里(比如一个 overflow-y: auto 的 div 里放了个横向 swipe 区域),浏览器仍然可能抢走 touchmove 事件——尤其是当用户起手稍微偏斜一点(dy > dx),系统就会判定为“想滚动”,直接把事件吞了,你的 handler 一次都不执行。我遇到过三次,每次都折腾半天才发现是这个原因。

❌ 错误二:touchmove 里直接 fetch 或 setState

比如在 touchmove 里调 fetch('https://jztheme.com/api/position')setState({ offsetX })。千万别。touchmove 触发频率太高(每秒 60~120 次),高频请求或状态更新会导致内存暴涨、UI 卡死,甚至触发 React 的 warning(Too many re-renders)。我的做法是:只存 dx/dy 到变量,RAF 更新视图,真正要提交数据,放到 touchend 后 debounce 300ms 执行。

❌ 错误三:用 clientX/clientY 而不是 touches[0].clientX

这是最隐蔽的坑。在 PC 浏览器模拟 touch 时,e.clientX 有值;但在真机上,touch 事件的 e.clientX 是 undefined(规范如此)。必须用 e.touches[0].clientX。我曾经在测试环境一切正常,发到测试群让同事真机测,结果全挂——查了半小时才反应过来。

实际项目中的坑

滚动穿透问题:如果 touchmove 区域在 modal 里,而 modal 下面是个长列表,手指滑动稍快,底层列表就会跟着滚。解决办法不是禁用 body scroll(那体验更差),而是给 modal 加 overscroll-behavior: contain,同时确保 touchmove 的 target 是 modal 内部元素,且它的父容器没有 overflow: scroll。iOS 上还得加 -webkit-overflow-scrolling: touch 配合。

iOS 17+ 的新问题:部分机型在页面顶部下拉刷新区域触发 touchstart 后,touchmove 会被拦截。我的 workaround 是:监听 scroll 事件,如果发现 window.scrollY === 0 且用户正在下拉,就临时把 touchmove handler 置空,等 touchend 再恢复。虽然糙,但管用。

和 wheel 事件共存:有些桌面端用户用触控板,会触发 wheel 而非 touch 事件。如果你只监听 touch,PC 用户就完全没法交互。我的方案是写个统一的 gesture engine,内部自动识别是 touch/wheel/mouse,对外暴露统一的 onDrag(deltaX, deltaY) 接口。代码略长,就不贴了,但思路值得参考。

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

  • 永远在 touchstart 里记下 e.timeStamp,别用 Date.now(),后者在后台 tab 里不准;
  • touchcancel 一定要监听并清状态,不然用户突然切到其他 App 再切回来,isDragging 一直为 true,整个交互就锁死了;
  • 不要在 touchmove 里用 getBoundingClientRect(),它会强制同步 layout,性能杀手。坐标差值全用 clientX/clientY 算,够用了。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 PointerEvent 做跨端统一、加阻力回弹、支持缩放,后续会继续分享这类博客。有更好的方案欢迎评论区交流。

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

暂无评论