做移动端H5这几年,最让我头疼的从来不是布局和兼容,而是手势。用户的手指是哪儿都能碰的,滑一下、点一下、捏一下,每个动作背后都跟着一串原生事件在跑。早期我自己写过拖拽、写过轮播,每次都要重新处理触摸坐标、位移方向、速度计算,代码写得越多,和页面滚动打架的概率也越高。后来把“手势识别”这件事交给触摸事件与手势识别库 Hammer.js,一次引入,tap、pan、swipe、pinch、rotate全齐,交互体验才算是真正稳下来。这篇文章是我在几个实际项目里用 Hammer.js 做交互沉淀下来的心得,从原理到接入、从参数调到踩坑填坑,适合刚接触手势识别、或者正为移动端交互方案发愁的前端开发者。
1. 为什么在一堆手势方案里偏偏选了 Hammer.js
1.1 移动端交互的真实痛点
先说清楚我们面对的到底是什么问题。移动端的触摸交互,底层就是 touchstart、touchmove、touchend 这一串事件。但直接拿它们做业务,你会发现每一层都在重复造轮子:算起始坐标、累计位移、判断方向、算速度、区分单击和双击、处理多指、考虑鼠标兼容……这些逻辑看着不难,写起来却全是细节。比如判断“这是一个 tap”还是“这是一个 pan”,要靠位移阈值和时间阈值去卡,阈值定小了误触多,定大了手势不灵敏。我见过不少同事自己封装手势工具,前几周能用,一到复杂页面就冒出一堆边界问题。
另一个痛点是交互联动。真实场景里手势不是单独存在的,页面里可能同时有横向轮播、纵向滚动、双击点赞、双指缩放。原生事件层面这些东西互相挤占,你会发现上下滑动页面时横向轮播也被触发,或者按住图片想放大,页面却先滚动走了。这些问题单靠手势库还不够,还得有一套“手势竞争与优先级”机制来协调。
1.2 Hammer.js 的核心定位和优势
Hammer.js 的定位就是解决上面这堆破事。它本质是一个轻量级手势识别库,压缩后体积只有 7KB 左右,不依赖任何框架,原生 JavaScript 就能直接跑。内置的手势识别器覆盖了日常交互里九成场景:tap 单击、doubletap 双击、press 长按、pan 拖拽、swipe 滑动、pinch 双指缩放、rotate 双指旋转。
相比自己造轮子,它最大的价值是那套“识别器 + 管理器”架构。每个手势是独立识别器,管理器统一调度并处理识别器之间的竞争关系。两三个手势同时监听时,谁先满足条件谁激活,输掉的一方自动失败,开发者不用在回调里做大量互斥判断。这一点在真实项目里极其省心。另外它对输入源处理得比较全面,触摸屏、鼠标、触摸笔都能识别,桌面端预览时不至于完全不可用。
我实际用下来的感受是,Hammer.js 更像一个“基础手势层”,它不限制你的业务动画怎么做,只负责把“这个动作是什么手势、力度多大、方向多快”判断清楚,剩下的交互效果可以自由发挥。这种“识别与渲染解耦”的设计,恰好适合从简单点击到复杂图片查看器这类跨度很大的项目。
2. 手势识别的底层原理:拆开揉碎讲清楚
2.1 触摸事件的生命周期
想用好 Hammer.js,先得理解它背后靠什么工作。触摸事件的基本生命周期是 touchstart(手指按下)→ touchmove(手指移动,会高频触发多次)→ touchend(手指抬起),中间还可能有 touchcancel(被来电、系统手势打断)。注意 touchend 触发后,事件里的 touches 数组会被清空,要拿当前手指位置得从 changedTouches 里取。这个坑在原生开发里特别常见,很多人第一次写拖拽都栽在这儿。
Hammer.js 在内部把这一串事件转换成了统一的数据结构,包含位置点列表、事件类型、时间戳、触点数量等,再喂给专门的识别器。这个过程叫“输入抽象层”,它同时兼容 Touch Events 和 Pointer Events,也兼顾鼠标输入。所以你不需要关心用户用的是手指还是鼠标,它已经把差异消化掉了。
2.2 识别器的工作机制
Hammer.js 里最重要的概念是“管理器 Manager”和“识别器 Recognizer”。管理器负责创建一个手势会话,接收输入层的事件数据,然后逐个通知注册好的识别器做判定。每个识别器内部维护一个状态机,常见状态是 possible(可能)、began(开始)、changed(变化)、ended(结束)、cancelled(取消)。
拿 pan 拖拽来举例:touchstart 时识别器进入 possible,记录起始点;touchmove 时如果累计位移超过了 threshold(默认 10px),识别器状态变成 began,开始派发 panstart;之后每次 touchmove 进入 changed,派发 panmove;touchend 进入 ended,派发 panend。tap 的判定逻辑则完全相反:它要求从按下到抬起之间,手的位移始终不超过 threshold,并且在规定的时间间隔内(默认 300ms)完成,否则直接失败。这就解释了为什么把 tap 和 pan 放在一起监听时,页面不会“拖一下弹一个点击”,因为位移一旦超过 tap 的阈值,tap 识别器就抢先把自己标记为失败,后续由 pan 接管。
2.3 手势竞争:谁先触发谁赢
多个识别器在同一元素上监听时,管理器会并行跑这些识别器,但不会让它们同时派发冲突事件。Hammer.js 提供两个核心 API 来管理这种竞争关系:recognizeWith 和 requireFailure。recognizeWith 表示“这两个识别器可以同时识别,互不打断”,典型就是 pinch 和 rotate,双指缩放时允许同时上报旋转角度。requireFailure 表示“只有当对方识别失败时,我才有机会成功”,典型就是 tap 对 pan,只有确认不是拖拽时,tap 才算成立。
理解这层机制之后,你再面对复杂手势就心里有底了。比如图片查看器里我要同时支持双指缩放、双指旋转、单指平移,真正常见的配置是 pinch.recognizeWith(rotate),这样两个手势互不干扰;而 pan 和 pinch 之间通常不加 recognizeWith,因为单指拖图片和双指缩放是不同操作,优先让 pinch 识别成功并取消 pan 更符合直觉。
2.4 常用手势的核心参数
每个手势识别器都有一组可调参数,这是 Hammer.js 的精华所在。下面是我常用的默认值和调参经验:
| 手势 | 核心参数 | 默认值 | 说明 |
|---|---|---|---|
| tap / doubletap | threshold | 10px | 手指允许的最大位移 |
| tap / doubletap | interval | 300ms | 从按下到抬起允许的时长 |
| pan | threshold | 10px | 触发拖拽需要的最小累计位移 |
| pan | direction | 全部方向 | 可限定只响应水平或垂直 |
| swipe | velocity | 0.3 | 峰值速度高于此值才判定为快速滑动 |
| swipe | threshold | 10px | 滑动至少要移动的距离 |
| pinch | threshold | 0 | 双指距离变化比例 |
| rotate | threshold | 0 | 双指连线旋转角度变化 |
| press | time | 500ms | 按住不动多长时间触发 |
注意 pinch 和 rotate 的 threshold 默认是 0,意思是双指一动就算,这在实际使用时往往会误触。我给图片查看器配参数时,通常会把 pinch 的 threshold 调到 0.1 左右,rotate 的 threshold 调到 5 度左右,让用户手抖一点不至于旋转。这种细微的调参差异,是做“手感好”和“手感差”的分水岭。
3. 实操:把 Hammer.js 接入项目,处理真实交互场景
3.1 快速集成
Hammer.js 引入方式很灵活。项目里能用 npm 装就推荐 npm 方式:
npm install hammerjs然后在需要用的文件里引入:
import Hammer from 'hammerjs';不想引入构建工具的话,直接用 CDN 脚本也可以,它会把全局的 Hammer 挂到 window 上。基础用法很简单,第一步创建管理器,第二步添加识别器,第三步监听事件:
const element = document.getElementById('viewer'); const mc = new Hammer.Manager(element); mc.add(new Hammer.Swipe({ direction: Hammer.DIRECTION_HORIZONTAL })); mc.on('swipeleft', (ev) => { // 切到下一张 nextSlide(); }); mc.on('swiperight', () => { // 切到上一张 prevSlide(); });这里有个细节:如果你直接new Hammer(element),它内部会自动注册一套默认识别器,包含 pan、pinch、press、rotate、swipe、tap。而用new Hammer.Manager(element)创建的是一个空白管理器,所有识别器都需要自己添加。两种方式没有绝对的谁好谁坏,只是前者适合快速接入,后者适合精细控制识别器之间的顺序和优先级。
3.2 场景 A:图片查看器的单手滑动和双指缩放
图片查看器是能体现 Hammer.js 价值的最典型场景。需求通常包含三块:单指左右滑动切换图片、双指捏合缩放、双指旋转。我直接贴一套可用配置:
const viewer = new Hammer.Manager(el, { touchAction: 'none', }); const pinch = new Hammer.Pinch({ threshold: 0.1 }); const rotate = new Hammer.Rotate({ threshold: 5 }); const pan = new Hammer.Pan({ direction: Hammer.DIRECTION_ALL, threshold: 0, }); viewer.add(pinch); viewer.add(rotate); viewer.add(pan); // 缩放和旋转允许并行识别 pinch.recognizeWith(rotate); rotate.recognizeWith(pinch); viewer.on('pinchmove', (ev) => { // ev.scale 是相对手势开始时的缩放比例 updateScale(startScale * ev.scale); }); viewer.on('rotatemove', (ev) => { // ev.rotation 是相对手势开始时的旋转角度 updateRotate(startRotate + ev.rotation); });这里最重要的配置是 touchAction: 'none'。移动端浏览器在双指操作时会默认处理页面缩放、滚动等行为,如果不关闭这些内置行为,你的 pinch 手势会被浏览器半路抢走。touchAction: 'none'相当于告诉浏览器“这块区域的手势全部交给我”,代价是页面本身的缩放和滚动需要你自己接管。在图片查看器场景下这个取舍是合理的,因为图片通常需要固定在可视区域内做变换。
另一个容易出错的地方是 startScale 和 startRotate。ev.scale 与 ev.rotation 是相对于本次手势“刚开始识别”那一刻的值,并不是相对上一帧。如果你想实现图片跟随手指缩放,就得在 pinchstart 时记录当前图片的缩放值,再乘以 ev.scale;如果直接每次都用 ev.scale 赋值,会出现一松手再捏,图片猛地跳回初始大小的诡异现象。
3.3 场景 B:侧边抽屉菜单的拖拽控制
抽屉类组件是 pan 手势的主场。需求一般是:手指在遮罩或边缘区向左/右拖动,抽屉跟随移动,松手后根据位移和速度决定打开还是关闭。实现思路不复杂,但方向限制必须做对。
const drawer = new Hammer.Manager(el, { touchAction: 'pan-y', }); const pan = new Hammer.Pan({ direction: Hammer.DIRECTION_HORIZONTAL, threshold: 0, }); drawer.add(pan); drawer.on('panstart', () => { // 记录当前抽屉位移 currentOffset = getDrawerOffset(); disableTransition(); }); drawer.on('panmove', (ev) => { // ev.deltaX 是手势期间的累计水平位移 const target = clamp(currentOffset + ev.deltaX, 0, maxWidth); setDrawerOffset(target); }); drawer.on('panend', (ev) => { enableTransition(); const shouldOpen = ev.velocityX > 0.5 || (ev.deltaX > threshold && Math.abs(ev.deltaX) > Math.abs(ev.deltaY)); if (shouldOpen) openDrawer(); else closeDrawer(); });touchAction: 'pan-y' 这个值值得多说一句。它的意思是“垂直方向的默认滚动行为交还给浏览器,水平方向由我处理”。业务场景里,抽屉往往嵌在一个可以上下滚动的页面中,如果设置成 touchAction: 'none',用户上下滑动页面会失效,轻则体验割裂,重则直接被产品经理打回。用 pan-y 之后,垂直滚动和水平拖拽各司其职,这是我在真实项目里最常用的配置。
3.4 场景 C:事件委托与动态元素处理
Hammer.js 的 API 是“实例绑定元素”模式,它没有像 jQuery 那种基于事件委托的全局监听。遇到一个列表里动态生成多个可手势操作项,我习惯用一个小函数统一管理:
function bindSwipeToItems(container, onSwipe) { const hammers = []; const applyToItem = (item) => { const mc = new Hammer.Manager(item); mc.add(new Hammer.Swipe({ direction: Hammer.DIRECTION_HORIZONTAL })); mc.on('swipeleft', () => onSwipe(item, 'left')); mc.on('swiperight', () => onSwipe(item, 'right')); hammers.push({ item, mc }); }; // 监听容器内新增子元素 const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) applyToItem(node); }); }); }); container.querySelectorAll('[data-swipe]').forEach(applyToItem); observer.observe(container, { childList: true }); return () => { observer.disconnect(); hammers.forEach(({ mc }) => mc.destroy()); }; }这里必须强调内存释放。Hammer.js 实例会在元素上注册若干事件监听,页面路由切换或组件销毁时如果不调用实例的 destroy() 方法,轻则内存泄漏,重则元素被复用后手势事件重复触发。我在 React 项目里还见过更隐蔽的坑:组件卸载后异步回调又触发手势事件,导致 setState 一个已卸载组件。所以不管用什么框架,统一管理 Hammer 实例生命周期都是必须做的事。
3.5 React 和 Vue 中的集成方式
React 里集成 Hammer.js 不算复杂,核心是把实例生命周期和组件生命周期对齐。我用 useRef 存 DOM 元素,useEffect 里创建和销毁实例:
import { useRef, useEffect } from 'react'; import Hammer from 'hammerjs'; function SwipeViewer({ onPrev, onNext }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; const mc = new Hammer.Manager(el); mc.add(new Hammer.Swipe({ direction: Hammer.DIRECTION_HORIZONTAL })); mc.on('swipeleft', onPrev); mc.on('swiperight', onNext); return () => mc.destroy(); }, [onPrev, onNext]); return <div ref={ref} className="viewer" />; }Vue 的思路基本一致,可以在 mounted 里创建实例、beforeUnmount 里销毁,或者封装成自定义指令。关键在于别把 Hammer 实例放到响应式数据里,否则每次状态更新都可能让 Vue 的代理机制干扰原生对象,出现莫名其妙的手势失效问题。实例这类非 UI 状态,老老实实放在普通变量或 ref 里管理就好。
4. 踩坑实录:这些问题几乎每个项目都会遇到
4.1 手势和页面滚动打架
这是问得最多的问题。现象很典型:页面是上下滚动的长列表,里面有个横向轮播,手指在轮播区域左右滑动时,页面却开始上下滚;或者反过来,垂直滑动时轮播图也跟着切。解决这个问题,靠的是三个层面的组合拳。
第一层是 Hammer 的 direction 限制。new Hammer.Pan({ direction: Hammer.DIRECTION_HORIZONTAL })之后,识别器对垂直位移不敏感。第二层是 CSS touch-action。像前面说的,touch-action: pan-y可以让浏览器接管垂直滚动,Hammer 只处理水平手势。第三层才是手势回调里的兜底判断:在 panmove 回调里判断如果 abs(ev.deltaY) > abs(ev.deltaX) 且 abs(ev.deltaY) 已超过阈值,就主动取消这次 pan 的后续逻辑。
我自己的经验是永远不要只依赖第一层。touch-action 是声明式方案,浏览器支持度高(iOS 13 以上、现代 Chrome 和 Safari 都可以),但它表达的是“允许/禁止哪些默认行为”,不能完全替代手势方向判断。最稳的写法是 direction 限定加 touch-action 限定再加一次回调内方向校验,三层都做了,基本告别手势乱串。
4.2 tap 触发后页面 300ms 延迟
老移动端浏览器里,点击后要等 300ms 才触发 click,原因是浏览器要判断你是不是想双击缩放。Hammer.js 的 tap 事件不会受这个延迟影响,但如果你在 tap 回调里又绑定了原生的 click 监听,就会出现“点一下,动两次”的情况。我的建议很简单:既然用了 Hammer 的 tap,就彻底放弃同一元素上的 click 逻辑,避免重复触发。
另一个相关问题是 tap 事件里的坐标获取。Hammer 的 tap 回调里可以通过 ev.center.x 和 ev.center.y 拿到手指位置,但注意这个位置是基于视口的坐标。如果你要定位到某个容器内部,得自己根据容器位置做一次换算。很多新手在移动端埋点或弹菜单时拿到坐标直接用,结果弹层位置偏出去一个header高度,就是这个换算没做。
4.3 双指手势的稳定性问题
双指缩放和旋转看起来高大上,写起来却有不少暗坑。第一个坑是手指抬起时序。用户快速松开一个手指时,pinch 和 rotate 的手势数据经常会跳变,因为 Hammer 内部维护的指针列表在触点从 2 变 1 的时候会出现一帧不稳定的插值数据。我的处理办法是监听 pinchmove / rotatemove 时检查 ev.pointers.length,小于 2 就直接忽略这一帧,不让它更新图片变换状态。
第二个坑是事件方向。ev.scale 的初始值是 1,手指向外分是变大,向内是变小;ev.rotation 初始值是 0,顺时针为正,逆时针为负。但如果你同时做了 pan 和 pinch,一定要注意手势数据里的相对计算。pan 的 deltaX/deltaY 是相对识别开始时累计的,pinch 的 scale 也是相对识别开始时的比例。两套“相对值”混着用时,最容易把图片位移和缩放关系算错。
4.4 passive 事件监听警告
Chrome 控制台里那句 “Unable to preventDefault inside passive event listener” 我见过太多次了。原因是新版浏览器默认把 touchstart、touchmove 等事件当作 passive 处理,也就意味着监听器里调用 preventDefault() 会被忽略。Hammer.js 在部分版本里确实需要在 touch 事件上调用 preventDefault 来阻止浏览器默认行为,这就会触发警告。
解决思路分两步:一是升级 Hammer.js 到 2.0.8 以上,这个版本对事件监听的处理方式已经有调整;二是配合 CSS touch-action 使用,声明式地告诉浏览器“哪些默认行为允许”,这样能减少对 preventDefault 的依赖。如果项目里确实需要最极端的手势控制(比如做一个自定义地图),那就得自己手动给 touch 事件绑定 passive: false 的监听器了。注意这样会影响滚动性能,能不用尽量不用。
4.5 桌面端模拟器与真机的差异
Chrome DevTools 的设备模拟模式可以模拟触摸事件,但它本质上是把鼠标事件伪装成触摸事件,和真机的手指操作还是有差距。最明显的差异是双指手势,Chrome 里很难模拟真实的多指接触点变化,Hammer 的 pinch 和 rotate 识别往往不太自然。我的建议是开发阶段用模拟器验证逻辑,手感调优一定要上真机或者用支持多点触控的设备去实测。实测时也不要只看正常路径,刻意快速操作、用指甲、手指出汗、贴膜后的灵敏度差异,都可能暴露问题。
5. 参数调优、进阶组合与性能优化心得
5.1 常用参数调优经验
Hammer.js 的优点是可调项多,但这也是新手最容易迷茫的地方。我的经验是按“场景手感”去调,而不是看着文档发呆。做一个轮播时,swipe 的 velocity 默认 0.3 比较合适,偏低会让手指轻轻一滑就切页,偏高会让用户快速甩动也切不过去。做列表删除时,swipe 的 threshold 可以适当提高到 30~50px,避免“只想点击却带着轻微滑动”误触发删除操作。
tap 和 doubletap 的调参也有讲究。默认 doubletap 的 interval 是 300ms,快速点击两次相邻的位置才能识别成功。如果你调小了,用户手速一般达不到;调大了,又很容易把两次单独的 tap 合并成 doubletap。一个比较稳的组合是 threshold 15px、interval 350ms,在多数设备上误触率和成功率比较均衡。
5.2 识别器组合:做出自己的复合手势
内置识别器满足基础场景,但有些业务需求需要组合。最常用的组合是 tap + pan + press 同时存在:短点触发 tap,拖动触发 pan,按住不动触发 press。这种组合在 Hammer.js 里默认就能工作,识别器之间不会互相踩踏,原因是 tap 要求位移小、按着不动时 press 靠时间判定,两者天然不冲突。
如果业务里需要“双击拖拽”这种较复杂的复合操作,可以用 requireFailure 去约束顺序。比如双击拖拽场景,先识别成功一次 tap,紧接着第二次 tap 时进入拖拽模式,这种逻辑内置识别器做不到,需要自定义识别器。Hammer.js 允许继承 Recognizer 基类,实现 getRequiredPointers 和 recognize 方法。不过说实话,日常业务里九成场景用内置识别器相互组合就够了,真正需要写自定义识别器的时候,通常说明交互设计已经复杂到需要重新审视的地步了。
5.3 挂载阶段与性能优化
手势事件的回调频率可以非常高,尤其是 panmove 和 pinchmove,一秒钟可能触发几十次甚至上百次。如果你在回调里直接操作大量 DOM 或者跑重逻辑,页面会明显卡顿。正确的做法是把手势产生的结果存到一个变量或 ref 里,然后用 requestAnimationFrame 统一渲染:
let targetScale = 1; let renderedScale = 1; viewer.on('pinchmove', (ev) => { targetScale = startScale * ev.scale; }); function renderLoop() { if (Math.abs(targetScale - renderedScale) > 0.001) { renderedScale += (targetScale - renderedScale) * 0.35; image.style.transform = `scale(${renderedScale})`; } requestAnimationFrame(renderLoop); } renderLoop();这本质上是用一个动画循环去消费手势数据,把高频事件变成了高频的数据更新,但渲染节奏由动画帧驱动。图片这类经常变形的元素,优先使用 transform 而不是修改 left/top 或 width/height,因为 transform 走的是合成器线程,不会触发重新布局。另外,不管手势多么流畅,都要记得在处理完之后把不需要的事件监听移除,比如组件卸载时调用 mc.destroy(),避免后台页面挂着手势监听白白消耗性能。
还有一个小经验是“识别器数量按需添加”。如果你只是做一个点击播放按钮的小需求,new Hammer(element)默认注册了 six 个识别器,其中有几个完全用不到。此时用 Manager 按需添加会比直接 Hammer(element) 更轻量,识别过程省掉几次无效判断。别小看这点开销,在列表页里每个 item 都创建一个实例时,积少成多带来的性能差异会很明显。
最后说点实在的。我个人是把 Hammer.js 当移动端交互动效的“地基”来用的,tap、swipe、pinch 这些判断交给它之后,剩余精力就能放到动画细节和业务逻辑上。如果项目只想要一个简单轮播,用不着把 Hammer.js 请进来,直接 CSS scroll snap 或者自己写几十行 touch 逻辑就够了;但一旦交互复杂到要识别组合手势、处理多指、协调拖拽与滚动,Hammer.js 这套识别器架构能帮你省掉大量重构成本。我真心建议你把一两个手势从原生 touch 事件改写一遍,再对比 Hammer.js 的代码,你会立刻理解它为什么值得引入——不是因为它替你写了手势判定的几十行代码,而是因为它的架构让“识别手势”这件事真正变成了可配置、可扩展、可维护的模块。