Skip to content

图形与动画(Canvas 与他的朋友们)

🎯 核心问题

以前的网页只能展示干巴巴的文字和图片。但如果你想做打砖块游戏、华丽的动态特效、或是可以自由拖拽的数据报表呢?这就是 Canvas(画布) 诞生的原因。


1. 什么是 Canvas?

如果说早期的那些 HTML 标签(如 <div><img>)是用乐高积木拼起一个静态的网页,那么 HTML5 的 <canvas> 标签就是扔给你一张巨大的数字白纸,然后递给你一支靠代码控制的画笔,剩下的全交给你自由发挥。

这里面的画没有任何标签结构,你用画笔涂上去的心血,一旦落笔就变成了最纯粹的“像素颜料”

1.1 Canvas vs SVG:两种不同流派的艺术家

在前端画图界,Canvas 有个宿敌叫 SVG。它们代表了两种截然不同的绘画观念:

Canvas(位图画板):

  • 原理:就像真实在纸上涂色,几笔画上去就变成一团颜料。
  • 优势:电脑只管往屏幕上“洒颜料”,性能起飞!能同时画出大几千个活蹦乱跳的闪烁粒子。
  • 缺点:画完就没法单独反悔(没法被 DOM 直接选择),而且你用浏览器一旦放大,画面就会马赛克发虚。

SVG(矢量图拼接):

  • 原理:就像在做幻灯片(PPT)。你画一个圆,它就生成一个圆圈的“实体对象”放在画面上。
  • 优势:不管被放大成 100 倍还是 10 万倍,永远极其清晰。而且因为每一个形状都是一个独立标签,你可以在任何时候用鼠标点中某个小正方形,命令它换一种颜色。
  • 缺点:如果你试图放几万个对象乱飞,繁重的排版引擎会直接把浏览器卡死。

🎮 简单总结:玩动态游戏、做酷炫粒子特效用 Canvas;画精密的 Logo、写交互清晰的小图表用 SVG。


2. 第一笔:用代码找坐标

2.1 这张纸的上下怎么颠倒了?

当你准备下笔时,得先明白 Canvas 里的尺子是反着的。对于传统的数学课坐标系,中心点零点在中间,越往上越大。

但在屏幕显示领域,几乎所有设备的“原点(0,0)”都定在屏幕的最左上角。向右走 X 轴变大没问题,但是向下走,Y 轴变大。

👇 动手点点看: 拖拽下面的这些点,直观地感受一下坐标是如何变化的:

Canvas Width:600px
Canvas Height:400px
Mouse Position:(0, 0)

Canvas Coordinate System / Canvas 坐标系统

  • Origin / 原点:在左上角,坐标为 (0, 0)
  • X Axis / X 轴:向右为正方向,从 0 到 canvas.width
  • Y Axis / Y 轴:向下为正方向,从 0 到 canvas.height
  • Unit / 单位:像素 (px),与 CSS 像素 1:1 对应

Example Code / 示例代码

// 绘制坐标轴
const canvas = document.getElementById('myCanvas')
const ctx = canvas.getContext('2d')

// X 轴(红色)
ctx.strokeStyle = '#e74c3c'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(canvas.width, 0)
ctx.stroke()

// Y 轴(蓝色)
ctx.strokeStyle = '#3498db'
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(0, canvas.height)
ctx.stroke()

// 绘制点
ctx.fillStyle = '#2ecc71'
ctx.beginPath()
ctx.arc(100, 100, 5, 0, Math.PI * 2)
ctx.fill()

💡提示: Canvas 的 Y 轴方向与传统数学坐标系相反,向下为正。这在处理图形定位时需要特别注意。

2.2 给你的魔法画笔上调料

有了坐标,我们就能召唤那支画笔了(在代码里这支笔叫 Context 或简称 ctx)。

就像拿着调色盘作画,流程总是固定的三步:

  1. 调色:告诉它你需要什么填充色(fillStyle)和描边色(strokeStyle
  2. 构形:构思你是画一个圈、还是一条直线?
  3. 下笔:实打实地去填充(fill( ))还是去勾勒边缘(stroke( )

👇 动手点点看: 试试把下面代码面板里的形状颜色换换:

🎨Canvas 基础用代码画图(通俗说:编程画板)

Code / 代码

const canvas = document.getElementById('myCanvas')
const ctx = canvas.getContext('2d')

ctx.fillStyle = '#3b82f6'
ctx.strokeStyle = '#1e293b'
ctx.lineWidth = 2

// 绘制填充矩形
ctx.fillRect(250, 150, 100, 100)

// 绘制描边矩形
ctx.strokeRect(250, 150, 100, 100)
💡核心思想:Canvas 是一个位图画布,所有绘制都是像素操作。绘制后无法修改已有内容,只能覆盖或清除重绘。

3. 翻页动画书:如何让画面动起来极度丝滑

我们刚才说过,Canvas 一旦你填上了颜色,这就变成了永久的马赛克。你怎么可能让马赛克奔跑呢?

答案是“骗过你的眼睛”。这和翻页手翻书或者电影胶片的原理一模一样。

如果你想让一个球飞起来:

  1. 擦黑板:用 clearRect 把这整块画布上的内容毫不留情地清空!
  2. 挪位置:让那个球的 X 坐标往前偷偷加 2 毫米。
  3. 下笔重画:把球在新的位置重新画一次。
  4. 疯狂循环:浏览器内置了一个极其精准的神仙秒表叫 requestAnimationFrame。它会以每秒 60 次(即 60 FPS)的变态速度,重复着【擦除 -> 移动 -> 重绘】。由于人眼自带“视觉残留”,你在屏幕上看到的,不仅不是黑板被擦,反而是如同丝绸般顺滑的动画。

👇 动手点点看: 尝试添加或者减少物体的数量,感受每秒 60 帧带来的无缝快感:

FPS:0
Frame:0

Animation Loop Code / 动画循环代码

// 弹跳球动画
let balls = [
  { x: 100, y: 100, vx: 2, vy: 3, radius: 20 },
  // ... 更多球
]

function animate(timestamp) {
  // 清除画布
  ctx.clearRect(0, 0, canvas.width, canvas.height)

  // 更新和绘制每个球
  balls.forEach(ball => {
    // 更新位置
    ball.x += ball.vx * 1
    ball.y += ball.vy * 1

    // 边界碰撞检测
    if (ball.x + ball.radius > canvas.width || ball.x - ball.radius < 0) {
      ball.vx = -ball.vx
    }
    if (ball.y + ball.radius > canvas.height || ball.y - ball.radius < 0) {
      ball.vy = -ball.vy
    }

    // 绘制
    ctx.beginPath()
    ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2)
    ctx.fill()
  })

  // 请求下一帧
  requestAnimationFrame(animate)
}

// 启动动画
requestAnimationFrame(animate)

Animation Principles / 动画原理

  • requestAnimationFrame: 浏览器提供的动画 API,在每次重绘前调用回调函数,通常为 60FPS
  • Clear & Redraw: 每帧先清除画布,再重新绘制所有内容
  • State Update: 更新对象位置、角度等状态
  • Performance: 使用时间差计算位置,确保不同刷新率下动画速度一致

💡提示: 动画的本质是快速连续绘制静态画面。Canvas 每秒可以绘制 60 帧(60FPS),形成流畅的动画效果。


4. 瞎子摸象:我在 Canvas 里面怎么点击?

因为 Canvas 画布就只是一张没有任何结构的“颜料布”。假设你在这个布上画了一只哥布林:

如果你想写个代码:“当玩家点中了哥布林,哥布林阵亡”。你根本没法像写普通网页那样通过 getElementById 去直接绑定这个外星怪物。因为在浏览器的眼里,这里永远没有任何怪兽,只有一块宽 600 高 400 的 <canvas> 标签死死挡在这里

那我们要怎么做事件交互呢?

  1. 监听布面被点:先获取你目前鼠标点在这个死板的 HTML 大布的哪个具体的 XY 位置。
  2. 拿账本去对:然后你必须自己翻你的代码记录,“我记得刚刚我在(100,100)的位置画了一个半径 50 的哥布林”。
  3. 勾股定理:我们用初中教的勾股定理公式去疯狂计算——当前鼠标点击的位置,是不是落在了那个(100,100)距离 50 半径的圆内?。

恭喜你!这种疯狂算几何数学距离的方法就是你在各大 3A 游戏里听过的 “碰撞检测 (Collision Detection)”

👇 动手点点看: 打开最下面的“Hover 悬停模式”,你就能看到它内部拼命去算距离有多累了。

Instructions / 操作说明

  • Click Mode:点击画布创建圆形,按住 Shift 可创建不同颜色

Event Log / 事件日志

Event Handling Code / 事件处理代码

// 点击创建圆形
canvas.addEventListener('click', (e) => {
  const rect = canvas.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top

  const circle = {
    x: x,
    y: y,
    radius: 30,
    color: '#3498db'
  }

  circles.push(circle)
  draw()
})

Event Handling Tips / 事件处理要点

  • 坐标转换: 使用 getBoundingClientRect() 获取 Canvas 在页面中的位置,计算相对坐标
  • 碰撞检测: 对于圆形,计算鼠标位置到圆心的距离;对于矩形,判断点是否在范围内
  • 事件委托: Canvas 只有一个元素,需要手动判断事件发生在哪个对象上
  • 性能优化: 使用 requestAnimationFrame 优化重绘,避免频繁操作

5. 解放算力:粒子系统与视觉魔法

到了这一步,当你把【坐标不断重绘的动画】跟【颜色和大小变换】融合,再放进成百上千个小碎片里。这就是引爆视觉的终极杀器:粒子系统

你只需要建立一个巨大的数组,里面塞满了几百个拥有独立生命值、独立初始随机速度的数字对象。每次“重绘”,让所有的点根据重力或者惯性去减速。你的浏览器里马上就能发生逼真的大爆炸或者漫天飞雪。

👇 动手点点看: 试试“烟花”和“鼠标轨迹”!

Active Particles:0
FPS:0

Particle System Code / 粒子系统代码

// 粒子系统核心代码
class Particle {
  constructor(x, y) {
    this.x = x
    this.y = y
    this.vx = (Math.random() - 0.5) * 1
    this.vy = (Math.random() - 0.5) * 1
    this.life = 1.0
    this.decay = 0.01 + Math.random() * 0.02
    this.size = 3
    this.color = this.randomColor()
  }

  update() {
    this.x += this.vx
    this.y += this.vy
    this.vy += 0.1  // 重力
    this.life -= this.decay
  }

  draw(ctx) {
    ctx.globalAlpha = this.life
    ctx.fillStyle = this.color
    ctx.beginPath()
    ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
    ctx.fill()
    ctx.globalAlpha = 1.0
  }

  isDead() {
    return this.life <= 0
  }
}

// 动画循环
function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)

  // 更新和绘制粒子
  particles = particles.filter(p => !p.isDead())
  particles.forEach(p => {
    p.update()
    p.draw(ctx)
  })

  requestAnimationFrame(animate)
}

Particle System Tips / 粒子系统要点

  • 粒子类: 每个粒子是一个对象,包含位置、速度、加速度、生命周期等属性
  • 更新循环: 每帧更新所有粒子的位置和状态,移除死亡的粒子
  • 性能优化: 限制粒子数量,使用对象池复用粒子对象
  • 视觉效果: 使用透明度、混合模式、渐变等增强视觉效果

💡提示: 移动鼠标或点击画布来产生粒子!不同的效果有不同的交互方式。


6. 守护 FPS 荣耀:如何应对高烧的 CPU?

让成千上万个对象在一秒内计算重画 60 遍,这是极其消耗电脑算力(CPU 和内存)的。 很多野生小白刚做出来的游戏玩了两分钟可能风扇就起飞了。下面是真正的引擎大佬使用的降温护体绝技:

  1. 局部擦黑板(脏矩形 Dirty Rect)! 一个角色在一望无际的草原上奔跑。你千万别每帧把整块大草原都擦了重画!角色经过哪一小块,你就用小板擦把哪里擦掉然后只补哪里的洞,这能省下几千倍的力气。
  2. 隐藏后台魔法(离屏 Canvas)! 如果游戏背景是繁星漫天、有各种复杂绚丽的山脉。最好先偷偷在没人的后台建一个内存 Canvas 把它一次性精美地画上去。以后每秒 60 下的刷新,你直接把这幅“定格全图”通过贴图的方式贴到前端(drawImage)就行了。
  3. 批量洗画笔! 如果画画时你要反复交替使用“红、蓝、红、蓝、红”这几种笔,频繁切换。可以提前把所有红色的兵全归档画完,再清空换蓝颜料画,省去了昂贵的上下文来回切换。

👇 动手点点看: 先把对象数量拉满,看着网页快掉进卡顿的深渊,再依次打开右下方的绝技进行抢救。

FPS:0
Frame Time:0ms
Objects:1000

Optimization Code / 优化代码

// 脏矩形优化 - 只重绘变化的部分
function draw() {
  // 不清除整个画布,只清除变化的区域
  if (useDirtyRect) {
    objects.forEach(obj => {
      if (obj.moved) {
        // 清除旧位置
        ctx.clearRect(
          obj.oldX - obj.size,
          obj.oldY - obj.size,
          obj.size * 2,
          obj.size * 2
        )
        // 绘制新位置
        obj.draw(ctx)
        obj.moved = false
      }
    })
  } else {
    // 传统方式:清除整个画布
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    objects.forEach(obj => obj.draw(ctx))
  }
}

Performance Tips / 性能优化要点

  • 减少重绘: 只重绘变化的部分(脏矩形技术),避免不必要的 clearRect
  • 离屏 Canvas: 预渲染静态内容到离屏 Canvas,减少每帧的绘制操作
  • 批量渲染: 减少状态切换(fillStyle、strokeStyle 等),批量处理相同类型的绘制
  • 对象池: 复用对象,减少垃圾回收压力
  • requestAnimationFrame: 使用浏览器提供的动画 API,优化渲染时机

7. 名词对照表

术语解释
CanvasHtml5 提供的 2D 画布。绘制极快,但画完就变成颜料像素,不支持通过 DOM 操作内容。
SVG矢量图,放大永远不模糊,且每个图形都是独立的标签元素可以单独点击绑定事件。
Context (ctx)获取到的“2D 上下文”,可以理解为用来在这张布上调各种颜色、干各种特殊效果的“画笔”。
requestAnimationFrame浏览器内置的神级节拍器,会以显示器的刷新率(通常 60FPS)不断狂飙执行,专门用来做完美动画。
FPS / Frame Rate帧率。60 FPS 代表一秒钟内浏览器帮我们默默擦除了 60 次黑板并画了 60 副新图,这骗过了视神经,看起来极其丝滑。
Dirty Rect / 脏矩形只在画面中发生变化的微小矩形区域内进行擦除和重绘,强力保留性能。
Offscreen Canvas藏在内存里的“影子画布”,把静态且复杂的树木和山脉先画好,当作死的一张贴图重复利用。

现在,不管是一把简单的魔法画笔、还是由万千雪花组成的宏大粒子系统,整个能够不断刷新重绘的数字世界引擎,都在你的掌控之中了!