HTML5 Canvas 基础:从按帧动画到 ECharts 数据可视化

HTML5 Canvas 基础:从按帧动画到 ECharts 数据可视化

这一次让我们将目光转向前端绘画的基础,Canvas 标签。Canvas(画布)是HTML5中一个非常重要的元素,它提供了一个可以通过JavaScript绘制图形的区域.

为什么前端需要 Canvas

在了解<canvas> 之前,我对前端的"画"能力理解得比较简单:HTML 搭结构、CSS 做样式、JavaScript 处理交互。但 <canvas> 提供的是另一套能力------直接在浏览器里进行位图绘制。

Canvas 的典型应用场景主要有三类:

  • 数据可视化(图表、大屏)
  • 网页游戏(2D / 3D)
  • 炫酷交互页面

Canvas 本身只提供一块"画布",真正的绘制是由 JavaScript 的 Canvas API 完成的。也就是说,<canvas> 是舞台,JavaScript 才是演员和导演。

Canvas 标签:先有一块画布

使用 <canvas> 标签可以在页面中开辟一块位图绘制区域。widthheight 属性决定的是画布的实际像素尺寸,而不是 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');

这里有两个要点我记了下来:

  1. getContext('2d') 返回的是 2D 绘图上下文,是我们这节课的主要战场。
  2. 如果使用 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 帧。每一帧都执行三个动作:

  1. clear:清除上一帧
  2. update:更新当前状态
  3. 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 方式做出来的飞机小游戏。让我们走一遍流程:

  1. 使用 Vite 初始化工程,配合 Git 管理版本。
  2. 与 AI Agent 进行头脑风暴:
    • 产品侧:列出游戏功能列表,先聚焦 MVP(最小可行性方案)。
    • 技术侧:确定技术路线与架构方案。
  3. 由 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"我要什么图表、数据是什么、样式怎么配",它会自动帮我们渲染出来。

使用方式可以总结为四步:

  1. 引入 ECharts
  2. 准备一个 DOM 容器(通常是 div
  3. 配置 option 对象(描述图表类型、数据、样式)
  4. 调用 echarts.init()setOption() 渲染图表

用 AI Agent 生成 ECharts 页面

这节课的实践环节,我们尝试用 AI Agent 来生成一个完整的数据可视化页面。具体流程是:

  1. 虚构数据:我们编了一个"肖式电商集团",设定了它一年的运动鞋销售数据(12个月,单位:百万元),以及各类商品占比、区域销售分布等维度。
  2. 向 Agent 描述需求 :告诉 AI 我们需要一个数据大屏,包含这些图表:
    • 全年销售趋势(折线图)
    • 商品类别占比(饼图)
    • 区域销售分布(柱状图)
    • 核心 KPI 数字(总销售额、订单数、活跃用户、转化率)
  3. Agent 生成代码 :AI 根据我们的描述,自动生成了完整的 HTML、CSS 和 JavaScript 代码,包括 ECharts 的 option 配置。
  4. 人工 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. 自测题

  1. getContext('2d') 返回的对象作用是什么?
  2. fillRectstrokeRect 有什么区别?
  3. clearRect 在帧动画中起什么作用?
  4. 为什么不建议用 setInterval 做动画?
  5. ECharts 与 Canvas 的关系是什么?

现在如何理解

这篇文章我希望能让大家对"前端能画什么"有了更具体的认识。

<canvas> 不是 DOM 的替代品,而是一种补充。当你需要大量动态图形、游戏画面、数据可视化时,Canvas 比操作 DOM 节点更高效。而 ECharts 这样的库则进一步说明:工程化社会里,我们很少会直接调用 Canvas API 画一个完整的大屏,更多时候是在已有的封装之上做配置和扩展。

飞机小游戏的例子还让我再次体会到 Vibe Coding 的工作流:先和 AI 一起把需求拆成 MVP,再由 AI 生成代码骨架,最后人工 Review、调试、迭代。Canvas 游戏本身的技术点固然重要,但更值得记住的是"怎么把一个想法变成可运行的项目"。

另外,requestAnimationFrame 这个知识点虽然小,但它连接了很多前端性能优化的基础概念:屏幕刷新率、渲染流水线、无效绘制。以后看到动画卡顿、游戏掉帧、大屏刷新问题时,我会先想到是不是动画调度方式没选对。

这节课没有明显的新框架要学,但让我们对浏览器底层绘图能力有了体感。接下来如果继续往游戏或可视化方向走,three.js 和更复杂的 ECharts 配置会是自然的延伸。

相关推荐
默_笙1 小时前
🎄 后端给我一堆扁平数据,我 10 行代码把它变成了树
前端·javascript
Mahut1 小时前
我用 Electron + FFmpeg 做了一个本地视频处理工作站 ClipForge
前端·ffmpeg·electron
前端Hardy1 小时前
又一个 AI 神器火了!
前端·javascript·后端
锋行天下1 小时前
我试图优化 Vite 的拆包,结果首屏慢了 10 倍
前端·vue.js·架构
PBitW2 小时前
GPT训练我的第二天,我表示不过如此!!!😕😕😕
前端·javascript·面试
用户99045017780092 小时前
学习了AI修图,我把自己闲鱼出租房照片整成airbnb风格了
前端
kyriewen3 小时前
白宫直接给 OpenAI 下了限制令,GPT-5.6 不能随便放出来了
前端·javascript·面试
PedroQue994 小时前
Vite插件v0.2.6:架构优化与自动化升级
前端·vite
threerocks5 小时前
什么?我连 A2A、MCP 都没学会,现在又来了 AG-UI、A2UI.
前端·aigc·ai编程