HTML5 Canvas 基础:从按帧动画到 ECharts 数据可视化
这一次让我们将目光转向前端绘画的基础,Canvas 标签。Canvas(画布)是HTML5中一个非常重要的元素,它提供了一个可以通过JavaScript绘制图形的区域.
为什么前端需要 Canvas
在了解<canvas> 之前,我对前端的"画"能力理解得比较简单:HTML 搭结构、CSS 做样式、JavaScript 处理交互。但 <canvas> 提供的是另一套能力------直接在浏览器里进行位图绘制。
Canvas 的典型应用场景主要有三类:
- 数据可视化(图表、大屏)
- 网页游戏(2D / 3D)
- 炫酷交互页面
Canvas 本身只提供一块"画布",真正的绘制是由 JavaScript 的 Canvas API 完成的。也就是说,<canvas> 是舞台,JavaScript 才是演员和导演。
Canvas 标签:先有一块画布
使用 <canvas> 标签可以在页面中开辟一块位图绘制区域。width 和 height 属性决定的是画布的实际像素尺寸,而不是 CSS 里的显示尺寸。如果这两个属性没设置,默认值是 300×150。
不支持 Canvas 的浏览器会显示标签内的降级文本,这有点像 <video> 标签的 fallback 机制。
html
<canvas id="myCanvas" width="400" height="400" style="border: 1px solid #333;">
你的浏览器不支持 canvas 标签(旧 IE 会显示这段文字)。
</canvas>
先放 <canvas>,再用 <script> 获取它并绘制。
获取 2D 上下文:所有绘制的入口
Canvas 标签准备好之后,下一步是获取它的"上下文对象"。后续所有绘制操作都通过该对象完成。
js
// 画布元素,canvas 中标签知识开始
const canvas = document.querySelector('#myCanvas');
// 绘画的上下文对象
const ctx = canvas.getContext('2d');
这里有两个要点我记了下来:
getContext('2d')返回的是 2D 绘图上下文,是我们这节课的主要战场。- 如果使用
getContext('webgl'),则可以进入 3D 上下文,调用 GPU 能力。
这里提一嘴,关于 3D 方向:AI 游戏方向可以重点关注 three.js 框架,物理大模型兴起之后,three.js 在 3D 交互与可视化领域前景较好。虽然这节课不深入去聊 3D 相关,但以后如果走游戏或 3D 可视化方向,three.js 是一个重要线索。
常用绘制 API:矩形、颜色与线条
Canvas 的 2D API 里,矩形是最基础的图形。这里有三个方法:
fillRect(x, y, width, height):填充矩形strokeRect(x, y, width, height):描边矩形clearRect(x, y, width, height):以矩形区域清除像素
以及三个常用属性:
fillStyle:填充色strokeStyle:描边色lineWidth:描边宽度
1.html 把这六个 API 串在了一起,注释也保留得很完整:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 炫酷新特性 - Canvas 画布</title>
</head>
<body>
<!-- width/height 属性 是画布的实际像素尺寸 -->
<canvas id="myCanvas" width="400" height="400"
style="border: 1px solid #333;">
你的浏览器不支持 canvas 标签(旧IE会显示这段文字)。
</canvas>
<script>
// 画布元素,canvas 中标签知识开始
const canvas = document.querySelector('#myCanvas');
// 绘画的上下文对象
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'red';//填充的颜色
ctx.fillRect(10, 10, 100, 100);//rect:矩形
ctx.strokeStyle = 'blue';//边框的颜色
ctx.lineWidth = 5;//边框的宽度
ctx.strokeRect(10, 10, 100, 100);//strokeRect:边框矩形
ctx.clearRect(10, 10, 100, 100);//clearRect:清除矩形区域
</script>
</body>
</html>
这段代码执行后的效果我记得很清楚:先画一个红色实心矩形,再给它画一个蓝色边框,最后用 clearRect 把这块区域擦掉。clearRect 在后面的帧动画里会非常重要。
帧动画:先擦掉,再重画
讲完静态绘制,接下来就是动画。按帧动画的本质很简单:先擦除上一帧,再绘制当前帧。
显示设备(以手机为例)通常以 60Hz 刷新,也就是每秒 60 帧。每一帧都执行三个动作:
- clear:清除上一帧
- update:更新当前状态
- draw:绘制当前状态
为什么用 requestAnimationFrame
特别强调,做动画不要用 setInterval,而要用 requestAnimationFrame。原因是:
setInterval的固定时间间隔可能与显示设备刷新率不一致,导致掉帧、卡顿或无效绘制。requestAnimationFrame直接对接显示设备的刷新信号,体验更顺滑,也是性能优化的关键。
用法是在绘制函数末尾递归调用 requestAnimationFrame(draw)。
移动的小方块
课堂示例 2.html 实现了这个逻辑,方块从左向右移动,超出画布后从左侧重新进入:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- 原本 html 页面中是 dom 树,现在要加上 canvas 标签渲染区域 -->
<!-- width/height 属性 是画布的实际像素尺寸 -->
<canvas id="myCanvas" width="400" height="400"
style="border: 1px solid #333;">
你的浏览器不支持 canvas 标签(旧IE会显示这段文字)。
</canvas>
<script>
// 画布元素,canvas 中标签知识开始
const canvas = document.querySelector('#myCanvas');
// 绘画的上下文对象
// 3d 会激发显存 GPU 能力
const ctx = canvas.getContext('2d');
let x = 20;
let y = 20;
const width = 100;
const height = 80;
const speed = 3;
function animate() {
// 清除画布:檫除上一帧绘制的矩形区域
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'red';
ctx.fillRect(x, y, width, height);
x += speed;
if(x > canvas.width) {
x = -width;
}
// 请求关键帧动画
requestAnimationFrame(animate);
}
animate();
</script>
</body>
</html>
这个例子把动画循环的三步体现得很清楚:
clearRect擦除整个画布;fillRect在新位置画矩形;x += speed更新下一帧的位置;requestAnimationFrame(animate)请求下一帧。
"帧动画 + 交互 = 游戏。" 在每一帧中处理用户输入、碰撞检测、状态更新,就可以构建完整的游戏循环。
飞机小游戏:把帧动画做成一个完整项目
这节课的实战部分是一个用 Vibe Coding 方式做出来的飞机小游戏。让我们走一遍流程:
- 使用 Vite 初始化工程,配合 Git 管理版本。
- 与 AI Agent 进行头脑风暴:
- 产品侧:列出游戏功能列表,先聚焦 MVP(最小可行性方案)。
- 技术侧:确定技术路线与架构方案。
- 由 LLM 生成代码,再人工 Review 与迭代。
工程结构很标准:
index.html:页面入口src/main.js:游戏主逻辑src/style.css:基础样式
页面与样式
index.html 非常简单,只有一个全屏的 <canvas>:
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>雷霆飞机</title>
</head>
<body>
<canvas id="gameCanvas"></canvas>
<script type="module" src="/src/main.js"></script>
</body>
</html>
style.css 把画布铺满全屏:
css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
package.json 里只依赖了 Vite:
json
{
"name": "airplane",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^8.1.0"
}
}
游戏主逻辑
src/main.js 是这整个项目的核心。老师没有一行一行讲,但带我们看了整体结构。我把它按模块拆开来理解。
首先是画布初始化和游戏状态:
js
import './style.css'
// ==================== 画布初始化 ====================
const canvas = document.getElementById('gameCanvas')
const ctx = canvas.getContext('2d')
function resizeCanvas() {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
resizeCanvas()
window.addEventListener('resize', resizeCanvas)
// ==================== 游戏状态 ====================
const GAME_STATE = {
READY: 0,
PLAYING: 1,
OVER: 2,
}
let state = GAME_STATE.READY
let score = 0
let lastEnemySpawn = 0
let enemySpawnInterval = 1200 // 毫秒,敌机生成间隔
然后是按键管理,支持方向键和 WASD:
js
// ==================== 按键管理 ====================
const keys = {}
window.addEventListener('keydown', (e) => {
keys[e.code] = true
// 空格发射子弹(防止长按连发太快,用节流控制)
if (e.code === 'Space' && state === GAME_STATE.PLAYING) {
e.preventDefault()
playerShoot()
}
// 回车开始/重新开始
if (e.code === 'Enter') {
if (state === GAME_STATE.READY || state === GAME_STATE.OVER) {
startGame()
}
}
})
window.addEventListener('keyup', (e) => {
keys[e.code] = false
})
玩家飞机、子弹、敌机、爆炸效果都被拆成了独立模块。比如玩家飞机:
js
// ==================== 玩家飞机 ====================
const player = {
x: 0,
y: 0,
width: 50,
height: 40,
speed: 6,
color: '#00bfff',
lastShoot: 0,
shootCooldown: 150, // 毫秒
}
function resetPlayer() {
player.x = canvas.width / 2 - player.width / 2
player.y = canvas.height - player.height - 30
}
function updatePlayer() {
if (keys['ArrowLeft'] || keys['KeyA']) {
player.x -= player.speed
}
if (keys['ArrowRight'] || keys['KeyD']) {
player.x += player.speed
}
if (keys['ArrowUp'] || keys['KeyW']) {
player.y -= player.speed
}
if (keys['ArrowDown'] || keys['KeyS']) {
player.y += player.speed
}
// 边界限制
if (player.x < 0) player.x = 0
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width
if (player.y < 0) player.y = 0
if (player.y + player.height > canvas.height) player.y = canvas.height - player.height
}
敌机系统里有随机生成和难度递增逻辑:
js
function spawnEnemy() {
const w = 36 + Math.random() * 20 // 36~56
const h = w * 0.8
enemies.push({
x: Math.random() * (canvas.width - w),
y: -h,
width: w,
height: h,
speed: 1.5 + Math.random() * 2, // 1.5~3.5
type: Math.random() < 0.2 ? 'red' : 'gray', // 20%红色精英
hp: Math.random() < 0.2 ? 2 : 1,
})
}
function updateEnemies() {
const now = Date.now()
// 定时生成敌机
if (now - lastEnemySpawn > enemySpawnInterval) {
spawnEnemy()
lastEnemySpawn = now
// 随分数增加难度:加快生成间隔
enemySpawnInterval = Math.max(400, 1200 - score * 2)
}
// ...
}
碰撞检测用的是 AABB(轴对齐边界框)算法:
js
// ==================== 碰撞检测 ====================
function rectCollide(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
)
}
最后是游戏主循环,把之前学的 "clear → update → draw → requestAnimationFrame" 完整落地:
js
// ==================== 游戏主循环 ====================
let lastTime = 0
function gameLoop(timestamp) {
// 清屏
ctx.fillStyle = '#000011'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 星空始终绘制
updateStars()
drawStars()
if (state === GAME_STATE.READY) {
drawReadyScreen()
} else if (state === GAME_STATE.PLAYING) {
// 更新
updatePlayer()
updateBullets()
updateEnemies()
checkCollisions()
updateExplosions()
// 绘制
drawBullets()
drawEnemies()
drawPlayer()
drawExplosions()
drawScore()
} else if (state === GAME_STATE.OVER) {
// 游戏结束后仍然绘制残余爆炸和分数
updateExplosions()
drawBullets()
drawEnemies()
drawExplosions()
drawScore()
drawGameOverScreen()
}
requestAnimationFrame(gameLoop)
}
// 启动
resetPlayer()
requestAnimationFrame(gameLoop)
这个例子让我真正理解了"帧动画 + 交互 = 游戏"这句话。游戏不是一次性画出来的,而是每一帧都在擦除、更新、重画。
ECharts:声明式的数据可视化
最后让我们来聊聊 ECharts,一个支持 Canvas 和 SVG 两种渲染方式的前端可视化框架。它支持柱状图、折线图、饼图、地图、雷达图等多种图表类型,适合报表、仪表盘、大屏等数据展示场景。
ECharts 的核心思想
ECharts 最大的特点是声明式配置 。我们不需要像 Canvas 那样手动调用 fillRect 画每一个柱子,只需要告诉 ECharts"我要什么图表、数据是什么、样式怎么配",它会自动帮我们渲染出来。
使用方式可以总结为四步:
- 引入 ECharts
- 准备一个 DOM 容器(通常是
div) - 配置
option对象(描述图表类型、数据、样式) - 调用
echarts.init()与setOption()渲染图表
用 AI Agent 生成 ECharts 页面
这节课的实践环节,我们尝试用 AI Agent 来生成一个完整的数据可视化页面。具体流程是:
- 虚构数据:我们编了一个"肖式电商集团",设定了它一年的运动鞋销售数据(12个月,单位:百万元),以及各类商品占比、区域销售分布等维度。
- 向 Agent 描述需求 :告诉 AI 我们需要一个数据大屏,包含这些图表:
- 全年销售趋势(折线图)
- 商品类别占比(饼图)
- 区域销售分布(柱状图)
- 核心 KPI 数字(总销售额、订单数、活跃用户、转化率)
- Agent 生成代码 :AI 根据我们的描述,自动生成了完整的 HTML、CSS 和 JavaScript 代码,包括 ECharts 的
option配置。 - 人工 Review 与迭代:我们检查生成的代码,调整样式细节,确保数据展示符合预期。
ECharts 的 option 长什么样
这是 ECharts 配置的核心。一个典型的 option 对象结构如下:
js
const option = {
title: { text: '肖式电商集团 - 年度运动鞋销售趋势' },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: monthLabels },
yAxis: { type: 'value', name: '销售额(百万元)' },
series: [{ data: salesData, type: 'bar' }]
}
可以看到,我们只需要描述"标题是什么、X 轴是什么类型、数据是什么",ECharts 就会自动计算坐标、绘制柱子、添加交互。这就是"声明式"的魅力。
生成的页面效果
Agent 生成的页面包含多个图表和 KPI 面板,用 CSS Grid 布局成一个完整的大屏。页面会自动适应窗口大小,KPI 数字还有从 0 滚动到目标值的动画效果(这里用到了我们前面学的 requestAnimationFrame)。
这个实践让我意识到,ECharts 把 Canvas 的底层绘制能力封装成了声明式的配置。我们不需要手动计算每个柱子的坐标,只需要描述数据和样式。这和飞机小游戏中手写每一帧的绘制形成了一种有趣的对比:前者是"命令式绘制",后者是"声明式配置"。
课后复习:几个容易混淆的点
几个关键问题,我把它们也整理进来。
1. 为什么推荐 requestAnimationFrame
text
与屏幕刷新率同步:减少无效绘制,降低 CPU/GPU 开销。
自动节流:当页面不可见时会自动暂停,节省资源。
性能更好:比 setInterval/setTimeout 更适合动画循环。
2. 动画循环三步走
text
1. clear:清除上一帧
2. draw:绘制当前状态
3. update:更新下一状态
3. 自测题
getContext('2d')返回的对象作用是什么?fillRect与strokeRect有什么区别?clearRect在帧动画中起什么作用?- 为什么不建议用
setInterval做动画? - ECharts 与 Canvas 的关系是什么?
现在如何理解
这篇文章我希望能让大家对"前端能画什么"有了更具体的认识。
<canvas> 不是 DOM 的替代品,而是一种补充。当你需要大量动态图形、游戏画面、数据可视化时,Canvas 比操作 DOM 节点更高效。而 ECharts 这样的库则进一步说明:工程化社会里,我们很少会直接调用 Canvas API 画一个完整的大屏,更多时候是在已有的封装之上做配置和扩展。
飞机小游戏的例子还让我再次体会到 Vibe Coding 的工作流:先和 AI 一起把需求拆成 MVP,再由 AI 生成代码骨架,最后人工 Review、调试、迭代。Canvas 游戏本身的技术点固然重要,但更值得记住的是"怎么把一个想法变成可运行的项目"。
另外,requestAnimationFrame 这个知识点虽然小,但它连接了很多前端性能优化的基础概念:屏幕刷新率、渲染流水线、无效绘制。以后看到动画卡顿、游戏掉帧、大屏刷新问题时,我会先想到是不是动画调度方式没选对。
这节课没有明显的新框架要学,但让我们对浏览器底层绘图能力有了体感。接下来如果继续往游戏或可视化方向走,three.js 和更复杂的 ECharts 配置会是自然的延伸。