用Hammer.js打造流畅的移动端手势交互体验
优化前:卡得不行
项目里用 Hammer.js 做了个横向滑动的卡片流,本以为几行代码的事儿,结果上线后用户反馈“一滑就卡”“页面直接不动了”。我自己拿 Android 低端机试了下,touchmove 一顿一顿的,60fps 直接掉到 20 多,滚动还延迟半秒。这哪是用户体验,这是考验用户耐心。
之前也没太当回事,毕竟 Hammer.js 看着挺成熟的,文档也全。但这次的问题明显不是业务逻辑复杂导致的,就是交互本身拖垮了主线程。尤其是手指还在滑,页面已经没反应了,松手后惯性动画还得再抽搐两下才结束 —— 谁用谁知道有多难受。
找到瘼颈了!
我先上 Chrome DevTools 的 Performance 面板录了一段操作,一看吓一跳:每一帧 touchmove 回调里都在跑一堆 DOM 查询 + classList 切换 + remeasure(强制重排)。而且 Hammer 的 recognizer 每次都重新计算位移,加上我们自己写的 translateX 动画没走 transform,直接改 left,这就完蛋了。
另外还发现一个问题:事件绑定没做节流,hammer 实例绑在了一个包含上百个子元素的容器上,每次 touchmove 触发时,内部又在做 getBoundingClientRect() 计算可视区域,CPU 占用直接飙到 90% 以上。这不是 Hammer 的锅,是我们用错了。
我还试了用 requestIdleCallback 包回调,结果更卡 —— 交互这种实时性要求高的场景,你越想“省资源”,它就越不流畅。最后还是得回到“减少每帧工作量 + 使用硬件加速”这条路。
核心优化:别在 touchmove 里干重活
最大的坑在于我们最初写法是在 panmove 事件里动态计算每个 item 的 opacity、scale,然后 setStyle。看起来很酷,实则每毫秒都在触发 layout thrashing。
优化思路很简单:把视觉反馈从 JS 计算 → CSS 控制,用 transform + transition 去驱动动画,JS 只负责告诉它“滑了多少”,而不是“怎么滑”。
下面是优化前的烂代码:
const hammertime = new Hammer(cardContainer);
hammertime.on('panmove', (ev) => {
const deltaX = ev.deltaX;
// 每次都遍历所有卡片,算位置和透明度
cards.forEach((card, index) => {
const dist = Math.abs(deltaX - index * cardWidth);
const opacity = Math.max(0, 1 - dist / 300);
const scale = Math.max(0.8, 1 - dist / 600);
// 直接操作 style,触发重绘
card.style.opacity = opacity;
card.style.transform = scale(${scale});
});
// 主轴移动用 left,不走 GPU
cardContainer.style.left = ${deltaX}px;
});
这代码在 iPhone 上还能勉强跑,在红米 Note 上滑两下浏览器就提示“网页未响应”。
优化后的版本:
// 使用 transform 而非 left
const hammertime = new Hammer(cardContainer);
// 提前给 container 加 transition: none,避免意外动画
cardContainer.style.transition = 'none';
hammertime.on('panstart', () => {
cardContainer.style.transition = 'none'; // 确保滑动过程无过渡
});
hammertime.on('panmove', (ev) => {
const deltaX = ev.deltaX;
// 只更新 transform
cardContainer.style.transform = translate3d(${deltaX}px, 0, 0);
// 关键:这里不再逐个更新卡片样式
// 改为通过添加 class 来触发预设的视差效果(CSS 驱动)
updateParallaxClasses(deltaX); // 这个函数做了节流处理
});
hammertime.on('panend', (ev) => {
const velocity = ev.velocityX;
const currentX = getCurrentTranslateX();
// 惯性滑动交给 CSS transition 处理
cardContainer.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
const finalX = currentX + velocity * 150; // 简单惯性计算
cardContainer.style.transform = translate3d(${finalX}px, 0, 0);
});
看到没?关键点有三个:
- 用 translate3d 替代 left/top,让动画进 GPU
- 去掉 panmove 中的所有 DOM 查询和复杂计算,只做单一 transform 更新
- 视觉反馈拆出去,加节流,比如视差效果可以 100ms 更新一次
再说说 updateParallaxClasses 怎么写的:
let throttleTimer = null;
function updateParallaxClasses(deltaX) {
if (throttleTimer) return;
throttleTimer = setTimeout(() => {
cards.forEach((card, index) => {
const cardCenter = index * cardWidth + cardWidth / 2;
const distance = Math.abs(cardCenter - deltaX);
const level = Math.min(Math.floor(distance / 100), 3);
card.className = card parallax-level-${level};
});
throttleTimer = null;
}, 16); // ~60fps 最大频率
}
这样既保留了视觉层次感,又不会每毫秒都刷 DOM。亲测从原来每帧 40ms+ 降到稳定 8ms 以内。
其他小修小补也得搞
除了主流程,还有几个细节优化:
- 给 Hammer 加 direction 限制:
hammer.get('pan').set({ direction: Hammer.DIRECTION_HORIZONTAL }),防止误触 vertical scroll 干扰 - 容器加
touch-action: pan-y,允许原生纵向滚动不受影响 - 在 PC 测试时记得关闭模拟触摸,否则 pointermove 频率过高也会卡
还有一个我踩过的坑:多个 Hammer 实例嵌套绑定会导致事件冒泡冲突。比如外层轮播用了 Hammer,里面卡片又绑了一层,结果滑动时两个都响应。解决方案是统一管理实例,或者用 ev.preventDefault() 控制传播。
优化后:流畅多了
改完之后再跑性能面板,FPS 稳定在 55~60,touchmove 回调时间从平均 35ms 降到 2~3ms。内存占用也下来了,GC 不再频繁触发。
真实数据对比:
- 滚动平均帧耗时:从 38ms → 3.2ms
- 主线程阻塞时间(5s 操作):从 1.8s → 0.24s
- 页面卡死概率(低端机):从 70% → 基本消失
用户反馈也变正向了,有人说“终于能一口气滑完了”。虽然还没到丝滑的程度(毕竟卡片太多),但至少不让人想卸载应用了。
这里注意我踩过好几次坑
有几个坑我反复踩:
- 忘了清
transition: none,导致 panend 后惯性回弹失败 - 节流时间设成 50ms,结果视差效果“跳帧”,后来改成 16ms 对齐帧率
- 在 panmove 里调用了
getComputedStyle,无意中触发 layout,这个一定要避免
还有一个建议:如果只是简单 swipe,其实不用 Hammer,直接用 touchstart/touchmove/touchend 更轻量。Hammer 适合复杂手势组合,比如双击+旋转+缩放同时存在的情况。
性能数据对比
下面是同一台测试机(Redmi 10A)上的两次采样对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均帧渲染时间 | 38ms | 3.2ms |
| JS 脚本执行时间/5s | 1840ms | 240ms |
| 长任务次数(>50ms) | 7 次 | 0 次 |
| FPS 稳定性 | 波动剧烈,常掉至 20 以下 | 基本维持在 55~60 |
说实话,改完也不是完美。比如快速来回滑时,惯性计算还是有点生硬,但已经不影响正常使用了。这种交互没必要追求物理引擎级别的精度,够用就行。
以上是我踩坑后的总结,希望对你有帮助
这个优化折腾了我整整两天,中间还怀疑过是不是该换别的库。最后发现真不是 Hammer 的问题,是我一开始就没按高性能交互的方式来写。
核心就一句话:**别让 JS 承担渲染职责,让它只做状态同步**。一旦你在 touchmove 里开始操作 opacity、left、width 这些会触发布局的属性,你就已经输了。
以上是我个人对 Hammer.js 性能优化的完整实践,有更优的实现方式欢迎评论区交流。后续还会分享一些基于 Intersection Observer 替代实时计算可视区域的技巧,也是提升滚动性能的大招。

暂无评论