requestAnimationFrame实战指南:让前端动画更流畅
为什么选 requestAnimationFrame?
上个月接了个需求,要做一个自定义的滚动条组件,支持平滑滚动、惯性回弹、还有滚动过程中的实时监听。一开始我直接用 scroll 事件加 setTimeout 节流,结果在低端安卓机上卡得像 PPT。用户反馈“一滚就掉帧”,老板看演示时眉头都皱起来了。
后来想到之前看过一些高性能动画的资料,决定试试 requestAnimationFrame(简称 rAF)。不是因为它多高级,而是因为浏览器原生支持,而且和屏幕刷新率同步,理论上能避免不必要的重绘。关键是,它不依赖时间间隔,而是靠下一帧的时机触发,这对滚动这种高频操作太友好了。
核心代码就这几行
先说结论:rAF 本身不难,难的是怎么把它和滚动行为结合起来。我最终的方案是:用 touch/mouse 事件收集位移,然后在 rAF 回调里统一计算位置并更新 DOM。这样既能保证流畅,又不会频繁触发 layout。
下面是我精简后的核心逻辑:
class SmoothScroller {
constructor(container) {
this.container = container;
this.isScrolling = false;
this.targetY = 0;
this.currentY = 0;
this.velocity = 0;
this.rafId = null;
this.bindEvents();
}
bindEvents() {
let startY = 0;
let startTop = 0;
const onTouchStart = (e) => {
cancelAnimationFrame(this.rafId);
this.isScrolling = true;
startY = e.touches[0].clientY;
startTop = this.currentY;
this.velocity = 0;
};
const onTouchMove = (e) => {
if (!this.isScrolling) return;
const deltaY = e.touches[0].clientY - startY;
this.targetY = startTop - deltaY;
};
const onTouchEnd = () => {
this.isScrolling = false;
// 这里可以加惯性逻辑,后面再说
this.animate();
};
this.container.addEventListener('touchstart', onTouchStart, { passive: false });
this.container.addEventListener('touchmove', onTouchMove, { passive: false });
this.container.addEventListener('touchend', onTouchEnd);
}
animate() {
if (this.isScrolling) return;
// 简单的插值,让滚动有缓动效果
const diff = this.targetY - this.currentY;
this.currentY += diff * 0.1;
// 更新 DOM
this.container.style.transform = translateY(${this.currentY}px);
// 如果还没到位,继续下一帧
if (Math.abs(diff) > 0.5) {
this.rafId = requestAnimationFrame(() => this.animate());
}
}
}
注意几个细节:passive: false 是必须的,否则 preventDefault 无效;transform 比直接改 scrollTop 更高效,因为不会触发 layout;缓动系数 0.1 是试出来的,太小了拖沓,太大了生硬。
最大的坑:性能问题
本以为这样就完事了,结果在测试时发现一个问题:快速连续滑动时,滚动会“卡住”或者突然跳变。折腾了半天才发现,是因为 onTouchMove 里直接赋值 this.targetY,但 animate 还在跑上一次的动画,两个状态打架了。
更糟的是,在某些机型上,如果手指离开太快,touchend 触发时 velocity 没算准,导致惯性滚动要么飞出去,要么直接停住。我一开始想用 performance.now() 记录时间差来算速度,但发现 touch 事件的频率不稳定,尤其在低端机上,两次 move 的时间间隔可能从 8ms 到 30ms 不等,根本没法准确估算速度。
后来我妥协了:只取最后 3 次 move 的位移做简单平均,忽略时间因素。虽然不精确,但至少不会乱飞。代码大概是这样:
// 在 onTouchMove 里维护一个位移队列
this.moveDeltas = this.moveDeltas || [];
const delta = e.touches[0].clientY - this.lastY;
this.moveDeltas.push(delta);
if (this.moveDeltas.length > 3) this.moveDeltas.shift();
this.lastY = e.touches[0].clientY;
// onTouchEnd 时
const avgDelta = this.moveDeltas.reduce((a, b) => a + b, 0) / this.moveDeltas.length;
this.velocity = avgDelta * 3; // 随便乘个系数,试出来的
this.targetY += this.velocity * 10; // 惯性距离
说实话,这个方案很糙,但亲测有效。在 90% 的设备上表现正常,剩下 10% 的极端情况(比如疯狂抖动手指)就随它去了——反正不影响主流程。
另一个隐藏雷区:resize 和内容变化
项目快上线时,QA 提了个 bug:窗口缩放后滚动位置错乱。我一拍脑袋,忘了处理容器尺寸变化的情况。因为我的 currentY 是绝对偏移量,但容器高度变了,最大可滚动范围也变了,得重新校验边界。
临时加了个 resize 监听器,但不敢用 rAF 里直接读 offsetHeight,怕触发 layout thrashing。所以改成在 resize 事件里打个标记,下一帧再处理:
window.addEventListener('resize', () => {
this.needsRecalc = true;
});
// 在 animate 开头加
if (this.needsRecalc) {
this.maxScroll = this.container.scrollHeight - window.innerHeight;
this.currentY = Math.max(Math.min(this.currentY, 0), -this.maxScroll);
this.needsRecalc = false;
}
虽然有点 dirty,但避免了频繁读取布局属性,性能影响很小。
回顾与反思
整体来看,用 rAF 做自定义滚动比纯事件节流流畅多了,尤其在 iOS 上几乎看不出卡顿。不过有几个地方现在想想还能优化:
- 惯性算法太粗糙,其实可以用物理引擎(比如简单的弹簧模型),但当时工期紧就没搞
- 没处理 wheel 事件,桌面端用户只能用触摸板,鼠标滚轮完全没反应——后来临时加了个兼容,但没深入测试
- 如果内容是动态加载的(比如无限滚动),需要手动触发边界重算,目前靠业务方调用
scroller.update(),体验不够自动化
另外,rAF 虽然好,但也不是万能的。比如在页面隐藏(visibilitychange)时,rAF 会暂停,这时候如果用户切回来,滚动状态可能不一致。我加了个 visibility 监听器暂停/恢复动画,但逻辑有点绕,属于“能跑就行”的级别。
最让我感慨的是:前端高性能交互,很多时候不是技术多牛,而是知道什么时候该妥协。这个方案肯定不是最优解,但足够简单、可控,而且在真实设备上表现稳定——这就够了。
写在最后
以上是我用 requestAnimationFrame 做自定义滚动的真实踩坑记录。核心思想就一点:**把高频输入(touch/mouse)和低频输出(DOM 更新)解耦,中间用 rAF 当缓冲带**。如果你也在做类似需求,不妨试试这个思路。
当然,这个实现还有很多瑕疵,比如没考虑 RTL、没处理嵌套滚动容器。如果你有更好的方案,或者踩过类似的坑,欢迎评论区交流!后续我可能会写一篇关于如何结合 Intersection Observer 做懒加载滚动的实践,感兴趣的话可以关注一下。

暂无评论