彻底解决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 已经不怎么维护了,新项目不太敢引入。
以上是我踩坑后的总结,希望对你有帮助。有更好的实现方式欢迎评论区交流。

暂无评论