移动端触摸体验优化的实战技巧与关键代码实现
谁更灵活?谁更省事?
我最近又在搞一个移动端卡片拖拽排序功能,需求看着简单:手指按住卡片、上下拖动、松手后自动吸附到最近位置。结果一上手就发现——touch事件比想象中坑得多。iOS卡顿、Android误触发、滚动和拖拽打架、touchmove里event.preventDefault()加不加都出问题……折腾了两天,我把目前主流的触摸优化方案重新捋了一遍,不是查文档,是真正在项目里改完上线、压测过、被用户反馈过之后的总结。
结论先扔这儿:我日常首选 passive + requestAnimationFrame + 手动节流,但如果是复杂拖拽(比如支持多指、缩放、惯性),我会直接上 Hammer.js;至于 CSS touch-action,只在纯滚动场景下用,其他时候基本是摆设。
方案一:原生 touch 事件硬刚(最自由,也最累)
这是我在早期项目里踩坑最多的方式——自己监听 touchstart/touchmove/touchend,手动记录坐标、计算位移、控制 preventDefault。优点?啥都能控。缺点?每一步都得自己扛。
关键代码长这样:
let startY = 0;
let isDragging = false;
el.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
isDragging = true;
e.preventDefault(); // 必须加,否则 iOS 不触发 touchmove
}, { passive: false });
el.addEventListener('touchmove', (e) => {
if (!isDragging) return;
const currentY = e.touches[0].clientY;
const diff = currentY - startY;
// 这里注意!别直接改 transform,要进 RAF
requestAnimationFrame(() => {
el.style.transform = translateY(${diff}px);
});
// 滚动冲突?靠这个拦住页面滚动(但可能影响体验)
e.preventDefault();
}, { passive: false });
el.addEventListener('touchend', () => {
isDragging = false;
// 吸附逻辑、回弹动画等
});
踩坑提醒:这里我踩过好几次坑:iOS Safari 在 touchmove 里不加 { passive: false } 就根本收不到事件;但加了又容易导致页面滚动卡顿;而且 e.preventDefault() 加晚了(比如在 RAF 里加)就无效;另外,如果父容器有 overflow: scroll,你得一层层往上 stopPropagation,不然还是会被截胡。
这个方案适合对交互精度要求极高、且团队有足够人力兜底的项目。但我现在除非接外包定制需求,否则不会再从零写这套了——太耗神,维护成本高,且 iOS 和 Android 行为差异大,每次系统更新都得重测。
方案二:CSS touch-action + 原生 scroll 优化(最省事,但很脆)
如果你只是想让某个区域「不抢滚动」,比如轮播图、横向滑动列表,那 touch-action: pan-y 或 touch-action: none 是最快解法。
.carousel {
touch-action: pan-y; /* 允许垂直滚动,禁用水平拖拽 */
}
.drag-handle {
touch-action: none; /* 完全接管,禁止浏览器默认行为 */
}
优点是真的省事:一行 CSS,不用写 JS,兼容性也好(iOS 13.4+、Android Chrome 56+ 都稳)。但它有个致命问题:它只管“是否允许浏览器处理”,不管“你怎么处理”。一旦你写了 touch-action: none,浏览器就彻底撒手不管了,后续所有 touch 事件你都得自己 handle,而且连 scroll-behavior: smooth 这种都失效。
我试过在轮播组件里用 touch-action: pan-x,结果安卓机上双指缩放时整个页面卡死——因为浏览器以为你在做手势,但实际没监听 multi-touch。最后还是切回 JS 方案。
一句话评价:适合静态交互,不适合动态响应。我的建议是:能用就用,但别指望它解决所有问题。
方案三:passive + requestAnimationFrame + 手动节流(我现在的主力方案)
这是我现在在多数项目里默认采用的组合。不依赖第三方库,可控性强,性能也不差,关键是——它把“滚动冲突”和“响应延迟”两个痛点分开了处理。
核心思路就三点:
- touchstart/touchend 用
{ passive: true }(安全,不阻止滚动) - touchmove 用
{ passive: false }(仅在真正需要拦截时才加,避免全局禁用) - 所有位移计算丢进
requestAnimationFrame,再加个 16ms 节流(防止 touchmove 过密炸帧)
let lastTime = 0;
const THROTTLE_MS = 16;
el.addEventListener('touchstart', (e) => {
// 记录初始状态,不 preventDefault
startData = getTouchData(e);
}, { passive: true });
el.addEventListener('touchmove', (e) => {
const now = Date.now();
if (now - lastTime < THROTTLE_MS) return;
lastTime = now;
const data = getTouchData(e);
const diff = data.y - startData.y;
requestAnimationFrame(() => {
el.style.transform = translateY(${diff}px);
// 关键:只在明确拖拽时才 preventDefault
if (Math.abs(diff) > 5) {
e.preventDefault();
}
});
}, { passive: false });
function getTouchData(e) {
return {
x: e.touches[0].clientX,
y: e.touches[0].clientY
};
}
这个方案亲测有效:iOS 滚动顺滑,Android 不误触,且能兼容 pull-to-refresh 类库(比如 better-scroll)。唯一的妥协是——它没法处理多指或 pinch 手势,但大多数业务场景也真用不上。
我比较喜欢用这个方案,因为它像一把瑞士军刀:不炫技,但够用、稳定、好调试。
方案四:Hammer.js(重型武器,适合复杂交互)
当你的需求变成「支持长按、旋转、双击、多指缩放、拖拽+惯性滚动」时,别硬刚了。Hammer.js 真的香。虽然它体积不小(gzip 后约 12KB),但换来的是开箱即用的手势抽象。
const hammer = new Hammer(el);
hammer.get('pan').set({ direction: Hammer.DIRECTION_ALL });
hammer.on('panstart panmove panend', (ev) => {
if (ev.type === 'panstart') {
// 初始化
} else if (ev.type === 'panmove') {
requestAnimationFrame(() => {
el.style.transform = translate(${ev.deltaX}px, ${ev.deltaY}px);
});
} else if (ev.type === 'panend') {
// 惯性补帧
const velocity = ev.velocityX;
// 自己实现或接 tween 库
}
});
它内部已经帮你处理了 passive、preventDefault 时机、多点触控合并、平台差异。我上次做带缩放的地图拖拽,三天搞定,换成原生写估计得一周起步。
缺点?一是体积,二是它会劫持所有 touch 事件,跟某些 UI 框架(比如 Ionic 的手势系统)可能打架。所以我一般只在独立模块里引入,不全局挂载。
我的选型逻辑
看场景,我一般选这三个档位:
- 纯滚动/轮播/简单滑动 → CSS touch-action(快、轻、稳)
- 单指拖拽、排序、滑块、表单交互 → passive + RAF + 节流(我主力,可控性强)
- 多指、缩放、长按、复杂物理效果 → Hammer.js 或 @use-gesture(别造轮子,时间就是钱)
React 项目里我还试过 @use-gesture,API 更现代,但源码里一堆 TS 类型和 hooks 抽象,debug 起来反而不如 Hammer 直观。所以现在 React 项目我也照常引 Hammer,hook 封装一层完事。
最后说句实在话:没有银弹。我上个月还遇到一个 bug —— 某款国产安卓浏览器里,即使加了 { passive: false },touchmove 仍会随机丢失。最后解决方案是 fallback 到 mouse 事件监听(是的,移动端也能触发 mouse 事件),并加了个 200ms 的 touch fallback timer。这种问题文档不会写,只能自己埋点、抓 log、真机测。
以上是我的对比总结,有更优的实现方式欢迎评论区交流。

暂无评论