一、动画基础:JavaScript动画概述、requestAnimationFrame原理、性能优化基础

大家好,我是你们的前端老司机。今天咱们聊聊JavaScript动画。

说实话,我刚入行那会儿,做动画全靠jQuery的.animate()。那时候觉得挺爽,写一行代码就能让元素动起来。后来项目越来越大,问题就来了——掉帧、卡顿、CPU飙升……嗯,这些坑我都踩过。

所以这一章,我想把动画的底层逻辑讲清楚。你理解了原理,以后不管用什么库,心里都有底。

1.1 什么是JavaScript动画?

动画的本质是什么?说白了,就是让画面在一段时间内连续变化。

你想想看,电影每秒播放24帧,每一帧都是静止的。但因为切换够快,人眼就觉得画面在动。网页动画也一样——我们通过JavaScript不断修改元素的样式,比如lefttopopacity,浏览器就会重新绘制页面。

举个例子:

// 一个最简单的动画:让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内完成一帧的渲染。为什么会来不及?

我总结了三类常见原因:

  1. 布局抖动(Layout Thrashing):反复读写DOM的几何属性(如offsetTopclientWidth),强制浏览器重新计算布局。
  2. 重绘面积过大:修改了widthheightmargin等会触发布局的属性,导致整个子树重新布局。
  3. 主线程阻塞:JavaScript执行时间过长,比如复杂的计算或大量DOM操作。

怎么优化?给你几个实战技巧:

  • 优先使用transformopacity:这两个属性只会触发合成(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操作:能用canvasWebGL的场景,尽量别用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,很容易造成循环失控。

好了,这一章就到这里。下一章我们会深入缓动函数——让动画看起来更自然,而不是像机器人一样生硬。到时候见。