怎样用 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 里用一个假的帧队列手动推进时间,这样能看清每一帧算出的值。
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 接到滚动上就行。用户在动画过程中自己滚动滚轮时,应该停止动画,不然会和用户抢滚动条。
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,直接跳到终点。