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)驱动动画,方便组合和复用
  • 性能监控:实时看帧率,别等用户吐槽了才去修

下一章我们会聊动画的缓动函数——怎么让动画看起来更「活」,而不是机械地匀速运动。嗯,那个才是真正让动画有灵魂的东西。