3、JavaScript动画引擎:requestAnimationFrame机制、时间线控制、缓动函数库、帧率监控
聊到JavaScript动画引擎,很多人第一反应是「用setInterval不就行了?」。嗯,我以前也这么干过。直到有一次,我在一个数据大屏项目里用setInterval做滚动动画,结果用户切换标签页再切回来,动画直接崩了——因为setInterval根本不管浏览器是否可见,它只管按时间戳往队列里塞任务。
说白了,requestAnimationFrame才是浏览器亲生的动画API。它知道什么时候该画,什么时候该歇着。
3.1 requestAnimationFrame 的核心机制
这个API的设计思路其实很朴素:你告诉浏览器「下一帧我要更新画面」,浏览器会在它准备好绘制新帧的时候调用你的回调。我个人的理解是,它像是一个「智能节流器」——你调用它,它不会立刻执行,而是等到下一次屏幕刷新(通常是16.6ms后)再统一处理。
关键区别:
- setInterval/setTimeout:不管浏览器是否可见,不管页面是否在后台,都会执行
- requestAnimationFrame:页面不可见时自动暂停,节省CPU和电池
- setInterval:无法保证执行间隔,容易造成丢帧或累积
- requestAnimationFrame:与显示器刷新率同步,天然60fps
来看一个最基础的用法:
function animate() {
// 更新动画状态
element.style.transform = `translateX(${position}px)`;
position += 2;
// 请求下一帧
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
这里有个坑,我曾经踩过——回调函数里的this指向问题。如果你把对象方法直接传给requestAnimationFrame,this会丢失。我习惯用箭头函数或者bind来解决。
3.2 时间线控制:从「帧」到「秒」的思维转变
很多新手写动画,喜欢用「每帧移动多少像素」这种思路。但这样做有个致命问题:不同设备的刷新率不一样。60Hz的屏幕和144Hz的屏幕,同样的代码跑出来速度完全不同。
正确的做法是基于时间增量来计算。说白了,就是记录每一帧的时间戳,然后根据时间差来计算位移。
let lastTime = 0;
const duration = 2000; // 动画持续2秒
const startPosition = 0;
const endPosition = 500;
function animate(time) {
if (!lastTime) lastTime = time;
const delta = time - lastTime;
lastTime = time;
// 计算当前进度(0~1之间)
const elapsed = time - startTime;
const progress = Math.min(elapsed / duration, 1);
// 更新位置
const currentPosition = startPosition + (endPosition - startPosition) * progress;
element.style.transform = `translateX(${currentPosition}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
const startTime = performance.now();
requestAnimationFrame(animate);
我的经验:performance.now() 比 Date.now() 精度更高,而且它是单调递增的,不会受系统时间调整影响。做动画时间线,我永远用 performance.now()。
3.3 缓动函数库:让动画「活」起来
线性动画看起来像机器人。你想想看,现实世界里的运动哪有匀速的?球落地会弹跳,车门关闭会缓冲。缓动函数就是用来模拟这些物理特性的。
我整理了一套常用的缓动函数,直接拿去用:
const Easing = {
// 匀速
linear: t => t,
// 先慢后快
easeInQuad: t => t * t,
// 先快后慢
easeOutQuad: t => t * (2 - t),
// 两端都慢
easeInOutQuad: t => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
// 弹性效果
easeOutElastic: t => {
const p = 0.3;
return Math.pow(2, -10 * t) * Math.sin((t - p / 4) * (2 * Math.PI) / p) + 1;
},
// 回弹效果
easeOutBounce: t => {
if (t < 1 / 2.75) return 7.5625 * t * t;
if (t < 2 / 2.75) return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
if (t < 2.5 / 2.75) return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
};
使用方式很简单:
function animateWithEasing(time) {
const elapsed = time - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = Easing.easeOutElastic(progress);
const currentX = startX + (endX - startX) * easedProgress;
element.style.transform = `translateX(${currentX}px)`;
if (progress < 1) requestAnimationFrame(animateWithEasing);
}
注意:缓动函数的输入t必须在0~1之间。我曾经在项目里忘记做Math.min限制,结果progress超过了1,缓动函数直接算出了负数,动画反向跑了——用户反馈说「这个弹窗怎么弹出去又缩回来了?」
3.4 帧率监控:别让用户觉得卡
帧率是动画的「体检报告」。60fps是流畅的底线,低于30fps用户就能明显感觉到卡顿。我习惯在开发环境里挂一个帧率监控器,随时观察性能。
实现一个简单的FPS计数器:
class FPSMonitor {
constructor() {
this.frames = 0;
this.lastTime = performance.now();
this.fps = 0;
}
tick() {
this.frames++;
const now = performance.now();
const delta = now - this.lastTime;
// 每秒更新一次
if (delta >= 1000) {
this.fps = this.frames / (delta / 1000);
this.frames = 0;
this.lastTime = now;
// 输出到控制台或UI
console.log(`当前帧率: ${Math.round(this.fps)}fps`);
// 如果低于30fps,发出警告
if (this.fps < 30) {
console.warn('⚠️ 帧率过低,建议优化动画性能');
}
}
}
}
// 在动画循环中调用
const monitor = new FPSMonitor();
function animate(time) {
monitor.tick();
// ... 动画逻辑
requestAnimationFrame(animate);
}
| 帧率范围 | 用户体验 | 建议措施 |
|---|---|---|
| 55-60fps | 非常流畅 | 保持现状 |
| 30-55fps | 轻微卡顿 | 检查重排重绘,减少DOM操作 |
| 15-30fps | 明显卡顿 | 考虑使用Canvas或WebGL替代DOM动画 |
| <15fps | 无法使用 | 必须重构动画方案 |
避坑指南:我曾经在移动端项目里发现帧率忽高忽低,排查了半天发现是console.log太多导致的。console.log是同步操作,会阻塞主线程。生产环境一定要去掉调试日志,或者用条件编译控制。
3.5 实战:组装一个完整的动画引擎
把上面这些知识点串起来,就是一个轻量级的动画引擎核心:
class AnimationEngine {
constructor() {
this.animations = new Map();
this.isRunning = false;
this.monitor = new FPSMonitor();
}
add(id, config) {
this.animations.set(id, {
...config,
startTime: null,
running: false
});
}
start(id) {
const anim = this.animations.get(id);
if (!anim) return;
anim.startTime = performance.now();
anim.running = true;
if (!this.isRunning) {
this.isRunning = true;
this.loop(performance.now());
}
}
loop = (time) => {
this.monitor.tick();
let hasRunning = false;
for (const [id, anim] of this.animations) {
if (!anim.running) continue;
const elapsed = time - anim.startTime;
const progress = Math.min(elapsed / anim.duration, 1);
const easedProgress = anim.easing(progress);
anim.onUpdate(easedProgress);
if (progress >= 1) {
anim.running = false;
anim.onComplete?.();
} else {
hasRunning = true;
}
}
if (hasRunning) {
requestAnimationFrame(this.loop);
} else {
this.isRunning = false;
}
}
}
使用起来就像这样:
const engine = new AnimationEngine();
engine.add('moveBox', {
duration: 1000,
easing: Easing.easeOutElastic,
onUpdate: (progress) => {
box.style.transform = `translateX(${progress * 300}px)`;
},
onComplete: () => {
console.log('动画完成');
}
});
engine.start('moveBox');
这个引擎虽然简单,但已经覆盖了核心功能:时间线控制、缓动函数、帧率监控。在实际项目中,我会在此基础上加上动画队列、暂停/恢复、反向播放等功能。但核心思想不变——用requestAnimationFrame驱动,基于时间增量计算,用缓动函数美化运动轨迹。
记住一句话:好的动画引擎,用户感觉不到它的存在,只会觉得「这个交互真舒服」。