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驱动,基于时间增量计算,用缓动函数美化运动轨迹。

记住一句话:好的动画引擎,用户感觉不到它的存在,只会觉得「这个交互真舒服」。