Skip to content

动画基础

Canvas 动画是通过 requestAnimationFrame() 循环实现的。核心思路是:每帧清除画布、更新状态、重新绘制,利用人眼的视觉暂留效应形成流畅的动画效果。本节将详细介绍 Canvas 动画的基本原理、帧率控制和优化技巧。

前置知识

阅读本节前,建议先了解:像素操作

基础概念

Canvas 动画遵循"清除-更新-绘制"(Clear-Update-Draw)循环模式。与 CSS 动画或 SVG SMIL 动画不同,Canvas 动画完全由 JavaScript 控制,每一帧都需要手动绘制。

动画循环三要素

  1. 清除clearRect() 或覆盖绘制清除上一帧
  2. 更新:计算新的位置、状态等
  3. 绘制:根据最新状态绘制所有图形

requestAnimationFrame

基本用法

requestAnimationFrame(callback) 在下一次重绘之前调用回调函数。浏览器通常以 60fps(约 16.67ms/帧)的频率调用。

javascript
function animate(timestamp) {
  // 清除画布
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 更新状态
  update(timestamp);

  // 绘制
  draw();

  // 请求下一帧
  requestAnimationFrame(animate);
}

// 启动动画
requestAnimationFrame(animate);

timestamp 参数

回调函数接收一个 DOMHighResTimeStamp 参数,表示从页面加载到当前回调的毫秒数。

javascript
let lastTime = 0;

function animate(timestamp) {
  const deltaTime = timestamp - lastTime; // 两帧之间的时间差
  lastTime = timestamp;

  // 使用 deltaTime 做基于时间的动画
  console.log(`帧间隔: ${deltaTime.toFixed(2)}ms`);

  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

启动与停止动画

javascript
let animationId = null;

function startAnimation() {
  if (animationId === null) {
    function loop(timestamp) {
      // ... 动画逻辑 ...
      animationId = requestAnimationFrame(loop);
    }
    animationId = requestAnimationFrame(loop);
  }
}

function stopAnimation() {
  if (animationId !== null) {
    cancelAnimationFrame(animationId);
    animationId = null;
  }
}

帧率控制

固定时间步长

javascript
const FPS = 30;
const frameDuration = 1000 / FPS; // 约 33.33ms
let lastTime = 0;

function animate(timestamp) {
  const elapsed = timestamp - lastTime;

  if (elapsed >= frameDuration) {
    lastTime = timestamp - (elapsed % frameDuration);
    update();
    draw();
  }

  requestAnimationFrame(animate);
}

基于时间的动画(推荐)

使用 deltaTime 使动画速度与帧率无关:

javascript
let lastTime = 0;
const speed = 100; // 像素/秒

function animate(timestamp) {
  const deltaTime = (timestamp - lastTime) / 1000; // 转换为秒
  lastTime = timestamp;

  // 基于时间的移动
  position.x += speed * deltaTime;

  draw();
  requestAnimationFrame(animate);
}

累加器模式

精确控制更新频率(常用于游戏物理):

javascript
const TICK_RATE = 1000 / 60; // 60 次/秒的固定更新频率
let accumulator = 0;
let lastTime = 0;

function gameLoop(timestamp) {
  const deltaTime = timestamp - lastTime;
  lastTime = timestamp;
  accumulator += deltaTime;

  // 每隔固定时间更新一次
  while (accumulator >= TICK_RATE) {
    update();
    accumulator -= TICK_RATE;
  }

  // 每帧都绘制(插值中间状态)
  draw();
  requestAnimationFrame(gameLoop);
}

帧率检测

javascript
/**
 * 帧率监测器
 */
class FPSMonitor {
  constructor() {
    this.fps = 0;
    this.frames = 0;
    this.lastTime = performance.now();
  }

  tick() {
    this.frames++;
    const now = performance.now();
    const elapsed = now - this.lastTime;

    if (elapsed >= 1000) {
      this.fps = Math.round((this.frames * 1000) / elapsed);
      this.frames = 0;
      this.lastTime = now;
    }
  }

  getFPS() {
    return this.fps;
  }
}

// 使用
const monitor = new FPSMonitor();

function animate() {
  monitor.tick();

  // 在画布上显示 FPS
  ctx.fillStyle = '#333';
  ctx.font = '14px monospace';
  ctx.fillText(`FPS: ${monitor.getFPS()}`, 10, 20);

  requestAnimationFrame(animate);
}

实战示例

示例 1:弹跳球动画

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>弹跳球</title>
  <style>
    canvas { border: 1px solid #ccc; display: block; margin: 10px 0; }
  </style>
</head>
<body>
  <h1>Canvas 弹跳球动画</h1>
  <canvas id="bounceCanvas" width="500" height="400"></canvas>

  <script>
    const canvas = document.getElementById('bounceCanvas');
    const ctx = canvas.getContext('2d');

    // 球的属性
    const ball = {
      x: 250,
      y: 100,
      radius: 15,
      vx: 150,    // 水平速度(像素/秒)
      vy: 0,      // 垂直速度
      gravity: 500, // 重力加速度(像素/秒²)
      bounce: 0.8, // 弹性系数
      color: '#4285f4'
    };

    let lastTime = 0;

    function update(deltaTime) {
      // 应用重力
      ball.vy += ball.gravity * deltaTime;

      // 更新位置
      ball.x += ball.vx * deltaTime;
      ball.y += ball.vy * deltaTime;

      // 碰撞检测:底部
      if (ball.y + ball.radius > canvas.height) {
        ball.y = canvas.height - ball.radius;
        ball.vy = -ball.vy * ball.bounce;
      }

      // 碰撞检测:顶部
      if (ball.y - ball.radius < 0) {
        ball.y = ball.radius;
        ball.vy = -ball.vy * ball.bounce;
      }

      // 碰撞检测:左右边界
      if (ball.x + ball.radius > canvas.width) {
        ball.x = canvas.width - ball.radius;
        ball.vx = -ball.vx;
      }
      if (ball.x - ball.radius < 0) {
        ball.x = ball.radius;
        ball.vx = -ball.vx;
      }
    }

    function draw() {
      // 清除画布
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      // 绘制阴影
      ctx.save();
      ctx.globalAlpha = 0.2;
      ctx.fillStyle = '#000';
      ctx.beginPath();
      ctx.ellipse(ball.x, canvas.height - 5, ball.radius, 5, 0, 0, Math.PI * 2);
      ctx.fill();
      ctx.restore();

      // 绘制球
      const gradient = ctx.createRadialGradient(
        ball.x - 5, ball.y - 5, 3,
        ball.x, ball.y, ball.radius
      );
      gradient.addColorStop(0, '#ffffff');
      gradient.addColorStop(0.3, ball.color);
      gradient.addColorStop(1, '#1a237e');

      ctx.beginPath();
      ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
      ctx.fillStyle = gradient;
      ctx.fill();
    }

    function animate(timestamp) {
      const deltaTime = Math.min((timestamp - lastTime) / 1000, 0.05); // 限制最大 dt
      lastTime = timestamp;

      update(deltaTime);
      draw();

      requestAnimationFrame(animate);
    }

    requestAnimationFrame(animate);
  </script>
</body>
</html>

示例 2:粒子系统

javascript
/**
 * 简单粒子系统
 */
class Particle {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = (Math.random() - 0.5) * 200;
    this.vy = (Math.random() - 0.5) * 200;
    this.life = 1.0;
    this.decay = 0.5 + Math.random() * 1.0; // 衰减速率
    this.size = 2 + Math.random() * 4;
    this.color = `hsl(${Math.random() * 360}, 70%, 60%)`;
  }

  update(deltaTime) {
    this.x += this.vx * deltaTime;
    this.y += this.vy * deltaTime;
    this.vy += 100 * deltaTime; // 重力
    this.life -= this.decay * deltaTime;
  }

  draw(ctx) {
    ctx.save();
    ctx.globalAlpha = Math.max(0, this.life);
    ctx.fillStyle = this.color;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.size * this.life, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

  isDead() {
    return this.life <= 0;
  }
}

// 使用
const particles = [];
let lastTime = 0;

function animate(timestamp) {
  const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
  lastTime = timestamp;

  // 每帧添加新粒子
  if (Math.random() < 0.3) {
    particles.push(new Particle(canvas.width / 2, canvas.height / 2));
  }

  // 更新和绘制粒子
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  for (let i = particles.length - 1; i >= 0; i--) {
    particles[i].update(dt);
    particles[i].draw(ctx);

    if (particles[i].isDead()) {
      particles.splice(i, 1);
    }
  }

  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

示例 3:缓动函数

javascript
/**
 * 常用缓动函数
 */
const Easing = {
  // 线性
  linear(t) {
    return t;
  },

  // 缓入
  easeInQuad(t) {
    return t * t;
  },

  easeInCubic(t) {
    return t * t * t;
  },

  // 缓出
  easeOutQuad(t) {
    return t * (2 - t);
  },

  easeOutCubic(t) {
    return (--t) * t * t + 1;
  },

  // 缓入缓出
  easeInOutQuad(t) {
    return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
  },

  easeInOutCubic(t) {
    return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
  },

  // 弹性
  easeOutElastic(t) {
    return Math.pow(2, -10 * t) * Math.sin((t - 0.1) * 5 * Math.PI) + 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 animateProperty(from, to, duration, easingFn, onUpdate, onComplete) {
  const startTime = performance.now();

  function step(timestamp) {
    const elapsed = timestamp - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const easedProgress = easingFn(progress);
    const currentValue = from + (to - from) * easedProgress;

    onUpdate(currentValue);

    if (progress < 1) {
      requestAnimationFrame(step);
    } else if (onComplete) {
      onComplete();
    }
  }

  requestAnimationFrame(step);
}

// 使用:从 0 移动到 400,持续 1 秒,使用 easeOutBounce
animateProperty(0, 400, 1000, Easing.easeOutBounce, (value) => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#4285f4';
  ctx.fillRect(value, 150, 30, 30);
});

注意事项

1. 首帧 deltaTime 过大

页面切换回来时,第一帧的 deltaTime 可能非常大:

javascript
// 限制最大 deltaTime
function animate(timestamp) {
  const deltaTime = Math.min((timestamp - lastTime) / 1000, 0.1); // 最大 100ms
  lastTime = timestamp;
  // ...
}

2. requestAnimationFrame 的自动暂停

当标签页不可见时,requestAnimationFrame 会暂停,这是浏览器的节能机制。

javascript
// 利用这个特性实现暂停功能(切换标签页即暂停)
// 如果需要标签页不可见时继续运行,使用 setTimeout 代替
function animate() {
  // ...
  setTimeout(() => requestAnimationFrame(animate), 0);
}

3. 内存泄漏:忘记取消

javascript
// 组件卸载时必须取消动画
class MyComponent {
  start() {
    this.animationId = requestAnimationFrame(this.loop.bind(this));
  }

  stop() {
    if (this.animationId) {
      cancelAnimationFrame(this.animationId);
      this.animationId = null;
    }
  }

  destroy() {
    this.stop();
  }
}

最佳实践

1. 动画架构模式

javascript
/**
 * 基于对象的动画管理器
 */
class AnimationManager {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.objects = [];
    this.running = false;
    this.lastTime = 0;
  }

  add(obj) {
    this.objects.push(obj);
  }

  remove(obj) {
    const index = this.objects.indexOf(obj);
    if (index > -1) this.objects.splice(index, 1);
  }

  start() {
    this.running = true;
    this.lastTime = performance.now();
    this.loop(this.lastTime);
  }

  stop() {
    this.running = false;
  }

  loop(timestamp) {
    if (!this.running) return;

    const dt = Math.min((timestamp - this.lastTime) / 1000, 0.1);
    this.lastTime = timestamp;

    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

    this.objects.forEach(obj => {
      obj.update(dt);
      obj.draw(this.ctx);
    });

    requestAnimationFrame((t) => this.loop(t));
  }
}

// 使用
const manager = new AnimationManager(canvas);
manager.add({
  x: 100, y: 200,
  update(dt) { this.x += 50 * dt; },
  draw(ctx) { ctx.fillStyle = '#4285f4'; ctx.fillRect(this.x, this.y, 30, 30); }
});
manager.start();

2. 减少重绘区域

javascript
// 不好的做法:清除整个画布
ctx.clearRect(0, 0, canvas.width, canvas.height);

// 好的做法:只清除需要更新的区域
ctx.clearRect(100, 100, 50, 50);

3. 性能对比

技术帧率适用场景
setInterval(fn, 16)不稳定简单动画、兼容旧浏览器
setTimeout(fn, 0)不稳定需要 tag 不可见时继续运行
requestAnimationFrame稳定 60fps主流动画方案
CSS 动画 / SVG SMILGPU 加速简单的 UI 动画

下一节

继续学习:Canvas 性能优化

参考链接