一、背景:为什么是 Canvas
HTML5 带来的新特性里,Canvas 可能是最「不像 Web」的那一个。
我们习惯了用 div、span、section 拼页面,习惯了 DOM 树、CSS 盒模型、BFC 这些概念。但 Canvas 完全不按这套逻辑出牌——它只有一个标签 <canvas>,剩下的全靠 JS 一笔一笔画出来。没有 DOM 节点,没有样式表,你拿到的是一块像素级的画布,想画什么,代码说了算。
这听起来很原始,但恰恰是这种「原始」给了它极大的发挥空间。数据可视化(ECharts 底层就是 Canvas/WebGL)、网页游戏(从 4399 Flash 时代到今天的 HTML5 游戏)、酷炫的交互页面——Canvas 几乎是这些场景的唯一解。
顺便一提:如果追求 3D 效果,可以用 getContext('3d') 走 WebGL 路线,配合 Three.js 等引擎释放 GPU 显存能力。近两年 AI 游戏爆发、物理大模型等话题也常和 WebGL/WebGPU 挂钩。但本文的焦点是 2D——先把手绘基本功练好。
本文用两个最小化的 HTML 文件,把 Canvas 的核心概念拆清楚。不依赖任何框架,每个例子都是十几行代码,打开浏览器就能跑。
二、目标:这个阶段要解决什么问题
一句话概括:理解 Canvas 的绘制模型和动画模型,能独立写出静态图形和帧动画。
| 第一阶段 | Canvas 标签、坐标系统、基本图形绘制(矩形)、填充与描边、局部擦除 | 1.html |
| 第二阶段 | 帧动画循环、requestAnimationFrame 原理、与 setInterval 的对比 | 2.html |
这两个阶段加起来不超过 50 行 JS,但它们是所有 Canvas 应用的基石。无论之后做游戏还是做可视化图表,核心套路都是这两步。
三、第一阶段:静态绘制 —— 把形状画上去
3.1 Canvas 标签与坐标系统

<canvas id="canvas" width="800" height="600"
style="border: 1px solid #333;">
你的浏览器不支持 Canvas 画布
</canvas>
几个关键点:
- width 和 height 是 HTML 属性,不是 CSS。它们决定画布的实际像素分辨率(这里是 800×600)。如果只用 CSS 设宽高,画布内部仍是默认的 300×150,然后被拉伸——这是高频踩坑点,后文会展开。
- 标签内的文字是降级内容。只有不兼容 Canvas 的古董浏览器(IE8 等)会渲染这段文字,现代浏览器会忽略它。
- 边框只是辅助线。style="border: 1px solid #333" 让我们能看到画布的边界,实际项目中通常不加。
Canvas 的坐标系统不同于数学课本——原点 (0, 0) 在左上角,x 轴向右为正,y 轴向下为正:
(0,0) ──────────── x →
│
│ ┌──────────┐
│ │ 矩形 │
│ └──────────┘
y
↓
这个坐标系一旦记错,后面所有定位都会跑偏。
3.2 getContext:拿到画笔
const canvas = document.querySelector('#canvas')
const ctx = canvas.getContext('2d')
getContext('2d') 返回的是一个 2D 渲染上下文对象——你可以把它理解为一支画笔,上面挂满了绘制方法。之后所有的 fillRect、strokeRect、clearRect 都通过 ctx 调用。
这个命名其实很有深意:它叫「上下文」而不是「画笔」,因为除了画笔的功能,它还管理着全局的绘图状态——当前的填充色、描边色、线宽、透明度、变换矩阵等等。你每次设置状态,影响的是后续所有绘制,而不是某一次调用。 这一点后面会反复体现。
3.3 填充矩形:fillRect + fillStyle
ctx.fillStyle = 'blue' // 设置填充颜色
ctx.fillRect(20, 20, 100, 80)
fillRect(x, y, width, height) 的四个参数含义:
fillRect(20, 20, 100, 80)
↑ ↑ ↑ ↑
x y 宽 高
(距离左上角的偏移)
fillStyle 的值可以是 CSS 颜色字符串——'blue'、'#4299e1'、'rgba(255,0,0,0.5)' 都可以。它是一个全局状态属性:一旦设置,之后所有的 fillRect() 都会用这个颜色,直到你再次修改它。
3.4 描边矩形:strokeRect + strokeStyle + lineWidth
ctx.strokeStyle = '#f56565' // 描边颜色
ctx.lineWidth = 5 // 线宽,单位 px
ctx.strokeRect(150, 20, 100, 80)
strokeRect 和 fillRect 是姊妹方法,但画的是空心矩形。注意 strokeStyle 和 fillStyle 是两套独立的状态——你可以同时设置两者,后续绘制时各取所需。
lineWidth 控制描边粗细。这里设为 5px 是为了让效果明显,实际项目中按需求来。同样,lineWidth 是全局状态,设一次,所有后续描边都会用这个线宽。
3.5 局部擦除:clearRect
ctx.clearRect(50, 50, 40, 30)
clearRect 把指定矩形区域内的像素「擦掉」,恢复为透明。它和 fillRect 的参数格式完全一致。
在 1.html 中,这个擦除操作是为了演示:你可以在蓝色矩形上「挖」一个洞。它的实际意义远比演示重要——在帧动画中,每一帧的第一步就是 clearRect 清空整张画布,再画上新的内容,这是整个动画机制的起点。
3.6 1.html 完整回顾
<canvas id="canvas" width="800" height="600"
style="border: 1px solid #333;">
你的浏览器不支持 Canvas 画布
</canvas>
<script>
const canvas = document.querySelector('#canvas')
const ctx = canvas.getContext('2d')
ctx.fillStyle = 'blue'
ctx.fillRect(20, 20, 100, 80)
ctx.strokeStyle = '#f56565'
ctx.lineWidth = 5
ctx.strokeRect(150, 20, 100, 80)
ctx.clearRect(50, 50, 40, 30)
</script>
运行效果:蓝色填充矩形 + 红色描边矩形 + 蓝色矩形上被擦掉的一块。
代码量不过十来行,但涉及了 Canvas 最核心的四个概念:坐标系统、上下文对象、填充/描边双轨制、全局状态。 这些概念会贯穿所有 Canvas 应用。
四、第二阶段:帧动画 —— 让画面动起来
静态绘图只是第一步。Canvas 真正的生命力在于动画——连续、流畅、可控的动画。
4.1 动画的本质:擦掉 → 重画 → 循环
回忆一下小时候画翻页动画(flip book):在每一页纸的边缘画一个略有位移的小人,快速翻动时小人就「动」了。
Canvas 的动画逻辑完全一致:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 帧 1 │ ──→ │ 帧 2 │ ──→ │ 帧 3 │
│ │ │ │ │ │
│ ▓▓▓▓ │ │ │ │ │
│ │ │ ▓▓▓▓ │ │ │
│ │ │ │ │ ▓▓▓▓ │
└──────────┘ └──────────┘ └──────────┘
擦除 擦除 擦除
在新位置 在新位置 在新位置
画矩形 画矩形 画矩形
写成分步操作就是:
4.2 为什么不能用 setInterval
直觉告诉我们:要做循环,用一个定时器就好了嘛——比如 60fps,就 setInterval(fn, 16)。
这是一个非常自然的想法,但恰恰是常见的误区。
问题主要有两个:
第一,帧率不对齐。 setInterval 的触发时机和显示器的刷新周期是完全独立的两个时钟。如果显示器是 60Hz(约 16.67ms 一次刷新),而 setInterval 按 16ms 触发,两个节奏就会逐渐错位,导致:
- 丢帧:一次刷新周期内没有新的画面准备好,显示器重复显示上一帧
- 撕裂:一帧画面在绘制中途遇到显示器刷新,上下半屏来自不同帧
setInterval(fn, 16):
|──16ms──|──16ms──|──16ms──|──16ms──|
屏幕刷新: ↓ ↓ ↓ ↓ ← 节奏脱节
第二,后台不暂停。 当你切到其他标签页,setInterval 照样在后台跑——无意义的 CPU 消耗,对移动端设备尤其不友好。
4.3 requestAnimationFrame:和屏幕同步的动画调度器
requestAnimationFrame(简称 rAF)是浏览器专门为动画设计的 API。它的行为:
- 在浏览器下一次重绘前执行你的回调,回调频率自动匹配显示器的刷新率
- 切到后台自动暂停,切回来自动恢复
- 页面不可见时完全不会触发,省电省资源
function animate() {
// 1. 擦掉上一帧
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 2. 画新的一帧
ctx.fillStyle = '#4299e1'
ctx.fillRect(x, y, width, height)
// 3. 更新位置(为下一帧准备)
x += speed
if (x > canvas.width) {
x = -width // 超出画布后从左边重新进入
}
// 4. 注册下一帧 —— 递归调用
requestAnimationFrame(animate)
}
animate() // 启动循环
对比两个方案:
requestAnimationFrame 的工作方式:
屏幕刷新: ↓ ↓ ↓
回调触发: ↓ ↓ ↓
绘制帧1 绘制帧2 绘制帧3
← 完全同步 →
setInterval(fn, 16) 的工作方式:
定时器: |──16ms──|──16ms──|──16ms──|
屏幕刷新: ↓ ↓ ↓ ↓
可能不同步,丢帧或撕裂风险
关键理解:rAF 不是「更精确的定时器」,它是「和浏览器渲染管线挂钩的调度器」。 它把控制权交给了浏览器——让浏览器在最合适的时机执行你的绘制逻辑,而不是你凭感觉估计一个毫秒数去叫浏览器刷新。
4.4 2.html 完整回顾
<canvas id="myCanvas" width="600" height="400"
style="border:1px solid #333;">
你的浏览器不支持Canvas(旧IE会显示这段文字)
</canvas>
<script>
const canvas = document.querySelector('#myCanvas')
const ctx = canvas.getContext('2d')
let x = 20
const y = 20
const width = 100
const height = 80
const speed = 3
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#4299e1'
ctx.fillRect(x, y, width, height)
x += speed
if (x > canvas.width) {
x = -width
}
requestAnimationFrame(animate)
}
animate()
</script>
运行效果:一个蓝色矩形在画布中从左到右平滑移动,超出右边界后从左侧重新出现。
20 多行代码,包含了动画的全部要素:状态变量、帧更新逻辑、边界处理、rAF 递归循环。这个循环一旦建立起来,剩下的就是往循环里加东西——加更多图形就是更复杂的动画,加交互就是游戏,加数据驱动就是实时可视化。
五、设计:Canvas 应用的分层模型
从 1.html 和 2.html 中,可以抽象出一个通用的 Canvas 应用架构。虽然这两个 demo 小到不需要显式分层,但分层意识是控制复杂度的关键:
┌────────────────────────────────────┐
│ 初始化层 │
│ – 获取 canvas 元素 │
│ – getContext('2d') │
│ – 设置画布尺寸 │
│ – 初始化状态变量 │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 帧循环层 │
│ │
│ ┌──────────┐ ┌───────────┐ │
│ │ update() │──→│ render() │ │
│ │ (数据更新)│ │ (画面绘制) │ │
│ └──────────┘ └───────────┘ │
│ │
│ requestAnimationFrame(loop) │
└────────────────────────────────────┘
│
▼
┌────────────────────────────────────┐
│ 交互层 │
│ – 键盘事件 │
│ – 鼠标/触摸事件 │
│ – 将用户输入转为状态变更 │
└────────────────────────────────────┘
- update 只改数据:改变 x、y、score 等状态变量,不碰 ctx
- render 只画画面:读取状态变量,调用 Canvas API 绘制,不改数据
这个职责分离在小项目里看不出威力,但当项目膨胀到几百行时,它几乎是保持清醒的唯一手段。
六、实际应用举例
掌握了基础的绘制和动画能力后,Canvas 的应用场景非常多:
6.1 数据可视化
ECharts 的底层渲染引擎就使用了 Canvas(2D 模式)和 WebGL(3D 模式)。你画的每一个柱状图、折线图、饼图,本质上就是 Canvas 的 fillRect、lineTo、arc 的一层高级封装。理解了 Canvas 的基本功,看 ECharts 的源码就不再是黑盒。
6.2 网页游戏
我在学习完上述基础后,动手做了一个「打飞机」小游戏,完整覆盖了:
- 玩家飞机(Path 绘制 + 渐变色引擎火焰)
- 敌人(两种类型,不同生命值和分值)
- 子弹(线性渐变,从白到黄)
- 粒子爆炸特效(每帧衰减透明度)
- 星空滚动背景(80 颗星,不同速度的视差效果)
- 键盘 + 触摸双模输入
- 得分系统 + 难度递增 + Game Over / 重新开始
全部代码约 400 行,一个 main.js 文件搞定。核心技术就是本文讲的 clearRect + 绘制 + rAF 循环,再加一套碰撞检测(矩形中心距离判定)。
6.3 酷炫页面效果
Canvas 可以做粒子背景、鼠标跟随特效、页面转场动画等。很多所谓的「炫酷官网」就是在一块全屏 Canvas 上画出来的,比如粒子连线的网络背景、鼠标位置的引力场等等。
七、踩坑:几个容易踩的细节
7.1 Canvas 的 CSS 宽高 ≠ 属性宽高
这是 Canvas 新手最常见的坑,值得单独列出来。
Canvas 的默认内部分辨率是 300×150。如果你只用 CSS 的 width: 100%; height: 100% 去拉伸它,画布的实际绘制区域仍然是 300×150,只是被 CSS 缩放放大了——结果就是画面模糊,并且坐标计算全部不对。
// 正确做法:设置属性
canvas.width = window.innerWidth
canvas.height = window.innerHeight
// CSS 只负责填满容器
// canvas { display: block; width: 100%; height: 100%; }
记忆法则:属性决定画布精度,CSS 决定画布在页面中占多大地方。 两者各司其职。
7.2 fillStyle 是全局状态,不是参数
Canvas 的 API 设计中,颜色不是作为参数传给 fillRect,而是先设置到 ctx.fillStyle 上,再调用绘制方法。这跟 SVG 或 CSS 的思维方式完全不同。
它的好处是减少参数传递(对于连续画多个同色图形很方便),代价是如果不注意状态管理,就会出现「上一段的颜色泄漏到下一段」的问题:
ctx.fillStyle = 'red'
ctx.fillRect(10, 10, 50, 50)
ctx.fillStyle = 'blue'
ctx.fillRect(70, 10, 50, 50)
// 此时 fillStyle 是 'blue',后续不设置的话全是蓝色
如果状态混乱了,用 ctx.save() 和 ctx.restore() 做快照/还原,它们保存和恢复的是整个绘图状态栈。
7.3 clearRect 不调用会导致残影
在 2.html 中如果注释掉 ctx.clearRect(0, 0, canvas.width, canvas.height),你会发现矩形移动时留下一条长长的「尾巴」。这是因为 Canvas 不会自动清空——每一帧的新绘制叠加在上一帧的画面上。
这条尾巴既是 bug 也是艺术——有些特效(拖尾、画笔、流光)恰恰是利用了不清空画布的效果。但大多数时候,请记得在每帧开头 clearRect。
7.4 动画不流畅?检查这些
如果动画出现卡顿,按以下顺序排查:
八、复盘:从 demo 到产品的距离
8.1 两个 demo 教会了我们什么
结语
HTML5 Canvas 诞生超过十年了。它的 API 不算大,但设计得很干净——坐标系统、上下文对象、状态机模型,一旦理解就能灵活运用。
如果你对 Canvas 的印象还停留在「那个画图的标签」,不妨花十分钟把 1.html 和 2.html 的代码自己敲一遍。看到那个小蓝块在屏幕上稳定地跑起来的那一刻,你对「浏览器能做什么」的理解会完全不同。


