在前端实现复杂动画,特别是需要高性能和流畅度的场景,使用 canvas
配合 drawImage
和 requestAnimationFrame
(rAF) 是一个非常强大的组合。这种方式将动画的每一帧都绘制在 canvas
上,而不是依赖 DOM 操作,从而避免了浏览器布局和重绘的开销,提供了更精细的控制。
核心概念
-
requestAnimationFrame (rAF)
:-
这是浏览器专门为动画提供的 API。它告诉浏览器你希望执行一个动画,并请求浏览器在下一次重绘之前调用你指定的回调函数。
-
优势:
- 与浏览器刷新率同步:确保动画在最佳时机执行,避免掉帧或过度渲染。
- 节省电池 :当页面不在活动标签页时,浏览器会暂停
rAF
回调,从而节省 CPU 和电池。 - 避免阻塞:它是一个异步操作,不会阻塞主线程。
-
-
canvas
2D 上下文:HTMLCanvasElement.getContext('2d')
返回一个CanvasRenderingContext2D
对象,提供了在canvas
上绘制图形、文本、图像的方法。
-
drawImage()
方法:-
这是在
canvas
上绘制图像的关键方法。它有多种重载形式,可以满足不同的需求:ctx.drawImage(image, dx, dy)
: 在(dx, dy)
处绘制整个image
。ctx.drawImage(image, dx, dy, dw, dh)
: 在(dx, dy)
处绘制整个image
,并将其缩放到(dw, dh)
的尺寸。ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
: 这是最常用的形式,特别是用于精灵图(spritesheet)。它从image
中裁剪出(sx, sy)
处开始,尺寸为(sWidth, sHeight)
的矩形区域(源矩形),然后将其绘制到canvas
上(dx, dy)
处开始,尺寸为(dWidth, dHeight)
的矩形区域(目标矩形)。
-
-
图像预加载:
drawImage
只能绘制已经加载完成的图像。因此,在开始动画之前,必须确保所有用到的图像资源都已加载完毕。
示例场景:精灵图动画(人物行走)
我们将创建一个简单的动画,一个人物角色在 canvas
上水平移动,并使用精灵图来实现其行走动画。
文件结构:
markdown
project/
├── index.html
├── style.css
└── script.js
└── assets/
└── character_spritesheet.png (假设这是你的精灵图)
character_spritesheet.png
示例:
假设你的精灵图是一行多帧的行走动画,每帧大小为 64x64
像素。
diff
+----+----+----+----+----+----+
| F1 | F2 | F3 | F4 | F5 | F6 | (F = Frame)
+----+----+----+----+----+----+
详细代码示例
index.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas 精灵图动画</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Canvas 精灵图动画示例</h1>
<canvas id="gameCanvas" width="800" height="400"></canvas>
<script src="script.js"></script>
</body>
</html>
style.css
(可选,用于美化)
css
body {
display: flex;
flex-direction: column;
align-items: center;
font-family: sans-serif;
background-color: #f0f0f0;
margin-top: 20px;
}
canvas {
border: 2px solid #333;
background-color: #fff;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
script.js
js
// 1. 获取 Canvas 元素和 2D 上下文
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// 2. 动画配置
const CHARACTER_WIDTH = 64; // 精灵图中每一帧的宽度
const CHARACTER_HEIGHT = 64; // 精灵图中每一帧的高度
const SPRITESHEET_FRAMES = 6; // 精灵图总共有多少帧
const ANIMATION_SPEED = 100; // 每帧动画播放的毫秒数 (值越小,动画越快)
const MOVE_SPEED = 2; // 角色每帧移动的像素数
let characterImage = new Image();
characterImage.src = './assets/character_spritesheet.png'; // 确保路径正确
let currentFrame = 0; // 当前播放的帧索引
let characterX = 0; // 角色当前X坐标
let lastFrameTime = 0; // 上一帧动画更新的时间戳
// 3. 图像预加载
// 确保图像加载完成后再开始动画
characterImage.onload = () => {
console.log('角色精灵图加载完成!');
// 图像加载完成后,启动动画循环
requestAnimationFrame(animate);
};
characterImage.onerror = () => {
console.error('角色精灵图加载失败!请检查路径。');
};
// 4. 动画循环函数
function animate(currentTime) {
// currentTime 是 requestAnimationFrame 提供的当前时间戳
// 性能优化:清除画布
// ctx.clearRect(0, 0, canvas.width, canvas.height); // 清除整个画布
// 或者只清除角色所在区域,但对于复杂场景,全清除更简单且通常性能影响不大
// ctx.clearRect(characterX - MOVE_SPEED, 0, CHARACTER_WIDTH + MOVE_SPEED * 2, canvas.height);
// 每次绘制前清除整个画布,确保没有残影
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 更新动画帧 (基于时间,确保在不同帧率下动画速度一致)
if (currentTime - lastFrameTime > ANIMATION_SPEED) {
currentFrame = (currentFrame + 1) % SPRITESHEET_FRAMES;
lastFrameTime = currentTime;
}
// 更新角色位置
characterX += MOVE_SPEED;
// 如果角色移出画布,则从左侧重新开始
if (characterX > canvas.width) {
characterX = -CHARACTER_WIDTH; // 从左侧完全移出再回来
}
// 绘制角色
// 使用 drawImage 的 9 参数形式:
// ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
// image: 要绘制的图像对象
// sx: 源图像中要裁剪的起始X坐标
// sy: 源图像中要裁剪的起始Y坐标
// sWidth: 源图像中要裁剪的宽度
// sHeight: 源图像中要裁剪的高度
// dx: 在 canvas 上绘制的起始X坐标
// dy: 在 canvas 上绘制的起始Y坐标
// dWidth: 在 canvas 上绘制的宽度 (可以缩放)
// dHeight: 在 canvas 上绘制的高度 (可以缩放)
const sourceX = currentFrame * CHARACTER_WIDTH; // 根据当前帧计算源X坐标
const sourceY = 0; // 假设所有帧都在精灵图的第一行
ctx.drawImage(
characterImage, // 图像源
sourceX, // 源X (精灵图中的裁剪X)
sourceY, // 源Y (精灵图中的裁剪Y)
CHARACTER_WIDTH, // 源宽度 (精灵图中的裁剪宽度)
CHARACTER_HEIGHT, // 源高度 (精灵图中的裁剪高度)
characterX, // 目标X (canvas上的绘制X)
canvas.height - CHARACTER_HEIGHT, // 目标Y (让角色站在底部)
CHARACTER_WIDTH, // 目标宽度 (canvas上的绘制宽度)
CHARACTER_HEIGHT // 目标高度 (canvas上的绘制高度)
);
// 5. 循环调用 requestAnimationFrame
// 请求浏览器在下一帧继续调用 animate 函数
requestAnimationFrame(animate);
}
// 初始调用 (在图像加载完成后)
// 注意:首次调用 requestAnimationFrame 应该在图像加载完成后进行,
// 否则可能在图像未加载时就开始绘制,导致图像不显示。
代码讲解
-
Canvas 和 Context 获取:
const canvas = document.getElementById('gameCanvas');
和const ctx = canvas.getContext('2d');
是获取 Canvas 元素和其 2D 渲染上下文的标准步骤。所有绘制操作都通过ctx
对象进行。
-
动画配置:
CHARACTER_WIDTH
,CHARACTER_HEIGHT
: 定义了精灵图中每一帧的尺寸。SPRITESHEET_FRAMES
: 精灵图包含的总帧数。ANIMATION_SPEED
: 控制精灵动画播放的速度。这里使用毫秒,表示每隔多少毫秒切换到下一帧。MOVE_SPEED
: 控制角色在canvas
上每帧移动的像素数。characterImage
: 创建Image
对象并设置src
来加载精灵图。
-
图像预加载 (
characterImage.onload
) :- 这是非常关键的一步。
drawImage
只能绘制已经加载完成的图像。 characterImage.onload
确保在图像完全加载到内存后才执行回调函数。- 在
onload
回调中,我们首次调用requestAnimationFrame(animate)
来启动动画循环。如果图像加载失败 (onerror
),则打印错误。
- 这是非常关键的一步。
-
动画循环 (
animate
函数) :-
animate
函数是动画的核心,它会在每一帧被requestAnimationFrame
调用。 -
currentTime
参数:requestAnimationFrame
会自动传入一个高精度的时间戳,表示当前帧的时间。我们用它来控制动画帧的切换,确保动画速度在不同设备和帧率下保持一致(基于时间而非帧数)。 -
ctx.clearRect(0, 0, canvas.width, canvas.height);
:- 在绘制新一帧之前,必须清除上一帧的内容。否则,你会看到残影。清除整个画布是最简单且通常性能足够好的方法。对于非常复杂的场景,可以考虑只清除需要更新的区域。
-
更新动画帧:
if (currentTime - lastFrameTime > ANIMATION_SPEED)
: 这是一个基于时间的帧切换逻辑。只有当距离上次帧更新的时间超过ANIMATION_SPEED
时,才切换到下一帧。currentFrame = (currentFrame + 1) % SPRITESHEET_FRAMES;
: 循环切换currentFrame
,使其在0
到SPRITESHEET_FRAMES - 1
之间循环。lastFrameTime = currentTime;
: 更新上次帧更新的时间。
-
更新角色位置:
characterX += MOVE_SPEED;
: 简单地让角色向右移动。if (characterX > canvas.width) { characterX = -CHARACTER_WIDTH; }
: 当角色移出画布右侧时,将其重置到画布左侧外,实现循环移动。
-
绘制角色 (
ctx.drawImage
) :- 这是最核心的绘制部分,使用了
drawImage
的 9 参数形式。 sourceX = currentFrame * CHARACTER_WIDTH;
: 根据当前的currentFrame
计算出要从精灵图中裁剪的起始 X 坐标。sourceY = 0;
: 假设精灵图是单行的,所以 Y 坐标始终为 0。CHARACTER_WIDTH
,CHARACTER_HEIGHT
: 定义了从源图像中裁剪的区域大小。characterX
,canvas.height - CHARACTER_HEIGHT
: 定义了在canvas
上绘制的目标位置。canvas.height - CHARACTER_HEIGHT
让角色底部与canvas
底部对齐。- 最后两个
CHARACTER_WIDTH
,CHARACTER_HEIGHT
: 定义了在canvas
上绘制的尺寸。这里保持与源尺寸一致,也可以放大或缩小。
- 这是最核心的绘制部分,使用了
-
-
循环调用
requestAnimationFrame(animate)
:- 在
animate
函数的末尾再次调用requestAnimationFrame(animate)
,形成一个无限循环,确保动画持续进行。
- 在
性能优化建议
-
图像预加载:始终在动画开始前加载所有图像资源。
-
避免在循环中创建对象 :在
animate
循环中,避免创建新的对象(如new Image()
),这会增加垃圾回收的压力。 -
clearRect
优化:- 如果场景复杂且只有小部分区域更新,可以考虑只清除并重绘受影响的区域,而不是整个画布。但对于大多数情况,全屏
clearRect
结合rAF
已经足够高效。 - 如果背景是静态的,可以将其绘制到一个单独的离屏
canvas
上,然后只在主canvas
上绘制动态元素,或者只清除动态元素的区域。
- 如果场景复杂且只有小部分区域更新,可以考虑只清除并重绘受影响的区域,而不是整个画布。但对于大多数情况,全屏
-
离屏 Canvas (Offscreen Canvas) :
- 对于非常复杂的图形计算或绘制,可以将这些操作放到 Web Worker 中,使用
OffscreenCanvas
进行渲染,然后将渲染结果传输到主线程的canvas
上显示。这可以避免阻塞主线程。
- 对于非常复杂的图形计算或绘制,可以将这些操作放到 Web Worker 中,使用
-
Batching Draw Calls (批量绘制) :
- 如果有很多小图像要绘制,尝试将它们合并到一张大精灵图上,然后用
drawImage
裁剪绘制,这比多次调用drawImage
绘制独立小图像性能更好。
- 如果有很多小图像要绘制,尝试将它们合并到一张大精灵图上,然后用
-
避免浮点数像素:
- 尽量使用整数像素坐标进行绘制 (
dx
,dy
,dw
,dh
),因为子像素渲染可能会导致模糊或额外的计算开销。可以使用Math.floor()
或Math.round()
。
- 尽量使用整数像素坐标进行绘制 (
-
GPU 加速:
canvas
的绘制操作通常会利用 GPU 加速。确保你的绘制操作能被浏览器优化。
-
减少 DOM 操作:
canvas
动画的优势之一就是减少 DOM 操作。在动画循环中,除了canvas
自身,尽量不要去操作其他 DOM 元素。
-
性能监控:
- 使用浏览器开发者工具(如 Chrome DevTools 的 Performance 面板)来监控动画的帧率、CPU 使用率和内存占用,找出性能瓶颈。
通过以上方法,你可以使用 canvas
和 drawImage
结合 requestAnimationFrame
实现高性能、流畅的前端复杂动画。