第三章:Canvas 2D 与坐标系统
写给 junior 的你:这一章我们要学会在浏览器里"画画"。就像小时候你拿起蜡笔在纸上涂鸦一样,Canvas 2D API 就是浏览器给你的那盒蜡笔。只不过,这支"蜡笔"精确到每一个像素,还能让图形旋转、缩放、动起来。
目录
- 什么是 Canvas 2D API
- Canvas 上下文与 save/restore 栈
- 坐标变换:translate、rotate、scale
- 屏幕坐标系 vs 笛卡尔坐标系
- 基本图形绘制
- 动画循环:requestAnimationFrame
- 实战:rough.js 手绘风格与坐标变换
- 常见问题 Q&A
1. 什么是 Canvas 2D API
1.1 生活类比:Canvas 就像一块真实的画布
想象一下你去美术用品店买画材:
- HTML
<canvas>元素 = 你买回家的那块空白画布(一块固定大小的布) - Canvas 2D 上下文(context) = 你选的那套画笔工具(油画笔、水彩笔、铅笔……)
- 绘制命令 = 你实际在画布上做的每一笔(画一条线、涂一个圆)
没有画笔,画布只是块布;没有画布,画笔只是根棍子。两者缺一不可。
1.2 本质:它到底是什么?
Canvas 2D API 是浏览器内置的一套**即时模式(Immediate Mode)**的 2D 图形绘制接口。
"即时模式"是什么意思?
想象你在沙盘上画画——你画一笔,沙子就变形一笔。但沙盘不会"记住"你画了什么,它只记住最终的沙子形状。如果你要修改,只能把整个沙盘抹平重画。
这和"保留模式"(如 SVG)形成对比。SVG 像乐高积木——每个图形都是一个独立的、可单独移动/修改的积木块。
<!-- 最基础的 Canvas 结构 -->
<!DOCTYPE html>
<html>
<head>
<style>
canvas {
background: #eee; /* 给画布加个灰色背景,方便看见边界 */
}
</style>
</head>
<body>
<!-- width/height 是画布的实际像素尺寸 -->
<canvas width="512" height="512"></canvas>
<script>
// 1. 拿到画布
const canvas = document.querySelector('canvas');
// 2. 拿到"画笔工具"——2D 上下文
const context = canvas.getContext('2d');
// 3. 开始画画!
context.fillStyle = 'red';
context.fillRect(50, 50, 100, 100); // 在 (50,50) 画一个 100x100 的红色方块
</script>
</body>
</html>
1.3 常见误解
| 误解 | 真相 |
|---|---|
| "Canvas 就是 HTML 元素,设置 CSS width/height 就能改变画布大小" | CSS 只改变显示尺寸,画布的实际像素由 HTML 属性 width/height 决定。CSS 放大反而会让图像模糊 |
| "Canvas 画出来的东西可以像 DOM 一样点击" | Canvas 是位图,画完就是一堆像素。要处理点击需要自己计算坐标 |
| "Canvas 只能画 2D" | 通过 getContext('webgl') 还可以做 3D,那是后面的章节 |
1.4 试一试
创建一个 first-canvas.html,尝试:
- 画一个蓝色的矩形
- 通过 CSS 把 canvas 显示尺寸放大到 2 倍,观察是否变模糊
- 把 HTML 的
width/height也改成 2 倍,观察区别
2. Canvas 上下文与 save/restore 栈
2.1 生活类比:画画时的"状态保存"
想象你在画一幅复杂的油画:
- 你先用大号刷子铺底色(红色)
- 然后换小号刷子画细节,但万一画错了想回到底色层?
- 聪明的做法:在换刷子之前,拍一张照片记录当前状态
- 画完细节后,如果满意就保留;不满意就"恢复"到拍照时的状态
Canvas 的 save() 和 restore() 就是这套"拍照-恢复"机制。
2.2 本质:状态栈
Canvas 上下文维护了一个状态栈(Stack)。每次 save() 把当前状态压入栈顶,restore() 从栈顶弹出状态并恢复。
状态包括什么?
- 变换矩阵(translate/rotate/scale 的累积效果)
- 裁剪区域(clipping region)
- 样式属性:fillStyle、strokeStyle、lineWidth、font 等
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 初始状态:默认填充色是黑色
ctx.fillStyle = 'black';
ctx.save(); // 【拍照1】保存:fillStyle = 'black'
ctx.fillStyle = 'red';
ctx.translate(100, 100); // 移动坐标系
ctx.save(); // 【拍照2】保存:fillStyle = 'red', translate(100,100)
ctx.fillStyle = 'blue';
ctx.rotate(Math.PI / 4); // 旋转 45 度
ctx.fillRect(0, 0, 50, 50); // 画一个蓝色的、旋转的矩形
ctx.restore(); // 【恢复2】回到拍照2的状态:fillStyle = 'red', translate(100,100)
// 此时 fillStyle 是 'red',rotate 的效果已经消失!
ctx.fillRect(60, 0, 50, 50); // 画一个红色的、不旋转的矩形
ctx.restore(); // 【恢复1】回到拍照1的状态:fillStyle = 'black'
// 此时 fillStyle 是 'black',translate 的效果也消失了!
ctx.fillRect(0, 0, 50, 50); // 在原始位置画黑色矩形
栈的变化过程:
初始: [默认状态]
save(): [默认状态, 状态A(red, translate)]
save(): [默认状态, 状态A, 状态B(blue, rotate)]
restore(): [默认状态, 状态A] ← 状态B 被弹出丢弃
restore(): [默认状态] ← 状态A 被弹出丢弃
2.3 常见误解
| 误解 | 真相 |
|---|---|
| "save 保存的是画布上的图像" | save 只保存绘制状态,不保存已画的内容。已经画上去的像素不会消失 |
| "save/restore 可以随意交叉" | 栈是 LIFO(后进先出),必须成对使用。save 3 次就要 restore 3 次,顺序不能乱 |
| "每次画图都要 save" | 不是必须的。只有当你临时修改状态且之后想恢复时才需要 |
2.4 试一试
下面的代码会画出什么?先在心里想,再运行验证:
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'green';
ctx.save();
ctx.fillStyle = 'yellow';
ctx.save();
ctx.fillStyle = 'purple';
ctx.restore();
ctx.fillRect(0, 0, 100, 100); // 什么颜色?
ctx.restore();
ctx.fillRect(100, 0, 100, 100); // 什么颜色?
3. 坐标变换:translate、rotate、scale
3.1 生活类比:移动相机 vs 移动世界
想象你在拍一部电影:
- translate(平移):把摄像机从房间中央移到窗边
- rotate(旋转):把摄像机倾斜 45 度角拍摄
- scale(缩放):用广角镜头把画面拉近/推远
Canvas 的坐标变换就是这套"摄像机操作系统"。但有个精妙的设定:你不是在移动摄像机,而是在移动整个世界,而摄像机永远固定在原点。
效果上完全一样,但数学上更简单——因为所有变换都是对坐标系的矩阵乘法。
3.2 本质:变换矩阵
Canvas 内部维护一个 3x3 变换矩阵:
| a c e |
| b d f |
| 0 0 1 |
每个像素坐标 (x, y) 都会左乘这个矩阵得到新坐标:
x' = a*x + c*y + e
y' = b*x + d*y + f
translate(dx, dy) —— 修改 e 和 f:
| 1 0 dx |
| 0 1 dy |
| 0 0 1 |
代入公式:
x' = 1*x + 0*y + dx = x + dx
y' = 0*x + 1*y + dy = y + dy
每个点都向右移 dx,向下移 dy。
rotate(angle) —— 修改 a, b, c, d:
| cos(θ) -sin(θ) 0 |
| sin(θ) cos(θ) 0 |
| 0 0 1 |
代入公式:
x' = cos(θ)*x - sin(θ)*y
y' = sin(θ)*x + cos(θ)*y
这就是高中数学里的旋转公式!Canvas 帮你封装好了。
scale(sx, sy) —— 修改 a 和 d:
| sx 0 0 |
| 0 sy 0 |
| 0 0 1 |
代入公式:
x' = sx * x
y' = sy * y
sx = 2 表示 x 方向放大 2 倍;sy = -1 表示 y 方向翻转。
3.3 变换的累积:顺序至关重要!
矩阵乘法不满足交换律:A * B ≠ B * A
这意味着:先 translate 再 rotate,和先 rotate 再 translate,结果完全不同!
const ctx = canvas.getContext('2d');
// 场景1:先移动,再旋转
ctx.save();
ctx.translate(100, 100); // 移动到 (100,100)
ctx.rotate(Math.PI / 4); // 绕当前原点旋转 45°
ctx.fillRect(0, 0, 50, 50); // 矩形中心在 (100,100),且旋转了
ctx.restore();
// 场景2:先旋转,再移动
ctx.save();
ctx.rotate(Math.PI / 4); // 绕画布原点 (0,0) 旋转 45°
ctx.translate(100, 100); // 沿着"旋转后的坐标轴"移动
ctx.fillRect(0, 0, 50, 50); // 矩形位置完全不同!
ctx.restore();
记忆口诀:变换是"从右往左"应用的——你写的最后一行变换,最先作用于图形。
3.4 常见误解
| 误解 | 真相 |
|---|---|
| "rotate 是绕图形中心旋转" | rotate 永远绕当前坐标原点旋转。要让图形绕自身中心转,需要先把原点 translate 到图形中心 |
| "变换只影响后面的一个图形" | 变换会持续影响后续所有绘制,直到被 restore 或 setTransform 重置 |
| "scale(-1, 1) 和 CSS 的 transform: scaleX(-1) 一样" | 效果一样,但 Canvas 的 scale 是在绘制前变换坐标系,CSS 是在绘制后变换 |
3.5 试一试
实现一个绕自身中心旋转的矩形(提示:需要 3 步变换):
// 目标:让矩形绕自己的中心 (150, 150) 持续旋转
const rectSize = [100, 100];
const centerX = 150;
const centerY = 150;
let angle = 0;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
// 你的代码在这里:3 行变换,1 行画矩形
// 提示顺序:移到中心 → 旋转 → 移回左上角
ctx.restore();
requestAnimationFrame(draw);
}
4. 屏幕坐标系 vs 笛卡尔坐标系
4.1 生活类比:地图的朝向
想象两张地图:
- 屏幕坐标系(Canvas 默认):你站在地图前,上方是北(Y 轴向下),右方是东(X 轴向右)
- 笛卡尔坐标系(数学课本):你站在地图前,上方是北(Y 轴向上),右方是东(X 轴向右)
等等,这不都一样吗?不!Canvas 的 Y 轴是向下增长的,而数学里的 Y 轴是向上增长的。
屏幕坐标系(Canvas) 笛卡尔坐标系(数学)
Y+ ↓ Y+ ↑
| |
| |
+----→ X+ +----→ X+
(0,0) (0,0)
4.2 本质:为什么 Y 轴向下?
这源于早期计算机显示器的扫描方式——电子束从左上角开始,一行一行向下扫描。所以:
- 行号(row) 向下递增 → 变成了 Y 坐标
- 列号(column) 向右递增 → 变成了 X 坐标
这个历史包袱一直保留到了今天的 Canvas、CSS、甚至你的手机屏幕。
4.3 从屏幕坐标到笛卡尔坐标
如果你想用"数学思维"画图(Y 向上),需要做一次坐标变换:
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 把原点移到画布中心
ctx.translate(canvas.width / 2, canvas.height / 2);
// Y 轴翻转!scale(1, -1) 让 Y 向上增长
ctx.scale(1, -1);
// 现在 (0, 0) 在画布中心,Y 向上,X 向右——和数学课本一致了!
ctx.fillRect(0, 0, 50, 50); // 这个矩形在中心右上方
推导过程:
原始屏幕坐标:(screenX, screenY),其中 screenY 向下增长。
我们想要:
- 新原点
(0,0)在画布中心(cx, cy) - 新 Y 轴向上增长
步骤1:把原点移到中心
x1 = screenX - cx
y1 = screenY - cy
步骤2:翻转 Y 轴
x2 = x1 = screenX - cx
y2 = -y1 = -(screenY - cy) = cy - screenY
用 Canvas API 就是:
ctx.translate(cx, cy);
ctx.scale(1, -1);
4.4 常见误解
| 误解 | 真相 |
|---|---|
| "翻转 Y 轴后,文字也倒过来了" | 是的!文字也是图形,会被坐标变换影响。如果要在翻转后的坐标系里写字,需要再翻转回来 |
| "翻转后 (0,0) 在左下角" | 不,translate(cx, cy) 把原点移到了画布中心。如果要去左下角,需要 translate(0, canvas.height) |
| "坐标系变换影响已画的内容" | 不影响。变换只影响后续绘制,已画好的像素不会动 |
4.5 试一试
在画布中心建立一个笛卡尔坐标系,然后画一个标准的 y = x² 抛物线:
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// 1. 建立笛卡尔坐标系
ctx.translate(cx, cy);
ctx.scale(1, -1);
// 2. 画坐标轴
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); // X 轴
ctx.moveTo(0, -cy); ctx.lineTo(0, cy); // Y 轴
ctx.stroke();
// 3. 画抛物线 y = x² / 100(缩小一点,不然太大)
ctx.strokeStyle = 'red';
ctx.beginPath();
for (let x = -200; x <= 200; x += 5) {
const y = x * x / 100;
if (x === -200) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
5. 基本图形绘制
5.1 生活类比:画图工具箱
Canvas 提供了一套"基础几何工具":
| API | 类比 | 作用 |
|---|---|---|
fillRect |
油漆桶 | 填充一个矩形区域 |
strokeRect |
钢笔勾线 | 只画矩形边框 |
arc |
圆规 | 画圆或圆弧 |
lineTo |
直尺 | 画直线段 |
5.2 本质:路径(Path)系统
Canvas 的绘制基于路径:
beginPath()—— 拿起一张新纸moveTo(x, y)/lineTo(x, y)/arc(...)—— 在纸上规划线条fill()/stroke()—— 用颜料填充或描边
const ctx = canvas.getContext('2d');
// ====== 矩形 ======
// 方法1:快速填充矩形(不经过路径系统)
ctx.fillStyle = 'blue';
ctx.fillRect(10, 10, 100, 80); // x, y, width, height
// 方法2:只画边框
ctx.strokeStyle = 'red';
ctx.lineWidth = 3;
ctx.strokeRect(120, 10, 100, 80);
// ====== 圆 ======
ctx.beginPath();
// arc(x, y, radius, startAngle, endAngle, counterClockwise)
// 角度用弧度!Math.PI = 180°,2*Math.PI = 360°
ctx.arc(80, 150, 40, 0, 2 * Math.PI);
ctx.fillStyle = 'yellow';
ctx.fill();
ctx.strokeStyle = 'orange';
ctx.stroke();
// ====== 自定义路径(三角形) ======
ctx.beginPath();
ctx.moveTo(150, 120); // 笔尖移到起点
ctx.lineTo(200, 200); // 画线到第二点
ctx.lineTo(100, 200); // 画线到第三点
ctx.closePath(); // 自动闭合回起点
ctx.fillStyle = 'green';
ctx.fill();
弧度的直观理解:
角度 弧度
0° 0
90° Math.PI / 2
180° Math.PI
270° 3 * Math.PI / 2
360° 2 * Math.PI
记忆口诀:"派是一百八,两派转一圈"。
5.3 常见误解
| 误解 | 真相 |
|---|---|
| "beginPath 会清除已画的内容" | beginPath 只清除当前的路径规划,不会影响已经画到画布上的像素 |
| "arc 的最后一个参数 true/false 是顺逆时针" | true = 逆时针,false(默认)= 顺时针。从正 X 轴方向开始测量 |
| "fillRect 和 rect + fill 完全一样" | 效果一样,但 fillRect 不经过当前路径,不会影响 beginPath 规划的路径 |
5.4 试一试
画一个笑脸:
const ctx = canvas.getContext('2d');
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// 1. 黄色圆脸
ctx.beginPath();
ctx.arc(cx, cy, 80, 0, 2 * Math.PI);
ctx.fillStyle = 'gold';
ctx.fill();
ctx.strokeStyle = 'black';
ctx.lineWidth = 2;
ctx.stroke();
// 2. 左眼(提示:arc,y 在 cy-20)
// 3. 右眼
// 4. 微笑的嘴巴(提示:arc,从 0.2*PI 到 0.8*PI)
6. 动画循环:requestAnimationFrame
6.1 生活类比:翻页动画书
小时候玩过那种在书角画小人、每页稍微动一点的动画书吗?快速翻页时,小人就"活"了。
Canvas 动画的原理一模一样:
- 画一帧(一页)
- 等一会儿(16.7ms,约 60fps)
- 擦掉重画下一帧(下一页)
- 循环往复
6.2 本质:浏览器 vs 手动定时器
你可能会想:"用 setInterval(draw, 16) 不就行了?"
不行。 原因:
setInterval不跟屏幕刷新同步:屏幕 60Hz 每 16.7ms 刷新一次。如果你的setInterval在屏幕刷新中间触发,浏览器只能等下一次刷新,导致画面撕裂或跳帧。requestAnimationFrame跟 VSync 同步:浏览器在每次屏幕刷新前调用你的绘制函数,保证一帧只画一次,丝滑流畅。节能:当页面不可见(切到别的标签页),
requestAnimationFrame会自动暂停;setInterval会继续跑,浪费电池。
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const rectSize = [100, 100];
let angle = 0;
function draw() {
// 第1步:清空画布(擦掉上一帧)
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
// 第2步:设置这一帧的状态
ctx.fillStyle = 'red';
// 第3步:变换(让矩形旋转起来)
ctx.translate(0.5 * canvas.width, 0.5 * canvas.height); // 移到中心
ctx.rotate(angle); // 旋转
ctx.translate(-0.5 * rectSize[0], -0.5 * rectSize[1]); // 移回左上角
// 第4步:绘制
ctx.beginPath();
ctx.rect(0, 0, ...rectSize);
ctx.fill();
ctx.restore();
// 第5步:更新状态(为下一帧做准备)
angle += 0.01;
// 第6步:请求下一帧
requestAnimationFrame(draw);
}
// 启动动画!
draw();
动画循环的通用模板:
function loop() {
// 1. 清屏
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. 更新状态(位置、角度、颜色等)
updateState();
// 3. 绘制新一帧
render();
// 4. 请求下一帧
requestAnimationFrame(loop);
}
6.3 常见误解
| 误解 | 真相 |
|---|---|
| "requestAnimationFrame 固定 60fps" | 它跟屏幕刷新率走。120Hz 屏幕就是 120fps,但你的绘制代码必须跟得上 |
| "不 clearRect 就能保留轨迹" | 是的,但那是"拖尾效果",不是正常的动画。正常动画必须清屏重画 |
| "angle 加得越大转得越快" | 是的,但如果加太大(比如 0.5),会跳帧,看起来卡顿。通常每帧 0.01~0.05 弧度比较平滑 |
6.4 试一试
修改上面的旋转矩形代码,实现以下效果:
- 矩形同时旋转和缩放(用
scale做一个呼吸效果) - 颜色随时间变化(提示:用 HSL 颜色
hsl(${hue}, 70%, 50%))
7. 实战:rough.js 手绘风格与坐标变换
7.1 生活类比:从工程制图到手绘草图
前面的例子都像"工程制图"——线条笔直、边缘锐利。但有时候你想要的是"手绘草图"的感觉,像设计师在餐巾纸上随手画的构思图。
rough.js 就是这样一个库,它能把你的几何图形渲染成带有随机抖动、粗细不均的手绘风格。
7.2 本质:rough.js 是什么?
rough.js 是一个基于 Canvas 2D 的图形库。它接收你的几何描述(比如"画个圆"),然后生成一系列带有随机扰动的路径来模拟手绘效果。
它不会取代 Canvas 2D——它建立在 Canvas 2D 之上,最终还是要通过 Canvas API 把像素画上去。
7.3 代码解析:coordinates 演示
这是 akira-graphics/coordinates/ 目录下的完整代码:
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>坐标系</title>
<!-- 引入 rough.js -->
<script src="https://lib.baomitu.com/rough.js/3.1.0/rough.umd.js"></script>
</head>
<body>
<canvas width="512" height="256"></canvas>
<script src="app.js"></script>
</body>
</html>
app.js:
// 1. 创建 rough.js 的 canvas 包装器
// 它会自动关联到 <canvas> 元素,并提供手绘风格的绘制方法
const rc = rough.canvas(document.querySelector('canvas'));
// 2. 获取底层的 Canvas 2D 上下文
// 我们仍然可以直接操作 ctx,做坐标变换
const ctx = rc.ctx;
// 3. ====== 坐标变换:建立笛卡尔坐标系 ======
// 把原点移到画布中心 (256, 128)
ctx.translate(256, 256);
// 翻转 Y 轴,让 Y 向上增长
ctx.scale(1, -1);
// 4. 配置"山丘"的绘制风格
const hillOpts = {
roughness: 2.8, // 粗糙度:越高越"潦草"
strokeWidth: 2, // 线条粗细
fill: 'blue' // 填充颜色
};
// 5. 画两座山(使用 SVG 路径语法)
// M = moveTo, L = lineTo
// 注意:这里的坐标已经是笛卡尔坐标系了!
// (-180, 0) → (-80, 100) → (20, 0)
rc.path('M-180 0L-80 100L20 0', hillOpts);
// 第二座山
rc.path('M-20 0L80 100L180 0', hillOpts);
// 6. 画太阳(圆)
rc.circle(0, 150, 105, {
stroke: 'red', // 边框颜色
strokeWidth: 4, // 边框粗细
fill: 'rgba(255, 255, 0, 0.4)', // 半透明黄色填充
fillStyle: 'solid', // 实心填充(不是斜线填充)
});
7.4 关键技巧解读
技巧1:rough.js + 原生 Canvas 变换的结合
const rc = rough.canvas(canvas); // rough.js 包装器
const ctx = rc.ctx; // 拿到原生上下文
// 用原生 API 做变换
ctx.translate(256, 256);
ctx.scale(1, -1);
// 用 rough.js 画图——它会自动应用当前的变换矩阵!
rc.circle(0, 150, 105, options);
这就是 Canvas 状态系统的强大之处:rough.js 内部使用的是同一个
ctx,所以你的translate/scale对它完全生效。
技巧2:SVG 路径语法
M-180 0 → moveTo(-180, 0)
L-80 100 → lineTo(-80, 100)
L20 0 → lineTo(20, 0)
rc.path() 接收 SVG 路径字符串,这是描述复杂形状的标准方式。
技巧3:翻转 Y 轴后的视觉
翻转前(屏幕坐标):
Y 向下增长,(0,0) 在左上角
山丘的"山顶" y=100 会在屏幕下方
翻转后(笛卡尔坐标):
Y 向上增长,(0,0) 在画布中心偏下(因为 translate(256, 256) 而画布高256)
山丘的"山顶" y=100 会在屏幕上方——看起来就是正常的山!
太阳在 y=150,在山顶上方——正常!
7.5 常见误解
| 误解 | 真相 |
|---|---|
| "rough.js 替换了 Canvas" | 没有,它只是生成手绘风格的路径,最终还是调用 Canvas 2D API 绘制 |
| "rough.js 的坐标和 Canvas 不一样" | 完全一样。rough.js 用的是同一个 ctx,所有变换都共享 |
| "fill: 'blue' 会覆盖 stroke" | 不会,rough.js 的 fill 和 stroke 是独立的,可以同时存在 |
7.6 试一试
基于上面的代码,添加以下内容:
- 画一个草地(提示:
rc.rectangle或rc.path) - 画几朵云(提示:
rc.ellipse或几个rc.circle组合) - 让太阳慢慢升起/落下(提示:结合
requestAnimationFrame,改变太阳的 y 坐标)
8. 常见问题 Q&A
Q1:为什么我的 Canvas 图像模糊?
A: 99% 是因为 CSS 和 HTML 的 width/height 不匹配。
<!-- 错误:画布实际 300x150(默认),但 CSS 硬拉大到 800x800 -->
<canvas style="width:800px; height:800px;"></canvas>
<!-- 正确:实际像素和显示尺寸一致 -->
<canvas width="800" height="800" style="width:800px; height:800px;"></canvas>
原理:Canvas 是位图。HTML 的 width/height 决定位图的像素数量,CSS 决定显示尺寸。像素少显示大 = 拉伸模糊。
Q2:rotate 的单位是什么?怎么转 90 度?
A: 弧度(radian),不是角度(degree)。
// 错误
ctx.rotate(90); // 转了 90 弧度!约 5156 度,转了很多圈
// 正确
ctx.rotate(Math.PI / 2); // 90 度 = π/2 弧度
// 转换函数
function toRad(deg) {
return deg * Math.PI / 180;
}
ctx.rotate(toRad(90));
Q3:save/restore 可以嵌套多少层?
A: 理论上没有硬性限制(取决于内存),但实际中嵌套 10 层以上就要考虑代码结构是否清晰了。如果嵌套太深,可能是没有合理拆分绘制函数。
Q4:怎么在翻转后的坐标系里写文字?
A: 文字也会被 scale(1, -1) 翻转,变成倒立的。解决方案:在写字前临时翻转回来。
ctx.translate(cx, cy);
ctx.scale(1, -1);
// ... 画图形 ...
// 写字时恢复
ctx.save();
ctx.scale(1, -1); // 再翻转一次 Y 轴,负负得正!
ctx.fillStyle = 'black';
ctx.font = '16px Arial';
ctx.fillText('Hello', 0, 0); // 现在文字正立了
ctx.restore();
Q5:requestAnimationFrame 的回调函数可以接收参数吗?
A: 不能直接传参,但可以用闭包:
function createLoop(data) {
function loop() {
// 这里可以访问 data
draw(data);
requestAnimationFrame(loop);
}
loop();
}
createLoop({ x: 100, y: 200 });
或者使用箭头函数:
function draw(timestamp) {
// timestamp 是页面加载后的毫秒数,由浏览器传入
console.log(timestamp);
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
Q6:Canvas 和 SVG 怎么选?
A:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 游戏、动画、像素操作 | Canvas | 位图渲染快,适合帧动画 |
| 图表、图标、需要交互的图形 | SVG | 保留模式,每个元素都是 DOM,可绑定事件 |
| 导出图片 | Canvas | 有 toDataURL() 直接生成 PNG/JPEG |
| 缩放不失真 | SVG | 矢量图形,任意缩放清晰 |
Q7:为什么 arc(0, 0, 50, 0, Math.PI) 画出来是个半圆,但方向不对?
A: 默认顺时针(counterclockwise = false)。从正 X 轴(3 点钟方向)开始,顺时针画 180 度,会经过 6 点钟方向,到达 9 点钟方向。所以画出来是下半圆。如果要上半圆,要么:
// 方法1:逆时针
ctx.arc(0, 0, 50, 0, Math.PI, true);
// 方法2:调整起始角度
ctx.arc(0, 0, 50, Math.PI, 0); // 从 180° 顺时针到 0°
总结
本章我们学习了 Canvas 2D 的核心概念:
- Canvas 2D API 是浏览器的即时模式 2D 绘图接口——像一块真实的画布,画完就是像素。
- save/restore 是状态栈机制——像拍照记录,随时回退,让复杂绘制井井有条。
- 坐标变换(translate/rotate/scale)是矩阵乘法——顺序至关重要,像操作一台虚拟摄像机。
- 屏幕坐标 vs 笛卡尔坐标——Y 轴方向相反,用
scale(1, -1)翻转,让数学公式直接可用。 - 基本图形(fillRect、strokeRect、arc、lineTo)——路径系统是基础,beginPath → 规划 → fill/stroke。
- requestAnimationFrame 是丝滑动画的钥匙——跟屏幕刷新同步,节能高效。
- rough.js 展示了 Canvas 的扩展能力——手绘风格 + 坐标变换 = 生动的可视化效果。
下一步:尝试用本章知识做一个完整的动画——比如一个太阳系模型:太阳在中间,地球绕太阳转,月球绕地球转。你会用到所有的变换技巧!
记住:Canvas 就像学骑自行车——看再多理论不如动手画几个圆。打开你的编辑器,现在就开始吧!