requestAnimationFrame实战指南:让前端动画更流畅

シ钰岩 交互 阅读 2,295
赞 13 收藏
二维码
手机扫码查看
反馈

为什么选 requestAnimationFrame?

上个月接了个需求,要做一个自定义的滚动条组件,支持平滑滚动、惯性回弹、还有滚动过程中的实时监听。一开始我直接用 scroll 事件加 setTimeout 节流,结果在低端安卓机上卡得像 PPT。用户反馈“一滚就掉帧”,老板看演示时眉头都皱起来了。

requestAnimationFrame实战指南:让前端动画更流畅

后来想到之前看过一些高性能动画的资料,决定试试 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 做懒加载滚动的实践,感兴趣的话可以关注一下。

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

暂无评论