深入解析交互时间优化的核心技术与实战经验
项目初期的技术选型
上个月接了个需求,要做一个移动端的滑动卡片交互,用户上下滑动可以快速切换内容,类似 Tinder 那种。产品经理说“要丝滑”,设计师给了个 Figma 文件,里面全是过渡动画和手势反馈。我一开始没太当回事,心想不就是监听 touch 事件嘛,结果后面折腾了快一周。
选型的时候,我其实犹豫过要不要用现成的库,比如 Swiper 或者 Hammer.js。但项目里已经有好几个第三方库了,再加一个怕 bundle 太大。而且这次交互逻辑比较定制化——不仅要滑动,还要在滑动过程中实时计算偏移量、触发背景色渐变、控制按钮显隐。最后决定自己手写,核心就靠 touchstart、touchmove、touchend 三个事件搞定。
又踩坑了,touchmove 滚动失效
写第一版的时候,我直接在卡片容器上绑了 touch 事件:
const card = document.querySelector('.card');
let startY = 0;
let currentY = 0;
card.addEventListener('touchstart', (e) => {
startY = e.touches[0].clientY;
});
card.addEventListener('touchmove', (e) => {
currentY = e.touches[0].clientY;
const diff = currentY - startY;
card.style.transform = translateY(${diff}px);
});
本地测试没问题,但在真机上一滑,整个页面跟着一起滚动!原来是因为浏览器默认的 touchmove 行为会触发页面滚动。我赶紧加了 e.preventDefault(),但问题来了:在 iOS 上,如果用户手指稍微斜一点,或者滑太快,preventDefault 会失效,页面还是会上下跳。
后来查资料发现,得在 touchmove 里加 { passive: false } 才能真正阻止默认行为。这个细节我之前一直忽略,以为只要 preventDefault 就行。改完后:
card.addEventListener('touchmove', (e) => {
e.preventDefault();
currentY = e.touches[0].clientY;
const diff = currentY - startY;
card.style.transform = translateY(${diff}px);
}, { passive: false });
这下稳了,但新问题又来了。
最大的坑:性能问题
卡顿。非常卡。尤其是在低端安卓机上,滑动时 transform 更新跟不上手指,画面一卡一卡的。我一开始以为是频繁操作 DOM 导致的,于是把 style 赋值换成 CSS 变量,甚至试过 requestAnimationFrame 包裹,但效果微乎其微。
折腾了半天,突然想到:是不是因为每次 touchmove 都触发重排?其实不是,transform 是合成属性,不会触发重排。那问题在哪?
后来用 Chrome DevTools 的 Performance 面板录了一次,发现主线程被 JS 占满了。原来我在 touchmove 里除了更新位置,还做了很多别的事:计算偏移比例、判断是否超过阈值、动态修改背景色、控制按钮透明度……这些计算虽然单次很快,但每帧都要跑几十次,累积起来就很吃资源。
解决方案是:把非关键逻辑延迟处理。比如背景色变化,其实不需要每帧都更新,只要在滑动结束或接近阈值时再更新就行。于是我拆了两个状态:一个是“实时拖拽状态”,只负责更新位置;另一个是“交互反馈状态”,用 throttle 控制更新频率。
// 简化后的核心逻辑
let isDragging = false;
let dragOffset = 0;
card.addEventListener('touchstart', (e) => {
isDragging = true;
startY = e.touches[0].clientY;
});
card.addEventListener('touchmove', (e) => {
if (!isDragging) return;
e.preventDefault();
const currentY = e.touches[0].clientY;
dragOffset = currentY - startY;
// 只更新位置,其他逻辑延迟
card.style.transform = translateY(${dragOffset}px);
// 用 requestAnimationFrame 合并视觉更新
requestAnimationFrame(() => {
updateVisualFeedback(dragOffset); // 这个函数内部做了 throttle
});
}, { passive: false });
card.addEventListener('touchend', () => {
isDragging = false;
handleSwipeEnd(dragOffset);
});
这里注意我踩过好几次坑:throttle 不能直接套在 touchmove 里,否则会丢帧。必须用 rAF 把视觉更新和手势解耦。
最终的解决方案
最后整理出一套还算稳定的方案。核心思路是:手势识别和视觉反馈分离,关键路径只做最少的事。
完整代码如下(已脱敏,可直接运行):
class SwipeCard {
constructor(el) {
this.el = el;
this.startY = 0;
this.offsetY = 0;
this.isDragging = false;
this.threshold = 100; // 滑动阈值
this.lastFeedbackTime = 0;
this.bindEvents();
}
bindEvents() {
this.el.addEventListener('touchstart', this.onTouchStart.bind(this));
this.el.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
this.el.addEventListener('touchend', this.onTouchEnd.bind(this));
}
onTouchStart(e) {
this.isDragging = true;
this.startY = e.touches[0].clientY;
this.el.style.transition = 'none';
}
onTouchMove(e) {
if (!this.isDragging) return;
e.preventDefault();
this.offsetY = e.touches[0].clientY - this.startY;
this.el.style.transform = translateY(${this.offsetY}px);
// 视觉反馈节流(每 16ms 最多一次)
const now = Date.now();
if (now - this.lastFeedbackTime > 16) {
this.updateFeedback();
this.lastFeedbackTime = now;
}
}
updateFeedback() {
const ratio = Math.min(1, Math.abs(this.offsetY) / this.threshold);
const bgColor = rgba(0, 0, 0, ${ratio * 0.2});
document.body.style.backgroundColor = bgColor;
// 其他反馈逻辑...
}
onTouchEnd() {
this.isDragging = false;
this.el.style.transition = 'transform 0.3s ease';
if (Math.abs(this.offsetY) > this.threshold) {
this.handleSwipeComplete(this.offsetY > 0 ? 'down' : 'up');
} else {
this.resetPosition();
}
}
resetPosition() {
this.el.style.transform = 'translateY(0)';
document.body.style.backgroundColor = '';
}
handleSwipeComplete(direction) {
console.log('swiped', direction);
// 发送数据、切换内容等
fetch('https://jztheme.com/api/swipe', {
method: 'POST',
body: JSON.stringify({ direction })
});
// 重置
setTimeout(() => this.resetPosition(), 300);
}
}
// 使用
new SwipeCard(document.querySelector('.swipe-card'));
这套代码在 iPhone 12 和中端安卓机上都跑得挺顺,FPS 基本能维持在 50+。关键点在于:
- 用
{ passive: false }确保 preventDefault 生效 - touchmove 里只做 transform 更新,其他逻辑节流
- 滑动结束才触发网络请求或复杂逻辑
回顾与反思
整体效果还行,用户反馈“确实挺顺”。但有几个小问题没彻底解决:在某些三星机型上,如果用户快速连续滑动,偶尔会卡住一帧。我怀疑是 rAF 调度的问题,但优先级不高就没深究。另外,横屏模式下没做适配,不过产品说暂时不用支持。
回头想想,如果时间充裕,或许用 CSS scroll-snap 也能实现类似效果,而且性能更好。但当时需求变动快,手写更灵活,能随时调整阈值和反馈逻辑。所以“最优解”不一定是最合适的,得看项目节奏。
还有个小技巧:测试时一定要用真机,模拟器根本看不出性能问题。我就是在 iPhone 上测完觉得 OK,结果 QA 用千元安卓机一跑,直接卡成 PPT。
以上是我个人对这个交互时间(其实就是手势滑动)的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做懒加载联动,后续会继续分享这类博客。希望对你有帮助。

暂无评论