从 Canvas 画方块到打飞机
本文记录了我从零开始学习 Canvas 绘图、用 requestAnimationFrame 做游戏循环,再到用 ECharts 做数据可视化的全过程。所有代码都是我自己写的,附带了和 AI 对话的提示词,希望能给正在学习前端可视化的同学一些参考。
一、先扔个方块:Canvas 初体验
学习前端可视化,绕不开 Canvas。这东西说白了就是一个画布标签,配合 JS API,你想怎么画就怎么画。
第一个 Demo:画矩形
最简单的,画一个蓝色矩形:
js
const canvas = document.getElementById('myCanvas')
const ctx = canvas.getContext('2d')
ctx.fillStyle = '#4299E1'
ctx.fillRect(20, 20, 100, 80)
getContext('2d') 拿到的就是画笔上下文,后面所有绘制操作都通过它来执行。fillRect(x, y, width, height) 从左上角开始画矩形,坐标原点在 Canvas 左上角。
我还试了画边框矩形和清除矩形:
js
ctx.strokeStyle = '#f56565' // 边框颜色
ctx.lineWidth = 4 // 边框宽度
ctx.strokeRect(150, 20, 100, 80) // 画边框矩形
ctx.clearRect(50, 50, 40, 30) // 擦掉一小块
clearRect 就像橡皮擦,能把画布上指定区域清空。
让方块动起来
光画静态的没意思,我想让方块动起来。最简单的思路:先擦掉旧的,再画新的。
js
let x = 20
const speed = 3
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height) // 擦掉整块画布
ctx.fillStyle = '#4299E1'
ctx.fillRect(x, 20, 100, 80) // 在新的位置画
x += speed // 更新位置
if (x > canvas.width) { // 超出边界就回到起点
x = -100
}
requestAnimationFrame(animate) // 请求下一帧
}
animate()
这里有一个关键点:为什么不用 setInterval,而用 requestAnimationFrame?
| 方法 | 说明 |
|---|---|
setInterval(cb, 16) |
强行每隔 16ms 执行一次,跟显示器刷新频率可能不同步,导致掉帧或卡顿 |
requestAnimationFrame(cb) |
浏览器自动跟随屏幕刷新率(60Hz),帧率协调,体验更流畅 |
我的笔记里有句话:「requestAnimationFrame 等于刷帧率,体验更协调」。这就是它的核心优势------跟显示设备同频共振。
二、上强度:用 Canvas 写一个打飞机小游戏
方块会动了,那就搞点真东西------打飞机小游戏。
提示词
我是这样让 AI 帮我梳理需求的:
用原生 HTML Canvas 写一个打飞机小游戏:
- 底部玩家飞机,方向键上下左右移动,边界限制
- 空格键发射子弹,子弹向上飞行
- 顶部随机生成敌机向下移动
- 子弹击中敌机销毁并加分,敌机碰到玩家游戏结束
- 用 requestAnimationFrame 做游戏循环,全部绘制在 canvas 里,不要引入第三方游戏引擎
项目结构
bash
ailplane/
├── index.html
├── package.json
├── src/
│ ├── main.js # 入口:初始化 Canvas、键盘监听、启动循环
│ ├── game.js # 游戏主循环:update → draw
│ ├── player.js # 玩家飞机:移动、绘制、碰撞包围盒
│ ├── bullet.js # 子弹系统:发射、移动、回收
│ ├── enemy.js # 敌机系统:随机生成、动态难度、移动
│ ├── collision.js # AABB 碰撞检测
│ ├── renderer.js # 渲染:星空背景、HUD、Game Over
│ └── style.css
核心架构:游戏循环
整个游戏的核心在 game.js,遵循标准的游戏循环模式:
js
class Game {
loop(keys) {
const now = performance.now()
this.update(keys, now) // 第一步:更新逻辑
this.draw() // 第二步:绘制画面
this.animationId = requestAnimationFrame(() => this.loop(keys))
}
}
每一帧做两件事:
- update --- 处理输入、移动物体、检测碰撞、更新分数
- draw --- 清空画布,重新绘制所有对象
玩家飞机:用 API 画出来的战机
我没有用图片,而是直接用 Canvas API 手绘了一架飞机:
js
// player.js --- 绘制飞机
draw(ctx) {
const cx = this.x + this.width / 2
const cy = this.y + this.height / 2
ctx.save()
ctx.translate(cx, cy)
// 引擎火焰
ctx.fillStyle = '#ff6600'
ctx.beginPath()
ctx.moveTo(-8, this.height / 2 - 2)
ctx.lineTo(0, this.height / 2 + 12)
ctx.lineTo(8, this.height / 2 - 2)
ctx.closePath()
ctx.fill()
// 机身主体
ctx.fillStyle = '#4488ff'
ctx.beginPath()
ctx.moveTo(0, -this.height / 2) // 机头
ctx.lineTo(-10, this.height / 2 - 4) // 左下
ctx.lineTo(10, this.height / 2 - 4) // 右下
ctx.closePath()
ctx.fill()
ctx.restore()
}
save() 和 restore() 用来保存和恢复画笔状态,避免 translate 影响到后面的绘制。
碰撞检测:AABB 算法
子弹打没打中敌机,敌机有没有撞到玩家,靠的是 AABB(轴对齐包围盒)碰撞检测:
js
// collision.js
export function checkCollision(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
)
}
原理很简单:两个矩形的四条边,如果都没有在对方之外,那就碰撞了。 这比用圆形碰撞检测要轻量得多,适合子弹数量多的情况。
动态难度:分数越高敌机越快
js
// enemy.js
getSpeed(score) {
const speed = this.baseSpeed + Math.floor(score / 100) * 0.5
return Math.min(speed, MAX_SPEED)
}
每得 100 分,敌机速度就提升 0.5,上限 5。同时生成间隔也会随分数缩短,分数越高压力越大。
子弹冷却:防止贴脸秒杀
js
// bullet.js
const FIRE_COOLDOWN = 200 // 毫秒
fire(x, y, now) {
if (now - this.lastFireTime < FIRE_COOLDOWN) return
this.bullets.push(new Bullet(x, y))
this.lastFireTime = now
}
用时间戳控制发射频率,防止按住空格键变成机关枪。

三、数据可视化:用 ECharts 画柱状图
游戏写完了,换个口味。用 Vite 搭了一个 ECharts 项目,老板想看一年运动鞋的销售数据。
提示词
帮我随机生成肖氏电商集团一年运动鞋的每个月销售数据,有一定的涨跌,以百万元为单位,等下用于柱状图的绘制,单独帮我放到一个 data.js 文件中
数据文件
js
// data.js
export const salesData = {
company: '李氏电商集团',
product: '运动鞋',
unit: '百万元',
months: [
{ month: '1月', sales: 8.2 },
{ month: '2月', sales: 6.5 },
{ month: '3月', sales: 9.8 },
// ... 全年数据
{ month: '11月', sales: 21.9 },
{ month: '12月', sales: 10.4 },
],
}
数据有季节性涨跌:春节后走低(2月),暑期和双十一走高(10-11月),符合电商的真实规律。
用 ECharts 绘制
js
import * as echarts from 'echarts'
const chartDom = document.getElementById('chart')
const myChart = echarts.init(chartDom)
const option = {
xAxis: { type: 'category', data: categories },
yAxis: { type: 'value', name: '销售额(百万元)' },
series: [{
type: 'bar',
data: values,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 1, color: '#188df0' },
]),
borderRadius: [4, 4, 0, 0],
},
}],
}
myChart.setOption(option)
这里用了 线性渐变 给柱状图着色,顶部浅蓝、底部深蓝,比纯色好看很多。borderRadius 让柱子顶部带圆角,细节提升质感。

四、经验和总结
| 模块 | 技术栈 | 核心知识点 |
|---|---|---|
| Canvas 基础 | 原生 Canvas 2D API | fillRect、clearRect、坐标系统 |
| 动画循环 | requestAnimationFrame | GPU 同步、帧动画模式 |
| 打飞机游戏 | Canvas + Vite | 游戏循环、AABB碰撞、对象池 |
| 数据可视化 | ECharts + Vite | 柱状图配置、线性渐变 |
几个值得记住的点:
- Canvas 是状态机 ---
save/restore管理绘制状态,translate/rotate变换坐标系,这些是写复杂绘制逻辑的基础 - 游戏循环 = update + draw --- 逻辑和数据更新分离,每一帧先算后画,这是所有游戏引擎的底层模式
- 不要用 setInterval 做动画 ---
requestAnimationFrame是浏览器亲儿子,帧率同步、省电、不掉帧 - ECharts 配置项很丰富 --- 渐变、圆角、tooltip 自定义,花点时间调样式,数据展示效果会好很多
以上就是我这三天的学习成果。从一个蓝色方块到完整的打飞机游戏,再到数据可视化图表,每一步都踩了一些坑但也学到了东西。
欢迎评论区交流:你写过哪些有意思的 Canvas 项目?或者你最想实现什么效果但一直没动手?