滑动距离计算与优化:前端交互体验的关键细节

闲人东慧 交互 阅读 2,411
赞 19 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线一个移动端商品列表页,用户反馈“一滑就卡”“手指动了页面半天不动”。我本地测试也确实离谱——低端机上滑动直接掉到10帧以下,高端机勉强能看但也不流畅。查了下代码,发现每次 touchmove 都在疯狂计算滑动距离、触发重排重绘,还夹杂着一堆没节流的 setState。说实话,当时第一反应是:“这谁写的?哦……是我自己两周前赶需求写的。”

滑动距离计算与优化:前端交互体验的关键细节

问题核心其实很简单:监听 touchmove 事件后,每移动一个像素都执行一次逻辑,包括获取 clientY、算差值、更新状态、触发动画。而 touchmove 在快速滑动时每秒能触发上百次,浏览器根本扛不住。

找到瓶颈了!

先别急着改代码,得知道到底哪慢。我打开 Chrome DevTools 的 Performance 面板(真机用 Safari Remote Debug 也行),录了一段滑动操作。结果一目了然:

  • 大量 Function Call 堆叠在主线程,全是 handleTouchMove
  • 频繁触发 Layout 和 Paint,每次耗时 30ms+,帧率直接崩
  • JS 执行时间占比超过 70%,典型的“过度计算”

再点进去看,发现每次 touchmove 都在做这些事:

  1. 读取 event.touches[0].clientY
  2. 和上一次位置比较,算 delta
  3. 更新 React 状态(触发 re-render)
  4. 根据新状态设置元素 transform

问题就出在这儿——第 3 步和第 4 步完全没必要在 touchmove 里同步做。尤其是 setState,在 React 里会触发整棵子树的 diff,成本太高。

试了几种方案,最后这个效果最好

我折腾了三种思路:

  • 方案一:requestAnimationFrame + 节流 —— 把计算丢进 rAF,但发现还是不够快,因为状态更新依然频繁
  • 方案二:CSS scroll-snap + pointer-events —— 想绕过 JS,但业务要的是自定义滑动行为(比如吸顶、联动动画),不适用
  • 方案三:分离“采集”和“应用” —— 这个成了

核心思想就一句:touchmove 只负责记录原始数据,渲染逻辑交给独立的动画循环。这样主线程压力骤减。

具体做法分两步:

  1. touchmove 里只存当前触摸坐标,不做任何计算或状态更新
  2. 用 requestAnimationFrame 启动一个独立的 render loop,从缓存里读最新坐标,计算位移并应用到 DOM

关键在于:render loop 是按屏幕刷新率跑的(60fps ≈ 16ms/帧),而不是按 touchmove 触发频率(可能 1ms/次)。这样就把上百次无效计算压缩成 16ms 内的一次有效计算。

下面是优化前后的代码对比:

优化前(反面教材)

function BadSlider() {
  const [position, setPosition] = useState(0);
  const startYRef = useRef(0);

  const handleTouchStart = (e) => {
    startYRef.current = e.touches[0].clientY;
  };

  const handleTouchMove = (e) => {
    const currentY = e.touches[0].clientY;
    const deltaY = currentY - startYRef.current;
    // 直接 setState!灾难!
    setPosition(deltaY);
  };

  return (
    <div
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      style={{ transform: translateY(${position}px) }}
    >
      内容...
    </div>
  );
}

优化后(亲测有效)

function OptimizedSlider() {
  const currentPositionRef = useRef(0); // 实际渲染位置
  const targetPositionRef = useRef(0);  // touchmove 记录的目标位置
  const animationRef = useRef(null);

  // 只记录,不更新状态
  const handleTouchMove = (e) => {
    const currentY = e.touches[0].clientY;
    const startY = /* 从 ref 获取起始 Y */;
    targetPositionRef.current = currentY - startY;
  };

  // 独立的渲染循环
  const animate = () => {
    // 简单插值让动画更平滑(可选)
    currentPositionRef.current += (targetPositionRef.current - currentPositionRef.current) * 0.1;
    
    // 直接操作 DOM,绕过 React
    sliderRef.current.style.transform = translateY(${currentPositionRef.current}px);
    
    animationRef.current = requestAnimationFrame(animate);
  };

  // 在 useEffect 里启动/停止动画
  useEffect(() => {
    animationRef.current = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(animationRef.current);
  }, []);

  return (
    <div
      ref={sliderRef}
      onTouchStart={/* 记录 startY */}
      onTouchMove={handleTouchMove}
    >
      内容...
    </div>
  );
}

这里注意我踩过好几次坑:

  • 不要用 setState 控制 transform:React 的批处理和调度机制在这里是累赘,直接操作 style 更快
  • 记得清理 rAF:组件卸载时 cancelAnimationFrame,否则内存泄漏
  • 起始坐标要缓存:别在 touchMove 里重新算 startY,提前在 touchStart 存到 ref

性能数据对比

在 Redmi Note 9(低端机)上实测:

指标 优化前 优化后
平均帧率 12 FPS 58 FPS
touchmove 处理耗时 平均 45ms/次 平均 0.3ms/次
主线程占用率 85% 22%

高端机(iPhone 14)上几乎看不出卡顿了,低端机也从“幻灯片”变成“基本流畅”。虽然偶尔快速滑动会有轻微掉帧,但完全在可接受范围——毕竟不是所有场景都需要 60FPS 完美丝滑。

另外,加载时间其实没变(这本来也不是加载问题),但交互响应延迟从 300ms+ 降到了 50ms 以内。用户感觉就是“跟手了”。

还有个小细节:别忘了 passive 事件

差点漏了这个!给 touchmove 加上 { passive: true } 能进一步提升滚动性能:

useEffect(() => {
  const slider = sliderRef.current;
  slider.addEventListener('touchmove', handleTouchMove, { passive: true });
  return () => slider.removeEventListener('touchmove', handleTouchMove);
}, []);

这样浏览器就知道你不会调用 preventDefault(),可以提前进行滚动,减少 10-20ms 的延迟。不过要注意:如果你真的需要阻止默认滚动(比如做横向滑动菜单),就不能加 passive。

总结一下

这次优化的核心就两点:

  • 解耦数据采集和渲染:touchmove 只写,rAF 只读
  • 绕过框架直接操作 DOM:对于高频动画,React/Vue 的状态管理反而拖后腿

改完后代码其实更简单了——少了一堆 setState,多了一个干净的动画循环。虽然牺牲了“纯 React”的优雅,但换来了实实在在的流畅度。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如配合 IntersectionObserver 做懒加载联动),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流!

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

暂无评论