动画基础
Canvas 动画是通过 requestAnimationFrame() 循环实现的。核心思路是:每帧清除画布、更新状态、重新绘制,利用人眼的视觉暂留效应形成流畅的动画效果。本节将详细介绍 Canvas 动画的基本原理、帧率控制和优化技巧。
前置知识
阅读本节前,建议先了解:像素操作
基础概念
Canvas 动画遵循"清除-更新-绘制"(Clear-Update-Draw)循环模式。与 CSS 动画或 SVG SMIL 动画不同,Canvas 动画完全由 JavaScript 控制,每一帧都需要手动绘制。
动画循环三要素
- 清除:
clearRect()或覆盖绘制清除上一帧 - 更新:计算新的位置、状态等
- 绘制:根据最新状态绘制所有图形
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 SMIL | GPU 加速 | 简单的 UI 动画 |
下一节
继续学习:Canvas 性能优化