动画与 Canvas
网页动画有 CSS、JavaScript、Canvas、SVG 等多种实现方式,选型取决于动画的复杂度与性能需求。本文从帧驱动动画的核心 API requestAnimationFrame 讲起,覆盖 CSS 过渡与关键帧动画、Web Animations API,再深入 Canvas 2D 的绘制体系,最后给出不同方案的选型对比。
一、requestAnimationFrame 原理与用法
为什么需要它
setTimeout / setInterval 只是把回调塞进任务队列,运行时机与屏幕刷新无关。而浏览器通常以 60Hz(每秒 60 帧)刷新屏幕,每帧约 16.7ms。用定时器驱动动画,可能一帧内执行多次(浪费)或两帧之间缺失(掉帧),造成卡顿。requestAnimationFrame 由浏览器在每一帧开始绘制前回调,与屏幕刷新完全同步。
| 对比项 | setTimeout 动画 | requestAnimationFrame |
|---|---|---|
| 执行时机 | 到点入队,受队列阻塞影响 | 每帧绘制前,与刷新率同步 |
| 帧率 | 不固定,写死 16.7ms 与 60Hz 匹配才顺滑 | 自动匹配设备刷新率(60/90/120Hz) |
| 后台标签页 | 仍执行,浪费性能 | 自动暂停,节省资源 |
| 节流 | 需手动处理 | 浏览器自动合并为每帧一次 |
| 典型用法 | 非动画的延迟任务 | 动画、图表刷新、滚动节流 |
基本用法
const box = document.getElementById("box");
let start = null;
function step(timestamp) {
// timestamp 是当前帧的起始时间(毫秒),可用于计算进度
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / 2000, 1); // 2 秒动画
box.style.transform = `translateX(${progress * 300}px)`;
if (progress < 1) {
requestAnimationFrame(step); // 继续下一帧
}
}
requestAnimationFrame(step);取消回调
requestAnimationFrame 返回一个 id,用 cancelAnimationFrame(id) 取消:
let rafId = null;
function animate() {
// ...动画逻辑
rafId = requestAnimationFrame(animate);
}
function stop() {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
animate();
// 需要停止时调用 stop()计算帧率与耗时
每帧回调的 timestamp 与上一帧之差即为帧间隔,可用来统计实际帧率:
let last = 0;
let frames = 0;
let fps = 0;
function loop(now) {
if (last !== 0) {
const gap = now - last; // 帧间隔 ms
fps = Math.round(1000 / gap); // 换算成 FPS
}
last = now;
frames++;
if (frames % 60 === 0) console.log("当前帧率约:", fps);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);二、CSS 动画与过渡
transition 过渡
transition 让属性值的变化平滑过渡,由状态 A 变为状态 B 时生效:
.box {
width: 100px;
background: #4f9cf9;
transition: width 0.5s ease, background 0.3s linear;
}
.box:hover {
width: 200px;
background: #f95f5f;
}| 子属性 | 作用 |
|---|---|
transition-property | 指定过渡的属性(all 表示全部) |
transition-duration | 过渡时长,如 0.3s |
transition-timing-function | 缓动函数:linear、ease、ease-in、ease-out、cubic-bezier() |
transition-delay | 延迟开始时间 |
注意 transition 只能响应离散的状态切换,无法描述中间多次变化,比如循环往返动画就不适合。
animation 关键帧动画
@keyframes 定义多段状态,animation 控制播放:
@keyframes bounce {
0% { transform: translateY(0); }
50% { transform: translateY(-40px); }
100% { transform: translateY(0); }
}
.ball {
animation: bounce 1s ease-in-out infinite;
}| 子属性 | 作用 |
|---|---|
animation-name | 引用的 @keyframes 名称 |
animation-duration | 单次播放时长 |
animation-timing-function | 缓动函数 |
animation-delay | 延迟播放 |
animation-iteration-count | 播放次数,infinite 无限次 |
animation-direction | normal / reverse / alternate(往复) |
animation-fill-mode | 播放前后状态:none / forwards / backwards / both |
animation-play-state | running / paused,可用 JS 控制暂停 |
用 JS 控制 CSS 动画
const ball = document.querySelector(".ball");
// 暂停与恢复
ball.style.animationPlayState = "paused";
ball.style.animationPlayState = "running";
// 监听动画结束
ball.addEventListener("animationend", () => {
console.log("动画播放完毕");
});transition 与 animation 对比
| 对比项 | transition | animation |
|---|---|---|
| 触发方式 | 属性变化自动触发 | 定义后自动播放 |
| 中间帧控制 | 只有起始与结束 | @keyframes 可定义多帧 |
| 循环播放 | 不支持 | 支持 infinite |
| 暂停控制 | 不支持 | 支持 play-state |
| 适用场景 | hover 等状态切换 | 持续的循环动画、复杂序列 |
三、Web Animations API(WAAPI)
WAAPI 是浏览器原生的 JavaScript 动画接口,element.animate() 一出手就相当于同时具备 CSS 动画的能力与 JS 的灵活控制:
const box = document.getElementById("box");
const anim = box.animate(
[
{ transform: "translateX(0)", opacity: 1 },
{ transform: "translateX(300px)", opacity: 0.3 },
],
{
duration: 1000,
easing: "ease-in-out",
iterations: Infinity,
direction: "alternate",
}
);动画对象提供完整的播放控制 API:
| 方法 | 作用 |
|---|---|
anim.play() | 播放 |
anim.pause() | 暂停 |
anim.reverse() | 反向播放 |
anim.cancel() | 取消并回到初始状态 |
anim.finish() | 立即跳到最后一帧 |
anim.currentTime | 读写当前播放进度(毫秒) |
// 暂停与恢复、拖动进度条同步
anim.pause();
anim.currentTime = 500; // 跳到中间位置
anim.finished.then(() => {
console.log("动画结束");
});anim.finished 返回 Promise,便于用 await 串联动画序列。WAAPI 与 CSS 动画可以互相补充:能用 CSS 的用 CSS,需要精细控制(进度条拖拽、播放序列)时用 WAAPI。
四、Canvas 2D 基础
<canvas> 是一个矩形绘图区域,所有绘制都通过 JavaScript 在 2D 上下文上进行。它适合逐帧重绘的动态画面(游戏、粒子、图表)。
画布尺寸与 CSS 尺寸
画布有两套尺寸:width / height 属性决定绘图缓冲区大小;CSS 样式决定元素在页面上的显示大小。两者不一致会导致模糊或缩放:
<canvas id="game" width="800" height="600"></canvas>/* 显示为 400x300,绘图区仍是 800x600,等比缩小 */
canvas { width: 400px; height: 300px; }高分屏适配:物理像素密度为 devicePixelRatio(常见 2 或 3),直接把绘图区放大到物理像素,再按比例缩放坐标,画面才清晰:
const canvas = document.getElementById("game");
const dpr = window.devicePixelRatio || 1;
canvas.width = 800 * dpr;
canvas.height = 600 * dpr;
canvas.style.width = "800px";
canvas.style.height = "600px";
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr); // 之后按 800x600 的逻辑坐标绘制坐标系
Canvas 坐标系以左上角为原点,x 轴向右、y 轴向下,单位是像素。
基础绘制
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
// 1. 矩形
ctx.fillStyle = "#4f9cf9";
ctx.fillRect(20, 20, 100, 60); // 实心矩形
ctx.strokeStyle = "#333";
ctx.strokeRect(140, 20, 100, 60); // 描边矩形
// 2. 路径:先 beginPath,再 moveTo/lineTo 等,最后 fill 或 stroke
ctx.beginPath();
ctx.moveTo(20, 120);
ctx.lineTo(120, 180);
ctx.lineTo(20, 180);
ctx.closePath();
ctx.fillStyle = "#f9a04f";
ctx.fill(); // 三角形
// 3. 圆
ctx.beginPath();
ctx.arc(200, 150, 40, 0, Math.PI * 2); // 圆心、半径、起止弧度
ctx.fillStyle = "#4f9cf9";
ctx.fill();
// 4. 文本
ctx.font = "24px sans-serif";
ctx.fillStyle = "#333";
ctx.fillText("你好,Canvas", 260, 100); // 实心文本
ctx.strokeText("描边文本", 260, 140); // 描边文本
// 5. 图片
const img = new Image();
img.onload = () => ctx.drawImage(img, 260, 160, 100, 80);
img.src = "avatar.png";渐变与阴影
// 线性渐变
const gradient = ctx.createLinearGradient(0, 0, 200, 0);
gradient.addColorStop(0, "#4f9cf9");
gradient.addColorStop(1, "#f95f5f");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 200, 80);
// 径向渐变
const radial = ctx.createRadialGradient(100, 150, 10, 100, 150, 60);
radial.addColorStop(0, "#fff");
radial.addColorStop(1, "#4f9cf9");
ctx.fillStyle = radial;
ctx.beginPath();
ctx.arc(100, 150, 60, 0, Math.PI * 2);
ctx.fill();
// 阴影
ctx.shadowColor = "rgba(0,0,0,0.4)";
ctx.shadowBlur = 10;
ctx.shadowOffsetX = 4;
ctx.shadowOffsetY = 4;
ctx.fillStyle = "#4f9cf9";
ctx.fillRect(300, 20, 100, 60);save 与 restore
ctx.save() 保存当前状态(样式、变换、阴影等),ctx.restore() 恢复。常用于组合绘制时避免状态互相污染:
ctx.fillStyle = "red";
ctx.save();
ctx.fillStyle = "blue";
ctx.fillRect(0, 0, 50, 50); // 蓝色
ctx.restore();
ctx.fillRect(60, 0, 50, 50); // 红色,因为状态已恢复变换:translate / rotate / scale
变换作用于坐标系而非图形,先变换后绘制。translate 移动原点,rotate 旋转,scale 缩放:
// 让矩形绕自身中心旋转:先平移到中心,再旋转,再画
ctx.save();
ctx.translate(200, 200); // 原点移到 (200,200)
ctx.rotate(Math.PI / 4); // 旋转 45°
ctx.scale(1.5, 1.5); // 放大 1.5 倍
ctx.fillRect(-50, -25, 100, 50); // 以新原点为中心绘制
ctx.restore();动画循环
Canvas 动画就是"每帧清屏 → 更新状态 → 重新绘制"的循环:
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
let x = 0;
let speed = 3;
function draw(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 1. 清屏
x += speed; // 2. 更新状态
if (x > canvas.width || x < 0) speed = -speed; // 撞墙反弹
ctx.beginPath(); // 3. 绘制
ctx.arc(x, 150, 30, 0, Math.PI * 2);
ctx.fillStyle = "#4f9cf9";
ctx.fill();
requestAnimationFrame(draw); // 4. 下一帧
}
requestAnimationFrame(draw);像素操作 getImageData
getImageData 读取画布像素(RGBA 数组),putImageData 写回。可做滤镜、取色、图像处理:
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// 灰度化:遍历每个像素,取 RGB 平均
for (let i = 0; i < data.length; i += 4) {
const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = gray;
data[i + 1] = gray;
data[i + 2] = gray;
}
ctx.putImageData(imageData, 0, 0);注意 getImageData 在画布被跨域图片污染后会抛出安全错误,需要图片服务器返回正确的 CORS 头并用 img.crossOrigin = "anonymous"。
五、Canvas 与 DOM 动画对比选型
| 对比项 | DOM/CSS 动画 | Canvas |
|---|---|---|
| 绘制方式 | 操作真实 DOM,浏览器负责渲染 | 逐像素绘制,完全由 JS 控制 |
| 元素数量 | 几百个以内尚可,过多卡顿 | 成千上万粒子无压力 |
| 交互 | 自带 DOM 事件、命中检测 | 需自行实现坐标命中 |
| 可访问性 | 内容可被搜索引擎与读屏器读取 | 内容不可见(对辅助技术是黑盒) |
| 开发成本 | 低,声明式 | 高,命令式逐帧绘制 |
| 适用场景 | 列表、按钮、页面级动效 | 游戏、粒子、图表、图像处理 |
选型建议:少量元素、需要语义化 → 用 DOM/CSS 动画;大量动态元素、每帧全量刷新 → 用 Canvas。
六、SVG 与 Canvas 对比
SVG 是矢量图形描述语言,图形以节点形式存在于 DOM 中,可被 CSS 与事件操作;Canvas 是位图绘制,绘制后没有节点概念。
| 对比项 | SVG | Canvas |
|---|---|---|
| 图形本质 | 矢量(可无限缩放不失真) | 位图(放大变模糊) |
| 是否在 DOM 中 | 是,可被 CSS/事件操作 | 否,只是画布像素 |
| 元素数量 | 图形多时性能下降 | 适合海量图形 |
| 清晰度 | 与分辨率无关,适配高分屏 | 需手动按 dpr 放大绘图区 |
| 动画 | 可配合 CSS/SMIL 动画 | 逐帧重绘 |
| 典型场景 | 图标、流程图、简单数据图 | 游戏、实时图表、图像处理 |
| 事件绑定 | 每个图形可独立绑定 | 需自行计算坐标 |
SVG 适合图形数量少、需要交互与缩放的场景;Canvas 适合图形数量巨大、性能优先的场景。 两者也可以混用,例如用 SVG 画静态背景、用 Canvas 叠加快速运动的粒子层。
掌握 requestAnimationFrame 的帧调度、CSS 动画的声明式写法与 Canvas 的命令式绘制,再结合场景选型,就能做出流畅且贴合需求的各类动画。