深入解析交互时间优化的核心技术与实战经验

码农甜茜 前端 阅读 569
赞 26 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个需求,要做一个移动端的滑动卡片交互,用户上下滑动可以快速切换内容,类似 Tinder 那种。产品经理说“要丝滑”,设计师给了个 Figma 文件,里面全是过渡动画和手势反馈。我一开始没太当回事,心想不就是监听 touch 事件嘛,结果后面折腾了快一周。

深入解析交互时间优化的核心技术与实战经验

选型的时候,我其实犹豫过要不要用现成的库,比如 Swiper 或者 Hammer.js。但项目里已经有好几个第三方库了,再加一个怕 bundle 太大。而且这次交互逻辑比较定制化——不仅要滑动,还要在滑动过程中实时计算偏移量、触发背景色渐变、控制按钮显隐。最后决定自己手写,核心就靠 touchstarttouchmovetouchend 三个事件搞定。

又踩坑了,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 做懒加载联动,后续会继续分享这类博客。希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论