第三章:Canvas 2D 与坐标系统

写给 junior 的你:这一章我们要学会在浏览器里"画画"。就像小时候你拿起蜡笔在纸上涂鸦一样,Canvas 2D API 就是浏览器给你的那盒蜡笔。只不过,这支"蜡笔"精确到每一个像素,还能让图形旋转、缩放、动起来。


目录

  1. 什么是 Canvas 2D API
  2. Canvas 上下文与 save/restore 栈
  3. 坐标变换:translate、rotate、scale
  4. 屏幕坐标系 vs 笛卡尔坐标系
  5. 基本图形绘制
  6. 动画循环:requestAnimationFrame
  7. 实战:rough.js 手绘风格与坐标变换
  8. 常见问题 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,尝试:

  1. 画一个蓝色的矩形
  2. 通过 CSS 把 canvas 显示尺寸放大到 2 倍,观察是否变模糊
  3. 把 HTML 的 width/height 也改成 2 倍,观察区别

2. Canvas 上下文与 save/restore 栈

2.1 生活类比:画画时的"状态保存"

想象你在画一幅复杂的油画:

  1. 你先用大号刷子铺底色(红色)
  2. 然后换小号刷子画细节,但万一画错了想回到底色层?
  3. 聪明的做法:在换刷子之前,拍一张照片记录当前状态
  4. 画完细节后,如果满意就保留;不满意就"恢复"到拍照时的状态

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 的绘制基于路径

  1. beginPath() —— 拿起一张新纸
  2. moveTo(x, y) / lineTo(x, y) / arc(...) —— 在纸上规划线条
  3. 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 动画的原理一模一样:

  1. 画一帧(一页)
  2. 等一会儿(16.7ms,约 60fps)
  3. 擦掉重画下一帧(下一页)
  4. 循环往复

6.2 本质:浏览器 vs 手动定时器

你可能会想:"用 setInterval(draw, 16) 不就行了?"

不行。 原因:

  1. setInterval 不跟屏幕刷新同步:屏幕 60Hz 每 16.7ms 刷新一次。如果你的 setInterval 在屏幕刷新中间触发,浏览器只能等下一次刷新,导致画面撕裂或跳帧。

  2. requestAnimationFrame 跟 VSync 同步:浏览器在每次屏幕刷新前调用你的绘制函数,保证一帧只画一次,丝滑流畅。

  3. 节能:当页面不可见(切到别的标签页),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 试一试

修改上面的旋转矩形代码,实现以下效果:

  1. 矩形同时旋转和缩放(用 scale 做一个呼吸效果)
  2. 颜色随时间变化(提示:用 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 试一试

基于上面的代码,添加以下内容:

  1. 画一个草地(提示:rc.rectanglerc.path
  2. 画几朵云(提示:rc.ellipse 或几个 rc.circle 组合)
  3. 让太阳慢慢升起/落下(提示:结合 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 的核心概念:

  1. Canvas 2D API 是浏览器的即时模式 2D 绘图接口——像一块真实的画布,画完就是像素。
  2. save/restore 是状态栈机制——像拍照记录,随时回退,让复杂绘制井井有条。
  3. 坐标变换(translate/rotate/scale)是矩阵乘法——顺序至关重要,像操作一台虚拟摄像机。
  4. 屏幕坐标 vs 笛卡尔坐标——Y 轴方向相反,用 scale(1, -1) 翻转,让数学公式直接可用。
  5. 基本图形(fillRect、strokeRect、arc、lineTo)——路径系统是基础,beginPath → 规划 → fill/stroke。
  6. requestAnimationFrame 是丝滑动画的钥匙——跟屏幕刷新同步,节能高效。
  7. rough.js 展示了 Canvas 的扩展能力——手绘风格 + 坐标变换 = 生动的可视化效果。

下一步:尝试用本章知识做一个完整的动画——比如一个太阳系模型:太阳在中间,地球绕太阳转,月球绕地球转。你会用到所有的变换技巧!


记住:Canvas 就像学骑自行车——看再多理论不如动手画几个圆。打开你的编辑器,现在就开始吧!