前端知库

怎样用 requestAnimationFrame 实现平滑动画?比如回到顶部

每一帧用 requestAnimationFrame 安排下一帧,回调里用时间戳减去开始时间算出进度,再经过缓动函数得到当前位置;进度到 1 就停。按时间而不是按帧数算,高刷新率屏幕上动画才不会变快。保存最新的请求 ID,用 cancelAnimationFrame 取消。

参考答案

简答

比如点“回到顶部”按钮,页面在 300 毫秒内从当前位置滑到顶部,先快后慢。做法是调用 requestAnimationFrame(step),浏览器在下一次绘制前调用 step,step 里算出这一帧的位置,没结束就再调用一次 requestAnimationFrame,一帧接一帧。

关键是按时间算进度,不按帧数。回调会收到一个时间戳,用它减去开始时间、除以总时长,得到 0 到 1 的进度。如果每帧固定移动 10 像素,60Hz 和 120Hz 的屏幕上速度会差一倍,MDN 也专门提醒了这一点。

进度再交给缓动函数,比如 1 - (1 - t) ** 3,得到“先快后慢”的效果,位置等于起点加上距离乘以缓动后的值。

要注意取消和边界。requestAnimationFrame 返回一个 ID,每一帧都是新的,所以要保存最新的 ID,用户中途滚动或组件卸载时调用 cancelAnimationFrame。另外后台标签页里大多数浏览器会暂停这些回调;只是想平滑滚动的话,window.scrollTo({ top: 0, behavior: 'smooth' }) 就够了。

手写一个通用的 animate

下面的 animate 不直接依赖浏览器,raf 和 caf 作为参数传进来。在浏览器里传 requestAnimationFrame 和 cancelAnimationFrame;示例在 Node 里用一个假的帧队列手动推进时间,这样能看清每一帧算出的值。

JavaScript
function easeOutCubic(t) {
  return 1 - Math.pow(1 - t, 3);
}

function animate({ from, to, duration, easing, onUpdate, raf, caf }) {
  let start = null;
  let id = null;

  function step(timestamp) {
    if (start === null) start = timestamp;
    // 按经过的时间算进度,帧率高低只影响画得多细,不影响总时长。
    const progress = Math.min((timestamp - start) / duration, 1);
    onUpdate(from + (to - from) * easing(progress));
    id = progress < 1 ? raf(step) : null;
  }

  id = raf(step);
  return function cancel() {
    if (id !== null) caf(id);
    id = null;
  };
}

// 在 Node 里用一个假的帧队列代替浏览器,手动推进时间。
function createFakeFrames() {
  const callbacks = new Map();
  let nextId = 1;
  return {
    raf(callback) {
      callbacks.set(nextId, callback);
      return nextId++;
    },
    caf(id) {
      callbacks.delete(id);
    },
    tick(time) {
      const pending = [...callbacks.values()];
      callbacks.clear();
      pending.forEach((callback) => callback(time));
    },
  };
}

const frames = createFakeFrames();
const positions = [];
animate({
  from: 1000,
  to: 0,
  duration: 300,
  easing: easeOutCubic,
  onUpdate: (y) => positions.push(Math.round(y)),
  raf: frames.raf,
  caf: frames.caf,
});
[0, 100, 200, 300, 400].forEach((time) => frames.tick(time));
console.log(positions.join(',')); // 1000,296,37,0

const slow = createFakeFrames();
const halfway = [];
const cancel = animate({
  from: 0,
  to: 100,
  duration: 1000,
  easing: (t) => t,
  onUpdate: (x) => halfway.push(x),
  raf: slow.raf,
  caf: slow.caf,
});
slow.tick(0);
slow.tick(500);
cancel();
slow.tick(1000);
console.log(halfway.join(',')); // 0,50

第一段从 1000 滚到 0,时长 300 毫秒。时间推进了 5 帧,却只输出 4 个值,因为第 300 毫秒那一帧进度已经到 1,step 不再安排下一帧,第 400 毫秒时队列是空的。

为什么 100 毫秒时就已经到了 296,走完了七成?因为用了缓动函数 easeOutCubic。进度 t 是匀速增长的,经过 1 - (1 - t)^3 变换以后,前期增长快、后期增长慢,画面上就是先快后慢地停下。换成 (t) => t 就是匀速。

第二段在 500 毫秒时调用了 cancel(),所以只输出 0,50。取消时清掉的是最新的 ID:step 每一帧都会把 id 更新成新申请的那一个,如果只记住第一次的 ID,取消就不起作用。MDN 还建议不要把 0 当作“没有 ID”的标记,所以这里用 null。

为什么要按时间算,而不是每帧走固定距离

MDN 写明,回调的调用频率通常和屏幕刷新率一致,常见的是 60Hz,也有 75Hz、120Hz 和 144Hz。如果每帧固定移动 10 像素,120Hz 屏幕一秒调用 120 次,动画就比 60Hz 屏幕快一倍。主线程忙的时候掉了几帧,动画又会变慢。

按时间算就没有这个问题。回调的第一个参数是一个毫秒时间戳,在窗口里等于 document.timeline.currentTime。用它减去第一帧的时间戳得到经过的时间,除以总时长就是进度。帧多帧少只影响画得细不细,到了 300 毫秒一定在终点。MDN 特意提醒,要用这个参数(或者别的取当前时间的办法)计算每帧的进展,否则动画在高刷新率屏幕上会跑得更快。

用在回到顶部

在浏览器里把 animate 接到滚动上就行。用户在动画过程中自己滚动滚轮时,应该停止动画,不然会和用户抢滚动条。

JavaScript
let cancelScroll = null;

function scrollToTop() {
  cancelScroll?.();
  cancelScroll = animate({
    from: window.scrollY,
    to: 0,
    duration: 300,
    easing: easeOutCubic,
    onUpdate: (y) => window.scrollTo(0, y),
    raf: requestAnimationFrame,
    caf: cancelAnimationFrame,
  });
}

window.addEventListener('wheel', () => cancelScroll?.(), { passive: true });

为什么不直接用 CSS 的 scroll-behavior: smooth 或者 scrollTo 的 behavior: 'smooth'?只要求平滑滚动的话,它们更简单。可是 MDN 说明,smooth 的缓动函数和时长都由浏览器决定,浏览器也可以忽略这个属性。需要指定时长、缓动曲线,或者动画的不是滚动位置而是数字、进度条时,才需要自己用 requestAnimationFrame 写。

和 setTimeout 写动画有什么区别

用 setTimeout(step, 16) 也能一帧帧地动,可是它的时机和屏幕刷新没有关系,16 毫秒也只是“至少等这么久”,和绘制对不齐时画面就会卡顿。requestAnimationFrame 的回调由浏览器安排在下一次重绘之前,更新和绘制的节奏是一致的。

另外,MDN 写明大多数浏览器在后台标签页和隐藏的 <iframe> 里会暂停 requestAnimationFrame 的回调,以节省性能和电量。切回来时,下一帧的时间戳已经过了很久,按时间算进度的动画会直接跳到终点,不会从中间慢慢补。定时器的精度问题见《怎样用 setTimeout 实现 setInterval?两者有什么区别?》。

常见追问

requestAnimationFrame 为什么每帧都要重新调用?

因为它是一次性的,MDN 原话是“one-shot”,调用一次只安排下一帧的一次回调。想继续动,就要在回调里再调用一次。这也是为什么每帧的 ID 都不一样,取消时要用最新的那个。

React 组件里用它要注意什么?

在 useEffect 里启动动画,把取消函数作为清理函数返回。组件卸载或依赖变化时,React 会调用清理函数,动画就不会在已经卸载的组件上继续跑。

动画要考虑用户关闭了动效吗?

要。系统里开启了“减少动态效果”时,媒体查询 prefers-reduced-motion: reduce 会匹配,这时可以把时长设成 0,直接跳到终点。