滑动距离计算与优化:前端交互体验的关键细节
优化前:卡得不行
上周上线一个移动端商品列表页,用户反馈“一滑就卡”“手指动了页面半天不动”。我本地测试也确实离谱——低端机上滑动直接掉到10帧以下,高端机勉强能看但也不流畅。查了下代码,发现每次 touchmove 都在疯狂计算滑动距离、触发重排重绘,还夹杂着一堆没节流的 setState。说实话,当时第一反应是:“这谁写的?哦……是我自己两周前赶需求写的。”
问题核心其实很简单:监听 touchmove 事件后,每移动一个像素都执行一次逻辑,包括获取 clientY、算差值、更新状态、触发动画。而 touchmove 在快速滑动时每秒能触发上百次,浏览器根本扛不住。
找到瓶颈了!
先别急着改代码,得知道到底哪慢。我打开 Chrome DevTools 的 Performance 面板(真机用 Safari Remote Debug 也行),录了一段滑动操作。结果一目了然:
- 大量 Function Call 堆叠在主线程,全是 handleTouchMove
- 频繁触发 Layout 和 Paint,每次耗时 30ms+,帧率直接崩
- JS 执行时间占比超过 70%,典型的“过度计算”
再点进去看,发现每次 touchmove 都在做这些事:
- 读取 event.touches[0].clientY
- 和上一次位置比较,算 delta
- 更新 React 状态(触发 re-render)
- 根据新状态设置元素 transform
问题就出在这儿——第 3 步和第 4 步完全没必要在 touchmove 里同步做。尤其是 setState,在 React 里会触发整棵子树的 diff,成本太高。
试了几种方案,最后这个效果最好
我折腾了三种思路:
- 方案一:requestAnimationFrame + 节流 —— 把计算丢进 rAF,但发现还是不够快,因为状态更新依然频繁
- 方案二:CSS scroll-snap + pointer-events —— 想绕过 JS,但业务要的是自定义滑动行为(比如吸顶、联动动画),不适用
- 方案三:分离“采集”和“应用” —— 这个成了
核心思想就一句:touchmove 只负责记录原始数据,渲染逻辑交给独立的动画循环。这样主线程压力骤减。
具体做法分两步:
- touchmove 里只存当前触摸坐标,不做任何计算或状态更新
- 用 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 做懒加载联动),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流!

暂无评论