一、动画基础:JavaScript动画概述、requestAnimationFrame原理、性能优化基础
大家好,我是你们的前端老司机。今天咱们聊聊JavaScript动画。
说实话,我刚入行那会儿,做动画全靠jQuery的.animate()。那时候觉得挺爽,写一行代码就能让元素动起来。后来项目越来越大,问题就来了——掉帧、卡顿、CPU飙升……嗯,这些坑我都踩过。
所以这一章,我想把动画的底层逻辑讲清楚。你理解了原理,以后不管用什么库,心里都有底。
1.1 什么是JavaScript动画?
动画的本质是什么?说白了,就是让画面在一段时间内连续变化。
你想想看,电影每秒播放24帧,每一帧都是静止的。但因为切换够快,人眼就觉得画面在动。网页动画也一样——我们通过JavaScript不断修改元素的样式,比如left、top、opacity,浏览器就会重新绘制页面。
举个例子:
// 一个最简单的动画:让div向右移动
const box = document.getElementById('box');
let position = 0;
function move() {
position += 1;
box.style.transform = `translateX(${position}px)`;
}
setInterval(move, 16); // 大约60fps
这段代码能跑,但问题很多。我待会儿会讲为什么。
1.2 三种常见的动画实现方式
做动画,你至少有三种选择:
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
setInterval / setTimeout |
定时器回调 | 简单、兼容性好 | 不精确、容易卡顿 |
requestAnimationFrame |
浏览器渲染帧同步 | 精准、省资源 | 需要手动管理循环 |
| CSS动画 / Web Animations API | 浏览器底层优化 | 性能最好、代码少 | 控制力弱、复杂动画难实现 |
我个人习惯是:能用CSS动画就用CSS,搞不定的复杂交互再用JavaScript。但不管用哪种,requestAnimationFrame都是你绕不开的核心。
1.3 requestAnimationFrame 原理
为什么说setInterval不靠谱?
你设定了16ms执行一次,但JavaScript是单线程的。如果主线程正在处理其他任务,你的回调就得排队。等轮到它的时候,可能已经过了20ms、30ms。更糟糕的是,如果浏览器标签页被隐藏了,setInterval还在后台拼命跑——白白浪费CPU。
requestAnimationFrame就不一样了。它会告诉浏览器:「嘿,下次你准备重绘屏幕的时候,记得叫我一声。」
这样做有两个好处:
- 同步刷新:回调的执行时间和浏览器的绘制周期完全对齐。通常是60Hz(每秒60帧),也就是每16.7ms一次。
- 自动暂停:页面切到后台时,浏览器会暂停回调。省电、省资源。
来看个对比:
// setInterval 版本
let count = 0;
const start = performance.now();
const id = setInterval(() => {
count++;
if (count >= 60) {
clearInterval(id);
console.log('setInterval 耗时:', performance.now() - start);
}
}, 16);
// requestAnimationFrame 版本
let count2 = 0;
const start2 = performance.now();
function loop() {
count2++;
if (count2 < 60) {
requestAnimationFrame(loop);
} else {
console.log('rAF 耗时:', performance.now() - start2);
}
}
requestAnimationFrame(loop);
我在项目中遇到过这种情况:用setInterval做滚动动画,用户快速切换标签页再切回来,动画直接「瞬移」到终点。换成requestAnimationFrame后,一切丝滑如初。
核心要点:requestAnimationFrame 的回调会接收一个高精度时间戳(DOMHighResTimeStamp),你可以用它来计算动画进度,而不是依赖Date.now()。
1.4 性能优化基础
动画卡顿,说白了就是浏览器来不及在16ms内完成一帧的渲染。为什么会来不及?
我总结了三类常见原因:
- 布局抖动(Layout Thrashing):反复读写DOM的几何属性(如
offsetTop、clientWidth),强制浏览器重新计算布局。 - 重绘面积过大:修改了
width、height、margin等会触发布局的属性,导致整个子树重新布局。 - 主线程阻塞:JavaScript执行时间过长,比如复杂的计算或大量DOM操作。
怎么优化?给你几个实战技巧:
- 优先使用
transform和opacity:这两个属性只会触发合成(Composite),不会触发布局(Layout)和绘制(Paint)。性能最好。 - 避免强制同步布局:不要在读取布局属性后立即修改样式。比如:
// ❌ 坏写法
const height = box.offsetHeight;
box.style.height = height + 10 + 'px'; // 强制重新布局
// ✅ 好写法
const height = box.offsetHeight;
// 先读完,再统一写
requestAnimationFrame(() => {
box.style.height = height + 10 + 'px';
});
- 使用
will-change提示浏览器:告诉浏览器某个元素即将变化,让它提前优化。
.animated-element {
will-change: transform, opacity;
}
注意:will-change不要滥用。用得太多,浏览器会占用大量内存。只对持续动画的元素使用,动画结束后记得移除。
- 减少DOM操作:能用
canvas或WebGL的场景,尽量别用DOM。我做过一个粒子动画,用DOM操作只能跑30fps,换成canvas直接飙到60fps。
1.5 一个完整的动画框架雏形
把上面这些知识串起来,我们可以写一个简单的动画工具:
function animate({ duration, timing, draw, done }) {
const start = performance.now();
function frame(time) {
let progress = (time - start) / duration;
if (progress > 1) progress = 1;
const easedProgress = timing ? timing(progress) : progress;
draw(easedProgress);
if (progress < 1) {
requestAnimationFrame(frame);
} else {
if (done) done();
}
}
requestAnimationFrame(frame);
}
// 使用示例
animate({
duration: 1000,
timing: t => t * (2 - t), // 缓出效果
draw: p => {
box.style.transform = `translateX(${p * 200}px)`;
},
done: () => console.log('动画完成')
});
这个框架虽然简陋,但核心思想都在了:用requestAnimationFrame驱动循环,用时间进度计算位置,支持缓动函数。我在实际项目中,很多复杂动画都是在这个基础上扩展的。
小技巧:如果你需要兼容旧浏览器(比如IE9以下),可以加一个setTimeout降级方案。不过说实话,现在基本不用考虑了。
1.6 避坑指南
最后分享几个我踩过的坑:
- 不要用
setInterval做精确计时。我曾经做过一个倒计时动画,用setInterval每100ms更新一次。结果用户切到其他标签页再回来,倒计时直接跳了十几秒。换成requestAnimationFrame加时间戳计算后,问题解决。 - 注意内存泄漏。如果你在
requestAnimationFrame回调里引用了外部变量,而忘记取消动画循环,这些变量永远不会被垃圾回收。 - 动画循环不要嵌套太多。每个
requestAnimationFrame回调里再开新的requestAnimationFrame,很容易造成循环失控。
好了,这一章就到这里。下一章我们会深入缓动函数——让动画看起来更自然,而不是像机器人一样生硬。到时候见。