深入解析 TouchEvent 事件机制与移动端交互优化实践

上官艳雯 移动 阅读 1,589
赞 18 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年做了一个移动端的滑动卡片组件,类似 Tinder 那种左右滑动匹配的效果。一开始想直接用第三方库,比如 Swiper,但发现它太重了,而且定制性不够——我们还要在滑动过程中实时计算偏移量、加点动画反馈,甚至要支持“回弹”和“半途放弃”的交互逻辑。折腾一圈后,决定自己撸一个基于原生 TouchEvent 的方案。

深入解析 TouchEvent 事件机制与移动端交互优化实践

选 TouchEvent 而不是 PointerEvent,主要是兼容性考虑。虽然现在主流浏览器都支持 PointerEvent,但项目里有些老安卓机(比如华为 EMUI 9 那批)对 PointerEvent 的 touch 支持不完整,干脆用更底层的 touchstart/touchmove/touchend 三件套,反而更稳。

核心代码就这几行

其实基础逻辑不复杂:监听 touch 事件,记录起始点,计算移动距离,然后用 transform 实现视觉位移。关键是要处理好事件冒泡和默认行为,不然页面一滑就跟着滚,体验炸裂。

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

const card = document.querySelector('.card');

card.addEventListener('touchstart', (e) => {
  if (e.touches.length !== 1) return; // 多指操作忽略
  isDragging = true;
  startX = e.touches[0].clientX;
  currentX = 0;
  card.style.transition = 'none';
}, { passive: false });

card.addEventListener('touchmove', (e) => {
  if (!isDragging || e.touches.length !== 1) return;
  e.preventDefault(); // 阻止页面滚动
  currentX = e.touches[0].clientX - startX;
  card.style.transform = translateX(${currentX}px);
}, { passive: false });

card.addEventListener('touchend', () => {
  if (!isDragging) return;
  isDragging = false;
  // 判断是否超过阈值,决定是归位还是飞走
  const threshold = window.innerWidth * 0.3;
  if (Math.abs(currentX) > threshold) {
    // 飞走
    card.style.transition = 'transform 0.3s ease-out';
    card.style.transform = translateX(${currentX > 0 ? '100vw' : '-100vw'});
    setTimeout(() => card.remove(), 300);
  } else {
    // 归位
    card.style.transition = 'transform 0.2s ease-out';
    card.style.transform = 'translateX(0)';
  }
});

这段代码跑起来基本能用,但上线前测了十几台真机,问题就来了。

又踩坑了,touchmove滚动失效

最大的问题是 iOS Safari 上偶尔滑不动。一开始以为是 e.preventDefault() 没生效,后来发现是因为用了 { passive: false } —— 现代浏览器为了滚动性能,默认把 touchmove 事件设为 passive(即不能调用 preventDefault),除非你显式声明 passive: false。但即便如此,在某些 iOS 版本上,如果 JS 执行稍慢(比如卡顿一下),preventDefault 就会失效,页面照样滚动。

解决办法是:除了在事件监听器里加 passive: false,还得在 CSS 里加一行:

.card {
  touch-action: none;
}

这行代码告诉浏览器:“这个区域别管默认手势了,全交给我 JS 处理”。加上之后,iOS 的滑动稳定性明显提升。不过要注意,touch-action: none 会禁用所有默认手势,包括双指缩放——好在我们的卡片不需要这些,所以没问题。

性能问题:掉帧到怀疑人生

在低端安卓机上(比如红米 Note 7),快速滑动时明显掉帧。一开始以为是 transform 计算太频繁,后来用 Chrome DevTools 的 Performance 面板一录,发现瓶颈其实在 getBoundingClientRect 或其他 layout-triggering 操作——但我的代码里根本没用这些啊?

折腾了半天发现,问题出在 touchmove 里频繁读取 clientX 并写入 style。虽然现代浏览器对 transform 有硬件加速,但如果 JS 主线程被阻塞(比如同时有其他定时器或网络请求),还是会卡。

优化方案有两个:

  • requestAnimationFrame 包裹更新逻辑,确保每帧只更新一次
  • 把 transform 值存在变量里,只在 rAF 回调里批量写入 DOM

改完后流畅多了,但说实话,低端机上还是有点小卡。不过产品说“能用就行”,就没再深究。

边界情况:多指误触和快速连点

测试时发现,用户如果用两根手指同时滑卡片,或者快速点击+滑动,组件会进入诡异状态:比如卡片飞一半停住,或者 transform 值错乱。原因是我只判断了 touches.length === 1,但没处理中途插入第二根手指的情况。

后来加了个更严格的判断:一旦检测到 touches 数量变化,就立刻中断当前拖拽:

card.addEventListener('touchmove', (e) => {
  if (!isDragging || e.touches.length !== 1) {
    // 如果中途多指,强制结束拖拽
    if (isDragging) {
      isDragging = false;
      card.style.transform = 'translateX(0)';
    }
    return;
  }
  // ...原有逻辑
});

另外,快速连点的问题是由于 touchend 后立即触发新 touchstart,但上一个动画还没结束。解决方案是在 touchend 后加个 50ms 的防抖锁,期间忽略新 touchstart。虽然不完美,但实测有效。

回顾与反思

整体来说,这个方案达到了预期效果:轻量(不到 100 行核心代码)、可控、无依赖。上线后 crash report 里没收到相关报错,说明兼容性还行。

但有几个地方还能优化:

  • 没做 velocity(速度)判断。现在只看位移阈值,但用户可能轻轻一推就飞走,或者猛拉却没触发。理想情况应该结合速度,比如 Lodash 的 throttle 或自研的简易速度计算
  • 低端机性能问题没彻底解决。或许可以尝试用 Web Worker 分离计算,但性价比不高
  • 没考虑横屏适配。不过产品说只支持竖屏,就偷懒了

最让我意外的是,其实大部分“坑”都不是 TouchEvent 本身的问题,而是浏览器兼容性、事件模型理解偏差、以及性能意识不足。比如 passive 选项、touch-action 这些细节,文档里都有,但实战中很容易忽略。

另外,千万别迷信“原生一定快”——如果你的逻辑复杂,第三方库(比如 Hammer.js)其实封装得更健壮,只是我们这次需求太简单,没必要引入。

结尾碎碎念

以上是我做滑动卡片时踩过的坑和临时补丁。代码肯定不是最优解,但胜在简单直接,适合中小项目快速落地。如果你也在搞类似交互,建议先用真机多测几轮,尤其是老款安卓和 iOS 12-14 这些版本。

这个技巧的拓展用法还有很多,比如配合 IntersectionObserver 做懒加载滑动,或者加个弹簧动画(用贝塞尔曲线模拟)。后续有空再分享。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你怎么处理 touch 速度判断的?

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

暂无评论