4、JavaScript动画基础:requestAnimationFrame原理、帧率控制、时间线管理
说到JavaScript动画,很多人第一反应是setInterval或者setTimeout。嗯,我早年做移动端开发时也这么干过。结果呢?掉帧、卡顿、电池狂掉……后来才明白,浏览器其实给了我们一个更专业的工具——requestAnimationFrame。
说白了,它就是浏览器专门为动画设计的API。你告诉浏览器「我要画下一帧了」,浏览器会在最合适的时机通知你。这个时机通常是屏幕刷新之前,大概16.6ms一次(60fps)。
4.1 requestAnimationFrame 的工作原理
为什么setInterval不行?你想想看,setInterval只管「每隔多少毫秒执行一次」,但它不知道屏幕什么时候刷新。如果回调执行时屏幕刚好在刷新中间,画面就会撕裂。更糟的是,如果页面切到后台,setInterval还在跑,白白浪费性能。
requestAnimationFrame就不一样了。它会跟浏览器的渲染管线同步。每次调用,你传进去的回调函数会收到一个高精度时间戳(DOMHighResTimeStamp),单位是毫秒。
核心区别一句话:setInterval是「我催你」,requestAnimationFrame是「你准备好了叫我」。
来看个最简单的例子:
function animate(timestamp) {
// timestamp 是浏览器传入的当前时间
console.log('当前帧时间:', timestamp);
// 在这里更新动画状态
// 比如移动一个元素
requestAnimationFrame(animate);
}
// 启动动画
requestAnimationFrame(animate);
这段代码会一直跑下去,直到你主动取消。取消的方法是用cancelAnimationFrame,参数是requestAnimationFrame返回的ID。
我的习惯:每次启动动画时,把返回的ID存下来。清理时用这个ID取消。不然动画可能会「幽灵般」一直运行,尤其在组件卸载时容易出问题。
4.2 帧率控制:别让动画跑太快
requestAnimationFrame默认跟着屏幕刷新率走。60Hz的屏幕就是60fps,120Hz就是120fps。但有时候我们不需要那么快,比如一个进度条动画,30fps就够用了。
帧率控制的核心思路很简单:跳过一些帧。我常用的方法是记录上一帧的时间,判断间隔是否达到目标帧率的要求。
let lastTime = 0;
const targetFPS = 30;
const interval = 1000 / targetFPS; // 约33.3ms
function animate(timestamp) {
const delta = timestamp - lastTime;
if (delta >= interval) {
// 够时间间隔了,更新动画
updateAnimation(delta);
lastTime = timestamp - (delta % interval); // 对齐时间
}
requestAnimationFrame(animate);
}
这里有个细节要注意:lastTime = timestamp - (delta % interval) 这行是为了避免误差累积。如果不做这个处理,跑久了动画会越来越慢。
我曾经踩过的坑:直接写 lastTime = timestamp,结果每帧都差那么零点几毫秒,30fps的动画跑了10分钟变成了28fps。嗯,这个对齐技巧是从游戏引擎里学来的。
4.3 时间线管理:让动画有「节奏」
单帧动画好写,但复杂的动画需要管理整个时间线。比如一个动画先移动再缩放,或者多个元素在不同时间点开始运动。
我习惯用一个时间线对象来管理。它记录动画的起始时间、持续时间、当前进度,以及每一帧要执行的回调。
class Timeline {
constructor(duration, onUpdate, onComplete) {
this.duration = duration; // 总时长(毫秒)
this.onUpdate = onUpdate; // 每帧回调
this.onComplete = onComplete; // 完成回调
this.startTime = null; // 开始时间
this.running = false;
}
start(timestamp) {
this.startTime = timestamp;
this.running = true;
this.tick(timestamp);
}
tick(timestamp) {
if (!this.running) return;
const elapsed = timestamp - this.startTime;
let progress = Math.min(elapsed / this.duration, 1);
// 调用更新回调,传入进度(0~1)
this.onUpdate(progress);
if (progress < 1) {
requestAnimationFrame((t) => this.tick(t));
} else {
this.running = false;
this.onComplete && this.onComplete();
}
}
stop() {
this.running = false;
}
}
// 使用示例
const moveAnim = new Timeline(
2000, // 2秒
(p) => {
// p 从0到1
const x = p * 300; // 移动300px
element.style.transform = `translateX(${x}px)`;
},
() => {
console.log('动画完成!');
}
);
requestAnimationFrame((t) => moveAnim.start(t));
这个时间线类虽然简单,但已经能覆盖大部分场景了。实际项目中,我还会加上缓动函数(easing)的支持,让动画看起来更自然。
避坑指南:时间线里的progress一定要用 Math.min(..., 1) 钳制。为什么?因为timestamp可能因为某些原因(比如浏览器卡了一下)突然跳变,导致elapsed超过duration。不做钳制的话,动画会「抽风」。
4.4 实战中的时间线组合
单个时间线好写,但多个动画串行或并行呢?比如:先让元素A移动,等A移动完了,B再开始缩放。
我常用的做法是链式调用。每个时间线结束时,启动下一个。
function chainAnimations(timelines, startTimestamp) {
let index = 0;
function runNext(timestamp) {
if (index >= timelines.length) return;
const tl = timelines[index];
tl.onComplete = () => {
index++;
requestAnimationFrame(runNext);
};
tl.start(timestamp);
}
requestAnimationFrame(runNext);
}
// 使用
const tl1 = new Timeline(1000, (p) => { /* 移动 */ });
const tl2 = new Timeline(500, (p) => { /* 缩放 */ });
chainAnimations([tl1, tl2]);
并行就更简单了,同时启动多个时间线就行。但要注意性能——如果同时跑几十个动画,每帧的计算量会很大。我一般会控制在10个以内,超过的话考虑用Canvas或者WebGL。
4.5 性能监控:别让动画拖垮页面
写动画最怕什么?怕用户觉得「卡」。但卡不卡不能靠感觉,得看数据。
我习惯在动画循环里加一个简单的帧率统计:
let frameCount = 0;
let lastFPSTime = 0;
let currentFPS = 0;
function monitorFPS(timestamp) {
frameCount++;
if (timestamp - lastFPSTime >= 1000) {
currentFPS = frameCount;
frameCount = 0;
lastFPSTime = timestamp;
console.log(`当前帧率:${currentFPS} FPS`);
if (currentFPS < 30) {
console.warn('帧率过低,考虑优化!');
}
}
requestAnimationFrame(monitorFPS);
}
这个监控器每秒输出一次帧率。如果低于30fps,就该检查是不是计算量太大了,或者DOM操作太频繁。
我的经验:移动端上,60fps是理想,30fps是底线。低于30fps,用户能明显感觉到卡顿。这时候优先检查是不是触发了强制回流(比如频繁读取offsetTop),其次是减少每帧的DOM操作次数。
4.6 总结一下
requestAnimationFrame是移动端动画的基石。它比setInterval更高效、更省电、更流畅。帧率控制和时间线管理是它的两个核心应用场景。
- 帧率控制:通过时间差判断是否更新,避免不必要的计算
- 时间线管理:用进度(0~1)驱动动画,方便组合和复用
- 性能监控:实时看帧率,别等用户吐槽了才去修
下一章我们会聊动画的缓动函数——怎么让动画看起来更「活」,而不是机械地匀速运动。嗯,那个才是真正让动画有灵魂的东西。