彻底解决touchmove事件的那些坑

打工人文斌 移动 阅读 2,440
赞 32 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

先上代码,这是我现在项目里通用的 touchmove 处理方式,主要用来做自定义滑动容器,比如轮播图、横向滚动列表这种。核心目标就一个:滑得顺,不卡顿,别跟页面默认行为打架。

彻底解决touchmove事件的那些坑

const slider = document.getElementById('slider')
let startY
let isScrolling = false
let initialMoveDetected = false

slider.addEventListener('touchstart', (e) => {
  // 记录起始Y坐标
  startY = e.touches[0].clientY
  isScrolling = false
  initialMoveDetected = false
}, { passive: true })

slider.addEventListener('touchmove', (e) => {
  if (initialMoveDetected) return

  const currentY = e.touches[0].clientY
  const deltaY = currentY - startY

  // 判断是垂直还是水平主导
  if (!isScrolling) {
    // 横向位移大于纵向?我们认为是横向滑动
    isScrolling = Math.abs(e.touches[0].clientX - startX) > Math.abs(deltaY)
  }

  // 如果是垂直滑动,就不阻止默认行为,让页面可以正常上下滚
  if (isScrolling === false) {
    return // 不调用 preventDefault,放行给外层页面处理
  }

  // 是横向滑动,阻止默认滚动
  e.preventDefault()

  // 这里处理你的逻辑,比如 translateX 移动元素
  handleSlide(e)

  initialMoveDetected = true
}, { passive: false }) // 注意这里必须设为 false,因为要调用 preventDefault

这段代码的关键点有几个:

  • 用 deltaY 和 deltaX 判断滑动方向:不能一上来就 preventDefault,否则 iOS 上整个页面都卡住没法滚动了,用户体验直接崩
  • passive: true 的陷阱:touchstart 设成 passive 是安全的,但 touchmove 如果要调用 preventDefault,监听器就必须声明 { passive: false },不然 Chrome 会警告你“Unable to preventDefault”
  • 只在明确是横向滑动时才拦截事件:这才是用户真正想操作当前组件的时候

我之前图省事直接在 touchmove 里无脑 e.preventDefault(),结果安卓机上页面完全不能上下滑,iOS 更惨,连页面刷新都要双击。折腾了半天发现是这个锅。

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

下面这些我都试过,每一个都能让你在联调时被产品指着鼻子问“为什么手机上划不动”。

错误写法1:无脑 preventDefault

slider.addEventListener('touchmove', (e) => {
  e.preventDefault() // ❌ 完全不让浏览器处理任何滚动
  handleSlide(e)
})

这种写法最致命的地方在于——它把页面级的滚动也干掉了。用户在一个长页面里,手指往下滑,结果页面不动,只能靠拖底部滚动条,体验差到极点。

错误写法2:忘了 passive 配置,默认变成 true

slider.addEventListener('touchmove', (e) => {
  if (shouldPrevent) e.preventDefault() // ⚠️ 即便写了,也可能无效!
})

现代浏览器默认给 touchmove 加了 passive: true,意味着你不允许调用 preventDefault。就算写了也没用,控制台还会报警告。一定要显式写上 { passive: false } 才行。

错误写法3:在 touchstart 就 preventDefault

slider.addEventListener('touchstart', (e) => {
  e.preventDefault() // ❌ 太早了!根本不知道用户想干嘛
})

更离谱的是有人在这里就拦住事件。这时候连方向都没判断,用户只是轻轻点了一下,你就把整个页面滚动能力给废了,简直是反人类设计。

实际项目中的坑

去年做电商详情页,有个横向滑动的规格选择器,内部 item 很多,需要流畅滑动。上线前测试没问题,结果灰度阶段大量反馈“页面卡住不能滑”。查了半天才发现是嵌套滚动场景的问题。

结构大概是这样:

<div class="page" style="height: 200vh; overflow-y: auto;">
  <div id="spec-slider" style="overflow-x: scroll; white-space: nowrap;">
    <!-- 一堆规格项 -->
  </div>
</div>

问题出在哪?当用户从上往下划的时候,spec-slider 拦截了所有 touchmove,导致外部 page 容器收不到事件,也就无法触发垂直滚动。

最终解决方案还是回到方向判断 + 动态放行:

let startX
slider.addEventListener('touchstart', (e) => {
  startX = e.touches[0].clientX
  startY = e.touches[0].clientY
}, { passive: true })

slider.addEventListener('touchmove', (e) => {
  if (initialMoveDetected) return

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

  if (dx < 5 && dy < 5) return // 微小移动不算

  // 横向为主 -> 拦截
  if (dx > dy) {
    e.preventDefault()
    handleSlide(e)
  }
  // 垂直为主 -> 放行,交给父容器滚动
  // 不做任何处理即可
  initialMoveDetected = true
}, { passive: false })

另外还有一个细节:有些安卓机自带“边缘返回”手势(从屏幕左边右滑返回上一页),如果你的 slider 在最左边还强行 intercept touchmove,会导致系统手势失效。建议在容器左右边界时动态放开事件控制。

我当时加了个判断:

if (isAtLeftEdge() || isAtRightEdge()) {
  // 边界状态,允许系统手势接管
  return // 不调用 preventDefault
}

性能优化这块也不能松

touchmove 触发频率极高,每秒可能几十次,如果每次都在里面算一堆东西,页面立马变卡。

我一般会做两件事:

  • 用 requestAnimationFrame 节流
  • 避免在回调里频繁读写 layout 属性(如 offsetTop、scrollTop)

推荐写法:

let ticking = false

function handleSlide(e) {
  if (!ticking) {
    requestAnimationFrame(() => {
      updateSliderPosition(e) // 真正的操作放这里
      ticking = false
    })
    ticking = true
  }
}

这样能保证重绘节奏和屏幕刷新率同步,不会造成丢帧。

别忘了 PC 兼容

虽然主题是 touchmove,但实际项目中很多人还是会用 iPad 或带触屏的 Windows 设备访问,甚至桌面浏览器缩放后调试也会遇到问题。

我的做法是统一抽象一层“move”事件:

const isTouch = 'ontouchstart' in window

const moveEvent = isTouch ? 'touchmove' : 'mousemove'
const downEvent = isTouch ? 'touchstart' : 'mousedown'
const upEvent = isTouch ? 'touchend' : 'mouseup'

element.addEventListener(downEvent, handleStart, { passive: true })
element.addEventListener(moveEvent, handleMove, { passive: false })

这样一套逻辑跑通移动端和桌面端,维护起来轻松不少。

fetch 示例里的 API 地址怎么写

顺便提一句,在组件初始化时可能需要加载数据,比如:

async function loadSliderData() {
  const res = await fetch('https://jztheme.com/api/slides')
  return res.json()
}

这只是个演示用的接口地址,别当真。真实项目里肯定要换成自己的服务端接口。

最后一点碎碎念

touchmove 看似简单,真要做得丝滑,得考虑太多边界情况:滑动方向判断不准、多指操作、快速滑动惯性、安卓/iOS 行为差异……

我现在已经不敢说“这个功能很简单”,每次接到类似需求都得先在脑子里过一遍上面这些坑。

这个方案也不是最优解,比如复杂的手势识别可以用 Hammer.js,但大多数场景下自己控制更轻量、可控性强。而且 Hammer.js 已经不怎么维护了,新项目不太敢引入。

以上是我踩坑后的总结,希望对你有帮助。有更好的实现方式欢迎评论区交流。

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

暂无评论