从Canvas到requestAnimationFrame:深入HTML5动画与游戏背后的核心原理

前言

当你打开一个炫酷的数据大屏,或者一个在浏览器里流畅运行的HTML5小游戏,背后支撑这一切的往往是同一个技术------Canvas 。但很多人写Canvas动画时,习惯性地复制粘贴,并不清楚每一帧发生了什么,更不理解 requestAnimationFrame 为何比 setInterval 高级。

本文不打算贴出一整篇雷霆战机代码或长篇ECharts配置(那些只是应用示例)。我们将直击本质:Canvas 的基础绘图API动画中的清除与重绘 、以及浏览器的帧动画调度器 requestAnimationFrame。读完此文,你不仅能流畅写出动画,还能讲清楚它为什么"顺滑且省电"。

适合读者:有JS基础,想系统掌握Canvas动画原理的前端开发者。


一、Canvas 标签与绘图上下文

1.1 画布元素

Canvas 是 HTML5 新增的绘图容器,本身没有任何绘图能力,它只提供一块"画布区域"。绘图全部由 JavaScript 完成。

html 复制代码
<canvas id="myCanvas" width="600" height="400" style="border:1px solid #ccc">
  你的浏览器不支持 Canvas(旧IE会显示这段文字)
</canvas>
  • width / height 属性定义画布的真实分辨率(像素尺寸),无单位,默认为 300×150。

  • CSS 的 width / height 只是显示缩放,不会改变绘图像素数。二者最好保持一致,否则图像会被拉伸。

1.2 获取绘图上下文

所有绘图操作都依赖一个 上下文对象(Context),它像一支笔,提供各种绘图方法

javascript 复制代码
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
  • getContext('2d') 返回 CanvasRenderingContext2D 实例,支持矩形、路径、文字、图像等绘图。

  • getContext('webgl') 可获取 WebGL 上下文,用于 3D 绘图(例如 Three.js 底层)。

注意:Canvas 不支持 getContext('3d'),那是早期某些浏览器的非标准实验。


二、基础绘图 API:矩形、颜色、擦除

Canvas 提供了最基础的图形------矩形的绘制方法,其他复杂图形(圆、线、多边形)需要通过路径(Path)实现。

2.1 实心矩形 fillRect

javascript 复制代码
ctx.fillStyle = '#4299e1';   // 设置填充色
ctx.fillRect(20, 20, 100, 80); // 在 (20,20) 绘制 100×80 的蓝色矩形
  • fillRect(x, y, width, height)(x,y) 是矩形左上角坐标(Canvas 坐标系原点在左上角,x 向右,y 向下)。

  • fillStyle 可以是任何 CSS 颜色值,一旦设置,后续所有实心图形都会沿用,直到重新赋值。

2.2 空心矩形 strokeRect

javascript 复制代码
ctx.strokeStyle = '#f56565';
ctx.lineWidth = 4;
ctx.strokeRect(150, 20, 100, 80);
  • strokeRect 只描边,内部透明。

  • strokeStyle 控制描边颜色,lineWidth 控制线宽(单位:像素)。

2.3 擦除区域 clearRect ------ 动画的基石

javascript 复制代码
ctx.clearRect(50, 50, 40, 30);
  • 清除指定矩形区域内的所有像素,使其完全透明(恢复初始状态)。

  • 为什么动画必须用它?

    动画的本质是每一帧绘制不同的画面。如果不擦除,上一帧的图形会残留,形成"拖尾"或"鬼影"。

    每一帧的正确流程clearRect(0,0,canvas.width,canvas.height) → 更新坐标 → 绘制新图形。

若不调用 clearRect,你将看到一长串矩形拖尾,那不是动画,而是"涂鸦"。

页面效果(滑动效果):


三、requestAnimationFrame ------ 专业的动画调度器

3.1 传统定时器的问题

早期的动画常使用 setIntervalsetTimeout

javascript 复制代码
setInterval(() => {
  // 更新并绘图
}, 1000 / 60);

但这种方式存在三大缺陷:

  1. 时间不精确:JS 是单线程,定时器回调可能被阻塞,实际间隔可能远大于设定值。

  2. 与屏幕刷新率不同步 :显示器一般 60Hz(约 16.6ms 一帧)。setInterval 几乎不可能恰好对齐这个节奏,可能导致丢帧(卡顿)或渲染撕裂。

  3. 不可见时依然执行:即使浏览器标签页被切换到后台,定时器仍在后台运行,浪费 CPU 和电量。

3.2 requestAnimationFrame 的工作原理

requestAnimationFrame 是浏览器专门为动画提供的 API。它会在浏览器下一次重绘之前调用指定的回调函数。

  • 频率自适应:通常匹配屏幕刷新率(60Hz → 约 16.6ms 调用一次)。若显示器是 144Hz,则回调更频繁。

  • 自动暂停:当页面切换到后台或不可见标签页时,回调会停止调用,节省资源。

  • 精确同步:回调执行时机与渲染流水线对齐,避免撕裂和多余重绘。

3.3 基本用法

javascript 复制代码
function draw(now) {   // now 是 DOMHighResTimeStamp,表示回调触发的时间
  // 更新动画状态
  // 绘图操作
  requestAnimationFrame(draw);   // 递归调用,形成循环
}
requestAnimationFrame(draw);

递归是必须的requestAnimationFrame 只请求一帧,如果你想要持续动画,必须在回调内部再次调用它。

3.4 对比示例:setInterval 与 requestAnimationFrame 的感性差异

假设我们让一个方块横向移动 1000 像素,两种方式:

  • setInterval 可能在 16.6ms 左右执行,但由于事件循环阻塞,实际可能跳跃到 20ms、30ms,移动不均,视觉上"一卡一卡"。

  • requestAnimationFrame 每次重绘前恰好执行,移动步长均匀,极其顺滑。

3.5 高级用法:基于时间的运动(与帧率解耦)

如果直接在每一帧中移动固定像素(如 x += 5),在高刷新率(144Hz)下物体会移动更快,在低刷新率下移动更慢。正确的做法是根据时间差移动:

javascript 复制代码
let lastTimestamp = 0;
let x = 0;
const speedPerSecond = 200; // 每秒移动 200 像素

function animate(now) {
  if (!lastTimestamp) lastTimestamp = now;
  const delta = Math.min(100, now - lastTimestamp); // 限制最大间隔
  x += speedPerSecond * delta / 1000; // 与时间成正比
  lastTimestamp = now;
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillRect(x, 20, 50, 50);
  
  requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

这样无论帧率是 30 还是 144,物体每秒移动距离恒定。

3.6 取消动画

使用 cancelAnimationFrame,传入 requestAnimationFrame 返回的 ID:

javascript 复制代码
let frameId = requestAnimationFrame(animate);
// 在某个条件满足时
cancelAnimationFrame(frameId);

四、Canvas 游戏循环的典型模式

虽然本文不重点展开游戏,但理解游戏循环有助于你掌握 requestAnimationFrame 的真实应用场景。

一个标准的游戏循环包含两大阶段:

  1. 更新(Update)

    • 处理用户输入(键盘、鼠标)

    • 更新所有实体坐标(子弹上移、敌机下移)

    • 碰撞检测与逻辑响应(加分、爆炸、游戏结束)

  2. 绘制(Draw)

    • clearRect 清空上一帧

    • 调用各种绘图 API 绘制当前帧的所有元素(玩家、子弹、敌机、UI)

整个循环由 requestAnimationFrame 驱动:

javascript 复制代码
function gameLoop() {
  update();   // 更新游戏状态
  draw();     // 重绘画布
  requestAnimationFrame(gameLoop);
}
gameLoop();

这种模式干净、高效,而且完全可控。相比 Vue/React 的声明式渲染,Canvas 游戏采用命令式逐帧绘制,这也是它性能极高的原因之一。


五、Canvas 与数据可视化(浅谈)

数据可视化图表(如折线图、柱状图、地图)也是 Canvas 的重要应用领域。著名的 ECharts 库底层可选用 Canvas 或 SVG。

用 Canvas 实现图表的基本思路:

  • fillRect 绘制柱状图;

  • lineTostroke 绘制折线;

  • fillText 绘制坐标轴标签。

ECharts 简化了大量底层细节,你只需配置数据即可得到炫酷的可视化效果。例如渐变柱状图(核心代码):

javascript 复制代码
series: [{
  type: 'bar',
  data: [8.2, 6.5, 9.1, ...],
  itemStyle: {
    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
      { offset: 0, color: '#667eea' },
      { offset: 1, color: '#764ba2' }
    ])
  }
}]

但无论如何封装,其背后依然是 Canvas 绘图 API 与 requestAnimationFrame 的巧妙结合(用于动画过渡)。

用AI vibe coding 的一张Ecart 效果:


六、总结与扩展

6.1 关键点回顾

知识点 核心作用 注意事项
canvas 标签 绘图容器 设置正确的 width/height,避免 CSS 缩放失真
getContext('2d') 获取画笔 上下文对象包含所有绘图方法
fillRect / strokeRect 绘制矩形 配合 fillStyle / strokeStyle 控制颜色
clearRect 擦除区域 每一帧动画的第一件事就是清空画布
requestAnimationFrame 动画调度 自动与刷新率同步,后台暂停,性能最佳

6.2 后续进阶方向

  • 路径绘图beginPath()arc()lineTo() 实现圆形、多边形、曲线。

  • 变换translaterotatescale 实现复杂图形移动。

  • 图像处理drawImage 绘制精灵图,getImageData / putImageData 实现像素滤镜。

  • 3D 扩展WebGL 或封装库 Three.js 进入三维世界。

6.3 最后的建议

不要依赖 setInterval 做动画。凡是涉及 Canvas 连续变化的需求(游戏、数据大屏滚动、交互动效),一律使用 requestAnimationFrame。它不仅能让你的动画丝滑流畅,更能体现你对浏览器渲染机制的理解深度。

如果你觉得这篇文章点透了 Canvas 动画的本质,欢迎点赞、收藏、转发。你也可以在评论区留言,告诉我你遇到过哪些 Canvas 动画的"坑",我们一起探讨。